Merge remote-tracking branch 'origin/main' into change-24583b5f

This commit is contained in:
Diego Imbert
2026-05-15 01:53:11 +02:00
99 changed files with 5110 additions and 920 deletions
+84
View File
@@ -0,0 +1,84 @@
name: Publish CLI docs repo
# Regenerates the windmill-cli-docs repo (consumed by context7) from the
# canonical sources in this repo on every Windmill release.
#
# Required secret:
# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered
# as a write-access deploy key on
# windmill-labs/windmill-cli-docs.
on:
push:
tags:
- "v*"
workflow_dispatch:
# Serialize pushes to windmill-cli-docs so two release tags landing close
# together (e.g. a release-please bump + a hotfix) can't race to force-push
# the docs repo.
concurrency:
group: publish-cli-docs
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout windmill (source of truth)
uses: actions/checkout@v4
with:
path: windmill
- name: Checkout windmill-cli-docs (publish target)
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-cli-docs
path: windmill-cli-docs
ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }}
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install pyyaml
- name: Regenerate docs
run: |
python3 windmill/system_prompts/generate.py \
--context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs"
- name: Commit and push if changed
working-directory: windmill-cli-docs
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
git config user.name "windmill-bot"
git config user.email "bot@windmill.dev"
git add -A
if git diff --cached --quiet; then
echo "No doc changes for ${REF_NAME}."
committed=false
else
committed=true
if [ "${REF_TYPE}" = "tag" ]; then
git commit -m "chore: sync from windmill ${REF_NAME}"
else
git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})"
fi
git push origin HEAD
fi
# Always mirror the version tag on tag pushes, even when content
# didn't change — downstream consumers tie snapshots to releases by
# tag, and skipping it would leave the docs repo without a tag for
# the new Windmill release.
# workflow_dispatch from a non-tag ref skips this so we don't
# create a junk tag named after a branch.
if [ "${REF_TYPE}" = "tag" ]; then
git tag -f "${REF_NAME}"
git push origin "${REF_NAME}" --force
echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})."
fi
+1
View File
@@ -20,6 +20,7 @@ rust-client/Cargo.toml
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
.claude/worktrees/
# Symlinked cache directories (for git worktrees)
backend/target
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14)
### Bug Fixes
* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5))
## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14)
### Features
* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683))
* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f))
### Bug Fixes
* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0))
## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13)
### Features
* **frontend:** unified EditorHeader with file picker for flow/script/app editors ([#9047](https://github.com/windmill-labs/windmill/issues/9047)) ([d0f23cc](https://github.com/windmill-labs/windmill/commit/d0f23cc5238b025208c61e983701894de28536d5))
* read-only flag on API tokens ([#9144](https://github.com/windmill-labs/windmill/issues/9144)) ([d666e84](https://github.com/windmill-labs/windmill/commit/d666e8431cdbf14d9373d9ef625b5aafc50ac50a))
### Bug Fixes
* align script path existence check with deploy logic; hide Delete for non-admin ([#9152](https://github.com/windmill-labs/windmill/issues/9152)) ([c509206](https://github.com/windmill-labs/windmill/commit/c5092069cbeda2c4c18bea80dd629c7c087b30bf))
* Allow devops role to use all_workspaces runs filter in admins workspace ([#9153](https://github.com/windmill-labs/windmill/issues/9153)) ([110bef0](https://github.com/windmill-labs/windmill/commit/110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64))
* **bun:** pass --preserve-symlinks on unbundled execution ([#9147](https://github.com/windmill-labs/windmill/issues/9147)) ([4d0f2c2](https://github.com/windmill-labs/windmill/commit/4d0f2c26a116a0f8a89a64231dc824eabda0a8c3))
* **cli:** prevent !inline-corruption in flow push/pull ([#9142](https://github.com/windmill-labs/windmill/issues/9142)) ([79c5b7b](https://github.com/windmill-labs/windmill/commit/79c5b7b8b7676b0a06fa6480dd04b7105d39d250))
* **operator:** refresh IAM RDS / Entra ID tokens in operator process ([#9141](https://github.com/windmill-labs/windmill/issues/9141)) ([7ebb081](https://github.com/windmill-labs/windmill/commit/7ebb08133cd4027bc00bacc4a0fc5865cd5709ec))
* **python:** preserve strings containing Infinity/NaN in result JSON ([#9149](https://github.com/windmill-labs/windmill/issues/9149)) ([33bf01b](https://github.com/windmill-labs/windmill/commit/33bf01b627c8ea430c03dfc27a97a8f2d770582f))
* scope promotion-mode debounce key per repo ([#9145](https://github.com/windmill-labs/windmill/issues/9145)) ([2ec1863](https://github.com/windmill-labs/windmill/commit/2ec1863340e759bba3408dbc4f41b16912b959ea))
* send flow push-loop ping outside transaction so zombie monitor sees it ([#9136](https://github.com/windmill-labs/windmill/issues/9136)) ([818cb31](https://github.com/windmill-labs/windmill/commit/818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d))
### Performance Improvements
* **dynselect:** only retrigger when helper args actually change ([#9148](https://github.com/windmill-labs/windmill/issues/9148)) ([dd19e52](https://github.com/windmill-labs/windmill/commit/dd19e52a84fb9a9f48e3ad061b084841c2ee7464))
## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12)
+11
View File
@@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for:
- `app`
- `script`
- `cli`
- `global`
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
@@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
## Global-specific rules
Global prompts should exercise workspace-level drafting behavior:
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
- writing AI drafts rather than saving or deploying by default
- producing coherent multi-artifact changes when the request crosses artifact boundaries
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
## Deterministic validation
Use deterministic validation only for hard failures such as:
+14 -2
View File
@@ -1,11 +1,12 @@
# AI Evals
Small benchmark runner for the four Windmill AI generation modes:
Small benchmark runner for the Windmill AI generation modes:
- `cli`
- `flow`
- `script`
- `app`
- `global`
The benchmark always tests the current production prompts, tools, and guidance in this checkout.
@@ -57,6 +58,7 @@ bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run global global-test1-script-create
bun run cli -- run cli bun-hello-script
```
@@ -94,7 +96,7 @@ Today:
Notes:
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
@@ -133,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as:
- minimum datatable / datatable-table counts
- specific required datatable tables
For `global` mode, `validate` can express draft-level requirements such as:
- required draft type/path/language
- required or forbidden snippets in draft values
- required or forbidden draft counts
- forbidden draft paths
App fixtures can also include an optional `datatables.json` file at the fixture root.
For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of
@@ -174,6 +183,7 @@ If `--record` is used, the CLI also appends one compact JSON line to:
- `ai_evals/history/flow.jsonl`
- `ai_evals/history/script.jsonl`
- `ai_evals/history/app.jsonl`
- `ai_evals/history/global.jsonl`
- `ai_evals/history/cli.jsonl`
Each recorded line contains:
@@ -194,6 +204,7 @@ Typical artifacts by mode:
- `flow`: `flow.json`
- `script`: `script.json` plus the generated script file
- `app`: `app.json` plus frontend/backend files
- `global`: `global-drafts.json`
- `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files
- backend-validated attempts also include `backend-preview.json`
@@ -209,6 +220,7 @@ Typical artifacts by mode:
## Notes
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
- Global mode evaluates the production global AI tools and validates the resulting AI draft store.
- CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow.
- CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions.
- Frontend progress streams live while the benchmark is running.
@@ -12,10 +12,11 @@ import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettin
import { emitFrontendBenchmarkProgress } from "./progress";
import { createAppModeRunner } from "../../modes/app";
import { createFlowModeRunner } from "../../modes/flow";
import { createGlobalModeRunner } from "../../modes/global";
import { createScriptModeRunner } from "../../modes/script";
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
export type FrontendBenchmarkMode = "flow" | "app" | "script";
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
@@ -85,11 +86,13 @@ function getModeRunner(
backendValidation,
backendSettings,
);
case "global":
return createGlobalModeRunner(model, backendSettings);
}
}
function parseMode(value: string | undefined): FrontendBenchmarkMode {
if (value === "flow" || value === "app" || value === "script") {
if (value === "flow" || value === "app" || value === "script" || value === "global") {
return value;
}
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
@@ -0,0 +1,127 @@
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AIProvider } from "$lib/gen/types.gen";
import {
globalTools,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
import {
registerBenchmarkWorkspaceRunnables,
unregisterBenchmarkWorkspaceRunnables,
type BenchmarkWorkspaceRunnables,
} from "../../mockBackend";
import { runEval } from "../shared";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
const MUTATING_GLOBAL_TOOLS = new Set([
"deploy_workspace_item",
"delete_workspace_item",
]);
export interface GlobalEvalResult {
success: boolean;
state: GlobalDraftState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails: ToolCallDetail[];
tokenUsage: TokenUsage;
}
export interface GlobalEvalOptions {
workspaceFixtures?: BenchmarkWorkspaceRunnables;
model?: string;
maxIterations?: number;
provider?: AIProvider;
backend: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runGlobalEval(
userPrompt: string,
apiKey: string,
options: GlobalEvalOptions,
): Promise<GlobalEvalResult> {
const workspaceRoot =
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
globalDraftStore.clearDrafts(workspaceRoot);
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(),
userMessage: prepareGlobalUserMessage(userPrompt),
tools: getGlobalEvalTools(),
helpers: {},
apiKey,
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
onToolCall: options.runContext?.onToolCall,
options: {
maxIterations: options.maxIterations,
model,
workspace: workspaceRoot,
provider: options.provider,
backend: options.backend,
caseId: options.runContext?.caseId,
attempt: options.runContext?.attempt,
},
});
return {
state: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
toolCallDetails: rawResult.toolCallDetails,
tokenUsage: rawResult.tokenUsage,
};
} finally {
globalDraftStore.clearDrafts(workspaceRoot);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
if (!options.workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true });
}
}
}
function getGlobalEvalTools(): ProductionTool<{}>[] {
return (globalTools as ProductionTool<{}>[]).map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
return tool;
}
return {
...tool,
requiresConfirmation: false,
validateBeforeConfirmation: undefined,
fn: async () =>
JSON.stringify(
{
success: false,
error:
"This mutating workspace tool is disabled during ai_evals global mode.",
},
null,
2,
),
};
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script'
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global'
export type FrontendBenchmarkProgressEvent =
| {
+1 -1
View File
@@ -16,7 +16,7 @@ const FRONTEND_BENCHMARK_TEST =
const FRONTEND_BENCHMARK_CONFIG =
"../ai_evals/adapters/frontend/vitest.config.ts";
export type FrontendMode = "flow" | "app" | "script";
export type FrontendMode = "flow" | "app" | "script" | "global";
export async function runFrontendBenchmarkAdapter(input: {
mode: FrontendMode;
@@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkScripts(data.workspace) ?? [])
: actual.ScriptService.listScripts(data),
existsScriptByPath: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkScriptByPath(data.workspace, data.path))
: actual.ScriptService.existsScriptByPath(data),
getScriptByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByPath(data.workspace, data.path)
@@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkFlows(data.workspace) ?? [])
: actual.FlowService.listFlows(data),
existsFlowByPath: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkFlowByPath(data.workspace, data.path))
: actual.FlowService.existsFlowByPath(data),
getFlowByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
@@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => {
}
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
listSchedules: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data),
getSchedule: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Schedule "${data.path}" not found in benchmark workspace`)
}
return actual.ScheduleService.getSchedule(data)
},
previewSchedule: async (data: { requestBody?: Record<string, unknown> }) =>
previewBenchmarkSchedule(data),
createSchedule: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
@@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => {
? createBenchmarkSchedule(data)
: actual.ScheduleService.createSchedule(data)
}),
ResourceService: wrapService(actual.ResourceService, {
existsResource: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data),
listResource: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data),
getResource: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
}
return actual.ResourceService.getResource(data)
},
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
listVariable: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data),
getVariable: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
}
return actual.VariableService.getVariable(data)
}
}),
AppService: wrapService(actual.AppService, {
existsApp: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data),
listApps: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data),
getAppByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`App "${data.path}" not found in benchmark workspace`)
}
return actual.AppService.getAppByPath(data)
}
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data),
listHttpTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data),
getHttpTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`)
}
return actual.HttpTriggerService.getHttpTrigger(data)
},
createHttpTrigger: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkHttpTrigger(data)
: actual.HttpTriggerService.createHttpTrigger(data)
}),
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
existsWebsocketTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.WebsocketTriggerService.existsWebsocketTrigger(data),
listWebsocketTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? []
: actual.WebsocketTriggerService.listWebsocketTriggers(data),
getWebsocketTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`)
}
return actual.WebsocketTriggerService.getWebsocketTrigger(data)
}
}),
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
existsKafkaTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.KafkaTriggerService.existsKafkaTrigger(data),
listKafkaTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data),
getKafkaTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`)
}
return actual.KafkaTriggerService.getKafkaTrigger(data)
}
}),
NatsTriggerService: wrapService(actual.NatsTriggerService, {
existsNatsTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data),
listNatsTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data),
getNatsTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`)
}
return actual.NatsTriggerService.getNatsTrigger(data)
}
}),
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
existsPostgresTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.PostgresTriggerService.existsPostgresTrigger(data),
listPostgresTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? []
: actual.PostgresTriggerService.listPostgresTriggers(data),
getPostgresTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`)
}
return actual.PostgresTriggerService.getPostgresTrigger(data)
}
}),
MqttTriggerService: wrapService(actual.MqttTriggerService, {
existsMqttTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data),
listMqttTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data),
getMqttTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`)
}
return actual.MqttTriggerService.getMqttTrigger(data)
}
}),
SqsTriggerService: wrapService(actual.SqsTriggerService, {
existsSqsTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data),
listSqsTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data),
getSqsTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`)
}
return actual.SqsTriggerService.getSqsTrigger(data)
}
}),
GcpTriggerService: wrapService(actual.GcpTriggerService, {
existsGcpTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data),
listGcpTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data),
getGcpTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`)
}
return actual.GcpTriggerService.getGcpTrigger(data)
}
}),
AzureTriggerService: wrapService(actual.AzureTriggerService, {
existsAzureTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.AzureTriggerService.existsAzureTrigger(data),
listAzureTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data),
getAzureTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`)
}
return actual.AzureTriggerService.getAzureTrigger(data)
}
})
}
})
+89
View File
@@ -0,0 +1,89 @@
- id: global-test1-script-create
prompt: |-
Create a draft Bun script at `f/evals/global/greet_user`.
It should take a string `name` input and return `Hello, ${name}!`.
Leave it as an AI draft only; do not deploy or save it.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/greet_user
language: bun
valueIncludes:
- name
- Hello
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a Bun script draft at f/evals/global/greet_user
- the script accepts a name input
- the script returns a greeting containing Hello, the provided name, and an exclamation mark
- the result stays as an AI draft and is not deployed or saved to the workspace
- id: global-test2-script-edit-existing
prompt: |-
Update the existing workspace script at `f/evals/global/format_greeting`.
Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark.
Leave the result as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/format_greeting
language: bun
valueIncludes:
- toUpperCase
- "!"
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates an AI draft for the existing f/evals/global/format_greeting script
- preserves the script as Bun
- uppercases the provided name in the greeting
- returns a message ending with an exclamation mark
- does not deploy or save the draft to the workspace
- id: global-test3-flow-create
prompt: |-
Create a draft flow at `f/evals/global/sum_numbers`.
It should take two numeric inputs, `a` and `b`, and return their sum.
Leave it as an AI draft only; do not deploy or save it.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/sum_numbers
valueIncludes:
- modules
- rawscript
- flow_input.a
- flow_input.b
toolExpect:
requiredToolsUsed:
- write_flow
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_flow
field: modules
stringStartsWithAnyOf:
- "["
judgeChecklist:
- creates a flow draft at f/evals/global/sum_numbers
- the flow accepts numeric inputs a and b
- the flow returns the sum of a and b
- the result stays as an AI draft and is not deployed or saved to the workspace
+4 -3
View File
@@ -53,6 +53,7 @@ async function main() {
" bun run cli -- run flow --record",
" bun run cli -- run flow --backend-validation preview",
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
" bun run cli -- run global global-test1-script-create",
" bun run cli -- run cli bun-hello-script",
"",
"Models:",
@@ -70,7 +71,7 @@ async function main() {
program
.command("cases")
.description("List available cases")
.argument("[mode]", "cli, flow, script, or app", parseOptionalMode)
.argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode)
.action(async (mode?: EvalMode) => {
await handleCases(mode);
});
@@ -78,7 +79,7 @@ async function main() {
program
.command("run")
.description("Run one benchmark mode")
.argument("<mode>", "cli, flow, script, or app", parseMode)
.argument("<mode>", "cli, flow, script, app, or global", parseMode)
.argument("[caseIds...]", "specific case ids to run")
.option(
"--runs <n>",
@@ -152,7 +153,7 @@ function handleModels() {
process.stdout.write("Available models\n");
for (const model of EVAL_MODELS) {
const supports = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.frontend ? ["flow", "script", "app", "global"] : []),
...(model.cli ? ["cli"] : []),
];
const aliases = [
+20
View File
@@ -183,6 +183,26 @@ describe("loadCases", () => {
});
});
it("loads global draft validation and forbidden tool expectations", async () => {
const globalCases = await loadCases("global");
const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create");
expect(caseEntry?.validate).toMatchObject({
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
},
],
});
expect(caseEntry?.toolExpect).toMatchObject({
requiredToolsUsed: ["write_script"],
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
});
});
it("loads tool expectations for workspace mutation cases", async () => {
const scriptCases = await loadCases("script");
const caseEntry = scriptCases.find(
+1 -1
View File
@@ -145,7 +145,7 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec
export function getEvalModelHelpText(): string {
return EVAL_MODELS.map((model) => {
const modes = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.frontend ? ["flow", "script", "app", "global"] : []),
...(model.cli ? ["cli"] : []),
];
return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`;
+24 -2
View File
@@ -1,4 +1,4 @@
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const;
export type EvalMode = (typeof EVAL_MODES)[number];
@@ -108,6 +108,27 @@ export interface AppValidationSpec {
forbiddenAppContent?: string[];
}
export interface GlobalDraftRequirement {
type: string;
path: string;
triggerKind?: string;
language?: string;
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
export interface GlobalValidationSpec {
draftCountAtLeast?: number;
draftCountExactly?: number;
requiredDrafts?: GlobalDraftRequirement[];
forbiddenDrafts?: Array<{
type: string;
path: string;
triggerKind?: string;
}>;
}
export interface CliValidationSpec {
requiredSkills?: string[];
forbiddenSkills?: string[];
@@ -136,10 +157,11 @@ export interface ToolCallArgumentRule {
export interface ToolValidationSpec {
requiredToolsUsed?: string[];
forbiddenToolsUsed?: string[];
toolCallArgs?: ToolCallArgumentRule[];
}
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec;
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec;
export interface EvalCase {
id: string;
+225
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import {
validateAppState,
validateCliWorkspace,
validateGlobalState,
validateScriptState,
validateToolExpectations,
} from "./validators";
@@ -117,6 +118,230 @@ describe("validateToolExpectations", () => {
details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"',
});
});
it("rejects forbidden tool usage", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["write_script", "deploy_workspace_item"],
skillsInvoked: [],
},
toolExpect: {
forbiddenToolsUsed: ["deploy_workspace_item"],
},
});
expect(checks).toContainEqual({
name: "does not use deploy_workspace_item",
passed: false,
details: "tools used: write_script, deploy_workspace_item",
});
});
});
describe("validateGlobalState", () => {
it("accepts a required script draft", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails when a required draft is missing", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
validate: {
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft f/evals/global/greet_user",
passed: false,
details: "drafts: none",
});
});
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_python",
language: "python3",
value: "def main(name: str):\n return f'Hello, {name}!'\n",
isDraft: true,
},
],
},
});
expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe(
false
);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("allows read-only global cases without draft expectations", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
});
expect(
checks.some(
(check) => check.name === "global produced at least one draft"
)
).toBe(false);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("matches expected global draft fixtures", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
expect(checks).toContainEqual({
name: "global drafts match expected",
passed: true,
});
});
it("fails when expected global draft fixtures differ", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user value differs"
);
expect(expectedMatchCheck?.details).toContain("Hello");
expect(expectedMatchCheck?.details).toContain("Bonjour");
});
it("explains expected global draft metadata mismatches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "python3",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user language differs"
);
expect(expectedMatchCheck?.details).toContain('actual="bun"');
expect(expectedMatchCheck?.details).toContain('expected="python3"');
});
});
describe("validateAppState", () => {
+376
View File
@@ -6,6 +6,7 @@ import type {
CliTrace,
CliValidationSpec,
FlowValidationSpec,
GlobalValidationSpec,
ModeRunOutput,
ToolValidationSpec,
} from "./types";
@@ -51,6 +52,20 @@ export interface AppDatatableState {
error?: string;
}
export interface GlobalDraftState {
drafts: GlobalDraft[];
}
export interface GlobalDraft {
type: string;
path: string;
triggerKind?: string;
summary?: string;
language?: string;
value?: unknown;
isDraft?: boolean;
}
const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]);
const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]);
@@ -154,6 +169,16 @@ export function validateToolExpectations(input: {
);
}
for (const toolName of expect.forbiddenToolsUsed ?? []) {
checks.push(
check(
`does not use ${toolName}`,
!input.run.toolsUsed.includes(toolName),
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
)
);
}
for (const rule of expect.toolCallArgs ?? []) {
const calls = toolCallDetails.filter((call) => call.name === rule.tool);
checks.push(
@@ -202,6 +227,161 @@ export function validateToolExpectations(input: {
return checks;
}
export function validateGlobalState(input: {
actual: GlobalDraftState;
expected?: GlobalDraftState;
validate?: GlobalValidationSpec;
}): BenchmarkCheck[] {
const drafts = input.actual.drafts ?? [];
const checks: BenchmarkCheck[] = [];
// Read-only global cases are valid; only enforce draft production when the
// case explicitly asks for draft output.
if (globalValidationExpectsDrafts(input)) {
checks.push(
check(
"global produced at least one draft",
drafts.length > 0,
`drafts=${drafts.length}`
)
);
}
checks.push(
check(
"all global outputs are drafts",
drafts.every((draft) => draft.isDraft === true),
summarizeGlobalDrafts(drafts)
)
);
for (const draft of drafts) {
if (draft.type !== "script" || typeof draft.value !== "string") {
continue;
}
const language = (draft.language ?? "bun").toLowerCase();
const syntaxErrors = getScriptSyntaxErrors(draft.value, language);
if (TS_LIKE_LANGUAGES.has(language)) {
checks.push(
check(
`script draft ${draft.path} exports entrypoint`,
hasSupportedEntrypoint(draft.value)
)
);
}
checks.push(
check(
`script draft ${draft.path} has no syntax errors`,
syntaxErrors.length === 0,
summarizeProblems(syntaxErrors)
)
);
}
if (input.expected) {
checks.push(
check(
"global drafts match expected",
globalDraftStatesEqual(input.actual, input.expected),
describeGlobalDraftStateMismatch(input.actual, input.expected)
)
);
}
const validate = input.validate;
if (!validate) {
return checks;
}
if (validate.draftCountAtLeast !== undefined) {
checks.push(
check(
`global includes at least ${validate.draftCountAtLeast} draft(s)`,
drafts.length >= validate.draftCountAtLeast,
`drafts=${drafts.length}`
)
);
}
if (validate.draftCountExactly !== undefined) {
checks.push(
check(
`global includes exactly ${validate.draftCountExactly} draft(s)`,
drafts.length === validate.draftCountExactly,
`drafts=${drafts.length}`
)
);
}
for (const required of validate.requiredDrafts ?? []) {
const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind);
checks.push(
check(
`global includes ${required.type} draft ${required.path}`,
Boolean(draft),
summarizeGlobalDrafts(drafts)
)
);
if (!draft) {
continue;
}
if (required.language !== undefined) {
checks.push(
check(
`${required.type} draft ${required.path} uses ${required.language}`,
draft.language === required.language,
`language=${draft.language ?? "(none)"}`
)
);
}
for (const snippet of required.summaryIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} summary includes '${snippet}'`,
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
`summary=${draft.summary ?? ""}`
)
);
}
const valueText = stringifyGlobalDraftValue(draft.value);
for (const snippet of required.valueIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value includes '${snippet}'`,
normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
);
}
for (const snippet of required.valueExcludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value excludes '${snippet}'`,
!normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
);
}
}
for (const forbidden of validate.forbiddenDrafts ?? []) {
checks.push(
check(
`global does not include ${forbidden.type} draft ${forbidden.path}`,
!findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind),
summarizeGlobalDrafts(drafts)
)
);
}
return checks;
}
export function validateAppState(input: {
actual: AppFilesState;
initial?: AppFilesState;
@@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined {
return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`;
}
function findGlobalDraft(
drafts: GlobalDraft[],
type: string,
path: string,
triggerKind?: string
): GlobalDraft | undefined {
return drafts.find(
(draft) =>
draft.type === type &&
draft.path === path &&
(triggerKind === undefined || draft.triggerKind === triggerKind)
);
}
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
const summary = drafts
.map((draft) => formatGlobalDraftKey(draft))
.join(", ");
return `drafts: ${summary || "none"}`;
}
function formatGlobalDraftKey(draft: GlobalDraft): string {
return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`;
}
function globalValidationExpectsDrafts(input: {
expected?: GlobalDraftState;
validate?: GlobalValidationSpec;
}): boolean {
const validate = input.validate;
return (
(input.expected?.drafts?.length ?? 0) > 0 ||
(validate?.requiredDrafts?.length ?? 0) > 0 ||
(validate?.draftCountAtLeast ?? 0) > 0 ||
(validate?.draftCountExactly ?? 0) > 0
);
}
function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean {
return (
JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) ===
JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? []))
);
}
function describeGlobalDraftStateMismatch(
actual: GlobalDraftState,
expected: GlobalDraftState
): string {
const actualDrafts = actual.drafts ?? [];
const expectedDrafts = expected.drafts ?? [];
const actualByKey = new Map(
actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
);
const expectedByKey = new Map(
expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
);
for (const key of Array.from(expectedByKey.keys()).sort()) {
const expectedDraft = expectedByKey.get(key);
if (expectedDraft && !actualByKey.has(key)) {
return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`;
}
}
for (const key of Array.from(actualByKey.keys()).sort()) {
const actualDraft = actualByKey.get(key);
if (actualDraft && !expectedByKey.has(key)) {
return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
}
}
for (const key of Array.from(expectedByKey.keys()).sort()) {
const actualDraft = actualByKey.get(key);
const expectedDraft = expectedByKey.get(key);
if (!actualDraft || !expectedDraft) {
continue;
}
const fieldMismatch = describeGlobalDraftFieldMismatch(
formatGlobalDraftKey(expectedDraft),
actualDraft,
expectedDraft
);
if (fieldMismatch) {
return fieldMismatch;
}
}
return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
}
function describeGlobalDraftFieldMismatch(
key: string,
actual: GlobalDraft,
expected: GlobalDraft
): string | undefined {
const fields: Array<"language" | "summary" | "value" | "isDraft"> = [
"language",
"summary",
"value",
"isDraft",
];
for (const field of fields) {
const actualValue = comparableGlobalDraftFieldValue(actual, field);
const expectedValue = comparableGlobalDraftFieldValue(expected, field);
if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) {
continue;
}
return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue(
actualValue
)}; expected=${formatGlobalDraftFieldValue(expectedValue)}`;
}
return undefined;
}
function comparableGlobalDraftFieldValue(
draft: GlobalDraft,
field: "language" | "summary" | "value" | "isDraft"
): unknown {
if (field === "summary" && typeof draft.summary === "string") {
return normalizeText(draft.summary);
}
if (field === "value" && typeof draft.value === "string") {
return normalizeText(draft.value);
}
if (field === "value") {
return canonicalizeJsonValue(draft.value);
}
return draft[field];
}
function formatGlobalDraftFieldValue(value: unknown): string {
if (value === undefined) {
return "(missing)";
}
return truncateForDetails(JSON.stringify(value), 300);
}
function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] {
return drafts
.slice()
.sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right)))
.map((draft) =>
canonicalizeJsonValue({
type: draft.type,
path: draft.path,
triggerKind: draft.triggerKind,
language: draft.language,
summary:
typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary,
value:
typeof draft.value === "string"
? normalizeText(draft.value)
: canonicalizeJsonValue(draft.value),
isDraft: draft.isDraft,
})
);
}
function globalDraftSortKey(draft: GlobalDraft): string {
return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`;
}
function canonicalizeJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeJsonValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, canonicalizeJsonValue(nested)])
);
}
return value;
}
function stringifyGlobalDraftValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
return JSON.stringify(value ?? null, null, 2);
}
function truncateForDetails(value: string, maxLength = 500): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
}
function validateCliExpectations(
assistantOutput: string,
trace: CliTrace | undefined,
@@ -0,0 +1,23 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/format_greeting",
"summary": "Format a greeting for a provided name",
"description": "Returns a plain greeting for the provided name.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
"content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n"
}
]
}
}
+81
View File
@@ -0,0 +1,81 @@
import { readFile } from "node:fs/promises";
import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner";
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
import type { FrontendEvalModelConfig } from "../core/models";
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
import { getFrontendApiKey } from "./frontendCommon";
export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
}
export function createGlobalModeRunner(
modelConfig: FrontendEvalModelConfig,
backendSettings: WindmillBackendSettings,
): ModeRunner<GlobalInitialFixture, GlobalDraftState, GlobalDraftState> {
return {
mode: "global",
concurrency: 3,
judgeThreshold: 80,
async loadInitial(path) {
return path ? await loadGlobalInitialFixture(path) : undefined;
},
async loadExpected(path) {
return path ? await loadGlobalExpectedFixture(path) : undefined;
},
async run(prompt, initial, context) {
const result = await runGlobalEval(
prompt,
getFrontendApiKey(modelConfig.provider),
{
workspaceFixtures: initial?.workspace,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
backend: backendSettings,
runContext: context,
},
);
return {
success: result.success,
actual: result.state,
error: result.error,
assistantMessageCount: result.assistantMessageCount,
toolCallCount: result.toolCallCount,
toolsUsed: result.toolsUsed,
toolCallDetails: result.toolCallDetails,
skillsInvoked: [],
tokenUsage: result.tokenUsage,
};
},
validate({ evalCase, actual, expected }) {
return validateGlobalState({
actual,
expected,
validate: evalCase.validate as GlobalValidationSpec | undefined,
});
},
buildArtifacts(actual): BenchmarkArtifactFile[] {
return [
{
path: "global-drafts.json",
content: JSON.stringify(actual, null, 2) + "\n",
},
];
},
};
}
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
};
}
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4"
}
+88 -88
View File
@@ -775,9 +775,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -785,9 +785,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
@@ -4559,9 +4559,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.28"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
@@ -8492,18 +8492,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.12"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
@@ -13788,7 +13788,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-nats",
@@ -13869,7 +13869,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"async-trait",
"aws-config",
@@ -13899,7 +13899,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13912,7 +13912,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"argon2",
@@ -14055,7 +14055,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14078,7 +14078,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14091,7 +14091,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14117,7 +14117,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14127,7 +14127,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14144,7 +14144,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14166,7 +14166,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14189,7 +14189,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14205,7 +14205,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14226,7 +14226,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14247,7 +14247,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14261,7 +14261,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-nats",
@@ -14293,7 +14293,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14318,7 +14318,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14336,7 +14336,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14358,7 +14358,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14378,7 +14378,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14408,7 +14408,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14436,7 +14436,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"lazy_static",
"serde",
@@ -14448,7 +14448,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14473,7 +14473,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14487,7 +14487,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14520,7 +14520,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"chrono",
"lazy_static",
@@ -14534,7 +14534,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14553,7 +14553,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14654,7 +14654,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14673,7 +14673,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"regex",
"serde",
@@ -14688,7 +14688,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14712,7 +14712,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"futures",
@@ -14729,7 +14729,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14745,7 +14745,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -14766,7 +14766,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -14797,7 +14797,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"arc-swap",
@@ -14822,7 +14822,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-stream",
@@ -14856,7 +14856,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"futures",
@@ -14874,7 +14874,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14883,7 +14883,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -14895,7 +14895,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -14907,7 +14907,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"gosyn",
@@ -14919,7 +14919,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -14931,7 +14931,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -14943,7 +14943,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -14954,7 +14954,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14965,7 +14965,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14977,7 +14977,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14988,7 +14988,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15022,7 +15022,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15036,7 +15036,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15053,7 +15053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15066,7 +15066,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde",
@@ -15078,7 +15078,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15096,7 +15096,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15112,7 +15112,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15128,7 +15128,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde",
@@ -15139,7 +15139,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15176,7 +15176,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"const_format",
@@ -15214,7 +15214,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15225,7 +15225,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15255,7 +15255,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15279,7 +15279,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15312,7 +15312,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15345,7 +15345,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15365,7 +15365,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15399,7 +15399,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15435,7 +15435,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15458,7 +15458,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15482,7 +15482,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15506,7 +15506,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15541,7 +15541,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15569,7 +15569,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15592,7 +15592,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"bitflags 2.11.1",
@@ -15611,7 +15611,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15720,7 +15720,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.700.2"
version = "1.702.1"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.700.2"
version = "1.702.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38
19a76a09ffb43649ee19e62d07e8b8a42d78757b
@@ -0,0 +1 @@
ALTER TABLE token DROP COLUMN IF EXISTS read_only;
@@ -0,0 +1,4 @@
-- Add a flag to restrict a token to read-only HTTP endpoints.
-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies
-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions.
ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
-- No-op: clearing a stray `auto_kind = 'lib'` value on failure/trigger/approval
-- scripts is not reversible (the original NULL/'lib' distinction is lost), and
-- restoring `'lib'` here would re-hide these scripts from their pickers.
@@ -0,0 +1,9 @@
-- Failure, Trigger, and Approval scripts are runnable entrypoints by
-- definition. A prior parser regression occasionally classified them as
-- `auto_kind = 'lib'`, which hid them from the flow error-handler /
-- trigger / approval pickers. Clear those stray values so existing
-- affected scripts re-appear without requiring a redeploy.
UPDATE script
SET auto_kind = NULL
WHERE auto_kind = 'lib'
AND kind IN ('failure', 'trigger', 'approval');
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.700.2"
version = "1.702.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.700.2"
version = "1.702.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+81 -54
View File
@@ -6,6 +6,8 @@ use windmill_common::{
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
#[cfg(feature = "operator")]
pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2;
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
let connect_options = get_database_url().await?.connect_options().await?;
@@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::E
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}
/// Connect to the database for the Kubernetes operator process.
///
/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server,
/// otherwise new pool connections start failing once the initial token expires (~15 min).
#[cfg(feature = "operator")]
pub async fn operator_connection(
#[cfg(all(feature = "enterprise", feature = "private"))]
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
let database_url = get_database_url().await?;
let pool = connect(
database_url.clone(),
DEFAULT_MAX_CONNECTIONS_OPERATOR,
false,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
Ok(pool)
}
pub async fn connect_db(
server_mode: bool,
indexer_mode: bool,
worker_mode: bool,
num_workers: i32,
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
use anyhow::Context;
@@ -43,70 +68,72 @@ pub async fn connect_db(
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
{
let needs_token_refresh = matches!(
database_url,
DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_)
);
let label = match &database_url {
DatabaseUrl::IamRds(_) => "IAM RDS",
DatabaseUrl::EntraId(_) => "Entra ID",
DatabaseUrl::Static(_) => "",
};
if needs_token_refresh {
let pool2 = pool.clone();
let database_url2 = database_url.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
if !database_url2.needs_refresh().await {
continue;
}
let new_url = tokio::time::timeout(
std::time::Duration::from_secs(10),
get_database_url(),
)
.await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool2.set_connect_options(connect_options);
tracing::info!("Refreshed {label} URL successfully");
}
Err(e) => {
tracing::error!(
"Error getting {label} connect options, retrying in 10s: {e}"
);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!(
"Error refreshing {label} URL, trying again in 10s: {e}"
);
continue;
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
Ok(pool)
}
/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire
/// and updates the pool's connect options so new connections use the fresh token.
/// No-op for static (password-based) database URLs.
#[cfg(all(feature = "enterprise", feature = "private"))]
pub fn spawn_token_refresh_task(
pool: sqlx::Pool<sqlx::Postgres>,
database_url: DatabaseUrl,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let label = match &database_url {
DatabaseUrl::IamRds(_) => "IAM RDS",
DatabaseUrl::EntraId(_) => "Entra ID",
DatabaseUrl::Static(_) => return,
};
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
if !database_url.needs_refresh().await {
continue;
}
let new_url = tokio::time::timeout(
std::time::Duration::from_secs(10),
get_database_url(),
)
.await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool.set_connect_options(connect_options);
tracing::info!("Refreshed {label} URL successfully");
}
Err(e) => {
tracing::error!(
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
"Error getting {label} connect options, retrying in 10s: {e}"
);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!(
"Error refreshing {label} URL, trying again in 10s: {e}"
);
continue;
}
Err(e) => {
tracing::error!(
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
);
continue;
}
}
}
});
}
}
}
Ok(pool)
});
}
pub async fn connect(
+18 -1
View File
@@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
tracing::info!("Starting Windmill Kubernetes operator...");
tracing::info!("Connecting to database...");
let db = crate::db_connect::initial_connection().await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
let (operator_killpill_tx, operator_killpill_rx) =
tokio::sync::broadcast::channel::<()>(2);
let db = crate::db_connect::operator_connection(
#[cfg(all(feature = "enterprise", feature = "private"))]
operator_killpill_rx,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
let _ = operator_killpill_tx.send(());
}
});
tracing::info!("Database connected. Starting ConfigMap watcher...");
windmill_operator::run(db).await?;
return Ok(());
+4 -2
View File
@@ -886,11 +886,13 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
if current.enabled != new_settings.enabled
|| current.enabled_languages != new_settings.enabled_languages
|| current.no_proxy_hosts != new_settings.no_proxy_hosts
{
tracing::info!(
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
"OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}",
new_settings.enabled,
new_settings.enabled_languages
new_settings.enabled_languages,
new_settings.no_proxy_hosts,
);
*current = new_settings;
}
+57
View File
@@ -577,6 +577,63 @@ export function main() {
Ok(())
}
/// Regression test: a `//nobundling` script that pulls a package whose CJS
/// internals do bare-specifier `require()` of a sibling dependency.
///
/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the
/// directory symlink in `node_modules/@langchain/core` to its global cache
/// entry, walk parent dirs from the cache realpath, and fail to find
/// `node_modules/zod` — producing:
/// ENOENT while resolving package 'zod/v3' from
/// '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'
///
/// The fix passes `--preserve-symlinks` so Bun resolves from the
/// symlink path under `<job_dir>/node_modules/`, where `zod` is a sibling.
#[sqlx::test(fixtures("base"))]
async fn test_bun_nobundling_transitive_require(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"//nobundling
import { ChatPromptTemplate } from "@langchain/core/prompts";
export async function main() {
const tpl = ChatPromptTemplate.fromMessages([
["system", "you are a {role}"],
["human", "{input}"],
]);
const out = await tpl.formatMessages({ role: "tester", input: "ping" });
return out.length;
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(2));
Ok(())
}
// ============================================================================
// Native Mode Tests (requires deno_core feature)
// ============================================================================
+106
View File
@@ -760,6 +760,112 @@ def main():
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_result_preserves_infinity_in_string(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
def main():
return {
"plain": "Infinity",
"embedded": "value=-Infinity end",
"nan_word": "this is NaN inside text",
"nested": [{"k": "Infinity"}],
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
serde_json::json!({
"plain": "Infinity",
"embedded": "value=-Infinity end",
"nan_word": "this is NaN inside text",
"nested": [{"k": "Infinity"}],
})
);
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_result_non_finite_floats_become_null(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
def main():
return {
"inf": float("inf"),
"neg_inf": float("-inf"),
"nan": float("nan"),
"finite": 1.5,
"nested": [float("inf"), {"x": float("nan")}],
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
serde_json::json!({
"inf": null,
"neg_inf": null,
"nan": null,
"finite": 1.5,
"nested": [null, {"x": null}],
})
);
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
+122
View File
@@ -0,0 +1,122 @@
use std::collections::HashMap;
use sqlx::{Pool, Postgres};
use windmill_api_client::types::{NewScript, ScriptLang};
use windmill_test_utils::init_client;
fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript {
NewScript {
content: content.into(),
language: ScriptLang::Bun,
lock: None,
parent_hash: None,
path: path.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
draft_only: None,
envs: vec![],
is_template: None,
kind: kind.map(|s| s.to_string()),
summary: "".to_string(),
tag: None,
schema: HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
/// Regression: a `failure`-kind script must never be marked `auto_kind = 'lib'`
/// even if the parser fails to detect a `main` function, because the flow
/// error-handler picker filters out lib scripts and would otherwise hide it.
#[sqlx::test(fixtures("base"))]
async fn failure_kind_script_without_main_is_not_marked_lib(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
// Content with no `main` — TS parser would normally set auto_kind = 'lib'.
client
.create_script(
"test-workspace",
&quick_ns(
"export function notMain() { return 42 }",
"u/test-user/failure_no_main",
Some("failure"),
),
)
.await
.unwrap();
let auto_kind: Option<String> = sqlx::query_scalar(
"SELECT auto_kind FROM script \
WHERE workspace_id = $1 AND path = $2",
)
.bind("test-workspace")
.bind("u/test-user/failure_no_main")
.fetch_one(&db)
.await?;
assert_ne!(
auto_kind.as_deref(),
Some("lib"),
"failure-kind script must not be marked as 'lib' auto_kind, got {:?}",
auto_kind
);
Ok(())
}
/// Sibling: a normal `script` kind WITHOUT main should still be marked `lib`
/// (so it stays hidden from the regular script picker). Guards against an
/// over-broad sanitizer accidentally clearing the value for plain scripts.
#[sqlx::test(fixtures("base"))]
async fn regular_script_without_main_is_still_marked_lib(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"export function notMain() { return 42 }",
"u/test-user/script_no_main",
Some("script"),
),
)
.await
.unwrap();
let auto_kind: Option<String> = sqlx::query_scalar(
"SELECT auto_kind FROM script \
WHERE workspace_id = $1 AND path = $2",
)
.bind("test-workspace")
.bind("u/test-user/script_no_main")
.fetch_one(&db)
.await?;
assert_eq!(
auto_kind.as_deref(),
Some("lib"),
"regular script without main should be marked 'lib', got {:?}",
auto_kind
);
Ok(())
}
@@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
+47 -7
View File
@@ -194,6 +194,7 @@ impl AuthCache {
scopes: None,
username_override,
token_prefix: claims.audit_span,
read_only: false,
};
let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok());
AUTH_CACHE.insert(
@@ -221,11 +222,20 @@ impl AuthCache {
token_hash = $1
AND (expiration > NOW() OR expiration IS NULL)
AND (workspace_id IS NULL OR workspace_id = $2)
RETURNING owner, email, super_admin, scopes, label",
RETURNING owner, email, super_admin, scopes, label, read_only",
t_hash,
w_id.as_ref(),
)
.map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label))
.map(|x| {
(
x.owner,
x.email,
x.super_admin,
x.scopes,
x.label,
x.read_only,
)
})
.fetch_optional(&self.db)
.await
.ok()
@@ -234,7 +244,9 @@ impl AuthCache {
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => {
(Some(owner), Some(email), super_admin, _, label, read_only)
if w_id.is_some() =>
{
let username_override = username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
@@ -280,6 +292,7 @@ impl AuthCache {
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
} else {
let groups = vec![name.to_string()];
@@ -305,6 +318,7 @@ impl AuthCache {
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
}
} else {
@@ -320,10 +334,11 @@ impl AuthCache {
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
}
}
(_, Some(email), super_admin, scopes, label) => {
(_, Some(email), super_admin, scopes, label, read_only) => {
let username_override = username_override_from_label(label);
if w_id.is_some() {
let row_o = sqlx::query!(
@@ -368,6 +383,7 @@ impl AuthCache {
scopes,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
}
None if super_admin => Some(ApiAuthed {
@@ -380,6 +396,7 @@ impl AuthCache {
scopes,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
}),
None => None,
}
@@ -394,6 +411,7 @@ impl AuthCache {
scopes,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
}
}
@@ -428,6 +446,7 @@ impl AuthCache {
scopes: None,
username_override: None,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
@@ -630,6 +649,7 @@ pub async fn resolve_opt_job_authed(
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
};
return Ok((OptJobAuthed { authed, job_id: None }, parts));
}
@@ -667,12 +687,11 @@ pub async fn resolve_opt_job_authed(
cache.get_opt_job_authed(workspace_id.clone(), &token).await
{
let authed = &mut opt_job_authed.authed;
let path = original_uri.path();
let method = parts.method.as_str();
if authed.scopes.is_some() {
transform_old_scope_to_new_scope(authed.scopes.as_mut());
let path = original_uri.path();
let method = parts.method.as_str();
if let Err(err) = crate::scopes::check_scopes_for_route(
authed.scopes.as_deref(),
path,
@@ -681,6 +700,27 @@ pub async fn resolve_opt_job_authed(
return Err((err, parts));
}
}
if authed.read_only {
// MCP transport runs over POST (streamable HTTP / SSE handshake),
// so the middleware can't safely reject mutating methods on it —
// the MCP runner itself filters out write tools and rejects
// mutating tool calls for read-only tokens. Narrow to the actual
// transport endpoints: anything else under `/api/mcp/*` (OAuth
// approve, token exchange, client registration) must still go
// through the read-only check, otherwise a read-only token
// could approve an OAuth flow that mints a new non-read-only
// token.
let is_mcp_transport = path == "/api/mcp/gateway"
|| (path.starts_with("/api/mcp/w/")
&& (path.ends_with("/mcp")
|| path.ends_with("/sse")
|| path.ends_with("/list_tools")));
if !is_mcp_transport {
if let Err(err) = crate::scopes::check_read_only_for_route(path, method) {
return Err((err, parts));
}
}
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
+14 -3
View File
@@ -55,6 +55,7 @@ pub struct ApiAuthed {
pub scopes: Option<Vec<String>>,
pub username_override: Option<String>,
pub token_prefix: Option<String>,
pub read_only: bool,
}
impl ApiAuthed {
@@ -103,6 +104,7 @@ impl From<Authed> for ApiAuthed {
scopes: value.scopes,
username_override: None,
token_prefix: value.token_prefix,
read_only: false,
}
}
}
@@ -183,6 +185,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed {
fn scopes(&self) -> Option<&[String]> {
self.scopes.as_deref()
}
fn read_only(&self) -> bool {
self.read_only
}
}
// ------------ Utility functions ------------
@@ -478,6 +484,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
scopes: authed.scopes,
username_override: None,
token_prefix: authed.token_prefix,
read_only: false,
};
API_AUTHED_CACHE.insert(
@@ -506,6 +513,8 @@ pub struct NewToken {
pub impersonate_email: Option<String>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
#[serde(default)]
pub read_only: Option<bool>,
}
impl NewToken {
@@ -515,8 +524,9 @@ impl NewToken {
impersonate_email: Option<String>,
scopes: Option<Vec<String>>,
workspace_id: Option<String>,
read_only: Option<bool>,
) -> Self {
Self { label, expiration, impersonate_email, scopes, workspace_id }
Self { label, expiration, impersonate_email, scopes, workspace_id, read_only }
}
}
@@ -564,8 +574,8 @@ pub async fn create_token_internal(
}
let rows = sqlx::query!(
"INSERT INTO token
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
WHERE $9::varchar IS NULL OR NOT EXISTS(
SELECT 1 FROM workspace WHERE id = $9 AND deleted = true
)",
@@ -578,6 +588,7 @@ pub async fn create_token_internal(
is_super_admin,
token_config.scopes.as_ref().map(|x| x.as_slice()),
token_config.workspace_id,
token_config.read_only.unwrap_or(false),
)
.execute(&mut *tx)
.await?;
+41
View File
@@ -686,6 +686,19 @@ fn scope_grants_access(
Ok(true)
}
/// Enforces a token's `read_only` flag: only methods classified as `Read`
/// (GET/HEAD/OPTIONS) are allowed. Run actions and mutating methods are
/// rejected. Independent of `scopes`.
pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<()> {
if map_http_method_to_action(http_method, route_path) == ScopeAction::Read {
Ok(())
} else {
Err(Error::PermissionDenied(
"Token is read-only. Mutating endpoints are not allowed.".to_string(),
))
}
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
@@ -778,6 +791,34 @@ mod tests {
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
}
#[test]
fn test_check_read_only_for_route() {
// Plain GETs pass.
assert!(check_read_only_for_route("/api/w/x/scripts/list", "GET").is_ok());
assert!(check_read_only_for_route("/api/w/x/scripts/get/foo", "HEAD").is_ok());
assert!(check_read_only_for_route("/api/w/x/anything", "OPTIONS").is_ok());
// Mutating methods are rejected.
assert!(check_read_only_for_route("/api/w/x/scripts/create", "POST").is_err());
assert!(check_read_only_for_route("/api/w/x/scripts/update", "PUT").is_err());
assert!(check_read_only_for_route("/api/w/x/scripts/delete", "DELETE").is_err());
assert!(check_read_only_for_route("/api/w/x/scripts/patch", "PATCH").is_err());
// Run paths are rejected even on GET (map_http_method_to_action elevates
// them to Run via RUN_PATH_ACTIONS).
assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "GET").is_err());
assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "POST").is_err());
// OAuth/registration endpoints under /api/mcp/* must NOT be exempted by
// the auth middleware — they go through this check on the gateway side
// because they can mint non-read-only tokens. The middleware decides
// which paths to exempt; this helper is method-only, so we just assert
// that mutating methods still fail.
assert!(
check_read_only_for_route("/api/mcp/gateway/oauth/server/approve", "POST").is_err()
);
}
#[test]
fn test_specific_scope_access() {
let scopes = vec!["jobs:read".to_string()];
+219 -84
View File
@@ -12,6 +12,9 @@ use axum::{
Json, Router,
};
use windmill_api_auth::require_owner_of_path;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::scripts::ScriptHash;
use windmill_common::DB;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -24,6 +27,31 @@ use windmill_common::{
utils::{not_found_if_none, StripPath},
};
/// Map a granular-ACL kind segment to the audit-log action prefix used by the
/// per-kind CRUD endpoints (e.g. `flows.update`, `scripts.update`). The
/// resulting action is suffixed with `.grant_acl` / `.revoke_acl` so the
/// audit log keeps a per-resource record of every `/acls/*` mutation —
/// folder/group already log via their dedicated permission-history tables.
fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> {
match kind {
"script" => Some("scripts"),
"flow" => Some("flows"),
"app" => Some("apps"),
// Distinct prefix so dashboards aggregating on `action` can separate
// raw_app ACL mutations from regular app ones without parsing the
// `kind` parameters field. (The granular_acls SQL routes raw_app
// writes to the same `app` table; audit log identity is separate.)
"raw_app" => Some("raw_apps"),
"resource" => Some("resources"),
"variable" => Some("variables"),
"schedule" => Some("schedules"),
"http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger"
| "postgres_trigger" | "mqtt_trigger" | "gcp_trigger" | "azure_trigger" | "sqs_trigger"
| "email_trigger" => Some("triggers"),
_ => None,
}
}
const KINDS: [&str; 20] = [
"script",
"group_",
@@ -131,9 +159,15 @@ async fn add_granular_acl(
}
}
// v2 raw apps are stored in the `app` table (with `app_version.raw_app = true`
// distinguishing them from regular apps); the legacy `raw_app` table no longer
// backs the workspace export, so granting/revoking on `raw_app` must hit `app`
// for the change to be visible. Git-sync dispatch still uses
// DeployedObject::RawApp so the worker writes back `<path>.raw_app.json`.
let table = if kind == "raw_app" { "app" } else { kind };
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
"UPDATE {table} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
))
.bind(vec![owner.clone()])
@@ -175,6 +209,34 @@ async fn add_granular_acl(
Some(&owner),
)
.await?;
} else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) {
// Mirror the folder/group permission-history coverage for every other
// ACLable kind. Folder/group already wrote a dedicated history row
// above; everything else (script/flow/app/raw_app/resource/...) lands
// in the general audit_log table here.
let access = if write.unwrap_or(false) {
"write"
} else {
"read"
};
let action = format!("{}.grant_acl", prefix);
audit_log(
&mut *tx,
&authed,
action.as_str(),
ActionKind::Update,
&w_id,
Some(path),
Some(
[
("kind", kind),
("owner", owner.as_str()),
("access", access),
]
.into(),
),
)
.await?;
}
tx.commit().await?;
@@ -193,46 +255,67 @@ async fn add_granular_acl(
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
"app" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
Some(format!("App '{}' changed permissions", path)),
true,
None,
)
.await?
}
"raw_app" => {
// RawApp deliberately uses its own DeployedObject variant: the
// git-sync worker reads `path_type` ("app" vs "raw_app") to decide
// whether to write `<path>.app.json` or `<path>.raw_app.json`.
// Collapsing this into `App` would dispatch raw_app perm changes
// against the wrong file in the repo.
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::RawApp { path: path.to_string(), parent_path: None, version: 0 },
Some(format!("Raw App '{}' changed permissions", path)),
true,
None,
)
.await?
}
"script" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Script {
path: path.to_string(),
parent_path: None,
hash: ScriptHash(0),
},
Some(format!("Script '{}' changed permissions", path)),
true,
None,
)
.await?
}
"flow" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 },
Some(format!("Flow '{}' changed permissions", path)),
true,
None,
)
.await?
}
_ => (),
}
@@ -295,13 +378,17 @@ async fn remove_granular_acl(
require_owner_of_path(&authed, path)?;
}
// See add_granular_acl: kind="raw_app" must hit the `app` table because v2
// raw apps live there and the legacy `raw_app` table no longer backs the
// workspace export.
let table = if kind == "raw_app" { "app" } else { kind };
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {kind}
SELECT extra_perms->$1 as old_write FROM {table}
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
)
UPDATE {kind} SET extra_perms = extra_perms - $1
UPDATE {table} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
))
@@ -335,6 +422,28 @@ async fn remove_granular_acl(
Some(&owner),
)
.await?;
} else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) {
// Mirror the add path: standard audit_log row for every kind that
// doesn't have a dedicated permission-history table.
let access = if write { "write" } else { "read" };
let action = format!("{}.revoke_acl", prefix);
audit_log(
&mut *tx,
&authed,
action.as_str(),
ActionKind::Update,
&w_id,
Some(path),
Some(
[
("kind", kind),
("owner", owner.as_str()),
("access", access),
]
.into(),
),
)
.await?;
}
tx.commit().await?;
@@ -353,46 +462,68 @@ async fn remove_granular_acl(
)
.await?
}
// "app" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
// Some(format!("App '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "script" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Script {
// path: path.to_string(),
// parent_path: None,
// hash: ScriptHash(0),
// },
// Some(format!("Script '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
// "flow" => {
// handle_deployment_metadata(
// &authed.email,
// &authed.username,
// &db,
// &w_id,
// DeployedObject::Flow { path: path.to_string(), parent_path: None },
// Some(format!("Flow '{}' changed permissions", path)),
// // true,
// )
// .await?
// }
"app" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
Some(format!("App '{}' changed permissions", path)),
true,
None,
)
.await?
}
"raw_app" => {
// See add_granular_acl: raw_app must use its own DeployedObject
// variant so git-sync writes `<path>.raw_app.json`.
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::RawApp {
path: path.to_string(),
parent_path: None,
version: 0,
},
Some(format!("Raw App '{}' changed permissions", path)),
true,
None,
)
.await?
}
"script" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Script {
path: path.to_string(),
parent_path: None,
hash: ScriptHash(0),
},
Some(format!("Script '{}' changed permissions", path)),
true,
None,
)
.await?
}
"flow" => {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 },
Some(format!("Flow '{}' changed permissions", path)),
true,
None,
)
.await?
}
_ => (),
}
}
@@ -421,9 +552,13 @@ async fn get_granular_acls(
} else {
"path"
};
// See add_granular_acl: raw_app rows live in the `app` table now, so the
// read path must also target `app` — otherwise GET would return stale or
// 404 state while POST /acls/add and /acls/remove write to `app`.
let table = if kind == "raw_app" { "app" } else { kind };
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
"SELECT extra_perms from {table} WHERE {identifier} = $1 AND workspace_id = $2"
))
.bind(path)
.bind(w_id)
@@ -51,6 +51,7 @@ fn test_authed() -> ApiAuthed {
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
@@ -573,8 +573,10 @@ async fn test_promotion_individual_branch_debounces_per_path(
// Wait for both deployment callbacks to resolve a debounce key. The
// alpha key is polled first as a warm-up, then we also wait for beta
// so the assertions don't race the second spawned callback.
let expected_alpha = "git_sync:script:f/target/alpha";
let expected_beta = "git_sync:script:f/target/beta";
// Keys are namespaced by the repo's resource path so multiple promotion
// repos don't collide on the same key.
let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha";
let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta";
let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?;
let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?;
assert!(
@@ -589,6 +591,162 @@ async fn test_promotion_individual_branch_debounces_per_path(
Ok(())
}
/// Create a second git repository resource for multi-repo tests.
#[allow(dead_code)]
async fn create_second_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
ON CONFLICT (workspace_id, path) DO NOTHING
"#,
)
.bind(json!({
"url": "https://github.com/test/test2.git",
"branch": "main",
"token": "test-token-2"
}))
.execute(db)
.await?;
Ok(())
}
/// Configure git sync with TWO promotion-mode repositories pointing at distinct
/// git repo resources. Both repos use the same sync script and the same item
/// filters — they only differ in the repo they target.
#[allow(dead_code)]
async fn setup_two_promotion_repos_config(
db: &Pool<Postgres>,
sync_script_path: &str,
group_by_folder: bool,
) -> anyhow::Result<()> {
let git_sync_config = json!({
"include_type": ["script"],
"include_path": ["**"],
"repositories": [
{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
"use_individual_branch": true,
"group_by_folder": group_by_folder
},
{
"script_path": sync_script_path,
"git_repo_resource_path": "$res:u/test-user/test_git_repo_2",
"use_individual_branch": true,
"group_by_folder": group_by_folder
}
]
});
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
git_sync_config,
"test-workspace"
)
.execute(db)
.await?;
Ok(())
}
/// Regression test for: when two promotion-mode repos are configured with the
/// same `use_individual_branch=true` settings (i.e. a primary and a secondary
/// promotion repo), deploying a single script must enqueue ONE deployment
/// callback per repo. Both callbacks must remain in the queue — neither may
/// be debounced into oblivion by the other.
///
/// The bug this guards against: the debounce key for promotion mode was
/// derived only from (path_type, path) and omitted any per-repo identifier,
/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and
/// `complete_debounced_job` flagged the first repo's job as `status='skipped'`
/// — silently dropping one of the two pushes.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_two_promotion_repos_both_enqueue_callback(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_folder(&db, "target").await?;
create_git_repo_resource(&db).await?;
create_second_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_two_promotion_repos";
create_sync_script(&db, sync_script_path).await?;
setup_two_promotion_repos_config(&db, sync_script_path, false).await?;
let (client, _port, _server) = init_client(db.clone()).await;
// Deploy a single script — handle_deployment_metadata should iterate
// both repos and create one callback job per repo.
create_test_script(&client, "f/target/alpha").await?;
// Both callbacks should reach the queue. With the bug, only one survives
// (the other is moved to v2_job_completed with status='skipped').
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let mut last_jobs: Vec<DeploymentCallbackJob> = vec![];
loop {
last_jobs =
get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?;
if last_jobs.len() >= 2 {
break;
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Inspect what landed in v2_job_completed so failure messages explain why.
let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as(
r#"
SELECT c.id, c.status::text
FROM v2_job_completed c
JOIN v2_job j ON j.id = c.id
WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback'
"#,
)
.bind(sync_script_path)
.fetch_all(&db)
.await?;
assert_eq!(
last_jobs.len(),
2,
"expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed",
last_jobs.len(),
skipped,
);
// Per-repo args sanity check: the two jobs must target different repos.
let mut repo_paths: Vec<String> = last_jobs
.iter()
.filter_map(|j| {
j.args
.as_ref()
.and_then(|a| a.get("repo_url_resource_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect();
repo_paths.sort();
repo_paths.dedup();
assert_eq!(
repo_paths.len(),
2,
"expected callbacks to target two distinct repos, got: {:?}",
last_jobs.iter().map(|j| &j.args).collect::<Vec<_>>()
);
// No callback should have been silently skipped via debouncing collision.
assert!(
skipped.iter().all(|(_, s)| s != "skipped"),
"no deployment callback should be marked skipped, got: {:?}",
skipped,
);
Ok(())
}
/// Promotion mode with group_by_folder: items destined for the same per-folder
/// branch must share one debounce key so they accumulate into a single sync
/// job; scripts in different folders must get distinct keys.
@@ -615,8 +773,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder(
// One in a different folder — should get its own key.
create_test_script(&client, "f/other/gamma").await?;
let expected_grouped = "git_sync:folder:f/grouped";
let expected_other = "git_sync:folder:f/other";
// Keys are namespaced by the repo's resource path.
let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped";
let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other";
// Wait for BOTH folder keys to appear, not just the first one.
let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?;
assert!(
@@ -629,7 +788,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder(
);
// Paths within the same folder must NOT leak as their own keys.
assert!(
!keys.iter().any(|k| k.starts_with("git_sync:script:")),
!keys
.iter()
.any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")),
"group_by_folder mode should not emit per-path keys, got: {keys:?}"
);
+17 -1
View File
@@ -1218,6 +1218,22 @@ async fn create_script_internal<'c>(
}
};
// Failure, Trigger, and Approval scripts are runnable entrypoints by
// definition. They must never be marked `auto_kind = 'lib'`, or they
// disappear from the flow error-handler / trigger / approval pickers
// (which filter out lib scripts). Strip a stray `lib` here so a parser
// misclassification — e.g. failing to detect `main` after a deno_ast
// bump — cannot orphan these scripts in the UI.
let auto_kind = if matches!(
ns.kind,
Some(ScriptKind::Failure) | Some(ScriptKind::Trigger) | Some(ScriptKind::Approval)
) && auto_kind.as_deref() == Some("lib")
{
None
} else {
auto_kind
};
let ci_test_refs =
windmill_common::schema::parse_ci_test_annotation(&ns.content, &lang.as_comment_lit());
let auto_kind = if ci_test_refs.is_some() {
@@ -2250,7 +2266,7 @@ async fn exists_script_by_path(
let path = path.to_path();
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)",
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)",
path,
w_id
)
+23 -9
View File
@@ -210,6 +210,8 @@ pub struct GlobalUserInfo {
first_time_user: bool,
role_source: String,
disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -297,6 +299,7 @@ pub struct TruncatedToken {
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
pub read_only: bool,
}
// NewToken is re-exported from windmill-api-auth above
@@ -450,13 +453,17 @@ async fn list_users_as_super_admin(
let rows = if active_only.is_some_and(|x| x) {
sqlx::query_as!(
GlobalUserInfo,
"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled
SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id
FROM password
WHERE email IN (SELECT email FROM active_users)
ORDER BY super_admin DESC, devops DESC
LIMIT $1 OFFSET $2",
UNION ALL
SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
FROM usr
WHERE is_service_account IS true
ORDER BY "super_admin!" DESC, "devops!" DESC
LIMIT $1 OFFSET $2"#,
per_page as i32,
offset as i32
)
@@ -465,8 +472,13 @@ async fn list_users_as_super_admin(
} else {
sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
$1 OFFSET $2",
r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password
UNION ALL
SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
FROM usr
WHERE is_service_account IS true
ORDER BY "super_admin!" DESC, "devops!" DESC, "email!"
LIMIT $1 OFFSET $2"#,
per_page as i32,
offset as i32
)
@@ -715,7 +727,7 @@ async fn global_whoami(
) -> JsonResult<GlobalUserInfo> {
let user = sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \
email = $1",
email
)
@@ -739,6 +751,7 @@ async fn global_whoami(
first_time_user: false,
role_source: "manual".to_string(),
disabled: false,
workspace_id: None,
}))
} else {
// Service accounts don't have a password row
@@ -755,6 +768,7 @@ async fn global_whoami(
first_time_user: false,
role_source: "service_account".to_string(),
disabled: false,
workspace_id: None,
}))
}
}
@@ -2249,7 +2263,7 @@ async fn list_tokens(
sqlx::query_as!(
TruncatedToken,
"SELECT label, token_prefix, expiration, created_at, \
last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)
last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
email,
per_page as i64,
@@ -2261,7 +2275,7 @@ async fn list_tokens(
sqlx::query_as!(
TruncatedToken,
"SELECT label, token_prefix, expiration, created_at, \
last_used_at, scopes, workspace_id FROM token WHERE email = $1
last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
email,
per_page as i64,
+14 -3
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.700.2
version: 1.702.1
title: Windmill API
contact:
@@ -22639,10 +22639,13 @@ components:
type: string
workspace_id:
type: string
read_only:
type: boolean
required:
- token_prefix
- created_at
- last_used_at
- read_only
ExternalJwtToken:
type: object
@@ -22691,6 +22694,12 @@ components:
type: string
workspace_id:
type: string
read_only:
type: boolean
description: |
If true, the token is restricted to read-only HTTP methods
(GET/HEAD/OPTIONS). Mutating endpoints and job-run actions are
rejected with 403, regardless of the scopes attached.
NewTokenImpersonate:
type: object
@@ -26367,7 +26376,7 @@ components:
type: string
login_type:
type: string
enum: ["password", "github"]
enum: ["password", "github", "service_account"]
super_admin:
type: boolean
devops:
@@ -26386,9 +26395,11 @@ components:
type: boolean
role_source:
type: string
enum: ["manual", "instance_group"]
enum: ["manual", "instance_group", "service_account"]
disabled:
type: boolean
workspace_id:
type: string
required:
- email
+1
View File
@@ -330,6 +330,7 @@ async fn inject_agent_authed(
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
},
job_id: None,
});
+8 -65
View File
@@ -7,6 +7,7 @@ use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use windmill_common::{db::UserDB, utils::StripPath, DB};
use windmill_mcp::common::schema::enrich_resource_schemas;
use windmill_mcp::common::transform::apply_key_transformation;
use windmill_mcp::common::types::{
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo,
@@ -203,71 +204,13 @@ impl McpBackend for WindmillBackend {
schema_obj.properties.insert(new_key, value);
}
for (_key, prop_value) in schema_obj.properties.iter_mut() {
if let Value::Object(prop_map) = prop_value {
if let Some(format_value) = prop_map.get("format") {
if let Value::String(format_str) = format_value {
if format_str.starts_with("resource-") {
let resource_type_key =
format_str.split("-").last().unwrap_or_default().to_string();
let resource_type = resources_types
.iter()
.find(|rt| rt.name == resource_type_key);
let resource_type_obj = resource_type.cloned();
if let Some(resource_cache) = resources_cache.get(&resource_type_key) {
let resources_count = resource_cache.len();
let description = match resource_type_obj {
Some(resource_type_obj) => format!(
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
resource_type_obj.name,
resource_type_obj.description.as_deref().unwrap_or("No description"),
if resources_count == 0 {
"This resource does not have any available instances, you should create one from your windmill workspace."
} else if resources_count > 1 {
"This resource has multiple available instances, you should precisely select the one you want to use."
} else {
"There is 1 resource available."
}
),
None => "An object parameter.".to_string(),
};
prop_map.insert(
"type".to_string(),
Value::String("string".to_string()),
);
prop_map
.insert("description".to_string(), Value::String(description));
if resources_count > 0 {
let resources_description = resource_cache
.iter()
.map(|resource| {
format!(
"{}: $res:{}",
resource
.description
.as_deref()
.unwrap_or("No title"),
resource.path
)
})
.collect::<Vec<String>>()
.join("\\n");
prop_map.insert(
"description".to_string(),
Value::String(format!(
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
prop_map.get("description").unwrap_or(&Value::String("No description".to_string())),
resources_description
)),
);
}
}
}
}
}
}
// Enrich every resource reference in the schema — including those
// inside `items`, nested `properties`, etc. — with a description
// listing the available resources. Both shapes are handled:
// { type: "object", format: "resource-<name>" } (top-level scalar)
// { type: "resource", resourceType: "<name>" } (inside list items)
for prop_value in schema_obj.properties.values_mut() {
enrich_resource_schemas(prop_value, resources_cache, resources_types);
}
schema_obj
+151 -52
View File
@@ -110,6 +110,12 @@ struct ScriptMetadata {
pub debouncing_settings: DebouncingSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(skip_serializing_if = "is_empty_extra_perms")]
pub extra_perms: serde_json::Value,
}
fn is_empty_extra_perms(value: &serde_json::Value) -> bool {
value.as_object().is_some_and(|o| o.is_empty()) || value.is_null()
}
pub fn is_none_or_false(val: &Option<bool>) -> bool {
@@ -224,12 +230,34 @@ pub(crate) struct ArchiveQueryParams {
default_ts: Option<String>,
/// Settings format version: "v1" (default) returns legacy flat format, "v2" returns grouped format
settings_version: Option<String>,
/// Opt-in: include `extra_perms` on flow / script / app rows. Default `false`
/// so cross-workspace tarball imports do not carry over ACLs referring to
/// identities that may not exist in the target workspace. `wmill sync pull`
/// passes `true` to surface ACLs in the git-tracked yaml.
preserve_extra_perms: Option<bool>,
}
/// How to handle `extra_perms` in the serialized output.
///
/// * `Drop` — strip the field unconditionally (legacy behavior for
/// types that have never carried ACLs in source).
/// * `KeepEvenEmpty` — always keep the field, even when `{}`. Matches the
/// pre-existing serialization for folders and groups so
/// no customer sees a one-time noisy diff on upgrade.
/// * `KeepIfNonEmpty` — keep when there is at least one entry, drop when `{}`
/// or null. New surface for flow / script / app, which
/// never carried ACLs in source before this change.
#[derive(Clone, Copy)]
pub enum ExtraPermsBehavior {
Drop,
KeepEvenEmpty,
KeepIfNonEmpty,
}
#[inline]
pub fn to_string_without_metadata<T>(
value: &T,
preserve_extra_perms: bool,
extra_perms: ExtraPermsBehavior,
ignore_keys: Option<Vec<&str>>,
) -> Result<String>
where
@@ -274,8 +302,19 @@ where
o2.remove("on_behalf_of");
o2.remove("on_behalf_of_email");
}
if !preserve_extra_perms && obj.contains_key("extra_perms") {
obj.remove("extra_perms");
if obj.contains_key("extra_perms") {
let is_empty_extra_perms = obj
.get("extra_perms")
.map(|v| v.as_object().is_some_and(|o| o.is_empty()) || v.is_null())
.unwrap_or(true);
let drop = match extra_perms {
ExtraPermsBehavior::Drop => true,
ExtraPermsBehavior::KeepEvenEmpty => false,
ExtraPermsBehavior::KeepIfNonEmpty => is_empty_extra_perms,
};
if drop {
obj.remove("extra_perms");
}
}
if obj
.get("default_permissioned_as")
@@ -442,6 +481,7 @@ pub(crate) async fn tarball_workspace(
include_workspace_dependencies,
default_ts,
settings_version,
preserve_extra_perms,
}): Query<ArchiveQueryParams>,
) -> Result<([(HeaderName, String); 2], impl IntoResponse)> {
tracing::info!(
@@ -452,6 +492,16 @@ pub(crate) async fn tarball_workspace(
skip_resources
);
// Opt-in behavior for surfacing per-resource ACLs on flow/app rows.
// Folder and group rows have always carried `extra_perms` in source and
// continue to do so unconditionally (`KeepEvenEmpty`) so existing
// customer git repos see no one-time noisy diff.
let new_kinds_extra_perms = if preserve_extra_perms.unwrap_or(false) {
ExtraPermsBehavior::KeepIfNonEmpty
} else {
ExtraPermsBehavior::Drop
};
let mut tx = user_db.begin(&authed).await?;
// Source-of-truth check for fork-ness: the workspace's parent_workspace_id
@@ -498,7 +548,8 @@ pub(crate) async fn tarball_workspace(
for folder in folders {
archive
.write_to_archive(
&to_string_without_metadata(&folder, true, None).unwrap(),
&to_string_without_metadata(&folder, ExtraPermsBehavior::KeepEvenEmpty, None)
.unwrap(),
&format!("f/{}/folder.meta.json", folder.name),
)
.await?;
@@ -506,15 +557,13 @@ pub(crate) async fn tarball_workspace(
}
{
let scripts = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
&format!(
"SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false
let scripts = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(&format!(
"SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false
AND (draft_only IS NULL OR draft_only = false)
AND created_at = (select max(created_at) from script where path = o.path AND \
workspace_id = $1)",
windmill_common::scripts::SCRIPT_COLUMNS,
),
)
windmill_common::scripts::SCRIPT_COLUMNS,
))
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
@@ -587,6 +636,15 @@ pub(crate) async fn tarball_workspace(
on_behalf_of_email: script.on_behalf_of_email,
modules: script.modules,
labels: script.labels,
// Same opt-in contract as flow/app: the tarball only surfaces
// ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the
// `is_empty_extra_perms` skip-serializer drop the field entirely.
extra_perms: if matches!(new_kinds_extra_perms, ExtraPermsBehavior::KeepIfNonEmpty)
{
script.extra_perms
} else {
serde_json::Value::Null
},
};
let metadata_str = serde_json::to_string_pretty(&metadata).unwrap();
archive
@@ -605,7 +663,8 @@ pub(crate) async fn tarball_workspace(
.await?;
for resource in resources {
let resource_str = &to_string_without_metadata(&resource, false, None).unwrap();
let resource_str =
&to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap();
archive
.write_to_archive(&resource_str, &format!("{}.resource.json", resource.path))
.await?;
@@ -622,7 +681,9 @@ pub(crate) async fn tarball_workspace(
.await?;
for resource_type in resource_types {
let resource_str = &to_string_without_metadata(&resource_type, false, None).unwrap();
let resource_str =
&to_string_without_metadata(&resource_type, ExtraPermsBehavior::Drop, None)
.unwrap();
archive
.write_to_archive(
&resource_str,
@@ -644,7 +705,7 @@ pub(crate) async fn tarball_workspace(
.await?;
for flow in flows {
let flow_str = &to_string_without_metadata(&flow, false, None).unwrap();
let flow_str = &to_string_without_metadata(&flow, new_kinds_extra_perms, None).unwrap();
archive
.write_to_archive(&flow_str, &format!("{}.flow.json", flow.path))
.await?;
@@ -673,7 +734,8 @@ pub(crate) async fn tarball_workspace(
Error::internal_err(format!("Error decrypting variable {}: {}", var.path, e))
})?);
}
let var_str = &to_string_without_metadata(&var, false, None).unwrap();
let var_str =
&to_string_without_metadata(&var, ExtraPermsBehavior::Drop, None).unwrap();
archive
.write_to_archive(&var_str, &format!("{}.variable.json", var.path))
.await?;
@@ -693,7 +755,7 @@ pub(crate) async fn tarball_workspace(
.await?;
for app in apps {
let app_str = &to_string_without_metadata(&app, false, None).unwrap();
let app_str = &to_string_without_metadata(&app, new_kinds_extra_perms, None).unwrap();
let kind = if app.raw_app { "raw_app" } else { "app" };
archive
.write_to_archive(&app_str, &format!("{}.{}.json", app.path, kind))
@@ -711,7 +773,7 @@ pub(crate) async fn tarball_workspace(
workspace_dependencies.len()
);
for dep in workspace_dependencies {
// let dep_str = &to_string_without_metadata(&dep, false, None).unwrap();
// let dep_str = &to_string_without_metadata(&dep, ExtraPermsBehavior::Drop, None).unwrap();
let filename = WorkspaceDependencies::to_path(&dep.name, dep.language)?;
tracing::info!(
"Adding workspace dependency: name={:?}, language={:?}, filename={}",
@@ -739,9 +801,12 @@ pub(crate) async fn tarball_workspace(
let schedule_ignore_keys = fork_schedule_ignore_keys(is_fork);
for schedule in schedules {
let app_str =
&to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone())
.unwrap();
let app_str = &to_string_without_metadata(
&schedule,
ExtraPermsBehavior::Drop,
schedule_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path))
.await?;
@@ -777,9 +842,12 @@ pub(crate) async fn tarball_workspace(
let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in http_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -796,9 +864,12 @@ pub(crate) async fn tarball_workspace(
let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in websocket_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -815,9 +886,12 @@ pub(crate) async fn tarball_workspace(
let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in kafka_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -834,9 +908,12 @@ pub(crate) async fn tarball_workspace(
let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in sqs_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -853,9 +930,12 @@ pub(crate) async fn tarball_workspace(
let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in gcp_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -872,9 +952,12 @@ pub(crate) async fn tarball_workspace(
let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in azure_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -891,9 +974,12 @@ pub(crate) async fn tarball_workspace(
let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in nats_triggers {
let trigger_str: &String =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str: &String = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -910,9 +996,12 @@ pub(crate) async fn tarball_workspace(
let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in postgres_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -929,9 +1018,12 @@ pub(crate) async fn tarball_workspace(
let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in mqtt_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -948,9 +1040,12 @@ pub(crate) async fn tarball_workspace(
let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in email_triggers {
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
let trigger_str = &to_string_without_metadata(
&trigger,
ExtraPermsBehavior::Drop,
trigger_ignore_keys.clone(),
)
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -978,7 +1073,7 @@ pub(crate) async fn tarball_workspace(
for trigger in native_triggers {
let trigger_str = &to_string_without_metadata(
&trigger,
false,
ExtraPermsBehavior::Drop,
Some(native_ignore_keys.clone()),
)
.unwrap();
@@ -1021,7 +1116,9 @@ pub(crate) async fn tarball_workspace(
disabled: user.disabled,
email: user.email,
};
let user_str = &to_string_without_metadata(&user, false, Some(vec!["email"])).unwrap();
let user_str =
&to_string_without_metadata(&user, ExtraPermsBehavior::Drop, Some(vec!["email"]))
.unwrap();
archive
.write_to_archive(&user_str, &format!("users/{}.user.json", user.email))
.await?;
@@ -1081,7 +1178,9 @@ pub(crate) async fn tarball_workspace(
admins,
};
let group_str = &to_string_without_metadata(&group, true, None).unwrap();
let group_str =
&to_string_without_metadata(&group, ExtraPermsBehavior::KeepEvenEmpty, None)
.unwrap();
archive
.write_to_archive(&group_str, &format!("groups/{}.group.json", group.name))
.await?;
@@ -616,6 +616,12 @@ pub struct OtelTracingProxySettings {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled_languages: Vec<ScriptLang>,
/// Comma-separated list of host patterns injected as NO_PROXY into jobs so their HTTP
/// clients bypass the local MITM tracing proxy. Independent of the worker's own
/// NO_PROXY env (which governs the proxy's upstream relay). Use this for clients that
/// pin their own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_proxy_hosts: Option<String>,
}
/// Script language identifier (for instance config use).
+431 -17
View File
@@ -2,11 +2,11 @@
//!
//! Contains functions for converting Windmill schemas into MCP-compatible formats.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use serde_json::Value;
use serde_json::{Map, Value};
use super::types::SchemaType;
use super::types::{ResourceInfo, ResourceType, SchemaType};
use windmill_common::scripts::Schema;
/// Convert a Windmill Schema to a SchemaType
@@ -22,38 +22,195 @@ pub fn convert_schema_to_schema_type(schema: Option<Schema>) -> SchemaType {
schema_obj
}
/// Extract resource type keys from a schema
///
/// Scans the schema properties for fields with format "resource-{type}"
/// and returns a set of all unique resource type names found.
pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet<String> {
let mut resource_types = HashSet::new();
for (_key, prop_value) in schema.properties.iter() {
if let Value::Object(prop_map) = prop_value {
if let Some(Value::String(format_str)) = prop_map.get("format") {
if let Some(rt) = format_str.strip_prefix("resource-") {
resource_types.insert(rt.to_string());
}
/// If `node` is a schema describing a Windmill resource reference, return the
/// resource type name. Recognizes both on-disk shapes:
/// - Form A (top-level scalar): `{ type: "object", format: "resource-<name>" }`
/// - Form B (inside `items`): `{ type: "resource", resourceType: "<name>" }`
fn resource_type_of_schema_node(node: &Value) -> Option<String> {
let obj = node.as_object()?;
if let Some(Value::String(fmt)) = obj.get("format") {
if let Some(name) = fmt.strip_prefix("resource-") {
return Some(name.to_string());
}
}
if obj.get("type").and_then(Value::as_str) == Some("resource") {
if let Some(Value::String(name)) = obj.get("resourceType") {
return Some(name.clone());
}
}
None
}
/// Recursively collect resource type names referenced at any depth in `node`.
fn collect_resource_types(node: &Value, out: &mut HashSet<String>) {
if let Some(rt) = resource_type_of_schema_node(node) {
out.insert(rt);
}
let Some(obj) = node.as_object() else { return };
if let Some(Value::Object(props)) = obj.get("properties") {
for v in props.values() {
collect_resource_types(v, out);
}
}
if let Some(items) = obj.get("items") {
collect_resource_types(items, out);
}
if let Some(additional) = obj.get("additionalProperties") {
if additional.is_object() {
collect_resource_types(additional, out);
}
}
for kw in ["allOf", "oneOf", "anyOf"] {
if let Some(Value::Array(arr)) = obj.get(kw) {
for s in arr {
collect_resource_types(s, out);
}
}
}
}
/// Extract resource type keys referenced anywhere in a schema (top-level
/// properties, nested objects, array items, additionalProperties, and
/// allOf/oneOf/anyOf subschemas). Recognizes both the `format: resource-<name>`
/// and `type: resource` + `resourceType` shapes.
pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet<String> {
let mut resource_types = HashSet::new();
for prop_value in schema.properties.values() {
collect_resource_types(prop_value, &mut resource_types);
}
resource_types
}
/// Rewrite a single schema node that points to a Windmill resource: set
/// `type: "string"` and inject a description listing the available resources.
/// Mirrors the top-level behavior that used to live in
/// `transform_schema_for_resources`. No-op if the resource type isn't in the
/// pre-fetched cache.
fn apply_resource_enrichment(
prop_map: &mut Map<String, Value>,
resource_type_key: &str,
resources_cache: &HashMap<String, Vec<ResourceInfo>>,
resources_types: &[ResourceType],
) {
let Some(resource_cache) = resources_cache.get(resource_type_key) else {
return;
};
let resource_type = resources_types
.iter()
.find(|rt| rt.name == resource_type_key);
let resources_count = resource_cache.len();
let description = match resource_type {
Some(rt) => format!(
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
rt.name,
rt.description.as_deref().unwrap_or("No description"),
if resources_count == 0 {
"This resource does not have any available instances, you should create one from your windmill workspace."
} else if resources_count > 1 {
"This resource has multiple available instances, you should precisely select the one you want to use."
} else {
"There is 1 resource available."
}
),
None => "An object parameter.".to_string(),
};
prop_map.insert("type".to_string(), Value::String("string".to_string()));
prop_map.insert("description".to_string(), Value::String(description));
// Drop the Windmill-internal keys we just consumed so the node is clean
// regardless of whether `make_schema_compatible` runs after us. (Its strip
// only fires while `type == "resource"`, which is no longer true here.)
prop_map.remove("resourceType");
if prop_map
.get("format")
.and_then(Value::as_str)
.is_some_and(|s| s.starts_with("resource-"))
{
prop_map.remove("format");
}
if resources_count > 0 {
let resources_description = resource_cache
.iter()
.map(|resource| {
format!(
"{}: $res:{}",
resource.description.as_deref().unwrap_or("No title"),
resource.path
)
})
.collect::<Vec<String>>()
.join("\\n");
let prior_description = prop_map
.get("description")
.and_then(Value::as_str)
.unwrap_or("No description")
.to_string();
prop_map.insert(
"description".to_string(),
Value::String(format!(
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
prior_description, resources_description
)),
);
}
}
/// Walk a schema and enrich every Windmill-resource reference (in either shape,
/// at any nesting depth) with `type: "string"` and a description listing
/// available resources. The non-standard keys (`format: resource-*`,
/// `resourceType`) consumed by the enrichment are stripped in place.
pub fn enrich_resource_schemas(
node: &mut Value,
resources_cache: &HashMap<String, Vec<ResourceInfo>>,
resources_types: &[ResourceType],
) {
if let Some(rt_key) = resource_type_of_schema_node(node) {
if let Value::Object(obj) = node {
apply_resource_enrichment(obj, &rt_key, resources_cache, resources_types);
}
}
let Some(obj) = node.as_object_mut() else {
return;
};
if let Some(Value::Object(props)) = obj.get_mut("properties") {
for v in props.values_mut() {
enrich_resource_schemas(v, resources_cache, resources_types);
}
}
if let Some(items) = obj.get_mut("items") {
enrich_resource_schemas(items, resources_cache, resources_types);
}
if let Some(additional) = obj.get_mut("additionalProperties") {
if additional.is_object() {
enrich_resource_schemas(additional, resources_cache, resources_types);
}
}
for kw in ["allOf", "oneOf", "anyOf"] {
if let Some(Value::Array(arr)) = obj.get_mut(kw) {
for s in arr.iter_mut() {
enrich_resource_schemas(s, resources_cache, resources_types);
}
}
}
}
/// Transform a JSON schema for maximum MCP client compatibility.
///
/// Ensures schemas conform to JSON Schema draft 2020-12 by:
/// - Converting `integer` type to `number` (some clients don't support integer)
/// - Removing invalid non-array `enum` values
/// - Stripping non-standard keywords (`originalType`, `format` with `resource-*` prefix)
/// - Rewriting the Windmill pseudo-type `type: "resource"` to `type: "string"`
/// - Fixing contradictory schemas (`type: "string"` with `properties` → `type: "object"`)
/// - Removing `default: null` when the type doesn't include `null`
/// - Adding `type: "object"` to empty schemas that have no type
pub fn make_schema_compatible(schema: &mut Value) {
let Value::Object(obj) = schema else { return };
// 1. Strip non-standard keywords that aren't part of JSON Schema
// 1. Strip non-standard keywords that aren't part of JSON Schema. The
// Windmill-internal `resourceType` is dropped unconditionally so it can't
// leak through even on enrichment cache-miss paths.
obj.remove("originalType");
obj.remove("resourceType");
// 2. Strip non-standard format values (resource-* is Windmill-internal)
if obj
@@ -64,6 +221,14 @@ pub fn make_schema_compatible(schema: &mut Value) {
obj.remove("format");
}
// 2b. Rewrite Windmill pseudo-type `resource` to `string`. The parser emits
// this shape (with a sibling `resourceType` key) for `list[ResourceType]`
// params; "resource" is not in the JSON Schema 2020-12 type enum and is
// rejected by strict validators (e.g. Anthropic's tool registration).
if obj.get("type").and_then(|v| v.as_str()) == Some("resource") {
obj.insert("type".to_string(), Value::String("string".to_string()));
}
// 3. Fix contradictory type: if `properties` is present, type must be "object"
if obj.contains_key("properties") {
match obj.get("type").and_then(|v| v.as_str()) {
@@ -151,8 +316,27 @@ pub fn make_schema_compatible(schema: &mut Value) {
#[cfg(test)]
mod tests {
use super::make_schema_compatible;
use super::*;
use crate::common::types::{ResourceInfo, ResourceType};
use serde_json::json;
use std::collections::HashMap;
fn aws_resources() -> (HashMap<String, Vec<ResourceInfo>>, Vec<ResourceType>) {
let mut cache = HashMap::new();
cache.insert(
"c_aws_account".to_string(),
vec![ResourceInfo {
path: "f/platform/aws_dev".to_string(),
description: Some("Dev account".to_string()),
resource_type: "c_aws_account".to_string(),
}],
);
let types = vec![ResourceType {
name: "c_aws_account".to_string(),
description: Some("AWS account".to_string()),
}];
(cache, types)
}
#[test]
fn converts_nested_integer_types() {
@@ -356,6 +540,96 @@ mod tests {
assert_eq!(schema, json!({}));
}
#[test]
fn rewrites_resource_pseudo_type_to_string() {
let mut schema = json!({
"type": "resource",
"resourceType": "c_aws_account"
});
make_schema_compatible(&mut schema);
assert_eq!(schema["type"], json!("string"));
assert!(schema.get("resourceType").is_none());
}
#[test]
fn rewrites_resource_pseudo_type_inside_array_items() {
// Phocas repro: list[ResourceType] parameter. Anthropic rejected this
// with "tools.<N>.custom.input_schema: JSON schema is invalid" because
// "resource" is not in the draft 2020-12 type enum.
let mut schema = json!({
"type": "object",
"properties": {
"accounts": {
"type": "array",
"items": {
"type": "resource",
"resourceType": "c_aws_account"
}
}
}
});
make_schema_compatible(&mut schema);
assert_eq!(
schema["properties"]["accounts"]["items"]["type"],
json!("string")
);
assert!(schema["properties"]["accounts"]["items"]
.get("resourceType")
.is_none());
}
#[test]
fn rewrites_resource_pseudo_type_inside_nested_properties() {
let mut schema = json!({
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"db": {
"type": "resource",
"resourceType": "postgresql"
}
}
}
}
});
make_schema_compatible(&mut schema);
assert_eq!(
schema["properties"]["config"]["properties"]["db"]["type"],
json!("string")
);
assert!(schema["properties"]["config"]["properties"]["db"]
.get("resourceType")
.is_none());
}
#[test]
fn strips_nested_resource_format_inside_array_items() {
let mut schema = json!({
"type": "object",
"properties": {
"dbs": {
"type": "array",
"items": {
"type": "object",
"format": "resource-postgresql"
}
}
}
});
make_schema_compatible(&mut schema);
assert!(schema["properties"]["dbs"]["items"].get("format").is_none());
}
#[test]
fn adds_type_to_schema_with_properties_but_no_type() {
let mut schema = json!({
@@ -368,4 +642,144 @@ mod tests {
assert_eq!(schema["type"], json!("object"));
}
#[test]
fn extract_resource_types_top_level_form_a() {
let schema: SchemaType = serde_json::from_value(json!({
"type": "object",
"properties": {
"db": { "type": "object", "format": "resource-postgresql" }
},
"required": []
}))
.unwrap();
let types = extract_resource_types_from_schema(&schema);
assert!(types.contains("postgresql"));
assert_eq!(types.len(), 1);
}
#[test]
fn extract_resource_types_inside_array_items_form_b() {
let schema: SchemaType = serde_json::from_value(json!({
"type": "object",
"properties": {
"accounts": {
"type": "array",
"items": { "type": "resource", "resourceType": "c_aws_account" }
}
},
"required": []
}))
.unwrap();
let types = extract_resource_types_from_schema(&schema);
assert!(types.contains("c_aws_account"));
}
#[test]
fn extract_resource_types_inside_nested_properties_and_one_of() {
let schema: SchemaType = serde_json::from_value(json!({
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"db": { "type": "resource", "resourceType": "postgresql" }
}
},
"either": {
"oneOf": [
{ "type": "object", "format": "resource-mysql" },
{ "type": "string" }
]
}
},
"required": []
}))
.unwrap();
let types = extract_resource_types_from_schema(&schema);
assert!(types.contains("postgresql"));
assert!(types.contains("mysql"));
}
#[test]
fn enrich_top_level_form_a_resource() {
let (cache, types) = aws_resources();
let mut node = json!({
"type": "object",
"format": "resource-c_aws_account"
});
enrich_resource_schemas(&mut node, &cache, &types);
assert_eq!(node["type"], json!("string"));
assert!(node.get("format").is_none());
let desc = node["description"].as_str().unwrap();
assert!(desc.contains("c_aws_account"));
assert!(desc.contains("$res:f/platform/aws_dev"));
}
#[test]
fn enrich_inside_array_items_form_b() {
// Phocas repro: items use the parser-style `type: resource` form.
let (cache, types) = aws_resources();
let mut node = json!({
"type": "array",
"items": { "type": "resource", "resourceType": "c_aws_account" }
});
enrich_resource_schemas(&mut node, &cache, &types);
// The items schema should be rewritten to string with a description
// listing the available resources, and the Windmill-internal
// resourceType key should be stripped.
assert_eq!(node["items"]["type"], json!("string"));
assert!(node["items"].get("resourceType").is_none());
let desc = node["items"]["description"].as_str().unwrap();
assert!(desc.contains("$res:f/platform/aws_dev"));
}
#[test]
fn enrich_is_noop_when_resource_type_not_in_cache() {
let mut node = json!({
"type": "object",
"format": "resource-unknown_type"
});
let before = node.clone();
enrich_resource_schemas(&mut node, &HashMap::new(), &[]);
assert_eq!(node, before);
}
#[test]
fn enrich_deeply_nested_resource() {
let (cache, types) = aws_resources();
let mut node = json!({
"type": "object",
"properties": {
"outer": {
"type": "object",
"properties": {
"inner": {
"type": "array",
"items": {
"type": "resource",
"resourceType": "c_aws_account"
}
}
}
}
}
});
enrich_resource_schemas(&mut node, &cache, &types);
assert_eq!(
node["properties"]["outer"]["properties"]["inner"]["items"]["type"],
json!("string")
);
}
}
@@ -33,6 +33,13 @@ pub trait McpAuth: Send + Sync + Clone + 'static {
/// Get token scopes
fn scopes(&self) -> Option<&[String]>;
/// True if the token was created with the `read_only` flag.
/// When set, write-capable tools must be hidden from `list_tools` and
/// rejected by `call_tool`. Defaults to false so existing impls compile.
fn read_only(&self) -> bool {
false
}
/// Check if the user has an MCP scope
fn has_mcp_scope(&self) -> bool {
self.scopes()
@@ -26,6 +26,12 @@ pub struct EndpointTool {
pub body_field_renames: Option<serde_json::Value>,
}
/// True if this endpoint is safe to expose to a read-only token. Mirrors the
/// `read_only_hint` computed by `create_endpoint_annotations`: only `GET`.
pub fn is_endpoint_read_only(tool: &EndpointTool) -> bool {
tool.method.as_ref() == "GET"
}
/// Convert a single endpoint tool to MCP tool
pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
let mut combined_properties = serde_json::Map::new();
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod tools;
// Re-export main types
pub use backend::{BackendResult, McpAuth, McpBackend};
pub use endpoints::{endpoint_tool_to_mcp_tool, EndpointTool};
pub use endpoints::{endpoint_tool_to_mcp_tool, is_endpoint_read_only, EndpointTool};
pub use runner::Runner;
pub use tools::create_tool_from_item;
+114 -83
View File
@@ -145,94 +145,100 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
let favorites_only = scope_config.favorites;
// Fetch all items concurrently
let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!(
self.backend
.list_scripts(&auth, &workspace_id, favorites_only, None),
self.backend
.list_flows(&auth, &workspace_id, favorites_only, None),
self.backend.list_resource_types(&auth, &workspace_id),
async {
if let Some(ref apps) = scope_config.hub_apps {
self.backend.list_hub_scripts(Some(apps)).await
} else {
Ok(vec![])
}
}
)?;
// Filter items based on scope
let filtered_scripts: Vec<_> = scripts
.into_iter()
.filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path))
.collect();
let filtered_flows: Vec<_> = flows
.into_iter()
.filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path))
.collect();
// Collect all needed resource types from all schemas
let mut needed_resource_types: HashSet<String> = HashSet::new();
for script in &filtered_scripts {
needed_resource_types.extend(extract_resource_types_from_schema(&script.get_schema()));
}
for flow in &filtered_flows {
needed_resource_types.extend(extract_resource_types_from_schema(&flow.get_schema()));
}
for hub_script in &hub_scripts {
needed_resource_types
.extend(extract_resource_types_from_schema(&hub_script.get_schema()));
}
// Pre-fetch all resources
let resource_futures: Vec<_> = needed_resource_types
.into_iter()
.map(|rt| {
let backend = self.backend.clone();
let auth = auth.clone();
let workspace_id = workspace_id.clone();
async move {
backend
.list_resources(&auth, &workspace_id, &rt)
.await
.map(|resources| (rt, resources))
}
})
.collect();
let resource_results = futures::future::try_join_all(resource_futures).await?;
let resources_cache: HashMap<String, Vec<ResourceInfo>> =
resource_results.into_iter().collect();
let read_only = auth.read_only();
let mut tools = Vec::new();
for script in &filtered_scripts {
tools.push(create_tool_from_item(
script,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
}
// Read-only tokens cannot run scripts/flows/hub-scripts (running is a
// mutating action), so skip the script/flow/hub/resource fetches
// entirely — they would only be discarded below.
if !read_only {
let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!(
self.backend
.list_scripts(&auth, &workspace_id, favorites_only, None),
self.backend
.list_flows(&auth, &workspace_id, favorites_only, None),
self.backend.list_resource_types(&auth, &workspace_id),
async {
if let Some(ref apps) = scope_config.hub_apps {
self.backend.list_hub_scripts(Some(apps)).await
} else {
Ok(vec![])
}
}
)?;
for flow in &filtered_flows {
tools.push(create_tool_from_item(
flow,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
}
let filtered_scripts: Vec<_> = scripts
.into_iter()
.filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path))
.collect();
for hub_script in &hub_scripts {
tools.push(create_tool_from_item(
hub_script,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
let filtered_flows: Vec<_> = flows
.into_iter()
.filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path))
.collect();
// Collect all needed resource types from all schemas
let mut needed_resource_types: HashSet<String> = HashSet::new();
for script in &filtered_scripts {
needed_resource_types
.extend(extract_resource_types_from_schema(&script.get_schema()));
}
for flow in &filtered_flows {
needed_resource_types
.extend(extract_resource_types_from_schema(&flow.get_schema()));
}
for hub_script in &hub_scripts {
needed_resource_types
.extend(extract_resource_types_from_schema(&hub_script.get_schema()));
}
// Pre-fetch all resources
let resource_futures: Vec<_> = needed_resource_types
.into_iter()
.map(|rt| {
let backend = self.backend.clone();
let auth = auth.clone();
let workspace_id = workspace_id.clone();
async move {
backend
.list_resources(&auth, &workspace_id, &rt)
.await
.map(|resources| (rt, resources))
}
})
.collect();
let resource_results = futures::future::try_join_all(resource_futures).await?;
let resources_cache: HashMap<String, Vec<ResourceInfo>> =
resource_results.into_iter().collect();
for script in &filtered_scripts {
tools.push(create_tool_from_item(
script,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
}
for flow in &filtered_flows {
tools.push(create_tool_from_item(
flow,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
}
for hub_script in &hub_scripts {
tools.push(create_tool_from_item(
hub_script,
self.backend.as_ref(),
&resources_cache,
&resource_types,
));
}
}
// Add endpoint tools from the generated MCP tools, filtered by scope
@@ -241,6 +247,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) {
continue;
}
if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) {
continue;
}
tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool));
}
@@ -259,6 +268,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
let scopes = auth.scopes().unwrap_or(&[]);
let scope_config =
parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?;
let read_only = auth.read_only();
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
@@ -278,6 +288,15 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
None,
));
}
if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) {
return Err(ErrorData::internal_error(
format!(
"Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations",
endpoint_tool.name
),
None,
));
}
// This is an endpoint tool, call via backend
let result = self
@@ -294,6 +313,18 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
}
}
// Anything below this point runs a script or flow, which is a mutating
// action and must be denied for read-only tokens.
if read_only {
return Err(ErrorData::internal_error(
format!(
"Access denied: tool '{}' runs a script/flow and this token is restricted to read-only operations",
request.name
),
None,
));
}
// Resolve the tool name to (type, path, is_hub)
let (type_str, is_hub, is_hashed) = parse_tool_prefix(&request.name).map_err(|e| {
ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None)
@@ -94,6 +94,7 @@ async fn new_webhook_token(
None,
Some(scopes),
Some(workspace_id.to_owned()),
None,
);
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
@@ -0,0 +1,206 @@
//! End-to-end coverage for the EE HTTP-tracing path on nativets.
//!
//! Pairs with `otel_init.rs` (which pins only the `OTEL_GLOBALS`
//! population contract). This test exercises the full chain that
//! actually delivers a span to a collector:
//!
//! `deno_telemetry::init` with EE config (Rust)
//! → `globalThis.__bootstrapOtel()` (JS, flips TRACING_ENABLED)
//! → user `fetch()` → deno_fetch's `builtinTracer().startSpan`
//! → `BatchSpanProcessor` → `HttpExporter` (OTLP/HTTP-binary)
//! → our mock OTLP listener captures the request bytes
//!
//! Without the v1.702.0 fix (#573 EE / #9163 OSS), the third arrow
//! panics in a tokio worker. With the fix in place, the span is
//! emitted and shows up at the listener — which is what the customer
//! is paying for when they enable HTTP tracing on nativets.
//!
//! `#[ignore]`'d: spins a V8 isolate (~seconds) and binds two TCP
//! listeners. Run with `cargo test -p windmill-runtime-nativets
//! --test otel_e2e -- --ignored`.
//!
//! Owns its own test binary so `OTEL_GLOBALS`'s `OnceCell` doesn't
//! race with `otel_init.rs`.
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use windmill_runtime_nativets::{deno_telemetry, transpile_ts, NativeAnnotation, PrewarmedIsolate};
/// Bind 127.0.0.1:0 and spawn an accept loop. Each connection is
/// read until idle/EOF, the body is appended to `captured`, then we
/// respond with HTTP 200. Returns the bound port.
async fn spawn_capturing_http(captured: Arc<Mutex<Vec<Vec<u8>>>>) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let captured = captured.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
let _ = tokio::time::timeout(Duration::from_millis(300), async {
loop {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
}
})
.await;
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
captured.lock().await.push(buf);
});
}
});
port
}
/// Initialize `deno_telemetry` with the exact `OtelConfig` shape that
/// the EE `load_internal_otel_exporter` ships in production. Keeps
/// this test in lockstep with the actual call site: if production
/// drifts away from `tracing_enabled + Capture`, this test breaks
/// before the customer-facing panic does.
fn init_with_ee_otel_config() {
deno_telemetry::init(
deno_telemetry::OtelRuntimeConfig {
runtime_name: "windmill-nativets".into(),
runtime_version: "0".into(),
},
deno_telemetry::OtelConfig {
tracing_enabled: true,
console: deno_telemetry::OtelConsoleConfig::Capture,
..Default::default()
},
)
.expect("deno_telemetry init failed");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spins V8 + tcp listeners; run with --ignored"]
async fn fetch_after_init_otel_emits_span_to_collector() {
let _ = rustls::crypto::ring::default_provider().install_default();
// 1. Stand up two listeners: one that pretends to be the user's
// fetch target, one that pretends to be the OTLP collector.
let fetch_hits: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let otlp_hits: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let fetch_port = spawn_capturing_http(fetch_hits.clone()).await;
let otlp_port = spawn_capturing_http(otlp_hits.clone()).await;
// 2. Point the deno_telemetry exporter at the mock collector and
// initialize. Mirrors `load_internal_otel_exporter` in EE.
//
// SAFETY: test runs in its own process binary; no other thread
// reads OTEL_EXPORTER_OTLP_ENDPOINT before init returns.
unsafe {
std::env::set_var(
"OTEL_EXPORTER_OTLP_ENDPOINT",
format!("http://127.0.0.1:{otlp_port}"),
);
}
init_with_ee_otel_config();
assert!(
deno_telemetry::OTEL_GLOBALS.get().is_some(),
"OTEL_GLOBALS missing — load_internal_otel_exporter's config regressed?"
);
// 3. Run user TS that bootstraps OTel and then issues a fetch
// at the mock target. `__bootstrapOtel` is fire-and-forget
// (resolves a dynamic import on the microtask queue); the
// `setTimeout` loop yields a few times so the import resolves
// and the `TRACING_ENABLED` flag is set before fetch runs.
let ts = format!(
r#"
declare const globalThis: any;
export async function main(): Promise<number> {{
globalThis.__bootstrapOtel();
// Yield multiple microtask + timer turns so the dynamic import
// in __bootstrapOtel resolves and TRACING_ENABLED flips before
// fetch runs (otherwise deno_fetch skips the span entirely).
for (let i = 0; i < 5; i++) {{
await new Promise<void>(r => setTimeout(r, 10));
}}
const resp = await fetch("http://127.0.0.1:{fetch_port}/probe");
return resp.status;
}}
"#
);
let js = transpile_ts(ts).expect("transpile failed");
let ann = NativeAnnotation { useragent: None, proxy: None };
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, vec![], None);
iso.wait_ready().await.expect("isolate failed to pre-warm");
let res = iso
.start_execution("{}".to_string())
.wait()
.await
.expect("isolate panicked");
// The exact bug: pre-fix, fetch panics this isolate. Post-fix,
// we get back the mock target's 200.
let raw = res.result.expect("user script returned an error");
assert_eq!(raw.get(), "200", "fetch should return mock target status");
assert_eq!(
fetch_hits.lock().await.len(),
1,
"fetch target should have been hit exactly once"
);
// 4. Force the BatchSpanProcessor to flush so the exporter posts
// to our mock collector synchronously (default flush interval
// is ~5s; tests can't wait that long).
deno_telemetry::flush();
// Exporter is async over the OTel runtime; give it a beat to
// actually send the HTTP request.
tokio::time::sleep(Duration::from_millis(500)).await;
let otlp_captured = otlp_hits.lock().await;
assert!(
!otlp_captured.is_empty(),
"OTLP collector should have received at least one export — \
spans aren't reaching the collector after init"
);
// Verify the export carries our fetch span. OTLP is protobuf, so
// grep the raw bytes for OTel HTTP semantic-convention markers
// that deno_fetch's auto-instrumentation attaches:
// - the target URL ("url.full" attribute)
// - "http.request.method" attribute
let combined: Vec<u8> = otlp_captured.iter().flatten().copied().collect();
let bytes_contain = |needle: &[u8]| combined.windows(needle.len()).any(|w| w == needle);
let url_marker = format!("http://127.0.0.1:{fetch_port}/probe");
let has_url = bytes_contain(url_marker.as_bytes());
let has_method_attr = bytes_contain(b"http.request.method");
if !(has_url && has_method_attr) {
eprintln!(
"OTLP bytes ({}): {:?}",
combined.len(),
String::from_utf8_lossy(&combined)
);
}
assert!(
has_url,
"exported OTLP body should reference the fetched URL ({}); got {} bytes",
url_marker,
combined.len()
);
assert!(
has_method_attr,
"exported OTLP body should carry HTTP semconv attributes (http.request.method); got {} bytes",
combined.len()
);
}
@@ -0,0 +1,88 @@
//! Regression test for the deno_telemetry 0.31 nativets-fetch panic.
//!
//! Setup: when EE's `load_internal_otel_exporter` runs (HTTP tracing
//! enabled), it calls `deno_telemetry::init` and then flips
//! `DENO_OTEL_INITIALIZED=true` so `js_eval` will run
//! `globalThis.__bootstrapOtel()` inside the nativets JsRuntime. That
//! bootstrap unconditionally sets JS-side `TRACING_ENABLED=true`, which
//! makes `deno_fetch`'s `fetch()` call `builtinTracer().startSpan(...)`
//! — and `OtelTracer::builtin()` does `OTEL_GLOBALS.get().unwrap()`.
//!
//! In deno_telemetry 0.31, `init` was given an early-return guard: if
//! `tracing_enabled`, `metrics_enabled`, and `console` are all
//! off/Ignore, it returns `Ok(())` *without populating OTEL_GLOBALS*.
//! v1.700.0 was passing `OtelConfig::default()` (all off) — so the JS
//! bootstrap proceeded but the Rust-side OnceCell was empty, and the
//! first `fetch()` panicked the tokio worker in a context that cannot
//! unwind. Fixed in v1.702.0 by passing `tracing_enabled: true` +
//! `console: Capture` from `load_internal_otel_exporter` (PRs #573 EE
//! / #9163 OSS), matching the JS bootstrap shape `[1, 0, 1, 0]`.
//!
//! This test pins that contract directly against `deno_telemetry::
//! init` — the function the EE call site invokes — so the next time
//! the dep is bumped and someone is tempted to "simplify" the config
//! back to `OtelConfig::default()`, CI catches it.
//!
//! Lives in `tests/` (not `src/`) so it gets its own test binary and
//! its own process — `OTEL_GLOBALS` is a `OnceCell`, so sharing it
//! with the smoke tests in `src/smoke_tests.rs` would race.
use windmill_runtime_nativets::deno_telemetry;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ee_otel_init_config_populates_otel_globals() {
// deno_telemetry::init builds an HttpExporter (uses rustls) — it
// needs a process-wide CryptoProvider, which the real binary
// installs in setup_deno_runtime / main. Mirror that here.
let _ = rustls::crypto::ring::default_provider().install_default();
// Pre-condition: nothing else in this test binary has touched the
// OnceCell, so it must be empty.
assert!(
deno_telemetry::OTEL_GLOBALS.get().is_none(),
"OTEL_GLOBALS must start empty in a fresh test process"
);
// Step 1: reproduce the footgun. `OtelConfig::default()` has
// tracing/metrics off and `console = Ignore`, which trips the
// 0.31 early-return — Ok(()) but OnceCell stays empty.
deno_telemetry::init(
deno_telemetry::OtelRuntimeConfig {
runtime_name: "windmill-nativets-test".into(),
runtime_version: "0".into(),
},
deno_telemetry::OtelConfig::default(),
)
.expect("init with default config returns Ok (early-return)");
assert!(
deno_telemetry::OTEL_GLOBALS.get().is_none(),
"deno_telemetry 0.31 contract: init with all-disabled config \
must NOT populate OTEL_GLOBALS — if this changes on a future \
bump, load_internal_otel_exporter can drop the explicit \
tracing_enabled/console fields."
);
// Step 2: re-call init with the exact config shape that the EE
// `load_internal_otel_exporter` ships — this is what production
// hits, so the test exercises the actual prod call path.
deno_telemetry::init(
deno_telemetry::OtelRuntimeConfig {
runtime_name: "windmill-nativets".into(),
runtime_version: "0".into(),
},
deno_telemetry::OtelConfig {
tracing_enabled: true,
console: deno_telemetry::OtelConsoleConfig::Capture,
..Default::default()
},
)
.expect("init with tracing_enabled config must succeed on a fresh OnceCell");
assert!(
deno_telemetry::OTEL_GLOBALS.get().is_some(),
"load_internal_otel_exporter's OtelConfig must populate \
OTEL_GLOBALS so OtelTracer::builtin() does not panic on \
.unwrap() once __bootstrapOtel flips JS-side TRACING_ENABLED"
);
}
@@ -2172,6 +2172,7 @@ try {{
"--",
&BUN_PATH,
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",
@@ -2238,6 +2239,7 @@ try {{
} else {
vec![
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",
@@ -3895,6 +3897,7 @@ pub async fn start_worker(
common_bun_proc_envs,
vec![
"run",
"--preserve-symlinks",
"-i",
"--prefer-offline",
"-r",
+11 -3
View File
@@ -841,7 +841,11 @@ def to_b_64(v: bytes):
b64 = base64.b64encode(v)
return b64.decode('ascii')
replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\*\\u0000|Infinity|\-Infinity)')
_u=re.compile(r'\\\\|\\u0000')
_us=lambda m:' null ' if m.group(0)[1]=='u' else m.group(0)
_r=lambda m,s='':(_u.sub(_us,s) if '\\u0000' in s else s) if (s:=m.group(0))[0]=='"' else ' null '
replace_invalid_fields=re.compile(r'"(?:\\.|[^"\\])*"|\bNaN\b|-?Infinity')
_fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s else re.sub(replace_invalid_fields,_r,s)
result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.json")
@@ -1367,7 +1371,11 @@ def to_b_64(v: bytes):
b64 = base64.b64encode(v)
return b64.decode('ascii')
replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)')
_u=re.compile(r'\\\\|\\u0000')
_us=lambda m:' null ' if m.group(0)[1]=='u' else m.group(0)
_r=lambda m,s='':(_u.sub(_us,s) if '\\u0000' in s else s) if (s:=m.group(0))[0]=='"' else ' null '
replace_invalid_fields=re.compile(r'"(?:\\.|[^"\\])*"|\bNaN\b|-?Infinity')
_fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s else re.sub(replace_invalid_fields,_r,s)
def res_to_json(res, typ):
{res_to_json_body}
@@ -2951,7 +2959,7 @@ fn get_result_postprocessor<'a>(skip: bool) -> &'a str {
if skip {
"unprocessed"
} else {
"re.sub(replace_invalid_fields, ' null ', unprocessed)"
"_fix(unprocessed)"
}
}
+69 -2
View File
@@ -277,6 +277,8 @@ pub struct OtelTracingProxySettings {
pub enabled: bool,
#[serde(default)]
pub enabled_languages: HashSet<ScriptLang>,
#[serde(default)]
pub no_proxy_hosts: Option<String>,
}
#[cfg(feature = "prometheus")]
@@ -969,14 +971,15 @@ async fn get_otel_tracing_proxy_envs(
}
};
let proxy_url = format!("http://127.0.0.1:{}", port);
let no_proxy = build_tracing_proxy_no_proxy().await;
Ok(vec![
("HTTP_PROXY", proxy_url.clone()),
("HTTPS_PROXY", proxy_url.clone()),
// Lowercase variants for Ruby and other runtimes that check lowercase first
("http_proxy", proxy_url.clone()),
("https_proxy", proxy_url),
("NO_PROXY", "".to_string()),
("no_proxy", "".to_string()),
("NO_PROXY", no_proxy.clone()),
("no_proxy", no_proxy),
// CA cert for various runtimes to trust the tracing proxy
("SSL_CERT_FILE", TRACING_PROXY_CA_CERT_PATH.to_string()),
("REQUESTS_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()),
@@ -990,6 +993,70 @@ async fn get_otel_tracing_proxy_envs(
])
}
/// NO_PROXY value injected into jobs so their HTTP clients bypass the local MITM proxy for
/// the configured hosts. This is distinct from the worker's own NO_PROXY env, which governs
/// what the MITM proxy bypasses when relaying upstream (e.g. through a corporate proxy) and
/// is honored automatically by the in-process MITM. The configured hosts are tunneled
/// through the proxy without TLS interception, so clients that pin their own CA (kubectl,
/// helm, terraform, etc.) keep working. Empty when unset, matching the prior behavior of
/// intercepting all destinations including loopback.
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn build_tracing_proxy_no_proxy() -> String {
let configured = OTEL_TRACING_PROXY_SETTINGS
.read()
.await
.no_proxy_hosts
.clone();
normalize_no_proxy_hosts(configured.as_deref())
}
/// Split a comma-separated NO_PROXY value, trim whitespace, drop empty entries, and
/// deduplicate while preserving order. `None` returns an empty string.
#[cfg(all(feature = "private", feature = "enterprise"))]
fn normalize_no_proxy_hosts(configured: Option<&str>) -> String {
let Some(configured) = configured else {
return String::new();
};
let mut seen = std::collections::HashSet::new();
let mut out: Vec<&str> = Vec::new();
for entry in configured.split(',') {
let trimmed = entry.trim();
if !trimmed.is_empty() && seen.insert(trimmed) {
out.push(trimmed);
}
}
out.join(",")
}
#[cfg(all(test, feature = "private", feature = "enterprise"))]
mod no_proxy_tests {
use super::normalize_no_proxy_hosts;
#[test]
fn unset_returns_empty() {
assert_eq!(normalize_no_proxy_hosts(None), "");
}
#[test]
fn empty_and_whitespace_only_returns_empty() {
assert_eq!(normalize_no_proxy_hosts(Some("")), "");
assert_eq!(normalize_no_proxy_hosts(Some(" , ,\t")), "");
}
#[test]
fn trims_and_skips_empties() {
assert_eq!(
normalize_no_proxy_hosts(Some(" *.eks.amazonaws.com ,, *.internal ")),
"*.eks.amazonaws.com,*.internal"
);
}
#[test]
fn dedupes_preserving_first_occurrence_order() {
assert_eq!(normalize_no_proxy_hosts(Some("a,b,a,c,b,d")), "a,b,c,d");
}
}
#[cfg(windows)]
lazy_static::lazy_static! {
pub static ref SYSTEM_ROOT: String = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string());
+5 -2
View File
@@ -3919,11 +3919,14 @@ async fn push_next_flow_job(
for (i, payload_tag) in job_payloads.into_iter().enumerate() {
if i % 100 == 0 && i != 0 {
tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}");
// Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the
// push transaction commits — otherwise large parallel pushes can be flagged as
// zombie and trigger a cancel/push deadlock.
sqlx::query!(
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()",
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1",
flow_job.id,
)
.execute(&mut *tx)
.execute(db)
.warn_after_seconds(3)
.await?;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.700.2";
export const VERSION = "v1.702.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+37 -13
View File
@@ -20,12 +20,18 @@ import newCommand from "./new.ts";
import generateAgentsCommand from "./generate_agents.ts";
import { isVersionsGeq1585 } from "../sync/global.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
export interface AppFile {
value: any;
public?: boolean;
summary: string;
policy: Policy;
// Mirrors granular ACLs on the app path. Omitted from app.yaml when no
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through update_app — so a perm-only
// change never bumps the app version.
extra_perms?: Record<string, boolean>;
}
const alreadySynced: string[] = [];
@@ -168,21 +174,27 @@ export async function pushApp(
// On create: backend applies folder defaults
}
// extra_perms goes through /acls/* — strip from the body so a perms-only
// edit never bumps the app version (see applyExtraPermsDiff for details).
const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
if (isSuperset(localApp, app)) {
if (isSuperset(localAppBody, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
return;
} else {
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
await wmill.updateApp({
workspace,
path: remotePath,
requestBody: {
deployment_message: message,
...localAppBody,
...preserveFields,
},
});
}
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
await wmill.updateApp({
workspace,
path: remotePath,
requestBody: {
deployment_message: message,
...localApp,
...preserveFields,
},
});
} else {
log.info(colors.yellow.bold("Creating new app..."));
@@ -191,11 +203,23 @@ export async function pushApp(
requestBody: {
path: remotePath,
deployment_message: message,
...localApp,
...localAppBody,
...preserveFields,
},
});
}
// Independent perms sync via /acls/* — self-contained log + non-fatal errors.
// No refetch: extra_perms is item-specific and folder perms are never merged
// onto item.extra_perms, and the body sent to update_app / create_app omits
// the field — so the value we already have from getAppByPath is authoritative.
await applyExtraPermsDiff(
workspace,
"app",
remotePath,
localPerms,
(app as any)?.extra_perms,
);
}
export async function generatingPolicy(
+47 -22
View File
@@ -19,6 +19,7 @@ import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { writeIfChanged } from "../../utils/utils.ts";
import { yamlOptions } from "../sync/sync.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
import {
EXTENSION_TO_LANGUAGE,
getLanguageFromExtension,
@@ -35,6 +36,10 @@ export interface AppFile {
datatable?: string;
schema?: string;
};
// Mirrors granular ACLs on the raw_app path. Synced via /acls/* by
// applyExtraPermsDiff — never through update_app_raw — so a perm-only
// change never bumps the app version. Stripped from the yaml when empty.
extra_perms?: Record<string, boolean>;
}
// Match siblings of a YAML metadata file case-insensitively. A buggy CLI
@@ -431,35 +436,45 @@ export async function pushRawApp(
value.data = localApp.data;
}
// extra_perms is synced independently via /acls/* — strip from the
// up-to-date comparison so a perm-only edit doesn't trigger a rebuild +
// new app_version. The kind segment is "raw_app" so git-sync writes back
// to `<path>.raw_app.json`, not `<path>.app.json`. The backend granular_acls
// handler routes "raw_app" to the `app` table (where v2 raw apps actually
// live) while still dispatching DeployedObject::RawApp for git-sync.
const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
// Check both metadata/runnables AND files for changes
// Files need separate comparison because isSuperset only checks if local keys exist in remote
const metadataUpToDate = isSuperset({ ...localApp, runnables }, app);
const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app);
const filesUpToDate = deepEqual(files, app.value?.files);
if (metadataUpToDate && filesUpToDate) {
log.info(colors.green(`App ${remotePath} is up to date`));
return;
}
const { js, css } = await createBundleRaw();
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
await wmill.updateAppRaw({
workspace,
path: remotePath,
formData: {
app: {
value,
path: remotePath,
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
} else {
const { js, css } = await createBundleRaw();
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
await wmill.updateAppRaw({
workspace,
path: remotePath,
formData: {
app: {
value,
path: remotePath,
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
},
js,
css,
},
js,
css,
},
});
});
}
} else {
const { js, css } = await createBundleRaw();
await wmill.createAppRaw({
@@ -480,6 +495,16 @@ export async function pushRawApp(
},
});
}
// No refetch needed: folder perms are never merged into item.extra_perms,
// and the body sent to update_app_raw / create_app_raw omits the field.
await applyExtraPermsDiff(
workspace,
"raw_app",
remotePath,
localPerms,
(app as any)?.extra_perms,
);
}
export async function generatingPolicy(
+52 -18
View File
@@ -19,6 +19,7 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
collectPathScriptPaths,
@@ -41,6 +42,11 @@ export interface FlowFile {
schema?: any;
on_behalf_of_email?: string;
has_on_behalf_of?: boolean;
// Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through update_flow — so a perm-only
// change never bumps the flow version.
extra_perms?: Record<string, boolean>;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
@@ -174,10 +180,14 @@ export async function pushFlow(
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles);
}
if (missingFiles.length > 0) {
log.warn(colors.yellow(
`Warning: missing inline script file(s): ${missingFiles.join(", ")}. ` +
`The flow will be pushed with unresolved !inline references.`
));
// Hard-fail rather than push the literal `!inline path` text as
// rawscript.content. That string would be persisted in flow_version.value
// and round-trip as the script body on the next pull, overwriting the
// user's local handler with the directive — see GIT-871 / #9140.
throw new Error(
`Cannot push flow ${remotePath}: missing inline script file(s): ${missingFiles.join(", ")}. ` +
`Either restore the file(s) or remove the !inline reference(s) from flow.yaml before pushing.`
);
}
const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email;
@@ -193,22 +203,30 @@ export async function pushFlow(
// On create: backend applies folder defaults — no client-side resolution needed
}
// extra_perms is synced independently via /acls/* (see applyExtraPermsDiff)
// so a perm-only edit never bumps the flow version. Strip the field from the
// body that goes to update_flow / create_flow and treat it as a separate
// step both for the up-to-date short-circuit and after the deploy.
const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & {
extra_perms?: Record<string, boolean>;
};
if (flow) {
if (isSuperset(localFlow, flow)) {
if (isSuperset(localFlowBody, flow)) {
log.info(colors.green(`Flow ${remotePath} is up to date`));
return;
}
log.info(colors.bold.yellow(`Updating flow ${remotePath}...`));
await wmill.updateFlow({
workspace: workspace,
path: remotePath.replaceAll(SEP, "/"),
requestBody: {
} else {
log.info(colors.bold.yellow(`Updating flow ${remotePath}...`));
await wmill.updateFlow({
workspace: workspace,
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...preserveFields,
},
});
requestBody: {
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlowBody,
...preserveFields,
},
});
}
} else {
log.info(colors.bold.yellow("Creating new flow..."));
try {
@@ -217,7 +235,7 @@ export async function pushFlow(
requestBody: {
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlow,
...localFlowBody,
...preserveFields,
},
});
@@ -228,6 +246,22 @@ export async function pushFlow(
);
}
}
// Independent of whether the flow body changed, sync extra_perms via /acls/*.
// Self-contained log line + non-fatal failures.
//
// No refetch is needed: extra_perms is item-specific and additive on top of
// folder perms — folder perms are never merged onto item.extra_perms. And
// since the request body sent to update_flow / create_flow doesn't carry
// extra_perms, the value we read in the initial getFlowByPath above is
// also the post-write value (a no-op deploy can't drift it).
await applyExtraPermsDiff(
workspace,
"flow",
remotePath.replaceAll(SEP, "/"),
localPerms,
(flow as any)?.extra_perms,
);
}
type Options = GlobalOptions;
+24 -6
View File
@@ -266,19 +266,32 @@ export async function generateFlowLockInternal(
return tree.isStale(treePath);
})
: changedScripts;
const missingFiles: string[] = [];
await replaceInlineScripts(
flowValue.value.modules,
fileReader,
log,
folder + SEP!,
SEP,
locksToRemove
locksToRemove,
missingFiles
);
if (flowValue.value.failure_module) {
await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove);
await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles);
}
if (flowValue.value.preprocessor_module) {
await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove);
await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles);
}
if (missingFiles.length > 0) {
// Abort before updateFlow rather than push the literal `!inline path`
// string as rawscript.content (GIT-871 / #9140). Note: at this point
// replaceInlineScripts has already mutated `flowValue.value` in place
// for the modules that *did* resolve. All current callers re-throw on
// this error; do not catch and reuse `flowValue` without re-parsing.
throw new Error(
`Cannot regenerate lock for flow ${remote_path}: missing inline script file(s): ${missingFiles.join(", ")}. ` +
`Either restore the file(s) or remove the !inline reference(s) from flow.yaml before retrying.`
);
}
//removeChangedLocks
@@ -304,18 +317,23 @@ export async function generateFlowLockInternal(
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", {
skipInlineScriptSuffix: getNonDottedPaths(),
});
// flowValue.value here is the backend's response from updateFlow, so a
// rawscript whose content is `!inline ...` is corruption (GIT-871) — fail
// fast rather than writing the literal directive back to a script file.
const extractOpts = { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true };
const inlineScripts = extractInlineScriptsForFlows(
flowValue.value.modules,
currentMapping,
SEP,
opts.defaultTs,
lockAssigner
lockAssigner,
extractOpts
);
if (flowValue.value.failure_module) {
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner));
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts));
}
if (flowValue.value.preprocessor_module) {
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner));
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts));
}
inlineScripts.forEach((s) => {
writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content);
+36
View File
@@ -2,6 +2,7 @@ import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
import { writeFile, stat, mkdir } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { colors } from "@cliffy/ansi/colors";
@@ -83,6 +84,11 @@ export interface ScriptFile {
is_template?: boolean;
lock?: Array<string>;
kind?: "script" | "failure" | "trigger" | "command" | "approval";
// Mirrors granular ACLs on the script path. Omitted from .script.yaml when
// no perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through create_script — so a perm-only
// change never bumps the script hash/version.
extra_perms?: Record<string, boolean>;
}
/**
@@ -542,6 +548,15 @@ export async function handleFile(
deepEqual(modules ?? null, remote.modules ?? null))
) {
log.info(colors.green(`Script ${remotePath} is up to date`));
// Even when the body is unchanged, perms may still drift — sync them
// independently before returning.
await applyExtraPermsDiff(
workspaceId,
"script",
remotePath.replaceAll(SEP, "/"),
(typed as any)?.extra_perms,
(remote as any)?.extra_perms,
);
return true;
}
}
@@ -581,6 +596,27 @@ export async function handleFile(
)
);
}
// Sync granular ACLs as an independent step — perm-only edits never reach
// create_script (which would bump the script hash) and instead route
// through /acls/* via applyExtraPermsDiff.
//
// No refetch is needed:
// - folder perms are additive at auth time, never merged onto item rows;
// - the body sent to create_script doesn't carry extra_perms, so a fresh
// deploy of an existing path inherits the previous version's perms
// unchanged. The diff against `remote` (captured before the deploy)
// therefore matches what `wmill acl remove` would do — and the granular
// ACL endpoint updates every matching row, so the inheritance on the
// new version doesn't leave ghost entries.
await applyExtraPermsDiff(
workspaceId,
"script",
remotePath.replaceAll(SEP, "/"),
(typed as any)?.extra_perms,
(remote as any)?.extra_perms,
);
return true;
}
return false;
+5 -1
View File
@@ -98,12 +98,16 @@ export async function downloadZip(
}
const includeWorkspaceDependenciesValue = !(skipWorkspaceDependencies ?? false);
// `preserve_extra_perms=true` opts the tarball into surfacing granular ACLs
// on flow / script / app rows. Default-off on the server protects cross-
// workspace tarball imports from carrying ACLs that reference identities
// missing in the target workspace; the CLI sync flow explicitly wants them.
const baseParams = `&plain_secret=${plainSecrets ?? false
}&skip_variables=${skipVariables ?? false}&skip_resources=${skipResources ?? false
}&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false
}&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false
}&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false
}&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2`;
}&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true`;
const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?";
+3 -3
View File
@@ -950,7 +950,7 @@ function ZipFSElement(
SEP,
defaultTs,
assigner,
{ skipInlineScriptSuffix: getNonDottedPaths() },
{ skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true },
);
if (flow.value.failure_module) {
inlineScripts.push(...extractInlineScriptsForFlows(
@@ -959,7 +959,7 @@ function ZipFSElement(
SEP,
defaultTs,
assigner,
{ skipInlineScriptSuffix: getNonDottedPaths() },
{ skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true },
));
}
if (flow.value.preprocessor_module) {
@@ -969,7 +969,7 @@ function ZipFSElement(
SEP,
defaultTs,
assigner,
{ skipInlineScriptSuffix: getNonDottedPaths() },
{ skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true },
));
}
} catch (error) {
+192
View File
@@ -0,0 +1,192 @@
import * as wmill from "../../gen/services.gen.ts";
import * as log from "./log.ts";
import { colors } from "@cliffy/ansi/colors";
import type { AddGranularAclsData } from "../../gen/types.gen.ts";
export type ExtraPermsKind = AddGranularAclsData["kind"];
type PermsMap = Record<string, boolean>;
type Normalized = {
perms: PermsMap;
/** Keys with non-boolean values: never revoked, never granted — treat as "no opinion". */
invalidOwners: Set<string>;
/** Top-level value is not a plain object (array, primitive, etc.). The
* entire map is rejected and the caller must treat it like "no opinion"
* to avoid silently revoking every remote ACL. */
malformedTop: boolean;
};
function normalize(value: unknown, source: string): Normalized {
const perms: PermsMap = {};
const invalidOwners = new Set<string>();
if (
value === null ||
value === undefined ||
typeof value !== "object" ||
Array.isArray(value)
) {
if (value !== undefined && value !== null) {
log.error(
colors.red(
`extra_perms: ${source} is not a {owner: boolean} map — skipping ACL sync to avoid clobbering remote ACLs`,
),
);
return { perms, invalidOwners, malformedTop: true };
}
return { perms, invalidOwners, malformedTop: false };
}
const invalidList: string[] = [];
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof v === "boolean") {
perms[k] = v;
} else {
invalidOwners.add(k);
invalidList.push(k);
}
}
if (invalidList.length > 0) {
log.error(
colors.red(
`extra_perms: ${invalidList.length} invalid entry/entries in ${source} (non-boolean value, treating as "no opinion"): ${invalidList.join(", ")}`,
),
);
}
return { perms, invalidOwners, malformedTop: false };
}
function formatError(e: unknown): string {
if (!e || typeof e !== "object") return String(e);
const anyE = e as { body?: unknown; message?: unknown };
if (anyE.body !== undefined) {
if (typeof anyE.body === "string") return anyE.body;
try {
return JSON.stringify(anyE.body);
} catch {
// fall through
}
}
if (typeof anyE.message === "string") return anyE.message;
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
/**
* Apply the diff between `local` and `remote` granular ACL maps as a sequence
* of `/acls/add` and `/acls/remove` calls. Used by every CLI push path so a
* yaml change that *only* touches `extra_perms` never bumps the script/flow/app
* version — perm mutations route through the dedicated granular-ACL endpoints
* exactly as if the user had clicked through the UI.
*
* **`local === undefined` means "no opinion".** If the yaml does not carry an
* `extra_perms` field at all, this function is a no-op — the remote ACLs are
* left untouched. This is what prevents a stale local checkout from racing
* a concurrent UI grant: only users who explicitly track perms in source (by
* writing `extra_perms:` in the yaml, even as `{}`) get destructive sync.
*
* The function is intentionally independent of the surrounding push logic:
* it has its own log lines and its own non-fatal failure mode. Each grant /
* revoke is logged as a separate line on success; failures are logged in red
* but never throw — a stale yaml referencing a deleted user/group surfaces as
* a red error, not a hard error that would block the surrounding deploy.
*
* @returns number of /acls/* calls actually issued (0 means perms in sync, or
* the local yaml had no `extra_perms` field).
*/
export async function applyExtraPermsDiff(
workspace: string,
kind: ExtraPermsKind,
path: string,
local: unknown,
remote: unknown,
): Promise<number> {
// Absent local field = "no opinion" — never call /acls/* in this case.
// Crucially, this protects users who don't track ACLs in source from having
// their UI-managed perms silently revoked by a stale CLI push.
if (local === undefined || local === null) {
return 0;
}
const localN = normalize(local, "local yaml");
// Top-level malformed (array, primitive, etc.) → treat as "no opinion" so a
// typo in yaml can never silently revoke every remote ACL.
if (localN.malformedTop) {
return 0;
}
const remoteN = normalize(remote, "remote response");
const localPerms = localN.perms;
const remotePerms = remoteN.perms;
const toGrant: Array<[string, boolean]> = [];
for (const [owner, write] of Object.entries(localPerms)) {
if (!(owner in remotePerms) || remotePerms[owner] !== write) {
toGrant.push([owner, write]);
}
}
// Owners with a malformed value in local yaml are treated as "no opinion":
// they are excluded from the revoke set so a typo (`g/devs: "write"`) never
// silently strips an existing ACL.
const toRevoke: string[] = Object.keys(remotePerms).filter(
(owner) =>
!(owner in localPerms) && !localN.invalidOwners.has(owner),
);
if (toGrant.length === 0 && toRevoke.length === 0) {
return 0;
}
let calls = 0;
for (const [owner, write] of toGrant) {
const access = write ? "write" : "read";
try {
await wmill.addGranularAcls({
workspace,
kind,
path,
requestBody: { owner, write },
});
log.info(
colors.green(
` extra_perms: granted ${access} to ${owner} on ${kind}/${path}`,
),
);
calls += 1;
} catch (e: any) {
log.error(
colors.red(
` extra_perms: failed to grant ${access} to ${owner} on ${kind}/${path}: ${formatError(e)}`,
),
);
}
}
for (const owner of toRevoke) {
try {
await wmill.removeGranularAcls({
workspace,
kind,
path,
requestBody: { owner },
});
log.info(
colors.green(` extra_perms: revoked ${owner} on ${kind}/${path}`),
);
calls += 1;
} catch (e: any) {
log.error(
colors.red(
` extra_perms: failed to revoke ${owner} on ${kind}/${path}: ${formatError(e)}`,
),
);
}
}
return calls;
}
+1 -1
View File
@@ -79,7 +79,7 @@ export {
token,
};
export const VERSION = "1.700.2";
export const VERSION = "1.702.1";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -574,3 +574,68 @@ describe("extractInlineScripts with mapping preserves file paths", () => {
expect(lockScript!.path).toBe("my.inline_script.lock");
});
});
// ---------------------------------------------------------------------------
// failOnInlineDirective option (GIT-871 / #9140)
// ---------------------------------------------------------------------------
describe("failOnInlineDirective option", () => {
test("default behavior: yaml-parsed module with !inline content extracts without throwing", () => {
// Simulates flow_metadata / dev callers: yaml-parsed local flow whose
// rawscript.content is the literal `!inline foo.ts` directive (the
// legitimate on-disk shape after extraction).
const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun");
expect(() =>
extractInlineScripts([mod], {}, "/", "bun"),
).not.toThrow();
});
test("yaml-parsed !inline content round-trips as the script's body", () => {
const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun");
const scripts = extractInlineScripts([mod], {}, "/", "bun");
const script = scripts.find((s) => !s.is_lock);
expect(script).toBeDefined();
expect(script!.content).toBe("!inline a.inline_script.ts");
});
test("opt-in: failOnInlineDirective=true throws on !inline content", () => {
// Simulates the sync-pull call site: rawscript came from the backend's
// flow_version.value, so `!inline ...` content means the row is corrupt.
const mod = makeRawscriptModule("failure", "!inline Handle_error.ts", "bun");
expect(() =>
extractInlineScripts([mod], {}, "/", "bun", undefined, {
failOnInlineDirective: true,
}),
).toThrow(/corrupted inline script/);
});
test("opt-in: real script content still extracts cleanly", () => {
const mod = makeRawscriptModule(
"failure",
'export function main() { return 1; }',
"bun",
);
expect(() =>
extractInlineScripts([mod], {}, "/", "bun", undefined, {
failOnInlineDirective: true,
}),
).not.toThrow();
});
test("opt-in: throws for nested rawscript inside branchall", () => {
const inner = makeRawscriptModule("inner", "!inline poisoned.ts", "bun");
const outer: FlowModule = {
id: "branch",
value: {
type: "branchall" as const,
branches: [{ summary: "b1", expr: "true", modules: [inner], skip_failure: false, parallel: false }],
parallel: false,
},
};
expect(() =>
extractInlineScripts([outer], {}, "/", "bun", undefined, {
failOnInlineDirective: true,
}),
).toThrow(/corrupted inline script/);
});
});
+6 -2
View File
@@ -111,6 +111,10 @@ kind: script
function createFlowFixture(name: string): Record<string, { path: string; content: string }> {
const flowSuffix = getFolderSuffix("flow");
const metadataFile = getMetadataFileName("flow", "yaml");
// !inline paths are resolved relative to the flow folder (see
// pushFlow's fileReader in cli/src/commands/flow/flow.ts), so the
// path inside the directive must NOT include the flow folder prefix.
const scriptFile = "a.ts";
return {
metadata: {
@@ -122,7 +126,7 @@ value:
- id: a
value:
type: rawscript
content: "!inline ${name}${flowSuffix}/a.ts"
content: "!inline ${scriptFile}"
language: bun
input_transforms: {}
schema:
@@ -133,7 +137,7 @@ schema:
`,
},
inlineScript: {
path: `${name}${flowSuffix}/a.ts`,
path: `${name}${flowSuffix}/${scriptFile}`,
content: `export async function main() {\n return "Hello from flow ${name}";\n}`,
},
};
@@ -20,13 +20,28 @@ function extractRawscriptInline(
rawscript: RawScript,
mapping: Record<string, string>,
separator: string,
assigner: PathAssigner
assigner: PathAssigner,
failOnInlineDirective: boolean
): InlineScript[] {
const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language);
const mappedPath = mapping[id];
const path = mappedPath ?? basePath + ext;
const language = rawscript.language;
const content = rawscript.content;
// Opt-in defensive guard: when extracting from backend-shaped data (i.e.
// sync pull), a rawscript whose content is itself an `!inline ...` directive
// means the backend was poisoned by a prior push that sent the unresolved
// directive as the script body (GIT-871 / #9140). Refuse to write it back
// to disk. Off by default because callers that operate on YAML-parsed local
// flows (flow_metadata, dev) legitimately see `!inline foo.ts` as content.
if (failOnInlineDirective && typeof content === "string" && content.startsWith("!inline ")) {
throw new Error(
`Refusing to extract corrupted inline script for module '${id}': ` +
`rawscript.content is the literal string \`${content.split("\n")[0]}\` ` +
`instead of script source. The backend's flow_version.value is corrupt — ` +
`re-push from a known-good local copy to repair it.`
);
}
const r = [{ path: path, content: content, language, is_lock: false}];
rawscript.content = "!inline " + path.replaceAll(separator, "/");
const lock = rawscript.lock;
@@ -50,6 +65,15 @@ function extractRawscriptInline(
export interface ExtractInlineScriptsOptions {
/** When true, skip the .inline_script. suffix in file names */
skipInlineScriptSuffix?: boolean;
/**
* When true, throw if a `rawscript.content` is itself an `!inline ...`
* directive. Set this only at the sync-pull call site, where the input
* comes from the backend's `flow_version.value` and `!inline ...` content
* means the row is corrupt (GIT-871 / #9140). Leave off for callers that
* pass YAML-parsed local flows — the directive is the legitimate on-disk
* shape there.
*/
failOnInlineDirective?: boolean;
}
/**
@@ -74,6 +98,7 @@ export function extractInlineScripts(
): InlineScript[] {
// Create pathAssigner only if not provided (top-level call), but reuse it for nested calls
const assigner = pathAssigner ?? newPathAssigner(defaultTs ?? "bun", { skipInlineScriptSuffix: options?.skipInlineScriptSuffix });
const failOnInlineDirective = options?.failOnInlineDirective ?? false;
return modules.flatMap((m) => {
if (m.value.type == "rawscript") {
@@ -83,7 +108,8 @@ export function extractInlineScripts(
m.value,
mapping,
separator,
assigner
assigner,
failOnInlineDirective
);
} else if (m.value.type == "forloopflow") {
return extractInlineScripts(
@@ -91,11 +117,12 @@ export function extractInlineScripts(
mapping,
separator,
defaultTs,
assigner
assigner,
options
);
} else if (m.value.type == "branchall") {
return m.value.branches.flatMap((b) =>
extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner)
extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner, options)
);
} else if (m.value.type == "whileloopflow") {
return extractInlineScripts(
@@ -103,7 +130,8 @@ export function extractInlineScripts(
mapping,
separator,
defaultTs,
assigner
assigner,
options
);
} else if (m.value.type == "branchone") {
return [
@@ -113,7 +141,8 @@ export function extractInlineScripts(
mapping,
separator,
defaultTs,
assigner
assigner,
options
)
),
...extractInlineScripts(
@@ -121,7 +150,8 @@ export function extractInlineScripts(
mapping,
separator,
defaultTs,
assigner
assigner,
options
),
];
} else if (m.value.type == "aiagent") {
@@ -138,7 +168,8 @@ export function extractInlineScripts(
toolValue,
mapping,
separator,
assigner
assigner,
failOnInlineDirective
);
});
} else {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.700.2",
"version": "1.702.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.700.2",
"version": "1.702.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.700.2",
"version": "1.702.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -25,6 +25,7 @@
import { type DynamicInput } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import { untrack } from 'svelte'
import { getHelperEntrypointArgs } from '$lib/infer'
interface Props {
value?: any
@@ -48,7 +49,9 @@
})
let resultJobLoader: JobLoader | undefined = $state()
let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false })
// loadInit:false — the $effect below owns the first refresh once
// resultJobLoader is bound; without this the promise is kicked off twice.
let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false, loadInit: false })
let items = $derived(_items.value)
let filterText: string = $state('')
@@ -125,9 +128,43 @@
}, 1000)
})
// Parameter names declared by the helper function. When known, we restrict
// the change-detection to only those keys so typing in unrelated form fields
// no longer retriggers the dynselect job. `undefined` means we couldn't
// determine the signature → fall back to a full-args comparison.
let helperParams = $state<Set<string> | undefined>(undefined)
$effect(() => {
const script = helperScript
const ep = entrypoint
if (!script) {
helperParams = undefined
return
}
let cancelled = false
void getHelperEntrypointArgs(script, ep || undefined).then((params) => {
if (!cancelled) helperParams = params
})
return () => {
cancelled = true
}
})
function filterArgs(args: Record<string, any> | undefined) {
if (!args || !helperParams) return args
const filtered: Record<string, any> = {}
for (const k of helperParams) {
if (k in args) filtered[k] = args[k]
}
return filtered
}
$effect(() => {
;[filterText, entrypoint, helperScript]
if (resultJobLoader && (open || neverLoaded || !deepEqual(lastArgs, nargs))) {
if (
resultJobLoader &&
(open || neverLoaded || !deepEqual(filterArgs(lastArgs), filterArgs(nargs)))
) {
neverLoaded = false
lastArgs = $state.snapshot(otherArgs)
_items.refresh()
@@ -699,6 +699,31 @@
</button>
{/each}
</div>
<div class="flex flex-col gap-1">
<label
for="otel_tracing_proxy_no_proxy_hosts"
class="block text-xs font-semibold text-emphasis"
>
NO_PROXY hosts (bypass tracing)
</label>
<TextInput
inputProps={{
type: 'text',
placeholder: '*.eks.amazonaws.com,*.internal',
id: 'otel_tracing_proxy_no_proxy_hosts',
disabled: !$enterpriseLicense
}}
bind:value={$values[setting.key].no_proxy_hosts}
/>
<p class="text-xs text-tertiary">
Comma-separated host patterns that job HTTP clients should bypass the tracing
proxy for — those hosts will not be traced. Use this for clients that pin their
own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.) which would
otherwise fail with <code>x509: certificate signed by unknown authority</code>.
Independent of the worker's own <code>NO_PROXY</code> env, which governs the proxy's
upstream relay (e.g. through a corporate proxy).
</p>
</div>
{/if}
</div>
{:else if setting.fieldType == 'object_store_config'}
+3 -3
View File
@@ -10,7 +10,7 @@
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore, userWorkspaces, superadmin } from '$lib/stores'
import { userStore, workspaceStore, userWorkspaces, superadmin, devopsRole } from '$lib/stores'
import {
Button,
ButtonType,
@@ -82,7 +82,7 @@
usernames,
folders,
jobTriggerKinds,
isSuperAdmin: !!$superadmin,
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})
)
@@ -750,7 +750,7 @@
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperadmin: !!$superadmin,
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
})}
bind:value={filters.val}
@@ -21,12 +21,15 @@
import {
ArrowRightLeft,
Ban,
Bot,
CheckCircle2,
ExternalLink,
Pencil,
UserMinus,
UserPlus
} from 'lucide-svelte'
import Badge from './common/badge/Badge.svelte'
import Tooltip from './Tooltip.svelte'
import DropdownV2 from './DropdownV2.svelte'
import Popover from './meltComponents/Popover.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
@@ -219,7 +222,16 @@
{filter}
items={users}
bind:filteredItems={filteredUsers}
f={(x) => x.email + ' ' + x.name + ' ' + x.company}
f={(x) =>
(x.email ?? '') +
' ' +
(x.name ?? '') +
' ' +
(x.company ?? '') +
' ' +
(x.username ?? '') +
' ' +
(x.workspace_id ?? '')}
/>
<div class="flex flex-col h-full w-full">
@@ -393,7 +405,8 @@
</Head>
<tbody>
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
{@const isServiceAccount = login_type === 'service_account'}
<tr
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
? 'opacity-60'
@@ -401,9 +414,22 @@
>
<Cell first class="max-w-[250px]">
<div class="flex items-center gap-1.5">
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{#if isServiceAccount}
<Bot size={16} class="text-blue-500 shrink-0" />
<span title={email} class="truncate block">{email}</span>
{:else}
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{/if}
{#if workspace_id}
<a
href="{base}/?workspace={workspace_id}"
title="Workspace: {workspace_id}"
>
<Badge color="blue">{truncate(workspace_id, 20)}</Badge>
</a>
{/if}
{#if disabled}
<span
class="text-2xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 whitespace-nowrap"
@@ -449,184 +475,205 @@
</Cell>
{/if}
<Cell>
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
selected={super_admin
? 'super_admin'
: devops
? 'devops'
: 'user'}
on:selected={async (e) => {
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
{#if isServiceAccount}
<div class="flex items-center gap-1">
<span
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
>
Operator
</span>
<Tooltip>Service accounts are always operators.</Tooltip>
</div>
{:else}
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
selected={super_admin
? 'super_admin'
: devops
? 'devops'
: 'user'}
on:selected={async (e) => {
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
listUsers(activeOnly)
return
}
let role = e.detail
if (role === 'super_admin') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: true,
is_devops: false
}
})
}
if (role === 'devops') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: true
}
})
}
if (role === 'user') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: false
}
})
}
sendUserToast('User updated')
listUsers(activeOnly)
return
}
let role = e.detail
if (role === 'super_admin') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: true,
is_devops: false
}
})
}
if (role === 'devops') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: true
}
})
}
if (role === 'user') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: false
}
})
}
sendUserToast('User updated')
listUsers(activeOnly)
}}
>
{#snippet children({ item })}
<ToggleButton
value={'user'}
small
label="User"
disabled={role_source === 'instance_group' &&
(super_admin || devops)}
tooltip={role_source === 'instance_group' &&
(super_admin || devops)
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
: undefined}
showTooltipIcon={role_source === 'instance_group' &&
(super_admin || devops)}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
{item}
/>
<ToggleButton
value={'super_admin'}
small
label="Superadmin"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if role_source === 'instance_group' && (super_admin || devops)}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to &quot;User&quot; requires removing them from the group."
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
}}
>
{#snippet children({ item })}
<ToggleButton
value={'user'}
small
label="User"
disabled={role_source === 'instance_group' &&
(super_admin || devops)}
tooltip={role_source === 'instance_group' &&
(super_admin || devops)
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
: undefined}
showTooltipIcon={role_source === 'instance_group' &&
(super_admin || devops)}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
{item}
/>
<ToggleButton
value={'super_admin'}
small
label="Superadmin"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if role_source === 'instance_group' && (super_admin || devops)}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to &quot;User&quot; requires removing them from the group."
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
{/if}
</Cell>
<Cell last>
<div class="flex items-center justify-end">
<div
bind:this={editWrappers[email]}
class="w-0 h-0 overflow-hidden"
>
<InstanceNameEditor
{login_type}
value={name}
{username}
{email}
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
</div>
<DropdownV2
items={[
{
displayName: 'Edit',
icon: Pencil,
action: () => {
const btn = editWrappers[email]?.querySelector(
'[aria-label="Popup button"]'
)
if (btn instanceof HTMLElement) btn.click()
}
},
{
displayName: disabled ? 'Enable' : 'Disable',
icon: disabled ? CheckCircle2 : Ban,
action: () => {
if (!disabled) {
disableUserEmail = email
disableConfirmedCallback = async () => {
try {
await UserService.globalUserUpdate({
email,
requestBody: { disabled: true }
})
sendUserToast('User disabled')
listUsers(activeOnly)
} catch (e) {
sendUserToast('Failed to disable user', true)
{#if isServiceAccount}
{#if workspace_id}
<a
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
class="text-xs text-secondary hover:text-primary hover:underline"
title="Manage in workspace settings">Manage in workspace</a
>
{/if}
{:else}
<div
bind:this={editWrappers[email]}
class="w-0 h-0 overflow-hidden"
>
<InstanceNameEditor
{login_type}
value={name}
{username}
{email}
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
</div>
<DropdownV2
items={[
{
displayName: 'Edit',
icon: Pencil,
action: () => {
const btn = editWrappers[email]?.querySelector(
'[aria-label="Popup button"]'
)
if (btn instanceof HTMLElement) btn.click()
}
},
{
displayName: disabled ? 'Enable' : 'Disable',
icon: disabled ? CheckCircle2 : Ban,
action: () => {
if (!disabled) {
disableUserEmail = email
disableConfirmedCallback = async () => {
try {
await UserService.globalUserUpdate({
email,
requestBody: { disabled: true }
})
sendUserToast('User disabled')
listUsers(activeOnly)
} catch (e) {
sendUserToast('Failed to disable user', true)
}
}
} else {
UserService.globalUserUpdate({
email,
requestBody: { disabled: false }
})
.then(() => {
sendUserToast('User enabled')
listUsers(activeOnly)
})
.catch(() => {
sendUserToast('Failed to enable user', true)
})
}
} else {
UserService.globalUserUpdate({
email,
requestBody: { disabled: false }
})
.then(() => {
sendUserToast('User enabled')
listUsers(activeOnly)
})
.catch(() => {
sendUserToast('Failed to enable user', true)
})
}
},
{
displayName: 'Reassign',
icon: ArrowRightLeft,
action: () => {
offboardingEmail = email
offboardingReassignOnly = true
}
},
{
displayName: 'Remove',
icon: UserMinus,
type: 'delete',
action: () => {
offboardingEmail = email
offboardingReassignOnly = false
}
}
},
{
displayName: 'Reassign',
icon: ArrowRightLeft,
action: () => {
offboardingEmail = email
offboardingReassignOnly = true
}
},
{
displayName: 'Remove',
icon: UserMinus,
type: 'delete',
action: () => {
offboardingEmail = email
offboardingReassignOnly = false
}
}
]}
/>
]}
/>
{/if}
</div>
</Cell>
</tr>
@@ -113,19 +113,17 @@ describe('global AI tools', () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
summary: 'Flow with empty module',
value: JSON.stringify({
modules: [
{
id: 'empty_step',
value: {
type: 'rawscript',
language: 'bun',
content: '',
input_transforms: {}
}
modules: JSON.stringify([
{
id: 'empty_step',
value: {
type: 'rawscript',
language: 'bun',
content: '',
input_transforms: {}
}
]
})
}
])
})
const code = 'export async function main() {\n\treturn 42\n}'
@@ -145,4 +143,59 @@ describe('global AI tools', () => {
})
).resolves.toBe(code)
})
it('writes flows with flow-mode arguments and reads compact flow value', async () => {
const writeResult = JSON.parse(
await callGlobalTool('write_flow', {
path: 'f/flows/with-schema-and-groups',
summary: 'Flow with schema and groups',
modules: JSON.stringify([
{
id: 'start',
summary: 'Start',
value: {
type: 'identity'
}
}
]),
schema: JSON.stringify({
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
}),
groups: JSON.stringify([{ summary: 'Main', start_id: 'start', end_id: 'start' }])
})
)
expect(writeResult.item.value.value).toBeUndefined()
const raw = await callGlobalTool('read_workspace_item', {
type: 'flow',
path: 'f/flows/with-schema-and-groups'
})
const item = JSON.parse(raw)
expect(item.value).toMatchObject({
modules: [
{
id: 'start',
summary: 'Start',
value: { type: 'identity' }
}
],
schema: {
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
},
preprocessor_module: null,
failure_module: null,
groups: [{ summary: 'Main', start_id: 'start', end_id: 'start' }]
})
expect(item.value.value).toBeUndefined()
})
})
@@ -37,6 +37,7 @@ import {
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
type EditableFlowJson,
validateEditableFlowJson
} from '../flow/editableFlowJson'
import { createInlineScriptSession } from '../flow/inlineScriptsUtils'
@@ -59,7 +60,6 @@ import {
type ToolCallbacks,
type ToolDisplayAction
} from '../shared'
import { flowModuleSchema, flowModulesSchema } from '../flow/openFlowZod.gen'
import {
resourceRequestSchema,
scheduleRequestSchema,
@@ -149,22 +149,6 @@ const writeScriptSchema = z.object({
content: z.string().describe('Full script source code.')
})
const flowValueSchema = z
.looseObject({
modules: flowModulesSchema.describe('Sequential flow modules.'),
preprocessor_module: flowModuleSchema
.nullable()
.optional()
.describe(
"Optional preprocessor module with id 'preprocessor'. Runs before normal modules; cannot reference results.*."
),
failure_module: flowModuleSchema
.nullable()
.optional()
.describe("Optional failure handler module with id 'failure'.")
})
.describe('OpenFlow value: modules plus optional preprocessor_module and failure_module.')
const readFlowModuleCodeSchema = z.object({
path: z.string().describe('Workspace path of the flow.'),
module_id: z
@@ -184,24 +168,81 @@ const setFlowModuleCodeSchema = z.object({
code: z.string().describe('New script source. Replaces the module\'s value.content entirely.')
})
// `value` is taken as a JSON string rather than a typed object because the
// underlying flowValueSchema is recursive (modules can contain modules), which
// makes z.toJSONSchema emit $defs/$ref. Gemini's tools API rejects those
// keywords ("Unknown name $ref/$defs"). The string is parsed and validated
// against flowValueSchema inside the handler. Same trick as set_flow_json in
// chat/flow/core.ts (see comment on its schema).
// Flow structure fields are taken as JSON strings rather than typed objects
// because the underlying flow module schema is recursive (modules can contain
// modules), which makes z.toJSONSchema emit $defs/$ref. Gemini's tools API
// rejects those keywords ("Unknown name $ref/$defs"). Same trick as
// set_flow_json in chat/flow/core.ts.
const writeFlowSchema = z.object({
path: z
.string()
.describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'),
summary: z.string().optional().describe('Short human-readable summary.'),
value: z
modules: z.string().describe('JSON string containing the complete flow modules array.'),
schema: z
.string()
.optional()
.nullable()
.describe('JSON string containing the flow input schema.'),
preprocessor_module: z
.string()
.optional()
.nullable()
.describe('JSON string containing the optional preprocessor module.'),
failure_module: z
.string()
.optional()
.nullable()
.describe('JSON string containing the optional failure module.'),
groups: z
.string()
.optional()
.nullable()
.describe(
'JSON string of the OpenFlow value object: { modules, preprocessor_module?, failure_module? }. Pass it as a JSON-encoded string, not a nested object.'
'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.'
)
})
function parseOptionalJsonArg(value: unknown, field: string): unknown {
if (value === undefined || value === null) {
return value
}
try {
return typeof value === 'string' ? JSON.parse(value) : value
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Invalid JSON for ${field}: ${message}`)
}
}
function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue {
const value: FlowValue = {
modules: editable.modules,
preprocessor_module: editable.preprocessor_module ?? undefined,
failure_module: editable.failure_module ?? undefined,
groups: editable.groups ?? undefined
}
return {
value,
schema: editable.schema,
groups: editable.groups
}
}
function flowDraftAsEditableInput(flowDraft: FlowDraftValue): {
value: FlowValue
schema?: Record<string, any> | null | undefined
} {
return {
value:
flowDraft.groups === undefined
? flowDraft.value
: { ...flowDraft.value, groups: flowDraft.groups ?? undefined },
schema: flowDraft.schema
}
}
const writeScheduleSchema = scheduleRequestSchema
const writeTriggerSchema = z.object({
@@ -428,7 +469,7 @@ Important rules:
- Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match.
- Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read.
- Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field.
- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, value is { value: <OpenFlow value>, schema, groups } so the inputs schema and groups round-trip through deploy. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions.
- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions.
- Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend/<key>/main.{ts|py}". /wmill.d.ts is generated and cannot be written.
- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice.
- Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts.
@@ -496,7 +537,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown {
if (item.type !== 'flow' || !item.value) return item
const flowDraft = item.value as FlowDraftValue
const session = createInlineScriptSession()
const editable = buildEditableFlowJson(flowDraft, session)
const editable = buildEditableFlowJson(flowDraftAsEditableInput(flowDraft), session)
return {
type: 'flow',
path: item.path,
@@ -1074,8 +1115,9 @@ function getFlowInstructions(): string {
return `# Global draft flow instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- A flow draft is a workspace item: \`{ type: 'flow', path, summary?, value, isDraft }\` where \`value\` is \`{ value: <OpenFlow value object>, schema, groups }\`. The inputs schema and groups are kept alongside the OpenFlow value so deploy round-trips them.
- \`value.modules\` contains normal sequential modules. Use top-level \`value.preprocessor_module\` and \`value.failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`value.modules\`.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.
@@ -1087,7 +1129,7 @@ function getFlowInstructions(): string {
- \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module.
- \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the AI draft.
- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body.
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`value\` argument is the **non-compact** OpenFlow value (rawscript content is the actual code, not a placeholder).
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder).
# Windmill flow authoring reference
@@ -1271,35 +1313,29 @@ export const globalTools: Tool<{}>[] = [
def: createToolDef(
writeFlowSchema,
'write_flow',
'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. value must be a JSON-encoded string of the OpenFlow value object.'
'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. Uses the same flow-structure arguments as set_flow_json plus path and summary.'
),
showDetails: true,
streamArguments: true,
showFade: true,
fn: async (ctx) => {
const parsed = writeFlowSchema.parse(ctx.args)
let value: unknown
try {
value = JSON.parse(parsed.value)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Invalid JSON for value: ${message}`)
}
const validated = flowValueSchema.safeParse(value)
if (!validated.success) {
throw new Error(
`Invalid flow value: ${validated.error.issues
.slice(0, 5)
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ')}`
)
}
const editable = validateEditableFlowJson({
modules: parseOptionalJsonArg(parsed.modules, 'modules'),
schema: parseOptionalJsonArg(parsed.schema, 'schema'),
preprocessor_module: parseOptionalJsonArg(
parsed.preprocessor_module,
'preprocessor_module'
),
failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'),
groups: parseOptionalJsonArg(parsed.groups, 'groups')
})
return writeDraft(
{
type: 'flow',
path: parsed.path,
summary: parsed.summary,
value: { value: validated.data as FlowValue, schema: null, groups: null },
value: editableFlowToDraftValue(editable),
isDraft: true
},
ctx
@@ -1683,7 +1719,7 @@ async function patchFlowJson(
// model uses set_flow_module_code to change inline script bodies.
const base = await loadFlowDraftValue(path, ctx.workspace)
const session = createInlineScriptSession()
const editable = buildEditableFlowJson(base.flow, session)
const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session)
const currentJson = JSON.stringify(editable)
const updatedJson = findAndReplace(
currentJson,
@@ -1730,7 +1766,7 @@ async function readFlowModuleCode(
})
const base = await loadFlowDraftValue(args.path, workspace)
const session = createInlineScriptSession()
buildEditableFlowJson(base.flow, session)
buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session)
const content = session.get(args.module_id)
if (content === undefined) {
throw new Error(
@@ -1753,7 +1789,7 @@ async function setFlowModuleCode(
})
const base = await loadFlowDraftValue(args.path, workspace)
const session = createInlineScriptSession()
const editable = buildEditableFlowJson(base.flow, session)
const editable = buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session)
if (!session.has(args.module_id)) {
throw new Error(
`Module "${args.module_id}" is not an inline rawscript in flow "${args.path}". Use patch_flow_json or write_flow for structural changes.`
@@ -2342,6 +2378,10 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise<stri
(await workspaceItemExists(item.type, item.path, workspace, item.triggerKind))
const stored = globalDraftStore.setDraft(workspace, item)
const serializedItem =
stored.type === 'variable' || stored.type === 'flow'
? serializeWorkspaceItemForRead(stored)
: stored
const verb = exists ? 'Updated' : 'Created'
toolCallbacks.setToolStatus(toolId, {
@@ -2352,7 +2392,7 @@ async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise<stri
{
success: true,
message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`,
item: stored.type === 'variable' ? serializeWorkspaceItemForRead(stored) : stored
item: serializedItem
},
null,
2
@@ -15,9 +15,27 @@
workspaceId: string
scope: string
initialScope?: string
readOnly?: boolean
}
let { workspaceId, scope = $bindable(), initialScope }: Props = $props()
let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props()
// Endpoints we can actually advertise to a read-only MCP token. Mirrors the
// runner's filter (only GET endpoints).
const visibleEndpointTools = $derived(
readOnly ? mcpEndpointTools.filter((e) => e.method === 'GET') : mcpEndpointTools
)
// When read-only flips on, prune already-selected non-GET endpoints so the
// scope string doesn't keep references to tools the server will reject.
$effect(() => {
if (!readOnly || selectedEndpoints.length === 0) return
const allowed = new Set(visibleEndpointTools.map((e) => e.name))
const filtered = selectedEndpoints.filter((n) => allowed.has(n))
if (filtered.length !== selectedEndpoints.length) {
selectedEndpoints = filtered
}
})
const parsedInitial = parseInitialScope(initialScope)
@@ -410,7 +428,7 @@
selectedFlows = []
}
function selectAllEndpoints() {
selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)]
selectedEndpoints = [...visibleEndpointTools.map((e) => e.name)]
}
function clearAllEndpoints() {
selectedEndpoints = []
@@ -529,7 +547,7 @@
<div class="flex flex-col gap-2 mt-2">
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)}
<MultiSelect
items={safeSelectItems(mcpEndpointTools.map((e) => e.name))}
items={safeSelectItems(visibleEndpointTools.map((e) => e.name))}
placeholder="Select endpoints"
bind:value={selectedEndpoints}
/>
@@ -594,29 +612,35 @@
</div>
{:else}
<div class="flex flex-col gap-2">
<span class="block text-xs">Scripts & Flows that will be available via MCP</span>
<div class="flex flex-wrap gap-1">
{#if includedRunnables.length > 0 && includedRunnables.length <= 5}
{#each includedRunnables as scriptOrFlow (scriptOrFlow)}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
{:else if includedRunnables.length > 0}
{#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
<Badge rounded small color="dark-gray">
+{includedRunnables.length - 3} more
</Badge>
{:else}
<p class="text-xs text-primary">
{warning}
</p>
{/if}
</div>
{#if !readOnly}
<span class="block text-xs">Scripts & Flows that will be available via MCP</span>
<div class="flex flex-wrap gap-1">
{#if includedRunnables.length > 0 && includedRunnables.length <= 5}
{#each includedRunnables as scriptOrFlow (scriptOrFlow)}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
{:else if includedRunnables.length > 0}
{#each includedRunnables.slice(0, 3) as scriptOrFlow (scriptOrFlow)}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
<Badge rounded small color="dark-gray">
+{includedRunnables.length - 3} more
</Badge>
{:else}
<p class="text-xs text-primary">
{warning}
</p>
{/if}
</div>
{:else}
<p class="text-xs text-tertiary">
Scripts and flows are hidden because this token is read-only.
</p>
{/if}
<span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span>
<div class="flex flex-wrap gap-1">
{#each mcpEndpointTools as endpoint (endpoint.name)}
{#each visibleEndpointTools as endpoint (endpoint.name)}
<Popover notClickable>
{#snippet text()}
<div class="flex flex-col gap-1">
@@ -23,14 +23,14 @@ export function buildRunsFilterSearchbarSchema({
usernames,
folders,
jobTriggerKinds,
isSuperAdmin,
isSuperAdminOrDevops,
isAdminsWorkspace
}: {
paths: string[]
usernames: string[]
folders: string[]
jobTriggerKinds: JobTriggerKind[]
isSuperAdmin: boolean
isSuperAdminOrDevops: boolean
isAdminsWorkspace: boolean
}) {
return {
@@ -206,12 +206,12 @@ export function buildRunsFilterSearchbarSchema({
label: 'Show future jobs (Default: true)',
description: 'Include jobs that are planned later'
},
...(isSuperAdmin &&
...(isSuperAdminOrDevops &&
isAdminsWorkspace && {
all_workspaces: {
type: 'boolean' as const,
label: 'All workspaces',
description: 'Show jobs of all workspaces (superadmin only)'
description: 'Show jobs of all workspaces (superadmin or devops only)'
}
})
} satisfies FilterSchemaRec
@@ -230,16 +230,16 @@ export function allowWildcards(filters: Partial<RunsFilterInstance> | undefined)
}
export const buildRunsFilterPresets = ({
isSuperadmin,
isSuperAdminOrDevops,
isAdminsWorkspace
}: {
isSuperadmin: boolean
isSuperAdminOrDevops: boolean
isAdminsWorkspace: boolean
}) => [
{ name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' },
{ name: 'Hide future jobs', value: 'show_future_jobs:\\ false' },
{ name: 'Show skipped', value: 'show_skipped:\\ true' },
...(isSuperadmin && isAdminsWorkspace
...(isSuperAdminOrDevops && isAdminsWorkspace
? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }]
: [])
]
@@ -46,6 +46,7 @@
let mcpLabelAutofilled = $state(false)
let pickedScopes = $state<string[] | null>(null)
let readOnly = $state(false)
function ensureCurrentWorkspaceIncluded(
workspacesList: UserWorkspace[],
@@ -67,6 +68,7 @@
newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore
newToken = undefined
newMcpToken = undefined
readOnly = false
if (!newTokenLabel) {
newTokenLabel = 'MCP token'
mcpLabelAutofilled = true
@@ -80,6 +82,7 @@
newTokenExpiration = undefined
newTokenWorkspace = defaultNewTokenWorkspace
newMcpToken = undefined
readOnly = false
if (mcpLabelAutofilled) {
newTokenLabel = undefined
}
@@ -100,7 +103,8 @@
label: newTokenLabel,
expiration: date?.toISOString(),
scopes: tokenScopes,
workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace
workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace,
read_only: readOnly
} as NewToken
})
@@ -184,6 +188,17 @@
{#each scopes as scope (scope)}
<TextInput inputProps={{ disabled: true }} value={scope} class="mb-2 w-full" />
{/each}
<div class="text-tertiary">
<Toggle
bind:checked={readOnly}
options={{
right: 'Read-only',
rightTooltip:
'Restricts this token to GET/HEAD endpoints. Any mutating request (POST/PUT/PATCH/DELETE) or job-run action will be rejected with 403, regardless of the scopes listed above.'
}}
size="2xs"
/>
</div>
</div>
{/if}
@@ -192,6 +207,7 @@
mode={mcpCreationMode ? 'mcp' : 'standard'}
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:value={pickedScopes}
bind:readOnly
/>
{/if}
@@ -7,10 +7,14 @@
import Tooltip from '../Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
interface Props {
selectedScopes?: string[]
disabled?: boolean
class?: string
/** Renders above the scope-list card, below the Selected Scopes summary. */
topSlot?: Snippet
}
interface ScopeState {
@@ -30,7 +34,12 @@
domains: Record<string, DomainState>
}
let { selectedScopes = $bindable([]), disabled = false, class: className = '' }: Props = $props()
let {
selectedScopes = $bindable([]),
disabled = false,
class: className = '',
topSlot
}: Props = $props()
let scopeDomains = $state<ScopeDomain[] | null>(null)
let loading = $state(false)
@@ -535,6 +544,12 @@
{/if}
</div>
{#if topSlot}
<div class="mb-3">
{@render topSlot()}
</div>
{/if}
<div class="max-h-96 overflow-y-auto border rounded-md">
{#each scopeDomains as domain}
{@const domainState = getDomainState(domain.name)}
@@ -10,9 +10,28 @@
initialScopes?: string[]
/** Final scope value: null = unrestricted/full access, array = explicit list */
value: string[] | null
/** Read-only flag; also forwarded to McpScopeSelector to filter incompatible
* endpoints/runnables. Two-way bound so the inline toggle below the
* "Limit token permissions" switch (and the MCP variant) writes back. */
readOnly?: boolean
}
let { mode, workspaceId = '', initialScopes, value = $bindable() }: Props = $props()
let {
mode,
workspaceId = '',
initialScopes,
value = $bindable(),
readOnly = $bindable(false)
}: Props = $props()
// In standard mode, only meaningful when the user has turned "Limit token
// permissions" on. Reset when they un-limit so the flag doesn't quietly
// stick if they re-enable later.
$effect(() => {
if (mode === 'standard' && !limited && readOnly) {
readOnly = false
}
})
const initialMcpScope = $derived(
(initialScopes ?? []).length > 0 ? (initialScopes ?? []).join(' ') : undefined
@@ -51,9 +70,41 @@
size="xs"
/>
{#if limited}
<ScopeSelector bind:selectedScopes={standardScopes} />
<ScopeSelector bind:selectedScopes={standardScopes}>
{#snippet topSlot()}
<div class="text-tertiary">
<Toggle
bind:checked={readOnly}
options={{
right: 'Read-only',
rightTooltip:
'Restricts this token to GET/HEAD endpoints. Any mutating request (POST/PUT/PATCH/DELETE) or job-run action will be rejected with 403, regardless of the scopes selected below.'
}}
size="2xs"
/>
</div>
{/snippet}
</ScopeSelector>
{/if}
</div>
{:else}
<McpScopeSelector {workspaceId} bind:scope={mcpScope} initialScope={initialMcpScope} />
<div class="flex flex-col gap-2">
<div class="text-tertiary">
<Toggle
bind:checked={readOnly}
options={{
right: 'Read-only',
rightTooltip:
'Restricts this MCP URL to read-only endpoints. The LLM will only see read tools — script and flow runs will be hidden.'
}}
size="2xs"
/>
</div>
<McpScopeSelector
{workspaceId}
bind:scope={mcpScope}
initialScope={initialMcpScope}
{readOnly}
/>
</div>
{/if}
@@ -170,7 +170,7 @@
{#snippet body()}
<tbody>
{#if tokens && tokens.length > 0}
{#each tokens as { token_prefix, expiration, label, scopes, workspace_id } (token_prefix)}
{#each tokens as { token_prefix, expiration, label, scopes, workspace_id, read_only } (token_prefix)}
{@const badge = expirationBadge(expiration, label)}
<tr>
<td class="w-32 text-xs text-primary">{token_prefix}****</td>
@@ -185,8 +185,15 @@
</td>
<td
class="min-w-0 max-w-48 truncate text-xs text-secondary"
title={scopes?.join(', ') ?? ''}>{scopes?.join(', ') ?? ''}</td
title={scopes?.join(', ') ?? ''}
>
<div class="flex items-center gap-1.5 truncate">
{#if read_only}
<Badge color="blue" small>Read-only</Badge>
{/if}
<span class="truncate">{scopes?.join(', ') ?? ''}</span>
</div>
</td>
<td class="w-24 text-center">
<div class="flex items-center justify-center gap-1">
<Button
+89 -1
View File
@@ -7,7 +7,13 @@ import {
} from '$lib/gen'
import { get, writable } from 'svelte/store'
import type { Schema, SupportedLanguage } from './common.js'
import { emptySchema, getHubFlowIdFromPath, isHubFlowPath, sortObject } from './utils.js'
import {
type DynamicInput,
emptySchema,
getHubFlowIdFromPath,
isHubFlowPath,
sortObject
} from './utils.js'
import { tick } from 'svelte'
import initTsParser, { parse_deno, parse_outputs } from 'windmill-parser-wasm-ts'
@@ -286,6 +292,88 @@ const SQL_LANGUAGES = [
'duckdb'
]
/**
* Returns the parameter names of `entrypoint` in `code` (or `main` if not given),
* or `undefined` if the function can't be found, the code can't be parsed, the
* language isn't supported here, or the signature contains rest/keyword args
* (in which case the callee should fall back to a conservative full comparison).
*
* Lighter than {@link inferArgs} — does not touch any schema.
*/
export async function parseEntrypointArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
entrypoint?: string
): Promise<Set<string> | undefined> {
if (!code) return undefined
try {
let sig: MainArgSignature
if (language === 'python3') {
await initWasmPython()
sig = JSON.parse(parse_python(code, entrypoint))
} else if (
language === 'deno' ||
language === 'nativets' ||
language === 'bun' ||
language === 'bunnative'
) {
await initWasmTs()
sig = JSON.parse(parse_deno(code, entrypoint))
} else {
return undefined
}
if (sig.type === 'Invalid') return undefined
if (sig.star_args || sig.star_kwargs) return undefined
if (!Array.isArray(sig.args)) return undefined
// The parser sets auto_kind when no matching entrypoint function was
// found — empty args in that case means "unknown signature", not
// "function takes no params", so we fall back to a full comparison.
if (sig.args.length === 0 && sig.auto_kind != null) return undefined
return new Set(sig.args.map((a) => a.name))
} catch {
return undefined
}
}
const helperEntrypointCache = new Map<string, Set<string> | undefined>()
/**
* Resolves a {@link DynamicInput.HelperScript} to its entrypoint parameter
* names. For deployed helpers it fetches the script (or the flow's inline
* dyn-select code) once and caches the result per workspace+path+entrypoint.
*/
export async function getHelperEntrypointArgs(
helper: DynamicInput.HelperScript,
entrypoint?: string
): Promise<Set<string> | undefined> {
if (helper.source === 'inline') {
return parseEntrypointArgs(helper.lang, helper.code, entrypoint)
}
const workspace = get(workspaceStore)
if (!workspace) return undefined
const cacheKey = `${workspace}::${helper.runnable_kind}::${helper.path}::${entrypoint ?? ''}`
if (helperEntrypointCache.has(cacheKey)) return helperEntrypointCache.get(cacheKey)
let result: Set<string> | undefined
try {
if (helper.runnable_kind === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path: helper.path })
result = await parseEntrypointArgs(script.language, script.content ?? '', entrypoint)
} else {
const flow = await FlowService.getFlowByPath({ workspace, path: helper.path })
const schema = flow.schema as Record<string, unknown> | undefined
const code = schema?.['x-windmill-dyn-select-code']
const lang = schema?.['x-windmill-dyn-select-lang']
if (typeof code === 'string' && typeof lang === 'string') {
result = await parseEntrypointArgs(lang as SupportedLanguage, code, entrypoint)
}
}
} catch {
result = undefined
}
helperEntrypointCache.set(cacheKey, result)
return result
}
export async function inferArgs(
language: SupportedLanguage | 'bunnative' | undefined,
code: string,
@@ -565,14 +565,16 @@
})
}
menuItems.push({
label: 'Delete',
Icon: Trash,
onclick: async () => {
deleteScript(script.hash)
},
color: 'red'
})
if ($userStore?.is_admin) {
menuItems.push({
label: 'Delete',
Icon: Trash,
onclick: async () => {
deleteScript(script.hash)
},
color: 'red'
})
}
}
return menuItems
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.700.2"
wmill = ">=1.702.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.700.2
version: 1.702.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.700.2'
ModuleVersion = '1.702.1'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.700.2"
version = "1.702.1"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+15
View File
@@ -38,6 +38,21 @@ python system_prompts/generate.py --plugin-dir ~/windmill-claude-plugin
- a plugin root such as `plugins/windmill-code-plugin`
- a direct `skills/` directory
To regenerate the public docs repo (consumed by context7):
```bash
python system_prompts/generate.py --context7-dir ~/windmill-cli-docs
```
`--context7-dir` writes a fully-rendered snapshot (`AGENTS.md`,
`cli-commands.md`, `skills/<name>/SKILL.md`, `README.md`, `manifest.json`
with the Windmill `version`) with all template placeholders resolved —
suitable for ingestion by docs aggregators. In CI this runs from
`.github/workflows/publish-cli-docs.yml` on every release tag. The
generator refuses to wipe the target directory unless it's empty or has
a context7 marker (`context7.json`, `manifest.json`, or a
`windmill-cli-docs` git remote), so a typo can't delete unrelated files.
This will:
1. Parse TypeScript and Python SDK files to extract function signatures
+404
View File
@@ -12,6 +12,7 @@ This script:
Usage:
python generate.py
python generate.py --plugin-dir /path/to/windmill-claude-plugin
python generate.py --context7-dir /path/to/windmill-cli-docs
"""
import argparse
@@ -1778,6 +1779,395 @@ def generate_plugin_skills(
return skills_dir
# =============================================================================
# Context7 Docs Repo Generation
# =============================================================================
# Files in the context7 target directory that must survive a regeneration
# (everything else is wiped to keep the export deterministic).
CONTEXT7_PRESERVE = frozenset(
{
".git",
".github",
".gitignore",
".gitattributes",
"CODEOWNERS",
"LICENSE",
"LICENSE.md",
"context7.json",
}
)
# Name written into manifest.json — also used to recognise the docs repo
# when re-generating into an existing checkout.
CONTEXT7_REPO_NAME = "windmill-cli-docs"
def extract_agents_md_template() -> str:
"""Extract the AGENTS.md template string from cli/src/guidance/core.ts.
Keeping a single source of truth in TypeScript avoids drift between what
`wmill init` writes locally and what we publish for context7 ingestion.
"""
core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts"
content = core_ts_path.read_text()
# Anchor on the function name so adding other template-literal-returning
# functions to core.ts can't silently re-target the regex.
match = re.search(
r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;",
content,
)
if not match:
raise RuntimeError(
f"Could not extract AGENTS.md template from {core_ts_path}"
)
return _unescape_ts_template_literal(match.group(1))
def _unescape_ts_template_literal(raw: str) -> str:
"""Decode TS template-literal escapes in one pass.
Multi-pass `.replace()` would mangle e.g. `\\\\` -> `\\` -> `` ` `` if the
template ever contained a literal backslash followed by a backtick. A
single-pass scan is order-independent.
"""
return re.sub(
r"\\(.)",
lambda m: {"`": "`", "$": "$", "\\": "\\"}.get(m.group(1), m.group(0)),
raw,
)
def render_agents_md_for_docs(
skills: list[str], skill_desc_map: dict[str, str]
) -> str:
"""Render AGENTS.md exactly as `wmill init` would, for the docs repo."""
template = extract_agents_md_template()
skills_reference = "\n".join(
f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}"
for name in skills
if name in skill_desc_map
)
return template.replace("${skillsReference}", skills_reference)
def build_skill_desc_map(skills: list[str]) -> dict[str, str]:
"""Map each skill name to its user-facing description.
Mirrors the logic in `generate_skills_ts_export`: language skills draw from
LANGUAGE_METADATA, everything else from SKILL_DEFINITIONS.
"""
desc_map = {s["name"]: s["description"] for s in SKILL_DEFINITIONS}
for skill in skills:
if skill.startswith("write-script-"):
lang_key = skill.replace("write-script-", "")
metadata = LANGUAGE_METADATA.get(lang_key)
if metadata:
desc_map[skill] = metadata["description"]
return desc_map
def _looks_like_windmill_manifest(path: Path) -> bool:
"""Return True iff `path` is a JSON file whose top-level `name` is ours.
Used to distinguish a previously-generated docs repo from an unrelated
project that happens to have a `manifest.json` (Chrome extensions, npm
packages, web app manifests, etc.).
"""
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return False
return isinstance(data, dict) and data.get("name") == CONTEXT7_REPO_NAME
def _verify_context7_target(target_dir: Path) -> None:
"""Refuse to wipe a non-empty dir that doesn't look like the docs repo.
A typo such as `--context7-dir .`, `~`, or the wrong checkout could
otherwise nuke unrelated files. We accept the target if it's empty/new,
if it has our ownership file, if its `manifest.json` self-identifies as
the windmill-cli-docs repo, or if its git origin points at one.
"""
if not target_dir.exists() or not any(target_dir.iterdir()):
return
if (target_dir / "context7.json").exists():
return
manifest_path = target_dir / "manifest.json"
if manifest_path.exists() and _looks_like_windmill_manifest(manifest_path):
return
git_dir = target_dir / ".git"
if git_dir.exists():
import subprocess
try:
origin = subprocess.run(
["git", "-C", str(target_dir), "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
if CONTEXT7_REPO_NAME in origin:
return
except subprocess.CalledProcessError:
pass
raise RuntimeError(
f"Refusing to overwrite {target_dir}: target does not look like the "
f"{CONTEXT7_REPO_NAME} docs repo.\n"
f"Expected one of:\n"
f" - a `context7.json` at the top level,\n"
f" - a `manifest.json` whose top-level `name` is {CONTEXT7_REPO_NAME!r},\n"
f" - a git remote `origin` containing '{CONTEXT7_REPO_NAME}'.\n"
f"If this is the right directory, add a `context7.json` and retry."
)
def clear_context7_dir(target_dir: Path) -> None:
"""Wipe the docs repo dir of previously generated content.
Preserves a small allowlist (.git, .github, LICENSE, context7.json, etc.)
so this can run against a real checkout without nuking version control or
CI config.
"""
if not target_dir.exists():
return
for entry in target_dir.iterdir():
if entry.name in CONTEXT7_PRESERVE:
continue
if entry.is_dir():
shutil.rmtree(entry)
else:
entry.unlink()
def _read_windmill_version() -> str | None:
"""Return the Windmill release version (e.g. '1.700.2'), or None if absent.
Sourced from `version.txt` at the repo root — the same file release-please
updates on every release.
"""
version_file = SCRIPT_DIR.parent / "version.txt"
if not version_file.exists():
return None
return version_file.read_text().strip() or None
def generate_context7_repo(
target_dir: Path,
skills: list[str],
schema_yaml_content: dict[str, str],
cli_commands_md: str,
) -> Path:
"""Generate a fully-rendered docs repo suitable for context7 ingestion.
Layout written to `target_dir`:
AGENTS.md # the prompt agents see in their projects
README.md # stable intro for humans / context7
manifest.json # version + skill list (for indexing)
cli-commands.md # full CLI flag reference
skills/<name>/SKILL.md # one rendered skill per file
"""
target_dir = target_dir.expanduser().resolve()
target_dir.mkdir(parents=True, exist_ok=True)
_verify_context7_target(target_dir)
clear_context7_dir(target_dir)
skill_desc_map = build_skill_desc_map(skills)
# AGENTS.md — the same content `wmill init` writes locally.
(target_dir / "AGENTS.md").write_text(
render_agents_md_for_docs(skills, skill_desc_map)
)
# Full CLI reference at top level.
(target_dir / "cli-commands.md").write_text(cli_commands_md)
# One markdown per skill, with schemas inlined (no template placeholders).
skills_dir = target_dir / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
for skill_name in skills:
skill_dir = skills_dir / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
render_plugin_skill_content(skill_name, schema_yaml_content)
)
# Stable README so the GitHub repo landing page tells readers (and
# context7's crawler) what they're looking at.
(target_dir / "README.md").write_text(_context7_readme(skills))
# Machine-readable index for context7 / downstream consumers.
# Note: the `name` field is also the marker `_verify_context7_target`
# uses to distinguish our `manifest.json` from generic ones.
manifest = {
"name": CONTEXT7_REPO_NAME,
"description": (
"Auto-generated Windmill CLI docs: agent prompt, skills, and "
"full CLI reference. Source: github.com/windmill-labs/windmill."
),
"skills": [
{"name": name, "description": skill_desc_map.get(name, "")}
for name in skills
],
}
version = _read_windmill_version()
if version:
manifest["version"] = version
(target_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2) + "\n"
)
print(f"\nGenerated for context7 docs repo:")
print(f" - {target_dir} ({len(skills)} skills + AGENTS.md + cli-commands.md)")
return target_dir
def _context7_readme(skills: list[str]) -> str:
"""Render the README that ships at the root of the docs repo.
Doubles as a CLI quickstart for humans landing on the GitHub page and as
the top-level entry point context7 indexes first — keep it actionable.
"""
skill_lines = "\n".join(f"- `skills/{name}/SKILL.md`" for name in skills)
return f"""# Windmill CLI Quickstart
[`wmill`](https://www.windmill.dev/docs/advanced/cli) is the official command
line interface for [Windmill](https://www.windmill.dev) — an open-source
platform for internal tools, workflows, API integrations, background jobs, and
UIs. Use it to authenticate against a workspace, scaffold local projects,
sync scripts/flows/apps between your filesystem and a workspace, and run or
debug jobs from your terminal.
## Install
```sh
npm install -g windmill-cli
wmill --version
```
Upgrade later with `wmill upgrade`.
## Connect to a workspace
```sh
wmill workspace add
```
This walks you through adding a workspace profile — a `(name, remote URL,
workspace id, token)` tuple stored under `~/.config/windmill`. You can have
multiple profiles and switch between them with `wmill workspace switch <name>`.
A workspace token is created from the Windmill UI under
`User Settings → Tokens`. For self-hosted instances, point the remote at your
own URL (e.g. `https://windmill.example.com`).
## Initialize a project directory
```sh
wmill init
```
`wmill init` creates:
- `wmill.yaml` — sync configuration (which folders/types to track).
- `AGENTS.md` + `CLAUDE.md` — the agent prompt published in this repo.
- `.claude/skills/` and `.agents/skills/` — per-task guides used by AI coding
assistants (Claude Code, Codex, Pi). These are the same `SKILL.md` files
you'll find under `skills/` in this repo.
It also offers to bind a workspace profile to the current git branch and to
import git-sync settings from the backend if any are configured.
## Sync between local files and a workspace
```sh
wmill sync pull # workspace → local (writes flows, scripts, apps, etc.)
wmill sync push # local → workspace
```
Sync is idempotent and diff-aware: `wmill sync push --dry-run` previews the
changes without applying them. Use `--yaml` (recommended) to keep specs as
YAML rather than JSON.
For individual entities you can also use the type-specific commands:
```sh
wmill script push path/to/script.ts
wmill flow push path/to/flow.yaml
wmill app push path/to/app.yaml
wmill resource push path/to/resource.yaml
```
## Run, inspect, and debug jobs
```sh
wmill script run u/me/my_script --data '{{"foo": "bar"}}'
wmill flow run u/me/my_flow --data @inputs.json
wmill job list --failed --limit 20
wmill job get <job_id>
wmill job logs <job_id>
```
Logs and flow steps stream as the job runs. For flow failures, `wmill job get`
shows the step tree with each sub-job's id so you can drill in with
`wmill job logs <sub_job_id>`.
## Scaffold new entities
```sh
wmill script new u/me/path --language bun
wmill flow new u/me/path --summary "..."
wmill app new u/me/path --summary "..." --framework svelte
```
These create the correct folder layout and a minimal spec file, then print
next-step hints. Prefer them over hand-creating the folders — they pick the
right naming conventions for your workspace.
## Triggers and schedules
Triggers (HTTP routes, WebSocket, Kafka, NATS, MQTT, SQS, GCP Pub/Sub, Azure
Event Hubs, Email, Postgres CDC) and cron schedules are tracked as YAML files
synced alongside your scripts and flows. See `skills/triggers/SKILL.md` and
`skills/schedules/SKILL.md` for the full schemas.
## Completion
```sh
source <(wmill completions bash) # bash, zsh: source <(wmill completions zsh)
source (wmill completions fish | psub) # fish
```
## Reference
- `cli-commands.md` — every `wmill` command and flag, generated from the
source.
- `AGENTS.md` — the top-level prompt the CLI installs into each project (and
the same instructions AI coding assistants follow when working in a
Windmill repo).
- `skills/<name>/SKILL.md` — one self-contained guide per common task.
### Skills index
{skill_lines}
## About this repo
Auto-generated mirror of the Windmill CLI's bundled AI-agent guidance and
command reference, published for ingestion by docs aggregators such as
[context7](https://context7.com).
**Do not edit by hand.** This repo is regenerated from
[windmill-labs/windmill](https://github.com/windmill-labs/windmill) on every
release. Open issues and PRs in the source repo, not here. The generator is
`system_prompts/generate.py --context7-dir`.
"""
# =============================================================================
# Main Entry Point
# =============================================================================
@@ -1799,6 +2189,15 @@ def parse_args() -> argparse.Namespace:
"a plugin root, or a skills directory, and refreshes standalone skills there."
),
)
parser.add_argument(
"--context7-dir",
type=Path,
help=(
"Optional path to a docs-repo checkout (e.g. windmill-cli-docs). "
"Writes AGENTS.md, cli-commands.md, skills/, README.md, and manifest.json "
"with all placeholders resolved, suitable for context7 ingestion."
),
)
return parser.parse_args()
@@ -2117,6 +2516,11 @@ export declare function getWorkflowAsCodePrompt(language?: string): string;
if args.plugin_dir:
generate_plugin_skills(args.plugin_dir, skills, schema_yaml_content)
if args.context7_dir:
generate_context7_repo(
args.context7_dir, skills, schema_yaml_content, cli_commands
)
print("\nDone!")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.700.2",
"version": "1.702.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.700.2",
"version": "1.702.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.700.2
1.702.1