mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
Merge remote-tracking branch 'origin/main' into operator-fork-workspace-ruleset
# Conflicts: # backend/ee-repo-ref.txt
This commit is contained in:
@@ -15,7 +15,7 @@ on:
|
||||
description: "Space-separated modes (global, cli, flow, app, script)"
|
||||
default: "global cli"
|
||||
models:
|
||||
description: "Space-separated model aliases (bun run cli -- models); cli runs only the Claude ones"
|
||||
description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"
|
||||
default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro"
|
||||
runs:
|
||||
description: "Runs per case"
|
||||
@@ -40,17 +40,28 @@ jobs:
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: ai_evals/core
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
|
||||
- id: matrix
|
||||
env:
|
||||
MODES: ${{ inputs.modes || 'global cli' }}
|
||||
# cli drives the Claude Agent SDK, so it only takes the Claude aliases
|
||||
# (`cli` entries in ai_evals/core/models.ts).
|
||||
# cli takes only the aliases with a `cli` entry in ai_evals/core/models.ts.
|
||||
run: |
|
||||
echo "matrix=$(jq -cn --arg modes "$MODES" --arg models "$MODELS" '
|
||||
cli_models=$(bun -e '
|
||||
import { EVAL_MODELS } from "./ai_evals/core/models.ts";
|
||||
console.log(JSON.stringify(EVAL_MODELS.filter((m) => m.cli)
|
||||
.flatMap((m) => [m.id, ...m.aliases]).map((a) => a.toLowerCase())));')
|
||||
echo "matrix=$(jq -cn --arg modes "$MODES" --arg models "$MODELS" --argjson cli "$cli_models" '
|
||||
def words: split(" ") | map(select(. != ""));
|
||||
[ $modes | words[] | select(IN("global", "flow", "app", "script", "cli")) as $mode
|
||||
| $models | words[]
|
||||
| select($mode != "cli" or test("^(claude-)?(haiku|sonnet|opus)"; "i"))
|
||||
| select($mode != "cli" or (ascii_downcase | IN($cli[])))
|
||||
| {mode: $mode, model: .} ]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
evals:
|
||||
@@ -120,6 +131,10 @@ jobs:
|
||||
working-directory: ./cli
|
||||
run: bun install && ./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh
|
||||
|
||||
- name: Install Codex CLI
|
||||
if: matrix.mode == 'cli'
|
||||
run: npm install --global @openai/codex@0.159.3
|
||||
|
||||
- name: Run ${{ matrix.mode }} evals on ${{ matrix.model }}
|
||||
working-directory: ./ai_evals
|
||||
env:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.825.0"
|
||||
".": "1.826.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [1.826.0](https://github.com/windmill-labs/windmill/compare/v1.825.0...v1.826.0) (2026-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* count OIDC signatures and reuse server-side tokens ([#11556](https://github.com/windmill-labs/windmill/issues/11556)) ([e277735](https://github.com/windmill-labs/windmill/commit/e277735788c260239a063063459776ec01f7d907))
|
||||
* isolate the raw-app editor preview when the app opts into sandboxing ([#11572](https://github.com/windmill-labs/windmill/issues/11572)) ([fa25397](https://github.com/windmill-labs/windmill/commit/fa253976e213c1053d1832c1d1fb7bedf7ce8c45))
|
||||
* job_token_scopes OIDC claim ([#11551](https://github.com/windmill-labs/windmill/issues/11551)) ([d493eff](https://github.com/windmill-labs/windmill/commit/d493eff237f96a353fb6f5715a15bcad61d24433))
|
||||
* set up a new app in one modal over its AI session, fix its deploy ([#11582](https://github.com/windmill-labs/windmill/issues/11582)) ([7190185](https://github.com/windmill-labs/windmill/commit/71901852fb3cc40515d6675a0a88cd158e942d56))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export getWorkspace and other client helpers as named exports ([#11581](https://github.com/windmill-labs/windmill/issues/11581)) ([bf201be](https://github.com/windmill-labs/windmill/commit/bf201be11e95f0fc3e12353531f25bb3ceb0f8f3))
|
||||
* give the dbt workspace settings page a header like its siblings ([#11576](https://github.com/windmill-labs/windmill/issues/11576)) ([38aa4ea](https://github.com/windmill-labs/windmill/commit/38aa4eaa1200db52e67a18ef9a7c5dc4d60a4948))
|
||||
* ignore schedule missed-run counters in workspace compare ([#11570](https://github.com/windmill-labs/windmill/issues/11570)) ([eab3dd5](https://github.com/windmill-labs/windmill/commit/eab3dd5a52bc8faf57bb84c85bd74eb20ebd8ec7))
|
||||
* keep chat tool cards collapsed the way the user left them ([#11575](https://github.com/windmill-labs/windmill/issues/11575)) ([c71ecad](https://github.com/windmill-labs/windmill/commit/c71ecad6f9cf3727a305b5f9038dcbe6a54398b8))
|
||||
* keep run status buttons from selecting the flow input node ([#11579](https://github.com/windmill-labs/windmill/issues/11579)) ([8661797](https://github.com/windmill-labs/windmill/commit/866179730a9798346d3950f27dd625c3358e690a))
|
||||
* make every trigger and schedule editor control respect read-only mode ([#11569](https://github.com/windmill-labs/windmill/issues/11569)) ([52a99c3](https://github.com/windmill-labs/windmill/commit/52a99c3047e1add477e36919b15a745f7d2604a0))
|
||||
* strip NUL from ai agent output and job args before jsonb writes ([#11568](https://github.com/windmill-labs/windmill/issues/11568)) ([cf4bec6](https://github.com/windmill-labs/windmill/commit/cf4bec611f6df8a45b238afe5b8da3e3a5748bf9))
|
||||
|
||||
## [1.825.0](https://github.com/windmill-labs/windmill/compare/v1.824.1...v1.825.0) (2026-10-06)
|
||||
|
||||
|
||||
|
||||
+21
-1
@@ -89,7 +89,21 @@ Notes:
|
||||
|
||||
- the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5`
|
||||
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases
|
||||
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
|
||||
- `cli` mode runs a coding agent over the workspace, chosen by the alias's `cli` entry; aliases without one are rejected there:
|
||||
|
||||
| Agent | Models | Label | Needs |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude Code (`@anthropic-ai/claude-agent-sdk`) | Claude aliases | `anthropic:<model>` | `ANTHROPIC_API_KEY` |
|
||||
| Claude Code on DeepSeek's Anthropic-compatible endpoint | `deepseek-v4-pro` | `claude-code:<model>` | `DEEPSEEK_API_KEY` |
|
||||
| Codex (`codex exec --json`) | `gpt-6.1-sol`, `gpt-6-astra` | `codex:<model>` | `codex` on `PATH` (CI pins 0.159.3), `OPENAI_API_KEY` |
|
||||
|
||||
Codex runs with a throwaway `HOME`, so your own `~/.codex` config and skills stay out of the run. It has no
|
||||
Skill tool: it finds `.agents/skills` on its own and loads a skill by reading its `SKILL.md`, so the trace
|
||||
records any command that touches `.agents/skills/<name>/SKILL.md` as a `Skill` call to `<name>`, just
|
||||
before that command, or just after it when the command mutates something before reading the skill. Its shell commands are recorded as `Bash` and its patches as `Edit`, so the
|
||||
`invokes skill … before first mutation` checks compare the same things for both agents. Codex has no
|
||||
turn cap; a run stops at `maxTurns` rounds of tool calls (parallel calls count once) and fails, as a
|
||||
Claude Code run does at its cap.
|
||||
- the judge model is separate and currently defaults to `claude-sonnet-5-5`; use `--skip-judge` for deterministic-only runs
|
||||
|
||||
## Case Format
|
||||
@@ -164,6 +178,12 @@ the decrypted value, exactly as against a real backend. The chat's read path pas
|
||||
Seed a recognizable secret (the existing fixture uses `sk_live_do_not_leak_me`) and
|
||||
assert it via `valueExcludes` to catch a leak.
|
||||
|
||||
`runtime.userAnswers` scripts the user's side of `askUserQuestion` in a global case: one
|
||||
answer per question, in the order they are asked, and a question past the last answer is
|
||||
dismissed, so `userAnswers: []` dismisses every question. Without it the tool reports that
|
||||
it cannot ask, and the model usually carries on with its own guess, which makes "asks
|
||||
before creating anything" impossible to assert.
|
||||
|
||||
`toolExpect.toolCallArgs` entries support `sharedByAtLeast: <n>`: at least `n` recorded
|
||||
calls to that tool must carry the same non-blank string in the field. Use it for calls that
|
||||
have to share an identifier, like two test runs of one chat conversation.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runCodex, toToolInvocations, unwrapShellCommand } from "./codex";
|
||||
|
||||
describe("unwrapShellCommand", () => {
|
||||
it("strips the login-shell wrapper Codex reports", () => {
|
||||
expect(unwrapShellCommand(`/usr/bin/zsh -lc 'cat .agents/skills/x/SKILL.md'`)).toBe(
|
||||
"cat .agents/skills/x/SKILL.md"
|
||||
);
|
||||
expect(unwrapShellCommand(`/bin/bash -lc "printf 'hi' > \\"a b\\""`)).toBe(
|
||||
`printf 'hi' > "a b"`
|
||||
);
|
||||
expect(unwrapShellCommand(`/bin/bash -lc 'echo '\\''x'\\'''`)).toBe(`echo 'x'`);
|
||||
expect(unwrapShellCommand(`bash -lc 'wmill sync push'`)).toBe("wmill sync push");
|
||||
expect(unwrapShellCommand(`/bin/bash -lc 'rg -g '"'"'!x'"'"' f'`)).toBe(`rg -g '!x' f`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toToolInvocations", () => {
|
||||
it("records reading a SKILL.md as a Skill call before the command", () => {
|
||||
const tools = toToolInvocations({
|
||||
type: "command_execution",
|
||||
command: `/bin/bash -lc 'sed -n 1,200p .agents/skills/write-script-bun/SKILL.md && mkdir f'`,
|
||||
});
|
||||
expect(tools.map((tool) => [tool.tool, tool.input])).toEqual([
|
||||
["Skill", { skill: "write-script-bun" }],
|
||||
["Bash", { command: "sed -n 1,200p .agents/skills/write-script-bun/SKILL.md && mkdir f" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a skill read after a mutation earlier in the same command", () => {
|
||||
const tools = toToolInvocations({
|
||||
type: "command_execution",
|
||||
command: `/bin/bash -lc 'printf main > f/a.ts && cat .agents/skills/write-script-bun/SKILL.md'`,
|
||||
});
|
||||
expect(tools.map((tool) => tool.tool)).toEqual(["Bash", "Skill"]);
|
||||
});
|
||||
|
||||
it("maps a patch to Edit so it counts as a mutation", () => {
|
||||
expect(
|
||||
toToolInvocations({ type: "file_change", changes: [{ path: "a.ts", kind: "add" }] })[0]
|
||||
).toMatchObject({ tool: "Edit", input: { changes: [{ path: "a.ts", kind: "add" }] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCodex", () => {
|
||||
it("keeps parallel tool calls in the order they started", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "fake-codex-"));
|
||||
const events = [
|
||||
{ type: "item.started", item: { id: "a", type: "command_execution", command: "mkdir f" } },
|
||||
{ type: "item.started", item: { id: "b", type: "command_execution", command: "cat .agents/skills/s/SKILL.md" } },
|
||||
{ type: "item.completed", item: { id: "b", type: "command_execution", command: "cat .agents/skills/s/SKILL.md" } },
|
||||
{ type: "item.completed", item: { id: "a", type: "command_execution", command: "mkdir f" } },
|
||||
{ type: "item.completed", item: { id: "c", type: "agent_message", text: "done" } },
|
||||
{ type: "turn.completed", usage: { input_tokens: 10, output_tokens: 2 } },
|
||||
];
|
||||
const lines = events.map((event) => `printf '%s\\n' '${JSON.stringify(event)}'`).join("\n");
|
||||
await writeFile(join(dir, "codex"), `#!/bin/sh\ncat > /dev/null\n${lines}\n`);
|
||||
await chmod(join(dir, "codex"), 0o755);
|
||||
try {
|
||||
const result = await runCodex("p", dir, 12, "m", { PATH: `${dir}:${process.env.PATH}` });
|
||||
expect(result.toolsUsed.map((tool) => tool.tool)).toEqual(["Bash", "Skill", "Bash"]);
|
||||
expect(result.output).toBe("done");
|
||||
expect(result.tokenUsage).toEqual({ prompt: 10, completion: 2, total: 12 });
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { BenchmarkTokenUsage, CliToolInvocation } from "../../core/types";
|
||||
import { isLikelyMutatingBashCommand } from "./mutation";
|
||||
|
||||
export interface AgentRunResult {
|
||||
output: string;
|
||||
toolsUsed: CliToolInvocation[];
|
||||
assistantMessageCount: number;
|
||||
tokenUsage: BenchmarkTokenUsage | null;
|
||||
finalContextTokens: number | null;
|
||||
}
|
||||
|
||||
interface CodexItem {
|
||||
type?: string;
|
||||
text?: string;
|
||||
command?: string;
|
||||
changes?: { path?: string; kind?: string }[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CodexEvent {
|
||||
type?: string;
|
||||
item?: CodexItem;
|
||||
usage?: { input_tokens?: number; output_tokens?: number };
|
||||
message?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
const SKILL_FILE_PATTERN = /\.agents\/skills\/([^/\s'"`*?[]+)\/SKILL\.md/g;
|
||||
|
||||
// Drives `codex exec` and maps its events onto the Claude Code tool vocabulary
|
||||
// the validators read: shell commands become `Bash`, patches `Edit`, and
|
||||
// reading `.agents/skills/<name>/SKILL.md` (how Codex loads a skill) becomes a
|
||||
// `Skill` call recorded just before that command.
|
||||
export async function runCodex(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
maxTurns: number,
|
||||
model: string,
|
||||
env: Record<string, string>
|
||||
): Promise<AgentRunResult> {
|
||||
// A fresh HOME keeps the user's ~/.codex config and ~/.agents/skills out of the run.
|
||||
const home = await mkdtemp(join(tmpdir(), "wmill-cli-benchmark-codex-home-"));
|
||||
const result: AgentRunResult = {
|
||||
output: "",
|
||||
toolsUsed: [],
|
||||
assistantMessageCount: 0,
|
||||
tokenUsage: null,
|
||||
finalContextTokens: null,
|
||||
};
|
||||
const messages: string[] = [];
|
||||
let failure: string | null = null;
|
||||
let lastError: string | null = null;
|
||||
let stderr = "";
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--json",
|
||||
"--ephemeral",
|
||||
"--skip-git-repo-check",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--model",
|
||||
model,
|
||||
"--cd",
|
||||
cwd,
|
||||
"-",
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
HOME: home,
|
||||
CODEX_HOME: home,
|
||||
CODEX_API_KEY: env.OPENAI_API_KEY ?? "",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
// Without this, a missing `codex` binary leaves the stdout loop waiting forever.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("spawn", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
child.stdin.on("error", () => {});
|
||||
child.stdin.end(prompt);
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk).slice(-4000);
|
||||
});
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", resolve);
|
||||
});
|
||||
|
||||
// Codex has no turn cap. A turn starts when the agent issues tool calls with
|
||||
// none in flight, so parallel calls count once, as they do for Claude Code.
|
||||
// Claude Code's cap includes the final reply, so the maxTurns-th tool round is
|
||||
// already over it.
|
||||
// ponytail: parallel calls that finish before the next one starts count as
|
||||
// separate turns; count model calls if Codex ever reports them.
|
||||
let turns = 0;
|
||||
let inFlight = 0;
|
||||
let capped = false;
|
||||
// Parallel calls complete out of order; the trace keeps the order they started in.
|
||||
const slots = new Map<string, number>();
|
||||
const ordered: CliToolInvocation[][] = [];
|
||||
const slotFor = (item: CodexItem) => {
|
||||
const id = typeof item.id === "string" ? item.id : undefined;
|
||||
if (id !== undefined && slots.has(id)) {
|
||||
return slots.get(id)!;
|
||||
}
|
||||
ordered.push([]);
|
||||
if (id !== undefined) {
|
||||
slots.set(id, ordered.length - 1);
|
||||
}
|
||||
return ordered.length - 1;
|
||||
};
|
||||
|
||||
for await (const line of createInterface({ input: child.stdout })) {
|
||||
const event = parseEvent(line);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
const item = event.item;
|
||||
|
||||
if (event.type === "item.started" && item && isToolItem(item)) {
|
||||
if (inFlight === 0) {
|
||||
turns += 1;
|
||||
}
|
||||
inFlight += 1;
|
||||
slotFor(item);
|
||||
if (turns >= maxTurns && !capped) {
|
||||
capped = true;
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
} else if (event.type === "item.completed" && item) {
|
||||
if (item.type === "agent_message") {
|
||||
result.assistantMessageCount += 1;
|
||||
if (typeof item.text === "string") {
|
||||
messages.push(item.text);
|
||||
}
|
||||
} else if (isToolItem(item)) {
|
||||
inFlight = Math.max(0, inFlight - 1);
|
||||
ordered[slotFor(item)] = toToolInvocations(item);
|
||||
}
|
||||
} else if (event.type === "turn.completed" && event.usage) {
|
||||
const prompt = event.usage.input_tokens ?? 0;
|
||||
const completion = event.usage.output_tokens ?? 0;
|
||||
result.tokenUsage = { prompt, completion, total: prompt + completion };
|
||||
} else if (event.type === "turn.failed") {
|
||||
failure = event.error?.message ?? JSON.stringify(event);
|
||||
} else if (event.type === "error") {
|
||||
// Also sent for retries Codex recovers from ("Reconnecting... 1/5").
|
||||
lastError = event.message ?? JSON.stringify(event);
|
||||
}
|
||||
}
|
||||
|
||||
result.toolsUsed = ordered.flat();
|
||||
const code = await exited;
|
||||
if (failure) {
|
||||
throw new Error(`codex: ${failure}`);
|
||||
}
|
||||
// Fail the attempt, as Claude Code does at its cap.
|
||||
if (capped) {
|
||||
throw new Error(`codex: reached maximum number of turns (${maxTurns})`);
|
||||
}
|
||||
if (code !== 0) {
|
||||
throw new Error(`codex exited with ${code}: ${lastError ?? stderr.trim()}`);
|
||||
}
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
result.output = messages.join("\n");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toToolInvocations(item: CodexItem): CliToolInvocation[] {
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (item.type === "command_execution" && typeof item.command === "string") {
|
||||
const command = unwrapShellCommand(item.command);
|
||||
// A skill read counts as preceding the command only when nothing before it in
|
||||
// the command mutates; otherwise `printf x > f && cat SKILL.md` would pass a
|
||||
// skill-before-first-mutation check.
|
||||
const before: string[] = [];
|
||||
const after: string[] = [];
|
||||
for (const match of command.matchAll(SKILL_FILE_PATTERN)) {
|
||||
const skill = match[1]!;
|
||||
if (before.includes(skill) || after.includes(skill)) {
|
||||
continue;
|
||||
}
|
||||
(isLikelyMutatingBashCommand(command.slice(0, match.index)) ? after : before).push(skill);
|
||||
}
|
||||
const skillCall = (skill: string) => ({ tool: "Skill", input: { skill }, timestamp });
|
||||
return [
|
||||
...before.map(skillCall),
|
||||
{ tool: "Bash", input: { command }, timestamp },
|
||||
...after.map(skillCall),
|
||||
];
|
||||
}
|
||||
|
||||
if (item.type === "file_change") {
|
||||
return [{ tool: "Edit", input: { changes: item.changes ?? [] }, timestamp }];
|
||||
}
|
||||
|
||||
const { id: _id, type, status: _status, ...input } = item;
|
||||
return [{ tool: String(type), input, timestamp }];
|
||||
}
|
||||
|
||||
// Codex reports commands as run through the user's login shell, e.g.
|
||||
// `/usr/bin/zsh -lc 'cat SKILL.md'`.
|
||||
export function unwrapShellCommand(command: string): string {
|
||||
const match = command.match(/^(?:\S*\/)?(?:ba|z)?sh -l?c (['"])([\s\S]*)\1$/);
|
||||
if (!match) {
|
||||
return command;
|
||||
}
|
||||
const body = match[2]!;
|
||||
return match[1] === "'"
|
||||
? body.replaceAll(`'\\''`, "'").replaceAll(`'"'"'`, "'")
|
||||
: body.replace(/\\(["\\$`])/g, "$1");
|
||||
}
|
||||
|
||||
function isToolItem(item: CodexItem): boolean {
|
||||
return !["agent_message", "reasoning", "todo_list", "error"].includes(item.type ?? "");
|
||||
}
|
||||
|
||||
function parseEvent(line: string): CodexEvent | null {
|
||||
try {
|
||||
const value = JSON.parse(line);
|
||||
return value && typeof value === "object" ? (value as CodexEvent) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function isLikelyMutatingBashCommand(command: string): boolean {
|
||||
return (
|
||||
/\b(?:mkdir|touch|rm|mv|cp|install|tee)\b/.test(command) ||
|
||||
/\b(?:cat|echo|printf)\b.*(?:>|>>|\|\s*tee\b)/.test(command) ||
|
||||
/\bsed\s+-i\b/.test(command) ||
|
||||
/\bperl\s+-pi\b/.test(command) ||
|
||||
// In command position only: `cat AGENTS.wmill.md` is a read.
|
||||
/(?:^|[;&|(]|\b(?:then|do|xargs)\s)\s*wmill(?:\s|$)/m.test(command)
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
anthropicUsageToBenchmarkTokenUsage,
|
||||
extractCliResultTokenUsage,
|
||||
endpointEnv,
|
||||
extractProposedWmillCommands,
|
||||
getFirstMutationToolIndex,
|
||||
parseWmillInvocationLog,
|
||||
} from "./runtime";
|
||||
|
||||
@@ -147,3 +149,32 @@ push
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstMutationToolIndex", () => {
|
||||
it("counts wmill only as a command, not in a file name", () => {
|
||||
const bash = (command: string) => ({ tool: "Bash", input: { command }, timestamp: 0 });
|
||||
expect(
|
||||
getFirstMutationToolIndex([
|
||||
bash("cat AGENTS.wmill.md .agents/skills/x/SKILL.md"),
|
||||
bash("ls && wmill generate-metadata"),
|
||||
])
|
||||
).toBe(1);
|
||||
expect(getFirstMutationToolIndex([bash("cd f\nwmill sync push")])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("endpointEnv", () => {
|
||||
it("passes the endpoint only its own key", () => {
|
||||
const env = endpointEnv(
|
||||
{ baseUrl: "https://example.test", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
||||
"m",
|
||||
{ PATH: "/bin", DEEPSEEK_API_KEY: "ds", OPENAI_API_KEY: "oa", ANTHROPIC_AUTH_TOKEN: "t" }
|
||||
);
|
||||
expect(env).toEqual({
|
||||
PATH: "/bin",
|
||||
ANTHROPIC_BASE_URL: "https://example.test",
|
||||
ANTHROPIC_API_KEY: "ds",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "m",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@ import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { delimiter, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getCliEvalModel, resolveEvalModel, type CliEvalModelConfig } from "../../core/models";
|
||||
import {
|
||||
getCliEvalModel,
|
||||
resolveEvalModel,
|
||||
type CliEvalModelConfig,
|
||||
} from "../../core/models";
|
||||
import { runCodex, type AgentRunResult } from "./codex";
|
||||
import { isLikelyMutatingBashCommand } from "./mutation";
|
||||
export { formatCliRunModelLabel } from "../../core/models";
|
||||
import type {
|
||||
BenchmarkTokenUsage,
|
||||
CliToolInvocation,
|
||||
@@ -141,16 +148,94 @@ export async function runPromptAndCapture(
|
||||
maxTurns: number = 3,
|
||||
modelConfig: CliEvalModelConfig = DEFAULT_CLI_EVAL_MODEL
|
||||
): Promise<PromptRunResult> {
|
||||
const toolsUsed: ToolInvocation[] = [];
|
||||
const skillsInvoked: string[] = [];
|
||||
const bashCommands: string[] = [];
|
||||
let output = "";
|
||||
let assistantMessageCount = 0;
|
||||
let tokenUsage: BenchmarkTokenUsage | null = null;
|
||||
let finalContextTokens: number | null = null;
|
||||
const startedAt = Date.now();
|
||||
const stubBinDir = join(cwd, WMILL_STUB_DIR_NAME);
|
||||
const wmillLogPath = join(cwd, WMILL_LOG_FILE_NAME);
|
||||
const env = {
|
||||
...getQueryEnv(),
|
||||
PATH: process.env.PATH ? `${stubBinDir}${delimiter}${process.env.PATH}` : stubBinDir,
|
||||
WMILL_BENCHMARK_LOG_PATH: wmillLogPath,
|
||||
};
|
||||
|
||||
await installWmillStub(stubBinDir);
|
||||
|
||||
const run =
|
||||
modelConfig.runtime === "codex"
|
||||
? await runCodex(prompt, cwd, maxTurns, modelConfig.model, env)
|
||||
: await runClaudeCode(prompt, cwd, maxTurns, modelConfig, env);
|
||||
|
||||
const skillsInvoked: string[] = [];
|
||||
const bashCommands: string[] = [];
|
||||
for (const tool of run.toolsUsed) {
|
||||
if (tool.tool === "Skill" && typeof tool.input.skill === "string") {
|
||||
pushUnique(skillsInvoked, tool.input.skill);
|
||||
}
|
||||
if (tool.tool === "Bash") {
|
||||
for (const command of extractBashCommands(tool.input)) {
|
||||
pushUnique(bashCommands, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wmillInvocations = await readWmillInvocationLog(wmillLogPath);
|
||||
|
||||
return {
|
||||
output: run.output,
|
||||
durationMs: Date.now() - startedAt,
|
||||
tokenUsage: run.tokenUsage,
|
||||
finalContextTokens: run.finalContextTokens,
|
||||
trace: {
|
||||
toolsUsed: run.toolsUsed,
|
||||
skillsInvoked,
|
||||
assistantMessageCount: run.assistantMessageCount,
|
||||
bashCommands,
|
||||
proposedCommands: extractProposedWmillCommands(run.output),
|
||||
executedWmillCommands: wmillInvocations.map(formatExecutedWmillCommand),
|
||||
wmillInvocations,
|
||||
firstMutationToolIndex: getFirstMutationToolIndex(run.toolsUsed),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Only the endpoint's own key may reach it: a missing key would make Claude Code
|
||||
// fall back to the user's Claude login, and the agent's Bash inherits this env,
|
||||
// so any other credential could end up in a tool result sent to the endpoint.
|
||||
export function endpointEnv(
|
||||
endpoint: NonNullable<CliEvalModelConfig["anthropicEndpoint"]>,
|
||||
model: string,
|
||||
env: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const key = env[endpoint.apiKeyEnv];
|
||||
if (!key) {
|
||||
throw new Error(`${endpoint.apiKeyEnv} is not set`);
|
||||
}
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(env).filter(([name]) => !/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(name))
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
ANTHROPIC_BASE_URL: endpoint.baseUrl,
|
||||
ANTHROPIC_API_KEY: key,
|
||||
// Claude Code's background calls default to Haiku, which the endpoint does not serve.
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeCode(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
maxTurns: number,
|
||||
modelConfig: CliEvalModelConfig,
|
||||
env: Record<string, string>
|
||||
): Promise<AgentRunResult> {
|
||||
const result: AgentRunResult = {
|
||||
output: "",
|
||||
toolsUsed: [],
|
||||
assistantMessageCount: 0,
|
||||
tokenUsage: null,
|
||||
finalContextTokens: null,
|
||||
};
|
||||
const endpoint = modelConfig.anthropicEndpoint;
|
||||
|
||||
const options: Options = {
|
||||
cwd,
|
||||
@@ -158,80 +243,42 @@ export async function runPromptAndCapture(
|
||||
maxTurns,
|
||||
settingSources: ["project"],
|
||||
allowedTools: ["Skill", "Read", "Glob", "Grep", "Bash", "Write", "Edit"],
|
||||
env: {
|
||||
...getQueryEnv(),
|
||||
PATH: process.env.PATH ? `${stubBinDir}${delimiter}${process.env.PATH}` : stubBinDir,
|
||||
WMILL_BENCHMARK_LOG_PATH: wmillLogPath,
|
||||
},
|
||||
env: endpoint ? endpointEnv(endpoint, modelConfig.model, env) : env,
|
||||
};
|
||||
|
||||
await installWmillStub(stubBinDir);
|
||||
|
||||
for await (const message of query({ prompt, options })) {
|
||||
if (message.type === "assistant") {
|
||||
assistantMessageCount += 1;
|
||||
result.assistantMessageCount += 1;
|
||||
const turnContext = anthropicUsageToBenchmarkTokenUsage(
|
||||
message.message?.usage
|
||||
)?.prompt;
|
||||
if (turnContext && turnContext > 0) {
|
||||
finalContextTokens = turnContext;
|
||||
result.finalContextTokens = turnContext;
|
||||
}
|
||||
const content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use") {
|
||||
const input = normalizeToolInput(block.input);
|
||||
toolsUsed.push({
|
||||
result.toolsUsed.push({
|
||||
tool: block.name,
|
||||
input,
|
||||
input: normalizeToolInput(block.input),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (block.name === "Skill") {
|
||||
const skillInput = input as { skill?: string };
|
||||
if (skillInput.skill) {
|
||||
pushUnique(skillsInvoked, skillInput.skill);
|
||||
}
|
||||
}
|
||||
|
||||
if (block.name === "Bash") {
|
||||
for (const command of extractBashCommands(input)) {
|
||||
pushUnique(bashCommands, command);
|
||||
}
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
output += block.text;
|
||||
result.output += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.type === "result") {
|
||||
const resultMessage = message as { result?: string };
|
||||
tokenUsage = extractCliResultTokenUsage(message) ?? tokenUsage;
|
||||
result.tokenUsage = extractCliResultTokenUsage(message) ?? result.tokenUsage;
|
||||
if (typeof resultMessage.result === "string") {
|
||||
output += resultMessage.result;
|
||||
result.output += resultMessage.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proposedCommands = extractProposedWmillCommands(output);
|
||||
const wmillInvocations = await readWmillInvocationLog(wmillLogPath);
|
||||
|
||||
return {
|
||||
output,
|
||||
durationMs: Date.now() - startedAt,
|
||||
tokenUsage,
|
||||
finalContextTokens,
|
||||
trace: {
|
||||
toolsUsed,
|
||||
skillsInvoked,
|
||||
assistantMessageCount,
|
||||
bashCommands,
|
||||
proposedCommands,
|
||||
executedWmillCommands: wmillInvocations.map(formatExecutedWmillCommand),
|
||||
wmillInvocations,
|
||||
firstMutationToolIndex: getFirstMutationToolIndex(toolsUsed),
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function wasSkillInvoked(result: PromptRunResult, skillName: string): boolean {
|
||||
@@ -242,10 +289,6 @@ export function wasToolUsed(result: PromptRunResult, toolName: string): boolean
|
||||
return result.trace.toolsUsed.some((tool) => tool.tool === toolName);
|
||||
}
|
||||
|
||||
export function formatCliRunModelLabel(modelConfig: CliEvalModelConfig): string {
|
||||
return `${modelConfig.provider}:${modelConfig.model}`;
|
||||
}
|
||||
|
||||
export function getToolInputs(
|
||||
result: PromptRunResult,
|
||||
toolName: string
|
||||
@@ -512,7 +555,7 @@ function formatExecutedWmillCommand(entry: CliWmillInvocation): string {
|
||||
return ["wmill", ...entry.argv].join(" ").trim();
|
||||
}
|
||||
|
||||
function getFirstMutationToolIndex(toolsUsed: ToolInvocation[]): number | null {
|
||||
export function getFirstMutationToolIndex(toolsUsed: ToolInvocation[]): number | null {
|
||||
for (const [index, tool] of toolsUsed.entries()) {
|
||||
if (tool.tool === "Write" || tool.tool === "Edit") {
|
||||
return index;
|
||||
@@ -526,16 +569,6 @@ function getFirstMutationToolIndex(toolsUsed: ToolInvocation[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyMutatingBashCommand(command: string): boolean {
|
||||
return (
|
||||
/\b(?:mkdir|touch|rm|mv|cp|install|tee)\b/.test(command) ||
|
||||
/\b(?:cat|echo|printf)\b.*(?:>|>>|\|\s*tee\b)/.test(command) ||
|
||||
/\bsed\s+-i\b/.test(command) ||
|
||||
/\bperl\s+-pi\b/.test(command) ||
|
||||
/\bwmill\b/.test(command)
|
||||
);
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], value: string): void {
|
||||
if (!values.includes(value)) {
|
||||
values.push(value);
|
||||
|
||||
@@ -123,6 +123,7 @@ export interface GlobalEvalOptions {
|
||||
planMode?: boolean;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
userAnswers?: string[];
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
@@ -220,6 +221,7 @@ export async function runGlobalEval(
|
||||
onToolCall: options.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options.maxIterations,
|
||||
userAnswers: options.userAnswers,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options.provider,
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
|
||||
const model = options.model ?? "gpt-4o";
|
||||
const maxIterations = options.maxIterations ?? 20;
|
||||
const pendingAnswers = options.userAnswers && [...options.userAnswers];
|
||||
const workspace = options.workspace ?? "test-workspace";
|
||||
const provider = toFrontendEvalProvider(options.provider);
|
||||
|
||||
@@ -138,6 +139,12 @@ export async function runEval<THelpers, TOutput>(
|
||||
// edit the arguments, so a case can assert what the model proposed but never how
|
||||
// it reacts to the user changing something.
|
||||
requestRunArgs: async (_toolId, form) => form.args,
|
||||
...(pendingAnswers && {
|
||||
requestUserQuestion: async () => {
|
||||
const answer = pendingAnswers.shift();
|
||||
return answer === undefined ? undefined : [answer];
|
||||
},
|
||||
}),
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface ToolCallDetail {
|
||||
export interface EvalRunnerOptions {
|
||||
backend: WindmillBackendSettings;
|
||||
maxIterations?: number;
|
||||
// Scripted answers to the model's questions, one per question in order; a question past
|
||||
// the last one is dismissed. Unset, the run cannot be asked anything.
|
||||
userAnswers?: string[];
|
||||
model?: string;
|
||||
workspace?: string;
|
||||
provider?: AIProvider;
|
||||
|
||||
+106
-1
@@ -198,22 +198,127 @@
|
||||
- uses the requested placeholder value and description
|
||||
- does not create a resource or deploy anything
|
||||
|
||||
# A new app that has to store data, with nothing said about where: the data setup is the
|
||||
# user's call, so it is asked before anything is created. The question is dismissed here,
|
||||
# which leaves nothing the model may build on.
|
||||
- id: global-test7-ambiguous-app-asks-question
|
||||
prompt: |-
|
||||
Create a new raw app for triaging support tickets.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 4
|
||||
maxTurns: 6
|
||||
userAnswers: []
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- list_datatables
|
||||
- askUserQuestion
|
||||
forbiddenToolsUsed:
|
||||
- init_app
|
||||
- exec_datatable_sql
|
||||
- write_app_file
|
||||
- write_app_runnable
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: askUserQuestion
|
||||
field: question
|
||||
stringIncludesAnyOf:
|
||||
- data table
|
||||
- datatable
|
||||
- schema
|
||||
- tables
|
||||
- store
|
||||
skipJudge: true
|
||||
|
||||
# The same request with the question answered: the app is built on what the user chose.
|
||||
- id: global-new-app-data-setup-answer-applied
|
||||
prompt: |-
|
||||
Create a new raw app for triaging support tickets.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 32
|
||||
userAnswers:
|
||||
- A new schema named helpdesk
|
||||
- Create new tables
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: app
|
||||
valueIncludes:
|
||||
- helpdesk
|
||||
- react-dom/client
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- askUserQuestion
|
||||
- init_app
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: init_app
|
||||
field: framework
|
||||
stringEqualsAnyOf:
|
||||
- react19
|
||||
- tool: exec_datatable_sql
|
||||
field: sql
|
||||
stringIncludesAnyOf:
|
||||
- helpdesk
|
||||
skipJudge: true
|
||||
|
||||
# No framework named, no app to follow, nothing to store: the modal's recommended
|
||||
# framework is used without a question.
|
||||
- id: global-new-app-defaults-to-react19
|
||||
prompt: |-
|
||||
Build me a little app that converts temperatures between Celsius and Fahrenheit.
|
||||
runtime:
|
||||
maxTurns: 12
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: app
|
||||
valueIncludes:
|
||||
- react-dom/client
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- init_app
|
||||
forbiddenToolsUsed:
|
||||
- askUserQuestion
|
||||
- exec_datatable_sql
|
||||
- deploy_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: init_app
|
||||
field: framework
|
||||
stringEqualsAnyOf:
|
||||
- react19
|
||||
skipJudge: true
|
||||
|
||||
# The user said where the data goes, so there is nothing left to ask.
|
||||
- id: global-new-app-storage-specified-no-question
|
||||
prompt: |-
|
||||
Build a todo list app in React. Keep the todos in the main data table, in a new
|
||||
schema called todo_app.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 20
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: app
|
||||
valueIncludes:
|
||||
- todo_app
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- init_app
|
||||
- write_app_runnable
|
||||
forbiddenToolsUsed:
|
||||
- askUserQuestion
|
||||
- deploy_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: exec_datatable_sql
|
||||
field: sql
|
||||
stringIncludesAnyOf:
|
||||
- todo_app
|
||||
skipJudge: true
|
||||
|
||||
- id: global-test8-human-script-infer-path-language
|
||||
|
||||
@@ -192,8 +192,6 @@ describe("loadCases", () => {
|
||||
requiredSkills: ["write-script-bun"],
|
||||
requiredSkillsBeforeFirstMutation: ["write-script-bun"],
|
||||
forbiddenSkills: ["write-script-python3", "write-flow"],
|
||||
orderedAssistantMentions: ["wmill generate-metadata", "wmill sync push"],
|
||||
orderedProposedCommands: ["wmill generate-metadata", "wmill sync push"],
|
||||
forbiddenExecutedCommands: ["^wmill generate-metadata", "^wmill sync push"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveEvalModel } from "./models";
|
||||
import { formatRunModelLabel, resolveEvalModel } from "./models";
|
||||
|
||||
describe("resolveEvalModel", () => {
|
||||
it("supports GPT-5.5 aliases for frontend evals", () => {
|
||||
@@ -47,6 +47,18 @@ describe("resolveEvalModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("labels cli runs by agent, keeping Claude's historical label", () => {
|
||||
expect(formatRunModelLabel("cli", resolveEvalModel("cli", "sonnet-5.5"))).toBe(
|
||||
"anthropic:claude-sonnet-5-5",
|
||||
);
|
||||
expect(formatRunModelLabel("cli", resolveEvalModel("cli", "gpt-6.1-sol"))).toBe(
|
||||
"codex:gpt-6.1-sol",
|
||||
);
|
||||
expect(formatRunModelLabel("cli", resolveEvalModel("cli", "deepseek-v4-pro"))).toBe(
|
||||
"claude-code:deepseek-v4-pro",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Gemini aliases for cli evals", () => {
|
||||
expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow(
|
||||
"Model gemini-3-flash-preview is not supported for cli mode",
|
||||
|
||||
+45
-7
@@ -5,9 +5,13 @@ export interface FrontendEvalModelConfig {
|
||||
model: string;
|
||||
}
|
||||
|
||||
// The coding agent that drives the model in cli mode (adapters/cli/runtime.ts).
|
||||
export interface CliEvalModelConfig {
|
||||
provider: "anthropic";
|
||||
runtime: "claude-code" | "codex";
|
||||
model: string;
|
||||
// An Anthropic-compatible endpoint other than Anthropic's, for claude-code,
|
||||
// and the env var holding its key.
|
||||
anthropicEndpoint?: { baseUrl: string; apiKeyEnv: string };
|
||||
}
|
||||
|
||||
export interface EvalModelSpec {
|
||||
@@ -35,7 +39,7 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
cli: {
|
||||
provider: "anthropic",
|
||||
runtime: "claude-code",
|
||||
model: "haiku",
|
||||
},
|
||||
},
|
||||
@@ -55,7 +59,7 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
},
|
||||
cli: {
|
||||
provider: "anthropic",
|
||||
runtime: "claude-code",
|
||||
model: "sonnet",
|
||||
},
|
||||
},
|
||||
@@ -74,7 +78,7 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "claude-opus-4-6",
|
||||
},
|
||||
cli: {
|
||||
provider: "anthropic",
|
||||
runtime: "claude-code",
|
||||
model: "opus",
|
||||
},
|
||||
},
|
||||
@@ -87,7 +91,7 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "claude-sonnet-5-5",
|
||||
},
|
||||
cli: {
|
||||
provider: "anthropic",
|
||||
runtime: "claude-code",
|
||||
model: "claude-sonnet-5-5",
|
||||
},
|
||||
},
|
||||
@@ -100,7 +104,7 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "claude-opus-5-5",
|
||||
},
|
||||
cli: {
|
||||
provider: "anthropic",
|
||||
runtime: "claude-code",
|
||||
model: "claude-opus-5-5",
|
||||
},
|
||||
},
|
||||
@@ -139,6 +143,10 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
provider: "openai",
|
||||
model: "gpt-6-astra",
|
||||
},
|
||||
cli: {
|
||||
runtime: "codex",
|
||||
model: "gpt-6-astra",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-6-sol",
|
||||
@@ -149,6 +157,19 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "gpt-6-sol",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-6.1-sol",
|
||||
label: "GPT-6.1 Sol",
|
||||
aliases: ["gpt-6.1-sol"],
|
||||
frontend: {
|
||||
provider: "openai",
|
||||
model: "gpt-6.1-sol",
|
||||
},
|
||||
cli: {
|
||||
runtime: "codex",
|
||||
model: "gpt-6.1-sol",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-6-luna",
|
||||
label: "GPT-6 Luna",
|
||||
@@ -215,6 +236,14 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
},
|
||||
cli: {
|
||||
runtime: "claude-code",
|
||||
model: "deepseek-v4-pro",
|
||||
anthropicEndpoint: {
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
apiKeyEnv: "DEEPSEEK_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -253,11 +282,20 @@ export function formatRunModelLabel(
|
||||
model: EvalModelSpec,
|
||||
): string {
|
||||
if (mode === "cli") {
|
||||
return `${model.cli!.provider}:${model.cli!.model}`;
|
||||
return formatCliRunModelLabel(model.cli!);
|
||||
}
|
||||
return `${model.frontend!.provider}:${model.frontend!.model}`;
|
||||
}
|
||||
|
||||
// Claude on Claude Code keeps the `anthropic:` prefix its dashboard history was
|
||||
// recorded under; any other pairing names the agent.
|
||||
export function formatCliRunModelLabel(config: CliEvalModelConfig): string {
|
||||
if (config.runtime === "claude-code" && !config.anthropicEndpoint) {
|
||||
return `anthropic:${config.model}`;
|
||||
}
|
||||
return `${config.runtime}:${config.model}`;
|
||||
}
|
||||
|
||||
export function getFrontendEvalModel(
|
||||
model: EvalModelSpec,
|
||||
): FrontendEvalModelConfig {
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface EvalCaseRuntimeAppContextSpec {
|
||||
|
||||
export interface EvalCaseRuntimeSpec {
|
||||
maxTurns?: number;
|
||||
// Global mode: scripted answers to askUserQuestion, one per question in order; a question
|
||||
// past the last one is dismissed, so `[]` dismisses them all. Unset, the question tool
|
||||
// reports that it cannot ask.
|
||||
userAnswers?: string[];
|
||||
backendPreview?: EvalCaseRuntimeBackendPreview;
|
||||
appContext?: EvalCaseRuntimeAppContextSpec;
|
||||
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
|
||||
|
||||
+32
-9
@@ -39,17 +39,36 @@ interface CliRunActual {
|
||||
trace: CliTrace;
|
||||
}
|
||||
|
||||
const CLAUDE_PROJECT_PREAMBLE = [
|
||||
"Follow the project instructions from AGENTS.md exactly.",
|
||||
"Before creating or modifying any Windmill entity, you MUST invoke the relevant Skill tool and follow it.",
|
||||
"Use the skill guidance for file layout, implementation details, and the exact next commands to tell the user.",
|
||||
"Do not skip the Skill step.",
|
||||
// Only the skill wording differs per agent: Claude Code has a Skill tool, Codex
|
||||
// loads a skill by reading its SKILL.md.
|
||||
const SKILL_STEP: Record<CliEvalModelConfig["runtime"], string[]> = {
|
||||
"claude-code": [
|
||||
"Before creating or modifying any Windmill entity, you MUST invoke the relevant Skill tool and follow it.",
|
||||
"Use the skill guidance for file layout, implementation details, and the exact next commands to tell the user.",
|
||||
"Do not skip the Skill step.",
|
||||
],
|
||||
codex: [
|
||||
"Before creating or modifying any Windmill entity, you MUST read the relevant skill's SKILL.md under .agents/skills and follow it.",
|
||||
"Use the skill guidance for file layout, implementation details, and the exact next commands to tell the user.",
|
||||
"Do not skip the skill step.",
|
||||
],
|
||||
};
|
||||
|
||||
const HARNESS_PREAMBLE = [
|
||||
"You are running inside an automated benchmark harness, not an interactive user session.",
|
||||
"Act autonomously and complete the requested file changes directly in the workspace.",
|
||||
"Do not ask for confirmation, do not ask the user to save or create files manually, and do not wait for approval.",
|
||||
"Do not respond with a plan when you can make the change directly.",
|
||||
"Only describe what was done after you have written the files.",
|
||||
].join(" ");
|
||||
];
|
||||
|
||||
function projectPreamble(runtime: CliEvalModelConfig["runtime"]): string {
|
||||
return [
|
||||
"Follow the project instructions from AGENTS.md exactly.",
|
||||
...SKILL_STEP[runtime],
|
||||
...HARNESS_PREAMBLE,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function createCliModeRunner(
|
||||
modelConfig: CliEvalModelConfig = DEFAULT_CLI_EVAL_MODEL
|
||||
@@ -89,7 +108,7 @@ export function createCliModeRunner(
|
||||
});
|
||||
await writeFile(join(workspaceDir, "rt.d.ts"), "export namespace RT {}\n", "utf8");
|
||||
|
||||
const renderedPrompt = await renderPrompt(prompt, workspaceDir);
|
||||
const renderedPrompt = await renderPrompt(prompt, workspaceDir, modelConfig.runtime);
|
||||
const run = await runPromptAndCapture(
|
||||
renderedPrompt,
|
||||
workspaceDir,
|
||||
@@ -193,7 +212,11 @@ export function getCliRunModelLabel(
|
||||
return formatCliRunModelLabel(modelConfig);
|
||||
}
|
||||
|
||||
async function renderPrompt(prompt: string, workspaceDir: string): Promise<string> {
|
||||
async function renderPrompt(
|
||||
prompt: string,
|
||||
workspaceDir: string,
|
||||
runtime: CliEvalModelConfig["runtime"]
|
||||
): Promise<string> {
|
||||
const renderedUserPrompt = prompt.replaceAll("{{workspace_root}}", workspaceDir);
|
||||
const agentsInstructions = await readFile(path.join(workspaceDir, "AGENTS.md"), "utf8");
|
||||
|
||||
@@ -202,7 +225,7 @@ async function renderPrompt(prompt: string, workspaceDir: string): Promise<strin
|
||||
agentsInstructions.trim(),
|
||||
"",
|
||||
"# Benchmark Harness",
|
||||
CLAUDE_PROJECT_PREAMBLE,
|
||||
projectPreamble(runtime),
|
||||
"",
|
||||
"# User Request",
|
||||
renderedUserPrompt,
|
||||
|
||||
@@ -54,6 +54,7 @@ export function createGlobalModeRunner(
|
||||
sessionChat: context.evalCase?.runtime?.sessionChat,
|
||||
planMode: context.evalCase?.runtime?.planMode,
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
userAnswers: context.evalCase?.runtime?.userAnswers,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
backend: backendSettings,
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nINSERT INTO concurrency_counter(concurrency_id, job_uuids)\nVALUES ($1, '{}'::jsonb)\nON CONFLICT (concurrency_id)\nDO UPDATE SET job_uuids = concurrency_counter.job_uuids\nRETURNING (SELECT COUNT(*)\n FROM jsonb_object_keys(job_uuids) AS keys(key)\n WHERE key <> $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "03cc00b0b5b8b1ffcc14ac5f6cb3062a8f706e70bc9c4b3764cd1d6f99b9440a"
|
||||
}
|
||||
Generated
+141
-139
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
c7e5143e8d52243bcfc88b3f5ac15eec278e0fc0
|
||||
a3049dc8821a12937f74a36be6bbd9bc0308a0a4
|
||||
@@ -1040,11 +1040,13 @@ pub fn parse_duckdb_typ(typ: &str) -> Typ {
|
||||
|
||||
pub fn parse_snowflake_typ(typ: &str) -> Typ {
|
||||
match typ {
|
||||
"varchar" => Typ::Str(None),
|
||||
"binary" => Typ::Bytes,
|
||||
"date" | "time" | "timestamp" => Typ::Datetime,
|
||||
"int" => Typ::Int,
|
||||
"float" => Typ::Float,
|
||||
"varchar" | "text" | "string" | "char" | "character" => Typ::Str(None),
|
||||
"binary" | "varbinary" => Typ::Bytes,
|
||||
"date" | "time" | "datetime" | "timestamp" | "timestamp_ntz" | "timestamp_ltz"
|
||||
| "timestamp_tz" => Typ::Datetime,
|
||||
"int" | "integer" | "bigint" | "smallint" | "tinyint" | "byteint" => Typ::Int,
|
||||
"float" | "float4" | "float8" | "double" | "double precision" | "real" | "number"
|
||||
| "decimal" | "numeric" => Typ::Float,
|
||||
"boolean" => Typ::Bool,
|
||||
"s3object" => Typ::Resource("S3Object".to_string()),
|
||||
_ => Typ::Str(None),
|
||||
|
||||
+24
-24
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6302,7 +6302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6314,7 +6314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6323,7 +6323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6335,7 +6335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6347,7 +6347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6359,7 +6359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6371,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6383,7 +6383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6394,7 +6394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6405,7 +6405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6417,7 +6417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6428,7 +6428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6450,7 +6450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6462,7 +6462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6518,7 +6518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6552,7 +6552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6568,7 +6568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6600,7 +6600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6614,7 +6614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.825.0"
|
||||
version = "1.826.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+13
-1
@@ -40,7 +40,7 @@ use windmill_common::ee_oss::{jobs_waiting_alerts, worker_groups_alerts};
|
||||
use windmill_common::global_settings::OAUTH_SETTING;
|
||||
use windmill_common::otel_oss::{
|
||||
otel_incr_zombie_delete_count, otel_incr_zombie_restart_count, otel_set_db_pool,
|
||||
otel_set_queue_count, otel_set_queue_running_count,
|
||||
otel_set_queue_count, otel_set_queue_delay, otel_set_queue_running_count,
|
||||
};
|
||||
use windmill_common::{
|
||||
agent_workers::DECODED_AGENT_TOKEN,
|
||||
@@ -168,6 +168,12 @@ lazy_static::lazy_static! {
|
||||
&["tag"]
|
||||
).unwrap();
|
||||
|
||||
static ref QUEUE_DELAY_SECONDS: prometheus::GaugeVec = prometheus::register_gauge_vec!(
|
||||
"queue_delay_seconds",
|
||||
"How long the next job to be picked up has been waiting, in seconds",
|
||||
&["tag"]
|
||||
).unwrap();
|
||||
|
||||
static ref QUEUE_RUNNING_COUNT: prometheus::IntGaugeVec = prometheus::register_int_gauge_vec!(
|
||||
"queue_running_count",
|
||||
"Number of running jobs in the queue",
|
||||
@@ -5597,6 +5603,7 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
for q in QUEUE_COUNT_TAGS.read().await.iter() {
|
||||
if queue_stats.get(q).is_none() {
|
||||
(*QUEUE_COUNT).with_label_values(&[q]).set(0);
|
||||
(*QUEUE_DELAY_SECONDS).with_label_values(&[q]).set(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5605,6 +5612,7 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() {
|
||||
if queue_stats.get(q).is_none() {
|
||||
otel_set_queue_count(q, 0);
|
||||
otel_set_queue_delay(q, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5620,6 +5628,9 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
if metrics_enabled {
|
||||
let metric = (*QUEUE_COUNT).with_label_values(&[tag]);
|
||||
metric.set(count as i64);
|
||||
(*QUEUE_DELAY_SECONDS)
|
||||
.with_label_values(&[tag])
|
||||
.set(stat.delay);
|
||||
tags_to_watch.push(tag.to_string());
|
||||
}
|
||||
|
||||
@@ -5627,6 +5638,7 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
otel_tags_to_watch.push(tag.to_string());
|
||||
}
|
||||
otel_set_queue_count(tag, count as i64);
|
||||
otel_set_queue_delay(tag, stat.delay);
|
||||
}
|
||||
|
||||
if save_metrics {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! AI decision steps run end to end against a stand-in for System One endpoints: TypeSafe's, and
|
||||
//! Cloudflare Workers AI's, which serves each model at its own URL inside an API envelope.
|
||||
//! AI decision steps run end to end against a stand-in for the decision endpoints: TypeSafe's
|
||||
//! System One, Cloudflare Workers AI's, which serves each model at its own URL inside an API
|
||||
//! envelope, and OpenAI's Decisions API.
|
||||
|
||||
use axum::{routing::post, Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
@@ -37,6 +38,19 @@ async fn start_decision_stub() -> anyhow::Result<u16> {
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
Json(json!({"result": refund_answers(&body), "success": true, "errors": []}))
|
||||
}),
|
||||
)
|
||||
// OpenAI's Decisions API takes the questions as a list and answers with one.
|
||||
.route(
|
||||
"/openai/v1/decisions",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
let answers = body["questions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|q| json!({"type": "choice", "name": q["name"], "choice": "refund"}))
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"answers": answers}))
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -72,6 +86,11 @@ async fn test_ai_decision_answers_route_a_following_branch(
|
||||
},
|
||||
"model": "clef-flash"
|
||||
}),
|
||||
json!({
|
||||
"kind": "openai",
|
||||
"resource": {"api_key": "key", "base_url": format!("http://127.0.0.1:{stub_port}/openai/v1")},
|
||||
"model": "gpt-6-luna"
|
||||
}),
|
||||
] {
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [
|
||||
|
||||
@@ -168,6 +168,7 @@ async fn test_all_metrics_e2e() {
|
||||
// ── Gauges ──────────────────────────────────────────────────────
|
||||
|
||||
otel_set_queue_count("python3", 42);
|
||||
otel_set_queue_delay("python3", 12.5);
|
||||
otel_set_queue_running_count("deno", 5);
|
||||
otel_set_worker_busy("worker-test-1", 1);
|
||||
otel_set_db_pool(5, 10, 20);
|
||||
@@ -188,7 +189,7 @@ async fn test_all_metrics_e2e() {
|
||||
let metrics = flush_and_get_metrics(&state);
|
||||
let names = metric_names(&metrics);
|
||||
|
||||
// ── Verify all 20 metric names are present ──────────────────────
|
||||
// ── Verify all 21 metric names are present ──────────────────────
|
||||
|
||||
let expected = [
|
||||
"windmill.queue.push_count",
|
||||
@@ -197,6 +198,7 @@ async fn test_all_metrics_e2e() {
|
||||
"windmill.queue.zombie_restart_count",
|
||||
"windmill.queue.zombie_delete_count",
|
||||
"windmill.queue.count",
|
||||
"windmill.queue.delay",
|
||||
"windmill.queue.running_count",
|
||||
"windmill.worker.execution_count",
|
||||
"windmill.worker.execution_duration",
|
||||
@@ -261,6 +263,9 @@ async fn test_all_metrics_e2e() {
|
||||
.expect("queue.count data point with tag=python3 not found");
|
||||
assert_eq!(dp.1, 42);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.queue.delay").unwrap();
|
||||
assert!((gauge_f64_value(m).unwrap() - 12.5).abs() < f64::EPSILON);
|
||||
|
||||
let m = find_metric(&metrics, "windmill.queue.running_count").unwrap();
|
||||
let values = gauge_i64_values(m);
|
||||
let dp = values
|
||||
|
||||
@@ -4698,6 +4698,129 @@ async fn test_run_wait_result_early_return_with_failure_module(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `recover: true` from the error handler of a step that fails inside a loop turns the flow
|
||||
/// green, as it does for a top-level step, without changing which steps run: a loop that
|
||||
/// stops at a failed iteration still stops there, one that skips failures still carries on.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_failure_module_recover_inside_loop(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let failing_loop = |skip_failures: bool| {
|
||||
json!({
|
||||
"id": "loop",
|
||||
"value": {
|
||||
"type": "forloopflow",
|
||||
"iterator": { "type": "static", "value": [1, 2, 3] },
|
||||
"skip_failures": skip_failures,
|
||||
"modules": [{
|
||||
"id": "a",
|
||||
"value": {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { throw new Error('boom'); }",
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
};
|
||||
let run = |loop_step: serde_json::Value| {
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [loop_step, {
|
||||
"id": "b",
|
||||
"value": {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return { ran_b: true } }",
|
||||
},
|
||||
}],
|
||||
"failure_module": {
|
||||
"value": {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return { handled: true, recover: true } }",
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
.run_until_complete(&db, false, port)
|
||||
};
|
||||
let handler_runs = || {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT count(*) FROM v2_job_completed c JOIN v2_job j USING (id)
|
||||
WHERE j.kind = 'preview' AND c.result @> '{\"handled\": true}'::jsonb",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
};
|
||||
|
||||
let stopped = run(failing_loop(false)).await;
|
||||
let stopped_handler_runs = handler_runs().await?;
|
||||
let skipping = run(failing_loop(true)).await;
|
||||
let skipping_handler_runs = handler_runs().await? - stopped_handler_runs;
|
||||
// A parallel node counts its iterations' stored status: an iteration that ended on a
|
||||
// recovered failure must still be stored as failed there.
|
||||
let parallel = run(json!({
|
||||
"id": "par",
|
||||
"value": {
|
||||
"type": "forloopflow",
|
||||
"iterator": { "type": "static", "value": [1, 2] },
|
||||
"parallel": true,
|
||||
"skip_failures": false,
|
||||
"modules": [failing_loop(false)],
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
// A recovery the inner loop absorbs, then an error raised starting the next step: that
|
||||
// error is a new failure and must leave the flow failed.
|
||||
#[cfg(feature = "quickjs")]
|
||||
let chaining_error = run(json!({
|
||||
"id": "outer",
|
||||
"value": {
|
||||
"type": "forloopflow",
|
||||
"iterator": { "type": "static", "value": [1] },
|
||||
"skip_failures": false,
|
||||
"modules": [failing_loop(true), {
|
||||
"id": "guarded",
|
||||
"skip_if": { "expr": "missingFunction()" },
|
||||
"value": { "input_transforms": {}, "type": "identity" },
|
||||
}],
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
|
||||
server.close().await.unwrap();
|
||||
|
||||
#[cfg(feature = "quickjs")]
|
||||
assert!(!chaining_error.success, "an error no handler recovered must keep the flow failed");
|
||||
|
||||
assert!(stopped.success, "a recovered failure inside a loop should end the flow as a success");
|
||||
assert!(
|
||||
stopped.json_result().unwrap().get("ran_b").is_none(),
|
||||
"the step after the loop must not run, as without recovery"
|
||||
);
|
||||
assert_eq!(stopped_handler_runs, 1, "the loop must stop at the first failed iteration");
|
||||
|
||||
assert!(skipping.success);
|
||||
assert_eq!(
|
||||
skipping.json_result().unwrap(),
|
||||
json!({ "ran_b": true }),
|
||||
"a loop that skips failures runs every iteration and the following step"
|
||||
);
|
||||
assert_eq!(skipping_handler_runs, 3);
|
||||
|
||||
assert!(
|
||||
!parallel.json_result().unwrap().to_string().contains("ran_b"),
|
||||
"the parallel loop fails as without recovery, so the step after it must not run"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_flow_lock_all(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -124,7 +124,7 @@ pub enum AIProvider {
|
||||
#[serde(rename = "aws_bedrock")]
|
||||
AWSBedrock,
|
||||
/// TypeSafe's Jev decision model. It answers typed questions rather than chatting, so only
|
||||
/// an AI decision runs it (`run_systemone`), never the agent loop.
|
||||
/// an AI decision runs it (`run_decision`), never the agent loop.
|
||||
TypeSafe,
|
||||
/// Cloudflare Workers AI, for its Jev-compatible decision models (Clef). Decisions only, as
|
||||
/// for TypeSafe; its base URL comes from the resource's account id.
|
||||
@@ -152,6 +152,12 @@ impl AIProvider {
|
||||
matches!(self, AIProvider::TypeSafe | AIProvider::Cloudflare)
|
||||
}
|
||||
|
||||
/// A provider an AI decision runs on: the decision providers, and OpenAI, whose Decisions
|
||||
/// API sits next to its chat routes on the same resource.
|
||||
pub fn runs_decisions(&self) -> bool {
|
||||
self.is_decision_provider() || matches!(self, AIProvider::OpenAI)
|
||||
}
|
||||
|
||||
/// Get the base URL for the AI provider
|
||||
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
|
||||
if let Some(base_url) = resource_base_url {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! AI decision steps: one call to a System One endpoint (TypeSafe's Jev, or Cloudflare's
|
||||
//! Jev-compatible Clef on Workers AI), which answers typed questions (`choice`, `score`, `noul`)
|
||||
//! about a state with calibrated probabilities instead of text.
|
||||
//! AI decision steps: one call to a decision endpoint, which answers typed questions (`choice`,
|
||||
//! `score`, `noul`) about a state with calibrated probabilities instead of text. The questions and
|
||||
//! answers are System One's (TypeSafe's Jev, Cloudflare's Jev-compatible Clef on Workers AI);
|
||||
//! OpenAI's Decisions API is asked and read through a translation to that shape.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -30,6 +31,8 @@ pub struct AIDecisionArgs {
|
||||
pub const TYPESAFE_DEFAULT_MODEL: &str = "jev-latest";
|
||||
/// Cloudflare's larger decision model, run when the step names none.
|
||||
pub const CLOUDFLARE_DEFAULT_MODEL: &str = "clef";
|
||||
/// The model OpenAI's Decisions API serves, run when the step names none.
|
||||
pub const OPENAI_DEFAULT_MODEL: &str = "gpt-6-luna";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SystemOneRequest<'a> {
|
||||
@@ -108,7 +111,263 @@ pub fn decision_inputs<'a>(
|
||||
Ok((state, questions))
|
||||
}
|
||||
|
||||
pub async fn run_systemone(
|
||||
/// Posts a decision request with the resource's bearer key and returns the response body.
|
||||
async fn post_decision<T: Serialize>(
|
||||
credentials: &ProviderCredentials,
|
||||
endpoint: &str,
|
||||
vendor: &str,
|
||||
payload: &T,
|
||||
timeout: Duration,
|
||||
) -> Result<String> {
|
||||
let auth_headers = retain_effective_credentials(
|
||||
credentials,
|
||||
vec![(
|
||||
"Authorization",
|
||||
format!(
|
||||
"Bearer {}",
|
||||
credentials.api_key.as_deref().unwrap_or_default()
|
||||
),
|
||||
)],
|
||||
);
|
||||
|
||||
// The endpoint derives from the resource's base URL, so the connect is pinned to the
|
||||
// address the SSRF check validated.
|
||||
let client = pinned_ai_client_for(&credentials.base_url).await?;
|
||||
let mut request = client.post(endpoint).timeout(timeout);
|
||||
for (name, value) in auth_headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
for (name, value) in common_outbound_headers(credentials) {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to call {vendor}: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to read body>".to_string());
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"{vendor} error calling {endpoint}: {status} - {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to read the {vendor} response: {e}")))
|
||||
}
|
||||
|
||||
/// Answers a decision on the endpoint its provider serves.
|
||||
pub async fn run_decision(
|
||||
credentials: &ProviderCredentials,
|
||||
model: &str,
|
||||
state: &Value,
|
||||
questions: &Map<String, Value>,
|
||||
timeout: Duration,
|
||||
) -> Result<DecisionResult> {
|
||||
if credentials.provider == AIProvider::OpenAI {
|
||||
run_openai_decisions(credentials, model, state, questions, timeout).await
|
||||
} else {
|
||||
run_systemone(credentials, model, state, questions, timeout).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAIDecisionsRequest<'a> {
|
||||
model: &'a str,
|
||||
input: Value,
|
||||
questions: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAIDecisionsResponse {
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
answers: Vec<Map<String, Value>>,
|
||||
#[serde(default)]
|
||||
usage: Option<SystemOneUsage>,
|
||||
}
|
||||
|
||||
/// The state as a Decisions `input`. OpenAI reads a text or a list of user messages, so a list of
|
||||
/// user messages (the only way to pass an image) goes as it is and any other object or array,
|
||||
/// a chat history included, as its JSON text.
|
||||
fn openai_input(state: &Value) -> Value {
|
||||
match state {
|
||||
Value::String(_) => state.clone(),
|
||||
Value::Array(items) if !items.is_empty() && items.iter().all(is_openai_user_message) => {
|
||||
state.clone()
|
||||
}
|
||||
_ => Value::String(state.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly a message the Decisions API reads: `role: "user"` with a text, or with parts that are
|
||||
/// each an `input_text` or an `input_image`.
|
||||
fn is_openai_user_message(message: &Value) -> bool {
|
||||
let is_part = |part: &Value| match part.get("type").and_then(Value::as_str) {
|
||||
Some("input_text") => part.get("text").is_some_and(Value::is_string),
|
||||
Some("input_image") => part.get("image_url").is_some_and(Value::is_string),
|
||||
_ => false,
|
||||
};
|
||||
message.get("role").and_then(Value::as_str) == Some("user")
|
||||
&& match message.get("content") {
|
||||
Some(Value::String(_)) => true,
|
||||
Some(Value::Array(parts)) => !parts.is_empty() && parts.iter().all(is_part),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The questions as OpenAI's Decisions API takes them: a list naming each question, with a
|
||||
/// `noul` asked as its `predicate` and each score level as a label.
|
||||
fn openai_questions(questions: &Map<String, Value>) -> Result<Vec<Value>> {
|
||||
let text = |v: &Value| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
v => v.to_string(),
|
||||
};
|
||||
// A structured description goes as its JSON text, as structured instructions do.
|
||||
let stated = |description: Option<&Value>| match description {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(d)) if d.trim().is_empty() => None,
|
||||
Some(d) => Some(text(d)),
|
||||
};
|
||||
questions
|
||||
.iter()
|
||||
.map(|(name, question)| {
|
||||
let criteria = question.get("criteria");
|
||||
let mut instructions = question.get("instructions").map(text).unwrap_or_default();
|
||||
let mut asked = Map::new();
|
||||
let kind = match question.get("type").and_then(Value::as_str) {
|
||||
Some("choice") => {
|
||||
let choices = criteria
|
||||
.and_then(Value::as_object)
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"The choice question '{name}' needs its options as 'criteria'"
|
||||
))
|
||||
})?
|
||||
.iter()
|
||||
.map(|(value, d)| match stated(Some(d)) {
|
||||
Some(d) => serde_json::json!({"value": value, "description": d}),
|
||||
None => serde_json::json!({"value": value}),
|
||||
})
|
||||
.collect();
|
||||
asked.insert("choices".to_string(), Value::Array(choices));
|
||||
"choice"
|
||||
}
|
||||
Some("score") => {
|
||||
let levels = criteria
|
||||
.and_then(Value::as_array)
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"The score question '{name}' needs its levels as 'criteria'"
|
||||
))
|
||||
})?
|
||||
.iter()
|
||||
.map(|level| serde_json::json!({"label": text(level)}))
|
||||
.collect();
|
||||
asked.insert("levels".to_string(), Value::Array(levels));
|
||||
"score"
|
||||
}
|
||||
Some("noul") => {
|
||||
for (key, meaning) in [("true", "Yes"), ("false", "No")] {
|
||||
if let Some(d) = stated(criteria.and_then(|c| c.get(key))) {
|
||||
instructions.push_str(&format!("\n{meaning}: {d}"));
|
||||
}
|
||||
}
|
||||
"predicate"
|
||||
}
|
||||
other => {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"The question '{name}' has type {}, which is not 'choice', 'score' or 'noul'",
|
||||
other.map_or("none".to_string(), |t| format!("'{t}'"))
|
||||
)))
|
||||
}
|
||||
};
|
||||
asked.insert("type".to_string(), kind.into());
|
||||
asked.insert("name".to_string(), name.as_str().into());
|
||||
asked.insert("instructions".to_string(), instructions.into());
|
||||
Ok(Value::Object(asked))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// OpenAI's answers keyed by question name, as every decision's are. A predicate's probability is
|
||||
/// also given as `noul`, so a flow reads a yes/no answer the same way whichever provider gave it.
|
||||
/// A refused question fails the decision: left in the output it has no `choice` or `noul`, so a
|
||||
/// branch on it would quietly take its "no" or default arm.
|
||||
fn openai_answers(answers: Vec<Map<String, Value>>) -> Result<Map<String, Value>> {
|
||||
let mut by_name = Map::new();
|
||||
for mut answer in answers {
|
||||
let Some(Value::String(name)) = answer.remove("name") else {
|
||||
continue;
|
||||
};
|
||||
match answer.get("type").and_then(Value::as_str) {
|
||||
Some("refusal") => {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"OpenAI refused to answer the question '{name}': {}",
|
||||
Value::Object(answer)
|
||||
)))
|
||||
}
|
||||
Some("predicate") => {
|
||||
if let Some(probability) = answer.get("probability").cloned() {
|
||||
answer.insert("noul".to_string(), probability);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
by_name.insert(name, Value::Object(answer));
|
||||
}
|
||||
Ok(by_name)
|
||||
}
|
||||
|
||||
async fn run_openai_decisions(
|
||||
credentials: &ProviderCredentials,
|
||||
model: &str,
|
||||
state: &Value,
|
||||
questions: &Map<String, Value>,
|
||||
timeout: Duration,
|
||||
) -> Result<DecisionResult> {
|
||||
let model = match model.trim() {
|
||||
"" => OPENAI_DEFAULT_MODEL,
|
||||
model => model,
|
||||
};
|
||||
let endpoint = format!("{}/decisions", credentials.base_url.trim_end_matches('/'));
|
||||
let body = post_decision(
|
||||
credentials,
|
||||
&endpoint,
|
||||
"OpenAI",
|
||||
&OpenAIDecisionsRequest {
|
||||
model,
|
||||
input: openai_input(state),
|
||||
questions: openai_questions(questions)?,
|
||||
},
|
||||
timeout,
|
||||
)
|
||||
.await?;
|
||||
let parsed = serde_json::from_str::<OpenAIDecisionsResponse>(&body)
|
||||
.map_err(|e| Error::ExecutionErr(format!("Unexpected OpenAI response ({e}): {body}")))?;
|
||||
|
||||
Ok(DecisionResult {
|
||||
output: serde_json::value::to_raw_value(&openai_answers(parsed.answers)?)
|
||||
.map_err(|e| Error::internal_err(format!("serializing decision answers: {e}")))?,
|
||||
model: parsed.model,
|
||||
usage: parsed
|
||||
.usage
|
||||
.map(|u| TokenUsage::from_input_output(u.input_tokens, u.output_tokens))
|
||||
.filter(|u| !u.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_systemone(
|
||||
credentials: &ProviderCredentials,
|
||||
model: &str,
|
||||
state: &Value,
|
||||
@@ -138,49 +397,14 @@ pub async fn run_systemone(
|
||||
format!("{base}/systemone")
|
||||
};
|
||||
let vendor = if cloudflare { "Cloudflare" } else { "TypeSafe" };
|
||||
let auth_headers = retain_effective_credentials(
|
||||
let body = post_decision(
|
||||
credentials,
|
||||
vec![(
|
||||
"Authorization",
|
||||
format!(
|
||||
"Bearer {}",
|
||||
credentials.api_key.as_deref().unwrap_or_default()
|
||||
),
|
||||
)],
|
||||
);
|
||||
|
||||
// The endpoint derives from the resource's base URL, so the connect is pinned to the
|
||||
// address the SSRF check validated.
|
||||
let client = pinned_ai_client_for(&credentials.base_url).await?;
|
||||
let mut request = client.post(&endpoint).timeout(timeout);
|
||||
for (name, value) in auth_headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
for (name, value) in common_outbound_headers(credentials) {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.json(&SystemOneRequest { model, state, questions })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to call {vendor}: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to read body>".to_string());
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"{vendor} error calling {endpoint}: {status} - {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to read the {vendor} response: {e}")))?;
|
||||
&endpoint,
|
||||
vendor,
|
||||
&SystemOneRequest { model, state, questions },
|
||||
timeout,
|
||||
)
|
||||
.await?;
|
||||
// Tried in turn rather than as an untagged enum, which cannot hold the raw `answers`.
|
||||
let parsed = match serde_json::from_str::<SystemOneResponse>(&body) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -223,6 +447,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `noul` goes out as a predicate with its yes/no meanings in the instructions, and comes
|
||||
/// back readable as `noul`; choices and levels go out as lists, answers come back by name.
|
||||
#[test]
|
||||
fn openai_questions_and_answers_translate() {
|
||||
let questions = json!({
|
||||
"intent": {"type": "choice", "instructions": "Why?", "criteria": {"refund": {"when": "paid"}, "bug": ""}},
|
||||
"urgency": {"type": "score", "instructions": "How urgent?", "criteria": ["Can wait", "Now"]},
|
||||
"angry": {"type": "noul", "instructions": "Angry?", "criteria": {"true": "Insults"}}
|
||||
});
|
||||
let asked = Value::Array(openai_questions(questions.as_object().unwrap()).unwrap());
|
||||
assert_eq!(
|
||||
asked,
|
||||
json!([
|
||||
{"choices": [
|
||||
{"value": "refund", "description": "{\"when\":\"paid\"}"},
|
||||
{"value": "bug"}
|
||||
], "type": "choice", "name": "intent", "instructions": "Why?"},
|
||||
{"levels": [
|
||||
{"label": "Can wait"},
|
||||
{"label": "Now"}
|
||||
], "type": "score", "name": "urgency", "instructions": "How urgent?"},
|
||||
{"type": "predicate", "name": "angry", "instructions": "Angry?\nYes: Insults"}
|
||||
])
|
||||
);
|
||||
for bad in [
|
||||
json!({"q": {"type": "choice", "instructions": "?"}}),
|
||||
json!({"q": {"type": "score", "instructions": "?", "criteria": []}}),
|
||||
json!({"q": {"type": "predicate", "instructions": "?"}}),
|
||||
] {
|
||||
assert!(openai_questions(bad.as_object().unwrap()).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
let answers = serde_json::from_value(json!([
|
||||
{"type": "predicate", "name": "angry", "probability": 0.92},
|
||||
{"type": "choice", "name": "intent", "choice": "refund", "confidence": 0.9}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Value::Object(openai_answers(answers).unwrap()),
|
||||
json!({
|
||||
"angry": {"type": "predicate", "probability": 0.92, "noul": 0.92},
|
||||
"intent": {"type": "choice", "choice": "refund", "confidence": 0.9}
|
||||
})
|
||||
);
|
||||
|
||||
let messages = json!([{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}]);
|
||||
assert_eq!(openai_input(&messages), messages);
|
||||
assert_eq!(openai_input(&json!({"m": "hi"})), json!("{\"m\":\"hi\"}"));
|
||||
assert_eq!(
|
||||
openai_input(&json!([{"role": "admin"}])),
|
||||
json!("[{\"role\":\"admin\"}]")
|
||||
);
|
||||
for records in [
|
||||
json!([{"role": "user", "content": {"ticket": "charged twice"}}]),
|
||||
json!([{"role": "user", "content": null}]),
|
||||
json!([{"role": "user", "content": ["a"]}]),
|
||||
json!([{"role": "user", "content": [{"type": "input_text", "text": {"t": 1}}]}]),
|
||||
json!([{"role": "user", "content": [{"type": "input_image"}]}]),
|
||||
] {
|
||||
assert_eq!(openai_input(&records), json!(records.to_string()));
|
||||
}
|
||||
assert_eq!(
|
||||
openai_input(&json!([{"role": "user", "content": "Hi"}])),
|
||||
json!([{"role": "user", "content": "Hi"}])
|
||||
);
|
||||
// A chat history is evaluated as text: OpenAI takes user messages only.
|
||||
let history =
|
||||
json!([{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Yo"}]);
|
||||
assert_eq!(openai_input(&history), json!(history.to_string()));
|
||||
let refused =
|
||||
serde_json::from_value(json!([{"type": "refusal", "name": "angry"}])).unwrap();
|
||||
assert!(openai_answers(refused).is_err());
|
||||
}
|
||||
|
||||
/// Refused are the values a flow produces when it forgot the input (no key, `null`, a blank
|
||||
/// string from an empty expression) and the scalars TypeSafe rejects. A text, an object or an
|
||||
/// array is a state, whatever it holds.
|
||||
|
||||
@@ -15,6 +15,7 @@ enterprise = ["windmill-common/enterprise"]
|
||||
[dependencies]
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-api-sse.workspace = true
|
||||
windmill-audit.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-queue.workspace = true
|
||||
axum.workspace = true
|
||||
|
||||
@@ -23,6 +23,10 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use windmill_api_auth::{check_scopes, get_scope_tags, ApiAuthed};
|
||||
use windmill_audit::{
|
||||
audit_oss::{audit_log_many, AuditAuthorable},
|
||||
ActionKind,
|
||||
};
|
||||
use windmill_common::{
|
||||
db::{UserDB, UserDbWithAuthed},
|
||||
error::{self, Error},
|
||||
@@ -196,11 +200,15 @@ pub async fn check_license_key_valid() -> error::Result<()> {
|
||||
pub async fn cancel_jobs(
|
||||
jobs: Vec<Uuid>,
|
||||
db: &DB,
|
||||
username: &str,
|
||||
author: &(impl AuditAuthorable + Sync),
|
||||
w_id: &str,
|
||||
force_cancel: bool,
|
||||
) -> error::JsonResult<Vec<Uuid>> {
|
||||
let username = author.username();
|
||||
let mut uuids = vec![];
|
||||
// A job that completed before its turn is reported back like the others but was not
|
||||
// cancelled, so it gets no audit entry.
|
||||
let mut audited = vec![];
|
||||
tracing::info!("Cancelling jobs: {:?}", jobs);
|
||||
let mut tx = db.begin().await?;
|
||||
let trivial_jobs = sqlx::query!("INSERT INTO v2_job_completed AS cj
|
||||
@@ -246,7 +254,7 @@ pub async fn cancel_jobs(
|
||||
}
|
||||
match tokio::time::timeout(tokio::time::Duration::from_secs(5), async move {
|
||||
let tx = db.begin().await?;
|
||||
let (tx, _) = cancel_job(
|
||||
let (tx, cancelled) = cancel_job(
|
||||
username,
|
||||
None,
|
||||
job_id.clone(),
|
||||
@@ -258,13 +266,14 @@ pub async fn cancel_jobs(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
Ok::<_, anyhow::Error>(cancelled)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => match result {
|
||||
Ok(_) => {
|
||||
Ok(cancelled) => {
|
||||
uuids.push(job_id);
|
||||
audited.extend(cancelled);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to cancel job {:?}: {:?}", job_id, e);
|
||||
@@ -279,8 +288,27 @@ pub async fn cancel_jobs(
|
||||
}
|
||||
}
|
||||
|
||||
audited.extend(trivial_jobs.iter().copied());
|
||||
uuids.extend(trivial_jobs);
|
||||
|
||||
// The cancels are committed by now: failing here would report them as not done.
|
||||
if let Err(e) = audit_log_many(
|
||||
db,
|
||||
author,
|
||||
if force_cancel {
|
||||
"jobs.force_cancel"
|
||||
} else {
|
||||
"jobs.cancel"
|
||||
},
|
||||
ActionKind::Delete,
|
||||
w_id,
|
||||
&audited.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to write audit entries for cancelled jobs in {w_id}: {e:#}");
|
||||
}
|
||||
|
||||
Ok(Json(uuids))
|
||||
}
|
||||
|
||||
|
||||
@@ -1487,11 +1487,12 @@ async fn set_default_error_handler(
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(value_content) = value {
|
||||
windmill_api_settings::set_global_setting_internal(&db, key, value_content).await?;
|
||||
if let Some(value_content) = value.clone() {
|
||||
windmill_api_settings::set_global_setting_internal(&db, key.clone(), value_content).await?;
|
||||
} else {
|
||||
windmill_api_settings::delete_global_setting(&db, key.as_str()).await?;
|
||||
}
|
||||
windmill_api_settings::audit_setting_write(&db, &authed, &key, value.as_ref()).await?;
|
||||
|
||||
if payload.override_existing {
|
||||
// The rewrite and its history rows go in one transaction: on separate
|
||||
|
||||
@@ -29,6 +29,7 @@ mod log_cleanup;
|
||||
mod storage_usage;
|
||||
|
||||
use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed};
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
|
||||
use windmill_common::DB;
|
||||
|
||||
@@ -829,7 +830,71 @@ pub async fn set_global_setting(
|
||||
Json(value): Json<Value>,
|
||||
) -> error::Result<()> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
set_global_setting_internal(&db, key, value.value.unwrap_or(serde_json::Value::Null)).await
|
||||
let value = value.value.unwrap_or(serde_json::Value::Null);
|
||||
set_global_setting_internal(&db, key.clone(), value.clone()).await?;
|
||||
audit_setting_write(&db, &authed, &key, Some(&value)).await
|
||||
}
|
||||
|
||||
/// A setting is recorded by name, with its value only when that is a boolean or a number.
|
||||
/// Any other shape can carry a credential (a registry URL, a webhook, a header map), and no
|
||||
/// list of secret-bearing settings stays complete as settings are added.
|
||||
///
|
||||
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
|
||||
/// `authed` may change instance settings before performing the write this records.
|
||||
pub async fn audit_setting_write(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
key: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> error::Result<()> {
|
||||
let deleted = match value {
|
||||
None | Some(serde_json::Value::Null) => true,
|
||||
Some(serde_json::Value::String(s)) => s.trim().is_empty(),
|
||||
Some(_) => false,
|
||||
};
|
||||
let recorded = match value {
|
||||
Some(v @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_))) => {
|
||||
Some(v.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
audit_log(
|
||||
db,
|
||||
authed,
|
||||
if deleted {
|
||||
"global_settings.delete"
|
||||
} else {
|
||||
"global_settings.update"
|
||||
},
|
||||
if deleted {
|
||||
ActionKind::Delete
|
||||
} else {
|
||||
ActionKind::Update
|
||||
},
|
||||
"global",
|
||||
Some(key),
|
||||
recorded.as_deref().map(|v| [("value", v)].into()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn audit_instance_action(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
operation: &str,
|
||||
action_kind: ActionKind,
|
||||
parameters: Option<std::collections::HashMap<&str, &str>>,
|
||||
) -> error::Result<()> {
|
||||
audit_log(
|
||||
db,
|
||||
authed,
|
||||
operation,
|
||||
action_kind,
|
||||
"global",
|
||||
Some(&authed.email),
|
||||
parameters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_global_setting_internal(
|
||||
@@ -1305,15 +1370,61 @@ async fn set_instance_config(
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
}
|
||||
}
|
||||
// The external-instance write removes its own key from the diff.
|
||||
let external = settings_diff
|
||||
.upserts
|
||||
.get(EXTERNAL_INSTANCE_PG_SETTING)
|
||||
.cloned()
|
||||
.map(Some)
|
||||
.or_else(|| {
|
||||
settings_diff
|
||||
.deletes
|
||||
.iter()
|
||||
.any(|k| k == EXTERNAL_INSTANCE_PG_SETTING)
|
||||
.then_some(None)
|
||||
});
|
||||
windmill_common::external_instance_pg::write_external_instance_pg_from_diff(
|
||||
&db,
|
||||
&mut settings_diff,
|
||||
)
|
||||
.await?;
|
||||
if let Some(value) = external {
|
||||
audit_setting_write(&db, &authed, EXTERNAL_INSTANCE_PG_SETTING, value.as_ref()).await?;
|
||||
}
|
||||
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
// Applied and audited one setting at a time: the writes are not transactional, so a
|
||||
// batch that fails midway must not leave the settings it did write unrecorded.
|
||||
let instance_config::SettingsDiff { upserts, deletes, mut previous_values, unchanged_count } =
|
||||
settings_diff;
|
||||
let steps = upserts
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Some(v)))
|
||||
.chain(deletes.into_iter().map(|k| (k, None)))
|
||||
.map(Some)
|
||||
.chain(std::iter::once(None));
|
||||
for step in steps {
|
||||
let mut diff = instance_config::SettingsDiff::default();
|
||||
match &step {
|
||||
Some((key, value)) => {
|
||||
if let Some(previous) = previous_values.remove(key) {
|
||||
diff.previous_values.insert(key.clone(), previous);
|
||||
}
|
||||
match value {
|
||||
Some(value) => {
|
||||
diff.upserts.insert(key.clone(), value.clone());
|
||||
}
|
||||
None => diff.deletes.push(key.clone()),
|
||||
}
|
||||
}
|
||||
None => diff.unchanged_count = unchanged_count,
|
||||
}
|
||||
instance_config::apply_settings_diff(&db, &diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
if let Some((key, value)) = &step {
|
||||
audit_setting_write(&db, &authed, key, value.as_ref()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if ai_config_changed {
|
||||
bump_instance_ai_config_revision();
|
||||
@@ -1343,9 +1454,37 @@ async fn set_instance_config(
|
||||
.collect();
|
||||
let configs_diff =
|
||||
instance_config::diff_worker_configs(¤t_wc, &desired_wc, ApplyMode::Merge);
|
||||
instance_config::apply_configs_diff(&db, &configs_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
let steps = configs_diff
|
||||
.upserts
|
||||
.into_iter()
|
||||
.map(|(g, c)| (g, Some(c)))
|
||||
.chain(configs_diff.deletes.into_iter().map(|g| (g, None)));
|
||||
for (group, config) in steps {
|
||||
let mut diff = instance_config::ConfigsDiff::default();
|
||||
let (operation, action_kind) = match config {
|
||||
Some(config) => {
|
||||
diff.upserts.insert(group.clone(), config);
|
||||
("worker_config.update", ActionKind::Update)
|
||||
}
|
||||
None => {
|
||||
diff.deletes.push(group.clone());
|
||||
("worker_config.delete", ActionKind::Delete)
|
||||
}
|
||||
};
|
||||
instance_config::apply_configs_diff(&db, &diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
operation,
|
||||
action_kind,
|
||||
"global",
|
||||
Some(&format!("worker__{group}")),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1583,6 +1722,14 @@ pub async fn renew_license_key(
|
||||
}
|
||||
)));
|
||||
} else {
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.renew_license_key",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Ok("Renewed license key".to_string());
|
||||
}
|
||||
}
|
||||
@@ -1812,6 +1959,14 @@ async fn refresh_custom_instance_user_pwd(
|
||||
require_super_admin(&db, &authed).await?;
|
||||
windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?;
|
||||
windmill_common::utils::refresh_custom_instance_replication_user_pwd(&db).await?;
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.refresh_custom_instance_user_pwd",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
@@ -1970,7 +2125,8 @@ async fn setup_custom_instance_pg_database(
|
||||
windmill_common::datatable_roles::lock_instance_databases_governance(&mut tx, [dbname.trim()])
|
||||
.await?;
|
||||
let mut logs = CustomInstanceDbLogs::default();
|
||||
let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await;
|
||||
let result =
|
||||
setup_custom_instance_pg_database_inner(authed.clone(), &db, &dbname, &mut logs).await;
|
||||
let success = result.is_ok();
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
let status = CustomInstanceDb {
|
||||
@@ -1998,6 +2154,20 @@ async fn setup_custom_instance_pg_database(
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.setup_custom_instance_pg_database",
|
||||
ActionKind::Create,
|
||||
Some(
|
||||
[
|
||||
("dbname", dbname.as_str()),
|
||||
("success", if success { "true" } else { "false" }),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let status: CustomInstanceDb = serde_json::from_value(saved).map_err(to_anyhow)?;
|
||||
|
||||
Ok(Json(status))
|
||||
@@ -2176,6 +2346,14 @@ async fn drop_custom_instance_pg_database(
|
||||
require_super_admin(&db, &authed).await?;
|
||||
|
||||
windmill_common::drop_custom_instance_database(&db, &dbname).await?;
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.drop_custom_instance_pg_database",
|
||||
ActionKind::Delete,
|
||||
Some([("dbname", dbname.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Database '{}' dropped successfully", dbname))
|
||||
}
|
||||
@@ -2220,6 +2398,14 @@ pub async fn migrate_secrets_to_vault(
|
||||
|
||||
let report = windmill_common::secret_backend::migrate_secrets_to_vault(&db, &settings).await?;
|
||||
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_to_vault",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
@@ -2241,6 +2427,14 @@ pub async fn migrate_secrets_to_database(
|
||||
let report =
|
||||
windmill_common::secret_backend::migrate_secrets_to_database(&db, &settings).await?;
|
||||
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_to_database",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
@@ -2274,6 +2468,14 @@ pub async fn migrate_secrets_to_azure_kv(
|
||||
let report =
|
||||
windmill_common::secret_backend::migrate_secrets_to_azure_kv(&db, &settings).await?;
|
||||
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_to_azure_kv",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
@@ -2291,6 +2493,14 @@ pub async fn migrate_secrets_from_azure_kv(
|
||||
let report =
|
||||
windmill_common::secret_backend::migrate_secrets_from_azure_kv(&db, &settings).await?;
|
||||
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_from_azure_kv",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
@@ -2315,6 +2525,14 @@ pub async fn migrate_secrets_to_aws_sm(
|
||||
) -> JsonResult<SecretMigrationReport> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let report = windmill_common::secret_backend::migrate_secrets_to_aws_sm(&db, &settings).await?;
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_to_aws_sm",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
@@ -2328,6 +2546,14 @@ pub async fn migrate_secrets_from_aws_sm(
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let report =
|
||||
windmill_common::secret_backend::migrate_secrets_from_aws_sm(&db, &settings).await?;
|
||||
audit_instance_action(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.migrate_secrets_from_aws_sm",
|
||||
ActionKind::Update,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
|
||||
@@ -10513,7 +10513,7 @@ async fn edit_workspace(
|
||||
pub(crate) async fn archive_workspace_impl(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
username: &str,
|
||||
authed: &ApiAuthed,
|
||||
// When archiving a dev workspace, its parent prod. The pairing teardown (clear is_dev + drop the
|
||||
// prod's lock) is folded into the same transaction as `deleted = true` so it's atomic with the
|
||||
// archive — a later failure can't strand a half-archived dev that's still flagged/locked.
|
||||
@@ -10627,7 +10627,7 @@ pub(crate) async fn archive_workspace_impl(
|
||||
let axum::Json(canceled_jobs) = windmill_api_jobs::cancel_jobs(
|
||||
jobs_to_cancel,
|
||||
db,
|
||||
username,
|
||||
authed,
|
||||
w_id,
|
||||
false, // force_cancel
|
||||
)
|
||||
@@ -10687,7 +10687,7 @@ async fn archive_workspace(
|
||||
// dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a
|
||||
// root archived out from under its dev is the pre-existing shape and not this pairing's to police.
|
||||
let (schedules_count, canceled_count, deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?;
|
||||
archive_workspace_impl(&db, &w_id, &authed, dev_lock_parent.as_deref()).await?;
|
||||
|
||||
// Audit log
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -944,7 +944,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Note: schedules were already moved to new workspace, so this will find 0 schedules
|
||||
info!("Archiving old workspace");
|
||||
let (_schedules_count, canceled_count, _deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &old_id, &authed.username, None).await?;
|
||||
archive_workspace_impl(&db, &old_id, &authed, None).await?;
|
||||
|
||||
// The old id stays live between the commit above and the archive, and a fork copy created for
|
||||
// it in that window registers under it. Creation checks the workspace is live under the fork
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.825.0
|
||||
version: 1.826.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
|
||||
@@ -4580,7 +4580,7 @@ async fn cancel_selection(
|
||||
let Json(mut w_cancelled) = cancel_jobs(
|
||||
ids,
|
||||
&db,
|
||||
authed.username.as_str(),
|
||||
&authed,
|
||||
workspace_id.as_str(),
|
||||
force_cancel,
|
||||
)
|
||||
|
||||
@@ -103,7 +103,9 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response<
|
||||
// The raw-app preview shell evaluates whatever js is posted to it, so it is
|
||||
// only ever served opaque-origin, with the deployed app wrapper's exact flags
|
||||
// (`get_raw_app_data`). Matched on the embedded file, not the request path,
|
||||
// so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy.
|
||||
// so `//ui_builder/...` is covered. A sandboxed app's editor preview loads it
|
||||
// as served, framed with the same flags (`RAW_APP_SANDBOX_FLAGS` in the
|
||||
// frontend); an unsandboxed one loads a same-origin blob: copy.
|
||||
if path == "ui_builder/app-preview.html" {
|
||||
res = res.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
|
||||
@@ -20,4 +20,7 @@ chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
lazy_static.workspace = true
|
||||
lazy_static.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -88,6 +88,21 @@ pub async fn audit_log<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// AUTHORIZATION: records entries, checks nothing. The caller must have authorized `author`
|
||||
/// for the operation on every resource in `w_id` before calling.
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn audit_log_many<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
_db: E,
|
||||
_author: &impl AuditAuthorable,
|
||||
_operation: &str,
|
||||
_action_kind: ActionKind,
|
||||
_w_id: &str,
|
||||
_resources: &[String],
|
||||
) -> Result<()> {
|
||||
// Implementation is not open source as Audit logs is a Windmill Enterprise Edition feature
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn list_audit(
|
||||
_tx: Transaction<'_, Postgres>,
|
||||
|
||||
@@ -94,6 +94,9 @@ pub fn otel_incr_zombie_delete_count(_count: u64) {}
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_queue_count(_tag: &str, _count: i64) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_queue_delay(_tag: &str, _delay_secs: f64) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn otel_set_queue_running_count(_tag: &str, _count: i64) {}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ lazy_static::lazy_static! {
|
||||
/// Use `tracing::info!(target: windmill_common::tracing_init::VERBOSE_TARGET, ...)` for logs that should be suppressed in quiet mode.
|
||||
pub const VERBOSE_TARGET: &str = "windmill_verbose";
|
||||
|
||||
/// Target of audit events, so a log pipeline can route them without matching on content.
|
||||
/// It keeps the `windmill` prefix because that is what a `RUST_LOG` of `info` or `debug`
|
||||
/// is rewritten to filter on.
|
||||
pub const AUDIT_TARGET: &str = "windmill:audit";
|
||||
|
||||
/// Prefix used by user scripts to emit a log line as an OTEL tracing event
|
||||
/// when `OTEL_JOB_LOGS=true`. Stripped before forwarding to the tracing layer.
|
||||
pub const OTEL_PREFIX: &str = "OTEL: ";
|
||||
|
||||
@@ -33,32 +33,19 @@ mod concurrency {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Stress: N concurrent admit-attempts on a pre-existing key with limit
|
||||
/// L < N. Asserts admitted count is exactly L (never over-admits) AND
|
||||
/// that the row's job_uuids reflects exactly L entries.
|
||||
/// Stress: N concurrent admit-attempts on a key with limit L < N. Asserts
|
||||
/// admitted count is exactly L (never over-admits) AND that the row's
|
||||
/// job_uuids reflects exactly L entries.
|
||||
///
|
||||
/// The row is pre-seeded with an empty `job_uuids` because there is a
|
||||
/// known preexisting race when the `concurrency_counter` row does not
|
||||
/// exist yet: `SELECT ... FOR UPDATE` on a missing row locks nothing,
|
||||
/// so all concurrent transactions see `running=0` and all admit via
|
||||
/// `INSERT ... ON CONFLICT DO UPDATE`. That race is inherited from
|
||||
/// the legacy code path and is out of scope for this PR. This test
|
||||
/// validates the property we *do* preserve: no over-admit once the
|
||||
/// row exists.
|
||||
/// The `concurrency_counter` row is deliberately absent at the start: the
|
||||
/// monitor deletes empty rows while jobs for the key are still queued, so
|
||||
/// the admit path has to serialize without a pre-existing row to lock.
|
||||
#[sqlx::test(migrations = "../migrations")]
|
||||
async fn stress_never_over_admits(db: Pool<Postgres>) {
|
||||
let key = "stress-never-over-admits".to_string();
|
||||
let limit: i32 = 5;
|
||||
let workers: usize = 50;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter(concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb)",
|
||||
key,
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("seed empty counter row");
|
||||
|
||||
let mut tasks = Vec::with_capacity(workers);
|
||||
for _ in 0..workers {
|
||||
let db = db.clone();
|
||||
|
||||
@@ -342,7 +342,7 @@ pub async fn cancel_suspended_trigger_jobs(
|
||||
let cancelled_jobs = cancel_jobs(
|
||||
jobs_to_cancel,
|
||||
&db,
|
||||
authed.username.as_str(),
|
||||
&authed,
|
||||
w_id.as_str(),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -1140,7 +1140,7 @@ pub enum FlowModuleValue {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
tool_inputs: HashMap<String, HashMap<String, InputTransform>>,
|
||||
},
|
||||
/// One call to a decision model (TypeSafe's Jev) answering the typed `questions` of its
|
||||
/// One call to a decision model (such as TypeSafe's Jev) answering the typed `questions` of its
|
||||
/// `input_transforms` about a `state`. A flow branches on the answers with a `BranchOne`.
|
||||
AIDecision {
|
||||
input_transforms: HashMap<String, InputTransform>,
|
||||
|
||||
@@ -28,7 +28,7 @@ use windmill_mcp::McpClient;
|
||||
use crate::ai::tools::McpClientStub as McpClient;
|
||||
use windmill_ai::{
|
||||
ai_providers::AIProvider,
|
||||
decision::{decision_inputs, run_systemone, AIDecisionArgs},
|
||||
decision::{decision_inputs, run_decision, AIDecisionArgs},
|
||||
image_handler::upload_image_to_s3,
|
||||
model_context::model_context_window,
|
||||
providers::{
|
||||
@@ -2449,9 +2449,9 @@ async fn handle_ai_decision(
|
||||
worker_name: &str,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let args = serde_json::from_str::<AIDecisionArgs>(&serde_json::to_string(local_args)?)?;
|
||||
if !args.provider.kind.is_decision_provider() {
|
||||
if !args.provider.kind.runs_decisions() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"An AI decision runs on a TypeSafe or Cloudflare resource, not {:?}",
|
||||
"An AI decision runs on a TypeSafe, Cloudflare or OpenAI resource, not {:?}",
|
||||
args.provider.kind
|
||||
)));
|
||||
}
|
||||
@@ -2467,7 +2467,7 @@ async fn handle_ai_decision(
|
||||
conn,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
run_systemone(
|
||||
run_decision(
|
||||
&credentials,
|
||||
args.provider.get_model(),
|
||||
state,
|
||||
|
||||
@@ -855,7 +855,7 @@ fn convert_typ_val(arg_t: String, arg_v: Value) -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
"binary" => {
|
||||
"binary" | "varbinary" => {
|
||||
// convert base64 to hex as expected by snowflake
|
||||
let bytes = engine::general_purpose::STANDARD
|
||||
.decode(arg_v.as_str().unwrap_or(""))
|
||||
|
||||
@@ -192,6 +192,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
flow_job_duration,
|
||||
stop_early_override,
|
||||
has_triggered_error_handler: false,
|
||||
recovered_by_child: false,
|
||||
};
|
||||
let mut step_failure = step_failure;
|
||||
loop {
|
||||
@@ -211,6 +212,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
worker_dir,
|
||||
rec.stop_early_override,
|
||||
rec.has_triggered_error_handler,
|
||||
rec.recovered_by_child,
|
||||
worker_name,
|
||||
job_completed_tx.clone(),
|
||||
flow_runners.clone(),
|
||||
@@ -240,6 +242,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
worker_dir,
|
||||
rec.stop_early_override,
|
||||
rec.has_triggered_error_handler,
|
||||
false,
|
||||
worker_name,
|
||||
job_completed_tx.clone(),
|
||||
flow_runners.clone(),
|
||||
@@ -293,6 +296,10 @@ pub struct RecUpdateFlowStatusAfterJobCompletion {
|
||||
flow_job_duration: Option<FlowJobDuration>,
|
||||
stop_early_override: Option<bool>,
|
||||
has_triggered_error_handler: bool,
|
||||
/// The failed child's error handler returned `recover: true`. The parent still handles the
|
||||
/// failure as it would any other, so the same steps run; only a root flow that this failure
|
||||
/// ends turns green.
|
||||
recovered_by_child: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -427,6 +434,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
worker_dir: &str,
|
||||
stop_early_override: Option<bool>,
|
||||
has_triggered_error_handler: bool,
|
||||
mut recovered_by_child: bool,
|
||||
worker_name: &str,
|
||||
job_completed_tx: JobCompletedSender,
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
@@ -449,6 +457,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
skip_if_stop_early,
|
||||
nresult,
|
||||
is_failure_step,
|
||||
failure_step_recovers,
|
||||
_cleanup_module,
|
||||
chat_ai_info,
|
||||
) = {
|
||||
@@ -1096,6 +1105,9 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
} else {
|
||||
has_triggered_error_handler = false;
|
||||
}
|
||||
// Recovered iterations are stored as successes, so a failed parallel
|
||||
// node failed on an iteration that did not recover.
|
||||
recovered_by_child = false;
|
||||
(success, Some(new_status))
|
||||
} else {
|
||||
add_time!(bench, "handle parallel flow start");
|
||||
@@ -1823,6 +1835,13 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let failure_step_recovers = is_failure_step && result_has_recover_true(nresult.clone());
|
||||
// An error this flow raises on its own (a stop predicate's error message) is a new
|
||||
// failure that no error handler recovered.
|
||||
if stop_early_err_msg.is_some() {
|
||||
recovered_by_child = false;
|
||||
}
|
||||
|
||||
let chat_ai_info = ChatAiInfo {
|
||||
chat_input_enabled: old_status.chat_input_enabled.unwrap_or(false),
|
||||
conversation_id: old_status.memory_id,
|
||||
@@ -1837,6 +1856,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
skip_if_stop_early,
|
||||
nresult,
|
||||
is_failure_step,
|
||||
failure_step_recovers,
|
||||
old_status.cleanup_module,
|
||||
chat_ai_info,
|
||||
)
|
||||
@@ -1844,6 +1864,10 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
|
||||
let flow_job = Arc::new(flow_job);
|
||||
|
||||
// An inner flow that ends on a recovered failure stays failed, as parents read their
|
||||
// children's stored status (a parallel node counts its iterations' statuses), so only the
|
||||
// root flow turns green.
|
||||
let ends_root = !(flow_job.is_flow_step() && flow_job.parent_job.is_some());
|
||||
let done = if !should_continue_flow {
|
||||
{
|
||||
let logs = if flow_job.is_canceled() {
|
||||
@@ -1851,7 +1875,9 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
} else if stop_early {
|
||||
format!("Flow job stopped early because of a stop early predicate returning true\n")
|
||||
} else if is_failure_step {
|
||||
format!("Flow job completed with error, and error handler was triggered.\nIt completed with {}, and with recover: {}\n", if success { "success" } else { "error" }, result_has_recover_true(nresult.clone()))
|
||||
format!("Flow job completed with error, and error handler was triggered.\nIt completed with {}, and with recover: {}\n", if success { "success" } else { "error" }, failure_step_recovers)
|
||||
} else if !success && recovered_by_child && ends_root {
|
||||
format!("Flow job completed with success: the step that failed had its error recovered by the error handler (recover: true)\n")
|
||||
} else {
|
||||
format!(
|
||||
"Flow job completed with {}\n",
|
||||
@@ -2048,7 +2074,11 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
.await;
|
||||
}
|
||||
|
||||
let success = success && (!is_failure_step || result_has_recover_true(nresult.clone()));
|
||||
let success = if is_failure_step {
|
||||
success && failure_step_recovers
|
||||
} else {
|
||||
success || (recovered_by_child && ends_root)
|
||||
};
|
||||
|
||||
add_time!(bench, "flow status update 1");
|
||||
|
||||
@@ -2149,6 +2179,8 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
.await;
|
||||
// override prior child's success so the parent learns this flow failed
|
||||
success = false;
|
||||
// a new failure, which no error handler recovered
|
||||
recovered_by_child = false;
|
||||
true
|
||||
}
|
||||
Ok(_) => false,
|
||||
@@ -2178,6 +2210,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
None
|
||||
},
|
||||
has_triggered_error_handler: has_triggered_error_handler || is_failure_step,
|
||||
recovered_by_child: failure_step_recovers || (recovered_by_child && !success),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.825.0";
|
||||
export const VERSION = "v1.826.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -908,7 +908,7 @@ const command = new Command()
|
||||
)
|
||||
.option(
|
||||
"--schema <schema:string>",
|
||||
"Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist."
|
||||
"Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it."
|
||||
)
|
||||
.option(
|
||||
"--overwrite",
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.825.0";
|
||||
export const VERSION = "1.826.0";
|
||||
|
||||
@@ -90,7 +90,7 @@ A data pipeline is NOT a flow: it is a set of independent scripts in one folder,
|
||||
## Raw App Development
|
||||
|
||||
You MUST use the \`raw-app\` skill to create or modify raw apps.
|
||||
When a new app needs to be created, YOU run \`wmill app new\` yourself with \`--summary\`, \`--path\`, and \`--framework\` flags (and any other relevant flags). Do NOT ask the user to run it. If you don't have the values for those flags, ask the user via \`AskUserQuestion\` (one call, all missing fields) — never invent them. See the \`raw-app\` skill for the full procedure.
|
||||
When a new app needs to be created, YOU run \`wmill app new\` yourself with \`--summary\`, \`--path\`, and \`--framework\` flags (and any other relevant flags). Do NOT ask the user to run it. If you don't have a summary or a path, ask the user via \`AskUserQuestion\` (one call, all missing fields) — never invent them. The framework is not asked: \`react19\` unless the user or the project's existing apps say otherwise. An app that stores data also needs its data table and schema settled with the user first. See the \`raw-app\` skill for the full procedure.
|
||||
|
||||
## Triggers
|
||||
|
||||
|
||||
Generated
+67
-20
File diff suppressed because one or more lines are too long
Generated
+2
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -61,7 +61,6 @@
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mermaid": "^11.16.1",
|
||||
"minimatch": "^10.2.3",
|
||||
"modern-screenshot": "^4.7.0",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
"monaco-vim": "^0.4.1",
|
||||
@@ -9657,12 +9656,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-screenshot": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz",
|
||||
"integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"name": "@codingame/monaco-vscode-editor-api",
|
||||
"version": "25.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.825.0",
|
||||
"version": "1.826.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
@@ -127,7 +127,6 @@
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mermaid": "^11.16.1",
|
||||
"minimatch": "^10.2.3",
|
||||
"modern-screenshot": "^4.7.0",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
"monaco-vim": "^0.4.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
|
||||
"version": "9fddc52",
|
||||
"sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522"
|
||||
"version": "d3fdb70",
|
||||
"sha256": "8c894b3f02d5d1872a966c37d43314f05ce42900f7330bdcf45a18a772deb2b9"
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
* other than the one being navigated. Resources and the models read off them are per
|
||||
* workspace, so without it this offers what the wrong one holds. */
|
||||
workspace?: string | undefined
|
||||
/** Offer the decision providers (TypeSafe, Cloudflare) in place of the chat ones, for an AI
|
||||
* decision. */
|
||||
/** Offer the decision providers (TypeSafe, Cloudflare, OpenAI) and their decision models in
|
||||
* place of the chat ones, for an AI decision. */
|
||||
decision?: boolean
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@
|
||||
return r
|
||||
})
|
||||
|
||||
// A decision provider's models are the ones Windmill knows: none has a listing of its decision
|
||||
// models, and an OpenAI resource's lists its chat models.
|
||||
function defaultModels(provider: AIProvider): string[] {
|
||||
return decision
|
||||
? ((DECISION_AI_PROVIDERS as Record<string, { defaultModels: string[] }>)[provider]
|
||||
?.defaultModels ?? [])
|
||||
: aiProviderDetails(provider).defaultModels
|
||||
}
|
||||
|
||||
async function loadModels(signal?: AbortSignal) {
|
||||
const provider = value?.kind
|
||||
const resourceValue = value?.resource
|
||||
@@ -92,6 +101,11 @@
|
||||
if (!provider || !resourcePath) {
|
||||
return
|
||||
}
|
||||
if (decision) {
|
||||
availableModels = defaultModels(provider)
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}`
|
||||
@@ -113,8 +127,7 @@
|
||||
return
|
||||
}
|
||||
// Fall back to default models for this provider
|
||||
const defaultModels = aiProviderDetails(provider).defaultModels
|
||||
availableModels = defaultModels
|
||||
availableModels = defaultModels(provider)
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
loading = false
|
||||
@@ -178,8 +191,7 @@
|
||||
if (provider && resourcePath) {
|
||||
loadModels(abortController.signal)
|
||||
} else {
|
||||
const defaultModels = provider ? aiProviderDetails(provider).defaultModels : []
|
||||
availableModels = defaultModels
|
||||
availableModels = provider ? defaultModels(provider) : []
|
||||
loading = false
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
const defaultColumnType = (
|
||||
{
|
||||
postgresql: 'BIGSERIAL',
|
||||
snowflake: 'varchar',
|
||||
snowflake: 'VARCHAR',
|
||||
ms_sql_server: 'varchar',
|
||||
bigquery: 'string',
|
||||
mysql: 'varchar',
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
case 'aidecision':
|
||||
return 'AI decision'
|
||||
return 'AI Decision'
|
||||
default:
|
||||
return stepDetail.id
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* button needs it: without it that text is invisible to the editor's dirty state,
|
||||
* so it is silently dropped on close and cannot even enable Save on its own. */
|
||||
onPendingChange?: (pending: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +25,8 @@
|
||||
onchange,
|
||||
class: clazz = '',
|
||||
workspace,
|
||||
onPendingChange
|
||||
onPendingChange,
|
||||
disabled = false
|
||||
}: Props = $props()
|
||||
|
||||
let adding = $state(false)
|
||||
@@ -140,12 +142,14 @@
|
||||
{#each labels ?? [] as label (label)}
|
||||
<Badge color="blue" small>
|
||||
{label}
|
||||
<button class="ml-0.5 hover:text-red-500" onclick={() => removeLabel(label)}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
{#if !disabled}
|
||||
<button class="ml-0.5 hover:text-red-500" onclick={() => removeLabel(label)}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
{#if adding}
|
||||
{#if adding && !disabled}
|
||||
<div class="relative">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
@@ -189,7 +193,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{:else if !disabled}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
|
||||
@@ -30,6 +30,42 @@
|
||||
nullable: 'YES' | 'NO'
|
||||
}
|
||||
|
||||
// Snowflake's information_schema reports canonical names (NUMBER, TEXT,
|
||||
// TIMESTAMP_NTZ, ...), which the pinned parser wasm only knows by a few aliases.
|
||||
function snowflakeParserAlias(typ: string): string {
|
||||
switch (typ) {
|
||||
case 'number':
|
||||
case 'decimal':
|
||||
case 'numeric':
|
||||
case 'float4':
|
||||
case 'float8':
|
||||
case 'double':
|
||||
case 'double precision':
|
||||
case 'real':
|
||||
return 'float'
|
||||
case 'integer':
|
||||
case 'bigint':
|
||||
case 'smallint':
|
||||
case 'tinyint':
|
||||
case 'byteint':
|
||||
return 'int'
|
||||
case 'text':
|
||||
case 'string':
|
||||
case 'char':
|
||||
case 'character':
|
||||
return 'varchar'
|
||||
case 'varbinary':
|
||||
return 'binary'
|
||||
case 'datetime':
|
||||
case 'timestamp_ntz':
|
||||
case 'timestamp_ltz':
|
||||
case 'timestamp_tz':
|
||||
return 'timestamp'
|
||||
default:
|
||||
return typ
|
||||
}
|
||||
}
|
||||
|
||||
function parseSQLArgs(field: string, dbType: DbType): string {
|
||||
let rawType = ''
|
||||
switch (dbType) {
|
||||
@@ -43,7 +79,7 @@
|
||||
rawType = parse_bigquery(field)
|
||||
break
|
||||
case 'snowflake':
|
||||
rawType = parse_snowflake(field)
|
||||
rawType = parse_snowflake(snowflakeParserAlias(field.toLowerCase()))
|
||||
break
|
||||
case 'ms_sql_server':
|
||||
rawType = parse_mssql(field)
|
||||
@@ -97,12 +133,27 @@
|
||||
schemaProperty.default = field.defaultValue
|
||||
}
|
||||
}
|
||||
if (field.type === 'timestamp without time zone' || field.type === 'timestamp') {
|
||||
const fieldType = dbType === 'snowflake' ? field.type.toLowerCase() : field.type
|
||||
if (
|
||||
fieldType === 'timestamp without time zone' ||
|
||||
fieldType === 'timestamp' ||
|
||||
fieldType === 'timestamp_ntz'
|
||||
) {
|
||||
schemaProperty.format = 'naive-date-time'
|
||||
}
|
||||
if (field.type === 'timestamp with time zone' || field.type === 'timestamptz') {
|
||||
if (
|
||||
fieldType === 'timestamp with time zone' ||
|
||||
fieldType === 'timestamptz' ||
|
||||
fieldType === 'timestamp_tz' ||
|
||||
fieldType === 'timestamp_ltz'
|
||||
) {
|
||||
schemaProperty.format = 'date-time'
|
||||
}
|
||||
if (dbType === 'snowflake' && fieldType === 'date') {
|
||||
schemaProperty.format = 'date'
|
||||
// DateInput defaults to dd-MM-yyyy, which Snowflake's AUTO input format rejects
|
||||
schemaProperty.dateFormat = 'yyyy-MM-dd'
|
||||
}
|
||||
|
||||
properties[field.name] = schemaProperty
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { clearOverlayHost } from './overlayHost.svelte'
|
||||
|
||||
let { children }: { children?: Snippet } = $props()
|
||||
|
||||
clearOverlayHost()
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) {
|
||||
setContext<OverlayHostContext>(OVERLAY_HOST_KEY, host)
|
||||
}
|
||||
|
||||
/** Hand this subtree's overlays back to the viewport, for one that speaks for the whole
|
||||
* page rather than the pane it happens to be opened from. */
|
||||
export function clearOverlayHost() {
|
||||
setContext<OverlayHostContext | undefined>(OVERLAY_HOST_KEY, undefined)
|
||||
}
|
||||
|
||||
export function getOverlayHost(): OverlayHostContext | undefined {
|
||||
return getContext<OverlayHostContext | undefined>(OVERLAY_HOST_KEY)
|
||||
}
|
||||
|
||||
@@ -117,13 +117,19 @@
|
||||
// Queued and running calls stay closed: their label already says what they are doing, and
|
||||
// opening them would only show empty logs and result. A call opens while its arguments
|
||||
// stream, while it waits on the user, and once it has an error or details to keep in view.
|
||||
let isExpanded = $derived(
|
||||
Boolean(
|
||||
(detailsAvailable &&
|
||||
(message.error !== undefined || (isSuccessful && !autoCollapseDetails))) ||
|
||||
(message.isStreamingArguments && hasParameters) ||
|
||||
(message.isLoading && message.needsConfirmation)
|
||||
)
|
||||
// The user's toggle lives in state keyed by call id: the `message` prop is replaced on every
|
||||
// chat update, so an override written onto a derived would be recomputed away. A pending
|
||||
// confirmation wins over it, since the Run/Reject footer renders inside the card.
|
||||
let toggled = $state<{ id: string | undefined; open: boolean } | undefined>(undefined)
|
||||
const isExpanded = $derived(
|
||||
Boolean(message.isLoading && message.needsConfirmation) ||
|
||||
(toggled?.id === message.tool_call_id
|
||||
? toggled.open
|
||||
: Boolean(
|
||||
(detailsAvailable &&
|
||||
(message.error !== undefined || (isSuccessful && !autoCollapseDetails))) ||
|
||||
(message.isStreamingArguments && hasParameters)
|
||||
))
|
||||
)
|
||||
|
||||
const visibleActions = $derived(
|
||||
@@ -323,7 +329,7 @@
|
||||
<ChatCollapsibleCard
|
||||
{label}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => (isExpanded = !isExpanded)}
|
||||
onToggle={() => (toggled = { id: message.tool_call_id, open: !isExpanded })}
|
||||
toggleable={detailsAvailable || message.isStreamingArguments === true}
|
||||
shimmer={isRunning}
|
||||
settleLabel
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
// included so an AI decision step's resource validates; `validateAiAgentProviders` keeps each kind
|
||||
// to its step type.
|
||||
const aiResourceTypes = () =>
|
||||
[...Object.keys(AI_PROVIDERS), ...Object.keys(DECISION_AI_PROVIDERS)] as AIProvider[]
|
||||
[
|
||||
...new Set([...Object.keys(AI_PROVIDERS), ...Object.keys(DECISION_AI_PROVIDERS)])
|
||||
] as AIProvider[]
|
||||
|
||||
/** Kinds whose model listing `fetchAvailableModels` narrows before returning it: OpenAI and Azure
|
||||
* OpenAI keep only `gpt-`/`o`/`codex` ids (so a fine-tune never appears), Bedrock keeps text
|
||||
|
||||
@@ -128,8 +128,15 @@ describe('validateAiAgentProviders', () => {
|
||||
]
|
||||
|
||||
expect(() => validateAiAgentProviders(decision(anthropic), undefined)).toThrow(
|
||||
/Step "d".*must be "typesafe" or "cloudflare"/
|
||||
/Step "d".*must be "typesafe", "cloudflare" or "openai"/
|
||||
)
|
||||
// OpenAI runs both: its resource serves the decision model next to the chat ones.
|
||||
const openai = (model: string) => ({
|
||||
type: 'static',
|
||||
value: { kind: 'openai', resource: '$res:u/admin/openai', model }
|
||||
})
|
||||
expect(() => validateAiAgentProviders(decision(openai('gpt-6-luna')), undefined)).not.toThrow()
|
||||
expect(() => validateAiAgentProviders(agentStep(openai('gpt-5')), undefined)).not.toThrow()
|
||||
expect(() => validateAiAgentProviders(agentStep(typesafe), undefined)).toThrow(
|
||||
/Step "agent".*use an "aidecision" step/
|
||||
)
|
||||
|
||||
@@ -29,8 +29,15 @@ export type AiAgentProviderCatalog = {
|
||||
defaultModel?: { kind: AIProvider; model: string }
|
||||
}
|
||||
|
||||
/** The providers an AI decision step runs on, and the kinds an AI agent step cannot. */
|
||||
const DECISION_PROVIDER_KINDS: readonly string[] = ['typesafe', 'cloudflare']
|
||||
/** The kinds that serve decision models only, which an AI agent step cannot run on. */
|
||||
const DECISION_ONLY_KINDS: readonly string[] = ['typesafe', 'cloudflare']
|
||||
|
||||
/** The model of OpenAI's Decisions API, which an `openai` resource serves next to its chat
|
||||
* models: the one kind both step types run on. */
|
||||
const OPENAI_DECISION_MODEL = 'gpt-6-luna'
|
||||
|
||||
/** The providers an AI decision step runs on. */
|
||||
const DECISION_PROVIDER_KINDS: readonly string[] = [...DECISION_ONLY_KINDS, 'openai']
|
||||
|
||||
/** A model id as every provider writes one: `claude-sonnet-5`, `meta-llama/Llama-3.3-70B`,
|
||||
* `anthropic.claude-haiku-4-5-20251001-v1:0`, `ft:gpt-4o:acme::abc`. Anything else is not
|
||||
@@ -150,7 +157,7 @@ This workspace has none, so an AI agent step has no model to run on. ${
|
||||
? ''
|
||||
: '\nThis list is incomplete: the workspace has AI provider resources that are not shown.'
|
||||
const decisionLine = catalog.options.some((o) => DECISION_PROVIDER_KINDS.includes(o.kind))
|
||||
? `\nA \`typesafe\` or \`cloudflare\` resource serves AI decision steps only, and AI decision steps run on nothing else.`
|
||||
? `\nA \`typesafe\` or \`cloudflare\` resource serves AI decision steps only. AI decision steps run on those or on an \`openai\` resource with model \`${OPENAI_DECISION_MODEL}\`, and on nothing else.`
|
||||
: ''
|
||||
return `## AI provider resources in this workspace
|
||||
|
||||
@@ -249,10 +256,10 @@ function checkProviderValue(
|
||||
}
|
||||
if (stepType === 'aidecision' && !DECISION_PROVIDER_KINDS.includes(kind)) {
|
||||
return blocking(
|
||||
`an AI decision step runs on a decision model: provider.kind must be "typesafe" or "cloudflare", not "${kind}"`
|
||||
`an AI decision step runs on a decision model: provider.kind must be "typesafe", "cloudflare" or "openai", not "${kind}"`
|
||||
)
|
||||
}
|
||||
if (stepType !== 'aidecision' && DECISION_PROVIDER_KINDS.includes(kind)) {
|
||||
if (stepType !== 'aidecision' && DECISION_ONLY_KINDS.includes(kind)) {
|
||||
return blocking(
|
||||
`"${kind}" serves decision models, which answer typed questions rather than messages: use an "aidecision" step rather than an AI agent`
|
||||
)
|
||||
@@ -286,6 +293,15 @@ function checkProviderValue(
|
||||
`provider.kind "${kind}" does not match "${resource}", which is a \`${match.kind}\` resource`
|
||||
)
|
||||
}
|
||||
// An OpenAI resource lists its chat models, which says nothing about its decision model.
|
||||
if (stepType === 'aidecision' && kind === 'openai') {
|
||||
return model === OPENAI_DECISION_MODEL
|
||||
? undefined
|
||||
: {
|
||||
message: `model "${model}" is not the model OpenAI's Decisions API is known to serve, "${OPENAI_DECISION_MODEL}"`,
|
||||
blocking: false
|
||||
}
|
||||
}
|
||||
if (match.modelsAreLive && !match.models.ids.includes(model)) {
|
||||
return {
|
||||
message: `model "${model}" is not in the model listing of "${resource}"`,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2607,6 +2607,25 @@ describe('global AI tools', () => {
|
||||
expect(raw).not.toContain('function New')
|
||||
})
|
||||
|
||||
it('records the data setup a new app is initialized with', async () => {
|
||||
await callGlobalTool('init_app', {
|
||||
path: 'u/admin/tickets',
|
||||
framework: 'react19',
|
||||
data: {
|
||||
datatable: 'main',
|
||||
schema: 'helpdesk',
|
||||
tables: ['helpdesk:tickets', 'notes', 'main/customers']
|
||||
}
|
||||
})
|
||||
|
||||
const draft = getBackendDraft('raw_app', 'u/admin/tickets', { workspace: WORKSPACE })
|
||||
expect(draft?.data).toEqual({
|
||||
datatable: 'main',
|
||||
schema: 'helpdesk',
|
||||
tables: ['main/helpdesk:tickets', 'main/helpdesk:notes', 'main/customers']
|
||||
})
|
||||
})
|
||||
|
||||
it('discards a draft without deleting the workspace item', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/discard-me',
|
||||
@@ -4709,6 +4728,7 @@ describe('global AI tools', () => {
|
||||
expect(onDeployed).toHaveBeenCalledWith({
|
||||
sessionId: 'sess-123',
|
||||
kind: 'raw_app',
|
||||
storagePath: 'f/apps/report',
|
||||
path: 'f/apps/report'
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -68,7 +68,10 @@ import {
|
||||
} from '$lib/components/job_args'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import { PLAN_MODE_MESSAGES } from '../planModeMessages'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
DEFAULT_DATA as DEFAULT_RAW_APP_DATA,
|
||||
formatDataTableRef
|
||||
} from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
|
||||
import { dataUrlToImagePart, normalizeImageDataUrl, type AttachedImage } from '../imageUtils'
|
||||
@@ -1294,7 +1297,22 @@ const initAppSchema = z.object({
|
||||
framework: z
|
||||
.enum(FRAMEWORK_KEYS)
|
||||
.describe(
|
||||
'Frontend framework template. Confirm with the user before calling — never default silently. react19 is recommended for new apps.'
|
||||
'Frontend framework template: the one the user named or an app they point at uses, otherwise react19. Do not ask.'
|
||||
),
|
||||
data: z
|
||||
.object({
|
||||
datatable: z.string().describe('Data table the app stores its data in.'),
|
||||
schema: z.string().optional().describe("Schema the app's new tables go in. Omit for public."),
|
||||
tables: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Tables the app queries, reused or about to be created, each as `<datatable>/<table>` (public) or `<datatable>/<schema>:<table>`.'
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
'Data setup of an app that stores data, as the user gave or chose it. Omit for an app that stores nothing.'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1546,7 +1564,7 @@ Raw apps:
|
||||
- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.
|
||||
- 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.
|
||||
- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app's data.
|
||||
- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.`
|
||||
)}
|
||||
|
||||
@@ -2416,7 +2434,7 @@ function getAppInstructions(language?: ScriptLang): string {
|
||||
|
||||
- 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. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. 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.
|
||||
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`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.
|
||||
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
|
||||
- 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? }\`).
|
||||
@@ -4500,7 +4518,6 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
),
|
||||
planModeSafe: true,
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false,
|
||||
fn: async (ctx) => {
|
||||
const parsed = getRuntimeLogsSchema.parse(ctx.args)
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Reading app runtime logs...' })
|
||||
@@ -5038,6 +5055,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise<SessionScr
|
||||
export type DeployedInSessionHandler = (req: {
|
||||
sessionId: string | undefined
|
||||
kind: 'script' | 'flow' | 'raw_app'
|
||||
/** The path the item's draft, and so its open editor, is keyed on. */
|
||||
storagePath: string
|
||||
/** Where the deploy landed. */
|
||||
path: string
|
||||
}) => void
|
||||
|
||||
@@ -6509,16 +6529,31 @@ async function testRunFlowStepByPath(
|
||||
)
|
||||
}
|
||||
|
||||
/** The `data.tables` entry for a table named without its data table (`schema:table`,
|
||||
* `schema.table` or `table`), which models write for a table of the app's own data table.
|
||||
* A bare name is a table of the app's own schema. */
|
||||
function appTableRef(datatable: string, schema: string | undefined, ref: string): string {
|
||||
if (ref.includes('/')) return ref
|
||||
const sep = ref.includes(':') ? ':' : '.'
|
||||
const at = ref.indexOf(sep)
|
||||
return formatDataTableRef(
|
||||
at === -1
|
||||
? { datatable, schema, table: ref }
|
||||
: { datatable, schema: ref.slice(0, at), table: ref.slice(at + 1) }
|
||||
)
|
||||
}
|
||||
|
||||
async function initApp(
|
||||
args: {
|
||||
path: string
|
||||
summary?: string
|
||||
framework: FrameworkKey
|
||||
data?: { datatable: string; schema?: string; tables?: string[] }
|
||||
},
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const { path, summary, framework } = args
|
||||
const { path, summary, framework, data } = args
|
||||
|
||||
if (await getGlobalDraft(workspace, 'app', path)) {
|
||||
throw new Error(
|
||||
@@ -6539,13 +6574,20 @@ async function initApp(
|
||||
const value: AppDraftValue = {
|
||||
summary,
|
||||
files: { ...template },
|
||||
runnables: structuredClone(STARTER_RUNNABLES)
|
||||
runnables: structuredClone(STARTER_RUNNABLES),
|
||||
...(data && {
|
||||
data: {
|
||||
tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)),
|
||||
datatable: data.datatable,
|
||||
schema: data.schema
|
||||
}
|
||||
})
|
||||
}
|
||||
await recomputeAppPolicy(value)
|
||||
const result = await saveAppDraft(workspace, path, value)
|
||||
return finishAppDraftWrite(result, ctx, () => ({
|
||||
content: `Saved app "${path}" draft (${framework})`,
|
||||
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.`
|
||||
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}`
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -8651,24 +8693,29 @@ async function deployDraft(
|
||||
// synthetic storage key never exists deployed, so the entry would otherwise
|
||||
// stop matching anything after the draft is gone.
|
||||
const deployedKind = itemKindFor(type, triggerKind)
|
||||
const draftStoragePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
|
||||
if (deployedKind) {
|
||||
toolCallbacks.onItemDeployed?.(
|
||||
deployedKind,
|
||||
getGlobalDraftStoragePath(workspace, type, path, triggerKind),
|
||||
deployedPath
|
||||
)
|
||||
toolCallbacks.onItemDeployed?.(deployedKind, draftStoragePath, deployedPath)
|
||||
}
|
||||
|
||||
// Reload the session preview if it's open on the deployed item. Map the
|
||||
// deploy type to the preview kind — a raw app deploys under 'app' but the
|
||||
// preview addresses it as 'raw_app'; non-previewable types map to undefined.
|
||||
// Reload the session preview if it's open on the deployed item, which its editor
|
||||
// knows by the draft's storage path. Map the deploy type to the preview kind — a
|
||||
// raw app deploys under 'app' but the preview addresses it as 'raw_app';
|
||||
// non-previewable types map to undefined.
|
||||
const previewKindByType: Partial<Record<WorkspaceItemType, 'script' | 'flow' | 'raw_app'>> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
app: 'raw_app'
|
||||
}
|
||||
const kind = previewKindByType[type]
|
||||
if (kind) deployedInSessionHandler?.({ sessionId, kind, path })
|
||||
if (kind) {
|
||||
deployedInSessionHandler?.({
|
||||
sessionId,
|
||||
kind,
|
||||
storagePath: draftStoragePath,
|
||||
path: deployedPath
|
||||
})
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deployed ${type} "${path}"`,
|
||||
@@ -8942,10 +8989,8 @@ export function prepareGlobalUserMessage(
|
||||
content += `path: ${activeEditor.path}\n`
|
||||
content += `isLiveDraft: true\n`
|
||||
if (activeEditor.isNew) {
|
||||
// The template only lives in the open editor until the first edit saves it, so
|
||||
// init_app would see no draft and overwrite it with a second app.
|
||||
content +=
|
||||
'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one.\n'
|
||||
'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one. For an app, its files are the framework it was started with and the `data` config read_workspace_item shows is the data setup it was started with: ask about neither.\n'
|
||||
}
|
||||
content += '\n'
|
||||
}
|
||||
|
||||
@@ -168,12 +168,17 @@ export const AI_PROVIDERS: Record<ChatAIProvider, AIProviderDetails> = {
|
||||
}
|
||||
|
||||
/** Decision models answer typed questions instead of messages, so only an AI decision offers
|
||||
* them: TypeSafe's Jev, and Cloudflare's Jev-compatible Clef. The pinned Jev version is there
|
||||
* them: TypeSafe's Jev, Cloudflare's Jev-compatible Clef, and the model of OpenAI's Decisions
|
||||
* API, which an OpenAI resource serves next to its chat models. The pinned Jev version is there
|
||||
* for flows tuned against its probabilities. */
|
||||
export const DECISION_AI_PROVIDERS: Record<
|
||||
Exclude<AIProvider, ChatAIProvider>,
|
||||
Exclude<AIProvider, ChatAIProvider> | 'openai',
|
||||
AIProviderDetails
|
||||
> = {
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
defaultModels: ['gpt-6-luna']
|
||||
},
|
||||
typesafe: {
|
||||
label: 'TypeSafe',
|
||||
defaultModels: ['jev-latest', 'jev-1.13.0']
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
<Badge color="indigo">AI Agent</Badge>
|
||||
{@render summaryInput()}
|
||||
{:else if flowModuleValue.type === 'aidecision'}
|
||||
<Badge color="indigo">AI decision</Badge>
|
||||
<Badge color="indigo">AI Decision</Badge>
|
||||
{@render summaryInput()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
...(customUi?.aiAgent != false
|
||||
? ([
|
||||
['AI Agent', 'aiagent'],
|
||||
['AI decision', 'aidecision']
|
||||
['AI Decision', 'aidecision']
|
||||
] as [string, string][])
|
||||
: [])
|
||||
]
|
||||
|
||||
@@ -212,15 +212,16 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
]
|
||||
}
|
||||
|
||||
/** The inputs of an AI decision step: one call to a decision model (TypeSafe's Jev, or
|
||||
* Cloudflare's Clef). */
|
||||
/** The inputs of an AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's
|
||||
* Clef, or OpenAI's Decisions API). */
|
||||
export const AI_DECISION_SCHEMA: Schema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'object',
|
||||
format: 'ai-decision-provider',
|
||||
description: 'The decision model to ask: TypeSafe (Jev) or Cloudflare (Clef).'
|
||||
description:
|
||||
'The decision model to ask: TypeSafe (Jev), Cloudflare (Clef) or OpenAI (Decisions API).'
|
||||
},
|
||||
// Untyped, so the static editor takes any JSON: TypeSafe reads a string, an object or an
|
||||
// array of strings.
|
||||
|
||||
@@ -74,7 +74,11 @@
|
||||
'text-xs rounded-md rounded-r-none px-1.5 h-[24px] w-full bg-surface flex flex-row items-center gap-2 justify-center transition-all duration-200 ',
|
||||
'hover:bg-surface-hover text-gray-400 hover:text-primary'
|
||||
)}
|
||||
onclick={onOpenPreview}
|
||||
onclick={(e) => {
|
||||
// These buttons sit inside the Input node, whose click handler would select it.
|
||||
e.stopPropagation()
|
||||
onOpenPreview?.()
|
||||
}}
|
||||
>
|
||||
{#if flowPreviewJob && !flowHasChanged}
|
||||
<div
|
||||
@@ -105,7 +109,10 @@
|
||||
<button
|
||||
class="h-[24px] px-1.5 bg-surface rounded-md rounded-l-none text-gray-400 hover:bg-red-500 hover:text-white"
|
||||
title="Hide jobs status"
|
||||
onclick={onHideJobStatus}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
onHideJobStatus?.()
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
/>
|
||||
{#if !nestedAgent}
|
||||
<TopLevelNode
|
||||
label="AI decision"
|
||||
label="AI Decision"
|
||||
chevron={false}
|
||||
onSelect={() => {
|
||||
dispatch('pickAiDecisionTool')
|
||||
@@ -338,7 +338,7 @@
|
||||
}}
|
||||
/>
|
||||
<TopLevelNode
|
||||
label="AI decision"
|
||||
label="AI Decision"
|
||||
onSelect={() => {
|
||||
dispatch('close')
|
||||
dispatch('new', { kind: 'aidecision' })
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
{nodeState}
|
||||
label={mod.summary ||
|
||||
(mod.value.type === 'aiagent' ? (mod.value.agent ?? 'AI Agent') : undefined) ||
|
||||
(mod.value.type === 'aidecision' ? 'AI decision' : undefined) ||
|
||||
(mod.value.type === 'aidecision' ? 'AI Decision' : undefined) ||
|
||||
(mod.id === 'preprocessor'
|
||||
? 'Preprocessor'
|
||||
: mod.id.startsWith('failure')
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
'Branch to one': { icon: GitBranch },
|
||||
'Branch to all': { icon: GitBranch },
|
||||
'AI Agent': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' },
|
||||
'AI decision': { icon: Scale, iconClass: 'text-ai' },
|
||||
'AI Decision': { icon: Scale, iconClass: 'text-ai' },
|
||||
'AI Sandbox': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' },
|
||||
'Claude Code': { icon: BotIcon, iconClass: 'text-ai' },
|
||||
MCP: { icon: Plug },
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
if (type === 'flow') return 'Flow'
|
||||
if (type === 'identity') return 'Identity'
|
||||
if (type === 'aiagent') return 'AI Agent'
|
||||
if (type === 'aidecision') return 'AI decision'
|
||||
if (type === 'aidecision') return 'AI Decision'
|
||||
return mod.id
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { JobById } from '../apps/types'
|
||||
import { JobService } from '$lib/gen'
|
||||
import type { Runnable } from './rawAppPolicy'
|
||||
import { RAW_APP_PREVIEW_RELAY } from './utils'
|
||||
import { undefinedIfEmpty } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
@@ -19,10 +20,9 @@
|
||||
* Restrict waitJob/getJob/streamJob to job ids launched by this app
|
||||
* instance (WIN-2006): a SANDBOXED bundle must not read arbitrary
|
||||
* workspace jobs through the credentialed bridge. Off for unsandboxed
|
||||
* renders (the default, and editor preview) — there the bundle holds
|
||||
* the same credential as the bridge, so gating adds nothing and would
|
||||
* only break unsandboxed apps that poll persisted or runnable-returned
|
||||
* job ids.
|
||||
* renders — there the bundle holds the same credential as the bridge,
|
||||
* so gating adds nothing and would only break unsandboxed apps that
|
||||
* poll persisted or runnable-returned job ids.
|
||||
*/
|
||||
gateJobIds?: boolean
|
||||
/**
|
||||
@@ -32,7 +32,7 @@
|
||||
* `window.opener` (this window), so the bridge must accept its
|
||||
* `event.source` and reply to it. A getter so it tracks the live handle
|
||||
* without a reactive prop. Editor-only — the detached window runs the
|
||||
* same unsandboxed bundle as the inline preview.
|
||||
* same bundle, sandboxed or not, as the inline preview.
|
||||
*/
|
||||
extraSourceWindow?: () => Window | null | undefined
|
||||
}
|
||||
@@ -62,7 +62,11 @@
|
||||
if (!iframe || !sourceWindow) return
|
||||
if (sourceWindow !== iframe.contentWindow && sourceWindow !== detachedWindow) return
|
||||
|
||||
const data = event.data
|
||||
// A sandboxed detached preview speaks through a same-origin relay, wrapped.
|
||||
const data =
|
||||
sourceWindow === detachedWindow && event.data?.type === RAW_APP_PREVIEW_RELAY
|
||||
? event.data.data
|
||||
: event.data
|
||||
|
||||
// Reply to whichever window sent the request (inline iframe or the
|
||||
// detached preview), not a hardcoded target — otherwise the detached
|
||||
@@ -81,7 +85,7 @@
|
||||
result = e
|
||||
}
|
||||
|
||||
if (event.data.type == 'backend' || event.data.type == 'waitJob') {
|
||||
if (data.type == 'backend' || data.type == 'waitJob') {
|
||||
respond({ result, error })
|
||||
}
|
||||
if (editor) {
|
||||
@@ -101,7 +105,7 @@
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (event.data.type == 'backend' || event.data.type == 'backendAsync') {
|
||||
if (data.type == 'backend' || data.type == 'backendAsync') {
|
||||
const runnable_id = data.runnable_id
|
||||
let runnable = runnables[runnable_id]
|
||||
if (runnable) {
|
||||
@@ -152,7 +156,7 @@
|
||||
)
|
||||
launchedJobs.add(uuid)
|
||||
let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid }
|
||||
if (event.data.type == 'backendAsync') {
|
||||
if (data.type == 'backendAsync') {
|
||||
let result = uuid
|
||||
respond({ result })
|
||||
}
|
||||
@@ -169,20 +173,20 @@
|
||||
} else {
|
||||
console.error('No runnable found for', runnable_id)
|
||||
}
|
||||
} else if (event.data.type == 'waitJob') {
|
||||
} else if (data.type == 'waitJob') {
|
||||
if (gateJobIds && !launchedJobs.has(data.jobId)) {
|
||||
respond({ result: { message: 'Unknown job' }, error: true })
|
||||
return
|
||||
}
|
||||
await respondWithResult(data.jobId)
|
||||
} else if (event.data.type == 'getJob') {
|
||||
} else if (data.type == 'getJob') {
|
||||
if (gateJobIds && !launchedJobs.has(data.jobId)) {
|
||||
respond({ result: { message: 'Unknown job' }, error: true })
|
||||
return
|
||||
}
|
||||
const job = await JobService.getJob({ workspace, id: data.jobId })
|
||||
respond({ result: job })
|
||||
} else if (event.data.type == 'streamJob') {
|
||||
} else if (data.type == 'streamJob') {
|
||||
// Stream job results using SSE
|
||||
const jobId = data.jobId
|
||||
const reqId = data.reqId
|
||||
|
||||
@@ -256,11 +256,14 @@
|
||||
selectedRole = undefined
|
||||
}
|
||||
|
||||
export function openDrawer() {
|
||||
/** Opens on `at` when given, a data table and optionally one of its schemas. */
|
||||
export function openDrawer(at?: { datatable: string; schema?: string }) {
|
||||
resetSelection()
|
||||
selectedSchemaKey = undefined
|
||||
selectedSchemaKey = at?.schema
|
||||
selectedTableKey = undefined
|
||||
selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0])
|
||||
selectDatatable(
|
||||
at?.datatable ?? (datatables.current.includes('main') ? 'main' : datatables.current[0])
|
||||
)
|
||||
expand = false
|
||||
open = true
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@
|
||||
type RawAppRuntimeLogRequester,
|
||||
type RawAppRunSummary,
|
||||
type RawAppRunsProvider,
|
||||
type RawAppScreenshotRequester
|
||||
type RawAppScreenshotRequester,
|
||||
RAW_APP_SANDBOX_FLAGS,
|
||||
RAW_APP_PREVIEW_RELAY,
|
||||
rawAppStorageKey,
|
||||
readRawAppStorage,
|
||||
applyRawAppStorageOp
|
||||
} from './utils'
|
||||
import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom'
|
||||
import InlineElementPrompt from './InlineElementPrompt.svelte'
|
||||
@@ -72,7 +77,7 @@
|
||||
appDatatableRole
|
||||
} from './dataTableRefUtils'
|
||||
import { datatableReference } from '../dbTypes'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { randomSecret, randomUUID } from '$lib/utils/uuid'
|
||||
import { editorFontSize } from '$lib/editorFontSize.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
@@ -370,11 +375,11 @@
|
||||
|
||||
let iframe: HTMLIFrameElement | undefined = $state(undefined)
|
||||
const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html'
|
||||
// The served shell is `CSP: sandbox` (opaque origin), but the editor reads the
|
||||
// preview's DOM and feeds it builds as a same-origin document, so it loads a
|
||||
// per-mount blob: copy instead — the same split as `unsandboxedRawAppHtml`, and
|
||||
// like it with no `<base>`, so the app resolves URLs as it will once deployed.
|
||||
let previewShellUrl: string | undefined = $state(undefined)
|
||||
// The served shell is `CSP: sandbox` (opaque origin). An app without sandbox
|
||||
// isolation runs same-origin once deployed, so its preview loads a per-mount
|
||||
// blob: copy instead — the same split as `unsandboxedRawAppHtml`, and like it
|
||||
// with no `<base>`, so the app resolves URLs as it will once deployed.
|
||||
let previewBlobUrl: string | undefined = $state(undefined)
|
||||
onMount(() => {
|
||||
let url: string | undefined
|
||||
let destroyed = false
|
||||
@@ -386,7 +391,7 @@
|
||||
.then((html) => {
|
||||
if (destroyed) return
|
||||
url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
||||
previewShellUrl = url
|
||||
previewBlobUrl = url
|
||||
})
|
||||
.catch((e) => sendUserToast(`Could not load the app preview: ${e}`, true))
|
||||
return () => {
|
||||
@@ -394,8 +399,50 @@
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
// An app with sandbox isolation previews the way it runs once deployed: in the
|
||||
// served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce
|
||||
// in its URL authenticates its `windmill:ready`, as in `RawAppPreview`.
|
||||
const sandboxed = $derived(policy?.sandbox === true)
|
||||
const previewStorageKey = $derived(rawAppStorageKey(opWorkspace ?? '', path, true))
|
||||
const handshakeNonce = randomSecret()
|
||||
const sandboxedShellUrl = `${PREVIEW_SHELL_URL}?wm_hs=${handshakeNonce}`
|
||||
const previewShellUrl = $derived(sandboxed ? sandboxedShellUrl : previewBlobUrl)
|
||||
// The pop-out of a sandboxed preview: a same-origin page framing the shell. A
|
||||
// top-level window can't be sandboxed by its opener, and the opener can't address
|
||||
// an opaque-origin window safely. It relays the frame's messages, and their
|
||||
// ports, to the editor, and the editor's replies back down. Relayed messages
|
||||
// carry our origin, so they travel wrapped: listeners that trust a same-origin
|
||||
// message must not see the isolated app's as their own.
|
||||
let previewControllerUrl: string | undefined = undefined
|
||||
onDestroy(() => {
|
||||
if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl)
|
||||
})
|
||||
function getPreviewControllerUrl(): string {
|
||||
previewControllerUrl ??= URL.createObjectURL(
|
||||
new Blob(
|
||||
[
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>App preview</title>
|
||||
<link rel="icon" href="${window.location.origin}/logo.svg">
|
||||
<style>html,body{margin:0;height:100%}iframe{display:block;border:0;width:100%;height:100%}</style>
|
||||
<script>
|
||||
addEventListener('message', function (e) {
|
||||
var frame = document.querySelector('iframe').contentWindow
|
||||
if (e.source === frame && opener) opener.postMessage({ type: '${RAW_APP_PREVIEW_RELAY}', data: e.data }, location.origin, e.ports)
|
||||
else if (e.source === opener) frame.postMessage(e.data, '*')
|
||||
})
|
||||
<\/script></head><body><iframe src="${window.location.origin}${sandboxedShellUrl}" sandbox="${RAW_APP_SANDBOX_FLAGS}" referrerpolicy="no-referrer"></iframe></body></html>`
|
||||
],
|
||||
{ type: 'text/html' }
|
||||
)
|
||||
)
|
||||
return previewControllerUrl
|
||||
}
|
||||
let previewIframe: HTMLIFrameElement | undefined = $state(undefined)
|
||||
let previewIframeLoaded = $state(false)
|
||||
// Sandboxed only: the handshake's port, the one channel to the preview that
|
||||
// can't reach a document app code navigated the frame to.
|
||||
let previewPort: MessagePort | undefined = undefined
|
||||
let externalPreviewPort: MessagePort | undefined = undefined
|
||||
let lastBuild: { css: string; js: string } | undefined = undefined
|
||||
// Detached preview tab/window rendering the same app-preview bundle as the
|
||||
// inline pane. Kept live-synced: every build is replayed into it until the
|
||||
@@ -1361,6 +1408,33 @@
|
||||
return
|
||||
}
|
||||
|
||||
const relayed = e.source === externalPreviewWindow && e.data?.type === RAW_APP_PREVIEW_RELAY
|
||||
const data = relayed ? e.data.data : e.data
|
||||
// A sandboxed shell came up, inline or (relayed by its controller) popped out.
|
||||
if (data?.type === 'windmill:ready') {
|
||||
const port = e.ports?.[0]
|
||||
if (!sandboxed || !port || data.nonce !== handshakeNonce) return
|
||||
if (e.source === previewIframe?.contentWindow) {
|
||||
previewPort = port
|
||||
onPreviewShellReady()
|
||||
} else if (relayed) {
|
||||
externalPreviewPort = port
|
||||
externalPreviewReady = true
|
||||
feedExternalPreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
// A sandboxed app's localStorage write, inline or popped out.
|
||||
if (
|
||||
data?.type === 'wm_ls_op' &&
|
||||
sandboxed &&
|
||||
(relayed || e.source === previewIframe?.contentWindow)
|
||||
) {
|
||||
applyRawAppStorageOp(previewStorageKey, data)
|
||||
return
|
||||
}
|
||||
if (relayed) return
|
||||
|
||||
// Two children speak to us now: the UI Builder iframe (source editor)
|
||||
// and the preview iframe (rendered user app). Gate by source so they
|
||||
// can't be confused or spoofed.
|
||||
@@ -1402,8 +1476,20 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (fromPreview && e.data.type === 'runtimeLogsResponse') {
|
||||
resolvePendingRuntimeLogRequest(e.data.requestId, normalizeRawAppRuntimeLogs(e.data.logs))
|
||||
if (fromPreview && typeof e.data.requestId === 'string') {
|
||||
resolvePendingPreviewRequest(e.data.requestId, e.data)
|
||||
return
|
||||
}
|
||||
if (fromPreview && e.data.type === 'previewScroll') {
|
||||
updateInlinePromptPos()
|
||||
return
|
||||
}
|
||||
// Escape inside the preview exits inspect mode, and also dismisses a lingering
|
||||
// green "selected" overlay after a pick (which auto-disables hover).
|
||||
if (fromPreview && e.data.type === 'previewEscape') {
|
||||
if (inspectorEnabled || inspectorElement || selectedDomSelectors.length > 0) {
|
||||
disableInspector()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1525,29 +1611,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Our own origin, or the handshake port when sandboxed — never '*': user app code
|
||||
// can navigate the preview elsewhere, and the build can carry app source/secrets.
|
||||
function postToPreview(msg: Record<string, unknown>) {
|
||||
if (sandboxed) previewPort?.postMessage(msg)
|
||||
else previewIframe?.contentWindow?.postMessage(msg, window.location.origin)
|
||||
}
|
||||
|
||||
function postToExternalPreview(msg: Record<string, unknown>) {
|
||||
if (!externalPreviewWindow || externalPreviewWindow.closed) {
|
||||
externalPreviewWindow = null
|
||||
externalPreviewReady = false
|
||||
return
|
||||
}
|
||||
// Restrict to our own origin: the detached window loads same-origin
|
||||
// app-preview.html, but user app code can navigate it elsewhere — don't
|
||||
// post the build (potential app source/secrets) to a cross-origin doc.
|
||||
externalPreviewWindow.postMessage(msg, window.location.origin)
|
||||
if (sandboxed) externalPreviewPort?.postMessage(msg)
|
||||
else externalPreviewWindow.postMessage(msg, window.location.origin)
|
||||
}
|
||||
|
||||
// `app-preview.html` evaluates the js we post, so prefixing the env is what a
|
||||
// bundled `windmill-client` needs — it reads `window.process.env` at module
|
||||
// load. Gated and scoped exactly like a deployed app — sandbox off or no
|
||||
// scopes means no env at all — so preview hits the same 403s, and the same
|
||||
// misconfiguration, as the deployed bundle.
|
||||
|
||||
// Stated on every payload, tokenless included: the preview shell reuses one
|
||||
// window across builds, so omitting it would leave an old token in place.
|
||||
// Deleting rather than blanking matches a deployed app with no scopes.
|
||||
const NO_SDK_ENV_JS = 'try { delete window.process } catch (_) {}\n'
|
||||
let previewSdkEnvJs = $state(NO_SDK_ENV_JS)
|
||||
// The shell turns this into `window.process.env`, which a bundled
|
||||
// `windmill-client` reads at module load. Gated and scoped exactly like a
|
||||
// deployed app — sandbox off or no scopes means no env at all — so preview hits
|
||||
// the same 403s, and the same misconfiguration, as the deployed bundle. Sent with
|
||||
// every build, tokenless included: the shell reuses one realm across builds, so
|
||||
// omitting it would leave an old token in place.
|
||||
type PreviewSdk = { token: string; baseUrl: string; workspace: string }
|
||||
let previewSdk: PreviewSdk | undefined = undefined
|
||||
// Identifies the request whose answer is still wanted. Toggling scopes starts a
|
||||
// new mint while an older one is in flight, and an out-of-order answer would
|
||||
// otherwise hand the preview the wrong scope set — or restore a token after all
|
||||
@@ -1562,24 +1650,32 @@
|
||||
* realm, so only a reload drops the old bundle's timers, listeners and the
|
||||
* token its client captured at module load. */
|
||||
function restartPreviewRealm() {
|
||||
if (!lastBuild || !previewShellUrl) return // nothing running yet
|
||||
previewIframeLoaded = false
|
||||
if (previewIframe) previewIframe.src = previewShellUrl
|
||||
if (!previewShellUrl) return
|
||||
if (lastBuild) {
|
||||
previewIframeLoaded = false
|
||||
previewPort = undefined
|
||||
if (previewIframe) previewIframe.src = previewShellUrl
|
||||
}
|
||||
// Even with nothing built yet: toggling isolation swaps the kind of page the
|
||||
// detached window must hold, and only this puts the right one there.
|
||||
if (externalPreviewWindow && !externalPreviewWindow.closed) {
|
||||
externalPreviewReady = false
|
||||
externalPreviewPort = undefined
|
||||
// User app code can navigate this window elsewhere, which makes its
|
||||
// location cross-origin and unreachable — that document holds no token.
|
||||
try {
|
||||
externalPreviewWindow.location.replace(previewShellUrl)
|
||||
externalPreviewWindow.location.replace(
|
||||
sandboxed ? getPreviewControllerUrl() : previewShellUrl
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle the credential and let the app start: the reloaded shells replay the
|
||||
* build themselves (the iframe from its `load` handler, the detached window
|
||||
* from `appPreviewReady`), so this only covers a shell already back up. */
|
||||
function applyPreviewSdkEnv(js: string) {
|
||||
previewSdkEnvJs = js
|
||||
* build themselves (on `load` or their handshake), so this only covers a shell
|
||||
* already back up. */
|
||||
function applyPreviewSdk(sdk: PreviewSdk | undefined) {
|
||||
previewSdk = sdk
|
||||
previewSdkPending = false
|
||||
if (lastBuild) feedPreviewIframe(lastBuild)
|
||||
syncExternalPreview()
|
||||
@@ -1588,9 +1684,10 @@
|
||||
$effect(() => {
|
||||
// Frontend SDK access is sandbox-only, so isolation off gets no credential
|
||||
// here either, however the policy's scope list reads.
|
||||
const scopes = policy?.sandbox === true ? (policy?.frontend_sdk_scopes ?? []) : []
|
||||
const scopes = sandboxed ? (policy?.frontend_sdk_scopes ?? []) : []
|
||||
const ws = opWorkspace
|
||||
const key = `${ws ?? ''}|${scopes.join(',')}`
|
||||
// `sandboxed` too: toggling it swaps the shell, which is a new realm.
|
||||
const key = `${sandboxed}|${ws ?? ''}|${scopes.join(',')}`
|
||||
if (key === previewSdkKey) return
|
||||
previewSdkKey = key
|
||||
// Drop the old credential before asking for its replacement, never after:
|
||||
@@ -1599,7 +1696,7 @@
|
||||
// a token for the workspace we just left.
|
||||
const willMint = scopes.length > 0 && !!ws
|
||||
previewSdkPending = willMint
|
||||
previewSdkEnvJs = NO_SDK_ENV_JS
|
||||
previewSdk = undefined
|
||||
restartPreviewRealm()
|
||||
if (willMint) mintPreviewSdkToken(scopes, ws, key)
|
||||
})
|
||||
@@ -1611,20 +1708,13 @@
|
||||
requestBody: { path, scopes }
|
||||
})
|
||||
if (key !== previewSdkKey) return
|
||||
applyPreviewSdkEnv(
|
||||
`window.process = { env: ${JSON.stringify({
|
||||
WM_RAW_APP: 'true',
|
||||
WM_TOKEN: token,
|
||||
BASE_URL: window.location.origin,
|
||||
WM_WORKSPACE: ws
|
||||
}).replace(/</g, '\\u003c')} };\n`
|
||||
)
|
||||
applyPreviewSdk({ token, baseUrl: window.location.origin, workspace: ws })
|
||||
} catch (e) {
|
||||
// Already tokenless — the effect cleared the env before calling us — so
|
||||
// this only releases the build. The key stays set, so a failed mint is not
|
||||
// retried until the scopes or workspace actually change.
|
||||
console.warn('Could not mint a preview SDK token', e)
|
||||
if (key === previewSdkKey) applyPreviewSdkEnv(NO_SDK_ENV_JS)
|
||||
if (key === previewSdkKey) applyPreviewSdk(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1634,7 +1724,9 @@
|
||||
postToExternalPreview({
|
||||
type: 'preview',
|
||||
css: lastBuild.css,
|
||||
js: previewSdkEnvJs + lastBuild.js
|
||||
js: lastBuild.js,
|
||||
sdk: previewSdk,
|
||||
storage: previewStorage()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1645,16 +1737,23 @@
|
||||
function feedPreviewIframe(build: { css: string; js: string }) {
|
||||
// Between dropping a credential and settling its replacement the shell stays
|
||||
// blank; whichever settles last — the mint or the shell's own `load` — starts
|
||||
// the app. Same for a shell still reloading: its `load` handler replays.
|
||||
// the app. Same for a shell still reloading: it replays once ready.
|
||||
if (previewSdkPending || !previewIframeLoaded) return
|
||||
runtimeError = undefined
|
||||
emptyRender = false
|
||||
// Same-origin app-preview.html, and the payload now carries a token — address
|
||||
// it to our origin rather than '*', as the detached preview already does.
|
||||
previewIframe?.contentWindow?.postMessage(
|
||||
{ type: 'preview', css: build.css, js: previewSdkEnvJs + build.js },
|
||||
window.location.origin
|
||||
)
|
||||
postToPreview({
|
||||
type: 'preview',
|
||||
css: build.css,
|
||||
js: build.js,
|
||||
sdk: previewSdk,
|
||||
storage: previewStorage()
|
||||
})
|
||||
}
|
||||
|
||||
// The shell seeds a fresh document's storage shim from this; unsandboxed
|
||||
// previews use real storage and get none.
|
||||
function previewStorage() {
|
||||
return sandboxed ? readRawAppStorage(previewStorageKey) : undefined
|
||||
}
|
||||
|
||||
// Full (re)feed of the detached window: theme first, then the build. Used
|
||||
@@ -1687,13 +1786,18 @@
|
||||
sendUserToast('The app preview is not available', true)
|
||||
return
|
||||
}
|
||||
const win = window.open(previewShellUrl, `windmillRawAppPreview:${encodeURIComponent(path)}`)
|
||||
const win = window.open(
|
||||
sandboxed ? getPreviewControllerUrl() : previewShellUrl,
|
||||
`windmillRawAppPreview:${encodeURIComponent(path)}`
|
||||
)
|
||||
if (!win) {
|
||||
sendUserToast('Could not open the preview window (popup blocked?)', true)
|
||||
return
|
||||
}
|
||||
externalPreviewWindow = win
|
||||
externalPreviewReady = false
|
||||
// The sandboxed shell announces itself through its controller's relay.
|
||||
if (sandboxed) return
|
||||
// Initial feed: fires once when the freshly opened tab loads. This is the
|
||||
// only feed path against an app-preview.html that predates the
|
||||
// `appPreviewReady` handshake, so the window isn't blank on first open
|
||||
@@ -1722,22 +1826,38 @@
|
||||
})
|
||||
}
|
||||
|
||||
const RUNTIME_LOGS_TIMEOUT_MS = 2000
|
||||
type PendingRuntimeLogRequest = {
|
||||
resolve: (entries: RawAppRuntimeLogEntry[] | undefined) => void
|
||||
// The preview may be an opaque-origin document, so everything the editor reads
|
||||
// from it is a request the shell answers, correlated by requestId.
|
||||
const PREVIEW_REQUEST_TIMEOUT_MS = 2000
|
||||
const SCREENSHOT_TIMEOUT_MS = 30_000
|
||||
type PendingPreviewRequest = {
|
||||
resolve: (response: any) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
const pendingRuntimeLogReqs = new Map<string, PendingRuntimeLogRequest>()
|
||||
const pendingPreviewReqs = new Map<string, PendingPreviewRequest>()
|
||||
|
||||
function resolvePendingRuntimeLogRequest(
|
||||
requestId: string,
|
||||
entries: RawAppRuntimeLogEntry[] | undefined
|
||||
) {
|
||||
const pending = pendingRuntimeLogReqs.get(requestId)
|
||||
function resolvePendingPreviewRequest(requestId: string, response: any) {
|
||||
const pending = pendingPreviewReqs.get(requestId)
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timer)
|
||||
pendingRuntimeLogReqs.delete(requestId)
|
||||
pending.resolve(entries)
|
||||
pendingPreviewReqs.delete(requestId)
|
||||
pending.resolve(response)
|
||||
}
|
||||
|
||||
/** Resolves with the shell's answer, or `undefined` when there is no loaded
|
||||
* preview to ask or it didn't answer in time. */
|
||||
function requestFromPreview(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = PREVIEW_REQUEST_TIMEOUT_MS
|
||||
): Promise<any> {
|
||||
if (!previewIframe || !previewIframeLoaded) return Promise.resolve(undefined)
|
||||
const requestId = randomUUID()
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolvePendingPreviewRequest(requestId, undefined), timeoutMs)
|
||||
pendingPreviewReqs.set(requestId, { resolve, timer })
|
||||
postToPreview({ type, requestId, ...payload })
|
||||
})
|
||||
}
|
||||
|
||||
const requestRuntimeLogs: RawAppRuntimeLogRequester = async (limit) => {
|
||||
@@ -1753,44 +1873,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function requestPreviewConsoleLogs(limit: number) {
|
||||
const win = previewIframe?.contentWindow
|
||||
if (!win || !previewIframeLoaded) return Promise.resolve(undefined)
|
||||
const requestId = randomUUID()
|
||||
return new Promise<RawAppRuntimeLogEntry[] | undefined>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolvePendingRuntimeLogRequest(requestId, undefined)
|
||||
}, RUNTIME_LOGS_TIMEOUT_MS)
|
||||
pendingRuntimeLogReqs.set(requestId, { resolve, timer })
|
||||
win.postMessage({ type: 'getRuntimeLogs', requestId, limit }, '*')
|
||||
})
|
||||
async function requestPreviewConsoleLogs(
|
||||
limit: number
|
||||
): Promise<RawAppRuntimeLogEntry[] | undefined> {
|
||||
const res = await requestFromPreview('getRuntimeLogs', { limit })
|
||||
return res ? normalizeRawAppRuntimeLogs(res.logs) : undefined
|
||||
}
|
||||
|
||||
// Live DOM inspection for the session chat. Same-origin: the preview iframe is a
|
||||
// same-origin document (see the load listener below that reads its contentWindow),
|
||||
// so we read `contentDocument` directly — no postMessage, no ui_builder change. The
|
||||
// element is re-read on every call, so the model always sees the current render.
|
||||
const requestDomQuery: RawAppDomRequester = async (query: RawAppDomQuery) => {
|
||||
const doc = previewIframe?.contentDocument
|
||||
if (!doc || !previewIframeLoaded) return undefined
|
||||
const selector = query.selector?.trim()
|
||||
let el: Element | null
|
||||
let matchCount: number
|
||||
if (selector) {
|
||||
let matches: NodeListOf<Element>
|
||||
try {
|
||||
matches = doc.querySelectorAll(selector)
|
||||
} catch (e) {
|
||||
return {
|
||||
text: `Invalid CSS selector "${selector}": ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
}
|
||||
matchCount = matches.length
|
||||
el = matches[0] ?? null
|
||||
} else {
|
||||
el = doc.body
|
||||
matchCount = el ? 1 : 0
|
||||
/** Rebuilds the element the shell serialized, in an inert document: its scripts
|
||||
* don't run and its resources don't load. `<template>` keeps context-bound tags
|
||||
* (`<td>`, `<li>`) intact but drops `<html>`/`<body>`, which DOMParser keeps. */
|
||||
function parsePreviewElement(outerHTML: string, tagName: string): Element | null {
|
||||
if (tagName === 'html' || tagName === 'body' || tagName === 'head') {
|
||||
const doc = new DOMParser().parseFromString(outerHTML, 'text/html')
|
||||
return tagName === 'html' ? doc.documentElement : doc[tagName]
|
||||
}
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = outerHTML
|
||||
return template.content.firstElementChild
|
||||
}
|
||||
|
||||
// Live DOM inspection for the session chat. The element is re-read on every call,
|
||||
// so the model always sees the current render.
|
||||
const requestDomQuery: RawAppDomRequester = async (query: RawAppDomQuery) => {
|
||||
const selector = query.selector?.trim()
|
||||
const res = await requestFromPreview('domRead', { selector })
|
||||
if (!res) return undefined
|
||||
if (typeof res.error === 'string') {
|
||||
return { text: `Invalid CSS selector "${selector}": ${res.error}` }
|
||||
}
|
||||
const matchCount: number = res.matchCount ?? 0
|
||||
const el =
|
||||
typeof res.outerHTML === 'string' ? parsePreviewElement(res.outerHTML, res.tagName) : null
|
||||
if (!el) {
|
||||
return {
|
||||
text: `No element matches selector "${selector}". It may not be rendered yet, or the selector is wrong. Try a broader selector or omit it to read the whole page.`
|
||||
@@ -1839,7 +1953,7 @@
|
||||
// ---- Inline "prompt this element" mini-composer (session preview only) ----
|
||||
// Anchored over the most-recently selected element in the live preview. It's a
|
||||
// host overlay (position: fixed in the top document) computed from the element's
|
||||
// rect inside the same-origin preview iframe, repositioned as the preview scrolls.
|
||||
// rect inside the preview iframe, repositioned as the preview scrolls.
|
||||
let inlinePromptDismissed = $state(false)
|
||||
let inlinePromptPos = $state<{ x: number; y: number } | undefined>(undefined)
|
||||
let inlinePromptLabel = $state('')
|
||||
@@ -1849,38 +1963,33 @@
|
||||
: undefined
|
||||
)
|
||||
|
||||
function shortElementLabel(el: Element): string {
|
||||
function shortElementLabel(el: { tagName: string; id: string; className: string }): string {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const id = el.id ? `#${el.id}` : ''
|
||||
const cls =
|
||||
typeof el.className === 'string'
|
||||
? el.className
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((c) => c && !c.startsWith('inspector-'))[0]
|
||||
: undefined
|
||||
const cls = el.className
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((c) => c && !c.startsWith('inspector-'))[0]
|
||||
return `${tag}${id}${cls ? `.${cls}` : ''}`
|
||||
}
|
||||
|
||||
function updateInlinePromptPos() {
|
||||
// Scrolls ask faster than the shell answers; only the latest answer is applied.
|
||||
let inlinePromptPosSeq = 0
|
||||
async function updateInlinePromptPos() {
|
||||
const seq = ++inlinePromptPosSeq
|
||||
const sel = inlinePromptSelector
|
||||
const doc = previewIframe?.contentDocument
|
||||
if (!sel || !doc || !previewIframeLoaded || inlinePromptDismissed) {
|
||||
if (!sel || !previewIframe || !previewIframeLoaded || inlinePromptDismissed) {
|
||||
inlinePromptPos = undefined
|
||||
return
|
||||
}
|
||||
let el: Element | null
|
||||
try {
|
||||
el = doc.querySelector(sel)
|
||||
} catch (_) {
|
||||
el = null
|
||||
}
|
||||
if (!el) {
|
||||
const el = await requestFromPreview('elementRect', { selector: sel })
|
||||
if (seq !== inlinePromptPosSeq || !previewIframe) return
|
||||
if (!el?.rect) {
|
||||
inlinePromptPos = undefined
|
||||
return
|
||||
}
|
||||
const r = el.getBoundingClientRect()
|
||||
const ir = previewIframe!.getBoundingClientRect()
|
||||
const r = el.rect
|
||||
const ir = previewIframe.getBoundingClientRect()
|
||||
// Hide while the element is scrolled outside the preview's visible area.
|
||||
if (r.bottom < 0 || r.top > ir.height || r.right < 0 || r.left > ir.width) {
|
||||
inlinePromptPos = undefined
|
||||
@@ -1913,20 +2022,18 @@
|
||||
|
||||
$effect(() => {
|
||||
// Recompute on selection change, preview (re)load, or dismiss; and keep the
|
||||
// overlay pinned as the preview (or the host) scrolls/resizes.
|
||||
// overlay pinned as the host scrolls/resizes. The preview's own scrolls arrive
|
||||
// as `previewScroll` messages.
|
||||
inlinePromptSelector
|
||||
selectedDomSelectors
|
||||
previewIframeLoaded
|
||||
inlinePromptDismissed
|
||||
updateInlinePromptPos()
|
||||
if (!previewIframe || !previewIframeLoaded) return
|
||||
const win = previewIframe.contentWindow
|
||||
const onScroll = () => updateInlinePromptPos()
|
||||
win?.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
return () => {
|
||||
win?.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
@@ -1950,111 +2057,41 @@
|
||||
return out.reverse()
|
||||
}
|
||||
|
||||
// Only values whose non-wrapping counterpart collapses whitespace identically.
|
||||
// `pre-line`/`break-spaces` have no such counterpart: forcing them to nowrap
|
||||
// would eat their preserved newlines, so they are left to re-wrap.
|
||||
const NON_WRAPPING_EQUIVALENT: Record<string, string> = {
|
||||
normal: 'nowrap',
|
||||
'pre-wrap': 'pre'
|
||||
}
|
||||
|
||||
// getClientRects yields a rect per contained node, not per line box, so the
|
||||
// count alone says nothing: `a <b>b</b>` is two rects on one line. Rects
|
||||
// sharing a line overlap vertically, and `top` alone would split a line that
|
||||
// mixes font sizes — so count vertically disjoint runs.
|
||||
function countLines(range: Range): number {
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 || r.height > 0)
|
||||
if (rects.length === 0) return 0
|
||||
rects.sort((a, b) => a.top - b.top)
|
||||
let lines = 1
|
||||
let lineBottom = rects[0].bottom
|
||||
for (const r of rects) {
|
||||
if (r.top >= lineBottom) {
|
||||
lines++
|
||||
lineBottom = r.bottom
|
||||
} else {
|
||||
lineBottom = Math.max(lineBottom, r.bottom)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// A box that shrink-wraps its text can have zero sub-pixel slack (a 208.59px box
|
||||
// holding a 208.59px text run). The capture re-runs layout in whole pixels, so
|
||||
// the text no longer fits, wraps, and is then clipped out of the box entirely.
|
||||
// Pinning runs that are already single-line is a no-op on the live DOM but stops
|
||||
// the re-layout from re-deciding where they break.
|
||||
function pinSingleLineText(root: HTMLElement): () => void {
|
||||
const doc = root.ownerDocument
|
||||
const view = doc.defaultView
|
||||
if (!view) return () => {}
|
||||
// Measure every candidate before mutating any of them: interleaving reads and
|
||||
// writes forces a synchronous reflow per element.
|
||||
const pending: Array<[HTMLElement, string]> = []
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) {
|
||||
const el = node as HTMLElement
|
||||
if (!(el instanceof view.HTMLElement)) continue
|
||||
const hasOwnText = Array.from(el.childNodes).some(
|
||||
(c) => c.nodeType === Node.TEXT_NODE && (c.textContent ?? '').trim() !== ''
|
||||
)
|
||||
if (!hasOwnText) continue
|
||||
const replacement = NON_WRAPPING_EQUIVALENT[view.getComputedStyle(el).whiteSpace]
|
||||
if (!replacement) continue
|
||||
const range = doc.createRange()
|
||||
range.selectNodeContents(el)
|
||||
if (countLines(range) !== 1) continue // already wraps — leave its breaks alone
|
||||
pending.push([el, replacement])
|
||||
}
|
||||
const restores = pending.map(([el, replacement]) => {
|
||||
const prev = el.style.getPropertyValue('white-space')
|
||||
const prio = el.style.getPropertyPriority('white-space')
|
||||
el.style.setProperty('white-space', replacement, 'important')
|
||||
return () => {
|
||||
if (prev) el.style.setProperty('white-space', prev, prio)
|
||||
else el.style.removeProperty('white-space')
|
||||
}
|
||||
})
|
||||
return () => restores.forEach((r) => r())
|
||||
}
|
||||
|
||||
// Capture the live preview as a PNG data URL. The preview iframe (the blob: copy
|
||||
// of the shell) is same-origin with no sandbox, so its rendered
|
||||
// document is reachable and can be serialized from here. There is no native
|
||||
// element-screenshot API; modern-screenshot reconstructs the DOM into an SVG
|
||||
// foreignObject, so a WebGL canvas is only captured when its context was created
|
||||
// with preserveDrawingBuffer. Lazy-imported so the library only loads on demand.
|
||||
// Capture the live preview as a PNG data URL, taken by the shell with
|
||||
// modern-screenshot: there is no native element-screenshot API, so it
|
||||
// reconstructs the DOM into an SVG foreignObject, and a WebGL canvas is only
|
||||
// captured when its context was created with preserveDrawingBuffer.
|
||||
const captureScreenshot: RawAppScreenshotRequester = async () => {
|
||||
const target = previewIframe?.contentDocument?.body
|
||||
if (!previewIframe || !previewIframeLoaded || !target) {
|
||||
throw new Error('App preview is not ready')
|
||||
}
|
||||
const body = await requestFromPreview('elementRect', { selector: 'body' })
|
||||
if (!body?.rect) throw new Error('App preview is not ready')
|
||||
const { width, height } = body.rect
|
||||
// Collapsing the preview leaves the iframe mounted and populated at zero
|
||||
// width, which passes every check above and then fails inside the rasteriser
|
||||
// as an opaque decode error. Name the cause so the agent can act on it.
|
||||
if (!target.clientWidth || !target.clientHeight) {
|
||||
// width, which then fails inside the rasteriser as an opaque decode error.
|
||||
// Name the cause so the agent can act on it.
|
||||
if (!width || !height) {
|
||||
throw new Error(
|
||||
'The app preview is collapsed, so there is nothing to capture. Ask the user to expand the preview panel, then try again.'
|
||||
)
|
||||
}
|
||||
const { domToPng } = await import('modern-screenshot')
|
||||
// Above CSS resolution for small previews (a 1× capture of a ~900px preview
|
||||
// reads blurry next to the live render), sub-1× for oversized bodies — see
|
||||
// captureScale. maximumCanvasSize is the belt over that math: the rasterised
|
||||
// box can exceed the body's client size, and an unbounded canvas on a tall
|
||||
// scrolling app can freeze the tab before normalize ever bounds the pixels.
|
||||
const scale = captureScale(Math.max(target.clientWidth, target.clientHeight))
|
||||
const restore = pinSingleLineText(target)
|
||||
try {
|
||||
return await domToPng(target, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale,
|
||||
maximumCanvasSize: MAX_IMAGE_EDGE
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
// box can exceed the body's size, and an unbounded canvas on a tall scrolling
|
||||
// app can freeze the tab before normalize ever bounds the pixels.
|
||||
const res = await requestFromPreview(
|
||||
'screenshot',
|
||||
{
|
||||
options: {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: captureScale(Math.max(width, height)),
|
||||
maximumCanvasSize: MAX_IMAGE_EDGE
|
||||
}
|
||||
},
|
||||
SCREENSHOT_TIMEOUT_MS
|
||||
)
|
||||
if (!res) throw new Error('The app preview did not return a screenshot')
|
||||
if (typeof res.error === 'string') throw new Error(res.error)
|
||||
return res.dataUrl
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -2067,8 +2104,8 @@
|
||||
onRunsProvider?.(undefined)
|
||||
onDomRequester?.(undefined)
|
||||
onScreenshotRequester?.(undefined)
|
||||
for (const requestId of Array.from(pendingRuntimeLogReqs.keys()))
|
||||
resolvePendingRuntimeLogRequest(requestId, undefined)
|
||||
for (const requestId of Array.from(pendingPreviewReqs.keys()))
|
||||
resolvePendingPreviewRequest(requestId, undefined)
|
||||
editorDestroyed = true
|
||||
buildTracker.release()
|
||||
}
|
||||
@@ -2097,30 +2134,25 @@
|
||||
iframeLoaded = true
|
||||
})
|
||||
})
|
||||
function onPreviewShellReady() {
|
||||
previewIframeLoaded = true
|
||||
// Replay the last build so the preview repopulates without
|
||||
// waiting for the user to trigger another bundle.
|
||||
if (lastBuild) {
|
||||
feedPreviewIframe(lastBuild)
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
previewIframe?.addEventListener('load', () => {
|
||||
previewIframeLoaded = true
|
||||
// Replay the last build so the preview repopulates without
|
||||
// waiting for the user to trigger another bundle.
|
||||
if (lastBuild) {
|
||||
feedPreviewIframe(lastBuild)
|
||||
}
|
||||
// Escape inside the preview exits inspect mode — the keydown fires in
|
||||
// the iframe's document, so the parent window listener can't see it.
|
||||
// We also want Escape to dismiss a lingering green "selected" overlay
|
||||
// after the user picked an element (which auto-disables hover).
|
||||
previewIframe?.contentWindow?.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (
|
||||
e.key === 'Escape' &&
|
||||
(inspectorEnabled || inspectorElement || selectedDomSelectors.length > 0)
|
||||
) {
|
||||
disableInspector()
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
const el = previewIframe
|
||||
if (!el) return
|
||||
// A new frame (sandbox isolation toggled) starts unloaded, port-less.
|
||||
untrack(() => {
|
||||
previewIframeLoaded = false
|
||||
previewPort = undefined
|
||||
})
|
||||
// A sandboxed shell is ready on its handshake, not on `load`.
|
||||
el.addEventListener('load', () => {
|
||||
if (!sandboxed) onPreviewShellReady()
|
||||
})
|
||||
})
|
||||
$effect(() => {
|
||||
@@ -2132,11 +2164,8 @@
|
||||
'*'
|
||||
)
|
||||
}
|
||||
if (previewIframe && previewIframeLoaded) {
|
||||
previewIframe.contentWindow?.postMessage(
|
||||
{ type: 'setDarkMode', dark: darkMode, variant: darkVariant },
|
||||
'*'
|
||||
)
|
||||
if (previewIframeLoaded) {
|
||||
postToPreview({ type: 'setDarkMode', dark: darkMode, variant: darkVariant })
|
||||
}
|
||||
postToExternalPreview({ type: 'setDarkMode', dark: darkMode, variant: darkVariant })
|
||||
})
|
||||
@@ -2151,11 +2180,8 @@
|
||||
// the harness renders one highlight per selector. Re-posts on every
|
||||
// selection change and on preview (re)load.
|
||||
const selectors = selectedDomSelectors
|
||||
if (previewIframe && previewIframeLoaded) {
|
||||
previewIframe.contentWindow?.postMessage(
|
||||
{ type: 'inspectorSetSelection', selectors: [...selectors] },
|
||||
'*'
|
||||
)
|
||||
if (previewIframeLoaded) {
|
||||
postToPreview({ type: 'inspectorSetSelection', selectors: [...selectors] })
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
@@ -2179,7 +2205,7 @@
|
||||
function clearInspectorSelection() {
|
||||
inspectorElement = undefined
|
||||
// Inspector lives in the preview iframe, so clear its overlay there.
|
||||
previewIframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
|
||||
postToPreview({ type: 'inspectorClear' })
|
||||
}
|
||||
|
||||
// Folders aren't selectable in the tree, so this only ever gets files — except
|
||||
@@ -2307,8 +2333,8 @@
|
||||
// `inspectorDisable` only stops hover/click; the green "selected"
|
||||
// overlay from a prior pick persists until we explicitly clear it.
|
||||
// Escape should reset both, so the iframe goes back to its idle look.
|
||||
previewIframe?.contentWindow?.postMessage({ type: 'inspectorDisable' }, '*')
|
||||
previewIframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
|
||||
postToPreview({ type: 'inspectorDisable' })
|
||||
postToPreview({ type: 'inspectorClear' })
|
||||
inspectorElement = undefined
|
||||
// Session mode: clear the chips too. Emptying the selection hides the
|
||||
// inline prompt (it's driven by the chips) and drops the highlights (the
|
||||
@@ -2421,7 +2447,7 @@
|
||||
bind:jobsById
|
||||
{runnables}
|
||||
{path}
|
||||
gateJobIds={false}
|
||||
gateJobIds={sandboxed}
|
||||
extraSourceWindow={() => externalPreviewWindow}
|
||||
/>
|
||||
<div
|
||||
@@ -2693,10 +2719,7 @@
|
||||
disableInspector()
|
||||
} else {
|
||||
inspectorEnabled = true
|
||||
previewIframe?.contentWindow?.postMessage(
|
||||
{ type: 'inspectorEnable' },
|
||||
'*'
|
||||
)
|
||||
postToPreview({ type: 'inspectorEnable' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -2739,14 +2762,20 @@
|
||||
{/snippet}
|
||||
</DraggableTabs>
|
||||
<!-- Not mounted before the shell is: a src-less iframe fires `load`
|
||||
for about:blank, which would mark the preview ready. -->
|
||||
for about:blank, which would mark the preview ready. Keyed so
|
||||
toggling isolation builds a new frame: a frame's sandbox flags
|
||||
only apply to its next navigation. -->
|
||||
{#if previewShellUrl}
|
||||
<iframe
|
||||
bind:this={previewIframe}
|
||||
title="App preview"
|
||||
src={previewShellUrl}
|
||||
class="w-full flex-1 block"
|
||||
></iframe>
|
||||
{#key sandboxed}
|
||||
<iframe
|
||||
bind:this={previewIframe}
|
||||
title="App preview"
|
||||
src={previewShellUrl}
|
||||
sandbox={sandboxed ? RAW_APP_SANDBOX_FLAGS : undefined}
|
||||
referrerpolicy={sandboxed ? 'no-referrer' : undefined}
|
||||
class="w-full flex-1 block"
|
||||
></iframe>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if buildError}
|
||||
<!-- top-12 clears the tab bar; `before:bg-surface` backs the
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
|
||||
import type { Runnable } from './rawAppPolicy'
|
||||
import { getContext, onMount, untrack } from 'svelte'
|
||||
import { unsandboxedRawAppHtml } from './utils'
|
||||
import {
|
||||
RAW_APP_SANDBOX_FLAGS,
|
||||
applyRawAppStorageOp,
|
||||
rawAppStorageKey,
|
||||
readRawAppStorage,
|
||||
unsandboxedRawAppHtml
|
||||
} from './utils'
|
||||
import { randomSecret } from '$lib/utils/uuid'
|
||||
|
||||
// Per-mount secret proving a `windmill:ready` came from the document we loaded.
|
||||
@@ -59,11 +65,7 @@
|
||||
// adding no isolation). The sandboxed path keeps the restrictive attribute; the
|
||||
// wrapper document's `CSP: sandbox` response header enforces the opaque origin
|
||||
// regardless.
|
||||
let sandboxAttr = $derived(
|
||||
unsandboxed
|
||||
? undefined
|
||||
: 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation'
|
||||
)
|
||||
let sandboxAttr = $derived(unsandboxed ? undefined : RAW_APP_SANDBOX_FLAGS)
|
||||
|
||||
// WIN-2006: source of the bundle iframe.
|
||||
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
|
||||
@@ -131,7 +133,7 @@
|
||||
// Windmill embedder and would never answer the relay (leaving the bundle without
|
||||
// ctx). The snapshot is handed to the bundle before it evaluates so its
|
||||
// localStorage is hydrated synchronously.
|
||||
const SHARED_LS_KEY = `wm_apps_localstorage:${workspace}:${path}`
|
||||
const SHARED_LS_KEY = rawAppStorageKey(workspace, path)
|
||||
function storageAccessible(): boolean {
|
||||
try {
|
||||
localStorage.getItem(SHARED_LS_KEY)
|
||||
@@ -149,24 +151,6 @@
|
||||
let pendingNonce: string | undefined = undefined
|
||||
let pendingPort: MessagePort | undefined = undefined
|
||||
|
||||
function readDirect(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SHARED_LS_KEY) || '{}')
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDirectOp(d: any) {
|
||||
try {
|
||||
const s = readDirect()
|
||||
if (d.op === 'set') s[d.key] = String(d.value)
|
||||
else if (d.op === 'remove') delete s[d.key]
|
||||
else if (d.op === 'clear') for (const k in s) delete s[k]
|
||||
localStorage.setItem(SHARED_LS_KEY, JSON.stringify(s))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** The nonce authenticates the asker: it lives in our URL, which a document
|
||||
* navigated into the frame can't read. Replying on the port that document
|
||||
* transferred then keeps the answer from landing in whatever document is
|
||||
@@ -179,8 +163,9 @@
|
||||
const payload = {
|
||||
type: 'windmill:ctx',
|
||||
// Same shape as the unsandboxed wrapper: always the object, so
|
||||
// `window.ctx.workspace` works for anonymous viewers too.
|
||||
ctx: { ctx: user, workspace },
|
||||
// `window.ctx.workspace` works for anonymous viewers too. Snapshotted: a
|
||||
// caller's `$state` user is a proxy, which postMessage cannot clone.
|
||||
ctx: { ctx: $state.snapshot(user), workspace },
|
||||
initialHash,
|
||||
storage: { local: bundleStorage ?? {}, session: {} },
|
||||
// The wrapper turns this into `window.process.env` before it injects the
|
||||
@@ -234,7 +219,7 @@
|
||||
const nonceEcho = typeof data.nonce === 'string' ? data.nonce : undefined
|
||||
const port = event.ports?.[0]
|
||||
if (!framed) {
|
||||
bundleStorage = readDirect()
|
||||
bundleStorage = readRawAppStorage(SHARED_LS_KEY)
|
||||
respondCtx(nonceEcho, port)
|
||||
} else if (bundleStorage !== undefined) {
|
||||
respondCtx(nonceEcho, port)
|
||||
@@ -246,7 +231,7 @@
|
||||
} else if (data?.type === 'wm_ls_op') {
|
||||
// The bundle mutated localStorage — apply it to the shared store.
|
||||
if (!framed) {
|
||||
applyDirectOp(data)
|
||||
applyRawAppStorageOp(SHARED_LS_KEY, data)
|
||||
} else {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
List,
|
||||
Ban,
|
||||
ExternalLinkIcon,
|
||||
Loader2,
|
||||
Code,
|
||||
MessagesSquare,
|
||||
WandSparkles
|
||||
Database,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
|
||||
import type { Policy } from '$lib/gen'
|
||||
import { WorkerService, type Policy } from '$lib/gen'
|
||||
import MissingWorkerTagAlert from '$lib/components/jobs/MissingWorkerTagAlert.svelte'
|
||||
import { hasWorkerForTag, queuedWithoutWorkerMessage } from '$lib/components/jobs/missingWorker'
|
||||
import { superadmin, userStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -22,8 +22,7 @@
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
|
||||
import { Alert, Badge } from '$lib/components/common'
|
||||
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
|
||||
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { react18Template, react19Template, svelte5Template } from './templates'
|
||||
@@ -52,11 +51,10 @@
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, Runnable>
|
||||
data: RawAppData
|
||||
summary: string
|
||||
policy: Policy
|
||||
}
|
||||
|
||||
/** `ai` hands the new app to the AI right away; `code` opens the editor on the setup's choices. */
|
||||
/** Where the set-up app opens: `ai` hands it to the AI, `code` opens it in the editor. */
|
||||
export type RawAppBuildMode = 'ai' | 'code'
|
||||
|
||||
let {
|
||||
@@ -68,18 +66,16 @@
|
||||
} = $props()
|
||||
|
||||
const templates = [
|
||||
{ name: 'React 19', icon: 'tsx', files: react19Template },
|
||||
{ name: 'React 19', icon: 'tsx', files: react19Template, recommended: true },
|
||||
{ name: 'React 18', icon: 'tsx', files: react18Template },
|
||||
{ name: 'Svelte 5', icon: 'svelte', files: svelte5Template }
|
||||
]
|
||||
|
||||
let step = $state<'choice' | 'setup'>('choice')
|
||||
let selectedTemplateIndex = $state(0)
|
||||
let selectedDatatable = $state<string | undefined>(undefined)
|
||||
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
|
||||
let selectedSchema = $state<string | undefined>(undefined)
|
||||
let newSchemaName = $state('')
|
||||
let appSummary = $state('')
|
||||
let preWhitelistedTables = $state<DataTableRef[]>([])
|
||||
/** The role each pre-whitelisted table's data table was browsed as. */
|
||||
let preWhitelistedRoles = $state<Record<string, string>>({})
|
||||
@@ -271,12 +267,7 @@
|
||||
// would announce AI as unconfigured while it is merely unknown. Gate on the
|
||||
// config describing opWs, and load it here so the claim owns its own evidence.
|
||||
const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs)
|
||||
// Say where the card leads: the route hands the app to a fresh AI session for
|
||||
// everyone who has one, and opens the docked chat for the rest.
|
||||
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
|
||||
const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled)
|
||||
// The AI starts on the default data table and its role, so both must have answered.
|
||||
const dataSettled = $derived(hasNoDatatables || (rolesSettled && accessSettled))
|
||||
|
||||
$effect(() => {
|
||||
if (open && opWs && !aiConfigLoaded) {
|
||||
@@ -285,9 +276,11 @@
|
||||
})
|
||||
|
||||
// With AI turned off for the workspace there is nothing to choose between.
|
||||
const shownStep = $derived($copilotInfo.workspaceDisabled ? 'setup' : step)
|
||||
const choiceCardClasses =
|
||||
'h-full flex-col items-start justify-start gap-3 rounded-lg p-6 text-left whitespace-normal'
|
||||
const aiOffered = $derived(!$copilotInfo.workspaceDisabled)
|
||||
|
||||
const canStart = $derived(
|
||||
!!templates[selectedTemplateIndex] && !newSchemaAlreadyExists && rolesSettled && accessSettled
|
||||
)
|
||||
|
||||
function appPolicy(): Policy {
|
||||
return {
|
||||
@@ -299,60 +292,103 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The AI builds on the default template and the default data table: it picks the framework
|
||||
* and creates the tables it needs itself. */
|
||||
function buildWithAI() {
|
||||
const datatable = usableDatatable
|
||||
const role = rolesAnswered ? effectiveRole : undefined
|
||||
open = false
|
||||
onStart(
|
||||
{
|
||||
files: templates[0].files,
|
||||
runnables: {},
|
||||
data: {
|
||||
tables: [],
|
||||
datatable,
|
||||
schema: undefined,
|
||||
roles: datatable && role ? { [datatable]: role } : undefined
|
||||
},
|
||||
summary: '',
|
||||
policy: appPolicy()
|
||||
},
|
||||
'ai'
|
||||
const startCardClasses =
|
||||
'relative h-full flex-col items-start justify-start gap-1.5 rounded-lg p-3 text-left whitespace-normal'
|
||||
|
||||
async function createSchema(input: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
schema: string
|
||||
role: string | undefined
|
||||
migrationRole: string | undefined
|
||||
}) {
|
||||
try {
|
||||
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
|
||||
const dbOps = dbSchemaOpsWithPreviewScripts({
|
||||
workspace: input.workspace,
|
||||
input: {
|
||||
type: 'database',
|
||||
resourceType: 'postgresql',
|
||||
resourcePath: `datatable://${input.datatable}`,
|
||||
role: input.role,
|
||||
migrationRole: input.migrationRole
|
||||
}
|
||||
})
|
||||
await dbOps.onCreateSchema({ schema: input.schema })
|
||||
} catch (e) {
|
||||
console.error('Failed to create schema:', e)
|
||||
sendUserToast(`Failed to create schema ${input.schema}: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// The start that is in flight, and the schema it is waiting on: creating one is a job,
|
||||
// which can sit in the queue long enough for a silent modal to look dead.
|
||||
let starting = $state<{ mode: RawAppBuildMode; schema?: string } | undefined>(undefined)
|
||||
|
||||
const SCHEMA_JOB_TAG = 'postgresql'
|
||||
|
||||
/** Whether a worker can pick up the job that creates a schema. Only a definite "no"
|
||||
* counts: with per-workspace default tags the job runs on a variant of the tag, and a
|
||||
* failed lookup establishes nothing. */
|
||||
async function schemaJobsServed(workspace: string): Promise<boolean> {
|
||||
try {
|
||||
if (await WorkerService.isDefaultTagsPerWorkspace()) return true
|
||||
return await hasWorkerForTag(workspace, SCHEMA_JOB_TAG)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Existing tables go with an existing schema: under the other modes the list is not shown,
|
||||
// and what is not shown is not saved.
|
||||
const tablesToUse = $derived(schemaMode === 'existing' ? preWhitelistedTables : [])
|
||||
|
||||
function exploreDatatable() {
|
||||
dataTableDrawer?.openDrawer(
|
||||
selectedDatatable
|
||||
? {
|
||||
datatable: selectedDatatable,
|
||||
schema: schemaMode === 'existing' ? selectedSchema : undefined
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
async function start() {
|
||||
async function start(mode: RawAppBuildMode) {
|
||||
if (starting) return
|
||||
const template = templates[selectedTemplateIndex]
|
||||
|
||||
if (schemaMode === 'new' && newSchemaName && usableDatatable && opWs) {
|
||||
try {
|
||||
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
|
||||
const dbOps = dbSchemaOpsWithPreviewScripts({
|
||||
workspace: opWs,
|
||||
input: {
|
||||
type: 'database',
|
||||
resourceType: 'postgresql',
|
||||
resourcePath: `datatable://${usableDatatable}`,
|
||||
role: effectiveRole,
|
||||
migrationRole: defaultMigrationRole(
|
||||
usableDatatable,
|
||||
roles.current.permissioned,
|
||||
roles.current.defaultRole
|
||||
)
|
||||
}
|
||||
})
|
||||
await dbOps.onCreateSchema({ schema: newSchemaName })
|
||||
} catch (e) {
|
||||
console.error('Failed to create schema:', e)
|
||||
sendUserToast(`Failed to create schema: ${e}`, true)
|
||||
starting = { mode, schema: newSchemaName }
|
||||
const creation = createSchema({
|
||||
workspace: opWs,
|
||||
datatable: usableDatatable,
|
||||
schema: newSchemaName,
|
||||
role: effectiveRole,
|
||||
migrationRole: defaultMigrationRole(
|
||||
usableDatatable,
|
||||
roles.current.permissioned,
|
||||
roles.current.defaultRole
|
||||
)
|
||||
})
|
||||
if (await schemaJobsServed(opWs)) {
|
||||
await creation
|
||||
} else {
|
||||
// The job stays queued for as long as no worker serves its tag: waiting on it
|
||||
// would hold the app back for nothing, so say why the schema is missing instead.
|
||||
sendUserToast(
|
||||
`Schema "${newSchemaName}" is not created yet. ${queuedWithoutWorkerMessage(SCHEMA_JOB_TAG)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
|
||||
starting = undefined
|
||||
|
||||
const formattedTables = tablesToUse.map(formatDataTableRef)
|
||||
const keepsDatatable = usableDatatable !== undefined
|
||||
// The roles shown, for the data tables the app ends up using.
|
||||
const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable))
|
||||
const usedDatatables = new Set(tablesToUse.map((t) => t.datatable))
|
||||
if (keepsDatatable) usedDatatables.add(usableDatatable!)
|
||||
const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt))
|
||||
const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined
|
||||
@@ -371,10 +407,9 @@
|
||||
files: template.files,
|
||||
runnables: {},
|
||||
data,
|
||||
summary: appSummary.trim(),
|
||||
policy: appPolicy()
|
||||
},
|
||||
'code'
|
||||
mode
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -385,240 +420,181 @@
|
||||
Modal closes its own UI but the picker's `open` prop stays true,
|
||||
so the route's `templatePicker → false` watcher never fires and
|
||||
autosave stays suspended after the dismissal. -->
|
||||
<Modal kind="X" bind:open title={shownStep === 'choice' ? 'New app' : 'New app setup'}>
|
||||
{#if shownStep === 'choice'}
|
||||
<div class="flex flex-col gap-4 min-w-sm">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={buildWithAI}
|
||||
disabled={!isAiEnabled || !dataSettled}
|
||||
btnClasses="{choiceCardClasses} border-ai/30 bg-ai/5 hover:bg-ai/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if aiConfigLoaded && dataSettled}
|
||||
<Sparkles size={28} class="text-ai" />
|
||||
{:else}
|
||||
<Loader2 size={28} class="text-ai animate-spin" />
|
||||
{/if}
|
||||
<span class="text-base font-semibold text-emphasis">Start with AI</span>
|
||||
<span class="text-xs text-secondary">
|
||||
{handsOffToSession
|
||||
? 'Describe your app in an AI session and let it build the frontend, backend and tables.'
|
||||
: 'Describe your app to the AI chat and let it build the frontend, backend and tables.'}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => (step = 'setup')} btnClasses={choiceCardClasses}>
|
||||
<Code size={28} class="text-primary" />
|
||||
<span class="text-base font-semibold text-emphasis">Start with code editor</span>
|
||||
<span class="text-xs text-secondary">
|
||||
Pick a framework and data configuration, then write the app in the editor.
|
||||
</span>
|
||||
</Button>
|
||||
<Modal kind="X" bind:open title="New app">
|
||||
<div class="flex flex-col gap-6 min-w-sm">
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-3">Framework</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each templates as t, i}
|
||||
<button
|
||||
onclick={() => (selectedTemplateIndex = i)}
|
||||
class="relative w-20 h-[4.5rem] flex justify-between py-3 flex-col {selectedTemplateIndex ===
|
||||
i
|
||||
? 'bg-surface-accent-selected border border-accent'
|
||||
: ''} hover:bg-surface-hover border rounded-lg transition-all"
|
||||
>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<FileEditorIcon file={'.' + t.icon} size={22} />
|
||||
</div>
|
||||
<div class="center-center w-full text-xs text-secondary">{t.name}</div>
|
||||
{#if t.recommended}
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge color="blue" small>Recommended</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-hint text-center">You can always switch later.</p>
|
||||
{#if aiConfigLoaded && !isAiEnabled}
|
||||
<Alert type="info" title="AI is not configured.">
|
||||
You can still create an app manually but using AI is highly recommended.
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
|
||||
|
||||
{#if hasNoDatatables}
|
||||
<Alert type="warning" title="No datatables configured.">
|
||||
You can still create an app, but for data storage you won't be able to use data tables
|
||||
which are <b>highly recommended</b>.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure AI in
|
||||
Configure datatables in
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
href="/workspace_settings?tab=windmill_data_tables"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
class="inline-flex items-center gap-1"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a>
|
||||
{#if $superadmin}
|
||||
or
|
||||
<a
|
||||
href="{base}/?workspace=admins#superadmin-settings"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>instance settings <ExternalLinkIcon size={16} />
|
||||
</a>
|
||||
{/if} to enable this feature.
|
||||
{:else if $superadmin}
|
||||
Configure AI in
|
||||
<a
|
||||
href="{base}/?workspace=admins#superadmin-settings"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>instance settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure AI in workspace settings to enable this feature.
|
||||
Ask your workspace admin to configure datatables in workspace settings to enable this
|
||||
feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6 min-w-sm">
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
|
||||
<TextInput
|
||||
bind:value={appSummary}
|
||||
inputProps={{
|
||||
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each templates as t, i}
|
||||
<button
|
||||
onclick={() => (selectedTemplateIndex = i)}
|
||||
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
|
||||
? 'bg-surface-accent-selected border border-accent'
|
||||
: ''} hover:bg-surface-hover border rounded-lg transition-all"
|
||||
>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<FileEditorIcon file={'.' + t.icon} size={32} />
|
||||
</div>
|
||||
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
|
||||
|
||||
{#if hasNoDatatables}
|
||||
<Alert type="warning" title="No datatables configured.">
|
||||
You can still create an app, but for data storage you won't be able to use data tables
|
||||
which are <b>highly recommended</b>.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure datatables in
|
||||
<a
|
||||
href="/workspace_settings?tab=windmill_data_tables"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure datatables in workspace settings to enable
|
||||
this feature.
|
||||
{/if}
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary mb-1 block"
|
||||
>Default settings for new tables</span
|
||||
>
|
||||
<div class="flex flex-col gap-4 rounded-md p-4 border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-emphasis font-semibold" for="datatable"
|
||||
>Datatable</label
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<MissingWorkerTagAlert
|
||||
tag={SCHEMA_JOB_TAG}
|
||||
subject="Data table queries and schema changes"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
|
||||
<div class="flex flex-col gap-4 rounded-md p-4 border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-emphasis font-semibold" for="datatable"
|
||||
>Datatable</label
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Select
|
||||
id="datatable"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
{#if showRolePicker}
|
||||
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
|
||||
<span class="text-xs text-secondary">as</span>
|
||||
<Select
|
||||
id="datatable"
|
||||
id="datatable-role"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
items={loadedRoles.map((r) => ({ value: r, label: r }))}
|
||||
bind:value={() => selectedRole, pickRole}
|
||||
clearable={false}
|
||||
placeholder="Role"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
{#if showRolePicker}
|
||||
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
|
||||
<span class="text-xs text-secondary">as</span>
|
||||
<Select
|
||||
id="datatable-role"
|
||||
disablePortal
|
||||
items={loadedRoles.map((r) => ({ value: r, label: r }))}
|
||||
bind:value={() => selectedRole, pickRole}
|
||||
clearable={false}
|
||||
placeholder="Role"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
{/if}
|
||||
{#if noUsableRole || rolesUnknown || accessUnknown}
|
||||
<span
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
title={access.current.error}
|
||||
>
|
||||
{rolesUnknown
|
||||
? 'could not read its roles'
|
||||
: noUsableRole
|
||||
? 'no role you can use'
|
||||
: 'could not reach it'}; the app is created without a default data
|
||||
table
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-emphasis font-semibold">Schema</span>
|
||||
<div class="flex flex-row gap-1 w-full items-center">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
|
||||
<ToggleButton
|
||||
value="new"
|
||||
label="New"
|
||||
icon={Plus}
|
||||
disabled={!canCreateSchema}
|
||||
tooltip={canCreateSchema
|
||||
? undefined
|
||||
: noUsableRole
|
||||
? `You can use no role of ${selectedDatatable}`
|
||||
: accessUnknown
|
||||
? `Could not read what may be created in ${selectedDatatable}`
|
||||
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
<ToggleButton
|
||||
value="existing"
|
||||
label="Existing"
|
||||
icon={List}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{#if schemaMode === 'new'}
|
||||
<TextInput
|
||||
bind:value={newSchemaName}
|
||||
inputProps={{
|
||||
placeholder: 'Schema name',
|
||||
oninput: () => (userEditedSchemaName = true)
|
||||
}}
|
||||
class="flex-1"
|
||||
error={newSchemaAlreadyExists}
|
||||
size="sm"
|
||||
/>
|
||||
{:else if schemaMode === 'existing'}
|
||||
<div class="flex-1">
|
||||
<Select
|
||||
disablePortal
|
||||
items={schemaItems}
|
||||
bind:value={selectedSchema}
|
||||
placeholder="Schema"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if newSchemaAlreadyExists}
|
||||
<span class="text-xs text-red-500"
|
||||
>Schema "{newSchemaName}" already exists</span
|
||||
{/if}
|
||||
{#if noUsableRole || rolesUnknown || accessUnknown}
|
||||
<span
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
title={access.current.error}
|
||||
>
|
||||
{rolesUnknown
|
||||
? 'could not read its roles'
|
||||
: noUsableRole
|
||||
? 'no role you can use'
|
||||
: 'could not reach it'}; the app is created without a default data
|
||||
table
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-emphasis font-semibold">Schema</span>
|
||||
<div class="flex flex-row gap-1 w-full items-center">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
|
||||
<ToggleButton
|
||||
value="new"
|
||||
label="New"
|
||||
icon={Plus}
|
||||
disabled={!canCreateSchema}
|
||||
tooltip={canCreateSchema
|
||||
? undefined
|
||||
: noUsableRole
|
||||
? `You can use no role of ${selectedDatatable}`
|
||||
: accessUnknown
|
||||
? `Could not read what may be created in ${selectedDatatable}`
|
||||
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
<ToggleButton
|
||||
value="existing"
|
||||
label="Existing"
|
||||
icon={List}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: Database }}
|
||||
title="Explore {selectedDatatable ?? 'the data table'}"
|
||||
onclick={exploreDatatable}
|
||||
/>
|
||||
{#if schemaMode === 'new'}
|
||||
<TextInput
|
||||
bind:value={newSchemaName}
|
||||
inputProps={{
|
||||
placeholder: 'Schema name',
|
||||
oninput: () => (userEditedSchemaName = true)
|
||||
}}
|
||||
class="flex-1"
|
||||
error={newSchemaAlreadyExists}
|
||||
size="sm"
|
||||
/>
|
||||
{:else if schemaMode === 'existing'}
|
||||
<div class="flex-1">
|
||||
<Select
|
||||
disablePortal
|
||||
items={schemaItems}
|
||||
bind:value={selectedSchema}
|
||||
placeholder="Schema"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if newSchemaAlreadyExists}
|
||||
<span class="text-xs text-red-500"
|
||||
>Schema "{newSchemaName}" already exists</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if schemaMode === 'existing'}
|
||||
<div class="pt-6">
|
||||
<RawAppDataTableList
|
||||
dataTableRefs={preWhitelistedTables}
|
||||
@@ -627,51 +603,122 @@
|
||||
roles={pickerRoles}
|
||||
standalone
|
||||
hideDefaultSelector
|
||||
onAdd={() => dataTableDrawer?.openDrawer()}
|
||||
onAdd={exploreDatatable}
|
||||
onRemove={(index) => {
|
||||
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex items-center justify-end gap-3">
|
||||
{#if isAiEnabled}
|
||||
{@const AiIcon = handsOffToSession ? MessagesSquare : WandSparkles}
|
||||
<p class="mr-auto text-xs text-hint">
|
||||
Click on
|
||||
<span
|
||||
class="mx-0.5 inline-flex h-5 w-5 items-center justify-center rounded-md border align-middle {AIBtnClasses(
|
||||
'default'
|
||||
)}"
|
||||
aria-label="AI"
|
||||
>
|
||||
<AiIcon size={12} />
|
||||
</span>
|
||||
in the editor to {handsOffToSession ? 'switch to AI sessions' : 'open the AI chat'} later
|
||||
on.
|
||||
</p>
|
||||
{#if aiOffered && aiConfigLoaded && !isAiEnabled}
|
||||
<Alert type="info" title="AI is not configured.">
|
||||
You can still create an app manually but using AI is highly recommended.
|
||||
<br />
|
||||
{#if $userStore?.is_admin}
|
||||
Configure AI in
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>workspace settings <ExternalLinkIcon size={16} />
|
||||
</a>
|
||||
{#if $superadmin}
|
||||
or
|
||||
<a
|
||||
href="{base}/?workspace=admins#superadmin-settings"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>instance settings <ExternalLinkIcon size={16} />
|
||||
</a>
|
||||
{/if} to enable this feature.
|
||||
{:else if $superadmin}
|
||||
Configure AI in
|
||||
<a
|
||||
href="{base}/?workspace=admins#superadmin-settings"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 font-semibold"
|
||||
>instance settings <ExternalLinkIcon size={16} />
|
||||
</a> to enable this feature.
|
||||
{:else}
|
||||
Ask your workspace admin to configure AI in workspace settings to enable this feature.
|
||||
{/if}
|
||||
{#if !$copilotInfo.workspaceDisabled}
|
||||
<Button variant="subtle" unifiedSize="md" onclick={() => (step = 'choice')}>Back</Button
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if aiOffered}
|
||||
<div class="pt-6 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => start('ai')}
|
||||
disabled={!canStart || !isAiEnabled || starting !== undefined}
|
||||
btnClasses="{startCardClasses} border-ai/40 bg-ai/5 hover:bg-ai/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
{#if !aiConfigLoaded || starting?.mode === 'ai'}
|
||||
<Loader2 size={18} class="text-ai animate-spin" />
|
||||
{:else}
|
||||
<Sparkles size={18} class="text-ai" />
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-emphasis whitespace-nowrap"
|
||||
>Start with AI</span
|
||||
>
|
||||
</span>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Describe your app and let the AI build it.
|
||||
</span>
|
||||
<span class="absolute -top-3 right-3">
|
||||
<Badge color="blue" small>Recommended</Badge>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => start('code')}
|
||||
disabled={!canStart || starting !== undefined}
|
||||
btnClasses="{startCardClasses} disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
{#if starting?.mode === 'code'}
|
||||
<Loader2 size={18} class="text-primary animate-spin" />
|
||||
{:else}
|
||||
<Code size={18} class="text-primary" />
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-emphasis whitespace-nowrap">
|
||||
Start with code editor
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Write the app yourself in the editor.
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-center {starting?.schema ? 'text-secondary' : 'text-hint'}">
|
||||
{starting?.schema
|
||||
? `Creating schema ${starting.schema}…`
|
||||
: 'You can always switch later.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pt-6 flex items-center justify-end gap-3">
|
||||
{#if starting?.schema}
|
||||
<p class="mr-auto text-xs text-secondary">Creating schema {starting.schema}…</p>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
onclick={() => start()}
|
||||
disabled={!templates[selectedTemplateIndex] ||
|
||||
newSchemaAlreadyExists ||
|
||||
!rolesSettled ||
|
||||
!accessSettled}
|
||||
onclick={() => start('code')}
|
||||
loading={starting?.mode === 'code'}
|
||||
disabled={!canStart || starting !== undefined}
|
||||
>
|
||||
Create app
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -686,6 +733,8 @@
|
||||
...refs
|
||||
]
|
||||
preWhitelistedRoles = { ...preWhitelistedRoles, ...browsedRoles }
|
||||
// Tables picked while exploring are existing ones to use: show them.
|
||||
if (refs.length > 0) schemaMode = 'existing'
|
||||
if (selectedDatatable !== undefined && browsedRoles[selectedDatatable] !== undefined) {
|
||||
selectedRole = browsedRoles[selectedDatatable]
|
||||
}
|
||||
|
||||
@@ -236,6 +236,38 @@ export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
|
||||
return JSON.stringify(runs, null, 2)
|
||||
}
|
||||
|
||||
/** `sandbox` attribute of a sandbox-isolated app's frame, deployed or in the editor preview. */
|
||||
export const RAW_APP_SANDBOX_FLAGS =
|
||||
'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation'
|
||||
|
||||
/** Envelope type for a sandboxed pop-out's relayed messages. */
|
||||
export const RAW_APP_PREVIEW_RELAY = 'wm:previewRelay'
|
||||
|
||||
/** Where a sandboxed app's (shimmed) localStorage persists, per app. The editor
|
||||
* preview has its own store, so testing in it never touches the deployed app's data. */
|
||||
export function rawAppStorageKey(workspace: string, path: string, preview = false): string {
|
||||
return `wm_apps_localstorage:${preview ? 'p:' : ''}${workspace}:${path}`
|
||||
}
|
||||
|
||||
export function readRawAppStorage(storeKey: string): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storeKey) || '{}')
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one `wm_ls_op` relayed by a sandboxed app's storage shim. */
|
||||
export function applyRawAppStorageOp(storeKey: string, d: any) {
|
||||
try {
|
||||
const s = readRawAppStorage(storeKey)
|
||||
if (d.op === 'set') s[d.key] = String(d.value)
|
||||
else if (d.op === 'remove') delete s[d.key]
|
||||
else if (d.op === 'clear') for (const k in s) delete s[k]
|
||||
localStorage.setItem(storeKey, JSON.stringify(s))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// The sandboxed (isolated) raw-app wrapper is generated server-side and served as
|
||||
// a sandboxed, opaque-origin document (see `get_raw_app_data` in the backend
|
||||
// `apps.rs`, WIN-2006) — a blob: URL cannot carry the `CSP: sandbox` response
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
>
|
||||
<DraggableTags
|
||||
items={valueEntry}
|
||||
{allowClear}
|
||||
allowClear={allowClear && !disabled}
|
||||
onRemove={onRemoveValue}
|
||||
onReorder={reorderable
|
||||
? (oldIdx, newIdx) => (value = reorder(currentValue, oldIdx, newIdx))
|
||||
@@ -167,7 +167,7 @@
|
||||
/>
|
||||
</ul>
|
||||
{/if}
|
||||
{#if allowClear && !hideMainClearBtn && !!value?.length}
|
||||
{#if allowClear && !disabled && !hideMainClearBtn && !!value?.length}
|
||||
<CloseButton
|
||||
noBg
|
||||
class="ml-2 remove-all bg-transparent text-hint"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type Session,
|
||||
type SessionPreviewTab
|
||||
} from './sessionState.svelte'
|
||||
import type { NewRawAppSeed, SessionRuntime } from './sessionRuntime.svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import {
|
||||
resolvePreviewTab,
|
||||
@@ -77,16 +77,10 @@
|
||||
slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined
|
||||
)
|
||||
const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit')
|
||||
// `new_draft` marks a raw app opened from the new-app builder before anything was saved.
|
||||
const newRawApp: NewRawAppSeed | undefined = $derived.by(() => {
|
||||
if (itemKind !== 'raw_app') return undefined
|
||||
const params = new URL(tab.url, 'http://x').searchParams
|
||||
if (params.get('new_draft') !== 'true') return undefined
|
||||
return {
|
||||
datatable: params.get('datatable') ?? undefined,
|
||||
role: params.get('datatable_role') ?? undefined
|
||||
}
|
||||
})
|
||||
// `new_draft` marks a raw app handed over by the new-app builder.
|
||||
const newRawApp = $derived(
|
||||
itemKind === 'raw_app' && new URL(tab.url, 'http://x').searchParams.get('new_draft') === 'true'
|
||||
)
|
||||
// A pinned deployed version, read from the tab URL rather than passed down: the tab
|
||||
// URL is the single record of where this tab points.
|
||||
const pinnedVersion = $derived(
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import RawAppTemplatePicker, {
|
||||
type RawAppBuildMode,
|
||||
type RawAppTemplatePickerResult
|
||||
} from '$lib/components/raw_apps/RawAppTemplatePicker.svelte'
|
||||
import { runtimeRawAppToDraft } from './appDraftCodec'
|
||||
import ViewportOverlays from '$lib/components/common/ViewportOverlays.svelte'
|
||||
import { withWorkspaceParam } from './sessionMode.svelte'
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import type { NewRawAppSeed, SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { react19Template } from '$lib/components/raw_apps/templates'
|
||||
import { removeSession, type SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates'
|
||||
import SessionEditorTarget from './SessionEditorTarget.svelte'
|
||||
import { runResetToDeployed } from '$lib/userDraftToast'
|
||||
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
@@ -23,7 +35,7 @@
|
||||
onNavigate,
|
||||
isActiveSession = true,
|
||||
active = true,
|
||||
newRawApp = undefined
|
||||
newRawApp = false
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
path: string
|
||||
@@ -34,8 +46,8 @@
|
||||
isActiveSession?: boolean
|
||||
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
|
||||
active?: boolean
|
||||
/** Forwarded to SessionEditorTarget: the tab was opened on a brand-new app. */
|
||||
newRawApp?: NewRawAppSeed
|
||||
/** The tab was opened on an app the new-app builder just started. */
|
||||
newRawApp?: boolean
|
||||
} = $props()
|
||||
|
||||
// This tab's own raw-app cell; each open app editor binds its own store.
|
||||
@@ -45,15 +57,63 @@
|
||||
// is a fresh app would steer it away from the work already there.
|
||||
const untouchedTemplate = $derived.by(() => {
|
||||
const files = cell.store.val?.files
|
||||
if (!newRawApp || cell.saved.val || !files) return false
|
||||
const keys = Object.keys(react19Template)
|
||||
return (
|
||||
Object.keys(files).length === keys.length &&
|
||||
keys.every((k) => files[k] === react19Template[k as keyof typeof react19Template])
|
||||
)
|
||||
if (!newRawApp || (cell.saved.val && !cell.saved.val.no_deployed) || !files) return false
|
||||
const count = Object.keys(files).length
|
||||
return Object.values(FRAMEWORK_TEMPLATES).some((template: Record<string, string>) => {
|
||||
const keys = Object.keys(template)
|
||||
return count === keys.length && keys.every((k) => files[k] === template[k])
|
||||
})
|
||||
})
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
// A new app arrives here before anything is saved, on the starter template: its setup
|
||||
// is asked over the session it will be built in. Once saved it has been set up, which
|
||||
// is also what a reload sees.
|
||||
let setupSettled = $state(false)
|
||||
const awaitingSetup = $derived(
|
||||
newRawApp &&
|
||||
!setupSettled &&
|
||||
isActiveSession &&
|
||||
active &&
|
||||
cell.slot.loadedPath === path &&
|
||||
!!cell.store.val &&
|
||||
!cell.saved.val
|
||||
)
|
||||
|
||||
async function onSetup(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
|
||||
setupSettled = true
|
||||
const val = cell.store.val
|
||||
if (!val) return
|
||||
const setUp = {
|
||||
...val,
|
||||
files: { ...result.files },
|
||||
runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) },
|
||||
data: result.data,
|
||||
policy: result.policy
|
||||
}
|
||||
cell.store.val = setUp
|
||||
// Saved now rather than on the autosave debounce: both ways out read it back by path.
|
||||
const key = { workspace: workspaceId, itemKind: 'raw_app' as const, path }
|
||||
UserDraft.save('raw_app', path, runtimeRawAppToDraft(setUp), { workspace: workspaceId })
|
||||
await tick()
|
||||
await UserDraftDbSyncer.flush(key)
|
||||
await UserDraft.forcePersist('raw_app', path, { workspace: workspaceId })
|
||||
const saved = UserDraftDbSyncer.getState(key)
|
||||
if (saved.state === 'failed') {
|
||||
sendUserToast(`Could not save the app: ${saved.failureMessage ?? 'unknown error'}`, true)
|
||||
return
|
||||
}
|
||||
if (mode === 'code') {
|
||||
// The session was opened for this app alone; with nothing said in it, it goes.
|
||||
const unused = runtime.manager.displayMessages.length === 0
|
||||
await goto(withWorkspaceParam(`${base}/apps_raw/edit/${path}`, workspaceId))
|
||||
if (unused) removeSession(runtime.sessionId)
|
||||
return
|
||||
}
|
||||
// Remounts the editor so the preview builds the picked template.
|
||||
await runtime.loadRawApp(workspaceId, path, true)
|
||||
}
|
||||
|
||||
// Path typed in the editor header, surfaced when it differs from the stored
|
||||
// path. Mirror it into the runtime draft as `draft_path` so the rename
|
||||
// mutates this cell's store → the autosave sig changes → the draft is saved
|
||||
@@ -258,6 +318,13 @@
|
||||
(cell.store.val?.draft_path || cell.store.val?.path) ?? path}
|
||||
>
|
||||
{#snippet editor()}
|
||||
<!-- Over the whole page, not the preview pane: it decides what the session is for. -->
|
||||
<ViewportOverlays>
|
||||
<RawAppTemplatePicker
|
||||
bind:open={() => awaitingSetup, (open) => (setupSettled = !open)}
|
||||
onStart={onSetup}
|
||||
/>
|
||||
</ViewportOverlays>
|
||||
{#if cell.store.val}
|
||||
<!-- newApp: a draft-only app (no_deployed=true) has a truthy synthesized
|
||||
savedApp but no deployed row, so it must deploy via createApp — keying
|
||||
@@ -292,7 +359,7 @@
|
||||
{onNavigate}
|
||||
condensedHeader={true}
|
||||
onResetToDeployed={reloadDeployed}
|
||||
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', e.path)}
|
||||
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', path, e.path)}
|
||||
defaultSidebarCollapsed
|
||||
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
|
||||
defaultSplitWithPreview={false}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import type { NewRawAppSeed, SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
|
||||
import type { SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
|
||||
import { useUserDraftSync, type DraftSyncCodec } from './useUserDraftSync.svelte'
|
||||
import { makeFlowCodec, makeScriptCodec, makeRawAppCodec } from './sessionDraftCodecs'
|
||||
import { itemDisplayName } from './previewRouter'
|
||||
@@ -21,7 +21,7 @@
|
||||
onNavigate,
|
||||
isActiveSession = true,
|
||||
isActiveTab = true,
|
||||
newRawApp = undefined,
|
||||
newRawApp = false,
|
||||
isNew = undefined
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
@@ -45,9 +45,9 @@
|
||||
* tab must not, else chat actions resolve to the wrong item's path.
|
||||
*/
|
||||
isActiveTab?: boolean
|
||||
/** A raw app just started from the new-app builder: when nothing exists at `path` yet
|
||||
* the editor opens on the template, in memory until the first edit saves it. */
|
||||
newRawApp?: NewRawAppSeed
|
||||
/** A raw app the new-app builder just started: when nothing exists at `path` yet
|
||||
* the editor opens on the starter template. */
|
||||
newRawApp?: boolean
|
||||
/** Whether the item is still the template a new-item builder started it on. */
|
||||
isNew?: () => boolean
|
||||
} = $props()
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type PreviewTarget
|
||||
} from './previewRouter'
|
||||
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
|
||||
import { sessionTargetHref } from './sessionMode.svelte'
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
|
||||
import {
|
||||
@@ -651,6 +652,25 @@ export class SessionPreviewTabs {
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
/** Follow an item its editor deployed under a new path, in place: the editor is keyed
|
||||
* on the path, and nothing is left to edit at the old one. */
|
||||
retargetEditor(from: SessionTarget, to: SessionTarget): void {
|
||||
const tab = this.#tabs.find((t) => isItemTabFor(t.url, from, 'edit'))
|
||||
if (!tab) return
|
||||
// One editor per item: a tab already on the new path keeps it.
|
||||
const existing = this.#tabs.find((t) => t.id !== tab.id && isItemTabFor(t.url, to, 'edit'))
|
||||
if (existing) {
|
||||
const wasActive = this.#activeId === tab.id
|
||||
this.close(tab.id)
|
||||
if (wasActive) this.#activeId = existing.id
|
||||
} else {
|
||||
const url = sessionTargetHref(to)
|
||||
if (!url) return
|
||||
retargetTab(tab, url)
|
||||
}
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
closeArtifact(artifactId: string): void {
|
||||
const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId)
|
||||
if (tab) this.close(tab.id)
|
||||
|
||||
@@ -241,6 +241,21 @@ describe('page item tabs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('follows an editor deployed under a new path in place', () => {
|
||||
const o = owner()
|
||||
o.open({
|
||||
type: 'item',
|
||||
item: { kind: 'app', raw_app: true, path: 'u/me/draft_1', summary: '' }
|
||||
})
|
||||
const id = o.tabs[0].id
|
||||
o.retargetEditor(
|
||||
{ kind: 'raw_app', path: 'u/me/draft_1' },
|
||||
{ kind: 'raw_app', path: 'u/me/app' }
|
||||
)
|
||||
const url = `${base}/apps_raw/edit/u/me/app`
|
||||
expect(o.tabs).toEqual([{ id, url, loc: url }])
|
||||
})
|
||||
|
||||
it('restores a tab saved on a row’s drawer as the row it was last on', () => {
|
||||
const snap = hydratePreviewTabs({
|
||||
previewTabs: [
|
||||
|
||||
@@ -135,9 +135,6 @@ export interface LoadSlot {
|
||||
|
||||
export type SessionTargetKind = 'flow' | 'script' | 'raw_app'
|
||||
|
||||
/** The data config a raw app opened from the new-app builder starts with. */
|
||||
export type NewRawAppSeed = { datatable?: string; role?: string }
|
||||
|
||||
// The live runtime value a raw-app editor cell binds. Legacy drag-and-drop apps
|
||||
// are intentionally NOT hosted in the session preview (only code-based raw apps).
|
||||
export interface RawAppRuntimeValue {
|
||||
@@ -237,8 +234,9 @@ export interface SessionRuntime {
|
||||
path: string,
|
||||
force?: boolean,
|
||||
deployedOnly?: boolean,
|
||||
/** Open the starter template when nothing exists at `path` yet (a new-app hand-off). */
|
||||
seedIfMissing?: NewRawAppSeed
|
||||
/** Open the starter template when nothing exists at `path` yet: a new app whose
|
||||
* setup has not been confirmed. Held in the cell only, nothing is saved. */
|
||||
seedIfMissing?: boolean
|
||||
): Promise<void>
|
||||
/** Register a mounted raw-app preview's log requester, keyed by app path, like
|
||||
* `registerDomRequester`: build state is per editor, so reads route to the app edited. */
|
||||
@@ -271,7 +269,14 @@ export interface SessionRuntime {
|
||||
* the deploy just changed. One call rather than a sequence at each deploy site — a
|
||||
* caller that remembers three of the four leaves a stale draft count or a picker
|
||||
* that still calls the item a draft. */
|
||||
itemDeployed(workspace: string, kind: 'script' | 'flow' | 'raw_app', path: string): void
|
||||
itemDeployed(
|
||||
workspace: string,
|
||||
kind: 'script' | 'flow' | 'raw_app',
|
||||
path: string,
|
||||
/** Where the deploy landed, when not at `path`: a new item parked at a
|
||||
* `draft_<uuid>` storage path deploys under the name it was given. */
|
||||
deployedPath?: string
|
||||
): void
|
||||
}
|
||||
|
||||
const runtimes = new SvelteMap<string, SessionRuntime>()
|
||||
@@ -827,7 +832,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
path: string,
|
||||
force = false,
|
||||
deployedOnly = false,
|
||||
seedIfMissing: NewRawAppSeed | undefined = undefined
|
||||
seedIfMissing = false
|
||||
) {
|
||||
const { slot, store, saved } = rawAppCell(path)
|
||||
if (slot.loadedPath === path && slot.loadedWorkspace === workspace && !force) return
|
||||
@@ -959,21 +964,12 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
slot.loadedWorkspace = workspace
|
||||
} catch (err) {
|
||||
if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) {
|
||||
// Held in the cell only: the editor's draft sync swallows this first write,
|
||||
// so nothing is saved until the template is actually changed.
|
||||
const user = get(userStore)
|
||||
saved.val = undefined
|
||||
store.val = {
|
||||
files: { ...react19Template },
|
||||
runnables: structuredClone(STARTER_RUNNABLES),
|
||||
data: {
|
||||
...DEFAULT_DATA,
|
||||
datatable: seedIfMissing.datatable,
|
||||
roles:
|
||||
seedIfMissing.datatable && seedIfMissing.role
|
||||
? { [seedIfMissing.datatable]: seedIfMissing.role }
|
||||
: undefined
|
||||
},
|
||||
data: { ...DEFAULT_DATA },
|
||||
policy: {
|
||||
on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`,
|
||||
on_behalf_of_email: user?.email,
|
||||
@@ -996,7 +992,16 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
}
|
||||
},
|
||||
|
||||
itemDeployed(workspace, kind, path) {
|
||||
itemDeployed(workspace, kind, storagePath, deployedPath = storagePath) {
|
||||
if (deployedPath !== storagePath) {
|
||||
// Nothing is deployed at the storage path, so its editor would go on
|
||||
// creating the item again and its draft would outlive the deploy: drop
|
||||
// the draft and move the tab to where the item now lives.
|
||||
UserDraft.stopSync(kind, storagePath, { workspace })
|
||||
UserDraft.discard(kind, storagePath, undefined, { workspace })
|
||||
previewTabs.retargetEditor({ kind, path: storagePath }, { kind, path: deployedPath })
|
||||
}
|
||||
const path = deployedPath
|
||||
// After deploy the editor state equals the deployed value; the reload
|
||||
// below re-seeds the cell from it, which must NOT POST as a fresh draft.
|
||||
// The full-page editor guards this with discardDraftAfterDeploy, but the
|
||||
@@ -1387,7 +1392,7 @@ setClosePreviewTabsHandler(({ sessionId: callerSessionId, all, match }) => {
|
||||
|
||||
// After a chat deploy, reload the calling session's preview — only if it's open
|
||||
// showing that exact item.
|
||||
setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
|
||||
setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, storagePath, path }) => {
|
||||
const sessionId = callerSessionId ?? sessionState.currentSessionId
|
||||
if (!sessionId) return
|
||||
const session = sessionState.sessions.find((s) => s.id === sessionId)
|
||||
@@ -1396,12 +1401,12 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
|
||||
// Peek without creating a cell: a deploy for an item with no open editor tab
|
||||
// must not allocate an empty cell that lingers until the next prune. The caches
|
||||
// still answer for the item, so they are dropped either way.
|
||||
if (runtime.loadedEditorPath(kind, path) !== path) {
|
||||
if (runtime.loadedEditorPath(kind, storagePath) !== storagePath) {
|
||||
invalidateWorkspaceDrafts(session.workspace_id)
|
||||
invalidateWorkspaceItems(session.workspace_id, kind === 'raw_app' ? 'app' : kind)
|
||||
return
|
||||
}
|
||||
runtime.itemDeployed(session.workspace_id, kind, path)
|
||||
runtime.itemDeployed(session.workspace_id, kind, storagePath, path)
|
||||
})
|
||||
|
||||
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) => {
|
||||
|
||||
@@ -178,6 +178,24 @@ async function openInSession(
|
||||
workspaceId?: string,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
): Promise<void> {
|
||||
await goto(await sessionHrefFor(url, workspaceId, opts))
|
||||
}
|
||||
|
||||
/** A fresh session's page, with an editor in its preview. For a `load` that sends the
|
||||
* user there by redirect, where a `goto` would start a second navigation. */
|
||||
export function editorSessionHref(
|
||||
target: SessionTarget,
|
||||
workspaceId?: string,
|
||||
previewParams?: Record<string, string>
|
||||
): Promise<string> {
|
||||
return sessionHrefFor(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId)
|
||||
}
|
||||
|
||||
async function sessionHrefFor(
|
||||
url: string | undefined,
|
||||
workspaceId?: string,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
): Promise<string> {
|
||||
// Seed the fresh session's preview with a single tab on `url` so it opens
|
||||
// straight onto what the caller wants (resetSessionPreviewTabs also writes
|
||||
// through a live runtime if one already exists for this id).
|
||||
@@ -198,7 +216,7 @@ async function openInSession(
|
||||
navRouteOffered = true
|
||||
}
|
||||
selectSession(session.id)
|
||||
await goto(sessionPageHref(session.id))
|
||||
return sessionPageHref(session.id)
|
||||
}
|
||||
|
||||
// Open an editor's own hand-off, running its `beforeOpen` (which persists the
|
||||
|
||||
@@ -37,8 +37,12 @@
|
||||
import { FlaskConical } from 'lucide-svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useTriggerLock } from '$lib/operatorWriteRights'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
// Saving a capture config is a trigger write the server refuses to a withdrawn operator, so
|
||||
// every capture kind locks here rather than each editor passing it down.
|
||||
const triggerLock = useTriggerLock()
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean | undefined
|
||||
@@ -289,7 +293,7 @@
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={() => dispatch('captureToggle', {})}
|
||||
{disabled}
|
||||
disabled={disabled || !!$triggerLock}
|
||||
variant={captureInfo.active ? 'default' : 'accent-secondary'}
|
||||
selected={captureInfo.active}
|
||||
startIcon={captureInfo.active
|
||||
@@ -327,12 +331,14 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute top-0 left-0 w-full text-center',
|
||||
disabled === true ? 'text-red-600 dark:text-red-400' : ''
|
||||
disabled === true && !$triggerLock ? 'text-red-600 dark:text-red-400' : ''
|
||||
)}
|
||||
in:fade={{ duration: 100, delay: 50 }}
|
||||
out:fade={{ duration: 50 }}
|
||||
>
|
||||
{#if disabled === true}
|
||||
{#if $triggerLock}
|
||||
{$triggerLock}.
|
||||
{:else if disabled === true}
|
||||
Enter a valid configuration to start capturing.
|
||||
{:else}
|
||||
{@render description?.()}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user