mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
Merge remote-tracking branch 'origin/main' into change-24583b5f
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script'
|
||||
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global'
|
||||
|
||||
export type FrontendBenchmarkProgressEvent =
|
||||
| {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
Generated
+88
-88
@@ -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
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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(());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -330,6 +330,7 @@ async fn inject_agent_authed(
|
||||
scopes: None,
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
},
|
||||
job_id: None,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+2
-2
@@ -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,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'}
|
||||
|
||||
@@ -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 "User" 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 "User" 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
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.700.2"
|
||||
wmill = ">=1.702.1"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
@@ -1 +1 @@
|
||||
1.700.2
|
||||
1.702.1
|
||||
|
||||
Reference in New Issue
Block a user