Merge remote-tracking branch 'origin/main' into remove-workspace-drafts

# Conflicts:
#	frontend/src/lib/components/copilot/chat/global/core.test.ts
This commit is contained in:
Diego Imbert
2026-05-28 17:33:30 +02:00
34 changed files with 2231 additions and 273 deletions
+16 -3
View File
@@ -142,6 +142,15 @@ For `global` mode, `validate` can express draft-level requirements such as:
- required or forbidden draft counts
- forbidden draft paths
Global initial fixtures can also seed `liveEditorDrafts` with `type`,
`storagePath`, `effectivePath`, and `value` fields. These drafts emulate the
currently open script, flow, or raw app editor so cases can test prompts that
refer to "this" or the "current" item.
Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with
the old behavior where the live editor is only discoverable through
`list_workspace_items`.
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
@@ -189,11 +198,15 @@ If `--record` is used, the CLI also appends one compact JSON line to:
Each recorded line contains:
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`, pass rate)
- `failedCaseIds`
The CLI headline duration and token averages use passed attempts only.
All-attempt averages are still recorded to make failures auditable without
letting failed attempts skew success cost comparisons.
Example:
- summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json`
@@ -7,8 +7,12 @@ import {
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
import {
clearGlobalDrafts,
listGlobalDrafts,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -24,6 +28,21 @@ const MUTATING_GLOBAL_TOOLS = new Set([
"deploy_workspace_item",
"delete_workspace_item",
]);
const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV =
"WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT";
const LIVE_EDITOR_ITEM_KINDS = {
script: "script",
flow: "flow",
app: "raw_app",
} as const;
export interface GlobalLiveEditorDraftFixture {
type: keyof typeof LIVE_EDITOR_ITEM_KINDS;
storagePath?: string;
effectivePath?: string;
value?: unknown;
}
export interface GlobalEvalResult {
success: boolean;
@@ -38,6 +57,7 @@ export interface GlobalEvalResult {
export interface GlobalEvalOptions {
workspaceFixtures?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
model?: string;
maxIterations?: number;
provider?: AIProvider;
@@ -55,19 +75,26 @@ export async function runGlobalEval(
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
globalDraftStore.clearDrafts(workspaceRoot);
clearGlobalDrafts(workspaceRoot);
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const injectActiveEditorContext =
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(),
userMessage: prepareGlobalUserMessage(userPrompt),
userMessage: prepareGlobalUserMessage(
userPrompt,
[],
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
),
tools: getGlobalEvalTools(),
helpers: {},
apiKey,
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }),
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
@@ -94,7 +121,8 @@ export async function runGlobalEval(
tokenUsage: rawResult.tokenUsage,
};
} finally {
globalDraftStore.clearDrafts(workspaceRoot);
clearGlobalDrafts(workspaceRoot);
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
if (!options.workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true });
@@ -102,6 +130,36 @@ export async function runGlobalEval(
}
}
function seedLiveEditorDrafts(
workspace: string,
fixtures: GlobalLiveEditorDraftFixture[],
): void {
for (const fixture of fixtures) {
const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type];
const storagePath = fixture.storagePath ?? fixture.effectivePath ?? "";
if (fixture.value !== undefined) {
UserDraft.save(itemKind, storagePath, fixture.value, { workspace });
}
UserDraft.setLiveEditorDraft({
workspace,
itemKind,
storagePath,
effectivePath: fixture.effectivePath ?? fixture.storagePath,
});
}
}
function clearLiveEditorDrafts(
workspace: string,
fixtures: GlobalLiveEditorDraftFixture[],
): void {
for (const fixture of fixtures) {
const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type];
const storagePath = fixture.storagePath ?? fixture.effectivePath ?? "";
UserDraft.clearLiveEditorDraft(itemKind, { workspace, storagePath });
}
}
function getGlobalEvalTools(): ProductionTool<{}>[] {
return (globalTools as ProductionTool<{}>[]).map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
@@ -79,6 +79,16 @@ vi.mock('$lib/gen', async () => {
}
return actual.ScriptService.getScriptByPath(data)
},
getScriptByPathWithDraft: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByPath(data.workspace, data.path)
if (!script) {
throw new Error(`Script "${data.path}" not found in benchmark workspace`)
}
return script
}
return actual.ScriptService.getScriptByPathWithDraft(data)
},
getScriptByHash: async (data: { workspace: string; hash: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByHash(data.workspace, data.hash)
@@ -108,6 +118,26 @@ vi.mock('$lib/gen', async () => {
return flow
}
return actual.FlowService.getFlowByPath(data)
},
getFlowByPathWithDraft: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
if (!flow) {
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
}
return flow
}
return actual.FlowService.getFlowByPathWithDraft(data)
},
getFlowLatestVersion: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
if (!flow) {
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
}
return { id: 1 }
}
return actual.FlowService.getFlowLatestVersion(data)
}
}),
JobService: wrapService(actual.JobService, {
+343
View File
@@ -87,3 +87,346 @@
- 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
- id: global-test4-multi-artifact-notification-job
prompt: |-
Set up a draft stale-trial notification job.
Create a Bun script at `f/evals/global/check_stale_trials` that accepts `max_age_days`, uses mocked inline trial account data, and returns the stale trial account IDs.
Also create a weekday 09:00 UTC schedule at `f/evals/global/check_stale_trials_weekday` for that script with `max_age_days` set to 14.
Add an HTTP POST trigger at `f/evals/global/check_stale_trials_manual` with route path `evals/check-stale-trials` that runs the same script manually.
Leave everything as AI drafts only; do not deploy or save anything to the workspace.
runtime:
maxTurns: 12
validate:
draftCountExactly: 3
requiredDrafts:
- type: script
path: f/evals/global/check_stale_trials
language: bun
valueIncludes:
- max_age_days
- trial
- type: schedule
path: f/evals/global/check_stale_trials_weekday
valueIncludes:
- f/evals/global/check_stale_trials
- UTC
- "14"
- type: trigger
triggerKind: http
path: f/evals/global/check_stale_trials_manual
valueIncludes:
- evals/check-stale-trials
- f/evals/global/check_stale_trials
toolExpect:
requiredToolsUsed:
- write_script
- write_schedule
- write_trigger
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a Bun script draft for stale trial accounts
- creates a weekday 09:00 UTC schedule draft for the script with max_age_days set to 14
- creates an HTTP POST trigger draft with route path evals/check-stale-trials for the same script
- leaves all artifacts as drafts only and does not deploy
- id: global-test5-existing-flow-inline-code-edit
prompt: |-
Update the existing flow at `f/evals/global/process_invoice`.
Only change the `calculate_total` inline code so it applies 8% tax and returns an object containing `subtotal`, `tax`, and `total`.
Leave the updated flow as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/process_invoice
valueIncludes:
- calculate_total
- tax
- total
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- reads the existing process_invoice flow before editing it
- updates the calculate_total inline code to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as an AI draft only
- id: global-test6-secret-variable-draft
prompt: |-
Create a secret variable draft at `f/evals/global/slack_bot_token`.
Use the placeholder value `xoxb-redacted-test-token` and description `Slack bot token for eval notifications`.
Do not create any resource or deploy anything.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
path: f/evals/global/slack_bot_token
valueIncludes:
- Slack bot token
- "true"
forbiddenDrafts:
- type: resource
path: f/evals/global/slack_bot_token
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates exactly one secret variable draft at f/evals/global/slack_bot_token
- uses the requested placeholder value and description
- does not create a resource or deploy anything
- id: global-test7-ambiguous-app-asks-question
prompt: |-
Create a new raw app for triaging support tickets.
runtime:
maxTurns: 4
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- askUserQuestion
forbiddenToolsUsed:
- init_app
- write_app_file
- write_app_runnable
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
- id: global-test8-human-script-infer-path-language
prompt: |-
I need a small helper that formats a customer-facing welcome line.
It should take a person's name and return "Welcome aboard, <name>!".
Please just stage it as a draft for now.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
valueIncludes:
- Welcome aboard
- name
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a single script draft for a welcome-line helper
- accepts a person's name as input
- returns a message containing Welcome aboard, the provided name, and an exclamation mark
- chooses a reasonable workspace path and script language without needing the user to specify them
- leaves the result as an AI draft only
- id: global-test9-human-weekday-trial-job
prompt: |-
Can you set up a draft daily job that checks a few hard-coded trial accounts and returns the ones whose trial has ended?
It should run every weekday morning around 9 in UTC with a 30 day cutoff.
Keep it as draft work only.
runtime:
maxTurns: 10
validate:
draftCountExactly: 2
requiredDrafts:
- type: script
pathIncludes:
- trial
valueIncludes:
- trial
- "30"
- type: schedule
pathIncludes:
- trial
valueIncludes:
- UTC
toolExpect:
requiredToolsUsed:
- write_script
- write_schedule
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a script draft that checks hard-coded trial accounts
- returns the accounts whose trial has ended based on a 30 day cutoff
- creates a schedule draft for weekday mornings around 09:00 UTC
- links the schedule to the generated script
- leaves both artifacts as drafts only
- id: global-test10-human-secret-variable
prompt: |-
I need a placeholder Slack bot token stored securely for future notification work.
Use xoxb-redacted-test-token and note that it is for eval notifications.
Only prepare a draft.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
pathIncludes:
- slack
valueIncludes:
- eval notifications
- "true"
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates a single secret variable draft for the Slack bot token placeholder
- uses the requested placeholder value
- includes a note or description that it is for eval notifications
- does not create a resource or deploy anything
- id: global-test11-human-existing-flow-informal-edit
prompt: |-
There is an invoice processing flow in this workspace.
Can you adjust its total calculation so it adds 8% tax and returns subtotal, tax, and total?
Keep the change as a draft.
initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
pathIncludes:
- invoice
valueIncludes:
- calculate_total
- tax
- total
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- finds and edits the existing invoice processing flow without the user providing its exact path
- updates the total calculation to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as an AI draft only
- id: global-test12-current-live-script-edit
prompt: |-
The script I have open formats greetings.
Can you update this script so it uppercases the name before greeting them and ends with an exclamation mark?
Keep it as draft work.
initial: ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/current_greeting
language: bun
valueIncludes:
- toUpperCase
- "!"
forbiddenDrafts:
- type: script
path: f/evals/global/format_greeting
- type: script
path: f/evals/global/format_greeting_archive
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- resolves "this script" to the active live editor script instead of another similarly named workspace script
- updates the greeting logic to uppercase the provided name
- returns a greeting ending with an exclamation mark
- leaves the result as a draft only
- id: global-test13-current-live-flow-edit
prompt: |-
I have the invoice flow open.
In the current flow, update the total calculation to add 8% tax and return subtotal, tax, and total.
Keep the change as a draft.
initial: ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/current_invoice_flow
valueIncludes:
- calculate_total
- tax
- total
forbiddenDrafts:
- type: flow
path: f/evals/global/process_invoice
- type: flow
path: f/evals/global/process_refund
toolExpect:
requiredToolsUsed:
- read_workspace_item
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- resolves "current flow" to the active live editor flow
- does not edit the similarly named deployed invoice or refund flows
- updates the calculate_total logic to apply 8% tax
- returns subtotal, tax, and total from the updated flow logic
- leaves the result as a draft only
- id: global-test14-current-without-live-editor-asks-question
prompt: |-
Please update this script so it returns `ok`.
Keep it as a draft.
runtime:
maxTurns: 4
validate:
draftCountExactly: 0
toolExpect:
forbiddenToolsUsed:
- write_script
- edit_script
- write_flow
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- asks which script to update when the user refers to "this script" without selected or active editor context
- does not guess a path or create a new script draft
+7 -3
View File
@@ -211,7 +211,7 @@ async function handleRun(input: {
const summaries: Array<{
label: string;
passRate: number;
averageDurationMs: number;
averagePassedDurationMs: number | null;
}> = [];
for (const [index, model] of models.entries()) {
@@ -259,7 +259,7 @@ async function handleRun(input: {
summaries.push({
label: `${model.id} (${runModel})`,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
averagePassedDurationMs: result.averagePassedDurationMs ?? null,
});
}
@@ -267,7 +267,7 @@ async function handleRun(input: {
process.stdout.write("\nModel summary\n");
for (const summary of summaries) {
process.stdout.write(
`- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`,
`- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`,
);
}
}
@@ -351,6 +351,10 @@ function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatNullableDuration(value: number | null): string {
return value === null ? "n/a" : `${Math.round(value)}ms`;
}
void main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
+28
View File
@@ -203,6 +203,34 @@ describe("loadCases", () => {
});
});
it("loads global active-editor eval cases", async () => {
const globalCases = await loadCases("global");
const scriptCase = globalCases.find(
(entry) => entry.id === "global-test12-current-live-script-edit"
);
const flowCase = globalCases.find(
(entry) => entry.id === "global-test13-current-live-flow-edit"
);
expect(scriptCase?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json"
);
expect(scriptCase?.toolExpect).toMatchObject({
requiredToolsUsed: ["read_workspace_item"],
});
expect(flowCase?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json"
);
expect(flowCase?.validate).toMatchObject({
requiredDrafts: [
{
type: "flow",
path: "f/evals/global/current_invoice_flow",
},
],
});
});
it("loads tool expectations for workspace mutation cases", async () => {
const scriptCases = await loadCases("script");
const caseEntry = scriptCases.find(
+242
View File
@@ -0,0 +1,242 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "bun:test";
import {
appendHistoryRecord,
buildRunResult,
formatRunSummary,
} from "./results";
import type { BenchmarkCaseResult } from "./types";
function caseResult(
attempts: BenchmarkCaseResult["attempts"],
): BenchmarkCaseResult {
return {
id: "case-1",
prompt: "Do the thing",
attempts,
};
}
describe("benchmark results", () => {
it("keeps success cost metrics separate from failed attempts", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
expect(result.attemptCount).toBe(2);
expect(result.passedAttempts).toBe(1);
expect(result.passRate).toBe(0.5);
expect(result.averageDurationMs).toBe(550);
expect(result.averagePassedDurationMs).toBe(1000);
expect(result.totalTokenUsage).toEqual({
prompt: 110,
completion: 25,
total: 135,
});
expect(result.totalPassedTokenUsage).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
expect(result.averageTokenUsagePerAttempt).toEqual({
prompt: 55,
completion: 12.5,
total: 67.5,
});
expect(result.averageTokenUsagePerPassedAttempt).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
const summary = formatRunSummary(result);
expect(summary).toContain("Average duration (passed): 1000ms");
expect(summary).toContain("Average tokens (passed): 120 total");
expect(summary).toContain("Average duration (all attempts): 550ms");
});
it("reports passed averages as unavailable when no attempt passes", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
expect(result.averagePassedDurationMs).toBeNull();
expect(result.totalPassedTokenUsage).toBeNull();
expect(result.averageTokenUsagePerPassedAttempt).toBeNull();
expect(formatRunSummary(result)).toContain(
"Average duration (passed): n/a",
);
});
it("normalizes passed token averages by passed attempts", () => {
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: true,
durationMs: 1200,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: null,
},
]),
],
});
expect(result.passedAttempts).toBe(2);
expect(result.totalPassedTokenUsage).toEqual({
prompt: 100,
completion: 20,
total: 120,
});
expect(result.averageTokenUsagePerPassedAttempt).toEqual({
prompt: 50,
completion: 10,
total: 60,
});
});
it("records passed-attempt metrics in history", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "windmill-ai-evals-"));
try {
const historyPath = join(tempDir, "history.jsonl");
const result = buildRunResult({
mode: "global",
runs: 1,
runModel: "model-under-test",
judgeModel: "judge-model",
caseResults: [
caseResult([
{
attempt: 1,
passed: true,
durationMs: 1000,
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["edit_script"],
skillsInvoked: [],
checks: [{ name: "edited", passed: true }],
judgeScore: 100,
judgeSummary: "ok",
error: null,
tokenUsage: { prompt: 100, completion: 20, total: 120 },
},
{
attempt: 2,
passed: false,
durationMs: 100,
assistantMessageCount: 1,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [{ name: "edited", passed: false }],
judgeScore: 10,
judgeSummary: "missed",
error: "failed",
tokenUsage: { prompt: 10, completion: 5, total: 15 },
},
]),
],
});
await appendHistoryRecord(result, historyPath);
const record = JSON.parse(await readFile(historyPath, "utf8"));
expect(record.averageDurationMs).toBe(550);
expect(record.averagePassedDurationMs).toBe(1000);
expect(record.averageTokenUsagePerAttempt.total).toBe(67.5);
expect(record.averageTokenUsagePerPassedAttempt.total).toBe(120);
expect(record.cases[0].averageDurationMs).toBe(550);
expect(record.cases[0].averagePassedDurationMs).toBe(1000);
expect(record.cases[0].averageTokenUsagePerAttempt.total).toBe(67.5);
expect(record.cases[0].averageTokenUsagePerPassedAttempt.total).toBe(
120,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
+114 -67
View File
@@ -4,12 +4,20 @@ import { execFileSync } from "node:child_process";
import { getAiEvalsRoot, getRepoRoot } from "./cases";
import type {
BenchmarkArtifactFile,
BenchmarkAttemptResult,
BenchmarkCaseResult,
BenchmarkRunResult,
BenchmarkTokenUsage,
EvalMode,
} from "./types";
type AttemptAggregate = {
attemptCount: number;
durationTotal: number;
tokenUsageAttemptCount: number;
tokenUsageTotal: BenchmarkTokenUsage | null;
};
export async function writeRunResult(
result: BenchmarkRunResult,
outputPath?: string,
@@ -77,36 +85,12 @@ export function buildRunResult(input: {
judgeModel: string | null;
caseResults: BenchmarkCaseResult[];
}): BenchmarkRunResult {
const attemptCount = input.caseResults.reduce(
(sum, entry) => sum + entry.attempts.length,
0,
);
const passedAttempts = input.caseResults.reduce(
(sum, entry) =>
sum + entry.attempts.filter((attempt) => attempt.passed).length,
0,
);
const durationTotal = input.caseResults.reduce(
(sum, entry) =>
sum +
entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0),
0,
);
const tokenUsageTotal = input.caseResults.reduce<BenchmarkTokenUsage | null>(
(sum, entry) => {
for (const attempt of entry.attempts) {
if (!attempt.tokenUsage) {
continue;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
}
return sum;
},
null,
);
const attempts = input.caseResults.flatMap((entry) => entry.attempts);
const passedAttemptResults = attempts.filter((attempt) => attempt.passed);
const attemptAggregate = aggregateAttempts(attempts);
const passedAttemptAggregate = aggregateAttempts(passedAttemptResults);
const attemptCount = attemptAggregate.attemptCount;
const passedAttempts = passedAttemptAggregate.attemptCount;
return {
version: 1,
@@ -120,16 +104,19 @@ export function buildRunResult(input: {
attemptCount,
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount,
totalTokenUsage: tokenUsageTotal,
averageDurationMs:
attemptCount === 0 ? 0 : attemptAggregate.durationTotal / attemptCount,
averagePassedDurationMs: averageDuration(passedAttemptAggregate),
totalTokenUsage: attemptAggregate.tokenUsageTotal,
totalPassedTokenUsage: passedAttemptAggregate.tokenUsageTotal,
averageTokenUsagePerAttempt:
attemptCount === 0 || !tokenUsageTotal
attemptCount === 0
? null
: {
prompt: tokenUsageTotal.prompt / attemptCount,
completion: tokenUsageTotal.completion / attemptCount,
total: tokenUsageTotal.total / attemptCount,
},
: averageTokenUsage(attemptAggregate, attemptCount),
averageTokenUsagePerPassedAttempt: averageTokenUsage(
passedAttemptAggregate,
passedAttempts,
),
cases: input.caseResults,
};
}
@@ -138,9 +125,25 @@ export function formatRunSummary(result: BenchmarkRunResult): string {
const lines = [
`${result.mode} benchmark complete`,
`Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`,
`Average duration: ${Math.round(result.averageDurationMs)}ms`,
`Average duration (passed): ${formatNullableDuration(result.averagePassedDurationMs ?? null)}`,
];
if (result.averageTokenUsagePerPassedAttempt) {
lines.push(
`Average tokens (passed): ${formatTokenUsage(result.averageTokenUsagePerPassedAttempt)}`,
);
}
if (result.passedAttempts < result.attemptCount) {
lines.push(
`Average duration (all attempts): ${Math.round(result.averageDurationMs)}ms`,
);
if (result.averageTokenUsagePerAttempt) {
lines.push(
`Average tokens (all attempts): ${formatTokenUsage(result.averageTokenUsagePerAttempt)}`,
);
}
}
const failures = collectFailures(result);
if (failures.length > 0) {
lines.push("Failures:");
@@ -172,6 +175,60 @@ function collectFailures(result: BenchmarkRunResult): string[] {
return failures;
}
function aggregateAttempts(attempts: BenchmarkAttemptResult[]): AttemptAggregate {
const aggregate: AttemptAggregate = {
attemptCount: attempts.length,
durationTotal: 0,
tokenUsageAttemptCount: 0,
tokenUsageTotal: null,
};
for (const attempt of attempts) {
aggregate.durationTotal += attempt.durationMs;
if (!attempt.tokenUsage) {
continue;
}
aggregate.tokenUsageAttemptCount += 1;
aggregate.tokenUsageTotal ??= { prompt: 0, completion: 0, total: 0 };
aggregate.tokenUsageTotal.prompt += attempt.tokenUsage.prompt;
aggregate.tokenUsageTotal.completion += attempt.tokenUsage.completion;
aggregate.tokenUsageTotal.total += attempt.tokenUsage.total;
}
return aggregate;
}
function averageDuration(aggregate: AttemptAggregate): number | null {
return aggregate.attemptCount === 0
? null
: aggregate.durationTotal / aggregate.attemptCount;
}
function averageTokenUsage(
aggregate: AttemptAggregate,
denominator: number,
): BenchmarkTokenUsage | null {
if (denominator === 0 || !aggregate.tokenUsageTotal) {
return null;
}
return {
prompt: aggregate.tokenUsageTotal.prompt / denominator,
completion: aggregate.tokenUsageTotal.completion / denominator,
total: aggregate.tokenUsageTotal.total / denominator,
};
}
function formatNullableDuration(value: number | null): string {
return value === null ? "n/a" : `${Math.round(value)}ms`;
}
function formatTokenUsage(value: BenchmarkTokenUsage): string {
const total = Math.round(value.total);
const prompt = Math.round(value.prompt);
const completion = Math.round(value.completion);
return `${total} total (${prompt} prompt, ${completion} completion)`;
}
function defaultFileName(mode: EvalMode): string {
return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`;
}
@@ -252,12 +309,15 @@ function toHistoryRecord(result: BenchmarkRunResult) {
passedAttempts: result.passedAttempts,
passRate: result.passRate,
averageDurationMs: result.averageDurationMs,
averagePassedDurationMs: result.averagePassedDurationMs ?? null,
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null,
averageTokenUsagePerPassedAttempt:
result.averageTokenUsagePerPassedAttempt ?? null,
failedCaseIds: Array.from(
new Set(
result.cases
@@ -268,31 +328,15 @@ function toHistoryRecord(result: BenchmarkRunResult) {
),
),
cases: result.cases.map((caseResult) => {
const attemptCount = caseResult.attempts.length;
const passedAttempts = caseResult.attempts.filter(
(attempt) => attempt.passed,
).length;
const totalDurationMs = caseResult.attempts.reduce(
(sum, attempt) => sum + attempt.durationMs,
0,
const attemptAggregate = aggregateAttempts(caseResult.attempts);
const passedAttemptAggregate = aggregateAttempts(
caseResult.attempts.filter((attempt) => attempt.passed),
);
const attemptCount = attemptAggregate.attemptCount;
const passedAttempts = passedAttemptAggregate.attemptCount;
const judgeScores = caseResult.attempts.flatMap((attempt) =>
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [],
);
const totalTokenUsage =
caseResult.attempts.reduce<BenchmarkTokenUsage | null>(
(sum, attempt) => {
if (!attempt.tokenUsage) {
return sum;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
return sum;
},
null,
);
return {
id: caseResult.id,
@@ -300,20 +344,23 @@ function toHistoryRecord(result: BenchmarkRunResult) {
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs:
attemptCount === 0 ? 0 : totalDurationMs / attemptCount,
attemptCount === 0
? 0
: attemptAggregate.durationTotal / attemptCount,
averagePassedDurationMs: averageDuration(passedAttemptAggregate),
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt:
attemptCount === 0 || !totalTokenUsage
attemptCount === 0
? null
: {
prompt: totalTokenUsage.prompt / attemptCount,
completion: totalTokenUsage.completion / attemptCount,
total: totalTokenUsage.total / attemptCount,
},
: averageTokenUsage(attemptAggregate, attemptCount),
averageTokenUsagePerPassedAttempt: averageTokenUsage(
passedAttemptAggregate,
passedAttempts,
),
};
}),
};
+6 -1
View File
@@ -110,7 +110,9 @@ export interface AppValidationSpec {
export interface GlobalDraftRequirement {
type: string;
path: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
language?: string;
summaryIncludes?: string[];
@@ -324,8 +326,11 @@ export interface BenchmarkRunResult {
passedAttempts: number;
passRate: number;
averageDurationMs: number;
averagePassedDurationMs?: number | null;
totalTokenUsage?: BenchmarkTokenUsage | null;
totalPassedTokenUsage?: BenchmarkTokenUsage | null;
averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null;
averageTokenUsagePerPassedAttempt?: BenchmarkTokenUsage | null;
artifactsPath?: string | null;
cases: BenchmarkCaseResult[];
}
+63
View File
@@ -195,6 +195,69 @@ describe("validateGlobalState", () => {
});
});
it("accepts a required script draft without an exact path", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
summary: "Friendly greeting helper",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
pathIncludes: ["greeting"],
language: "bun",
summaryIncludes: ["Friendly"],
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("reports flexible global draft path filters when no draft matches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/team_tools/friendly_greeting",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
requiredDrafts: [
{
type: "script",
pathIncludes: ["invoice"],
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft (path includes invoice)",
passed: false,
details: "drafts: script:f/team_tools/friendly_greeting",
});
});
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
const checks = validateGlobalState({
actual: {
+100 -15
View File
@@ -315,10 +315,11 @@ export function validateGlobalState(input: {
}
for (const required of validate.requiredDrafts ?? []) {
const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind);
const requirementLabel = formatGlobalDraftRequirement(required);
const draft = findGlobalDraft(drafts, required);
checks.push(
check(
`global includes ${required.type} draft ${required.path}`,
`global includes ${requirementLabel}`,
Boolean(draft),
summarizeGlobalDrafts(drafts)
)
@@ -330,7 +331,7 @@ export function validateGlobalState(input: {
if (required.language !== undefined) {
checks.push(
check(
`${required.type} draft ${required.path} uses ${required.language}`,
`${requirementLabel} uses ${required.language}`,
draft.language === required.language,
`language=${draft.language ?? "(none)"}`
)
@@ -340,7 +341,7 @@ export function validateGlobalState(input: {
for (const snippet of required.summaryIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} summary includes '${snippet}'`,
`${requirementLabel} summary includes '${snippet}'`,
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
`summary=${draft.summary ?? ""}`
)
@@ -351,7 +352,7 @@ export function validateGlobalState(input: {
for (const snippet of required.valueIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value includes '${snippet}'`,
`${requirementLabel} value includes '${snippet}'`,
normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
@@ -361,7 +362,7 @@ export function validateGlobalState(input: {
for (const snippet of required.valueExcludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value excludes '${snippet}'`,
`${requirementLabel} value excludes '${snippet}'`,
!normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
@@ -373,7 +374,7 @@ export function validateGlobalState(input: {
checks.push(
check(
`global does not include ${forbidden.type} draft ${forbidden.path}`,
!findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind),
!findGlobalDraft(drafts, forbidden),
summarizeGlobalDrafts(drafts)
)
);
@@ -615,16 +616,100 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined {
function findGlobalDraft(
drafts: GlobalDraft[],
type: string,
path: string,
triggerKind?: string
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
): GlobalDraft | undefined {
return drafts.find(
(draft) =>
draft.type === type &&
draft.path === path &&
(triggerKind === undefined || draft.triggerKind === triggerKind)
const candidates = drafts.filter((draft) =>
globalDraftMatchesLocator(draft, requirement)
);
return (
candidates.find((draft) => globalDraftMatchesContent(draft, requirement)) ??
candidates[0]
);
}
function globalDraftMatchesLocator(
draft: GlobalDraft,
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
}
): boolean {
return (
draft.type === requirement.type &&
(requirement.path === undefined || draft.path === requirement.path) &&
(requirement.pathStartsWith === undefined ||
draft.path.startsWith(requirement.pathStartsWith)) &&
(requirement.pathIncludes ?? []).every((snippet) =>
normalizeText(draft.path).includes(normalizeText(snippet))
) &&
(requirement.triggerKind === undefined ||
draft.triggerKind === requirement.triggerKind)
);
}
function globalDraftMatchesContent(
draft: GlobalDraft,
requirement: {
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
): boolean {
const summary = normalizeText(draft.summary ?? "");
const value = normalizeText(stringifyGlobalDraftValue(draft.value));
return (
(requirement.summaryIncludes ?? []).every((snippet) =>
summary.includes(normalizeText(snippet))
) &&
(requirement.valueIncludes ?? []).every((snippet) =>
value.includes(normalizeText(snippet))
) &&
(requirement.valueExcludes ?? []).every(
(snippet) => !value.includes(normalizeText(snippet))
)
);
}
function formatGlobalDraftRequirement(
requirement: {
type: string;
path?: string;
pathIncludes?: string[];
pathStartsWith?: string;
triggerKind?: string;
}
): string {
const typeLabel =
requirement.triggerKind === undefined
? requirement.type
: `${requirement.triggerKind} ${requirement.type}`;
if (requirement.path !== undefined) {
return `${typeLabel} draft ${requirement.path}`;
}
const filters = [
...(requirement.pathStartsWith === undefined
? []
: [`path starts with ${requirement.pathStartsWith}`]),
...(requirement.pathIncludes ?? []).map(
(snippet) => `path includes ${snippet}`
),
];
return filters.length === 0
? `${typeLabel} draft`
: `${typeLabel} draft (${filters.join(", ")})`;
}
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
@@ -0,0 +1,66 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/format_greeting",
"summary": "Format a deployed greeting",
"description": "Returns a plain greeting for a 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"
},
{
"path": "f/evals/global/format_greeting_archive",
"summary": "Archived greeting formatter",
"description": "Older greeting formatter kept for reference.",
"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 `Hi, ${name}`\n}\n"
}
]
},
"liveEditorDrafts": [
{
"type": "script",
"storagePath": "f/evals/global/current_greeting",
"effectivePath": "f/evals/global/current_greeting",
"value": {
"path": "f/evals/global/current_greeting",
"summary": "Open greeting formatter",
"description": "Formats a greeting in the live editor.",
"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",
"is_template": false,
"kind": "script"
}
}
]
}
@@ -0,0 +1,118 @@
{
"workspace": {
"flows": [
{
"path": "f/evals/global/process_invoice",
"summary": "Deployed invoice processor",
"description": "Calculates invoice totals from a subtotal.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
},
{
"path": "f/evals/global/process_refund",
"summary": "Refund processor",
"description": "Calculates refund totals.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate refund total",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
}
]
},
"liveEditorDrafts": [
{
"type": "flow",
"storagePath": "f/evals/global/current_invoice_flow",
"effectivePath": "f/evals/global/current_invoice_flow",
"value": {
"path": "f/evals/global/current_invoice_flow",
"summary": "Open invoice processor",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
},
"edited_by": "",
"edited_at": "",
"archived": false,
"extra_perms": {}
}
}
]
}
@@ -0,0 +1,40 @@
{
"workspace": {
"flows": [
{
"path": "f/evals/global/process_invoice",
"summary": "Process an invoice subtotal",
"description": "Calculates invoice totals from a subtotal.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"subtotal": {
"type": "number"
}
},
"required": ["subtotal"]
},
"value": {
"modules": [
{
"id": "calculate_total",
"summary": "Calculate total from subtotal",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n",
"input_transforms": {
"subtotal": {
"type": "javascript",
"expr": "flow_input.subtotal"
}
}
}
}
]
}
}
]
}
}
+7 -1
View File
@@ -1,5 +1,8 @@
import { readFile } from "node:fs/promises";
import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner";
import {
runGlobalEval,
type GlobalLiveEditorDraftFixture,
} 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";
@@ -9,6 +12,7 @@ import { getFrontendApiKey } from "./frontendCommon";
export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
}
export function createGlobalModeRunner(
@@ -31,6 +35,7 @@ export function createGlobalModeRunner(
getFrontendApiKey(modelConfig.provider),
{
workspaceFixtures: initial?.workspace,
liveEditorDrafts: initial?.liveEditorDrafts,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
@@ -73,6 +78,7 @@ async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixt
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
liveEditorDrafts: parsed.liveEditorDrafts ?? [],
};
}
+1 -1
View File
@@ -1 +1 @@
327d23f7438968a21bac9fd42e7f6f027c61477c
55c19293232be379a3044eb78f677b545882ffd6
+61 -9
View File
@@ -6,7 +6,10 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::{collections::HashMap, time::Duration};
use std::{
collections::{BTreeSet, HashMap},
time::Duration,
};
#[cfg(feature = "parquet")]
mod audit_logs_s3;
@@ -47,6 +50,7 @@ use windmill_common::secret_backend::{
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
};
use windmill_common::{
auth::is_super_admin_email,
ee_oss::{get_license_plan, LicensePlan},
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
@@ -1135,6 +1139,8 @@ struct CustomInstanceDb {
success: bool,
error: Option<String>,
tag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
used_by_workspaces: Vec<String>,
}
#[derive(Deserialize, Debug, Serialize, Default)]
@@ -1154,7 +1160,7 @@ struct CustomInstanceDbLogs {
}
async fn list_custom_instance_pg_databases(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<HashMap<String, CustomInstanceDb>> {
let result = sqlx::query_scalar!(
@@ -1163,12 +1169,57 @@ async fn list_custom_instance_pg_databases(
.fetch_one(&db)
.await?
.ok_or_else(|| error::Error::ExecutionErr("Couldn't find custom_instance_pg_databases".to_string()))?;
let result = serde_json::from_value(result).map_err(|e| {
error::Error::ExecutionErr(format!(
"couldn't parse custom_instance_pg_databases.databases : {}",
e.to_string()
))
})?;
let mut result: HashMap<String, CustomInstanceDb> =
serde_json::from_value(result).map_err(|e| {
error::Error::ExecutionErr(format!(
"couldn't parse custom_instance_pg_databases.databases : {}",
e.to_string()
))
})?;
if is_super_admin_email(&db, &authed.email).await? {
// Enrich each database with the list of workspaces referencing it through
// either a ducklake catalog or a datatable database whose resource_type is
// 'instance'. Not stored in DB to avoid drift.
let usages = sqlx::query!(
r#"
SELECT ws.workspace_id AS "workspace_id!", entry->'catalog'->>'resource_path' AS dbname
FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(
CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'
THEN ws.ducklake->'ducklakes'
ELSE '{}'::jsonb END
) AS dl(k, entry)
WHERE entry->'catalog'->>'resource_type' = 'instance'
AND entry->'catalog'->>'resource_path' IS NOT NULL
UNION ALL
SELECT ws.workspace_id AS "workspace_id!", entry->'database'->>'resource_path' AS dbname
FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(
CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
THEN ws.datatable->'datatables'
ELSE '{}'::jsonb END
) AS dt(k, entry)
WHERE entry->'database'->>'resource_type' = 'instance'
AND entry->'database'->>'resource_path' IS NOT NULL
"#,
)
.fetch_all(&db)
.await?;
let mut by_db: HashMap<String, BTreeSet<String>> = HashMap::new();
for row in usages {
if let Some(dbname) = row.dbname {
by_db.entry(dbname).or_default().insert(row.workspace_id);
}
}
for (dbname, entry) in result.iter_mut() {
if let Some(workspaces) = by_db.remove(dbname) {
entry.used_by_workspaces = workspaces.into_iter().collect();
}
}
}
return Ok(Json(result));
}
@@ -1196,7 +1247,8 @@ async fn setup_custom_instance_pg_database(
let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await;
let success = result.is_ok();
let error = result.err().map(|e| e.to_string());
let status = CustomInstanceDb { logs, success, error, tag: body.tag };
let status =
CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] };
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
// Save that the database was setup successfully
sqlx::query!(
+5
View File
@@ -25476,6 +25476,11 @@ components:
example: "Connection timeout"
tag:
$ref: "#/components/schemas/CustomInstanceDbTag"
used_by_workspaces:
type: array
items:
type: string
description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted.
NewSqsTrigger:
type: object
+11
View File
@@ -252,6 +252,17 @@ lazy_static::lazy_static! {
pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0);
/// Stochastic admission probability for capped workspaces, expressed in
/// parts per 10_000 (so `420` = 4.2%). The refresh computes this from the
/// observed worker-second distribution and the configured cap so that
/// admission converges to the target *worker-second* share — independent
/// of how the capped vs uncapped workspaces compare on per-job durations.
/// See `workspace_fairness_ee::refresh_overloaded` for the derivation.
/// `10_000` (= admit all) is the default until the first refresh
/// classifies an overloaded set — before that, no workspace is capped so
/// `should_admit_capped` is moot and "admit all" is the correct no-op.
pub static ref WORKSPACE_FAIRNESS_ADMISSION_PPM: AtomicU32 = AtomicU32::new(10_000);
pub static ref SMTP_CONFIG: arc_swap::ArcSwap<Option<Smtp>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref INDEXER_CONFIG: arc_swap::ArcSwap<TantivyIndexerSettings> = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default());
+248 -10
View File
@@ -1,15 +1,253 @@
//! Per-workspace fairness for the shared worker pool (Enterprise feature).
//! # Per-workspace fairness for the shared worker pool (Enterprise feature)
//!
//! The real algorithm — overloaded-set aggregation, coordinated refresh on
//! `background_task_state`, audit emission, stochastic admission decision —
//! lives in [`crate::workspace_fairness_ee`] and only compiles when the
//! `private` feature is on. This module is the public surface used by the
//! pull dispatch in `jobs.rs` and the integration tests; when EE is on it
//! transparently re-exports the EE implementation, when EE is off it
//! provides no-op stubs so the OSS build stays bit-identical to the
//! pre-fairness pull path.
//! On multi-tenant deployments (notably `app.windmill.dev`, and any EE
//! cluster with a single shared worker group) a single workspace flooding
//! the queue with jobs can degrade quality of service for everyone else.
//! This module computes the set of "overloaded" workspaces whose share of
//! the worker pool must be capped, and the dispatch in `jobs.rs` uses a
//! **duration-weighted stochastic admission rule** at pull time to enforce
//! the cap as a *worker-second* share, not a pull-count share.
//!
//! See [`crate::workspace_fairness_ee`] for design notes and SQL details.
//! The full algorithm lives in [`crate::workspace_fairness_ee`] behind the
//! `private` feature — this OSS-facing module is the public surface that
//! the pull dispatch and integration tests call. When EE is on, the symbols
//! here transparently re-export the EE implementation. When EE is off, they
//! are no-ops: `maybe_refresh_overloaded` does nothing, `should_admit_capped`
//! always returns `true`, and the pull path is bit-identical to its
//! pre-fairness shape. **Both runtime correctness and the entire reasoning
//! below assume the EE module is compiled in**; the OSS build is a stub.
//!
//! Every numerical default mentioned below (`MAX_PERCENT = 50`,
//! `DURATION_SECS = 10`, `MIN_TOTAL = 4`, `WORKER_PING_LIVE_SECS = 60`,
//! `ADMISSION_EPSILON_PERCENT = 5`) is tunable via global settings or
//! constants; the values here are the as-shipped defaults at the time of
//! writing and what the design discussion below was calibrated against.
//!
//! ## 1. What "overloaded" means — worker-seconds, not jobs
//!
//! A workspace is overloaded when, over a rolling
//! `WORKSPACE_FAIRNESS_DURATION_SECS = 10s` window, it has consumed at least
//! `WORKSPACE_FAIRNESS_MAX_PERCENT = 50%` of cluster worker-time. Activity
//! is measured in **worker-seconds**: each job contributes the wall-clock
//! time it actually held a worker, intersected with the window. A
//! count-based signal — "what fraction of jobs in the window are from this
//! workspace" — gets badly fooled by job-duration heterogeneity: 600
//! short (100ms) jobs and one long (60s) job consume the same worker-time
//! but the count-based form attributes 600× more weight to the spammy
//! workspace. Worker-seconds put both patterns on the same scale.
//!
//! Two sources contribute to a workspace's worker-second total:
//!
//! - **Running** (live, currently-on-a-worker): driven from `v2_job_runtime`
//! filtered on `ping > now() - WORKER_PING_LIVE_SECS` (60s, ≈ 2× worker
//! heartbeat interval), then PK-joined to `v2_job` for the `kind` filter
//! and `v2_job_queue` for `started_at` / `suspend_until`. Contribution is
//! `clamp(min(now, ping) max(started_at, window_start), 0, window)`.
//! End-of-interval is the per-job `ping`, which both (a) implements the
//! zombie defense — a worker that stopped pinging stops accruing
//! worker-seconds at its last heartbeat, so a backlog of stuck
//! `running = true` rows can't dominate the denominator — and (b) matches
//! the semantic of "worker-seconds the worker has confirmed". `v2_job_runtime`
//! is small (rows deleted on completion), so driving the scan from there
//! keeps the per-refresh cost bounded by the *in-flight* count rather
//! than by the queue size, even when one workspace has thousands of
//! `running = true` rows.
//!
//! - **Completed** (recently finished): pulled by an index scan over
//! `v2_job_completed (completed_at)`, then PK-joined to `v2_job`. The
//! index hit is critical — see "Why no `WITH params AS (...)` CTE" below.
//! Contribution is `clamp(min(completed_at, now) max(started_at,
//! completed_at duration_ms, window_start), 0, window)`. Clamping
//! start-of-interval by `completed_at duration_ms` defends against
//! zombie rows that `zombie_monitor` force-failed: `started_at` may be
//! far in the past, but `duration_ms` reflects the actual measured worker
//! time, so the row only contributes its real runtime, not the idle wait
//! before force-fail.
//!
//! Both halves exclude **flow-orchestration kinds**
//! (`flow, flowpreview, flownode, singlestepflow`) and **concurrency-
//! suspended rows** (`suspend_until IS NOT NULL`) — these hold
//! `running = true` but consume no worker slot. Same predicate as
//! `handle_zombie_jobs` in `monitor.rs`.
//!
//! `WORKSPACE_FAIRNESS_MIN_TOTAL = 4` is also in worker-seconds (≈ 40 %
//! utilization of one worker over a 10s window) — below the floor, the
//! cluster is too quiet to bother capping anyone.
//!
//! ## 2. The cap is enforced stochastically, weighted by duration
//!
//! The pull dispatch in `jobs.rs` flips a coin on every pull: with
//! probability `p_c` it uses the standard pull query (capped workspaces
//! are admissible — FIFO will pick them if they're at the head), and with
//! probability `1 p_c` it uses the *fairness pull query* which excludes
//! the overloaded workspaces. Doing it as a probabilistic split rather
//! than a binary cap/uncap gate keeps victim latency flat instead of
//! breathing in/out with each refresh cycle.
//!
//! The key design choice is how `p_c` is set. The natural first try is
//! `p_c = (MAX_PERCENT + ε) / 100` — a constant. That converges the
//! *pull-count* ratio to `MAX_PERCENT`, but only matches the worker-second
//! ratio when capped and uncapped workspaces share the same mean job
//! duration. The steady-state share equation is:
//!
//! `share = p_c · D_c / (p_c · D_c + (1 p_c) · D_u)`
//!
//! where `D_c` and `D_u` are the per-job mean durations of capped and
//! uncapped workspaces respectively. With `D_c = 34s` and `D_u = 1s` (the
//! exact numbers observed during the lancom01-prod / jps-internal cloud
//! incident), a constant `p_c = 0.65` (60 % + 5 % ε) yields
//!
//! `share = 0.65 · 34 / (0.65 · 34 + 0.35 · 1) = 22.1 / 22.45 ≈ 98%`
//!
//! — i.e., the "60 % cap" was in practice giving capped workspaces 98 %
//! of worker-seconds. Victims were observed waiting 15s+ for pickup
//! despite the cap firing on every pull.
//!
//! Inverting the equation for the desired share `t = (MAX_PERCENT + ε) / 100`:
//!
//! `p_c = t · D_u / ((1 t) · D_c + t · D_u)`
//!
//! Same numbers, target 0.65: `p_c ≈ 0.054` — about 12× tighter than the
//! count-based form. The refresh computes `p_c` and stores it in
//! [`WORKSPACE_FAIRNESS_ADMISSION_PPM`] (parts-per-10_000, fits in an
//! `AtomicU32`). The pull-time check is one atomic load plus one
//! `rand::rng().random_range(0..10_000)` draw — same hot-path cost as the
//! count-based form.
//!
//! ### `D_c`/`D_u` come from a separate, longer service-time window
//!
//! Crucially, `D_c` and `D_u` must be **true mean service times**, because
//! the share equation above is Little's-law-based
//! (`occupancy = arrival_rate × mean_service_time`). They are **not** taken
//! from the occupancy aggregation: that aggregation clamps each job's
//! contribution to the short occupancy window (`DURATION_SECS`, 10s), so a
//! job longer than the window contributes at most 10s — fine for measuring
//! *share*, but it would truncate `D_c` to ≤ 10s and systematically
//! under-admit the skew exactly when capped jobs are long (the case the cap
//! exists for: e.g. true `D_c = 34s` clamped to 10s gives `p_c ≈ 0.157`, an
//! 86 % effective share instead of 65 %). Instead, the refresh samples true
//! unclamped `duration_ms` of completed jobs over a longer, decoupled
//! service-time window (`DURATION_SAMPLE_SECS`, 60s) — long enough to avoid
//! truncation and to keep the mean stable when few jobs complete within the
//! 10s occupancy window. So the refresh emits two per-workspace signals:
//! windowed occupancy worker-seconds (for classification) and a 60s
//! service-time `(Σ duration_ms, count)` (for admission), merged per
//! workspace.
//!
//! ### Why we kept the fallback when the fairness pull returns empty
//!
//! The 100 `p_c` % of pulls that try the fairness query (excluding
//! capped workspaces) fall back to the standard query if the fairness
//! query returns no row. The alternative — idle the worker, holding the
//! slot open in case a victim shows up — was considered but rejected for
//! the first iteration: with `p_c` correctly tightened, victims do get the
//! slot they need *when they exist*, and absent victims, falling back to
//! the capped pool is the right behaviour (otherwise the cluster
//! under-utilises itself for no benefit). Adding a reserve-capacity skip
//! is a fine-tuning lever for bursty victim arrival patterns and is left
//! as a follow-up.
//!
//! ### Degenerate cases
//!
//! If either bucket is empty — no capped jobs, no uncapped jobs, or a
//! capped workspace with zero completions in the 60s service-time window
//! (all its jobs still running) — the formula is undefined. The refresh
//! falls back to the count-based `p_c = t` in those cases — it matches
//! the pre-refactor behaviour and is the safest thing to do when there's
//! no service-time signal yet to weight on.
//!
//! ## 3. Coordinated refresh — exactly once per cycle, cluster-wide
//!
//! The aggregation is too expensive to run on every worker process every
//! pull (and would produce no new information on the sub-second
//! timescale). It runs **at most once every `refresh_interval` seconds
//! across the entire fleet**, gated by both a per-process CAS and a
//! DB-side row lock:
//!
//! 1. **Per-process gate** — `maybe_refresh_overloaded` (called from the
//! pull path) does `LAST_REFRESH_MICROS.compare_exchange` to ensure at
//! most one in-flight refresh per process per interval. If the CAS
//! fails or the interval hasn't elapsed yet, the call is a no-op.
//! Cost on the hot path: one atomic load, optionally one CAS.
//!
//! 2. **DB-side claim** — `refresh_overloaded` first does a cheap upsert
//! (`INSERT ... ON CONFLICT ON background_task_state ... WHERE
//! updated_at < NOW() refresh_interval RETURNING true`). The `VALUES`
//! clause is all constants, so Postgres has no expensive work to do
//! even for losers. Only the unique winner per cycle gets `Some(true)`;
//! losers get `None` and skip the aggregation entirely.
//!
//! 3. **Winner-only aggregation** — the winner runs the
//! `v2_job_runtime v2_job_completed` worker-second aggregation
//! returning per-workspace `(workspace_id, worker_seconds, jobs)`,
//! classifies into overloaded/uncapped, computes `p_c`, and writes the
//! new payload `{"overloaded": [...], "admission_ppm": N}` back to
//! `background_task_state.workspace_fairness`.
//!
//! 4. **Everyone reads** — winner and losers alike then `SELECT` the
//! current value, parse it, and update their in-process
//! `WORKSPACE_FAIRNESS_OVERLOADED` and `WORKSPACE_FAIRNESS_ADMISSION_PPM`
//! atomics. This is what makes losers eventually see the winner's
//! decision; they just don't pay the aggregation cost.
//!
//! The refresh interval is `ACTIVE_REFRESH_SECS = 2s` when the cluster
//! currently has a capped workspace (faster — we want the cap to lift
//! promptly once load drops) and `IDLE_REFRESH_SECS = 5s` otherwise
//! (slower — minimise DB load during normal operation). The DB-side guard
//! always uses the tighter `ACTIVE_REFRESH_SECS` to bound the race
//! window; the per-process gate enforces the idle cadence.
//!
//! If a refresh fails (DB error, timeout > 5s), `LAST_REFRESH_MICROS` is
//! left set to the attempt's timestamp so the next attempt has to wait a
//! full interval — exactly the same cooldown as a successful refresh.
//! Resetting to `0` on failure would remove the rate limit entirely
//! precisely when DB load is highest, which is the wrong direction.
//!
//! ## 4. Audit logging
//!
//! Workspaces entering or leaving the capped set produce
//! `workspace_fairness.capped` / `workspace_fairness.uncapped` audit
//! entries scoped to the `admins` workspace, with the affected workspace
//! as the `resource` field. Emitted by the refresh winner only, so a
//! transition produces exactly one audit row regardless of fleet size.
//! The "previous list" diffed against is the DB value (not the per-process
//! cache) so a freshly-restarted worker that happens to win the first
//! claim doesn't emit spurious "newly capped" entries for workspaces that
//! were already capped before it started.
//!
//! ## 5. Notable SQL performance constraints
//!
//! - **No `WITH params AS (...)` CTE for `window_start`.** A natural
//! refactor would be to compute `NOW() - make_interval(secs => N)` once
//! in a CTE and reference it in both halves of the UNION. But Postgres
//! *materialises* the CTE and the optimiser can no longer push the
//! `completed_at > window_start` predicate down to the
//! `ix_job_completed_completed_at` index. On the production cloud DB
//! (~12M `v2_job_completed` rows), that turns a 10 ms index scan into a
//! ~47s full table scan. The query intentionally inlines `NOW()` and
//! `NOW() - make_interval(...)` at every callsite.
//!
//! - **Drive running side from `v2_job_runtime`, not `v2_job_queue`.**
//! Naive ordering ("scan v2_job_queue for `running = true`, join v2_job
//! for the kind filter") does a Seq Scan over ~thousands of running-or-
//! bookkeeping rows and does a PK lookup into `v2_job` for every one of
//! them — ~10 ms in prod, but worse: bounded by *queue size*. Pivoting
//! to drive the scan from `v2_job_runtime` filtered on
//! `ping > NOW() - 60s` narrows to the in-flight set (small, deletes-
//! on-completion) *before* any PK lookups: 1.3 ms, 9× less I/O,
//! bounded by *live worker count*.
//!
//! ## 6. Enterprise gating
//!
//! The cap is an Enterprise feature. `windmill-api-settings` rejects
//! `workspace_fairness_enabled = true` writes from non-EE builds, and on a
//! single-tenant self-hosted deployment the default
//! `workspace_fairness_enabled = false` keeps the pull path identical to
//! the pre-fairness baseline. At runtime the dispatch checks the atomic
//! only — when fairness is off, `maybe_refresh_overloaded` drains the
//! cached state in one pull cycle (resetting `WORKSPACE_FAIRNESS_OVERLOADED`
//! to empty and `WORKSPACE_FAIRNESS_ADMISSION_PPM` to 10_000 = "admit all"),
//! so toggling the feature off without restarting workers is safe.
#[cfg(feature = "private")]
#[allow(unused)]
+9 -2
View File
@@ -22,6 +22,7 @@ import {
showConflict,
showDiff,
extractNativeTriggerInfo,
redactEncryptionKey,
} from "../../types.ts";
import { downloadZip } from "./pull.ts";
import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts";
@@ -3095,16 +3096,22 @@ function prettyChanges(
),
);
} else if (change.name === "edited") {
const changeType = getTypeStrFromPath(change.path);
log.info(
colors.yellow(
`~ ${getTypeStrFromPath(change.path)} ` +
`~ ${changeType} ` +
displayPath +
colors.gray(wsNote) +
(change.codebase ? ` (codebase changed)` : ""),
),
);
if (change.before != change.after) {
if (change.path.endsWith(".yaml")) {
if (changeType === "encryption_key") {
showDiff(
redactEncryptionKey(change.before),
redactEncryptionKey(change.after),
);
} else if (change.path.endsWith(".yaml")) {
try {
showDiff(
yamlStringify(
+12 -6
View File
@@ -207,13 +207,19 @@ async function reconcileIncludingFile(options: {
}
function referencesIncludeLine(content: string, includeLine: string): boolean {
// Match only when the include sits on a line by itself (allowing leading
// and trailing whitespace). Earlier we split on `\s+`, but that
// false-positives on commented-out includes like `<!-- @AGENTS.cli.md -->`
// where the middle token equals the include. CRLF is handled by the
// `\r?\n` split.
// Match when the include appears as a whitespace-separated token on any
// line that isn't an HTML comment. We can't require the include to be on a
// line by itself: our own CLAUDE.md default is `Instructions are in
// @AGENTS.md` (one sentence), and a strict equality check made `wmill
// refresh prompts` re-prompt every run on files wmill itself wrote.
// Skipping comment-bearing lines keeps `<!-- @AGENTS.cli.md -->` from
// false-positiving.
for (const line of content.split(/\r?\n/)) {
if (line.trim() === includeLine) {
const trimmed = line.trim();
if (trimmed.startsWith("<!--") || trimmed.endsWith("-->")) {
continue;
}
if (trimmed.split(/\s+/).includes(includeLine)) {
return true;
}
}
+36 -1
View File
@@ -129,11 +129,46 @@ export function showDiff(local: string, remote: string) {
export function showConflict(path: string, local: string, remote: string) {
log.info(colors.yellow(`- ${path}`));
showDiff(local, remote);
let isEncryptionKey = false;
try {
isEncryptionKey = getTypeStrFromPath(path) === "encryption_key";
} catch {
// ignore
}
if (isEncryptionKey) {
showDiff(redactEncryptionKey(local), redactEncryptionKey(remote));
} else {
showDiff(local, remote);
}
log.info("\x1b[31mlocal\x1b[31m - \x1b[32mremote\x1b[32m");
log.info("\n");
}
// Reveal only the first 5 chars of the key so a rotation is still visible in
// the diff (different prefixes), without leaking the whole secret to stdout.
// The remaining chars are replaced with `*`, preserving length so the diff
// keeps showing whether the key length changed.
export function redactEncryptionKey(content: string): string {
if (!content) return content;
// The encryption_key payload is JSON-encoded (a quoted string). Parse it so
// we redact the key value itself, then re-serialize to JSON to preserve the
// file's shape; fall back to raw redaction if parsing fails.
try {
const parsed = JSON.parse(content);
if (typeof parsed === "string") {
return JSON.stringify(redactString(parsed));
}
} catch {
// not JSON — treat content as the raw key
}
return redactString(content);
}
function redactString(s: string): string {
if (s.length <= 5) return s;
return s.slice(0, 5) + "*".repeat(s.length - 5);
}
/**
* Pushes an object to the workspace server based on its type
* @param workspace - The workspace ID to push to
+7 -1
View File
@@ -391,6 +391,13 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", ()
["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"],
["leading whitespace then include", " @AGENTS.cli.md\n"],
["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"],
// Mid-sentence include: this is how our own CLAUDE.md default looks
// ("Instructions are in @AGENTS.md"). A strict line-equality check made
// `wmill refresh prompts` re-prompt every run on files wmill wrote.
["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"],
// `>` blockquote prefix doesn't disable Claude's `@`-import expansion,
// so we treat it as a reference too.
["blockquoted include", "> @AGENTS.cli.md"],
])("treats %s as a reference (no append)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
@@ -406,7 +413,6 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", ()
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"],
["commented-out include", "<!-- @AGENTS.cli.md -->"],
["blockquoted include", "> @AGENTS.cli.md"],
])("does not treat %s as a reference (append happens)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "00c9834",
"sha256": "5757e5b9cbf79c20d507dc4c588640368e84b873cb48f3806ca8c67fd1aa625f"
"version": "fe13d03",
"sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c"
}
@@ -681,7 +681,11 @@ export class AIChatManager {
} else if (this.mode === AIMode.NAVIGATOR) {
return prepareNavigatorUserMessage(pendingPrompt)
} else if (this.mode === AIMode.GLOBAL) {
return prepareGlobalUserMessage(pendingPrompt, this.contextManager.getSelectedContext())
return prepareGlobalUserMessage(
pendingPrompt,
this.contextManager.getSelectedContext(),
{ workspace: get(workspaceStore) }
)
}
return undefined
},
@@ -898,7 +902,9 @@ export class AIChatManager {
userMessage = prepareApiUserMessage(oldInstructions)
break
case AIMode.GLOBAL:
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext)
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, {
workspace: get(workspaceStore)
})
break
case AIMode.APP:
userMessage = prepareAppUserMessage(
@@ -74,6 +74,8 @@ vi.mock('$lib/gen', async () => {
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false),
createAppRaw: vi.fn(async () => 'created'),
updateAppRaw: vi.fn(async () => 'updated'),
getAppByPath: vi.fn(async () => {
throw new Error('getAppByPath mock not configured')
}),
@@ -96,9 +98,17 @@ vi.mock('$lib/gen', async () => {
}
})
vi.mock('./rawAppBundlerBridge', () => ({
bundleRawAppDraft: vi.fn(async () => ({
js: 'bundled js',
css: 'bundled css'
}))
}))
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
AppService,
FlowService,
@@ -960,6 +970,111 @@ describe('global AI tools', () => {
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('deploys a new raw app draft by bundling files and creating a raw app', async () => {
UserDraft.save(
'raw_app',
'f/apps/report',
{
summary: 'AI report',
files: {
'/index.tsx': 'console.log("app")',
'/package.json': '{"dependencies":{"react":"19.0.0"}}'
},
runnables: {},
data: { tables: [] }
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('deploy_workspace_item', {
type: 'app',
path: 'f/apps/report',
deployment_message: 'ship report'
})
expect(bundleRawAppDraft).toHaveBeenCalledWith(
expect.objectContaining({
workspace: WORKSPACE,
files: expect.objectContaining({
'/index.tsx': 'console.log("app")'
})
})
)
expect(AppService.createAppRaw).toHaveBeenCalledWith({
workspace: WORKSPACE,
formData: {
app: {
path: 'f/apps/report',
value: {
files: {
'/index.tsx': 'console.log("app")',
'/package.json': '{"dependencies":{"react":"19.0.0"}}'
},
runnables: {},
data: { tables: [] }
},
summary: 'AI report',
policy: expect.objectContaining({ execution_mode: 'publisher' }),
deployment_message: 'ship report',
custom_path: undefined
},
js: 'bundled js',
css: 'bundled css'
}
})
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(JSON.parse(raw)).toMatchObject({
success: true,
type: 'app',
path: 'f/apps/report'
})
})
it('deploys an existing raw app draft by bundling files and updating the raw app', async () => {
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
UserDraft.save(
'raw_app',
'f/apps/report',
{
summary: 'Updated report',
files: { '/index.tsx': 'console.log("updated")' },
runnables: {},
data: { tables: ['orders'] },
policy: { execution_mode: 'anonymous' },
custom_path: 'kept-by-backend'
},
{ workspace: WORKSPACE }
)
await callGlobalTool('deploy_workspace_item', {
type: 'app',
path: 'f/apps/report'
})
expect(AppService.updateAppRaw).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/apps/report',
formData: {
app: {
path: 'f/apps/report',
value: {
files: { '/index.tsx': 'console.log("updated")' },
runnables: {},
data: { tables: ['orders'] }
},
summary: 'Updated report',
policy: expect.objectContaining({ execution_mode: 'anonymous' }),
deployment_message: undefined
},
js: 'bundled js',
css: 'bundled css'
}
})
expect(AppService.createAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('fills an empty rawscript module through set_flow_module_code', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
@@ -1168,6 +1283,7 @@ describe('prepareGlobalSystemMessage', () => {
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
)
expect(content).toContain('If the user message includes an ACTIVE EDITOR section')
expect(content).not.toContain('AI draft')
expect(content).not.toContain('UserDraft')
expect(content).not.toContain('localStorage')
@@ -1190,6 +1306,27 @@ describe('prepareGlobalSystemMessage', () => {
})
describe('prepareGlobalUserMessage', () => {
it('injects the active editor reference without contents', () => {
__resetUserDraftForTesting()
localStorage.clear()
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: '',
effectivePath: 'f/scripts/live_greeting'
})
const message = prepareGlobalUserMessage('Update this script', [], { workspace: WORKSPACE })
expect(message.content).toContain('## ACTIVE EDITOR')
expect(message.content).toContain('type: script')
expect(message.content).toContain('path: f/scripts/live_greeting')
expect(message.content).toContain('isLiveDraft: true')
expect(message.content).toContain('## INSTRUCTIONS:\nUpdate this script')
expect(message.content).not.toContain('When the user says')
expect(message.content).not.toContain('content')
})
it('includes selected workspace item references without contents', () => {
const message = prepareGlobalUserMessage('Update these items', [
{
@@ -64,6 +64,7 @@ import {
import type { ContextElement } from '../context'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import { emptySchema } from '$lib/utils'
import { inferArgs } from '$lib/infer'
import {
resourceRequestSchema,
scheduleRequestSchema,
@@ -83,6 +84,7 @@ import {
type WorkspaceItemType
} from './workspaceItems'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
clearEphemeralSecretVariableDraftValue,
deleteGlobalDraft,
@@ -111,6 +113,28 @@ const INSTRUCTION_SUBJECTS = [
'app'
] as const satisfies readonly WorkspaceItemType[]
const MAX_LIST_LIMIT = 100
type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0]
const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly {
itemKind: LiveEditorDraftKind
type: ActiveGlobalEditorType
}[] = [
{ itemKind: 'script', type: 'script' },
{ itemKind: 'flow', type: 'flow' },
{ itemKind: 'raw_app', type: 'app' }
]
export type GlobalActiveEditorContext = {
type: ActiveGlobalEditorType
path: string
isLiveDraft: true
}
export type GlobalUserMessageOptions = {
workspace?: string
activeEditor?: GlobalActiveEditorContext
}
const itemTypeSchema = z.enum(ITEM_TYPES)
const instructionSubjectSchema = z.enum(INSTRUCTION_SUBJECTS)
@@ -497,7 +521,7 @@ Use tools to inspect workspace items and create local drafts for scripts, flows,
Rules:
- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items.
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
- If the user refers to the open editor, use the item marked isLiveDraft=true.
- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".
- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a local draft to the workspace.
- Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item.
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
@@ -516,7 +540,7 @@ Raw apps:
- Use write_app_file, patch_app_file, and delete_app_file for frontend files.
- Use write_app_runnable and delete_app_runnable for backend runnables.
- Use init_app only after confirming framework, path, and summary with the user.
- Apps cannot be deployed from chat; tell the user to open the app editor.`
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[]
@@ -1211,7 +1235,7 @@ type InstructionSubject = (typeof INSTRUCTION_SUBJECTS)[number]
function getAppInstructions(): string {
return `# Global draft app instructions
- Global mode edits raw app drafts only; it does not save, deploy, or bundle.
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path (e.g. \`f/folder/my_app\`). The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
@@ -1219,7 +1243,7 @@ function getAppInstructions(): string {
- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).
- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.
- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.
- 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.
- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.
- Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file.
- Note: the authoring reference below mentions the CLI on-disk layout (\`backend/<id>.<ext>\`, \`raw_app.yaml\`, \`sql_to_apply/\`). That layout is only relevant for the terminal workflow — in chat, apps are addressed via the tool surface above.
@@ -2544,7 +2568,13 @@ async function patchAppFile(
}
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any
const policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as NonNullable<
AppDraftValue['policy']
>
if (!policy.execution_mode) {
policy.execution_mode = 'publisher'
}
value.policy = policy
}
async function writeAppRunnable(
@@ -2719,12 +2749,6 @@ async function deployDraft(
const { workspace, toolId, toolCallbacks } = ctx
const { type, path, trigger_kind: triggerKind, deployment_message: deploymentMessage } = args
if (type === 'app') {
throw new Error(
'Apps cannot be deployed from chat. Open the app editor to deploy (the editor bundles JS/CSS before save).'
)
}
if (type === 'trigger' && !triggerKind) {
throw new Error('trigger_kind is required when deploying a trigger.')
}
@@ -2748,10 +2772,16 @@ async function deployDraft(
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined
await ScriptService.createScript({
workspace,
requestBody: buildScriptDeployRequestBody(path, draft, existing, deploymentMessage)
})
const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage)
// Infer the arg schema from the content so it matches the code, like the editor does.
try {
const schema = emptySchema()
await inferArgs(requestBody.language, requestBody.content, schema)
requestBody.schema = schema
} catch (e) {
console.error('Failed to infer script schema before deploy', e)
}
await ScriptService.createScript({ workspace, requestBody })
break
}
case 'flow': {
@@ -2820,6 +2850,87 @@ async function deployDraft(
actions = [createOpenVariableAction(path)]
break
}
case 'app': {
const appDraft = draft.value as AppDraftValue
const appValue: AppDraftValue = {
...appDraft,
files: { ...(appDraft.files ?? {}) },
runnables: { ...(appDraft.runnables ?? {}) },
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
}
await recomputeAppPolicy(appValue)
const policy = appValue.policy
if (!policy) {
throw new Error(`Draft app "${path}" has no policy to deploy.`)
}
toolCallbacks.setToolStatus(toolId, {
content: `Bundling app "${path}"...`
})
const bundle = await bundleRawAppDraft({
workspace,
files: appValue.files,
onLog: (delta) => {
const lines = delta
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const latest = lines[lines.length - 1]
if (latest) {
toolCallbacks.setToolStatus(toolId, {
content: `Bundling app "${path}"... ${latest}`
})
}
}
})
toolCallbacks.setToolStatus(toolId, {
content: `Deploying app "${path}"...`
})
const rawAppValue = {
files: appValue.files,
runnables: appValue.runnables,
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
}
const summary = appValue.summary ?? draft.summary ?? ''
if (await AppService.existsApp({ workspace, path })) {
// Omit custom_path on update for now. The backend preserves it when absent, while
// sending it requires admin privileges; this chat deploy path does not yet mirror
// the raw app editor's user/admin-specific custom_path handling.
await AppService.updateAppRaw({
workspace,
path,
formData: {
app: {
path,
value: rawAppValue,
summary,
policy,
deployment_message: deploymentMessage
},
js: bundle.js,
css: bundle.css
}
})
} else {
await AppService.createAppRaw({
workspace,
formData: {
app: {
path,
value: rawAppValue,
summary,
policy,
deployment_message: deploymentMessage,
custom_path: appValue.custom_path
},
js: bundle.js,
css: bundle.css
}
})
}
break
}
}
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
@@ -2914,15 +3025,36 @@ export function prepareGlobalSystemMessage(
}
}
export function getActiveGlobalEditorContext(
workspace: string
): GlobalActiveEditorContext | undefined {
for (const { itemKind, type } of ACTIVE_GLOBAL_EDITOR_DRAFTS) {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
const path = liveDraft?.effectivePath || liveDraft?.storagePath
if (!path) continue
return { type, path, isLiveDraft: true }
}
}
export function prepareGlobalUserMessage(
instructions: string,
selectedContext: ContextElement[] = []
selectedContext: ContextElement[] = [],
options: GlobalUserMessageOptions = {}
): ChatCompletionUserMessageParam {
const selectedWorkspaceItems = selectedContext.filter(
(context) => context.type === 'workspace_script' || context.type === 'workspace_flow'
)
const activeEditor =
options.activeEditor ?? (options.workspace ? getActiveGlobalEditorContext(options.workspace) : undefined)
let content = ''
if (activeEditor) {
content += '## ACTIVE EDITOR\n'
content += `type: ${activeEditor.type}\n`
content += `path: ${activeEditor.path}\n`
content += `isLiveDraft: true\n\n`
}
if (selectedWorkspaceItems.length > 0) {
content += '## SELECTED CONTEXT\n'
for (const context of selectedWorkspaceItems) {
@@ -0,0 +1,144 @@
import { WorkspaceService } from '$lib/gen'
export type RawAppBundle = {
js: string
css: string
}
type BundleRawAppFilesParams = {
files: Record<string, string>
sharedUiFiles?: Record<string, string>
bundlerType?: 'esbuild' | 'rolldown'
timeoutMs?: number
onLog?: (delta: string) => void
}
type BundleRawAppDraftParams = BundleRawAppFilesParams & {
workspace: string
}
const DEFAULT_TIMEOUT_MS = 120_000
function makeRequestId(): string {
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
}
async function loadSharedUiFiles(workspace: string): Promise<Record<string, string>> {
try {
const res = (await WorkspaceService.getSharedUi({ workspace })) as {
files?: Record<string, string>
}
return res.files ?? {}
} catch (e) {
console.warn('Failed to load shared UI for raw app bundling:', e)
return {}
}
}
export async function bundleRawAppDraft(params: BundleRawAppDraftParams): Promise<RawAppBundle> {
const sharedUiFiles = params.sharedUiFiles ?? (await loadSharedUiFiles(params.workspace))
return bundleRawAppFiles({
...params,
sharedUiFiles
})
}
export function bundleRawAppFiles({
files,
sharedUiFiles = {},
bundlerType = 'esbuild',
timeoutMs = DEFAULT_TIMEOUT_MS,
onLog
}: BundleRawAppFilesParams): Promise<RawAppBundle> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(new Error('Raw app bundling requires a browser environment.'))
}
return new Promise((resolve, reject) => {
const requestId = makeRequestId()
const iframe = document.createElement('iframe')
let settled = false
let bundleRequestSent = false
const cleanup = () => {
window.removeEventListener('message', onMessage)
clearTimeout(timeout)
iframe.remove()
}
const settle = <T>(fn: (value: T) => void, value: T) => {
if (settled) return
settled = true
cleanup()
fn(value)
}
const postToBundler = (message: Record<string, unknown>) => {
if (!iframe.contentWindow) {
settle(reject, new Error('Raw app bundler iframe did not initialize.'))
return
}
iframe.contentWindow.postMessage(message, window.location.origin)
}
const sendBundleProtocolRequest = () => {
if (settled || bundleRequestSent) return
bundleRequestSent = true
postToBundler({
type: 'bundleRawApp',
requestId,
files,
sharedUiFiles,
bundlerType
})
}
const timeout = window.setTimeout(() => {
settle(reject, new Error('Timed out while bundling raw app.'))
}, timeoutMs)
function onMessage(event: MessageEvent) {
if (event.source !== iframe.contentWindow) return
const data = event.data
if (!data) return
if (data.type === 'bundleRawAppReady') {
sendBundleProtocolRequest()
return
}
if (data.requestId !== requestId) return
if (data.type === 'appendLogs') {
onLog?.(String(data.delta ?? ''))
} else if (data.type === 'bundleRawAppResult') {
const bundle = data.bundle
if (!bundle?.js) {
settle(reject, new Error('Raw app bundler returned an empty JavaScript bundle.'))
return
}
settle(resolve, {
js: String(bundle.js),
css: String(bundle.css ?? '')
})
} else if (data.type === 'bundleRawAppError') {
settle(reject, new Error(String(data.error ?? 'Raw app bundle failed.')))
}
}
iframe.title = 'Raw app bundler'
iframe.tabIndex = -1
// Windmill pages use COEP=require-corp; the static UI builder iframe must be credentialless.
iframe.setAttribute('credentialless', '')
iframe.style.position = 'fixed'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
iframe.style.opacity = '0'
iframe.style.pointerEvents = 'none'
iframe.src = '/ui_builder/index.html?mode=bundle'
window.addEventListener('message', onMessage)
document.body.appendChild(iframe)
})
}
@@ -72,6 +72,8 @@
let wrapperEl: HTMLDivElement | undefined = $state()
let searchInputEl: TextInput | undefined = $state()
let currentValue = $derived(value ?? [])
$effect(() => searchInputEl?.focus())
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
@@ -87,18 +89,20 @@
})
let valueEntry = $derived(
value.map((v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v })
currentValue.map(
(v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v }
)
)
function onAddValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
} else {
value = [...value, item.value]
value = [...currentValue, item.value]
}
}
function onRemoveValue(item: ProcessedItem<Value>) {
value = value.filter((v) => v !== item.value)
value = currentValue.filter((v) => v !== item.value)
}
function clearValue() {
@@ -132,7 +136,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{#if value.length === 0}
{#if currentValue.length === 0}
<span class={twMerge('text-xs h-full flex items-center flex-1 text-hint', placeholderClass)}>
{placeholder}
</span>
@@ -149,7 +153,7 @@
{allowClear}
onRemove={onRemoveValue}
onReorder={reorderable
? (oldIdx, newIdx) => (value = reorder(value, oldIdx, newIdx))
? (oldIdx, newIdx) => (value = reorder(currentValue, oldIdx, newIdx))
: undefined}
/>
</ul>
@@ -166,7 +170,7 @@
{disablePortal}
onSelectValue={onAddValue}
{open}
processedItems={processedItems.filter((item) => !value.includes(item.value))}
processedItems={processedItems.filter((item) => !currentValue.includes(item.value))}
value={undefined}
{disabled}
{filterText}
@@ -181,7 +185,7 @@
ulClass="options"
>
{#snippet header()}
{#if processedItems.length - value.length > 0 || onCreateItem}
{#if processedItems.length - currentValue.length > 0 || onCreateItem}
<div class="mx-2 mb-1 mt-2 flex items-center relative">
<TextInput
bind:this={searchInputEl}
@@ -672,124 +672,124 @@
</div>
</div>
<div class="flex flex-col h-full justify-end">
<Menubar class="flex flex-col gap-1 mb-6 md:mb-10">
<Menubar class="flex flex-col">
{#snippet children({ createMenu })}
<UserMenu {isCollapsed} {createMenu} />
<div class="flex flex-col gap-1 mb-6 md:mb-10">
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification notificationCount={subItem['notificationCount']} />
</div>
{/if}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
{/snippet}
</Menubar>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification notificationCount={subItem['notificationCount']} />
</div>
{/if}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
</div>
<div class="flex flex-col gap-1">
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
</div>
{/snippet}
</Menubar>
</div>
@@ -696,7 +696,7 @@
variant="default"
startIcon={{ icon: List }}
disabled={!allowSchedule || pathError != '' || emptyString(script_path)}
href={`${base}/runs/${script_path}?show_schedules=true&show_future_jobs=true`}
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
>
View runs
</Button>
@@ -9,6 +9,8 @@
import { ArrowRight, TriangleAlert } from 'lucide-svelte'
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import type { Snippet } from 'svelte'
import Tooltip from '../meltComponents/Tooltip.svelte'
import { workspaceStore } from '$lib/stores'
type Props = {
value: string | undefined
@@ -37,6 +39,11 @@
)
)
let open = $state(false)
function otherWorkspaces(dbname: string): string[] {
const all = customInstanceDbs.current?.[dbname]?.used_by_workspaces ?? []
return all.filter((w) => w !== $workspaceStore)
}
</script>
<div class="flex relative items-center {className}">
@@ -52,11 +59,17 @@
disabled={!$isCustomInstanceDbEnabled}
>
{#snippet endSnippet({ item })}
{@render customInstanceDbWizardButton(item.value)}
<div class="flex items-center gap-1">
{@render sharedWorkspacesWarning(item.value)}
{@render customInstanceDbWizardButton(item.value)}
</div>
{/snippet}
</Select>
{#if value}
{@render customInstanceDbWizardButton(value, 'absolute right-1.5')}
<div class="absolute right-1.5 flex items-center gap-1">
{@render sharedWorkspacesWarning(value)}
{@render customInstanceDbWizardButton(value)}
</div>
{/if}
</div>
@@ -74,12 +87,12 @@
}
/>
{#snippet customInstanceDbWizardButton(dbname: string, clazz: string = '')}
{#snippet customInstanceDbWizardButton(dbname: string)}
{@const status = customInstanceDbs.current?.[dbname]}
<Button
spacingSize="xs2"
variant="default"
wrapperClasses="bg-surface-input h-6 -my-2 {clazz}"
wrapperClasses="bg-surface-input h-6 -my-2"
onClick={() => ((openedDbNameWizard = dbname), (open = false))}
>
{#if !status}
@@ -95,3 +108,21 @@
{/if}
</Button>
{/snippet}
{#snippet sharedWorkspacesWarning(dbname: string)}
{@const others = otherWorkspaces(dbname)}
{#if others.length > 0}
<Tooltip placement="top">
<TriangleAlert
class="text-orange-500 dark:text-orange-400"
size={16}
aria-label="Database is shared with other workspaces"
/>
{#snippet text()}
This database is also used by workspace{others.length > 1 ? 's' : ''}
<span class="font-semibold">{others.join(', ')}</span>. Any data written here will be shared
with {others.length > 1 ? 'them' : 'it'}.
{/snippet}
</Tooltip>
{/if}
{/snippet}
@@ -462,7 +462,7 @@
{/key}
<div class="flex gap-2 items-center justify-end">
<Button
href={`${base}/runs/?schedule_path=${path}&show_schedules=true&show_future_jobs=true`}
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
unifiedSize="md"
startIcon={{ icon: List }}
variant="subtle"
@@ -532,11 +532,7 @@
{
displayName: 'View runs',
icon: List,
href:
base +
'/runs/?schedule_path=' +
path +
'&show_schedules=true&show_future_jobs=true'
href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`
},
{
displayName: 'Audit logs',