Merge remote-tracking branch 'origin/main' into raw-apps-wmill-client-frontend

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Diego Imbert
2026-07-29 01:12:21 +02:00
140 changed files with 6486 additions and 998 deletions
+61
View File
@@ -0,0 +1,61 @@
# The python and typescript SDK unit suites, on release tags only: they guard
# what gets published to npm / PyPI / JSR, and a tag is the moment that decides
# it.
#
# This runs alongside the publish workflows rather than ahead of them, so it
# reports a broken SDK rather than holding one back. Gating would mean putting
# the job inside each publish workflow, since Actions cannot express `needs`
# across workflows.
name: SDK Tests
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
typescript-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# No build step: these suites are deliberately free of the generated API
# client, so they run against the sources as committed.
- name: Run tests
working-directory: ./typescript-client
run: bun test --timeout 120000 tests/
python-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
# The interpreter is named explicitly: on a clean checkout uv picks the
# runner's system python and stops with "not compatible with the locked
# Python requirement" rather than fetching one. Keep in step with
# `requires-python` in uv.lock.
#
# Note this is not the version a worker runs the SDK on — those are 3.12.
# `uv.lock` asks for >=3.14, so pinning lower means regenerating it, which
# is worth doing separately.
- name: Install the interpreter the lockfile requires
run: uv python install 3.14
# `--frozen` so a drifted lockfile fails here rather than quietly
# resolving to something nobody has run.
- name: Run tests
working-directory: ./python-client/wmill
env:
PYTHONPATH: .
run: uv run --frozen --python 3.14 pytest tests/ -q
+1
View File
@@ -12,6 +12,7 @@ Open-source platform for internal tools, workflows, API integrations, background
## Documentation
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
- **Unreleased SDK changes**: `docs/wac-sdk-e2e.md` — exercising a client change on a real worker
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [1.775.1](https://github.com/windmill-labs/windmill/compare/v1.775.0...v1.775.1) (2026-07-28)
### Bug Fixes
* **ai:** render pending parallel tool calls as faded queued cards ([#10208](https://github.com/windmill-labs/windmill/issues/10208)) ([d842f76](https://github.com/windmill-labs/windmill/commit/d842f765a457f0c80a2cd1eb06bc703167512160))
* **frontend:** add the preprocessor node before the error handler markers ([#10395](https://github.com/windmill-labs/windmill/issues/10395)) ([39dd411](https://github.com/windmill-labs/windmill/commit/39dd411481d450b1cdb92ddba3a8b613ddd2218a))
## [1.775.0](https://github.com/windmill-labs/windmill/compare/v1.774.0...v1.775.0) (2026-07-28)
### Features
* add github dark mode variant switchable in user settings ([#10002](https://github.com/windmill-labs/windmill/issues/10002)) ([bd24664](https://github.com/windmill-labs/windmill/commit/bd246644c962f754a8477353d49ed39ba9b87ffd))
* validate and make explicit the hub project's resource type export ([#10388](https://github.com/windmill-labs/windmill/issues/10388)) ([91b8ce5](https://github.com/windmill-labs/windmill/commit/91b8ce581a15baf7fc9ea4e983b50f233cf5e023))
### Bug Fixes
* render the flow editor's error handler node as an inert run marker ([#10391](https://github.com/windmill-labs/windmill/issues/10391)) ([bff6545](https://github.com/windmill-labs/windmill/commit/bff654596fb9cf8b086919367614ba0915cd7b87))
* truncate strings on char boundaries to avoid panics on multibyte input ([#10390](https://github.com/windmill-labs/windmill/issues/10390)) ([faa2aaf](https://github.com/windmill-labs/windmill/commit/faa2aaf214444f4fbb129f10a46060d4a8ab3ae8))
## [1.774.0](https://github.com/windmill-labs/windmill/compare/v1.773.0...v1.774.0) (2026-07-28)
### Features
* **ai-chat:** add get_flow_run_details tool for per-step flow run results ([#10374](https://github.com/windmill-labs/windmill/issues/10374)) ([c12e7c3](https://github.com/windmill-labs/windmill/commit/c12e7c34310b417e89f94efc3c412b3c7278344b))
* **ai-chat:** recall queued/last message into the composer ([#10191](https://github.com/windmill-labs/windmill/issues/10191)) ([ad0fef4](https://github.com/windmill-labs/windmill/commit/ad0fef4a2e0ea63ae00562ff10d9ebbae66c42d0))
* bulk discard selected drafts on the compare & deploy page ([#10372](https://github.com/windmill-labs/windmill/issues/10372)) ([ac970ef](https://github.com/windmill-labs/windmill/commit/ac970efa1698e42ad1e7b3cea5f06f18a567269d))
* reusable AI agent steps with rigid linking and edit/fork ([#9825](https://github.com/windmill-labs/windmill/issues/9825)) ([3b95a2d](https://github.com/windmill-labs/windmill/commit/3b95a2d0967544640feb53fe962a8215e049f4d2))
### Bug Fixes
* **ai-agent:** keep tool description through flow deployment ([#10373](https://github.com/windmill-labs/windmill/issues/10373)) ([8d684c0](https://github.com/windmill-labs/windmill/commit/8d684c0b2305798a781685ca022b1b9dc0731cf0))
* **ai-chat:** make hub script paths readable from the global chat ([#10381](https://github.com/windmill-labs/windmill/issues/10381)) ([5feec9b](https://github.com/windmill-labs/windmill/commit/5feec9b4cd3cdd4eed3edf5be0fc42dabfeb13d2))
* **ai:** resolve deployment-pinned Azure base URLs to the v1 surface ([#10362](https://github.com/windmill-labs/windmill/issues/10362)) ([621718b](https://github.com/windmill-labs/windmill/commit/621718b32fe5322638039d4a560b845e11b38008))
* **apps:** stop cross-origin isolating the raw app viewer ([#10370](https://github.com/windmill-labs/windmill/issues/10370)) ([3c2dab9](https://github.com/windmill-labs/windmill/commit/3c2dab9f8f302e2f80ef800a4271bfe5aa152767))
* **datatable:** provision the replication user on managed postgres ([#10375](https://github.com/windmill-labs/windmill/issues/10375)) ([1b6b2aa](https://github.com/windmill-labs/windmill/commit/1b6b2aa8598100b5327ecc3e16f1541db95f9e47))
* **frontend:** preserve top-level flow settings in AI flow tools ([#10369](https://github.com/windmill-labs/windmill/issues/10369)) ([a544dfd](https://github.com/windmill-labs/windmill/commit/a544dfde9a5f409247e78ef1c1aac2b9699d9ac8))
* home kind filter no longer resets a fork or reloads the page ([#10384](https://github.com/windmill-labs/windmill/issues/10384)) ([2a2ef41](https://github.com/windmill-labs/windmill/commit/2a2ef411525f032743af93f143c10ad373c20fc8))
* loop "Test an iteration" progress bar, while-loop modules and schema ([#10357](https://github.com/windmill-labs/windmill/issues/10357)) ([350eb66](https://github.com/windmill-labs/windmill/commit/350eb66560d2626c4100a21edb2101cdfa4a0a72))
* make same worker mutually exclusive with retries and sleeps in the flow editor ([#10379](https://github.com/windmill-labs/windmill/issues/10379)) ([ecde945](https://github.com/windmill-labs/windmill/commit/ecde94567c5ec43002a06d2d765927e0d89640a4))
* mark Setup URL as required in self-managed GitHub App instructions ([#10380](https://github.com/windmill-labs/windmill/issues/10380)) ([c4e7568](https://github.com/windmill-labs/windmill/commit/c4e75683a8a346f4cb39038f3045c94f3a9621b2))
* raw apps with no stylesheet were permanently un-deployable ([#10364](https://github.com/windmill-labs/windmill/issues/10364)) ([8a96e3a](https://github.com/windmill-labs/windmill/commit/8a96e3a4ec47c0a28dd422b31388f7f0cced8e67))
* show draft badge and disable the toggle for draft-only triggers ([#10155](https://github.com/windmill-labs/windmill/issues/10155)) ([b0c7e09](https://github.com/windmill-labs/windmill/commit/b0c7e0917376248cda0f894b138075d82f19f620))
* surface postgres publication errors as 400 instead of 500 ([#10376](https://github.com/windmill-labs/windmill/issues/10376)) ([5e52346](https://github.com/windmill-labs/windmill/commit/5e523462423f73419df25e4d7aa1cd2e77c0228a))
* surface the real postgres error when data table migrations fail ([#10371](https://github.com/windmill-labs/windmill/issues/10371)) ([fbf9f04](https://github.com/windmill-labs/windmill/commit/fbf9f04e107c674474f61c1559a2f1e56dfeeaba))
* **wac:** one failure record for tasks and steps, in every round ([#10368](https://github.com/windmill-labs/windmill/issues/10368)) ([aeaea57](https://github.com/windmill-labs/windmill/commit/aeaea57ca1a0fac02157035530e84c3588a7aec1))
* **wac:** report a task failure the child round's body catches ([#10366](https://github.com/windmill-labs/windmill/issues/10366)) ([727d22b](https://github.com/windmill-labs/windmill/commit/727d22b9a1f9520788afb9b519be8fcd8fd64319))
* **wac:** return the checkpointed value from step(), not the live object ([#10367](https://github.com/windmill-labs/windmill/issues/10367)) ([044ce39](https://github.com/windmill-labs/windmill/commit/044ce39e5f9c8207287e19a0d2eefa22b79188f2))
## [1.773.0](https://github.com/windmill-labs/windmill/compare/v1.772.0...v1.773.0) (2026-07-27)
+160 -2
View File
@@ -809,6 +809,142 @@ export function listBenchmarkMcpTools(): EndpointTool[] {
return BENCHMARK_MCP_TOOLS
}
/** A stand-in Windmill hub. `search_hub_scripts` and a `hub/` read go out over
* relative `/api/...` fetches, which have no origin here, so without these the
* hub tools throw and no case can exercise hub reuse. Serving fixtures rather
* than the live hub also keeps assertions on script content stable as the real
* hub republishes new versions. */
const BENCHMARK_HUB_SCRIPTS = [
{
version_id: 22235,
app: 'holded',
summary: 'Send Document',
terms: 'holded invoice document send email mail',
language: 'bun',
content: `//native
type Holded = {
apiKey: string;
};
/**
* Send Document
* Send a specific document by email.
*/
export async function main(
auth: Holded,
docType: string,
documentId: string,
body: {
mailTemplateId?: string;
emails: string;
subject?: string;
message?: string;
docIds?: string;
},
) {
const url = new URL(
\`https://api.holded.com/api/invoicing/v1/documents/\${docType}/\${documentId}/send\`,
);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
key: auth.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(\`\${response.status} \${text}\`);
}
return await response.json();
}
`,
schema: {
type: 'object',
required: ['auth', 'docType', 'documentId', 'body'],
properties: {
auth: { type: 'object', format: 'resource-holded' },
docType: { type: 'string' },
documentId: { type: 'string' },
body: { type: 'object' }
}
}
},
{
version_id: 28294,
app: 'discord',
summary: 'Send a message to Discord using Webhook',
terms: 'discord webhook message send chat channel',
language: 'bunnative',
content: `//native
type DiscordWebhook = {
webhook_url: string;
};
export async function main(discord_webhook: DiscordWebhook, message: string) {
const response = await fetch(\`\${discord_webhook.webhook_url}?wait=true\`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
throw new Error(\`\${response.status} \${await response.text()}\`);
}
return await response.json();
}
`,
schema: {
type: 'object',
required: ['discord_webhook', 'message'],
properties: {
discord_webhook: { type: 'object', format: 'resource-discord_webhook' },
message: { type: 'string' }
}
}
}
]
/** Naive whole-word overlap — enough to rank a handful of fixtures for a natural
* query without pulling an embedding model into the benchmark. Every frontend eval
* shares this handler, so the bar to match is deliberately high: naming the
* integration, or overlapping on three meaningful words. A looser bar answers
* "send a Slack message" with the Discord fixture, handing an unrelated case a
* plausible-looking wrong integration. */
function searchBenchmarkHubScripts(text: string) {
const tokens = new Set(
text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2)
)
return BENCHMARK_HUB_SCRIPTS.map((script) => {
const words = new Set(
`${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/)
)
const score = [...tokens].filter((token) => words.has(token)).length
return { script, score, namesApp: tokens.has(script.app) }
})
.filter((entry) => entry.namesApp || entry.score >= 3)
.sort((a, b) => b.score - a.score)
.map(({ script }, index) => ({
ask_id: script.version_id,
id: script.version_id,
version_id: script.version_id,
summary: script.summary,
app: script.app,
kind: 'script',
score: 1 - index * 0.01
}))
}
/** The hub keys a script by its version id; the app and slug segments that
* follow are descriptive, so match on the id exactly as the real hub does. */
function getBenchmarkHubScript(path: string) {
const versionId = Number(path.replace(/^\/api\/scripts\/hub\/get_full\/hub\//, '').split('/')[0])
return BENCHMARK_HUB_SCRIPTS.find((script) => script.version_id === versionId)
}
const BENCHMARK_WORKERS = [
{
worker: 'wk-benchmark-1',
@@ -837,10 +973,16 @@ const BENCHMARK_WORKERS = [
* intercepting it with a synthetic 404 sends the model into retry loops. */
export function hasBenchmarkApiHandler(url: string): boolean {
const path = url.split('?')[0]
return path === '/api/workers/list' || /^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)
return (
path === '/api/workers/list' ||
/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) ||
path === '/api/embeddings/query_hub_scripts' ||
path.startsWith('/api/scripts/hub/get_full/')
)
}
/** Answer a relative `/api/...` fetch issued by the API catalog executor. */
/** Answer a relative `/api/...` fetch — from the API catalog executor, or from the
* chat's hub tools. */
export function handleBenchmarkApiFetch(url: string): Response {
const path = url.split('?')[0]
if (path === '/api/workers/list') {
@@ -849,5 +991,21 @@ export function handleBenchmarkApiFetch(url: string): Response {
if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) {
return Response.json([])
}
if (path === '/api/embeddings/query_hub_scripts') {
const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? ''
return Response.json(searchBenchmarkHubScripts(text))
}
if (path.startsWith('/api/scripts/hub/get_full/')) {
const script = getBenchmarkHubScript(path)
if (!script) {
return Response.json({ error: 'hub script not found' }, { status: 404 })
}
return Response.json({
content: script.content,
language: script.language,
schema: script.schema,
summary: script.summary
})
}
return Response.json({ error: `no benchmark handler for ${path}` }, { status: 404 })
}
+46
View File
@@ -1877,3 +1877,49 @@
stringIncludesAnyOf:
- email
skipJudge: true
# --- Windmill Hub reuse (search_hub_scripts + read_workspace_item on a hub/ path) ---
# Holded's API is obscure enough that a model writing from memory cannot reproduce
# its endpoint and `key` auth header — so the draft's fidelity to the published
# script is what proves the hub content was actually fetched, not guessed.
- id: global-hub1-reuse-hub-script
prompt: |-
I want to email one of my Holded invoices to a customer from Windmill.
There is already a script for that on the Windmill hub — reuse it instead of writing
your own, and save it as a draft script at `f/evals/global/holded_send_document`.
Leave it as an AI draft; do not deploy it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 12
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/holded_send_document
language: bun
valueIncludes:
- api.holded.com/api/invoicing/v1/documents
# `mailTemplateId` is an optional field of the published script's body
# that a model writing from memory does not invent, so it is what
# separates reusing the hub script from re-deriving one that merely
# hits the same endpoint.
- mailTemplateId
toolExpect:
requiredToolsUsed:
- search_hub_scripts
- read_workspace_item
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: read_workspace_item
field: path
stringIncludesAnyOf:
- hub/
judgeChecklist:
- the draft sends an existing Holded document by email rather than creating one
- the request targets Holded's document send endpoint, not an invented URL
- authentication uses Holded's own key header rather than a bearer token
- the document type, document id, and recipient emails are inputs to the script
- the result stays an AI draft and is not deployed
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT flow_step_id as \"flow_step_id!\" FROM v2_job\n WHERE parent_job = $1 AND workspace_id = $2 AND flow_step_id IS NOT NULL\n AND ($3::text[] IS NULL OR tag = ANY($3))\n ORDER BY flow_step_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_step_id!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
true
]
},
"hash": "2ef832a6d8e9feb83de6706b0ffe8e849e56f8db795df2ad4d907cdb3c9fce60"
}
@@ -0,0 +1,37 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.id,\n COALESCE(c.status::text,\n CASE WHEN q.running AND q.suspend > 0 THEN 'suspended'\n WHEN q.running THEN 'running'\n ELSE 'queued' END) as \"status!\",\n COALESCE((\n SELECT m->'value'->>'type'\n FROM v2_job parent_j\n LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id\n AND parent_j.kind::text = 'flow'\n LEFT JOIN flow f ON f.path = parent_j.runnable_path\n AND f.workspace_id = parent_j.workspace_id\n LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id\n CROSS JOIN LATERAL jsonb_array_elements(\n COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'\n ) m\n WHERE parent_j.id = $1\n AND m->>'id' = $3\n LIMIT 1\n ), '')::text as \"parent_module_type!\"\n FROM v2_job j\n LEFT JOIN v2_job_completed c ON c.id = j.id\n LEFT JOIN v2_job_queue q ON q.id = j.id\n LEFT JOIN (\n SELECT fj.jid, fj.ord\n FROM (SELECT COALESCE(\n (SELECT flow_status FROM v2_job_completed WHERE id = $1),\n (SELECT flow_status FROM v2_job_status WHERE id = $1)\n ) AS fs) pf\n CROSS JOIN LATERAL (\n SELECT m FROM jsonb_array_elements(pf.fs->'modules') m\n WHERE m->>'id' = $3\n LIMIT 1\n ) md\n CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')\n WITH ORDINALITY fj(jid, ord)\n ) pos ON pos.jid = j.id::text\n WHERE j.parent_job = $1 AND j.workspace_id = $2 AND j.flow_step_id = $3\n AND ($4::text[] IS NULL OR j.tag = ANY($4))\n ORDER BY pos.ord NULLS LAST, j.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "status!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "parent_module_type!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false,
null,
null
]
},
"hash": "48c2eeca50aedc1895256f615aecc8b68627c8ed9a6da969674734854ef2742a"
}
@@ -1,83 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE job_tree AS (\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n '' as path_label, 0 as depth,\n j.id::text as id_path,\n ''::text as parent_module_type\n FROM v2_job j\n WHERE j.id = $2 AND j.workspace_id = $1\n UNION ALL\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n CASE\n WHEN jt.path_label = '' THEN COALESCE(j.flow_step_id, '')\n ELSE jt.path_label || '/' || COALESCE(j.flow_step_id, '')\n END,\n jt.depth + 1,\n jt.id_path || '/' || j.id::text,\n COALESCE((\n SELECT m->'value'->>'type'\n FROM v2_job parent_j\n LEFT JOIN flow f ON f.path = parent_j.runnable_path\n AND f.workspace_id = parent_j.workspace_id\n LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id\n CROSS JOIN LATERAL jsonb_array_elements(\n COALESCE(parent_j.raw_flow, f.value, fn.flow)->'modules'\n ) m\n WHERE parent_j.id = jt.id\n AND m->>'id' = j.flow_step_id\n LIMIT 1\n ), '')::text\n FROM v2_job j\n JOIN job_tree jt ON j.parent_job = jt.id\n WHERE j.workspace_id = $1\n ),\n with_sibling_index AS (\n SELECT jt.*,\n ROW_NUMBER() OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ORDER BY jt.id\n ) as sibling_index,\n COUNT(*) OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ) as sibling_count\n FROM job_tree jt\n )\n SELECT w.id, w.kind, w.flow_step_id, w.path_label,\n w.sibling_index::int as sibling_index,\n w.sibling_count::int as sibling_count,\n w.depth::int as depth,\n w.parent_module_type,\n coalesce(job_logs.logs, '') as logs,\n COALESCE(job_logs.log_offset, 0) as log_offset,\n job_logs.log_file_index\n FROM with_sibling_index w\n LEFT JOIN job_logs ON job_logs.job_id = w.id\n ORDER BY w.id_path ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "path_label",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "sibling_index",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "sibling_count",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "depth",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "parent_module_type",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "logs",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "log_offset",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "log_file_index",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
true
]
},
"hash": "5b95f464d492227ffe8a449844da00a6539788f9be647d6b52f01cbb2967473e"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "SELECT j.kind::text as kind,\n c.status::text as completed_status,\n c.duration_ms as \"duration_ms?\",\n COALESCE(c.started_at, q.started_at) as started_at,\n LEFT(c.result::text, $3) as result_prefix,\n length(c.result::text) as result_length,\n q.running as \"q_running?\",\n q.suspend as \"q_suspend?\"\n FROM v2_job j\n LEFT JOIN v2_job_completed c ON c.id = j.id\n LEFT JOIN v2_job_queue q ON q.id = j.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "completed_status",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "duration_ms?",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "result_prefix",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "result_length",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "q_running?",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "q_suspend?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int4"
]
},
"nullable": [
null,
null,
false,
null,
null,
null,
false,
false
]
},
"hash": "6981553b904f9ae38d6eaeae05716ac71390028dfe8c54798b1701b01591fff7"
}
@@ -0,0 +1,84 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE job_tree AS (\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n '' as path_label, 0 as depth,\n j.id::text as id_path,\n ''::text as parent_module_type\n FROM v2_job j\n WHERE j.id = $2 AND j.workspace_id = $1\n UNION ALL\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n CASE\n WHEN jt.path_label = '' THEN COALESCE(j.flow_step_id, '')\n ELSE jt.path_label || '/' || COALESCE(j.flow_step_id, '')\n END,\n jt.depth + 1,\n jt.id_path || '/' || j.id::text,\n COALESCE((\n SELECT m->'value'->>'type'\n FROM v2_job parent_j\n LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id\n AND parent_j.kind::text = 'flow'\n LEFT JOIN flow f ON f.path = parent_j.runnable_path\n AND f.workspace_id = parent_j.workspace_id\n LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id\n CROSS JOIN LATERAL jsonb_array_elements(\n COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'\n ) m\n WHERE parent_j.id = jt.id\n AND m->>'id' = j.flow_step_id\n LIMIT 1\n ), '')::text\n FROM v2_job j\n JOIN job_tree jt ON j.parent_job = jt.id\n WHERE j.workspace_id = $1\n AND ($3::text[] IS NULL OR j.tag = ANY($3))\n ),\n positions AS (\n SELECT g.parent_job, g.flow_step_id, fj.jid, fj.ord\n FROM (SELECT DISTINCT parent_job, flow_step_id FROM job_tree\n WHERE parent_job IS NOT NULL AND flow_step_id IS NOT NULL) g\n CROSS JOIN LATERAL (\n SELECT COALESCE(\n (SELECT flow_status FROM v2_job_completed WHERE id = g.parent_job),\n (SELECT flow_status FROM v2_job_status WHERE id = g.parent_job)\n ) AS fs\n ) pf\n CROSS JOIN LATERAL (\n SELECT m FROM jsonb_array_elements(pf.fs->'modules') m\n WHERE m->>'id' = g.flow_step_id\n LIMIT 1\n ) md\n CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')\n WITH ORDINALITY fj(jid, ord)\n ),\n with_sibling_index AS (\n SELECT jt.*,\n ROW_NUMBER() OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ORDER BY pos.ord NULLS LAST, jt.id\n ) as sibling_index,\n COUNT(*) OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ) as sibling_count\n FROM job_tree jt\n LEFT JOIN positions pos ON pos.parent_job = jt.parent_job\n AND pos.flow_step_id = jt.flow_step_id\n AND pos.jid = jt.id::text\n )\n SELECT w.id, w.kind, w.flow_step_id, w.path_label,\n w.sibling_index::int as sibling_index,\n w.sibling_count::int as sibling_count,\n w.depth::int as depth,\n w.parent_module_type,\n coalesce(job_logs.logs, '') as logs,\n COALESCE(job_logs.log_offset, 0) as log_offset,\n job_logs.log_file_index\n FROM with_sibling_index w\n LEFT JOIN job_logs ON job_logs.job_id = w.id\n ORDER BY w.id_path ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "path_label",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "sibling_index",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "sibling_count",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "depth",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "parent_module_type",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "logs",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "log_offset",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "log_file_index",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"TextArray"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
true
]
},
"hash": "94f0e271a67179c78de217ae1f4d7190324533119792b650c6db734a3b99575c"
}
@@ -0,0 +1,110 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE job_tree AS (\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n '' as path_label, 0 as depth,\n j.id::text as id_path,\n ''::text as parent_module_type\n FROM v2_job j\n WHERE j.id = $2 AND j.workspace_id = $1\n UNION ALL\n SELECT j.id, j.kind::text, j.flow_step_id, j.parent_job,\n CASE\n WHEN jt.path_label = '' THEN COALESCE(j.flow_step_id, '')\n ELSE jt.path_label || '/' || COALESCE(j.flow_step_id, '')\n END,\n jt.depth + 1,\n jt.id_path || '/' || j.id::text,\n COALESCE((\n SELECT m->'value'->>'type'\n FROM v2_job parent_j\n LEFT JOIN flow_version fv ON fv.id = parent_j.runnable_id\n AND parent_j.kind::text = 'flow'\n LEFT JOIN flow f ON f.path = parent_j.runnable_path\n AND f.workspace_id = parent_j.workspace_id\n LEFT JOIN flow_node fn ON fn.id = parent_j.runnable_id\n CROSS JOIN LATERAL jsonb_array_elements(\n COALESCE(parent_j.raw_flow, fv.value, f.value, fn.flow)->'modules'\n ) m\n WHERE parent_j.id = jt.id\n AND m->>'id' = j.flow_step_id\n LIMIT 1\n ), '')::text\n FROM v2_job j\n JOIN job_tree jt ON j.parent_job = jt.id\n WHERE j.workspace_id = $1\n AND ($4::text[] IS NULL OR j.tag = ANY($4))\n ),\n positions AS (\n SELECT g.parent_job, g.flow_step_id, fj.jid, fj.ord\n FROM (SELECT DISTINCT parent_job, flow_step_id FROM job_tree\n WHERE parent_job IS NOT NULL AND flow_step_id IS NOT NULL) g\n CROSS JOIN LATERAL (\n SELECT COALESCE(\n (SELECT flow_status FROM v2_job_completed WHERE id = g.parent_job),\n (SELECT flow_status FROM v2_job_status WHERE id = g.parent_job)\n ) AS fs\n ) pf\n CROSS JOIN LATERAL (\n SELECT m FROM jsonb_array_elements(pf.fs->'modules') m\n WHERE m->>'id' = g.flow_step_id\n LIMIT 1\n ) md\n CROSS JOIN LATERAL jsonb_array_elements_text(md.m->'flow_jobs')\n WITH ORDINALITY fj(jid, ord)\n ),\n with_sibling_index AS (\n SELECT jt.*,\n ROW_NUMBER() OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ORDER BY pos.ord NULLS LAST, jt.id\n ) as sibling_index,\n COUNT(*) OVER (\n PARTITION BY jt.parent_job, jt.flow_step_id\n ) as sibling_count\n FROM job_tree jt\n LEFT JOIN positions pos ON pos.parent_job = jt.parent_job\n AND pos.flow_step_id = jt.flow_step_id\n AND pos.jid = jt.id::text\n ),\n limited AS (\n SELECT * FROM with_sibling_index ORDER BY id_path ASC LIMIT $5\n )\n SELECT w.id, w.kind, w.flow_step_id, w.path_label,\n w.sibling_index::int as sibling_index,\n w.sibling_count::int as sibling_count,\n w.depth::int as depth,\n w.parent_module_type,\n c.status::text as completed_status,\n c.duration_ms as \"duration_ms?\",\n COALESCE(c.started_at, q.started_at) as started_at,\n LEFT(c.result::text, $3) as result_prefix,\n length(c.result::text) as result_length,\n q.running as \"q_running?\",\n q.suspend as \"q_suspend?\"\n FROM limited w\n LEFT JOIN v2_job_completed c ON c.id = w.id\n LEFT JOIN v2_job_queue q ON q.id = w.id\n ORDER BY w.id_path ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "path_label",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "sibling_index",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "sibling_count",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "depth",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "parent_module_type",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "completed_status",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "duration_ms?",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"name": "result_prefix",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "result_length",
"type_info": "Int4"
},
{
"ordinal": 13,
"name": "q_running?",
"type_info": "Bool"
},
{
"ordinal": 14,
"name": "q_suspend?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int4",
"TextArray",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
false,
null,
null,
null,
false,
false
]
},
"hash": "b8e732a03969666444f73397ac153d23ec2af084b2f93da24c920532c1916384"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by, COALESCE(flow_innermost_root_job, parent_job) as enclosing_job FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "enclosing_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "bd594f06413dd21d7e6923e6754b58fbce8c09e6ec8172d9ed94716958a29b40"
}
+85 -85
View File
@@ -4482,13 +4482,13 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
@@ -7073,7 +7073,7 @@ dependencies = [
"bitflags 2.13.1",
"libc",
"plain",
"redox_syscall 0.9.0",
"redox_syscall 0.9.1",
]
[[package]]
@@ -9920,9 +9920,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e"
dependencies = [
"bitflags 2.13.1",
]
@@ -14489,7 +14489,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-nats",
@@ -14574,7 +14574,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"async-stream",
"async-trait",
@@ -14607,7 +14607,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14620,7 +14620,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"argon2",
@@ -14759,7 +14759,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14782,7 +14782,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14797,7 +14797,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14823,7 +14823,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14833,7 +14833,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14850,7 +14850,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14872,7 +14872,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14895,7 +14895,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14932,7 +14932,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14953,7 +14953,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15002,7 +15002,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15027,7 +15027,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -15045,7 +15045,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15067,7 +15067,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15087,7 +15087,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15124,7 +15124,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15152,7 +15152,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"lazy_static",
"serde",
@@ -15164,7 +15164,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15189,7 +15189,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15203,7 +15203,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15238,7 +15238,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"chrono",
"lazy_static",
@@ -15252,7 +15252,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15271,7 +15271,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15375,7 +15375,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15394,7 +15394,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"regex",
"serde",
@@ -15409,7 +15409,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15433,7 +15433,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"futures",
@@ -15450,7 +15450,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15466,7 +15466,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15487,7 +15487,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15518,7 +15518,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"arc-swap",
@@ -15543,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-stream",
@@ -15577,7 +15577,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"futures",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15604,7 +15604,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15616,7 +15616,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15628,7 +15628,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"gosyn",
@@ -15640,7 +15640,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15652,7 +15652,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15664,7 +15664,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -15675,7 +15675,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15686,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15698,7 +15698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15709,7 +15709,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15731,7 +15731,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15743,7 +15743,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15757,7 +15757,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15774,7 +15774,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15787,7 +15787,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde",
@@ -15799,7 +15799,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15817,7 +15817,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15833,7 +15833,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15849,7 +15849,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde",
@@ -15860,7 +15860,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15899,7 +15899,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"const_format",
@@ -15939,7 +15939,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15950,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15984,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16008,7 +16008,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16041,7 +16041,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16068,7 +16068,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16101,7 +16101,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16121,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16155,7 +16155,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16191,7 +16191,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16214,7 +16214,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16238,7 +16238,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16262,7 +16262,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16297,7 +16297,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16325,7 +16325,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16350,7 +16350,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16369,7 +16369,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16484,7 +16484,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.773.0"
version = "1.775.1"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.773.0"
version = "1.775.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
72b9d9fd2eece229631c6dc13bfb010c8ea664ac
4e0358ab7301a9cffe29508dd5cd75bcf5eee319
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.773.0"
version = "1.775.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.773.0"
version = "1.775.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -0,0 +1,300 @@
//! Regression test for running data table migrations against a database whose
//! role only holds DML grants.
//!
//! Two failure modes are pinned here:
//! - the Postgres message must reach the caller. `tokio_postgres::Error`'s
//! `Display` renders only the error kind, so interpolating it with `{}`
//! produced a bare `Failed to ensure _wm_migrations table: db error`.
//! - `CREATE TABLE IF NOT EXISTS` checks CREATE on the schema *before* it
//! checks existence, so the run must probe for `_wm_migrations` first or an
//! unprivileged role can never migrate, even against a pre-created table.
//!
//! Plus the privilege report that surfaces the same state from workspace
//! settings before anyone reaches a migration.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ROLE: &str = "wm_dtmig_test_role";
const ROLE_PASSWORD: &str = "wm_dtmig_test_pwd";
/// Deliberately hyphenated: it only parses inside double quotes, so it pins that
/// the emitted recovery statement quotes the role rather than interpolating it.
const NOSCHEMA_ROLE: &str = "wm-dtmig-noschema";
fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
b.header("Authorization", "Bearer DTMIG_ADMIN_TOKEN")
}
/// Point the fixture's data table at this test's own database, connecting as a
/// role that may read and write but not create: `GRANT USAGE` without `CREATE`,
/// and the schema's own CREATE revoked from PUBLIC so the outcome does not
/// depend on the server's default `public` grants (relaxed before Postgres 15).
async fn setup_unprivileged_datatable_role(db: &Pool<Postgres>) -> anyhow::Result<()> {
let opts = (*db.connect_options()).clone();
let dbname = opts.get_database().expect("test database name").to_string();
sqlx::query(&format!(
// Roles are cluster objects, not per-test-database ones. A previous run
// leaving the role behind raises duplicate_object; the tests in this
// binary run in parallel, so two sessions can also clear that check
// together and collide on pg_authid's unique index instead.
"DO $$ BEGIN \
CREATE ROLE {ROLE} LOGIN PASSWORD '{ROLE_PASSWORD}'; \
EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \
END $$"
))
.execute(db)
.await?;
sqlx::raw_sql(&format!(
"REVOKE CREATE ON SCHEMA public FROM PUBLIC; \
GRANT CONNECT ON DATABASE \"{dbname}\" TO {ROLE}; \
GRANT USAGE ON SCHEMA public TO {ROLE};"
))
.execute(db)
.await?;
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \
VALUES ('dtmig-ws', 'u/dtmig-admin/pg', $1, 'postgresql', 'dtmig-admin')",
)
.bind(json!({
"host": opts.get_host(),
"port": opts.get_port(),
"dbname": dbname,
"user": ROLE,
"password": ROLE_PASSWORD,
"sslmode": "disable",
}))
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("datatable_migrations_grants"))]
async fn test_run_migrations_without_create_privilege(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
setup_unprivileged_datatable_role(&db).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let url =
format!("http://localhost:{port}/api/w/dtmig-ws/workspaces/run_datatable_migrations/main");
// No `_wm_migrations` yet and no way to create one: the caller must be told
// what Postgres actually refused, not "db error".
let resp = authed(reqwest::Client::new().post(&url)).send().await?;
assert_eq!(resp.status(), 500);
let body = resp.text().await?;
assert!(
body.contains("permission denied for schema"),
"the Postgres message should reach the caller, got: {body}"
);
// The suggested statement must be complete and quoted, not a placeholder.
assert!(
body.contains(&format!("GRANT CREATE ON SCHEMA \"public\" TO \"{ROLE}\"")),
"the hint should name the actual role and schema, got: {body}"
);
// Once an operator has created the bookkeeping table and granted DML on it,
// migrations run even though the role still cannot create tables.
sqlx::raw_sql(&format!(
"CREATE TABLE _wm_migrations ( \
datatable TEXT NOT NULL, \
version BIGINT NOT NULL, \
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), \
PRIMARY KEY (datatable, version)); \
GRANT SELECT, INSERT, UPDATE, DELETE ON _wm_migrations TO {ROLE};"
))
.execute(&db)
.await?;
let resp = authed(reqwest::Client::new().post(&url)).send().await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 200,
"run should succeed on a pre-created table: {body}"
);
Ok(())
}
#[sqlx::test(fixtures("datatable_migrations_grants"))]
async fn test_datatable_connection_report(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
setup_unprivileged_datatable_role(&db).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let url =
format!("http://localhost:{port}/api/w/dtmig-ws/workspaces/test_datatable_connection/main");
// The report is a privilege disclosure about the data table's database, so
// it stays behind the same bar as editing the data table config.
let resp = reqwest::Client::new()
.get(&url)
.header("Authorization", "Bearer DTMIG_USER_TOKEN")
.send()
.await?;
assert_eq!(resp.status(), 403, "non-admins must not get the report");
let report: Value = authed(reqwest::Client::new().get(&url))
.send()
.await?
.json()
.await?;
assert_eq!(report["user"], ROLE);
assert_eq!(report["schema"], "public");
assert_eq!(report["can_create_table"], false);
assert_eq!(report["can_create_schema"], false);
let grants = report["suggested_grants"].as_array().unwrap();
assert!(
grants
.iter()
.any(|g| g.as_str().unwrap()
== format!("GRANT CREATE ON SCHEMA \"public\" TO \"{ROLE}\"")),
"missing schema grant: {report}"
);
// Pin the name, not just the shape: the endpoint reads it from
// `current_database()` rather than the resource, and a prefix assertion
// would pass either way.
let dbname = (*db.connect_options())
.clone()
.get_database()
.expect("test database name")
.to_string();
assert!(
grants.iter().any(|g| g.as_str().unwrap()
== format!("GRANT CREATE ON DATABASE \"{dbname}\" TO \"{ROLE}\"")),
"missing database grant for {dbname}: {report}"
);
// A pre-created bookkeeping table lets migration *tracking* work, but the
// role still cannot create anything: the report must keep saying so rather
// than falling silent because nothing needs creating right now.
sqlx::raw_sql(&format!(
"CREATE TABLE _wm_migrations ( \
datatable TEXT NOT NULL, \
version BIGINT NOT NULL, \
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), \
PRIMARY KEY (datatable, version)); \
GRANT SELECT, INSERT, UPDATE, DELETE ON _wm_migrations TO {ROLE};"
))
.execute(&db)
.await?;
let report: Value = authed(reqwest::Client::new().get(&url))
.send()
.await?
.json()
.await?;
assert_eq!(report["migrations_table_exists"], true);
assert_eq!(report["can_create_table"], false);
assert!(
report["suggested_grants"]
.as_array()
.unwrap()
.iter()
.any(|g| g.as_str().unwrap().contains("ON SCHEMA")),
"an existing bookkeeping table must not suppress the schema grant: {report}"
);
// Granting the privileges clears the suggestions.
sqlx::raw_sql(&format!(
"GRANT CREATE ON SCHEMA public TO {ROLE}; \
GRANT CREATE ON DATABASE \"{dbname}\" TO {ROLE};"
))
.execute(&db)
.await?;
let report: Value = authed(reqwest::Client::new().get(&url))
.send()
.await?
.json()
.await?;
assert_eq!(report["can_create_table"], true);
assert_eq!(report["can_create_schema"], true);
assert_eq!(report["suggested_grants"].as_array().unwrap().len(), 0);
Ok(())
}
/// Point the fixture's second data table at a role whose `search_path` resolves
/// to nothing, the one state where no grant helps.
async fn setup_schemaless_datatable_role(db: &Pool<Postgres>) -> anyhow::Result<()> {
let opts = (*db.connect_options()).clone();
let dbname = opts.get_database().expect("test database name").to_string();
sqlx::query(&format!(
"DO $$ BEGIN \
CREATE ROLE \"{NOSCHEMA_ROLE}\" LOGIN PASSWORD '{ROLE_PASSWORD}'; \
EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \
END $$"
))
.execute(db)
.await?;
// Cluster-wide for this role, which is why it gets one of its own rather
// than sharing the role the other assertions connect with.
sqlx::raw_sql(&format!(
"ALTER ROLE \"{NOSCHEMA_ROLE}\" SET search_path = wm_dtmig_absent_schema; \
GRANT CONNECT ON DATABASE \"{dbname}\" TO \"{NOSCHEMA_ROLE}\";"
))
.execute(db)
.await?;
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \
VALUES ('dtmig-ws', 'u/dtmig-admin/pg_noschema', $1, 'postgresql', 'dtmig-admin')",
)
.bind(json!({
"host": opts.get_host(),
"port": opts.get_port(),
"dbname": dbname,
"user": NOSCHEMA_ROLE,
"password": ROLE_PASSWORD,
"sslmode": "disable",
}))
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("datatable_migrations_grants"))]
async fn test_datatable_connection_without_a_resolvable_schema(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
setup_schemaless_datatable_role(&db).await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let report: Value = authed(reqwest::Client::new().get(format!(
"http://localhost:{port}/api/w/dtmig-ws/workspaces/test_datatable_connection/noschema"
)))
.send()
.await?
.json()
.await?;
assert!(report["schema"].is_null(), "expected no schema: {report}");
// No grant fixes an empty search_path, so suggesting one would send the
// reader after a statement that changes nothing.
assert!(
!report["suggested_grants"]
.as_array()
.unwrap()
.iter()
.any(|g| g.as_str().unwrap().contains("ON SCHEMA")),
"an empty search_path must not yield a schema grant: {report}"
);
assert_eq!(
report["suggested_search_path"],
format!("ALTER ROLE \"{NOSCHEMA_ROLE}\" SET search_path = public")
);
Ok(())
}
+35
View File
@@ -0,0 +1,35 @@
-- Fixture for the data table migration bookkeeping-grants regression test.
-- Workspace + admin token + a data table pointing at a postgres resource; the
-- test fills that resource in with credentials for a deliberately unprivileged
-- role, since the database name is allocated per test run.
INSERT INTO workspace (id, name, owner) VALUES
('dtmig-ws', 'DTMIG WS', 'dtmig-admin');
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
('dtmig-ws', 'cloud', 'dtmig-key');
INSERT INTO workspace_settings (workspace_id, datatable) VALUES
('dtmig-ws', '{"datatables": {"main": {"database": {"resource_type": "postgresql", "resource_path": "u/dtmig-admin/pg"}, "migrations_enabled": true}, "noschema": {"database": {"resource_type": "postgresql", "resource_path": "u/dtmig-admin/pg_noschema"}, "migrations_enabled": true}}}');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('dtmig-ws', 'all', 'All users', '{}');
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('dtmig-admin@windmill.dev', 'x', 'password', true, true, 'DTMIG Admin', 'dtmig-admin');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('dtmig-ws', 'dtmig-admin@windmill.dev', 'dtmig-admin', true, 'Admin');
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('DTMIG_ADMIN_TOKEN'::bytea), 'hex'), 'DTMIG_ADM', 'DTMIG_ADMIN_TOKEN', 'dtmig-admin@windmill.dev', 't', true);
-- Non-admin member, to pin that the privilege report stays admin-only.
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('dtmig-user@windmill.dev', 'x', 'password', false, true, 'DTMIG User', 'dtmig-user');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('dtmig-ws', 'dtmig-user@windmill.dev', 'dtmig-user', false, 'User');
INSERT INTO token(token_hash, token_prefix, token, email, label)
VALUES (encode(sha256('DTMIG_USER_TOKEN'::bytea), 'hex'), 'DTMIG_USR', 'DTMIG_USER_TOKEN', 'dtmig-user@windmill.dev', 't');
+1 -5
View File
@@ -422,11 +422,7 @@ async fn sign_expression(
let mut tx = user_db.begin(&authed).await?;
// Truncate expression for resource field if too long (max 255 chars)
let resource = if request.expression.len() > 200 {
format!("{}...", &request.expression[..200])
} else {
request.expression.clone()
};
let resource = windmill_common::utils::truncate_with_ellipsis(&request.expression, 200);
audit_log(
&mut *tx,
+2 -2
View File
@@ -53,7 +53,7 @@ use windmill_common::{
auth::is_super_admin_email,
ee_oss::{get_license_plan, LicensePlan},
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
error::{self, pg_error_message, JsonResult, Result},
get_database_url,
global_settings::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
@@ -1705,7 +1705,7 @@ async fn setup_custom_instance_pg_database_inner(
.map_err(|e| {
error::Error::ExecutionErr(format!(
"Failed to grant permissions to custom_instance_user: {}",
e.to_string(),
pg_error_message(&e),
))
})?;
@@ -21,14 +21,16 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
use std::collections::{HashMap, HashSet};
use tokio_postgres::error::SqlState;
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_api_jobs::run_wait_result_internal;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::error::{Error, JsonResult, Result};
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, DebouncingSettings};
use windmill_common::scripts::ScriptLang;
use windmill_common::users::username_to_permissioned_as;
@@ -222,7 +224,31 @@ async fn run_datatable_migration_job(
/// key would let one data table's migration mark another's same-version
/// migration as already applied (and rollback could touch the wrong row).
async fn ensure_wm_migrations_schema(client: &tokio_postgres::Client) -> Result<()> {
client
// `CREATE TABLE IF NOT EXISTS` checks CREATE on the schema before it checks
// existence, so probing first is what lets a data table whose role only holds
// DML grants keep migrating against an already-created bookkeeping table.
// `to_regclass` resolves through search_path, like the unqualified statements
// the rest of this module runs against it. Takes no parameters, so it goes
// through the simple protocol: a named prepared statement is what stalls
// behind a transaction-pooling proxy (see `pg_get_full_schema`).
let rows = client
.simple_query("SELECT to_regclass('_wm_migrations') IS NOT NULL AS present")
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to look up _wm_migrations table: {}",
pg_error_message(&e)
))
})?;
let exists = rows.iter().any(|msg| match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => row.get("present") == Some("t"),
_ => false,
});
if exists {
return Ok(());
}
let Err(e) = client
.batch_execute(
"CREATE TABLE IF NOT EXISTS _wm_migrations (\
datatable TEXT NOT NULL, \
@@ -231,10 +257,53 @@ async fn ensure_wm_migrations_schema(client: &tokio_postgres::Client) -> Result<
PRIMARY KEY (datatable, version))",
)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to ensure _wm_migrations table: {}", e))
})?;
Ok(())
else {
return Ok(());
};
let mut msg = format!(
"Failed to ensure _wm_migrations table: {}",
pg_error_message(&e)
);
// A role with only table-level grants cannot create it: since Postgres 15 the
// `public` schema no longer grants CREATE to PUBLIC, so this is the usual
// failure on a bring-your-own database.
if e.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE) {
// Windmill connects as the role that lacks the privilege, so it cannot
// grant it: hand over the statement a schema owner has to run instead.
// Keep it ahead of the explanation below — the UI collapses everything
// past the first couple of lines behind a "Show more".
if let Some((user, schema)) = connection_identity(client).await {
// Both come back unquoted, so a mixed-case or hyphenated name would
// otherwise render a statement that targets a different schema.
msg.push_str(&format!(
". Run: GRANT CREATE ON SCHEMA {} TO {}",
render_db_quoted_identifier(&schema, DbType::Postgresql),
render_db_quoted_identifier(&user, DbType::Postgresql),
));
}
msg.push_str(
". Applied migrations are recorded in a `_wm_migrations` table in the data \
table's own database, so its user needs to be able to create it",
);
}
Err(Error::internal_err(msg))
}
/// The role and default schema of a data table connection, for grant hints.
/// Both come from the server so the statement we suggest names what the
/// connection actually resolves to, not what the resource happens to say.
async fn connection_identity(client: &tokio_postgres::Client) -> Option<(String, String)> {
let rows = client
.simple_query("SELECT current_user AS usr, current_schema() AS sch")
.await
.ok()?;
rows.iter().find_map(|msg| match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => {
Some((row.get("usr")?.to_string(), row.get("sch")?.to_string()))
}
_ => None,
})
}
/// Open a connection to a data table's own database and hold the session-level
@@ -265,7 +334,12 @@ async fn lock_datatable_migration_runs(
client
.batch_execute("SELECT pg_advisory_lock(hashtext('windmill_datatable_migrations')::int8)")
.await
.map_err(|e| Error::internal_err(format!("Failed to acquire migration lock: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!(
"Failed to acquire migration lock: {}",
pg_error_message(&e)
))
})?;
Ok(client)
}
@@ -287,7 +361,7 @@ async fn read_applied_versions_on_client(
Err(e) if e.as_db_error().map(|d| d.code().code()) == Some("42P01") => Ok(HashSet::new()),
Err(e) => Err(Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
e
pg_error_message(&e)
))),
}
}
@@ -366,7 +440,12 @@ async fn run_datatable_migrations(
&[&datatable_name, &m.timestamp],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to record migration: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!(
"Failed to record migration: {}",
pg_error_message(&e)
))
})?;
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
}
@@ -433,7 +512,12 @@ async fn rollback_datatable_migrations(
&[&datatable_name, &only],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to read _wm_migrations: {}", e)))?,
.map_err(|e| {
Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))
})?,
None => client
.query_opt(
"SELECT version FROM _wm_migrations WHERE datatable = $1 \
@@ -441,7 +525,12 @@ async fn rollback_datatable_migrations(
&[&datatable_name],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to read _wm_migrations: {}", e)))?,
.map_err(|e| {
Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))
})?,
};
let version: i64 = match target {
@@ -492,7 +581,12 @@ async fn rollback_datatable_migrations(
&[&datatable_name, &version],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to drop migration record: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!(
"Failed to drop migration record: {}",
pg_error_message(&e)
))
})?;
Ok(Json(RollbackDatatableMigrationsResult {
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
@@ -940,7 +1034,10 @@ async fn mark_datatable_version_installed(
)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to mark initial migration installed: {}", e))
Error::internal_err(format!(
"Failed to mark initial migration installed: {}",
pg_error_message(&e)
))
})?;
Ok(())
}
@@ -1378,7 +1475,7 @@ fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> {
Some("42P01") => Ok(()),
_ => Err(Error::internal_err(format!(
"Failed to update _wm_migrations: {}",
e
pg_error_message(&e)
))),
}
}
+197 -17
View File
@@ -34,6 +34,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
@@ -51,7 +52,7 @@ use windmill_common::workspaces::{
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
use windmill_common::PgDatabase;
use windmill_common::{
error::{Error, JsonResult, Result},
error::{pg_error_message, Error, JsonResult, Result},
global_settings::{
AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING,
},
@@ -133,6 +134,10 @@ pub fn workspaced_service() -> Router {
get(get_datatable_table_schema),
)
.route("/edit_datatable_config", post(edit_datatable_config))
.route(
"/test_datatable_connection/{datatable_name}",
get(test_datatable_connection),
)
.merge(crate::datatable_migrations::routes())
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode))
@@ -2023,6 +2028,127 @@ struct DataTableTableSchema {
columns: ColumnMap,
}
#[derive(Serialize, Debug)]
struct DataTableConnectionCheck {
/// The role the data table actually connects as, and the schema its
/// unqualified statements resolve to. Both are read from the server rather
/// than the resource, which need not spell either of them out.
user: String,
schema: Option<String>,
/// Whether that role can create tables in `schema` / schemas in the database.
can_create_table: bool,
can_create_schema: bool,
/// Whether the migration bookkeeping table is already present. Informative
/// only: it explains why migration *tracking* can work without CREATE, and
/// grants nothing beyond that.
migrations_table_exists: bool,
/// Statements to run for the privileges that are missing, empty when there
/// are none. Windmill connects as the role that lacks them, so it can only
/// name them for a schema owner to run.
suggested_grants: Vec<String>,
/// Statement that gives the session a schema to work in, when `search_path`
/// resolves to none. Rendered here rather than by the caller so identifier
/// quoting stays in one place.
#[serde(skip_serializing_if = "Option::is_none")]
suggested_search_path: Option<String>,
}
/// Report what the data table's own database lets its role do. Surfacing this
/// from the settings page is the difference between finding out here and finding
/// out on a first schema change, when the failure reads as a Postgres refusal
/// deep inside a migration.
async fn test_datatable_connection(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DataTableConnectionCheck> {
require_admin(authed.is_admin, &authed.username)?;
let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
let (client, connection) = pg_db.connect(Some(&db)).await?;
let join_handle = tokio::spawn(async move { connection.await });
// One round trip, no side effects: `has_*_privilege` answers for the
// connected role without attempting the operation.
let rows = client
.simple_query(
"SELECT current_user AS usr, \
current_schema() AS sch, \
current_database() AS db, \
has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, \
has_database_privilege(current_database(), 'CREATE') AS can_create_schema, \
to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table",
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to inspect data table privileges: {}",
pg_error_message(&e)
))
});
drop(client);
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
let row = rows?
.into_iter()
.find_map(|msg| match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => Some(row),
_ => None,
})
.ok_or_else(|| Error::internal_err("Privilege query returned no row".to_string()))?;
let user = row.get("usr").unwrap_or_default().to_string();
let schema = row.get("sch").map(str::to_string);
let can_create_table = row.get("can_create_table") == Some("t");
let can_create_schema = row.get("can_create_schema") == Some("t");
let migrations_table_exists = row.get("has_migrations_table") == Some("t");
let quoted_user = render_db_quoted_identifier(&user, DbType::Postgresql);
let mut suggested_grants = Vec::new();
// Suggest on the capability alone: an existing `_wm_migrations` spares only
// that one table, and says nothing about the tables a migration will create.
// A NULL `current_schema()` means search_path resolves to nothing, and no
// grant fixes that — an unqualified CREATE fails with `no schema has been
// selected to create in` whoever holds the privilege — so suggest nothing
// and let `schema: null` carry the diagnosis.
if let (false, Some(target)) = (can_create_table, schema.as_deref()) {
suggested_grants.push(format!(
"GRANT CREATE ON SCHEMA {} TO {}",
render_db_quoted_identifier(target, DbType::Postgresql),
quoted_user
));
}
if !can_create_schema {
// Named from the server like every other identifier here: behind a
// pooler the resource's dbname can be an alias for another database.
let dbname = row.get("db").unwrap_or(pg_db.dbname.as_str());
suggested_grants.push(format!(
"GRANT CREATE ON DATABASE {} TO {}",
render_db_quoted_identifier(dbname, DbType::Postgresql),
quoted_user
));
}
// An empty search_path is not a privilege problem, so it gets a statement of
// its own rather than a grant.
let suggested_search_path = schema
.is_none()
.then(|| format!("ALTER ROLE {quoted_user} SET search_path = public"));
Ok(Json(DataTableConnectionCheck {
user,
schema,
can_create_table,
can_create_schema,
migrations_table_exists,
suggested_grants,
suggested_search_path,
}))
}
async fn list_datatable_schemas(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -2139,7 +2265,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
&[],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e)))
})?;
// Build hierarchical structure: schema -> table -> column -> compact_type
let mut schema_map: SchemaMap = HashMap::new();
@@ -2173,7 +2301,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
&[&schema_names],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e)))
})?;
for row in rows {
let table_schema: String = row.get(0);
@@ -2221,7 +2351,9 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
&[],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e)))
})?;
let mut table_map: TableListMap = HashMap::new();
let schema_names: Vec<String> = schema_rows
@@ -2247,7 +2379,9 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
&[&schema_names],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to query tables: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!("Failed to query tables: {}", pg_error_message(&e)))
})?;
for row in rows {
let table_schema: String = row.get(0);
@@ -2299,7 +2433,9 @@ async fn get_datatable_table_columns(
&[&schema_name, &table_name],
)
.await
.map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?;
.map_err(|e| {
Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e)))
})?;
if rows.is_empty() {
return Err(Error::NotFound(format!(
@@ -2671,7 +2807,10 @@ async fn create_pg_database(
)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to check database existence: {}", e))
Error::internal_err(format!(
"Failed to check database existence: {}",
pg_error_message(&e)
))
})?;
let db_exists: bool = row.get(0);
@@ -2690,7 +2829,8 @@ async fn create_pg_database(
.map_err(|e| {
Error::internal_err(format!(
"Failed to create database '{}': {}",
req.target_dbname, e
req.target_dbname,
pg_error_message(&e)
))
})?;
@@ -3209,20 +3349,60 @@ async fn check_dev_promotion_targets_parent_repo<'a>(
repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
) -> Result<()> {
for r in repos.filter(|r| r.use_individual_branch.unwrap_or(false)) {
if !windmill_common::git_sync_ee::dev_promotion_target_matches_parent(
let Some(m) = windmill_common::git_sync_ee::dev_promotion_target_mismatch(
db,
w_id,
&r.git_repo_resource_path,
)
.await?
{
return Err(Error::BadRequest(
"Promotion mode on a dev workspace must reuse the parent workspace's git repository \
(same URL and branch), but this repository is not one the parent tracks promotion \
would target a repository the parent does not sync with."
.to_string(),
));
}
else {
continue;
};
// A resource with no `branch` field normalizes to an empty string.
let named = |b: &str| {
if b.is_empty() {
"<none>".to_string()
} else {
b.to_string()
}
};
let branch = named(&m.branch);
let hint = if m.parent_branches.is_empty() {
format!(
"the parent workspace '{}' does not track that repository. Point this repository at \
the one the parent syncs with, or add it to the parent's git sync settings.",
m.parent_workspace
)
} else {
// With no branch of its own there is nothing for the parent to add,
// so only offer the side of the advice that can be acted on.
let other_way = if m.branch.is_empty() {
String::new()
} else {
format!(", or add branch '{branch}' to the parent's git sync settings")
};
format!(
"the parent workspace '{}' tracks it on {} '{}'. Set this repository's branch to \
match{other_way}.",
m.parent_workspace,
if m.parent_branches.len() > 1 {
"branches"
} else {
"branch"
},
m.parent_branches
.iter()
.map(|b| named(b))
.collect::<Vec<_>>()
.join("', '")
)
};
return Err(Error::BadRequest(format!(
"Promotion mode on a dev workspace must reuse the parent workspace's git repository \
(same URL and branch). Repository '{}' targets '{}' on branch '{branch}', but {hint}",
r.git_repo_resource_path.trim_start_matches("$res:"),
m.repo,
)));
}
Ok(())
}
+157 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.773.0
version: 1.775.1
title: Windmill API
contact:
@@ -4633,6 +4633,52 @@ paths:
items:
$ref: "#/components/schemas/DataTableSchema"
/w/{workspace}/workspaces/test_datatable_connection/{datatable_name}:
get:
summary: check what the data table's database lets its role do
operationId: testDataTableConnection
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: connection and privilege report
content:
application/json:
schema:
type: object
properties:
user:
type: string
schema:
type: string
nullable: true
can_create_table:
type: boolean
can_create_schema:
type: boolean
migrations_table_exists:
type: boolean
suggested_grants:
type: array
items:
type: string
suggested_search_path:
type: string
required:
- user
- schema
- can_create_table
- can_create_schema
- migrations_table_exists
- suggested_grants
/w/{workspace}/workspaces/list_datatable_tables:
get:
summary: list tables of all connected Datatables
@@ -14215,6 +14261,116 @@ paths:
- sibling_count
- logs
/w/{workspace}/jobs_u/get_flow_all_results/{id}:
get:
summary: get statuses and truncated results for all jobs of a flow job's execution tree
operationId: getFlowAllResults
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
- name: max_result_len
in: query
description: per-entry cap (in characters of JSON text) on result_prefix (default 2000, max 30000)
schema:
type: integer
- name: step
in: query
description: 'step address to resolve to a single job instead of enumerating the tree: "b", "b/c", "b[12]" (1-based iteration/branch), composable as "b[12]/c"'
schema:
type: string
responses:
"200":
description: per-job statuses and truncated results of the flow's execution tree, or the single resolved entry when step is provided
content:
application/json:
schema:
type: object
properties:
enclosing_job:
type: string
format: uuid
description: set when the requested job is itself a step of a larger flow run; id of the flow run directly enclosing it
entries:
type: array
items:
type: object
properties:
job_id:
type: string
label:
type: string
description: human-readable label describing the job's position in the flow tree
kind:
type: string
description: job kind (script, flow, forloopflow, ...)
flow_step_id:
type: string
nullable: true
step_path:
type: string
nullable: true
description: materialized step path (e.g. "a/b")
depth:
type: integer
description: depth in the flow tree (0 for the root flow job)
parent_module_type:
type: string
nullable: true
description: parent module type (forloopflow, branchall, ...)
sibling_index:
type: integer
description: 1-based index of this job among siblings sharing the same step
sibling_count:
type: integer
description: total number of siblings sharing the same step
status:
type: string
enum:
[
success,
failure,
canceled,
skipped,
suspended,
running,
queued,
]
success:
type: boolean
duration_ms:
type: integer
format: int64
started_at:
type: string
format: date-time
result_prefix:
type: string
description: result JSON text truncated to the per-entry budget; absent until the job has completed
result_length:
type: integer
description: full length in characters of the result JSON text (greater than the prefix length when truncated)
required:
- job_id
- label
- kind
- depth
- sibling_index
- sibling_count
- status
truncated:
type: boolean
description: true when the tree has more jobs than the entry cap; entries then hold the depth-first prefix
scope_filtered:
type: boolean
description: true when the caller's token is tag-scoped; steps running on other tags are omitted
step_error:
type: string
description: set when step was provided but could not be resolved; a diagnostic listing available step ids or iteration statuses
required:
- entries
/w/{workspace}/jobs_u/get_completed_logs_tail/{id}:
get:
summary: get completed job logs tail
File diff suppressed because it is too large Load Diff
+87 -14
View File
@@ -297,20 +297,54 @@ impl std::fmt::Debug for FlowData {
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
/// Top-level fields of a stored flow value that [`FlowValue`] does not model, so parsing a
/// flow into a `FlowValue` drops them. Every write-back that round-trips a stored flow
/// through `FlowValue` must capture them first and re-attach them with
/// [`FlowExtras::reattach`], or they are destroyed on save. Adding a display-only flow
/// field means adding it here — this is the only list, and `FlowValue` must never model a
/// field named here: `reattach` flattens the two together, so a name in both would be
/// emitted twice and the value would no longer deserialize.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct FlowExtras {
pub notes: Option<Box<RawValue>>,
pub groups: Option<Box<RawValue>>,
}
impl FlowExtras {
/// Serialize `flow` with these extras folded back in. Fallible on purpose: the result is
/// written straight over a deployed flow value, so a serialization failure must abort the
/// write rather than persist a truncated value.
pub fn reattach(&self, flow: &FlowValue) -> error::Result<Box<RawValue>> {
// `flatten` + `RawValue` is fine for serialization; only deserialization breaks.
#[derive(Serialize)]
struct FlowValueWithExtras<'a> {
#[serde(flatten)]
flow: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<&'a Box<RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
groups: Option<&'a Box<RawValue>>,
}
serde_json::value::to_raw_value(&FlowValueWithExtras {
flow,
notes: self.notes.as_ref(),
groups: self.groups.as_ref(),
})
.map_err(|e| error::Error::internal_err(format!("Failed to serialize flow value: {e}")))
}
/// Capture the extras carried by a raw stored flow value.
pub fn capture(raw_flow: &RawValue) -> Self {
serde_json::from_str::<FlowExtras>(raw_flow.get())
.map_err(|e| tracing::warn!("Failed to parse flow extras: {e}"))
.unwrap_or_default()
}
}
impl FlowData {
pub fn extras(&self) -> Option<FlowExtras> {
serde_json::from_str::<FlowExtras>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse flow extras: {}", e);
error::Error::internal_err(format!("Failed to parse flow extras: {}", e))
})
.ok()
pub fn extras(&self) -> FlowExtras {
FlowExtras::capture(&self.raw_flow)
}
}
impl FlowData {
@@ -1294,7 +1328,7 @@ mod tests {
assert!(data.value().modules.is_empty());
// But extras() recovers them from the raw JSON
let extras = data.extras().expect("extras should parse");
let extras = data.extras();
let notes: serde_json::Value =
serde_json::from_str(extras.notes.expect("notes present").get()).unwrap();
assert_eq!(notes.as_array().unwrap().len(), 1);
@@ -1312,9 +1346,7 @@ mod tests {
let raw = serde_json::value::to_raw_value(&json!({"modules": []})).unwrap();
let data = FlowData::from_raw(raw).unwrap();
let extras = data
.extras()
.expect("extras should parse even without notes/groups");
let extras = data.extras();
assert!(extras.notes.is_none());
assert!(extras.groups.is_none());
}
@@ -1337,10 +1369,51 @@ mod tests {
let data2 = FlowData::from_raw(stripped_raw).unwrap();
// Notes are gone after the FlowValue round-trip
let extras = data2.extras().expect("extras should parse");
assert!(
extras.notes.is_none(),
data2.extras().notes.is_none(),
"notes lost after FlowValue round-trip"
);
}
#[test]
fn flow_extras_reattach_restores_what_the_roundtrip_drops() {
// Every write-back that re-serializes a stored flow through FlowValue must go
// through reattach, or notes/groups are destroyed.
let raw = serde_json::value::to_raw_value(&json!({
"modules": [{
"id": "a",
"value": {"type": "rawscript", "content": "x", "language": "bun",
"input_transforms": {}}
}],
"same_worker": true,
"notes": [{"id": "n1", "text": "t", "color": "blue", "type": "free"}],
"groups": [{"start_id": "a", "end_id": "b", "summary": "grp"}]
}))
.unwrap();
let data = FlowData::from_raw(raw.clone()).unwrap();
let reattached: serde_json::Value =
serde_json::from_str(data.extras().reattach(data.value()).unwrap().get()).unwrap();
let original: serde_json::Value = serde_json::from_str(raw.get()).unwrap();
for key in original.as_object().unwrap().keys() {
assert_eq!(
reattached.get(key),
original.get(key),
"{key} did not survive the FlowValue round-trip"
);
}
// Absent extras must stay absent rather than serialize as null, which would show
// up as a spurious change in flow diffs.
let without =
FlowData::from_raw(serde_json::value::to_raw_value(&json!({ "modules": [] })).unwrap())
.unwrap();
let output = without
.extras()
.reattach(without.value())
.unwrap()
.to_string();
assert!(!output.contains("notes") && !output.contains("groups"));
}
}
+87
View File
@@ -247,6 +247,41 @@ pub fn to_anyhow<T: 'static + std::error::Error + Send + Sync>(e: T) -> anyhow::
From::from(e)
}
/// Render a `tokio_postgres` error for a user-facing message.
///
/// The pinned rust-postgres build prints only the error *kind* in its `Display`
/// impl, so `format!("{e}")` on one of these yields the useless `db error` and
/// drops the Postgres message. Interpolate errors from a `tokio_postgres::Client`
/// through this instead.
pub fn pg_error_message(e: &tokio_postgres::Error) -> String {
match e.as_db_error() {
Some(db_err) => format_db_error(db_err.message(), db_err.detail(), db_err.hint()),
// Non-database failures (io, tls, protocol) keep their message in the cause.
None => error_source_chain(e),
}
}
fn format_db_error(message: &str, detail: Option<&str>, hint: Option<&str>) -> String {
let mut msg = message.to_string();
if let Some(detail) = detail {
msg.push_str(&format!(" ({detail})"));
}
if let Some(hint) = hint {
msg.push_str(&format!(". Hint: {hint}"));
}
msg
}
fn error_source_chain(e: &dyn std::error::Error) -> String {
let mut msg = e.to_string();
let mut source = e.source();
while let Some(cause) = source {
msg.push_str(&format!(": {cause}"));
source = cause.source();
}
msg
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = match self {
@@ -419,4 +454,56 @@ mod tests {
let rendered = Error::JsonErr(v).to_string();
assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]");
}
#[test]
fn db_error_renders_message_with_detail_and_hint() {
assert_eq!(
super::format_db_error("permission denied for schema public", None, None),
"permission denied for schema public"
);
assert_eq!(
super::format_db_error("insert violates foreign key", Some("Key (id)=(1)"), None),
"insert violates foreign key (Key (id)=(1))"
);
assert_eq!(
super::format_db_error(
"column does not exist",
None,
Some("Perhaps you meant \"b\"")
),
"column does not exist. Hint: Perhaps you meant \"b\""
);
}
#[test]
fn non_db_error_walks_the_source_chain() {
#[derive(Debug)]
struct Layer(&'static str, Option<Box<Layer>>);
impl std::fmt::Display for Layer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Layer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1
.as_ref()
.map(|c| c.as_ref() as &(dyn std::error::Error + 'static))
}
}
// The pinned rust-postgres build renders only the kind, so everything
// actionable is in the causes: they must all reach the message.
let err = Layer(
"error connecting to server",
Some(Box::new(Layer(
"tcp connect error",
Some(Box::new(Layer("timed out", None))),
))),
);
assert_eq!(
super::error_source_chain(&err),
"error connecting to server: tcp connect error: timed out"
);
}
}
@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::db::DB;
use crate::error::Result;
use crate::utils::truncate_with_ellipsis;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "MESSAGE_TYPE", rename_all = "lowercase")]
@@ -51,12 +52,8 @@ pub async fn get_or_create_conversation_with_id(
return Ok(existing);
}
// Truncate title to 25 char characters max
let title = if title.len() > 25 {
format!("{}...", &title[..25])
} else {
title.to_string()
};
// Truncate title to 25 characters max
let title = truncate_with_ellipsis(title, 25);
// Create new conversation with provided ID
let conversation = sqlx::query_as!(
+2 -61
View File
@@ -10,7 +10,6 @@ pub use windmill_types::flows::*;
use anyhow::Context;
use serde::Deserialize;
use serde::Serialize;
use sqlx::types::Json;
use sqlx::types::JsonRawValue;
@@ -99,18 +98,6 @@ pub async fn get_full_hub_flow_by_path(
.flow)
}
/// Serialize-only wrapper that combines resolved FlowValue with display-only extras.
/// flatten + RawValue is fine for serialization (only deserialization breaks).
#[derive(Serialize)]
struct FlowValueWithExtras<'a> {
#[serde(flatten)]
flow: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<&'a Box<JsonRawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
groups: Option<&'a Box<JsonRawValue>>,
}
/// Resolve the value of a flow if any.
pub async fn resolve_maybe_value<T>(
e: &sqlx::PgPool,
@@ -130,17 +117,13 @@ pub async fn resolve_maybe_value<T>(
}
/// Resolve modules recursively.
/// Stashes display-only fields (notes, groups) before the FlowValue round-trip
/// and re-injects them after, since FlowValue doesn't carry them.
async fn resolve_value_for_api(
e: &sqlx::PgPool,
workspace_id: &str,
value: &mut Box<JsonRawValue>,
with_code: bool,
) -> Result<(), Error> {
let extras = serde_json::from_str::<FlowExtras>(value.get())
.map_err(|e| tracing::warn!("Failed to parse flow extras: {e}"))
.ok();
let extras = FlowExtras::capture(value);
let mut val = serde_json::from_str::<FlowValue>(value.get()).map_err(|err| {
Error::internal_err(format!("resolve: Failed to parse flow value: {}", err))
@@ -149,12 +132,7 @@ async fn resolve_value_for_api(
resolve_module(e, workspace_id, &mut module.value, with_code).await?;
}
let extras = extras.unwrap_or(FlowExtras { notes: None, groups: None });
*value = to_raw_value(&FlowValueWithExtras {
flow: &val,
notes: extras.notes.as_ref(),
groups: extras.groups.as_ref(),
});
*value = extras.reattach(&val)?;
Ok(())
}
@@ -271,43 +249,6 @@ pub async fn resolve_modules(
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn flow_value_with_extras_serializes_notes_and_groups() {
let input = json!({
"modules": [],
"notes": [{"id": "n1", "text": "hello", "color": "yellow", "type": "free"}],
"groups": [{"start_id": "a", "end_id": "b", "summary": "grp"}]
});
let input_str = serde_json::to_string(&input).unwrap();
// Parse FlowValue (drops notes/groups) and FlowExtras (captures them)
let val: FlowValue = serde_json::from_str(&input_str).unwrap();
let extras: FlowExtras = serde_json::from_str(&input_str).unwrap();
// Serialize via FlowValueWithExtras — should include both
let combined = FlowValueWithExtras {
flow: &val,
notes: extras.notes.as_ref(),
groups: extras.groups.as_ref(),
};
let output: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&combined).unwrap()).unwrap();
assert_eq!(output["notes"], input["notes"]);
assert_eq!(output["groups"], input["groups"]);
assert!(output["modules"].is_array());
}
#[test]
fn flow_value_with_extras_omits_none_extras() {
let val: FlowValue = serde_json::from_str(r#"{"modules":[]}"#).unwrap();
let combined = FlowValueWithExtras { flow: &val, notes: None, groups: None };
let output = serde_json::to_string(&combined).unwrap();
assert!(!output.contains("notes"));
assert!(!output.contains("groups"));
}
#[test]
fn extract_hub_flow_id_accepts_id_only_paths() {
@@ -1077,12 +1077,7 @@ pub fn format_setting_value(key: &str, value: &serde_json::Value) -> String {
};
}
let value = mask_nested_sensitive(key, value);
let s = value.to_string();
if s.len() > 200 {
format!("{}...", &s[..197])
} else {
s
}
crate::utils::truncate_with_ellipsis(&value.to_string(), 197)
}
/// Extract the expiry timestamp from a license key JSON value.
+1 -1
View File
@@ -1257,7 +1257,7 @@ pub async fn create_custom_instance_database(
tracing::warn!(
"Failed to grant permissions on '{}': {}. Continuing.",
dbname,
e
crate::error::pg_error_message(&e)
);
}
@@ -1,6 +1,8 @@
use serde::{Deserialize, Deserializer, Serialize};
use windmill_types::scripts::ScriptLang;
use crate::error::pg_error_message;
fn deserialize_bool_from_null<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
@@ -4848,7 +4850,7 @@ fn required_str<'a>(
column: &str,
) -> Result<&'a str, String> {
row.try_get(column)
.map_err(|e| format!("Failed to read column {}: {}", column, e))?
.map_err(|e| format!("Failed to read column {}: {}", column, pg_error_message(&e)))?
.ok_or_else(|| format!("Unexpected NULL in column {}", column))
}
@@ -4894,7 +4896,7 @@ pub async fn pg_get_full_schema(
)
.await
.map(simple_query_rows)
.map_err(|e| format!("Failed to query columns: {}", e))?;
.map_err(|e| format!("Failed to query columns: {}", pg_error_message(&e)))?;
let fk_rows = client
.simple_query(
@@ -4922,7 +4924,7 @@ pub async fn pg_get_full_schema(
)
.await
.map(simple_query_rows)
.map_err(|e| format!("Failed to query foreign keys: {}", e))?;
.map_err(|e| format!("Failed to query foreign keys: {}", pg_error_message(&e)))?;
let mut result: FullDatabaseSchema = std::collections::HashMap::new();
+35
View File
@@ -1364,10 +1364,45 @@ pub fn strip_json_nul(serialized: &str) -> Cow<'_, str> {
Cow::Owned(String::from_utf8(out).expect("removing a NUL escape preserves valid UTF-8"))
}
/// Prefix of `s` holding at most `max_chars` characters.
/// Slicing by byte index (`&s[..n]`) panics when `n` lands inside a multibyte character.
pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((byte_idx, _)) => &s[..byte_idx],
None => s,
}
}
/// Keep at most `max_chars` characters of `s`, appending `...` when anything was dropped —
/// so a truncated result is `max_chars + 3` characters long, not `max_chars`.
pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
let truncated = truncate_chars(s, max_chars);
if truncated.len() < s.len() {
format!("{}...", truncated)
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_handles_multibyte_at_boundary() {
// Byte 25 of this string falls inside a 2-byte 'а'; naive `&s[..25]` would panic.
let cyrillic = "а".repeat(30);
assert_eq!(truncate_chars(&cyrillic, 25), "а".repeat(25));
assert_eq!(
truncate_with_ellipsis(&cyrillic, 25),
format!("{}...", "а".repeat(25))
);
assert_eq!(truncate_with_ellipsis("ааааааааааааа", 25), "ааааааааааааа");
assert_eq!(truncate_chars("abcd", 3), "abc");
assert_eq!(truncate_with_ellipsis("abc", 3), "abc");
assert_eq!(truncate_with_ellipsis("abcd", 3), "abc...");
}
// The 6-char JSON escape for U+0000: backslash + "u0000". Written via an
// escaped backslash so no literal NUL byte ever appears in this source.
const NUL_ESC: &str = "\\u0000";
+337 -2
View File
@@ -80,6 +80,172 @@ mod tests {
assert_eq!(approval_resume_id("approval_2"), 0x50d1_eeca);
assert_eq!(approval_resume_id("manager"), 0x6ee4_a469);
}
use super::wac_failure_record;
use serde_json::json;
/// The point of the function: a task failure and a step failure describing
/// the same error must be indistinguishable to the handler that catches
/// them, apart from the child job only a task has.
#[test]
fn a_task_and_a_step_failure_read_the_same() {
let from_child = wac_failure_record(
"fetch",
Some("abc-123"),
&json!({"error": {"name": "ValueError", "message": "nope", "stack": "frames"}}),
);
let from_step = wac_failure_record(
"fetch",
None,
&json!({"error": {"name": "ValueError", "message": "nope", "stack": "frames"}}),
);
assert_eq!(from_child["result"], from_step["result"]);
assert_eq!(from_child["message"], json!("nope"));
assert_eq!(from_step["message"], json!("nope"));
assert_eq!(from_child["child_job_id"], json!("abc-123"));
assert_eq!(from_step.get("child_job_id"), None);
}
/// A child job's result is whatever the failing job produced — a cancel, a
/// timeout, an executor that writes a bare string. The handler is still
/// promised `name` and `message`, so they cannot be conjured per-caller.
#[test]
fn an_unshaped_child_result_still_yields_name_and_message() {
for raw in [
json!({"error": "boom"}),
json!({"error": {"message": "boom"}}),
json!("boom"),
json!(null),
] {
let rec = wac_failure_record("s", None, &raw);
assert_eq!(rec["result"]["error"]["name"], json!("Error"), "{raw}");
assert!(
rec["result"]["error"]["message"].is_string(),
"{raw} produced no message"
);
assert_eq!(rec["result"]["error"].get("stack"), None, "{raw}");
}
// Nothing to say beats saying `{}`: the fallback exists for these.
for empty in [json!(null), json!({}), json!([]), json!({"error": ""})] {
assert_eq!(
wac_failure_record("s", None, &empty)["message"],
json!("WAC step 's' failed"),
"{empty}"
);
}
}
/// Extra keys are the failing side's own, and dropping them would lose a
/// custom error's fields; the three normalized ones still win.
#[test]
fn extra_error_fields_survive_normalization() {
let rec = wac_failure_record(
"s",
None,
&json!({"error": {"name": "HttpError", "message": "429", "code": 429, "stack": 12}}),
);
assert_eq!(rec["result"]["error"]["code"], json!(429));
assert_eq!(rec["result"]["error"]["name"], json!("HttpError"));
// a non-string stack is not something a handler can be told to read
assert_eq!(rec["result"]["error"].get("stack"), None);
}
use super::normalize_posted_step_result;
/// An SDK that predates the echoed record raises from the copy it built, so
/// rewriting what it posted would make its live round and its replays
/// disagree — the very thing being fixed here. It keeps its own shape.
#[test]
fn a_legacy_sdk_marker_is_stored_untouched() {
let legacy = json!({
"__wmill_error": true,
"message": "nope",
"step_key": "s",
"result": {"error": "nope", "type": "TypeError"},
});
assert_eq!(normalize_posted_step_result("s", legacy.clone()), legacy);
}
#[test]
fn a_current_sdk_marker_is_normalized_and_a_success_is_not() {
let posted = json!({
"__wmill_error": true,
"message": "nope",
"step_key": "s",
"result": {"error": {"name": "ValueError", "message": "nope"}},
});
let stored = normalize_posted_step_result("s", posted);
assert_eq!(stored["result"]["error"]["name"], json!("ValueError"));
assert_eq!(stored["step_key"], json!("s"));
let success = json!({"rows": 3});
assert_eq!(normalize_posted_step_result("s", success.clone()), success);
}
#[test]
fn an_oversized_stack_is_truncated() {
let rec = wac_failure_record(
"s",
None,
&json!({"error": {"message": "m", "stack": "x".repeat(100_000)}}),
);
let stack = rec["result"]["error"]["stack"].as_str().unwrap();
assert!(stack.len() < 100_000, "stack was not truncated");
assert!(stack.ends_with("... (truncated)"));
}
/// The cap bounds what lands in the checkpoint, so it has to be bytes: a
/// multibyte traceback counted in characters would be up to 4x over.
#[test]
fn the_stack_cap_counts_bytes_not_characters() {
let rec = wac_failure_record(
"s",
None,
&json!({"error": {"message": "m", "stack": "é".repeat(50_000)}}),
);
let stack = rec["result"]["error"]["stack"].as_str().unwrap();
assert!(
stack.len() <= 8 * 1024 + "\n... (truncated)".len(),
"kept {} bytes",
stack.len()
);
}
/// `extra` is the failing side's own attributes, so it can carry a response
/// body straight past the cap that exists to bound the checkpoint.
#[test]
fn an_oversized_extra_is_dropped_rather_than_stored() {
let rec = wac_failure_record(
"s",
None,
&json!({"error": {"message": "m", "extra": {"body": "x".repeat(100_000)}}}),
);
assert_eq!(rec["result"]["error"].get("extra"), None);
assert_eq!(rec["result"]["error"]["extra_omitted"], json!(true));
// one that fits is kept whole
let small = wac_failure_record(
"s",
None,
&json!({"error": {"message": "m", "extra": {"code": 429}}}),
);
assert_eq!(small["result"]["error"]["extra"], json!({"code": 429}));
assert_eq!(small["result"]["error"].get("extra_omitted"), None);
}
/// `message` is the failure's own message; `Value::to_string` on a string
/// would hand the handler `"boom"` with the JSON quotes still on it.
#[test]
fn a_bare_string_failure_keeps_its_message_unquoted() {
assert_eq!(
wac_failure_record("s", None, &json!({"error": "boom"}))["message"],
json!("boom")
);
assert_eq!(
wac_failure_record("s", None, &json!("boom"))["message"],
json!("boom")
);
}
}
/// Load the WAC checkpoint from `v2_job_status.workflow_as_code_status._checkpoint`.
@@ -139,6 +305,164 @@ pub async fn save_checkpoint(
Ok(())
}
/// Marks a `completed_steps` entry as a failure rather than a step result.
pub(crate) const WAC_ERROR_MARKER: &str = "__wmill_error";
/// Per-field budget for the two unbounded things a failure record carries, the
/// stack and `extra`. `persist_inline_checkpoint_delta` rewrites the whole
/// checkpoint on every step, so either one left unbounded is re-serialized once
/// per subsequent step for the rest of the workflow. The two are additive, so a
/// record costs at most twice this.
const MAX_CHECKPOINT_FIELD_BYTES: usize = 8 * 1024;
fn truncate_stack(stack: &str) -> String {
if stack.len() <= MAX_CHECKPOINT_FIELD_BYTES {
return stack.to_string();
}
// Byte budget, not characters: the cap exists to bound what goes into the
// checkpoint, and a multibyte traceback would otherwise be up to 4x it.
let mut cut = MAX_CHECKPOINT_FIELD_BYTES;
while cut > 0 && !stack.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n... (truncated)", &stack[..cut])
}
/// A failure's own message, without the JSON quoting `Value::to_string` puts
/// around a string. `None` when the value carries no message at all, so the
/// caller's fallback wins — a reader handed `{}` learns less than one handed
/// "WAC step 'x' failed".
fn value_message(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) if s.is_empty() => None,
Value::String(s) => Some(s.clone()),
Value::Object(o) if o.is_empty() => None,
Value::Array(a) if a.is_empty() => None,
other => Some(other.to_string()),
}
}
fn message_only(message: Option<String>) -> serde_json::Map<String, Value> {
let mut m = serde_json::Map::new();
if let Some(message) = message {
m.insert("message".to_string(), Value::String(message));
}
m
}
/// Build the failure record a caught WAC failure reads, from whatever the
/// failing side produced.
///
/// The single place this shape is decided, for both a task failure (arriving as
/// the child job's own result) and a `step()` failure (as the SDK posted it).
/// Assembling it per caller instead is how the two come to disagree on `name`
/// or on `stack` while both claim to be one shape. `name`, `message` and
/// `stack` are normalized here; any other key the failing side attached to its
/// error is passed through untouched, so a custom error's own fields survive.
pub fn wac_failure_record(step_key: &str, child_job_id: Option<&str>, raw_result: &Value) -> Value {
let mut error = match raw_result.get("error") {
Some(Value::Object(o)) => o.clone(),
// A bare-string error (some executors), or a result not shaped like a
// failure at all: keep whatever it says as the message rather than
// dropping it.
Some(other) => message_only(value_message(other)),
None => message_only(value_message(raw_result)),
};
let name = error
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("Error")
.to_string();
let message = error
.get("message")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("WAC step '{step_key}' failed"));
error.insert("name".to_string(), Value::String(name));
error.insert("message".to_string(), Value::String(message.clone()));
match error.get("stack").and_then(|v| v.as_str()) {
Some(stack) => {
error.insert("stack".to_string(), Value::String(truncate_stack(stack)));
}
// Never invent one, and never keep a non-string in the field a handler
// is told it can read.
None => {
error.remove("stack");
}
}
// `extra` is the failing side's own attributes, so it can hold a response
// body or a dataframe repr and route straight around the stack cap into the
// checkpoint this record is rewritten into on every later step. Dropped
// wholesale past the same budget rather than truncated, since half a
// structure is worse than a flag saying it was too big.
if let Some(extra) = error.get("extra") {
if serde_json::to_string(extra).map_or(true, |s| s.len() > MAX_CHECKPOINT_FIELD_BYTES) {
error.remove("extra");
error.insert("extra_omitted".to_string(), Value::Bool(true));
}
}
let mut record = serde_json::Map::new();
record.insert(WAC_ERROR_MARKER.to_string(), Value::Bool(true));
// `str(e)` / `e.message` reads the failure's own message whether it came
// from a task or a step; which task, and which child job, are the fields
// below rather than prose baked into the message.
record.insert("message".to_string(), Value::String(message));
record.insert("step_key".to_string(), Value::String(step_key.to_string()));
if let Some(child) = child_job_id {
record.insert("child_job_id".to_string(), Value::String(child.to_string()));
}
record.insert(
"result".to_string(),
Value::Object(
[("error".to_string(), Value::Object(error))]
.into_iter()
.collect(),
),
);
Value::Object(record)
}
/// Decide what to store for a step result an SDK posted.
///
/// A failure is normalized through `wac_failure_record`, the same function that
/// shapes task failures, so the two cannot drift apart.
///
/// A marker an older SDK posted is stored untouched instead. Those are
/// recognizable by an `error` that is a message string rather than an object,
/// and the SDK that posted one raises from the copy it built and ignores the
/// record echoed back to it. Rewriting it here would leave the round that ran
/// the failing body reading one shape and every replay of it reading another —
/// the divergence this whole mechanism exists to remove. It keeps its own shape
/// until it upgrades.
pub(crate) fn normalize_posted_step_result(key: &str, posted: Value) -> Value {
if !is_wac_failure(&posted) {
return posted;
}
let normalizable = posted
.get("result")
.and_then(|r| r.get("error"))
.map(|e| e.is_object())
.unwrap_or(false);
if !normalizable {
return posted;
}
wac_failure_record(key, None, posted.get("result").unwrap_or(&Value::Null))
}
/// Whether a `completed_steps` entry is a failure record.
pub(crate) fn is_wac_failure(value: &Value) -> bool {
value
.get(WAC_ERROR_MARKER)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Process a completed child job result: add to checkpoint's completed_steps.
pub fn add_completed_step(checkpoint: &mut WacCheckpoint, step_key: &str, result: Value) {
checkpoint
@@ -225,6 +549,11 @@ pub fn add_completed_step(checkpoint: &mut WacCheckpoint, step_key: &str, result
/// multiple times per call, and the `||` merges re-serialize the whole
/// object. The two-statement Rust-side load-modify-save below is ~10×
/// faster in practice, so we keep it and rely on the SDK-level lock.
///
/// Returns the value actually stored: a failure posted by an SDK is normalized
/// through `wac_failure_record` first, so the round that ran the failing body
/// can raise from the same record every replay will read instead of building
/// its own copy of it.
pub async fn persist_inline_checkpoint_delta(
tx: &mut Transaction<'_, Postgres>,
job_id: &Uuid,
@@ -233,7 +562,7 @@ pub async fn persist_inline_checkpoint_delta(
result: Value,
started_at: Option<&str>,
duration_ms: Option<u64>,
) -> error::Result<()> {
) -> error::Result<Option<Value>> {
// Row-lock the existing checkpoint row (if any) for the duration of the
// transaction. NULL if the row doesn't exist yet — see the doc comment
// above for why the first-write race is accepted.
@@ -288,6 +617,12 @@ pub async fn persist_inline_checkpoint_delta(
"WAC v2 inline checkpoint — persisting step result"
);
let result = normalize_posted_step_result(key, result);
// Only a failure is ever read back, so only a failure is copied: a
// successful step's result can be large and moves straight into the
// checkpoint.
let failure = is_wac_failure(&result).then(|| result.clone());
add_completed_step(&mut checkpoint, key, result);
let status_json = serde_json::to_value(&checkpoint)
@@ -335,5 +670,5 @@ pub async fn persist_inline_checkpoint_delta(
.await
.map_err(|e| Error::InternalErr(format!("Failed to write step timeline: {e}")))?;
Ok(())
Ok(failure)
}
@@ -0,0 +1,108 @@
{
"_readme": [
"Cases the python and the typescript SDK must both satisfy when they serialize",
"a failed step() body into a `__wmill_error` marker.",
"",
"The two drifted apart twice while #10368 was in review: `name` was taken from",
"the constructor in one client and from `e.name` in the other, and `stack` was",
"written in two different formats. Each was caught by a reviewer reading the",
"diff, not by a test, because each suite only ever checked its own language.",
"A shared corpus turns the next divergence into a failure in both suites.",
"",
"It sits next to `wac_failure_record`, the function that decides the record's",
"final shape, because that is the contract these markers are the raw material",
"for. The SDK test suites read it by relative path.",
"",
"`thrown` describes a value each language constructs natively:",
" name the name the record must report",
" message the message the record must report",
" props own properties/attributes that must reach `extra`",
" circular_prop an own property holding a cycle, which must be dropped",
" without taking the rest of `extra` with it",
"`expect.stack` is only \"present\" or \"absent\": its text is language-specific",
"(each client matches its own executor's format) and is asserted there.",
"",
"Known divergence, deliberately not covered here: a non-finite float reaches",
"`extra` as the string \"NaN\" in python and as null in typescript, because",
"JSON.stringify has no hook for it. The executors differ the same way.",
"",
"Second known divergence, also not covered: an attribute named like a field",
"the executors report separately \u2014 `name`, `message`, `stack` \u2014 reaches",
"`extra` in python and not in typescript. Each client mirrors its own",
"executor, which differ the same way: the python one copies `__dict__`",
"wholesale, the bun/deno one filters that skip-list out."
],
"cases": [
{
"case": "a named error keeps its name and message",
"thrown": {
"name": "HttpError",
"message": "429 too many requests"
},
"expect": {
"name": "HttpError",
"message": "429 too many requests",
"stack": "present",
"absent": [
"extra"
]
}
},
{
"case": "custom properties reach extra",
"thrown": {
"name": "HttpError",
"message": "429",
"props": {
"code": 429,
"retry_after": 5,
"endpoint": "/v1/jobs"
}
},
"expect": {
"name": "HttpError",
"message": "429",
"stack": "present",
"extra": {
"code": 429,
"retry_after": 5,
"endpoint": "/v1/jobs"
}
}
},
{
"case": "a property that cannot be serialized is dropped on its own",
"thrown": {
"name": "RequestError",
"message": "socket hang up",
"props": {
"code": "ECONNRESET"
},
"circular_prop": "request"
},
"expect": {
"name": "RequestError",
"message": "socket hang up",
"stack": "present",
"extra": {
"code": "ECONNRESET"
}
}
},
{
"case": "an error carrying nothing of its own has no extra",
"thrown": {
"name": "ValueError",
"message": "nope"
},
"expect": {
"name": "ValueError",
"message": "nope",
"stack": "present",
"absent": [
"extra"
]
}
}
]
}
+43 -10
View File
@@ -875,19 +875,17 @@ pub struct AgentTool {
pub value: ToolValue,
}
// Convert FlowModule -> AgentTool
impl From<FlowModule> for AgentTool {
fn from(flow_module: FlowModule) -> Self {
impl AgentTool {
/// Fold a `FlowModule` that went through dependency locking back into the tool it came from.
/// `description` has no `FlowModule` counterpart, so it must be carried over from the
/// existing tool: rebuilding an `AgentTool` from the module alone drops it on every deploy.
pub fn update_from_module(&mut self, flow_module: FlowModule) {
let module_value = serde_json::from_str::<FlowModuleValue>(flow_module.value.get())
.unwrap_or(FlowModuleValue::Identity);
AgentTool {
id: flow_module.id,
summary: flow_module.summary,
// FlowModule has no dedicated tool description; it is carried on AgentTool only.
description: None,
value: ToolValue::FlowModule(module_value),
}
self.id = flow_module.id;
self.summary = flow_module.summary;
self.value = ToolValue::FlowModule(module_value);
}
}
@@ -1356,6 +1354,41 @@ mod tests {
assert_eq!(val.modules.len(), 1);
}
#[test]
fn agent_tool_keeps_description_through_locking() {
// #10244: the dependency job rebuilds each tool from its locked FlowModule; the
// tool description lives only on AgentTool and must survive that round-trip.
let mut tool: AgentTool = serde_json::from_value(json!({
"id": "b",
"summary": "my_tool",
"description": "when to call me",
"value": {
"tool_type": "flowmodule",
"type": "rawscript",
"content": "def main(): return 1",
"language": "python3",
"input_transforms": {}
}
}))
.unwrap();
let mut locked: FlowModule = Option::<FlowModule>::from(&tool).unwrap();
locked.value = to_raw_value(&json!({
"type": "rawscript",
"content": "def main(): return 1",
"language": "python3",
"lock": "# py: 3.11",
"input_transforms": {}
}));
tool.update_from_module(locked);
assert_eq!(tool.description.as_deref(), Some("when to call me"));
assert_eq!(tool.summary.as_deref(), Some("my_tool"));
assert!(serde_json::to_string(&tool.value)
.unwrap()
.contains("# py: 3.11"));
}
#[test]
fn flow_rejects_absolute_step_path() {
// #9751: an absolute local path baked into a step must be rejected on deploy.
@@ -1791,13 +1791,11 @@ pub(crate) async fn handle_wac_child_completion(
step_key = %step_key,
"WAC v2 child job failed, storing error for workflow try/catch"
);
json!({
"__wmill_error": true,
"message": format!("WAC task '{}' failed (child job {})", step_key, child_job_id),
"child_job_id": child_job_id.to_string(),
"step_key": step_key,
"result": child_err,
})
windmill_common::wac::wac_failure_record(
&step_key,
Some(&child_job_id.to_string()),
&child_err,
)
};
tracing::info!(
@@ -6,7 +6,6 @@ use std::fs::{create_dir_all, remove_dir_all};
use crate::ansible_executor::{get_git_repos_lock, AnsibleDependencyLocks};
use async_recursion::async_recursion;
use itertools::Itertools;
use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{from_value, json, Value};
use sha2::Digest;
@@ -33,7 +32,7 @@ use windmill_parser_yaml::AnsibleRequirements;
use windmill_common::{
apps::AppScriptId,
cache::{self, RawData},
error::{self, to_anyhow},
error,
flows::{add_virtual_items_if_necessary, FlowValue},
scripts::ScriptLang,
DB,
@@ -647,26 +646,7 @@ pub async fn handle_flow_dependency_job(
.await?;
}
#[derive(Debug, Clone, Serialize)]
struct FlowValueWithExtras<'a> {
#[serde(flatten)]
value: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<Box<RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
groups: Option<Box<RawValue>>,
}
let new_flow_value = Json(
serde_json::value::to_raw_value(&FlowValueWithExtras {
value: &flow,
notes: extras.as_ref().and_then(|e| e.notes.clone()),
groups: extras.as_ref().and_then(|e| e.groups.clone()),
})
.map_err(to_anyhow)?,
);
let new_flow_value = Json(extras.reattach(&flow)?);
// Re-check cancellation to ensure we don't accidentally override a flow.
if sqlx::query_scalar!(
@@ -758,14 +738,7 @@ pub async fn handle_flow_dependency_job(
)
.await?;
let value_lite_with_extras = Json(
serde_json::value::to_raw_value(&FlowValueWithExtras {
value: &value_lite,
notes: extras.as_ref().and_then(|e| e.notes.clone()),
groups: extras.as_ref().and_then(|e| e.groups.clone()),
})
.map_err(to_anyhow)?,
);
let value_lite_with_extras = Json(extras.reattach(&value_lite)?);
sqlx::query!(
"INSERT INTO flow_version_lite (id, value) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value",
@@ -1296,7 +1269,7 @@ async fn lock_modules(
let locked = locked_iter.next().ok_or_else(|| {
Error::internal_err("locked tool module should exist".to_string())
})?;
tools[idx] = locked.into();
tools[idx].update_from_module(locked);
}
e.value = FlowModuleValue::AIAgent {
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.773.0";
export const VERSION = "v1.775.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -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.773.0";
export const VERSION = "1.775.1";
+11 -4
View File
@@ -6506,6 +6506,8 @@ Python: \`except Exception\` is safe around WAC calls because internal suspensio
TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.
A caught failure reads the same whether it came from a task or from a \`step()\`, and the same in the round that ran the failing body as in every round replaying it. It carries \`step_key\`, \`child_job_id\` (absent for a \`step()\`, which runs in the workflow job and has no child job), a \`message\` that is the failure's own message, and \`result\` = \`{"error": {"name", "message", "stack"?, "extra"?}}\`. \`name\`, \`message\` and \`stack\` are the fields that read the same whichever side failed; \`name\` and \`message\` are always there, \`stack\` only when the failure had a traceback to give. \`extra\` carries the failure's own custom fields (an exception's attributes, an error's properties) and is best-effort: it is absent when there were none, and a task can report entries a step does not, so read it defensively and don't branch on its absence. \`extra\` is dropped when it is too large to keep in the checkpoint, and \`extra_omitted: true\` says so — absent \`extra\` with no \`extra_omitted\` means the failure simply had no custom fields. Branch on those, not on the original exception type: the workflow body re-runs from the top every round and a replay rebuilds the failure from the checkpoint, so nothing outside that record survives. Python raises \`TaskError\`; TypeScript throws an \`Error\` named \`TaskError\` carrying the same fields. Nothing is chained onto \`__cause__\` / \`cause\` — the traceback is in \`result.error.stack\`, and is also printed to the job log when the step fails.
## TypeScript Workflow-as-Code API (windmill-client)
@@ -6644,14 +6646,19 @@ export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R>
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError\`
\`\`\`python
# Raised when a WAC task step failed.
# Raised when a WAC \`\`task\`\` or \`\`step\`\` failed.
#
# Attributes:
# step_key: The checkpoint key of the failed step.
# child_job_id: The UUID of the failed child job.
# result: The error result from the child job.
# child_job_id: The UUID of the failed child job, or \`\`None\`\` for a
# \`\`step()\`\`, which runs in the workflow job and has no child job.
# result: \`\`{"error": {"name", "message", "stack"?, "extra"?}}\`\` — the
# same shape whether a task or a step failed. \`\`name\`\` and \`\`message\`\`
# are always present; \`\`stack\`\` only when the failure had a traceback,
# and \`\`extra\`\` only when it carried custom fields of its own, dropped
# with \`\`extra_omitted: True\`\` beside it when too large to checkpoint.
class TaskError(Exception):
def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)
def __init__(self, message: str, *, step_key: str = '', child_job_id: Optional[str] = None, result = None)
# Get URLs needed for resuming a flow after suspension.
#
+68
View File
@@ -0,0 +1,68 @@
# Exercising an unreleased SDK change end to end
A job installs the **published** `windmill-client` / `wmill`, so a change in this
repo is invisible to a real job until it is injected into the worker's dependency
cache. The SDK unit suites cover neither the worker nor the SDK the worker
installs, and that gap is where the Workflow-as-Code failure contract kept coming
apart: every finding behind #10366, #10367 and #10368 came from review or from a
run like the one below, never from a green suite.
## Recipe
Use a private `WINDMILL_DIR`. `/tmp/windmill/` is shared by every worktree's
backend, so patching it in place leaks a modified SDK into other people's jobs.
```bash
# Run from the repository root. The backend holds a terminal of its own; every
# other command is a subshell, so nothing depends on where the last one left you.
# 1. a backend of your own, with its own cache root — in its own terminal
(cd backend && DATABASE_URL=... PORT=8062 WINDMILL_DIR=/tmp/windmill-mytest \
cargo run --features quickjs) # add ,python to run python jobs
# 2. one job to populate the cache with the published SDK
# (any preview job importing the client will do)
# 3. build the client and overwrite what the cache holds
C=$(echo /tmp/windmill-mytest/cache_nomount/bun/windmill-client@*@@@1)
(cd typescript-client && ./build.sh && npx tsdown --format esm --no-dts \
&& cp dist/index.mjs "$C/dist/index.mjs" \
&& cp dist/index.mjs "$C/dist/client.mjs") # code-split package: cover both
# python instead: copy the source file straight over, then drop the bytecode
cp python-client/wmill/wmill/client.py \
/tmp/windmill-mytest/cache/python_3_12/wmill==*/wmill/client.py
find /tmp/windmill-mytest -name __pycache__ -type d -exec rm -rf {} +
# 4. RESTART the backend — see below
# 5. run your scenarios, and rm -rf /tmp/windmill-mytest when done
```
## Restart the workers after injecting
A worker materializes the package once and keeps using its copy, so patching the
cache under a running backend leaves some workers on the old code. With more than
one worker the results then **alternate run to run** as jobs land on one worker or
the other, which reads like flakiness in the product rather than in the harness.
Restarting after the swap makes it deterministic.
Symptom worth recognising: identical jobs returning two different answers in a
stable pattern, with each run internally consistent.
## Run your scenarios twice
Once against the published SDK, once against the injected one. A scenario that
behaves the same either way is not testing what you think it is, and it is easy
to write several of those without noticing.
As a calibration: a spread of WAC scenarios written this way scored 10/17 (bun)
and 7/18 (python) against the published SDK and 17/17 and 18/18 against a client
carrying #10366, #10367 and #10368. The ones that did not move were covering
behaviour those PRs never touched — worth knowing before concluding that a green
run means anything.
## Worth covering, and easy to miss
The deno path, `taskScript` / `taskFlow`, `waitForApproval`, and failures
interleaved with parallelism. None of these were exercised while the failure
record was being unified.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.773.0",
"version": "1.775.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.773.0",
"version": "1.775.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.773.0",
"version": "1.775.1",
"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",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "1f1fe4f",
"sha256": "4c20b2b51f324e93dda3b46d914ebf7bc7d0cb4dc7eb2d37068408191220d206"
"version": "76ee616",
"sha256": "1d696d38a152179ef6e1cada5df302d078cc677b6dbc244b5c42335e11feabac"
}
@@ -0,0 +1,35 @@
{
"surface-accent-primary": "#506ab8",
"surface-accent-hover": "#5873c8",
"surface-accent-clicked": "#425797",
"text-primary": "#d5dbe2",
"text-secondary": "#8b949e",
"text-primary-inverse": "#24292f",
"text-secondary-inverse": "#57606a",
"text-tertiary-inverse": "#6e7781",
"surface-selected": "#212830",
"surface-disabled": "#151b23",
"surface-secondary": "#010409",
"surface-hover": "#6e768119",
"surface-primary": "#0d1117",
"border-light": "#242a32",
"border-normal": "#6e7681",
"border-accent": "#4a6ed6",
"surface-accent-selected": "#141a2e",
"surface-accent-secondary": "#e3e8ee",
"surface-tertiary": "#151b23",
"text-emphasis": "#dfe5eb",
"text-hint": "#6e7681",
"text-disabled": "#484f58",
"surface-accent-secondary-hover": "#d0d7de",
"surface-accent-secondary-clicked": "#afb8c1",
"component-button-accent-secondary": "#f0f6fc",
"text-emphasis-inverse": "#1f2328",
"reserved-ai": "#d2a8ff",
"component-virtual-node": "#151b23",
"text-accent": "#bdc8ee",
"border-selected": "#5a72c4",
"surface-sunken": "#010409",
"text-tertiary": "#6e7681",
"surface-input": "#0c0f14"
}
+6 -1
View File
@@ -239,7 +239,12 @@
"magenta-950": "#6e2ba1"
}
},
"guidelines": { "mode-1": { "blue": "#5e81ac", "demo-background": "#ffffff00" } },
"guidelines": {
"mode-1": {
"blue": "#5e81ac",
"demo-background": "#ffffff00"
}
},
"tailwind-c-s-s-v-3-3-2": {
"mode-1": {
"black": "#000000",
+173 -43
View File
@@ -22,7 +22,11 @@
draftBaseIsStale
} from '$lib/utils_draft_deploy'
import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy'
import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import {
type DraftItem,
invalidateWorkspaceDrafts,
useWorkspaceDrafts
} from '$lib/workspaceDrafts.svelte'
import type { Kind as LayoutKind } from '$lib/utils_deployable'
import { userStore } from '$lib/stores'
@@ -44,8 +48,8 @@
draftCount?: number
/** When set (reached via a session's Review button), preselect only the
* rows this chat modified — `${UserDraftItemKind}:${path}` keys, matching
* Row.key. Undefined → preselect all deployable rows (the default). All rows
* are still shown either way. */
* Row.key. Undefined → preselect all actionable rows (the default). All
* rows are still shown either way. */
chatMask?: Set<string>
/** False while the (async) chatMask is still loading. The select-all default
* waits for this so it doesn't race the mask and select everything. Defaults
@@ -180,35 +184,45 @@
isFork && hideUnchanged ? items.filter((i) => i.unchanged_from_parent !== true) : items
)
// A row is actionable when it isn't already deployed this session, the user has
// write permission, AND it's their own draft (you can't deploy someone else's
// draft — those show view-only in the "all drafts" view). The server enforces
// the same; this keeps the UI honest. A data-pipeline bundle is never deployable
// from this page — its scripts deploy individually inside the pipeline view — so
// it's excluded from every selection path.
function isSelectable(item: Row): boolean {
// Selection gate: a row is actionable when it isn't already deployed this
// session AND it's the user's own draft (someone else's shows view-only in the
// "all drafts" view). A data-pipeline bundle is excluded — its scripts deploy
// individually inside the pipeline view. Every selectable row can at least be
// discarded: discarding your own email-scoped draft never needs write
// permission on the path — only legacy (ownerless) drafts stay write-gated,
// mirroring the server's discard check.
function isDiscardable(item: Row): boolean {
return (
deploymentStatus[item.key]?.status !== 'deployed' &&
item.can_write &&
item.mine &&
item.draftKind !== 'data_pipeline'
item.draftKind !== 'data_pipeline' &&
(!item.legacy_draft || item.can_write)
)
}
// Why a row can't be deployed (drives the disabled-checkbox tooltip).
// `undefined` ⇒ actionable.
// Deploying additionally requires write permission on the path, so the Deploy
// count can be lower than the selection when a drafted path lost writability.
function isDeployable(item: Row): boolean {
return isDiscardable(item) && item.can_write
}
// Why a row can't be selected (drives the disabled-checkbox tooltip).
// `undefined` ⇒ selectable.
function blockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
if (!item.can_write) return "You don't have write permission on this path"
if (item.legacy_draft && !item.can_write)
return 'Discarding a legacy draft requires write permission on the path'
return undefined
}
// Why a row can't be discarded (drives the Discard button's title).
// Discarding only removes the caller's own draft row, which they always own,
// so — unlike deploy — it never requires write permission on the path. The
// only block is someone else's draft (view-only in the "all drafts" view).
// Discarding only removes the caller's own draft row, so it doesn't require
// write permission on the path — except for legacy (ownerless) drafts, which
// the server write-gates like a deploy.
function discardBlockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
if (item.legacy_draft && !item.can_write)
return 'Discarding a legacy draft requires write permission on the path'
return undefined
}
@@ -290,8 +304,9 @@
if (ws === currentWorkspaceId) deployPerm = p
})
})
// Select all on the first non-empty load (deploy-all is the common intent);
// only once, so a refetch after a deploy doesn't re-select the leftovers.
// Select all on the first non-empty load (acting on everything is the common
// intent); only once, so a refetch after a deploy doesn't re-select the
// leftovers.
let hasAutoSelected = $state(false)
const deploymentStatus: Record<
@@ -314,9 +329,9 @@
$effect(() => {
if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) {
// Default intent is deploy-all; when reached from a session's Review
// Default intent is act-on-all; when reached from a session's Review
// (chatMask set), preselect only that chat's items instead.
const selectable = visibleItems.filter(isSelectable)
const selectable = visibleItems.filter(isDiscardable)
selectedItems = (
chatMask
? selectable.filter((i) =>
@@ -332,17 +347,24 @@
}
})
// Selected items still in the visible list and deployable. Derived (not a
// pruning effect) so the "Deploy N drafts" button stays reactive to the
// Workspace Drafts resource: deploy/discard drop items, and stale keys left in
// selectedItems are simply ignored here (and by deploySelected).
let selectedCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length
// Selected items still in the visible list, per action. Derived (not a
// pruning effect) so the footer buttons stay reactive to the Workspace
// Drafts resource: deploy/discard drop items, and stale keys left in
// selectedItems are simply ignored here (and by the action handlers).
let deployableCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i)).length
)
let discardableCount = $derived(
visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i)).length
)
// Selected rows the user can discard but not deploy (own draft on a path
// without write permission) — surfaced under the footer so the diverging
// button counts are explained.
let undeployableSelectedCount = $derived(discardableCount - deployableCount)
let allSelected = $derived(
visibleItems.filter(isSelectable).length > 0 &&
visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key))
visibleItems.filter(isDiscardable).length > 0 &&
visibleItems.filter(isDiscardable).every((i) => selectedItems.includes(i.key))
)
function toggleItem(item: { key: string }) {
@@ -354,7 +376,7 @@
}
function selectAll() {
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
selectedItems = visibleItems.filter(isDiscardable).map((i) => i.key)
}
function deselectAll() {
@@ -394,8 +416,8 @@
deploying = true
// Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts
// resource, so `items` can change mid-loop — iterate a stable copy. Guard on
// isSelectable so a non-writable row can never be deployed via a stale key.
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i))
// isDeployable so a non-writable row can never be deployed via a stale key.
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
let deployedAny = false
for (const item of toDeploy) {
deploymentStatus[item.key] = { status: 'loading' }
@@ -470,6 +492,64 @@
if (item) void doDiscard(item)
}
// --- Bulk discard ---
// One click can drop many drafts at once, and some of them (draft_only with
// no other drafter) are permanent deletions — always confirm, listing the
// permanent ones explicitly.
let bulkDiscardItems = $state<Row[] | undefined>(undefined)
let discarding = $state(false)
const bulkPermanent = $derived((bulkDiscardItems ?? []).filter(isDestructiveDiscard))
// Third outcome the modal must cover: a draft-only item someone else also
// drafted isn't deleted — only this user's draft goes; the item survives via
// the other drafts.
const bulkSharedCount = $derived(
(bulkDiscardItems ?? []).filter((i) => i.draft_only && !isDestructiveDiscard(i)).length
)
function onDiscardSelectedClick() {
const toDiscard = visibleItems.filter((i) => selectedItems.includes(i.key) && isDiscardable(i))
if (toDiscard.length > 0) bulkDiscardItems = toDiscard
}
async function discardSelected(toDiscard: Row[]) {
discarding = true
let changed = false
for (const item of toDiscard) {
deploymentStatus[item.key] = { status: 'loading' }
// invalidate: false — one refetch after the whole batch (below), not
// one per row.
const res = await discardDraft(
item.draftKind,
item.path,
currentWorkspaceId,
item.draft_only,
item.legacy_draft,
false
)
if (res.success) {
changed = true
delete deploymentStatus[item.key]
} else {
deploymentStatus[item.key] = { status: 'failed', error: res.error }
sendUserToast(`Failed to discard ${item.path}: ${res.error}`, true)
}
}
discarding = false
selectedItems = []
if (changed) {
// Refetch the Draft list once for the batch, then refresh the fork
// comparison.
invalidateWorkspaceDrafts(currentWorkspaceId)
onChanged?.()
}
}
function confirmBulkDiscard() {
const toDiscard = bulkDiscardItems
bulkDiscardItems = undefined
if (toDiscard) void discardSelected(toDiscard)
}
// Editor URL for a draft item, scoped to the current workspace. Raw apps live
// under a different editor route, so map their kind accordingly. Kinds whose
// editor is a drawer on a list page (variables, resources, schedules,
@@ -559,7 +639,7 @@
{selectedItems}
{deploymentStatus}
{allSelected}
selectablePredicate={(item) => isSelectable(item as unknown as Row)}
selectablePredicate={(item) => isDiscardable(item as unknown as Row)}
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
onToggleItem={toggleItem}
onSelectAll={selectAll}
@@ -752,17 +832,34 @@
{#snippet footer()}
<div class="flex flex-col items-end gap-2">
<Button
variant="accent"
disabled={selectedCount === 0 || deploying || !deployPerm.ok}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
onClick={deploySelected}
>
Deploy {selectedCount} draft{selectedCount !== 1 ? 's' : ''}
</Button>
<div class="flex items-center gap-2">
<Button
variant="default"
destructive
disabled={discardableCount === 0 || deploying || discarding}
loading={discarding}
startIcon={{ icon: Undo2 }}
onClick={onDiscardSelectedClick}
>
Discard {discardableCount} draft{discardableCount !== 1 ? 's' : ''}
</Button>
<Button
variant="accent"
disabled={deployableCount === 0 || deploying || discarding || !deployPerm.ok}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
onClick={deploySelected}
>
Deploy {deployableCount} draft{deployableCount !== 1 ? 's' : ''}
</Button>
</div>
{#if !deployPerm.ok}
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
{:else if undeployableSelectedCount > 0}
<span class="text-xs text-secondary">
{undeployableSelectedCount} selected draft{undeployableSelectedCount !== 1 ? 's' : ''}
can't be deployed (no write permission on the path) but can still be discarded
</span>
{/if}
</div>
{/snippet}
@@ -772,6 +869,39 @@
<DiffDrawer bind:this={diffDrawer} {isFlow} />
</div>
<ConfirmationModal
open={bulkDiscardItems !== undefined}
title="Discard selected drafts"
confirmationText="Discard"
onConfirmed={confirmBulkDiscard}
onCanceled={() => (bulkDiscardItems = undefined)}
>
<p>
This will discard {bulkDiscardItems?.length} draft{(bulkDiscardItems?.length ?? 0) !== 1
? 's'
: ''}. Items with a deployed version revert to it.
</p>
{#if bulkSharedCount > 0}
<p class="mt-2">
{bulkSharedCount} draft-only {bulkSharedCount === 1 ? 'item is' : 'items are'} also drafted by
other users: only your draft is removed and the {bulkSharedCount === 1 ? 'item' : 'items'} will
remain through theirs.
</p>
{/if}
{#if bulkPermanent.length > 0}
<p class="mt-2">
{bulkPermanent.length}
{bulkPermanent.length === 1 ? 'item exists' : 'items exist'} only as a draft and will be
<span class="font-semibold">permanently deleted</span>:
</p>
<ul class="list-disc list-inside font-mono text-xs mt-1">
{#each bulkPermanent as item (item.key)}
<li>{item.draft_path ?? item.path}</li>
{/each}
</ul>
{/if}
</ConfirmationModal>
<!-- Only the destructive discard (deleting the last draft of a never-deployed
item) opens this modal; non-destructive discards run without confirmation. -->
<ConfirmationModal
@@ -1,5 +1,5 @@
<script lang="ts">
import { isInitialized } from './vscode'
import { getEditorTheme, isInitialized } from './vscode'
import { editor as meditor } from 'monaco-editor'
@@ -8,11 +8,7 @@
function onThemeChange() {
if (isInitialized) {
if (document.documentElement.classList.contains('dark')) {
meditor.setTheme('nord')
} else {
meditor.setTheme('myTheme')
}
meditor.setTheme(getEditorTheme())
}
}
@@ -2505,7 +2505,7 @@
{/snippet}
{#snippet editorContent()}
<div class="h-full !overflow-visible bg-surface dark:bg-[#272D38] relative flex flex-col">
<div class="h-full !overflow-visible bg-surface dark:bg-surface-secondary relative flex flex-col">
{#if supportsModules}
<div
class="flex items-center border-b border-tertiary/30 bg-surface-secondary px-1 gap-0.5 text-xs overflow-x-auto shrink-0"
@@ -3,10 +3,17 @@
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Version from './Version.svelte'
import DarkModeToggle from './sidebar/DarkModeToggle.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import TokensTable from './settings/TokensTable.svelte'
import { createEventDispatcher } from 'svelte'
import UserInfoSettings from './settings/UserInfoSettings.svelte'
import AIUserSettings from './settings/AIUserSettings.svelte'
import {
getDarkModeVariant,
setDarkModeVariant,
type DarkModeVariant
} from '$lib/darkModeVariant'
interface Props {
scopes?: string[] | undefined
@@ -28,6 +35,7 @@
let drawer: Drawer | undefined = $state()
let openWithMcpMode = $state(false)
let darkVariant = $state<DarkModeVariant>(getDarkModeVariant())
const dispatch = createEventDispatcher()
@@ -51,15 +59,39 @@
}
</script>
<Drawer bind:this={drawer} size="900px" on:close={removeHash} {disableChatOffset}>
<Drawer
bind:this={drawer}
size="900px"
on:open={() => (darkVariant = getDarkModeVariant())}
on:close={removeHash}
{disableChatOffset}
>
<DrawerContent title="User settings" on:close={closeDrawer}>
<div class="flex flex-col gap-6 pb-8">
{#if scopes == undefined}
<div
class="flex flex-row justify-between items-start gap-2 border border-border-light p-4 rounded-md"
>
<div class="font-semibold text-emphasis text-xs flex items-center">
Theme <DarkModeToggle forcedDarkMode={false} />
<div class="flex flex-col gap-2">
<div class="font-semibold text-emphasis text-xs flex items-center">
Theme <DarkModeToggle forcedDarkMode={false} />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-secondary">Dark variant</span>
<ToggleButtonGroup
selected={darkVariant}
class="w-fit"
onSelected={(v) => {
darkVariant = v
setDarkModeVariant(v)
}}
>
{#snippet children({ item })}
<ToggleButton value="default" label="Default" size="sm" {item} />
<ToggleButton value="github" label="GitHub" size="sm" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
</div>
<div class="text-xs text-emphasis flex-col flex">
Windmill <Version />
@@ -1,5 +1,6 @@
<script lang="ts">
import { User, UserRoundX } from 'lucide-svelte'
import { applyDarkModeVariant } from '$lib/darkModeVariant'
import { enterpriseLicense, userStore } from '$lib/stores'
import { base } from '$app/paths'
import { page } from '$app/state'
@@ -73,6 +74,8 @@
} else {
document.documentElement.classList.remove('dark')
}
// This route bypasses the (root) layout, so restore the variant class too.
applyDarkModeVariant()
let globalUser = $state<GlobalWhoamiResponse | undefined>(undefined)
async function loadGlobalUser() {
@@ -14,7 +14,6 @@
Folder,
Hand,
HistoryIcon,
Hourglass,
MousePointer2,
Plus,
TextSelect,
@@ -25,7 +24,7 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { isActiveUserQuestion, type DisplayMessage } from './shared'
import { pendingUserAction, type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
@@ -486,24 +485,15 @@
}
})
// "Waiting for user" detection — when the latest tool message is staged
// for confirmation or has an unanswered askUserQuestion, the AI loop is
// paused on the user, not on its own work. The typing-dots indicator
// implies the AI is busy, which is misleading; surface a text pill
// instead so users know to act on the tool above.
const waitingForUserAction = $derived.by(() => {
if (!aiChatManager.loading) return false
const last = messages[messages.length - 1]
if (!last || last.role !== 'tool') return false
if (last.needsConfirmation && last.isLoading) return true
if (isActiveUserQuestion(last)) return true
return false
})
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
// While the AI is waiting on an answer to an askUserQuestion, the only valid
// input is one of the choices (or the custom answer) in the question card —
// so disable the main chat input until the question is answered or canceled.
const hasActiveUserQuestion = $derived(isActiveUserQuestion(messages[messages.length - 1]))
const hasActiveUserQuestion = $derived(pendingUserAction(messages) === 'question')
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
@@ -682,28 +672,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
{#if waitingForUserAction}
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/80 backdrop-blur text-2xs text-accent"
aria-label="Waiting for your input"
>
<Hourglass class="w-3 h-3 hourglass-flip" />
Waiting for your input
</span>
{:else}
<ChatTypingIndicator
loading={aiChatManager.loading}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
{/if}
<ChatTypingIndicator
loading={aiChatManager.loading}
paused={waitingForUserAction}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
{/if}
</div>
@@ -1067,26 +1048,3 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
<style>
/* Hourglass flips every 4s with long rests at each upright position.
`:global` because the class is applied to a child component's root
(Lucide SVG) and Svelte scoped CSS otherwise wouldn't match it. */
:global(.hourglass-flip) {
animation: hourglass-flip 4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
transform-origin: center;
}
@keyframes hourglass-flip {
0%,
35% {
transform: rotate(0deg);
}
50%,
85% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -3828,10 +3828,11 @@ export class AIChatManager {
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
this.displayMessages = this.displayMessages.map((message) => {
if (message.role === 'tool' && message.isLoading) {
if (message.role === 'tool' && (message.isLoading || message.isQueued)) {
return {
...message,
isLoading: false,
isQueued: false,
// A question's card disappears once canceled, so keep the question
// itself readable in the collapsed header.
content: message.userQuestion
@@ -1,26 +1,37 @@
<script lang="ts">
import { Hourglass } from 'lucide-svelte'
let {
loading,
compact = false,
paused = false,
label
}: { loading: boolean; compact?: boolean; label?: string } = $props()
}: { loading: boolean; compact?: boolean; paused?: boolean; label?: string } = $props()
// Wall-clock for the typing-dots indicator. Starts on the rising edge of
// `loading`, ticks once a second, frozen on the last value when loading
// ends so callers reading the dots briefly after still see a coherent number.
let loadingStartedAt = $state<number | undefined>(undefined)
let loadingElapsedMs = $state(0)
// Starts on the rising edge of `loading`, frozen on its last value once loading
// ends so a caller reading it just after still sees a coherent number. `paused`
// suspends it and resumes where it stopped: time the user spends answering is
// theirs, and counting it makes a fast turn read as a slow one.
let elapsedMs = $state(0)
let accumulatedMs = 0
let wasLoading = false
$effect(() => {
if (!loading) {
loadingStartedAt = undefined
wasLoading = false
return
}
loadingStartedAt = Date.now()
loadingElapsedMs = 0
const interval = setInterval(() => {
if (loadingStartedAt) loadingElapsedMs = Date.now() - loadingStartedAt
}, 1000)
return () => clearInterval(interval)
if (!wasLoading) {
wasLoading = true
accumulatedMs = 0
elapsedMs = 0
}
if (paused) return
const startedAt = Date.now() - accumulatedMs
const interval = setInterval(() => (elapsedMs = Date.now() - startedAt), 1000)
return () => {
accumulatedMs = Date.now() - startedAt
clearInterval(interval)
}
})
function formatElapsed(ms: number): string {
@@ -35,30 +46,42 @@
}
</script>
<span
class={compact
? 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-surface/80 backdrop-blur'
: 'inline-flex items-center gap-2 px-2 py-1 rounded-md bg-surface/80 backdrop-blur'}
aria-label="AI is generating a response"
>
<span class={compact ? 'inline-flex items-end gap-0.5' : 'inline-flex items-end gap-1'}>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-2'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-3'}
></span>
</span>
<span class={(compact ? 'text-[10px]' : 'text-2xs') + ' text-tertiary tabular-nums leading-none'}
>{label ? label + ' · ' : ''}{formatElapsed(loadingElapsedMs)}</span
{#if paused}
<span
class={(compact ? 'gap-1 px-1.5 py-0.5 text-[10px]' : 'gap-1.5 px-2 py-1 text-2xs') +
' inline-flex items-center rounded-md bg-surface/80 backdrop-blur text-accent'}
aria-label="Waiting for your input"
>
</span>
<Hourglass class={(compact ? 'w-2.5 h-2.5' : 'w-3 h-3') + ' hourglass-flip'} />
Waiting for your input
</span>
{:else}
<span
class={compact
? 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-surface/80 backdrop-blur'
: 'inline-flex items-center gap-2 px-2 py-1 rounded-md bg-surface/80 backdrop-blur'}
aria-label="AI is generating a response"
>
<span class={compact ? 'inline-flex items-end gap-0.5' : 'inline-flex items-end gap-1'}>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-2'}
></span>
<span
class={(compact ? 'w-[3px] h-[3px]' : 'w-[5px] h-[5px]') +
' rounded-full bg-accent chat-typing-dot chat-typing-dot-3'}
></span>
</span>
<span
class={(compact ? 'text-[10px]' : 'text-2xs') + ' text-tertiary tabular-nums leading-none'}
>{label ? label + ' · ' : ''}{formatElapsed(elapsedMs)}</span
>
</span>
{/if}
<style>
.chat-typing-dot {
@@ -80,4 +103,24 @@
opacity: 1;
}
}
/* Hourglass flips every 4s with long rests at each upright position. Global:
the class lands on a lucide component's own element. */
:global(.hourglass-flip) {
animation: hourglass-flip 4s cubic-bezier(0.65, 0, 0.35, 1) infinite;
transform-origin: center;
}
@keyframes hourglass-flip {
0%,
35% {
transform: rotate(0deg);
}
50%,
85% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -26,6 +26,7 @@
const isSuccessful = $derived(
!message.isLoading &&
!message.isQueued &&
!message.error &&
!message.needsConfirmation &&
!message.isStreamingArguments
@@ -64,7 +65,14 @@
{#if activeUserQuestion}
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
{:else}
<div class="font-mono text-xs">
<!-- Queued calls (waiting their turn behind the executing tool) are faded: the
reduced weight is what says "not started" — no icon, no spinner. -->
<div
class={twMerge(
'font-mono text-xs',
message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' : ''
)}
>
<!-- Collapsible Header -->
{#snippet headerButton()}
<button
@@ -19,6 +19,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry'
import {
appendPendingToolImages,
processToolCall,
queuedToolStatus,
type Tool,
type ToolCallbacks,
type WebSearchSource
@@ -227,7 +228,7 @@ export async function parseAnthropicCompletion(
callbacks.setToolStatus(toolId, {
isLoading: true,
content: tool?.streamingLabel ?? `Calling ${toolName}...`,
content: tool?.streamingLabel ?? `Preparing ${toolName}...`,
toolName,
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
@@ -256,6 +257,17 @@ export async function parseAnthropicCompletion(
{ errorCode, query: webSearchQueries.get(block.tool_use_id), sources }
)
}
} else if (event.type === 'content_block_stop' && currentStreamingTool) {
// Args fully streamed: demote to queued (see queuedToolStatus). Server-side
// web search is exempt — it executes remotely and never goes through
// processToolCall, so demoting it would strand the card as queued.
if (!currentStreamingTool.isWebSearch) {
callbacks.setToolStatus(
currentStreamingTool.tempId,
queuedToolStatus(tools, currentStreamingTool.toolName, accumulatedJson)
)
}
currentStreamingTool = undefined
}
})
@@ -275,7 +287,10 @@ export async function parseAnthropicCompletion(
})
completion.on('inputJson', (partialJson: string) => {
if (currentStreamingTool?.isWebSearch) {
if (!currentStreamingTool) {
return
}
if (currentStreamingTool.isWebSearch) {
accumulatedJson += partialJson
const query = partialWebSearchQuery(accumulatedJson)
if (query) {
@@ -284,10 +299,10 @@ export async function parseAnthropicCompletion(
}
return
}
if (currentStreamingTool?.shouldStream && currentStreamingTool.tempId) {
// Accumulate the partial JSON
accumulatedJson += partialJson
// Accumulated even for non-streamArguments tools: queuedToolStatus derives
// the queued header from the completed args at content_block_stop.
accumulatedJson += partialJson
if (currentStreamingTool.shouldStream && currentStreamingTool.tempId) {
// Try to parse and display
try {
const parsed = JSON.parse(accumulatedJson)
@@ -352,11 +367,6 @@ export async function parseAnthropicCompletion(
arguments: JSON.stringify(block.input)
}
})
// Preprocess tool if it has a preAction
const tool = tools.find((t) => t.def.function.name === block.name)
if (tool && tool.preAction) {
tool.preAction({ toolCallbacks: callbacks, toolId: block.id })
}
}
}
@@ -1164,6 +1164,7 @@ AI agents can use tools to accomplish tasks. When creating an AI agent module:
{
id: "search_docs",
summary: "Search_documentation",
description: "Search the product documentation. Use it whenever the user asks how a feature works.",
value: {
tool_type: "flowmodule",
type: "rawscript",
@@ -1178,7 +1179,8 @@ AI agents can use tools to accomplish tasks. When creating an AI agent module:
\`\`\`
- **Tool IDs**: Cannot contain spaces - use underscores
- **Tool summaries**: Cannot contain spaces - use underscores
- **Tool summaries**: Cannot contain spaces - use underscores. This is the tool *name* the agent sees
- **Tool descriptions**: Optional free text telling the agent when and how to call the tool. Set it whenever the name alone does not make that obvious - it overrides the description derived from the underlying script
- **Tool types**: \`flowmodule\` for scripts/flows, \`mcp\` for MCP server tools
### Contexts
@@ -69,6 +69,9 @@ vi.mock('$lib/gen', async () => {
}),
queryHubScripts: vi.fn(async () => []),
getHubScriptContentByPath: vi.fn(async () => ''),
getHubScriptByPath: vi.fn(async () => {
throw new Error('getHubScriptByPath mock not configured')
}),
listScripts: vi.fn(async () => [])
}),
JobService: wrapService(actual.JobService, {
@@ -534,6 +537,29 @@ describe('global AI tools', () => {
])
})
it('reads a hub script path through the hub endpoint, not the workspace one', async () => {
vi.mocked(ScriptService.getHubScriptByPath).mockResolvedValueOnce({
content: 'export async function main() {}',
language: 'bunnative',
summary: 'Send a message to discord using webhook',
schema: { type: 'object', properties: {} }
})
const raw = await callGlobalTool('read_workspace_item', {
type: 'script',
path: 'hub/28294/discord/send_a_message_to_discord_using_webhook'
})
expect(ScriptService.getHubScriptByPath).toHaveBeenCalledWith({
path: 'hub/28294/discord/send_a_message_to_discord_using_webhook'
})
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
expect(JSON.parse(raw)).toMatchObject({
language: 'bunnative',
value: 'export async function main() {}'
})
})
it('reads the deployed state, skipping chat and DB drafts, with version: deployed', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/greet',
@@ -90,6 +90,7 @@ import {
executeFlowStepTestRun,
executeTestRun,
findAndReplace,
isHubPath,
type CreatedResourceTriggerKind,
type PreviewCardKind,
type Tool,
@@ -140,6 +141,7 @@ import {
getDraftDiffValues
} from '$lib/utils_draft_deploy'
import { changedLineIndices, draftDeployedPatch, windowPatch } from './draftDiff'
import { getFlowRunDetails } from './flowRunTree'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
import type { UserDraftItemKind } from '$lib/gen'
@@ -362,7 +364,11 @@ const listWorkspaceItemsSchema = z.object({
const readWorkspaceItemSchema = z.object({
type: itemTypeSchema,
path: z.string().describe('Workspace path of the item to read.'),
path: z
.string()
.describe(
'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script.'
),
trigger_kind: triggerKindSchema
.optional()
.describe('Required when type is trigger. Identifies which trigger service to call.'),
@@ -598,6 +604,16 @@ const getJobLogsSchema = z.object({
id: z.string().describe('The UUID of the job to fetch logs for.')
})
const getFlowRunDetailsSchema = z.object({
id: z.string().describe('The UUID of the flow run to inspect.'),
step: z
.string()
.optional()
.describe(
'Step to drill into for its result (returned in full up to 12k chars), addressed by the step ids shown in the tree: "b" for a top-level step, "b/c" for a step inside a subflow, "b[12]" for iteration 12 of a loop or attempt 12 of a retried step (1-based), composable as "b[12]/c". Omit to get the whole per-step tree.'
)
})
const cancelJobSchema = z.object({
id: z.string().describe('The UUID of the job to cancel.')
})
@@ -1170,11 +1186,13 @@ Rules:
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
- Use search_resource_types before write_resource.
- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.
- Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema.
- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
${pipelineBullet}
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs without starting a new test run.
- To see what a flow run actually did per step statuses and results across the whole execution tree, subflow steps and loop iterations included use get_flow_run_details with the run id (it also works while the flow is still running). Pass step to read one step's result in full (capped at 12k chars). Prefer it over get_job_logs when you need step results rather than logs.
- Use open_page to show a workspace page with filters applied Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
- Whenever you ask the user to perform a manual step in the UI fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting call open_page in the same message, targeted at that item (pass open with its path to land in its edit drawer, or the page's filters otherwise). Never just describe where to click.
- When the user is happy with the changes and wants to review or deploy them, use open_page with page "compare" it opens the Compare & Deploy review page.${
@@ -1182,7 +1200,7 @@ ${pipelineBullet}
? ' By default it preselects the items this chat modified; pass items ("<kind>:<path>" entries) to control the selection'
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools use the draft tools and delete_workspace_item instead.
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools use the draft tools and delete_workspace_item instead.
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step they run the draft.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
@@ -1778,6 +1796,20 @@ async function readWorkspaceItem(
): Promise<WorkspaceItem> {
switch (type) {
case 'script': {
// Hub scripts are not workspace items: search_hub_scripts hands back
// `hub/<version>/<app>/<slug>` paths, which getScriptByPath cannot resolve.
if (isHubPath(path)) {
const hub = await ScriptService.getHubScriptByPath({ path })
return {
type: 'script',
path,
summary: hub.summary,
language: hub.language as ScriptLang,
value: hub.content,
schema: hub.schema,
isDraft: false
}
}
// Prefer the DB draft (newer than the deployed version) when one exists,
// unless the caller explicitly asked for the deployed state.
const script = await ScriptService.getScriptByPath({
@@ -2785,7 +2817,7 @@ export const globalTools: Tool<{}>[] = [
return JSON.stringify({ success: false, error: message })
}
const draft =
parsed.version === 'deployed'
parsed.version === 'deployed' || isHubPath(parsed.path)
? null
: await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind)
if (draft) {
@@ -2957,6 +2989,7 @@ export const globalTools: Tool<{}>[] = [
},
requiresConfirmation: true,
confirmationMessage: (args) => `Run a test of ${pathLeaf(args?.path, 'the script')}`,
queuedLabel: (args) => `Test ${args?.path ?? 'the script'}`,
showDetails: true,
autoCollapseDetails: false
},
@@ -2968,6 +3001,7 @@ export const globalTools: Tool<{}>[] = [
},
requiresConfirmation: true,
confirmationMessage: (args) => `Run a test of ${pathLeaf(args?.path, 'the flow')}`,
queuedLabel: (args) => `Test ${args?.path ?? 'the flow'}`,
showDetails: true,
autoCollapseDetails: false
},
@@ -2980,6 +3014,7 @@ export const globalTools: Tool<{}>[] = [
requiresConfirmation: true,
confirmationMessage: (args) =>
`Run a test of step "${args?.stepId ?? ''}" in ${pathLeaf(args?.path, 'the flow')}`,
queuedLabel: (args) => `Test step "${args?.stepId ?? ''}" of ${args?.path ?? 'the flow'}`,
showDetails: true,
autoCollapseDetails: false
},
@@ -3011,6 +3046,30 @@ export const globalTools: Tool<{}>[] = [
return result
}
},
{
def: createToolDef(
getFlowRunDetailsSchema,
'get_flow_run_details',
"Inspect a flow run's execution tree: per-step statuses and truncated results, including subflow steps, loop iterations, branches, and retries. Works on running flows too. Pass step to fetch one step's result in full (up to 12k chars)."
),
showDetails: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = getFlowRunDetailsSchema.parse(args)
toolCallbacks.setToolStatus(toolId, {
content: parsed.step
? `Fetching result of step ${parsed.step} in run ${parsed.id}...`
: `Inspecting flow run ${parsed.id}...`
})
const result = await getFlowRunDetails(workspace, parsed.id, parsed.step)
toolCallbacks.setToolStatus(toolId, {
content: parsed.step
? `Fetched result of step ${parsed.step} in run ${parsed.id}`
: `Inspected flow run ${parsed.id}`,
result
})
return result
}
},
{
def: createToolDef(
getJobLogsSchema,
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest'
import { buildFlowTree, shapeFlowRunTree, type FlowResultEntry } from './flowRunTree'
let uuidCounter = 0
function entry(partial: Partial<FlowResultEntry>): FlowResultEntry {
uuidCounter++
return {
job_id: `00000000-0000-0000-0000-${String(uuidCounter).padStart(12, '0')}`,
label: partial.step_path ? `Step ${partial.step_path}` : 'Flow',
kind: 'script',
depth: 1,
sibling_index: 1,
sibling_count: 1,
status: 'success',
success: true,
result_prefix: '"ok"',
result_length: 4,
...partial
}
}
function root(partial: Partial<FlowResultEntry> = {}): FlowResultEntry {
return entry({ kind: 'flow', depth: 0, step_path: null, label: 'Flow', ...partial })
}
/** Root flow with steps a, b where b is a subflow containing c. */
function nestedEntries(): FlowResultEntry[] {
return [
root(),
entry({ step_path: 'a', flow_step_id: 'a' }),
entry({ step_path: 'b', flow_step_id: 'b', kind: 'flow', label: 'Step b (subflow)' }),
entry({
step_path: 'b/c',
flow_step_id: 'c',
depth: 2,
status: 'failure',
success: false,
result_prefix: '{"error":"boom"}',
result_length: 16
})
]
}
/** Root flow with loop step l of `n` iterations; `failed` are 1-based indices. */
function loopEntries(n: number, failed: number[]): FlowResultEntry[] {
const entries = [root()]
for (let i = 1; i <= n; i++) {
entries.push(
entry({
step_path: 'l',
flow_step_id: 'l',
kind: 'flow',
label: `Step l forloop (iteration ${i}/${n})`,
parent_module_type: 'forloopflow',
sibling_index: i,
sibling_count: n,
status: failed.includes(i) ? 'failure' : 'success',
success: !failed.includes(i)
})
)
}
return entries
}
describe('buildFlowTree', () => {
it('rebuilds nesting from the depth-first flat list', () => {
const tree = buildFlowTree(nestedEntries())!
expect(tree.children.map((c) => c.entry.flow_step_id)).toEqual(['a', 'b'])
expect(tree.children[1].children.map((c) => c.entry.flow_step_id)).toEqual(['c'])
})
})
describe('shapeFlowRunTree', () => {
it('renders subflow steps nested under their parent step', () => {
const rendered = JSON.parse(shapeFlowRunTree({ entries: nestedEntries() }))
expect(rendered.run.status).toBe('success')
const stepB = rendered.steps[1]
expect(stepB.step).toBe('b')
expect(stepB.steps[0].step).toBe('b/c')
expect(stepB.steps[0].status).toBe('failure')
expect(stepB.steps[0].result).toBe('{"error":"boom"}')
})
it('collapses loop iterations to a tally with capped failed iterations plus the last', () => {
const rendered = JSON.parse(shapeFlowRunTree({ entries: loopEntries(50, [3, 7, 21, 30]) }))
const loop = rendered.steps[0]
expect(loop.type).toBe('forloopflow')
expect(loop.iterations).toBe(50)
expect(loop.ok).toBe(46)
expect(loop.failed_iterations).toEqual([3, 7, 21, 30])
// 3 failed shown (cap) + the last iteration
expect(loop.iterations_shown.map((i: any) => i.iteration)).toEqual([3, 7, 21, 50])
expect(loop.iterations_elided).toBe(46)
})
it('renders retried steps as attempts of one step, not loop iterations', () => {
const entries = [root()]
for (let i = 1; i <= 3; i++) {
entries.push(
entry({
step_path: 'a',
flow_step_id: 'a',
label: `Step a (attempt ${i}/3)`,
parent_module_type: 'rawscript',
sibling_index: i,
sibling_count: 3,
status: i < 3 ? 'failure' : 'success',
success: i === 3
})
)
}
const step = JSON.parse(shapeFlowRunTree({ entries })).steps[0]
expect(step.iterations).toBeUndefined()
expect(step.attempts).toBe(3)
expect(step.status).toBe('success')
expect(step.label).toBe('Step a (attempt 3/3)')
expect(step.previous_attempts.map((a: any) => a.status)).toEqual(['failure', 'failure'])
})
it('counts running iterations as unfinished and skipped ones as skipped', () => {
const entries = loopEntries(4, [])
entries[2].status = 'running'
entries[2].success = undefined
entries[2].result_prefix = undefined
entries[2].result_length = undefined
entries[3].status = 'skipped'
const loop = JSON.parse(shapeFlowRunTree({ entries })).steps[0]
expect(loop.ok).toBe(2)
expect(loop.unfinished).toBe(1)
expect(loop.skipped).toBe(1)
expect(loop.failed_iterations).toBeUndefined()
})
it('reports the truncated total size and surfaces the enclosing flow of a step job', () => {
const entries = nestedEntries()
entries[1].result_prefix = 'x'.repeat(700)
entries[1].result_length = 5000
const rendered = JSON.parse(shapeFlowRunTree({ enclosing_job: 'enclosing-uuid', entries }))
expect(rendered.note).toContain('enclosing-uuid')
expect(rendered.steps[0].result_total_chars).toBe(5000)
})
it('notes when the server truncated the tree', () => {
const rendered = JSON.parse(shapeFlowRunTree({ entries: nestedEntries(), truncated: true }))
expect(rendered.note).toContain('more jobs')
})
it('compares result sizes in code points and never splits a surrogate pair', () => {
const entries = [root()]
// 400 astral chars: 800 UTF-16 units but 400 code points. The 700-unit
// head budget keeps only 350 of them — comparing in UTF-16 units
// (400 < 700) used to hide that truncation entirely.
entries.push(
entry({
step_path: 'a',
flow_step_id: 'a',
status: 'failure',
success: false,
result_prefix: '🦄'.repeat(400),
result_length: 400
})
)
const step = JSON.parse(shapeFlowRunTree({ entries })).steps[0]
expect(step.result_total_chars).toBe(400)
// the cut must never leave a lone high surrogate at the tail
expect(step.result.charCodeAt(step.result.length - 1)).toBeGreaterThan(0xdbff)
})
it('shrinks result heads to fit the total budget instead of overflowing', () => {
const entries = [root()]
for (let i = 1; i <= 60; i++) {
entries.push(
entry({
step_path: `s${i}`,
flow_step_id: `s${i}`,
status: i === 1 ? 'failure' : 'success',
success: i !== 1,
result_prefix: 'y'.repeat(700),
result_length: 700
})
)
}
const rendered = shapeFlowRunTree({ entries })
expect(rendered.length).toBeLessThanOrEqual(21000)
const parsed = JSON.parse(rendered)
// failure detail outlives success detail: the failed step keeps a result
// head while succeeded steps are reduced to their size
expect(parsed.steps[0].status).toBe('failure')
expect(parsed.steps[0].result.length).toBeGreaterThan(0)
expect(parsed.steps[1].result).toBeUndefined()
expect(parsed.steps[1].result_total_chars).toBe(700)
})
})
@@ -0,0 +1,313 @@
import { JobService, type GetFlowAllResultsResponse } from '$lib/gen'
/**
* Model-facing view of a flow run's execution tree for the global chat's
* get_flow_run_details tool. The backend endpoint (get_flow_all_results)
* enumerates every job of the tree with per-entry truncated results; this
* module shapes that flat list into a compact per-step tree the model can
* read in one tool result. Step addresses ('b/c', 'b[12]/c') are resolved
* server-side by the same endpoint for full-result drill-down.
*/
export type FlowResultEntry = GetFlowAllResultsResponse['entries'][number]
/** Per-entry result budget requested from the server for the tree view. */
export const TREE_RESULT_HEAD_CHARS = 700
/** Cap on a drilled single-step full result handed to the model. */
export const STEP_RESULT_MAX_CHARS = 12000
/** Cap on the whole rendered tree; heads shrink progressively to fit. */
const TREE_TOTAL_BUDGET_CHARS = 20000
/** Failed loop iterations shown with a result head (the rest are indices only). */
const MAX_FAILED_ITERATIONS_SHOWN = 3
/** Head-shrink ladder tried in order until the rendered tree fits the budget.
* `successHead` caps results of succeeded steps separately so failure detail
* survives longest. */
const SHRINK_LADDER = [
{ head: TREE_RESULT_HEAD_CHARS, successHead: TREE_RESULT_HEAD_CHARS },
{ head: 250, successHead: 250 },
{ head: 250, successHead: 0 },
{ head: 100, successHead: 0 }
]
export interface FlowTreeNode {
entry: FlowResultEntry
children: FlowTreeNode[]
}
/** Rebuild the parent/child tree from the server's depth-first flat list. */
export function buildFlowTree(entries: FlowResultEntry[]): FlowTreeNode | undefined {
let root: FlowTreeNode | undefined
const stack: FlowTreeNode[] = []
for (const entry of entries) {
const node: FlowTreeNode = { entry, children: [] }
while (stack.length && stack[stack.length - 1].entry.depth >= entry.depth) {
stack.pop()
}
const parent = stack[stack.length - 1]
if (parent) {
parent.children.push(node)
} else if (!root) {
root = node
}
stack.push(node)
}
return root
}
interface ChildGroup {
stepId: string
nodes: FlowTreeNode[]
}
/** Group sibling jobs by flow step id (loop iterations / branches of one step
* form one group). Keyed rather than run-length so ordering quirks between
* parallel iterations can't split a step into two groups. */
function groupChildren(children: FlowTreeNode[]): ChildGroup[] {
const groups = new Map<string, ChildGroup>()
for (const child of children) {
const stepId = child.entry.flow_step_id ?? `job:${child.entry.job_id}`
let group = groups.get(stepId)
if (!group) {
group = { stepId, nodes: [] }
groups.set(stepId, group)
}
group.nodes.push(child)
}
return Array.from(groups.values())
}
interface ShapeOpts {
head: number
successHead: number
}
/** Postgres LEFT()/length() count code points while JS .length/.slice count
* UTF-16 units compare and cut in code points so astral characters neither
* hide a truncation nor get split through a surrogate pair. */
function countCodePoints(s: string): number {
let n = 0
for (const _ of s) n++
return n
}
function sliceCodePointSafe(s: string, maxUnits: number): string {
const cut = s.slice(0, maxUnits)
const last = cut.charCodeAt(cut.length - 1)
// drop a trailing lone high surrogate
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
}
function shapeResult(
entry: FlowResultEntry,
opts: ShapeOpts
): { result?: string; result_total_chars?: number } {
if (entry.result_prefix === undefined || entry.result_prefix === null) return {}
const budget = entry.success ? opts.successHead : opts.head
if (budget <= 0) return { result_total_chars: entry.result_length ?? undefined }
const head = sliceCodePointSafe(entry.result_prefix, budget)
const total = entry.result_length ?? countCodePoints(entry.result_prefix)
return {
result: head,
...(total > countCodePoints(head) ? { result_total_chars: total } : {})
}
}
function shapeStep(node: FlowTreeNode, opts: ShapeOpts, iteration?: number): Record<string, any> {
const entry = node.entry
const shaped: Record<string, any> = {
...(entry.step_path ? { step: entry.step_path } : {}),
...(iteration !== undefined ? { iteration } : {}),
label: entry.label,
job_id: entry.job_id,
status: entry.status,
...(entry.duration_ms !== undefined && entry.duration_ms !== null
? { duration_ms: entry.duration_ms }
: {}),
...shapeResult(entry, opts)
}
const childSteps = shapeChildren(node.children, opts)
if (childSteps.length > 0) {
shaped.steps = childSteps
}
return shaped
}
/** Module types whose sibling jobs are iterations/branches; sibling jobs of
* any other step are retry attempts of that step. */
const FAN_OUT_MODULE_TYPES = new Set([
'forloopflow',
'whileloopflow',
'branchall',
'branchone',
'aiagent'
])
function shapeGroup(group: ChildGroup, opts: ShapeOpts): Record<string, any> {
if (group.nodes.length === 1 && group.nodes[0].entry.sibling_count <= 1) {
return shapeStep(group.nodes[0], opts)
}
const first = group.nodes[0].entry
if (first.parent_module_type === 'branchall') {
return {
step: first.step_path ?? group.stepId,
type: 'branchall',
branches: group.nodes.map((n) => shapeStep(n, opts))
}
}
const byIndex = [...group.nodes].sort((a, b) => a.entry.sibling_index - b.entry.sibling_index)
if (first.parent_module_type && !FAN_OUT_MODULE_TYPES.has(first.parent_module_type)) {
// Retried step: siblings are attempts of the same step, the last one is
// the final outcome — render it as the step, keeping earlier attempts as
// status-only references.
const shaped = shapeStep(byIndex[byIndex.length - 1], opts)
shaped.attempts = byIndex.length
shaped.previous_attempts = byIndex.slice(0, -1).map((n) => ({
attempt: n.entry.sibling_index,
status: n.entry.status,
job_id: n.entry.job_id
}))
return shaped
}
// Loop-like fan-out (forloopflow, whileloopflow, aiagent actions, or any
// other multi-job step): tally statuses, show failed iterations (capped) and
// the latest one, elide the rest.
const ok = byIndex.filter((n) => n.entry.status === 'success').length
const failedNodes = byIndex.filter(
(n) => n.entry.status === 'failure' || n.entry.status === 'canceled'
)
const skipped = byIndex.filter((n) => n.entry.status === 'skipped').length
// skipped is terminal — only running/queued/suspended count as unfinished
const unfinished = byIndex.length - ok - failedNodes.length - skipped
const shown = failedNodes.slice(0, MAX_FAILED_ITERATIONS_SHOWN)
const last = byIndex[byIndex.length - 1]
if (!shown.includes(last)) {
shown.push(last)
}
return {
step: first.step_path ?? group.stepId,
type: first.parent_module_type || 'loop',
iterations: byIndex.length,
ok,
...(failedNodes.length > 0
? { failed_iterations: failedNodes.map((n) => n.entry.sibling_index) }
: {}),
...(skipped > 0 ? { skipped } : {}),
...(unfinished > 0 ? { unfinished } : {}),
iterations_shown: shown.map((n) => shapeStep(n, opts, n.entry.sibling_index)),
...(byIndex.length > shown.length ? { iterations_elided: byIndex.length - shown.length } : {})
}
}
function shapeChildren(children: FlowTreeNode[], opts: ShapeOpts): Record<string, any>[] {
return groupChildren(children).map((g) => shapeGroup(g, opts))
}
function renderTree(
root: FlowTreeNode,
rootJobNote: string | undefined,
opts: ShapeOpts
): Record<string, any> {
const run = shapeStep(root, opts)
const steps = run.steps
delete run.steps
// The backend labels every depth-0 job "Flow"; correct that for the graceful
// non-flow case so the model doesn't mistake a plain script run for a flow.
if (!steps && root.entry.kind !== 'flow' && root.entry.kind !== 'flowpreview') {
run.label = `Job (${root.entry.kind})`
}
return {
...(rootJobNote ? { note: rootJobNote } : {}),
run,
...(steps ? { steps } : {}),
hint: `Results are truncated. Call get_flow_run_details again with step="<step>" (e.g. "b/c", or "b[12]" for one loop iteration) for a step's result in full (up to ${STEP_RESULT_MAX_CHARS} chars).`
}
}
/** Render the whole tree, shrinking result heads until it fits the budget. */
export function shapeFlowRunTree(response: GetFlowAllResultsResponse): string {
const root = buildFlowTree(response.entries)
if (!root) {
return 'No jobs found for this run.'
}
const notes = [
...(response.enclosing_job
? [
`This job is a step of a larger flow run — its enclosing run is ${response.enclosing_job}; pass that id to see more of the tree.`
]
: []),
...(response.truncated
? [
`The run has more jobs than the server returns — this tree only covers the first ${response.entries.length} (depth-first), so tallies may undercount.`
]
: []),
...(response.scope_filtered
? [`Your token is tag-scoped: steps running on other tags are omitted from this tree.`]
: [])
]
const rootJobNote = notes.length > 0 ? notes.join(' ') : undefined
let rendered = ''
for (const opts of SHRINK_LADDER) {
rendered = JSON.stringify(renderTree(root, rootJobNote, opts), null, 1)
if (rendered.length <= TREE_TOTAL_BUDGET_CHARS) {
return rendered
}
}
return (
rendered.slice(0, TREE_TOTAL_BUDGET_CHARS) +
`\n… (tree truncated at ${TREE_TOTAL_BUDGET_CHARS} chars — drill into specific steps with the step parameter)`
)
}
/** Entry point of the get_flow_run_details tool. Without `step`: the compact
* tree. With `step`: that job's full (capped) result, resolved server-side. */
export async function getFlowRunDetails(
workspace: string,
id: string,
step?: string
): Promise<string> {
if (!step) {
return shapeFlowRunTree(
await JobService.getFlowAllResults({ workspace, id, maxResultLen: TREE_RESULT_HEAD_CHARS })
)
}
// Drill-down: the server resolves the address directly (a few indexed
// lookups, no tree enumeration) and returns the single job as an entry.
const response = await JobService.getFlowAllResults({
workspace,
id,
maxResultLen: STEP_RESULT_MAX_CHARS,
step
})
if (response.step_error) {
return (
response.step_error +
(response.scope_filtered
? ' (Steps running on tags outside your tokens scope are hidden.)'
: '')
)
}
const entry = response.entries[0]
if (!entry) {
return 'No jobs found for this run.'
}
if (entry.status === 'running' || entry.status === 'queued' || entry.status === 'suspended') {
return `Step "${step}" (job ${entry.job_id}) has not completed yet — status: ${entry.status}.`
}
if (entry.result_prefix === undefined || entry.result_prefix === null) {
return `Step "${step}" (job ${entry.job_id}, ${entry.status}) has no recorded result.`
}
const total = entry.result_length ?? countCodePoints(entry.result_prefix)
const capped =
total > countCodePoints(entry.result_prefix)
? entry.result_prefix + `\n… (result truncated: ${total} chars total)`
: entry.result_prefix
return `Step "${step}" (job ${entry.job_id}, ${entry.status}) result:\n${capped}`
}
@@ -16,6 +16,7 @@ import { applyReasoningToConfig } from '../reasoningRegistry'
import {
appendPendingToolImages,
processToolCall,
queuedToolStatus,
type Tool,
type ToolCallbacks,
type WebSearchSource
@@ -402,7 +403,7 @@ export async function parseOpenAIResponsesCompletion(
callbacks.onMessageEnd()
callbacks.setToolStatus(`${item.id}`, {
isLoading: true,
content: tool?.streamingLabel ?? `Calling ${item.name}...`,
content: tool?.streamingLabel ?? `Preparing ${item.name}...`,
toolName: item.name,
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
@@ -470,11 +471,12 @@ export async function parseOpenAIResponsesCompletion(
// Handle function call arguments done
runner.on('response.function_call_arguments.done', (event) => {
// Clear streaming state
// Args fully streamed: demote to queued (see queuedToolStatus).
currentStreamingTool = undefined
callbacks.setToolStatus(`${event.item_id}`, {
isStreamingArguments: false
})
callbacks.setToolStatus(
`${event.item_id}`,
queuedToolStatus(tools, toolCallsMap[event.item_id]?.name ?? '', event.arguments)
)
// Retrieve tool call metadata from map
const metadata = toolCallsMap[event.item_id]
@@ -888,6 +888,55 @@ describe('isActiveUserQuestion', () => {
})
})
describe('pendingUserAction', () => {
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
role: 'tool',
tool_call_id: 'call_p',
content: 'running',
isLoading: true,
...overrides
})
const question = toolMessage({ userQuestion: { question: 'Pick one', choices: ['a'] } })
it('distinguishes an unanswered question from a staged confirmation', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([question])).toBe('question')
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
})
it('is undefined for a tool the AI is running on its own', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([toolMessage()])).toBe(undefined)
expect(pendingUserAction([toolMessage({ needsConfirmation: true, isLoading: false })])).toBe(
undefined
)
})
// A multi-tool turn creates every card before running the calls one at a time,
// so the blocked card is not the last message.
it('finds a blocked card sitting behind queued ones', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([question, toolMessage(), toolMessage()])).toBe('question')
expect(pendingUserAction([toolMessage({ needsConfirmation: true }), toolMessage()])).toBe(
'confirmation'
)
})
// Text emitted between two tool calls lands as an assistant card between them.
it('finds a blocked card behind an interleaved assistant card', async () => {
const { pendingUserAction } = await import('./shared')
const assistant: DisplayMessage = { role: 'assistant', content: 'and also…' }
expect(pendingUserAction([question, assistant, toolMessage()])).toBe('question')
})
it('stops at the previous turn rather than reviving its resolved cards', async () => {
const { pendingUserAction } = await import('./shared')
const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'go on' }
expect(pendingUserAction([question, userMessage, toolMessage()])).toBe(undefined)
})
})
describe('pollJobCompletion detach', () => {
function makeCallbacks() {
return {
@@ -1069,6 +1118,68 @@ describe('trimJob', () => {
})
})
describe('processToolCall preAction', () => {
// preAction's "-ing" label must land at execution start, not stream time —
// firing it earlier would relabel a still-queued card as active.
it('invokes preAction at promotion, before the tool fn runs', async () => {
const { processToolCall } = await import('./shared')
const calls: string[] = []
const tool = {
def: { type: 'function' as const, function: { name: 'patch_app_file', parameters: {} } },
preAction: () => calls.push('preAction'),
fn: vi.fn().mockImplementation(async () => {
calls.push('fn')
return 'ok'
})
}
await processToolCall({
tools: [tool] as any,
toolCall: {
id: 'call_1',
type: 'function',
function: { name: 'patch_app_file', arguments: '{}' }
},
helpers: {},
toolCallbacks: { setToolStatus: vi.fn() } as any,
workspace: 'test'
})
expect(calls).toEqual(['preAction', 'fn'])
})
})
describe('queuedToolStatus', () => {
const tool = (extra: Record<string, unknown> = {}) => ({
def: { type: 'function' as const, function: { name: 'run_script', parameters: {} } },
fn: vi.fn(),
...extra
})
it('humanizes snake_case and camelCase tool names by default', async () => {
const { queuedToolStatus } = await import('./shared')
expect(queuedToolStatus([], 'run_script', '{}')).toMatchObject({
isLoading: false,
isQueued: true,
isStreamingArguments: false,
content: 'Run script'
})
expect(queuedToolStatus([], 'askUserQuestion', '{}').content).toBe('Ask user question')
})
it('derives the label from parsed args via queuedLabel', async () => {
const { queuedToolStatus } = await import('./shared')
const t = tool({ queuedLabel: (args: any) => `Test ${args.path}` })
expect(queuedToolStatus([t] as any, 'run_script', '{"path": "u/admin/x"}').content).toBe(
'Test u/admin/x'
)
})
it('falls back to the humanized name when args are truncated', async () => {
const { queuedToolStatus } = await import('./shared')
const t = tool({ queuedLabel: (args: any) => `Test ${args.path}` })
expect(queuedToolStatus([t] as any, 'run_script', '{"path": "u/adm').content).toBe('Run script')
})
})
describe('appendPendingToolImages', () => {
// Tool results are string-only, so tool-produced images ride a follow-up
// user message appended after the whole tool batch. It must land in BOTH
@@ -1124,3 +1235,70 @@ describe('openItemPreviewAction', () => {
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
})
})
describe('createSearchHubScriptsTool', () => {
const hit = (version_id: number, app: string, summary: string) => ({
version_id,
app,
summary,
ask_id: version_id,
id: version_id,
kind: 'script' as const,
score: 1
})
async function runWithContent(getHubScriptByPath: ReturnType<typeof vi.fn>) {
const { ScriptService } = await import('$lib/gen')
Object.assign(ScriptService, {
queryHubScripts: vi.fn(async () => [
hit(1, 'discord', 'Send a message'),
hit(2, 'slack', 'Post a message')
]),
getHubScriptByPath
})
const { createSearchHubScriptsTool } = await import('./shared')
const raw = await createSearchHubScriptsTool(true).fn({
args: { query: 'send a message' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
return JSON.parse(raw)
}
it('reports each script language alongside its content', async () => {
const results = await runWithContent(
vi.fn(async ({ path }: { path: string }) => ({
content: `// ${path}`,
language: path.startsWith('hub/1/') ? 'bunnative' : 'python3'
}))
)
expect(results).toEqual([
{
path: 'hub/1/discord/send_a_message',
summary: 'Send a message',
language: 'bunnative',
content: '// hub/1/discord/send_a_message'
},
{
path: 'hub/2/slack/post_a_message',
summary: 'Post a message',
language: 'python3',
content: '// hub/2/slack/post_a_message'
}
])
})
it('keeps the other results when one content fetch fails', async () => {
const results = await runWithContent(
vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('hub/1/')) throw new Error('hub unreachable')
return { content: 'ok', language: 'python3' }
})
)
expect(results[0].error).toContain('hub unreachable')
expect(results[0].content).toBeUndefined()
expect(results[1].content).toBe('ok')
})
})
@@ -577,6 +577,8 @@ export type ToolDisplayMessage = {
result?: any
logs?: string
isLoading?: boolean
/** Arguments fully streamed but execution not started (see queuedToolStatus). */
isQueued?: boolean
error?: string
needsConfirmation?: boolean
showDetails?: boolean
@@ -648,6 +650,26 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
)
}
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
// staged for confirmation. The manager stays `loading` through both, so anything
// rendering progress must ask here first or it reports "the AI is working".
export type PendingUserAction = 'question' | 'confirmation'
// Scans back to the turn boundary, not just the last message: a turn's cards are
// created up front and run one at a time, and text between two tool calls pushes
// an assistant card between them, so the blocked card is rarely last. Only cards
// of a live turn can match — every resolution path clears `isLoading`.
export function pendingUserAction(messages: DisplayMessage[]): PendingUserAction | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role === 'user') break
if (message.role !== 'tool') continue
if (isActiveUserQuestion(message)) return 'question'
if (message.needsConfirmation && message.isLoading) return 'confirmation'
}
return undefined
}
// Fires after every tool call resolves, with the tool name. Lets a host (e.g.
// the sessions page) react to mutating tools — refreshing previews — without
// the tool layer knowing about the UI. Single slot; the consumer filters by name
@@ -749,6 +771,7 @@ export async function processToolCall<T>({
content: validationError,
parameters: args,
isLoading: false,
isQueued: false,
isStreamingArguments: false,
error: validationError,
needsConfirmation: false,
@@ -773,12 +796,18 @@ export async function processToolCall<T>({
? tool.confirmationMessage(args)
: tool?.confirmationMessage
// preAction fires at promotion, not stream time, so its "-ing" label covers
// only the execution window — queued cards keep their imperative header.
// Before the promotion patch, so a confirmation label still wins the header.
tool?.preAction?.({ toolCallbacks, toolId: toolCall.id })
toolCallbacks.setToolStatus(toolCall.id, {
...(requiresConfirmation
? { content: confirmationContent ?? 'Waiting for confirmation...' }
: {}),
parameters: args,
isLoading: true,
isQueued: false,
needsConfirmation: needsConfirmation,
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
@@ -851,6 +880,7 @@ export async function processToolCall<T>({
const errorMessage = formatToolError(err)
toolCallbacks.setToolStatus(toolCall.id, {
isLoading: false,
isQueued: false,
isStreamingArguments: false,
error: errorMessage
})
@@ -913,8 +943,40 @@ export interface Tool<T> {
streamArguments?: boolean
showFade?: boolean
/** Header shown while the model is still streaming this call's arguments,
* before `fn` runs and sets a real status. Defaults to "Calling <name>...". */
* before `fn` runs and sets a real status. Defaults to "Preparing <name>...". */
streamingLabel?: string
/** Header shown while the call waits its turn to execute (args fully streamed).
* Pass a function to derive it from the parsed arguments (e.g. name the script
* about to run). Defaults to the humanized tool name ("run_script" "Run script"). */
queuedLabel?: string | ((args: any) => string)
}
/** Status patch demoting a tool call to the queued state once its arguments have
* fully streamed: it waits its turn (tool calls in one message run sequentially)
* and processToolCall flips it back to loading when execution starts. The header
* switches from the "-ing" streaming label to an imperative one so a waiting call
* doesn't read as active. */
export function queuedToolStatus(
tools: Tool<any>[],
toolName: string,
argsString: string | undefined
): Partial<ToolDisplayMessage> {
const tool = tools.find((t) => t.def.function.name === toolName)
const words = toolName
.replaceAll('_', ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
let content = words.charAt(0).toUpperCase() + words.slice(1)
if (typeof tool?.queuedLabel === 'string') {
content = tool.queuedLabel
} else if (typeof tool?.queuedLabel === 'function') {
try {
content = tool.queuedLabel(JSON.parse(argsString || '{}'))
} catch {
// Truncated/invalid args: keep the humanized name; the error path handles the rest.
}
}
return { isLoading: false, isQueued: true, isStreamingArguments: false, content }
}
/** Status of a job the chat started and tracks in the jobs tray. Mirrors the
@@ -1130,6 +1192,18 @@ const searchHubScriptsToolDef = createToolDef(
'Search for scripts in the hub'
)
/** The hub resolves a script by its version id alone; the app and summary
* segments are descriptive only. Mirrors the paths the hub pickers build. */
function hubScriptPath(s: { version_id: number; app: string; summary: string }): string {
return `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
}
/** Hub scripts are hosted outside the workspace: their paths resolve through the
* hub endpoints only, never through workspace lookups or drafts. */
export function isHubPath(path: string): boolean {
return path.startsWith('hub/')
}
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
def: searchHubScriptsToolDef,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -1141,22 +1215,29 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
text: parsedArgs.query,
kind: 'script'
})
// Each result costs a content fetch, so cap the fan-out when content is wanted.
const matches = withContent ? scripts.slice(0, 3) : scripts
toolCallbacks.setToolStatus(toolId, {
content: 'Found ' + scripts.length + ' scripts in the hub related to "' + args.query + '"'
content: `Found ${matches.length} script${matches.length === 1 ? '' : 's'} in the hub related to "${parsedArgs.query}"`
})
// if withContent, fetch scripts with their content, limit to 3 results
const results = await Promise.all(
scripts.slice(0, withContent ? 3 : undefined).map(async (s) => {
let content = ''
if (withContent) {
content = await ScriptService.getHubScriptContentByPath({
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`
})
matches.map(async (s) => {
const path = hubScriptPath(s)
if (!withContent) {
return { path, summary: s.summary }
}
return {
path: `hub/${s.version_id}/${s.app}/${s.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
summary: s.summary,
...(withContent ? { content } : {})
try {
// get_full, not the raw content endpoint: callers are told to match the
// script's language, which raw content does not carry.
const hub = await ScriptService.getHubScriptByPath({ path })
return { path, summary: s.summary, language: hub.language, content: hub.content }
} catch (err) {
// One unreachable script must not sink the whole search.
return {
path,
summary: s.summary,
error: `Could not fetch content: ${err instanceof Error ? err.message : String(err)}`
}
}
})
)
@@ -1489,10 +1570,14 @@ export function backgroundJobCompletionNote(
): string {
const status = job.success ? 'succeeded' : 'FAILED'
const resultHead = formattedResult ?? formatResult(job.result).slice(0, 2000)
const flowHint =
!job.success && (job.job_kind === 'flow' || job.job_kind === 'flowpreview')
? ` For per-step statuses and results call get_flow_run_details with id="${jobId}".`
: ''
return (
`Background job ${jobId} for "${label}" ${status}.\n` +
`Result: ${resultHead}\n` +
`(For full logs call get_job_logs with id="${jobId}".)`
`(For full logs call get_job_logs with id="${jobId}".${flowHint})`
)
}
@@ -1570,7 +1655,16 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
...(job.success ? {} : { error: getErrorMessage(job.result) })
})
return formatResultSummary(job.result, job.logs, job.success)
const summary = formatResultSummary(job.result, job.logs, job.success)
// get_flow_run_details only exists in the global/sessions chat (the same
// hosts that wire the job hooks) — don't advertise it to in-editor chats.
if (detachEnabled && config.contextName === 'flow' && !job.success) {
return (
summary +
`\n\nFor per-step statuses and results (subflow steps included), call get_flow_run_details with id="${jobId}".`
)
}
return summary
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
config.toolCallbacks.setToolStatus(config.toolId, {
@@ -134,6 +134,95 @@ describe('parseOpenAICompletion tool call arguments', () => {
expect(addedMessages).toEqual(messages)
})
it('marks only the executing tool call as loading when one message has several', async () => {
const { parseOpenAICompletion } = await import('./lib')
const statuses: Record<string, any> = {}
const callbacks = createCallbacks()
callbacks.setToolStatus.mockImplementation((id: string, patch: any) => {
statuses[id] = { ...statuses[id], ...patch }
})
// Snapshot both cards' statuses at each execution start: tools run
// sequentially, so the not-yet-started call must read as queued, not loading.
const seen: any[] = []
const fn = vi.fn().mockImplementation(async () => {
seen.push({ call_1: { ...statuses['call_1'] }, call_2: { ...statuses['call_2'] } })
return 'tool ok'
})
await parseOpenAICompletion(
streamOf([
toolCallChunk({
index: 0,
id: 'call_1',
function: { name: 'patch_app_file', arguments: '{}' }
}),
toolCallChunk({
index: 1,
id: 'call_2',
function: { name: 'patch_app_file', arguments: '{}' }
})
]),
callbacks,
[],
[],
[createTool(fn)] as any,
{},
undefined,
{ workspace: 'test' }
)
expect(seen).toHaveLength(2)
expect(seen[0].call_1).toMatchObject({ isLoading: true, isQueued: false })
// Queued header is the humanized tool name, not the "-ing" streaming label.
expect(seen[0].call_2).toMatchObject({
isLoading: false,
isQueued: true,
content: 'Patch app file'
})
expect(seen[1].call_1).toMatchObject({ isLoading: false })
expect(seen[1].call_2).toMatchObject({ isLoading: true, isQueued: false })
})
it('never leaves a call both queued and loading when deltas interleave', async () => {
const { parseOpenAICompletion } = await import('./lib')
const statuses: Record<string, any> = {}
// Merged state after every patch: a call demoted to queued then re-promoted
// by an interleaved delta must never keep both flags set at once.
const timeline: Array<{ id: string; isLoading: any; isQueued: any }> = []
const callbacks = createCallbacks()
callbacks.setToolStatus.mockImplementation((id: string, patch: any) => {
statuses[id] = { ...statuses[id], ...patch }
timeline.push({ id, isLoading: statuses[id].isLoading, isQueued: statuses[id].isQueued })
})
await parseOpenAICompletion(
streamOf([
toolCallChunk({
index: 0,
id: 'call_1',
function: { name: 'patch_app_file', arguments: '{"path": ' }
}),
toolCallChunk({
index: 1,
id: 'call_2',
function: { name: 'patch_app_file', arguments: '{}' }
}),
// call_1 resumes streaming after call_2 started: re-promotion must clear isQueued
toolCallChunk({ index: 0, function: { arguments: '"u/admin/app"}' } })
]),
callbacks,
[],
[],
[createTool()] as any,
{},
undefined,
{ workspace: 'test' }
)
const bothFlags = timeline.filter((s) => s.isLoading === true && s.isQueued === true)
expect(bothFlags).toEqual([])
})
it('executes a tool call with valid streamed arguments and keeps them verbatim', async () => {
const { parseOpenAICompletion } = await import('./lib')
const fn = vi.fn().mockResolvedValue('tool ok')
+25 -7
View File
@@ -20,6 +20,7 @@ import { formatResourceTypes } from './utils'
import {
appendPendingToolImages,
processToolCall,
queuedToolStatus,
type Tool,
type ToolCallbacks
} from './chat/shared'
@@ -1073,6 +1074,9 @@ export async function parseOpenAICompletion(
options?: { workspace?: string; provider?: string }
): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
// The tool call currently receiving argument deltas; when the stream moves on
// to the next call, the previous one is demoted to queued.
let streamingToolCallId: string | undefined = undefined
let malformedFunctionCallError = false
let tokenUsage = emptyChatTokenUsage()
@@ -1163,10 +1167,17 @@ export async function parseOpenAICompletion(
id: toolCallId
} = finalToolCall
if (funcName && toolCallId) {
const tool = tools.find((t) => t.def.function.name === funcName)
if (tool && tool.preAction) {
tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId })
if (streamingToolCallId !== undefined && streamingToolCallId !== toolCallId) {
const previous = Object.values(finalToolCalls).find(
(tc) => tc.id === streamingToolCallId
)
callbacks.setToolStatus(
streamingToolCallId,
queuedToolStatus(tools, previous?.function?.name ?? '', previous?.function?.arguments)
)
}
streamingToolCallId = toolCallId
const tool = tools.find((t) => t.def.function.name === funcName)
const shouldStream = tool?.streamArguments ?? false
const accumulatedArgs = finalToolCall.function.arguments
@@ -1179,10 +1190,13 @@ export async function parseOpenAICompletion(
}
}
// Display tool call with streaming parameters if enabled
// Display tool call with streaming parameters if enabled. isQueued is
// cleared explicitly: a provider may interleave deltas of parallel
// calls, re-promoting a call that was already demoted to queued.
callbacks.setToolStatus(toolCallId, {
isLoading: true,
content: tool?.streamingLabel ?? `Calling ${funcName}...`,
isQueued: false,
content: tool?.streamingLabel ?? `Preparing ${funcName}...`,
toolName: funcName,
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
@@ -1207,10 +1221,13 @@ export async function parseOpenAICompletion(
callbacks.onMessageEnd()
// Clear streaming state for all tool calls
// Stream over: every parsed call is queued until its turn in processToolCall.
for (const toolCall of Object.values(finalToolCalls)) {
if (toolCall.id) {
callbacks.setToolStatus(toolCall.id, { isStreamingArguments: false })
callbacks.setToolStatus(
toolCall.id,
queuedToolStatus(tools, toolCall.function?.name ?? '', toolCall.function?.arguments)
)
}
}
@@ -1240,6 +1257,7 @@ export async function parseOpenAICompletion(
if (invalidToolCallIds.has(toolCall.id)) {
callbacks.setToolStatus(toolCall.id, {
isLoading: false,
isQueued: false,
isStreamingArguments: false,
error: 'Tool call arguments were invalid or truncated'
})
@@ -122,10 +122,13 @@ export function agentToolToFlowModule(tool: FlowModuleTool): FlowModule {
}
/**
* Convert a FlowModule back to an AgentTool
* Used when saving changes back to the AI Agent tools array
* Wrap a newly created FlowModule as an AgentTool.
*
* Only valid for a module that is not already a tool: FlowModule carries none of the
* AgentTool-level metadata (`description`), so folding an edited module back into an
* existing tool through here would drop it spread over the existing tool instead.
*/
export function flowModuleToAgentTool(flowModule: FlowModule): AgentTool {
export function newFlowModuleAgentTool(flowModule: FlowModule): AgentTool {
return {
id: flowModule.id,
summary: flowModule.summary,
@@ -1380,7 +1380,11 @@
maximum number of attempts as defined below.
</Tooltip>
{/snippet}
<FlowRetries bind:flowModuleRetry={flowModule.retry} bind:flowModule />
<FlowRetries
bind:flowModuleRetry={flowModule.retry}
bind:flowModule
{isAgentTool}
/>
</Section>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'concurrency'}
<Section label="Concurrency limits" class="flex flex-col gap-4" eeOnly>
@@ -1545,7 +1549,11 @@
</div>
{:else if advancedSelected === 'sleep'}
<div>
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
<FlowModuleSleep
previousModuleId={previousModule?.id}
bind:flowModule
{isAgentTool}
/>
</div>
{:else if advancedSelected === 'debounce'}
<div>
@@ -12,13 +12,16 @@
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import { getStepPropPicker } from '../previousResults'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
import { Alert } from '$lib/components/common'
interface Props {
flowModule: FlowModule
previousModuleId: string | undefined
isAgentTool?: boolean
}
let { flowModule = $bindable(), previousModuleId }: Props = $props()
let { flowModule = $bindable(), previousModuleId, isAgentTool = false }: Props = $props()
const { selectionManager, flowStore, flowStateStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -44,6 +47,8 @@
const result = flowStateStore.val[selectionManager.getSelectedId()]?.previewResult ?? {}
let isSleepEnabled = $derived(Boolean(flowModule.sleep))
// Agent tools never go through the flow scheduler, so `same_worker` doesn't apply to them.
let sameWorker = $derived(Boolean(!isAgentTool && flowStore.val.value.same_worker))
</script>
<Section label="Sleep" class="w-full">
@@ -54,8 +59,15 @@
</Tooltip>
{/snippet}
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory" class="mb-4">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use a sleep.
</Alert>
{/if}
<Toggle
checked={isSleepEnabled}
disabled={sameWorker}
class="mb-6"
on:change={() => {
if (isSleepEnabled && flowModule.sleep != undefined) {
@@ -72,7 +84,7 @@
}}
/>
<Label label="Sleep for duration">
{#if flowModule.sleep && schema.properties['sleep']}
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
<div class="border rounded-md overflow-auto">
<PropPickerWrapper
noFlowPlugConnect={true}
@@ -15,17 +15,21 @@
import { NEVER_TESTED_THIS_FAR } from '../models'
import { validateRetryConfig } from '$lib/utils'
import EEOnly from '$lib/components/EEOnly.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
interface Props {
flowModuleRetry: Retry | undefined
disabled?: boolean
flowModule?: FlowModule
isAgentTool?: boolean
}
let {
flowModule = $bindable(),
flowModuleRetry = $bindable(),
disabled = false
disabled = false,
isAgentTool = false
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -53,6 +57,14 @@
: null
)
// `flowModule` is only set when editing a flow step: the trigger/schedule usages of this
// component share the flow editor context but are unaffected by `same_worker`, and so are
// agent tools, which never go through the flow scheduler.
let sameWorker = $derived(
Boolean(flowModule && !isAgentTool && flowStore?.val?.value?.same_worker)
)
let isDisabled = $derived(disabled || sameWorker)
let isRetryConditionEnabled = $derived(Boolean(flowModuleRetry?.retry_if))
let result = $derived(
flowModule && flowStateStore?.val
@@ -111,9 +123,15 @@
</script>
<div class="flex flex-col gap-4">
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use retries.
</Alert>
{/if}
<ToggleButtonGroup
bind:selected={delayType}
class={`${disabled ? 'disabled' : ''}`}
disabled={isDisabled}
on:selected={(e) => {
flowModuleRetry = undefined
if (e.detail === 'constant') {
@@ -132,7 +150,7 @@
{/snippet}
</ToggleButtonGroup>
{#if delayType === 'constant' || delayType === 'exponential'}
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<Section label="Retry Condition" class="w-full">
{#snippet header()}
<Tooltip>
@@ -216,7 +234,7 @@
</Section>
{/if}
{#if delayType === 'constant' || delayType === 'exponential'}
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<div class="flex h-[calc(100%-22px)]">
<div class="w-1/2 h-full overflow-auto pr-2">
{#if delayType === 'constant'}
@@ -30,6 +30,7 @@
import OnBehalfOfSelector, {
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
interface Props {
noEditor: boolean
@@ -63,6 +64,11 @@
let displayWorkerTagPicker = $state(false)
// Only block turning it on: a flow deployed with both (CLI, YAML editor) must stay fixable.
let conflictingModuleIds = $derived(
flowStore.val.value.same_worker ? [] : modulesWithRetryOrSleep(flowStore.val.value)
)
run(() => {
if (flowStore.val.tag) {
displayWorkerTagPicker = true
@@ -394,20 +400,31 @@
<!-- Shared Directory Section -->
{#if customUi?.settingsTabs?.sharedDiretory != false}
<Toggle
textClass="font-medium"
size="xs"
bind:checked={flowStore.val.value.same_worker}
options={{
right: 'Same Worker + Shared directory on `./shared`',
rightTooltip:
'Steps will share a folder at `./shared` in which they can store heavier data and ' +
'pass them to the next step. Beware that the `./shared` folder is not ' +
'preserved across suspends and sleeps.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory'
}}
/>
<div class="flex flex-col gap-1">
<Toggle
textClass="font-medium"
size="xs"
disabled={conflictingModuleIds.length > 0}
bind:checked={flowStore.val.value.same_worker}
options={{
right: 'Same Worker + Shared directory on `./shared`',
rightTooltip:
'Steps will share a folder at `./shared` in which they can store heavier data and ' +
'pass them to the next step. Beware that the `./shared` folder is not ' +
'preserved across suspends and sleeps.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory'
}}
/>
{#if conflictingModuleIds.length > 0}
<span class="text-xs text-secondary">
{SAME_WORKER_INCOMPATIBLE_MSG} Remove them from step{conflictingModuleIds.length > 1
? 's'
: ''}
{conflictingModuleIds.join(', ')} first.
</span>
{/if}
</div>
{/if}
<!-- Visibility Section -->
@@ -45,7 +45,7 @@
import {
type AgentTool,
type SpecialToolKind,
flowModuleToAgentTool,
newFlowModuleAgentTool,
createMcpTool,
createWebsearchTool,
createAiAgentTool,
@@ -250,8 +250,7 @@
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
return modules as AgentTool[]
} else if (toolKind === 'flowmoduleTool') {
// Create AgentTool from FlowModule
const agentTool = flowModuleToAgentTool(module)
const agentTool = newFlowModuleAgentTool(module)
;(modules as AgentTool[]).splice(index, 0, agentTool)
return modules as AgentTool[]
} else {
@@ -611,6 +610,7 @@
{flowHasChanged}
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
onDelete={(id) => requestDelete([id])}
onDismissRunNode={(id) => onDelete?.(id)}
onInsert={async (detail) => {
if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return
await tick()
@@ -6,13 +6,16 @@ import {
type Job,
type RestartedFrom,
type OpenFlow,
type MemoryConfig
type MemoryConfig,
type FlowValue,
type Retry
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanExpr, emptySchema } from '$lib/utils'
import { get } from 'svelte/store'
import type { FlowModuleState } from './flowState'
import { type PickableProperties, dfs } from './previousResults'
import { forEachFlowModule } from './dfs'
import { NEVER_TESTED_THIS_FAR } from './models'
import { sendUserToast } from '$lib/toast'
import type { ExtendedOpenFlow } from './types'
@@ -227,6 +230,37 @@ export function emptyFlowModuleState(): FlowModuleState {
}
}
// `same_worker` hands the next step directly to the worker holding `./shared`, bypassing
// `scheduled_for`: a retry delay is silently ignored, and a sleep breaks the hand-off so the
// next step can land on another worker without `./shared`. Keep the two mutually exclusive.
export const SAME_WORKER_INCOMPATIBLE_MSG =
'Retries and sleeps are not compatible with the shared directory (`Same Worker`): retry delays would be ignored and a sleep would lose the `./shared` folder.'
// Mirrors the backend's `Retry::has_attempts`: a retry with no attempt never runs, and the
// retries tab renders it as "Disabled", so it must not block anything.
function hasRetryAttempts(retry: Retry | undefined): boolean {
return (retry?.constant?.attempts ?? 0) > 0 || (retry?.exponential?.attempts ?? 0) > 0
}
export function modulesWithRetryOrSleep(flow: FlowValue): string[] {
// The failure and preprocessor modules live outside `modules` but run as regular steps on
// the same-worker hand-off. Agent tools don't: they never go through the flow scheduler.
const roots = [flow.modules, flow.failure_module, flow.preprocessor_module]
.flat()
.filter((m) => m != undefined)
const ids: string[] = []
forEachFlowModule(
roots,
(m) => {
if (hasRetryAttempts(m.retry) || m.sleep != undefined) {
ids.push(m.id)
}
},
{ skipToolNodes: true }
)
return ids
}
export function checkIfParentLoop(
flowStore: ExtendedOpenFlow,
modId: string
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { FlowValue } from '$lib/gen'
import { modulesWithRetryOrSleep } from './utils.svelte'
const constantRetry = { constant: { attempts: 1, seconds: 5 } }
function step(id: string, extra: Record<string, unknown> = {}) {
return { id, value: { type: 'identity' }, ...extra } as any
}
describe('modulesWithRetryOrSleep', () => {
it('reports retries and sleeps everywhere same_worker applies', () => {
const flow: FlowValue = {
modules: [
step('a', { retry: constantRetry }),
step('b', { sleep: { type: 'static', value: 3 } }),
step('c'),
{
id: 'd',
value: {
type: 'forloopflow',
modules: [step('e', { retry: constantRetry })],
iterator: { type: 'static', value: [] },
skip_failures: false
}
} as any
],
failure_module: step('failure', { retry: constantRetry }),
preprocessor_module: step('preprocessor', { sleep: { type: 'static', value: 1 } })
}
expect(modulesWithRetryOrSleep(flow)).toEqual(['a', 'b', 'e', 'failure', 'preprocessor'])
})
it('ignores what same_worker does not govern: agent tools and attempt-less retries', () => {
const flow: FlowValue = {
modules: [
step('a', { retry: { constant: { attempts: 0, seconds: 5 } } }),
{
id: 'b',
value: { type: 'aiagent', tools: [step('tool', { retry: constantRetry })] }
} as any
]
}
expect(modulesWithRetryOrSleep(flow)).toEqual([])
})
})
@@ -5,6 +5,7 @@
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
@@ -48,7 +49,7 @@
await gitSyncContext.detectRepository(idx)
} catch (error: any) {
console.error('Detection failed:', error)
sendUserToast('Detection failed: ' + error.message, true)
sendUserToast('Detection failed: ' + apiErrorMessage(error), true)
}
}
@@ -72,7 +73,7 @@
sendUserToast('Git sync connection saved successfully')
} catch (error: any) {
console.error('Failed to save connection:', error)
sendUserToast('Failed to save connection: ' + error.message, true)
sendUserToast('Failed to save connection: ' + apiErrorMessage(error), true)
}
}
</script>
@@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type {
GitRepositorySettings as BackendGitRepositorySettings,
@@ -373,7 +374,7 @@ export function createGitSyncContext(workspace: string) {
}
repo.detectionState = 'error'
repo.detectionError = error?.message || error?.toString() || 'Failed to detect repository'
repo.detectionError = apiErrorMessage(error) || 'Failed to detect repository'
repo.detectionJobStatus = 'failure'
}
}
@@ -599,11 +600,7 @@ export function createGitSyncContext(workspace: string) {
})
} catch (error: any) {
// Initialize the job entry if it doesn't exist (e.g., job creation failed)
const errorMessage =
(typeof error?.body === 'string' ? error.body : error?.body?.message) ||
error?.message ||
error?.toString() ||
'Failed to run test job'
const errorMessage = apiErrorMessage(error) || 'Failed to run test job'
if (!gitSyncTestJobs[idx]) {
gitSyncTestJobs[idx] = {
jobId: '',
@@ -4,6 +4,7 @@
import PullWorkspaceModal from '$lib/components/git_sync/PullWorkspaceModal.svelte'
import GitSyncSuccessModal from '$lib/components/git_sync/GitSyncSuccessModal.svelte'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
const gitSyncContext = $state(getGitSyncContext())
@@ -15,11 +16,17 @@
// If this was a repository initialization, auto-save the connection
if (repo.isUnsavedConnection && repo.detectionState === 'no-wmill') {
gitSyncContext.saveRepository(idx).then(() => {
sendUserToast('Repository initialized and connection saved successfully')
}).catch((error) => {
sendUserToast('Repository initialized but failed to save connection: ' + error.message, true)
})
gitSyncContext
.saveRepository(idx)
.then(() => {
sendUserToast('Repository initialized and connection saved successfully')
})
.catch((error) => {
sendUserToast(
'Repository initialized but failed to save connection: ' + apiErrorMessage(error),
true
)
})
} else {
sendUserToast('Successfully pushed to git repository')
}
@@ -27,7 +34,6 @@
gitSyncContext.closePushModal()
}
function handlePullSuccess() {
sendUserToast('Successfully pulled from git repository')
gitSyncContext.closePullModal()
@@ -54,7 +60,7 @@
sendUserToast('Connection saved successfully without initializing repository')
gitSyncContext.closePushModal()
} catch (error: any) {
sendUserToast('Failed to save connection: ' + error.message, true)
sendUserToast('Failed to save connection: ' + apiErrorMessage(error), true)
}
}
</script>
@@ -16,6 +16,7 @@
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import DetectionFlow from './DetectionFlow.svelte'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { fade } from 'svelte/transition'
import { workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
import type { GitSyncRepository } from './GitSyncContext.svelte'
@@ -114,7 +115,10 @@
repo.use_individual_branch = prevIndiv
repo.group_by_folder = prevGbf
}
sendUserToast(`Could not ${v ? 'enable' : 'disable'} Git promotion: ${e}`, true)
sendUserToast(
`Could not ${v ? 'enable' : 'disable'} Git promotion: ${apiErrorMessage(e)}`,
true
)
} finally {
savingDevPromotion = false
}
@@ -128,7 +132,7 @@
await gitSyncContext.saveRepository(idx)
} catch (e) {
if (repo) repo.group_by_folder = prev
sendUserToast(`Could not change promotion granularity: ${e}`, true)
sendUserToast(`Could not change promotion granularity: ${apiErrorMessage(e)}`, true)
} finally {
savingDevPromotion = false
}
@@ -353,7 +357,7 @@
sendUserToast('Repository settings updated')
} catch (error: any) {
console.error('Failed to save repository:', error)
sendUserToast('Failed to save repository: ' + error.message, true)
sendUserToast('Failed to save repository: ' + apiErrorMessage(error), true)
}
}
@@ -379,7 +383,7 @@
sendUserToast('Repository connection removed successfully')
} catch (error: any) {
console.error('Failed to remove repository:', error)
sendUserToast('Failed to remove repository: ' + error.message, true)
sendUserToast('Failed to remove repository: ' + apiErrorMessage(error), true)
} finally {
confirmingDelete = false
}
@@ -15,6 +15,7 @@
import { JobService, ResourceService } from '$lib/gen'
import { workspaceStore, userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import hubPaths from '$lib/hubPaths.json'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync'
@@ -100,7 +101,7 @@
onSettingsSaved?.()
} catch (error) {
console.error('Failed to save settings:', error)
sendUserToast('Failed to save updated settings', true)
sendUserToast('Failed to save updated settings: ' + apiErrorMessage(error), true)
}
}
@@ -289,7 +290,7 @@
}
} catch (error: any) {
console.error('Failed to apply settings:', error)
sendUserToast('Failed to apply settings: ' + error.message, true)
sendUserToast('Failed to apply settings: ' + apiErrorMessage(error), true)
} finally {
isApplying = false
}
@@ -27,6 +27,7 @@
type SimplifiableFlow
} from './graphBuilder.svelte'
import ModuleNode from './renderers/nodes/ModuleNode.svelte'
import FailureModuleNode from './renderers/nodes/FailureModuleNode.svelte'
import InputNode from './renderers/nodes/InputNode.svelte'
import BranchAllStart from './renderers/nodes/BranchAllStart.svelte'
import BranchAllEndNode from './renderers/nodes/BranchAllEndNode.svelte'
@@ -169,6 +170,9 @@
onMoveMultiple?: (ids: string[]) => void
movingIds?: string[]
onDelete?: (id: string) => void
/** Forget the run state of a node that only mirrors a run (the error handler marker), so it
* stops being rendered. Must not touch the flow itself. */
onDismissRunNode?: (id: string) => void
onInsert?: (detail: {
sourceId?: string
targetId?: string
@@ -218,6 +222,7 @@
let {
onInsert = undefined,
onDelete = undefined,
onDismissRunNode = undefined,
onMove = undefined,
onDuplicate = undefined,
onDeleteBranch = undefined,
@@ -531,6 +536,9 @@
},
hideJobStatus: () => {
onHideJobStatus?.()
},
dismissRunNode: (id: string) => {
onDismissRunNode?.(id)
}
}
@@ -806,6 +814,7 @@
const nodeTypes = {
input2: InputNode,
module: ModuleNode,
failureModule: FailureModuleNode,
branchAllStart: BranchAllStart,
branchAllEnd: BranchAllEndNode,
forLoopEnd: ForLoopEndNode,
@@ -62,6 +62,8 @@ export type GraphEventHandlers = {
deleteBranch: (detail: { id: string; index: number }, label: string) => void
select: (mod: string | FlowModule) => void
delete: (detail: { id: string }, label: string) => void
/** Drop a node that only mirrors run state (the error handler marker). Never edits the flow. */
dismissRunNode: (id: string) => void
newBranch: (id: string) => void
move: (detail: { id: string }) => void
duplicate: (detail: { id: string }) => void
@@ -123,6 +125,7 @@ export type FlowNode =
| CollapsedGroupN
| GroupHeadN
| GroupEndN
| FailureModuleN
export type InputN = {
type: 'input2'
@@ -163,6 +166,18 @@ export type ModuleN = {
}
}
/** The error handler as it appears in the editor graph once a run triggered it: an inert
* marker of where the handler fired, not an editable step of the flow structure. */
export type FailureModuleN = {
type: 'failureModule'
data: {
id: string
module: FlowModule
eventHandlers: GraphEventHandlers
flowModuleState: GraphModuleState | undefined
}
}
export type BranchAllStartN = {
type: 'branchAllStart'
data: {
@@ -387,7 +402,13 @@ export function topologicalSort(
if (visited.has(id)) return
visited.add(id)
const node = nodeMap.get(id)!
// A parent id with no node is a bug in whoever built the graph, but the whole editor
// unmounts if this throws, so warn and let the rest of the graph render.
const node = nodeMap.get(id)
if (!node) {
console.warn('Edge to a node that does not exist: ', id)
return
}
node.parentIds?.forEach(visit)
result.push(node)
}
@@ -484,6 +505,29 @@ export function graphBuilder(
return module.id
}
// In the editor the error handler is configured from its own header button, so its graph node
// is only a marker of the last run: inert, dismissable, and never selectable. Everywhere else
// (run view, viewers) it stays a regular step card.
function addFailureNode(module: FlowModule) {
if (!extra.editMode) {
return addNode(module)
}
nodes.push({
id: module.id,
data: {
id: module.id,
module,
eventHandlers: eventHandlers,
flowModuleState: extra.flowModuleStates?.[module.id]
},
type: 'failureModule',
selectable: false
})
return module.id
}
// TODO : Do better than this
const nodeIdsWithOutputAssets = new Set(
getAllModules(modules)
@@ -1193,7 +1237,17 @@ export function graphBuilder(
processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined)
}
// Before the failure markers: the preprocessor can be the step that failed, and a marker is
// only anchored to a step already present in `nodes`.
if (preprocessorModule) {
addNode(preprocessorModule)
const id = JSON.parse(JSON.stringify(preprocessorModule.id))
addEdge(id, 'Input', undefined, undefined, { type: 'empty' })
}
if (failureModule) {
// Keyed by failing step, so a step that failed several times (loop iterations each run
// their own handler, with ids like `failure-0-1`) gets one marker, not a stack of them.
let toAdd: Record<string, string> = {}
Object.keys(extra.flowModuleStates ?? {}).forEach((id) => {
if (id.startsWith('failure')) {
@@ -1205,19 +1259,19 @@ export function graphBuilder(
})
Object.entries(toAdd).forEach((x) => {
addNode({ ...failureModule, id: x[1] })
// Run state outlives the flow it ran on: a step deleted since the run keeps its marker
// in `flowModuleStates`. Anchoring the marker to a step that is no longer in the graph
// leaves a parent id no node answers to, which the layout cannot sort.
if (!nodes.some((n) => n.id === x[0])) {
return
}
addFailureNode({ ...failureModule, id: x[1] })
addEdge(x[0], x[1], undefined, undefined, { type: 'empty' })
})
}
if (preprocessorModule) {
addNode(preprocessorModule)
const id = JSON.parse(JSON.stringify(preprocessorModule.id))
addEdge(id, 'Input', undefined, undefined, { type: 'empty' })
}
if (failureModule && !extra.flowModuleStates) {
addNode(failureModule)
addFailureNode(failureModule)
}
Object.keys(parents).forEach((key) => {
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest'
// Mock modules that transitively import CSS/Monaco
vi.mock('monaco-editor', () => ({}))
vi.mock('@xyflow/svelte', () => ({}))
vi.mock('./renderers/nodes/AssetNode.svelte', () => ({
assetDisplaysAsOutputInFlowGraph: () => false
}))
vi.mock('../modulesTest.svelte', () => ({}))
import { topologicalSort } from './graphBuilder.svelte'
describe('topologicalSort', () => {
it('skips parent ids with no node instead of throwing', () => {
const sorted = topologicalSort([
{ id: 'a' },
{ id: 'b', parentIds: ['a'] },
{ id: 'orphan', parentIds: ['deleted'] }
])
expect(sorted.map((n) => n.id)).toEqual(['orphan', 'b', 'a'])
})
})
@@ -0,0 +1,70 @@
<script lang="ts">
import { Bug, EyeOff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import NodeWrapper from './NodeWrapper.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import type { FailureModuleN } from '../../graphBuilder.svelte'
import { getNodeColorClasses, NODE } from '$lib/components/graph'
import { msToSec } from '$lib/utils'
interface Props {
data: FailureModuleN['data']
}
let { data }: Props = $props()
let state = $derived(data.flowModuleState)
let colorClasses = $derived(getNodeColorClasses(state?.skipped ? '_Skipped' : state?.type, false))
</script>
<NodeWrapper>
{#if state?.duration_ms}
<div
class="absolute z-5 right-0 -top-4 mr-2 center-center text-2xs font-normal text-gray-400 dark:text-gray-500"
>
{msToSec(state.duration_ms)}s
</div>
{/if}
<div
class="flex flex-row justify-center items-center"
style="width: {NODE.width}px; height: {NODE.height}px;"
>
<div class="group relative">
<Tooltip placement="bottom">
<div
class={twMerge(
'flex flex-row items-center gap-2 rounded-md border border-dashed border-border-normal px-3 py-1.5 max-w-full cursor-default',
colorClasses.bg
)}
>
<Bug size={14} class={twMerge('shrink-0', colorClasses.text)} />
<div class={twMerge('truncate text-2xs', colorClasses.text)}>
{data.module.summary || 'Error handler'}
</div>
</div>
{#snippet text()}
Marks where the error handler ran during the last run. It is not a step of the flow: edit
it from the error handler button above the graph.
{/snippet}
</Tooltip>
<!-- Deliberately not an `X` on a bug badge: that is the header control which deletes
`failure_module` for good. This one only hides a run marker. -->
<Tooltip
placement="top"
class="absolute -top-1.5 -right-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
<button
type="button"
aria-label="Hide error handler run marker"
class="rounded-full border border-border bg-surface p-0.5 text-secondary hover:bg-surface-hover"
onclick={() => data.eventHandlers.dismissRunNode(data.id)}
>
<EyeOff size={11} />
</button>
{#snippet text()}
Hide this marker until the next run. The error handler itself is kept.
{/snippet}
</Tooltip>
</div>
</div>
</NodeWrapper>
@@ -1361,7 +1361,7 @@
if (itemKind != 'all') {
subtab = v
}
setQuery(page.url, 'kind', v)
setQuery('kind', v)
}}
>
{#snippet children({ item })}
@@ -184,11 +184,15 @@
<strong>Homepage URL</strong>: your Windmill instance URL
</li>
<li>
<strong>Callback URL</strong>: <code>&lt;your-windmill-url&gt;/gh_success</code>
<strong>Setup URL</strong> (under "Post installation"):
<code>&lt;your-windmill-url&gt;/gh_success</code> with "Redirect on update" checked.
Required for installing the app from workspace Git sync settings — GitHub redirects
here after installation to link it to the workspace. Without it, installations can
still be linked manually from the Workspace assignments table that appears below
once the app details are saved.
</li>
<li>
<strong>Setup URL</strong> (optional):
<code>&lt;your-windmill-url&gt;/gh_success</code> with "Redirect on update" checked
<strong>Callback URL</strong>: <code>&lt;your-windmill-url&gt;/gh_success</code>
</li>
<li>Uncheck <strong>Active</strong> under Webhook (not needed)</li>
</ul>
@@ -29,6 +29,7 @@
import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom'
import InlineElementPrompt from './InlineElementPrompt.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant'
import RawAppSidebar from './RawAppSidebar.svelte'
import type { Modules } from './RawAppModules.svelte'
import { isRunnableByName, isRunnableByPath } from '../apps/inputType'
@@ -1304,7 +1305,7 @@
// it always matches the editor's current state. Plain rebuilds use
// `syncExternalPreview` alone (the theme hasn't changed).
function feedExternalPreview() {
postToExternalPreview({ type: 'setDarkMode', dark: darkMode })
postToExternalPreview({ type: 'setDarkMode', dark: darkMode, variant: darkVariant })
syncExternalPreview()
}
@@ -1697,6 +1698,17 @@
})
let darkMode: boolean = $state(false)
// Mirrors the `github-dark` class (the runtime source of truth); the
// DarkModeObserver below must keep it in sync or it goes stale.
let darkVariant: DarkModeVariant = $state(getAppliedDarkModeVariant())
// Read the DOM classes, not reactive state, so the src stays constant after
// mount: a reactive src would reload the iframe on every theme toggle. Live
// theme changes travel through postMessage instead (see the $effect below).
function uiBuilderIframeSrc(): string {
const dark = document.documentElement.classList.contains('dark')
const variant = getAppliedDarkModeVariant()
return `/ui_builder/index.html?dark=${dark}&variant=${variant}`
}
// Host's computed `text-xs` size in px. Windmill bumps :root to 18px at
// ≥1760px viewports, so this re-evaluates on resize via the listener below.
let editorFontSize = $state(12)
@@ -1746,12 +1758,18 @@
// Push dark mode to both children. The UI Builder iframe and the
// preview iframe each listen for `setDarkMode` separately.
if (iframe && iframeLoaded) {
iframe.contentWindow?.postMessage({ type: 'setDarkMode', dark: darkMode }, '*')
iframe.contentWindow?.postMessage(
{ type: 'setDarkMode', dark: darkMode, variant: darkVariant },
'*'
)
}
if (previewIframe && previewIframeLoaded) {
previewIframe.contentWindow?.postMessage({ type: 'setDarkMode', dark: darkMode }, '*')
previewIframe.contentWindow?.postMessage(
{ type: 'setDarkMode', dark: darkMode, variant: darkVariant },
'*'
)
}
postToExternalPreview({ type: 'setDarkMode', dark: darkMode })
postToExternalPreview({ type: 'setDarkMode', dark: darkMode, variant: darkVariant })
})
$effect(() => {
// Match VS Code's editor font size to Windmill's text-xs.
@@ -2036,7 +2054,12 @@
</script>
<svelte:window onmessage={listener} onkeydown={handleKeydown} />
<DarkModeObserver bind:darkMode />
<DarkModeObserver
bind:darkMode
on:change={() => {
darkVariant = getAppliedDarkModeVariant()
}}
/>
<RawAppBackgroundRunner
workspace={opWorkspace ?? ''}
@@ -2214,7 +2237,7 @@
<iframe
bind:this={iframe}
title="UI builder"
src="/ui_builder/index.html"
src={uiBuilderIframeSrc()}
class="w-full h-full block"
onload={attachIframeSaveShortcut}
></iframe>
@@ -1,11 +1,5 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
Building,
GitFork,
GitPullRequestClosed
} from 'lucide-svelte'
import { AlertTriangle, Building, CircleHelp, GitFork, GitPullRequestClosed } from 'lucide-svelte'
import type { SessionChatStatus } from './sessionRuntime.svelte'
// The fork icon is deliberately sync-state-agnostic (no ahead/behind/
@@ -21,16 +15,20 @@
idle: 'No chat activity',
streaming: 'Generating response…',
'awaiting-user': 'Waiting for your reply',
'awaiting-answer': 'Waiting for your answer',
'needs-confirmation': 'Needs your confirmation',
draft: 'Unsent draft',
error: 'Last message had an error'
}
// Live chat signals take precedence over the persistent kind/fork
// indicator: streaming, needs-confirmation, and error are time-critical
// and warrant briefly hijacking the icon slot.
// indicator: they are time-critical and warrant briefly hijacking the icon
// slot.
const liveOverride = $derived(
status === 'streaming' || status === 'needs-confirmation' || status === 'error'
status === 'streaming' ||
status === 'awaiting-answer' ||
status === 'needs-confirmation' ||
status === 'error'
)
const persistentTitle = $derived(
@@ -51,8 +49,10 @@
<span class="w-[3px] h-[3px] rounded-full bg-blue-500 typing-dot dot-2"></span>
<span class="w-[3px] h-[3px] rounded-full bg-blue-500 typing-dot dot-3"></span>
</span>
{:else if status === 'needs-confirmation'}
<AlertCircle class="w-3 h-3 text-amber-500" />
{:else if status === 'awaiting-answer' || status === 'needs-confirmation'}
<!-- Both mean "the run is blocked on you"; at 12px a second amber circle
glyph would be indistinguishable, so the tooltip carries which one. -->
<CircleHelp class="w-3 h-3 text-amber-500" />
{:else if status === 'error'}
<AlertTriangle class="w-3 h-3 text-red-500" />
{:else if isFork}
@@ -86,7 +86,7 @@ import type {
RawAppDomResult
} from '$lib/components/raw_apps/rawAppDom'
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
// Per-kind load state for a session's editor target. Pure state container the
@@ -967,7 +967,10 @@ export function resetSessionPreviewTabs(sessionId: string, url: string): void {
export type SessionChatStatus =
| 'idle'
| 'streaming'
// The assistant's turn ended and the user has yet to reply — passive, unlike
// 'awaiting-answer'/'needs-confirmation', where a running loop is blocked.
| 'awaiting-user'
| 'awaiting-answer'
| 'needs-confirmation'
| 'draft'
| 'error'
@@ -1243,10 +1246,15 @@ setScreenshotHandler(async ({ sessionId: callerSessionId }) => {
export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus {
const m = runtime.manager
const last = m.displayMessages[m.displayMessages.length - 1]
// A loop parked on the user still reports `loading`, so these must be tested
// before `streaming` — otherwise "answer me" renders as "the AI is typing"
// and a session that needs the user looks like one that doesn't.
const pending = pendingUserAction(m.displayMessages)
if (pending === 'question') return 'awaiting-answer'
if (pending === 'confirmation') return 'needs-confirmation'
if (m.loading) return 'streaming'
if (m.instructions.trim().length > 0) return 'draft'
const last = m.displayMessages[m.displayMessages.length - 1]
if (last?.role === 'tool' && last.needsConfirmation) return 'needs-confirmation'
if (last?.role === 'user' && last.error) return 'error'
if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user'
return 'idle'
@@ -179,6 +179,15 @@
return ambiguous
})
// Opening while a fork is active expands that fork's family, so the tick sits
// on the active fork's own row instead of on its collapsed root.
function seedExpandedFamilies() {
expandedFamilies.clear()
if (currentFamily && currentFamily.id !== $workspaceStore) {
expandedFamilies.add(currentFamily.id)
}
}
// The active workspace itself (fork included) — names the settings entry.
const activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived(
@@ -193,13 +202,14 @@
<svelte:window onkeydowncapture={onExpandKeydown} />
<!-- Expansion is per-open: every open starts with all families collapsed,
including the active fork's. -->
<!-- Expansion is per-open: every open starts from the active workspace's family
alone, discarding whatever the previous open expanded. -->
<Menu
{createMenu}
usePointerDownOutside
placement="bottom-start"
bind:open={menuOpen}
on:open={seedExpandedFamilies}
on:close={() => expandedFamilies.clear()}
>
{#snippet triggr({ trigger })}
@@ -1,5 +1,6 @@
// Sidebar rail background. Deliberately raw hex — no theme token exists for
// the rail chrome — kept here as the single source for every host that renders
// or mimics the sidebar (app layout, kitchen-sink harness).
// Sidebar rail background. Kept here as the single source for every host that
// renders or mimics the sidebar (app layout, kitchen-sink harness).
// The dark value is a theme-driven CSS variable so it adapts across dark
// variants (default vs GitHub dark) — see tailwind.config.cjs `--sidebar-bg-dark`.
export const SIDEBAR_BG = '#F3F3F7'
export const SIDEBAR_BG_DARK = '#1e232e'
export const SIDEBAR_BG_DARK = 'var(--sidebar-bg-dark)'
+52 -5
View File
@@ -1,6 +1,7 @@
import '@codingame/monaco-vscode-standalone-typescript-language-features'
import { editor as meditor, Uri as mUri } from 'monaco-editor'
import { getAppliedDarkModeVariant } from '$lib/darkModeVariant'
export let isInitialized = false
export let isInitializing = false
@@ -12,6 +13,15 @@ import {
import getLanguagesServiceOverride from '@codingame/monaco-vscode-languages-service-override'
import { getCssColor } from '$lib/utils'
// Monaco theme name matching the current document theme (light / dark / GitHub dark variant).
export function getEditorTheme(): string {
const classes = document.documentElement.classList
if (!classes.contains('dark')) {
return 'myTheme'
}
return getAppliedDarkModeVariant() === 'github' ? 'github-dark' : 'nord'
}
export function buildWorkerDefinition() {
const envEnhanced = getEnhancedMonacoEnvironment()
const getWorker = (moduleId: string, label: string) => {
@@ -248,6 +258,47 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem
}
})
meditor.defineTheme('github-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ background: '0D1117', token: '' },
{ foreground: '8b949e', token: 'comment' },
{ foreground: 'a5d6ff', token: 'string' },
{ foreground: '79c0ff', token: 'constant.numeric' },
{ foreground: '79c0ff', token: 'constant.language' },
{ foreground: 'ff7b72', token: 'keyword' },
{ foreground: 'ff7b72', token: 'storage' },
{ foreground: 'ff7b72', token: 'storage.type' },
{ foreground: 'ffa657', token: 'entity.name.class' },
{ foreground: 'ffa657', fontStyle: 'bold', token: 'entity.other.inherited-class' },
{ foreground: 'd2a8ff', token: 'entity.name.function' },
{ foreground: '7ee787', token: 'entity.name.tag' },
{ foreground: '79c0ff', token: 'entity.other.attribute-name' },
{ foreground: 'd2a8ff', token: 'support.function' },
{ foreground: 'f0f6fc', background: 'f85149', token: 'invalid' },
{ foreground: 'f0f6fc', background: 'bd561d', token: 'invalid.deprecated' },
{ foreground: '79c0ff', token: 'constant.color.other.rgb-value' },
{ foreground: '79c0ff', token: 'constant.character.escape' },
{ foreground: 'ffa657', token: 'variable.other.constant' },
{ token: 'string.value.json', foreground: 'a5d6ff' }, // string values in JSON
{ token: 'keyword.json', foreground: '79c0ff' } // true, false, null in JSON
],
colors: {
'editor.foreground': '#e6edf3',
'editor.background': getCssColor('surface-input', { format: 'hex-github-dark' }),
'editor.selectionBackground': '#3392FF44',
'editor.inactiveSelectionBackground': '#3392FF22',
'editor.lineHighlightBackground': '#161b22',
'editorCursor.foreground': '#e6edf3',
'editorWhitespace.foreground': '#484f58',
'editorIndentGuide.background1': '#21262d',
'editorIndentGuide.activeBackground1': '#30363d',
'editorLineNumber.foreground': '#6e7681',
'editorLineNumber.activeForeground': '#e6edf3'
}
})
meditor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
@@ -262,11 +313,7 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem
}
})
if (document.documentElement.classList.contains('dark')) {
meditor.setTheme('nord')
} else {
meditor.setTheme('myTheme')
}
meditor.setTheme(getEditorTheme())
} catch (e) {
console.error('Failed to initialize monaco services', e)
} finally {
@@ -51,7 +51,7 @@
</script>
<script lang="ts">
import { Plus } from 'lucide-svelte'
import { Plus, PlugZap } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
@@ -69,7 +69,12 @@
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import { random_adj } from '../random_positive_adjetive'
import { sendUserToast } from '$lib/toast'
import { SettingService, WorkspaceService, type GetSettingsResponse } from '$lib/gen'
import {
SettingService,
WorkspaceService,
type GetSettingsResponse,
type TestDataTableConnectionResponse
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
@@ -90,6 +95,39 @@
let { dataTableSettings = $bindable() }: Props = $props()
// Result of the last "Test connection", shown under the table: the grant
// statements have to stay selectable, which rules out a toast.
let connectionCheck = $state<
| {
name: string
loading: boolean
report?: TestDataTableConnectionResponse
error?: string
}
| undefined
>(undefined)
// Identifies the request the single result slot is waiting on. The data table
// name is not enough: A -> B -> A leaves two A requests in flight, and the
// first to be issued can be the last to land.
let latestCheck = 0
async function testConnection(name: string) {
const check = ++latestCheck
connectionCheck = { name, loading: true }
try {
const report = await WorkspaceService.testDataTableConnection({
workspace: $workspaceStore ?? '',
datatableName: name
})
if (check !== latestCheck) return
connectionCheck = { name, loading: false, report }
} catch (err) {
if (check !== latestCheck) return
connectionCheck = { name, loading: false, error: err?.body ?? err?.message ?? String(err) }
}
}
let tableHeadNames = ['Name', 'Database', '', ''] as const
let tableHeadTooltips: Partial<Record<(typeof tableHeadNames)[number], string | undefined>> = {
Name: 'Data tables are referenced by their name. main is a special name that can be used as the default data table.',
@@ -305,6 +343,17 @@
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: PlugZap }}
iconOnly
disabled={!!dirtyMap[dataTable.name]}
loading={connectionCheck?.name === dataTable.name && connectionCheck.loading}
title="Test connection: check the database is reachable and its user can create tables"
on:click={() => testConnection(dataTable.name)}
/>
{#if dirtyMap[dataTable.name]}
<Popover
openOnHover
@@ -343,6 +392,69 @@
</tbody>
</DataTable>
{#if connectionCheck && !connectionCheck.loading}
{@const report = connectionCheck.report}
{#if connectionCheck.error}
<Alert type="error" title="Could not connect to {connectionCheck.name}" class="mt-4" size="xs">
{connectionCheck.error}
</Alert>
{:else if report}
{@const fullyPrivileged = report.can_create_table && report.can_create_schema}
<Alert
type={fullyPrivileged ? 'success' : 'warning'}
title={fullyPrivileged
? `${connectionCheck.name} is reachable and its user can create tables and schemas`
: `${connectionCheck.name} is reachable but its user is missing privileges`}
class="mt-4"
size="xs"
>
<div class="flex flex-col gap-2">
<div>
Connects as <span class="font-mono">{report.user}</span>{#if report.schema}, resolving
unqualified statements to schema <span class="font-mono">{report.schema}</span>{/if}.
</div>
{#if report.suggested_search_path}
<div>
Its search_path resolves to no schema, so unqualified statements fail with
<span class="font-mono">no schema has been selected to create in</span> whatever
privileges the role holds. Point it at one, e.g.
<span class="font-mono select-all">{report.suggested_search_path}</span>.
</div>
{/if}
<ul class="list-disc list-inside">
<li>
Create tables{report.schema ? ` in ${report.schema}` : ''}:
<span class="font-semibold">{report.can_create_table ? 'yes' : 'no'}</span>
</li>
<li>
Create schemas:
<span class="font-semibold">{report.can_create_schema ? 'yes' : 'no'}</span>
</li>
<li>
Migration bookkeeping table exists:
<span class="font-semibold">{report.migrations_table_exists ? 'yes' : 'no'}</span>
</li>
</ul>
{#if report.suggested_grants.length > 0}
<div>
Windmill connects as the role that lacks these privileges, so it cannot grant them
itself. Run as a schema owner or superuser on that database:
</div>
<pre class="whitespace-pre-wrap select-all text-xs"
>{report.suggested_grants.map((g) => `${g};`).join('\n')}</pre
>
{#if report.schema && !report.can_create_table && !report.migrations_table_exists}
<div>
Alternatively, create the <span class="font-mono">_wm_migrations</span> bookkeeping table
yourself and grant only SELECT, INSERT, UPDATE, DELETE on it.
</div>
{/if}
{/if}
</div>
</Alert>
{/if}
{/if}
<SettingsFooter
class="mt-8"
{hasUnsavedChanges}
@@ -287,8 +287,9 @@
{/if}
<Tooltip>
Resource types the selected items depend on (whether passed as inputs or
referenced by a hardcoded path). Synced to the Hub so a fork knows what
credentials it needs to fill.
referenced by a hardcoded path). A stub resource of each type is synced to the
Hub so a fork knows what credentials it needs to fill. Publishing a type's own
definition is opt-in — tick it in the details drawer.
</Tooltip>
</span>
{#if s.dependencyTypes.length === 0}
@@ -303,6 +304,9 @@
: 'bg-surface'}"
>
{r.resource_type}
{#if s.exportedResourceTypes.has(r.resource_type)}
<Badge color="blue" size="xs">exported</Badge>
{/if}
</span>
{/each}
<Button
@@ -311,7 +315,9 @@
wrapperClasses="ml-auto"
onclick={() => resourceDrawer?.openDrawer()}
>
View details
{s.exportedDependencyTypes.length > 0
? `View details (${s.exportedDependencyTypes.length} type definition${s.exportedDependencyTypes.length > 1 ? 's' : ''} exported)`
: 'View details'}
</Button>
{/if}
</div>
@@ -830,12 +836,19 @@
<DrawerContent title="Resource dependencies" on:close={() => resourceDrawer?.closeDrawer()}>
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
Resource types the selected items depend on. Each is synced to the Hub so a fork knows
what credentials it needs to fill. <span class="font-semibold">Input</span> means the
item takes the resource as a parameter;
Resource types the selected items depend on. A stub resource of each type is synced to
the Hub so a fork knows what credentials it needs to fill.
<span class="font-semibold">Input</span> means the item takes the resource as a
parameter;
<span class="font-semibold">hardcoded path</span> means the item pins a specific resource
path in its code.
</p>
<p class="text-xs text-secondary">
Publishing a type's own <span class="font-semibold">definition</span> (its schema and
description) is a separate, explicit choice: tick
<span class="font-semibold">Export type definition</span> only for custom types the Hub doesn't
already know. Standard types are already defined on the Hub and need no export.
</p>
{#if s.dependencyTypes.length === 0}
<span class="text-xs text-hint">No resource references in the current selection.</span>
{:else}
@@ -852,6 +865,19 @@
<span class="text-[11px] text-hint">
{r.usages.length} usage{r.usages.length > 1 ? 's' : ''}
</span>
<div class="ml-auto shrink-0">
<Toggle
size="xs"
checked={s.exportedResourceTypes.has(r.resource_type)}
disabled={s.deploying}
on:change={() => s.toggleResourceTypeExport(r.resource_type)}
options={{
right: 'Export type definition',
rightTooltip:
'Publishes this resource type (name, schema, description) to the Hub project. Leave off if the Hub already defines it.'
}}
/>
</div>
</div>
<div class="flex flex-col gap-3">
{#each r.usages as u, ui (ui)}
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest'
import { canRecordSession, mergeAppTableOrigin, type DeployItem } from './deployToHubItems'
import {
canRecordSession,
inputResourceTypes,
mergeAppTableOrigin,
type DeployItem
} from './deployToHubItems'
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
return { rec: 'none', ...over }
@@ -34,3 +39,26 @@ describe('mergeAppTableOrigin', () => {
).toBe(orphan)
})
})
describe('inputResourceTypes', () => {
const schema = {
properties: {
db: { format: 'resource-postgresql' },
file: { format: 'resource-s3_object' },
typo: { format: 'resource-postgres' },
theme: { format: 'resource-app_theme' },
name: { format: 'email' }
}
}
it('keeps only formats the workspace declares as a resource type', () => {
expect(inputResourceTypes(schema, new Set(['postgresql', 'stripe']))).toEqual(['postgresql'])
})
// Undefined (still loading) and empty (a workspace that never synced the Hub's
// types) are both "no catalog" — validating would drop every legitimate type.
// `s3_object` is never a resource type, so it stays out even here.
it('falls back to every non-hidden format without a type catalog', () => {
const all = ['postgresql', 'postgres']
expect(inputResourceTypes(schema, undefined)).toEqual(all)
expect(inputResourceTypes(schema, new Set())).toEqual(all)
})
})
@@ -18,6 +18,43 @@ export interface DeployItem {
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
// `s3_object` is a built-in file-picker format, never a resource type (it is not
// on the Hub's type list), so it is excluded even when there is no catalog to
// validate against.
export const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache', 's3_object'])
/**
* Resource types an item takes as an input, read off its schema's
* `resource-<type>` arg formats.
*
* A `resource-<x>` format is not a type declaration: stale and misspelled ones
* pass through unchanged. Publishing those to the Hub would push an empty-schema
* type and a stub resource nothing can ever fill, so an input-derived type only
* counts once the workspace declares it the same bar `ArgInput` applies before
* rendering an arg as a resource picker.
*
* `known` is only authoritative once it holds something: undefined (still
* loading, or the call failed) and empty (a workspace whose type catalog was
* never synced from the Hub) both mean "no catalog to validate against", and
* filtering on one would drop every legitimate type instead.
*/
export function inputResourceTypes(schema: unknown, known: Set<string> | undefined): string[] {
const validate = known !== undefined && known.size > 0
const out = new Set<string>()
const props = (schema as any)?.properties
if (props && typeof props === 'object') {
for (const key of Object.keys(props)) {
const fmt = props[key]?.format
if (typeof fmt !== 'string' || !fmt.startsWith('resource-')) continue
const type = fmt.slice('resource-'.length)
if (HIDDEN_RESOURCE_TYPES.has(type)) continue
if (validate && !known.has(type)) continue
out.add(type)
}
}
return [...out]
}
// A raw app has no run to capture: its demo is a recorded session of someone
// using it, driven in the record drawer and replayed on the Hub page. Legacy raw
// apps live only in the `raw_app` table, and the record surface loads the app
@@ -38,7 +38,9 @@ import type { Kind } from '$lib/utils_deployable'
import {
canRecord,
canRecordSession,
inputResourceTypes,
mergeAppTableOrigin,
HIDDEN_RESOURCE_TYPES,
type DeployItem
} from './deployToHubItems'
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
@@ -92,8 +94,6 @@ const ITEM_KIND_ROUTE: Record<ItemKind, string> = {
raw_app: 'apps_raw/get'
}
const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache'])
// Prune a folder's asset graph to a set of scripts so a pipeline recording only
// runs, renders and samples the project's included members — a deselected branch
// (its nodes, code, logs/results and table samples) never enters the recording.
@@ -114,20 +114,6 @@ function pruneGraphToScripts(graph: AssetGraphResponse, scripts: Set<string>): A
return { assets, runnables, edges, triggers, macro_edges, test_edges }
}
function typesFromSchema(schema: any): string[] {
const out = new Set<string>()
const props = schema?.properties
if (props && typeof props === 'object') {
for (const key of Object.keys(props)) {
const fmt = props[key]?.format
if (typeof fmt === 'string' && fmt.startsWith('resource-')) {
out.add(fmt.slice('resource-'.length))
}
}
}
return [...out]
}
type DependencyUsage =
| { role: 'input'; label: string; kind: ItemKind; itemPath: string }
| { role: 'hardcoded'; label: string; kind: ItemKind; path: string; itemPath: string }
@@ -219,6 +205,14 @@ export class DeployToHubSession {
// they pick up the fresh SQL (Monaco doesn't sync external `code` changes).
migrationsGeneration = $state(0)
// Resource type names declared by the workspace, used to tell a real type from
// an arbitrary `resource-<x>` arg format. Stays undefined until the list loads.
resourceTypeNames = $state<Set<string> | undefined>(undefined)
// Resource types the user explicitly opted into publishing. Opt-in, never
// derived from the selection: exporting a type definition to the Hub is a
// deliberate act, so the default is to export none.
exportedResourceTypes = $state<Set<string>>(new Set())
bundlePreview = $state<ProjectBundle | undefined>(undefined)
detectingResources = $state(false)
// Data tables (→ tables) the current selection reads/writes, detected off the
@@ -257,11 +251,26 @@ export class DeployToHubSession {
load() {
void this.#loadWorkspace()
void this.#loadResourceTypeNames()
void this.#loadTriggers()
void this.rehydrateFromHub()
void this.#loadPipelineGraph()
}
// Deliberately not `resourceTypesStore.getResourceTypes()`: its error path
// resolves to a non-empty `['error_fetching_names']`, which here would read as
// a real catalog and filter out every legitimate type. A failure must leave the
// catalog unset so validation stays off.
async #loadResourceTypeNames() {
try {
const names = await ResourceService.listResourceTypeNames({ workspace: this.workspace })
if (this.#disposed) return
this.resourceTypeNames = new Set(names)
} catch (e: any) {
console.error('failed to load resource type names, resource type validation is off', e)
}
}
filteredWorkspaceItems = $derived(
this.workspaceItems.filter((i) => i.path.startsWith(this.selectedFolder + '/'))
)
@@ -372,8 +381,7 @@ export class DeployToHubSession {
itemPath: it.path
})
}
for (const t of typesFromSchema(it.schema)) {
if (HIDDEN_RESOURCE_TYPES.has(t)) continue
for (const t of inputResourceTypes(it.schema, this.resourceTypeNames)) {
ensure(t).usages.push({ role: 'input', label, kind: it.kind, itemPath: it.path })
}
}
@@ -400,6 +408,22 @@ export class DeployToHubSession {
return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type))
})
// Only the types the user ticked, restricted to what the current selection
// actually depends on — deselecting the last item that used a type drops it
// from the export without the user having to untick it.
exportedDependencyTypes = $derived(
this.dependencyTypes
.map((d) => d.resource_type)
.filter((rt) => this.exportedResourceTypes.has(rt))
)
toggleResourceTypeExport = (resource_type: string) => {
const next = new Set(this.exportedResourceTypes)
if (next.has(resource_type)) next.delete(resource_type)
else next.add(resource_type)
this.exportedResourceTypes = next
}
toggleItem = (item: { key: string }) => {
const next = new Set(this.manualDeselected)
if (next.has(item.key)) next.delete(item.key)
@@ -827,9 +851,13 @@ export class DeployToHubSession {
if (this.deploying || this.triggersLoading || this.triggerDiscoveryFailed) return
this.deploying = true
try {
// Captured at click time rather than read in #deployAll, which only runs
// after the draft request resolves: what gets published must be what was
// ticked on confirmation, whatever mutates `exportedResourceTypes` after.
const exportedTypes = new Set(this.exportedResourceTypes)
if (!(await this.#createDraft())) return
onDraftCreated?.()
await this.#deployAll()
await this.#deployAll(exportedTypes)
} finally {
this.deploying = false
}
@@ -1048,7 +1076,7 @@ export class DeployToHubSession {
return results.reduce((a: number, b) => a + b, 0)
}
async #deployAll() {
async #deployAll(exportedTypes: Set<string>) {
const slug = this.hubSlug
// Snapshot the selection up-front: `selectedItems`/`relevantTriggers` are
// derived from live workspace data and `migrationDrafts` is edited in the
@@ -1096,14 +1124,22 @@ export class DeployToHubSession {
// was replaced (workspace/folder switch) in the meantime.
if (this.#disposed) return
// Types come from $res: stubs AND schema inputs (resource-<type>).
const inputTypes = bundle.items
.flatMap((i) => typesFromSchema(i.schema))
.filter((t) => !HIDDEN_RESOURCE_TYPES.has(t))
// Types come from $res: stubs AND schema inputs (resource-<type>). A stub's
// type is declared by an existing resource, so it needs no validation; an
// input's is a free-form format string, so it does.
const inputTypes = bundle.items.flatMap((i) =>
inputResourceTypes(i.schema, this.resourceTypeNames)
)
const types = [
...new Set([...bundle.resourceStubs.map((s) => s.resource_type), ...inputTypes])
]
const depFailures = await this.#pushResourceTypes(slug, types)
// Only the types the user ticked are published. The others still get their
// stub below, so a fork knows which credential to fill; only the type
// definition itself stays out of the Hub.
const depFailures = await this.#pushResourceTypes(
slug,
types.filter((t) => exportedTypes.has(t))
)
// Input-type deps with no path get a conventional f/<slug>/<type> stub.
const stubsByPath = new Map<string, { path: string; resource_type: string }>()
@@ -1185,7 +1221,10 @@ export class DeployToHubSession {
this.hubHasRemoteLogo = this.hubLogo !== null
this.hubLogo = undefined
} catch (e: any) {
sendUserToast(`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`, true)
sendUserToast(
`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`,
true
)
failures++
}
}
@@ -20,6 +20,7 @@ import {
} from '../triggers/workspaceTriggersList'
import { updatePolicy } from '$lib/components/apps/editor/appPolicy'
import { updateRawAppPolicy } from '$lib/sharedUtils'
import { apiErrorMessage as errorMessage } from '$lib/utils'
import type { App } from '$lib/components/apps/types'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import {
@@ -74,18 +75,6 @@ export function varContainmentViolation(value: any, folder: string): string | un
return undefined
}
// Surface the backend's explanation: API errors carry the real message in
// `.body` (plain text for Windmill 4xx), while `.message` is the generic
// status text ("Bad Request"). Prefer the body so e.g. a path/route_path
// collision reads as the actual reason, not just "Bad Request".
function errorMessage(e: any): string {
const body = e?.body
if (typeof body === 'string' && body.trim() !== '') return body
if (body && typeof body === 'object')
return body.error?.message ?? body.message ?? JSON.stringify(body)
return e?.message ?? String(e)
}
// Recompute an app's execution policy from its (retargeted) value, mirroring
// what the editor does on deploy. `triggerables_v2` is keyed by
// `<component>:rawscript/<sha256(inline content)>`; retargeting rewrites that
@@ -19,6 +19,7 @@ import {
type DatabaseSchema
} from '$lib/components/datatableSchemaSql'
import { WorkspaceService } from '$lib/gen'
import { apiErrorMessage } from '$lib/utils'
import type { FetchedItem } from './projectBundle'
export interface GeneratedMigration {
@@ -229,14 +230,7 @@ function orderByFkDependency(schema: DatabaseSchema, tables: ResolvedTable[]): R
// Pull a readable one-line message out of an API error for embedding in a SQL
// comment (collapse whitespace so it can't break out of the `--` line).
function errorText(e: any): string {
const body = e?.body
const raw =
typeof body === 'string' && body.trim()
? body
: body && typeof body === 'object'
? (body.error?.message ?? body.message ?? JSON.stringify(body))
: (e?.message ?? String(e))
return String(raw).replace(/\s+/g, ' ').trim()
return apiErrorMessage(e).replace(/\s+/g, ' ').trim()
}
/**
+27
View File
@@ -0,0 +1,27 @@
export type DarkModeVariant = 'default' | 'github'
const DARK_MODE_VARIANT_KEY = 'dark-mode-variant'
export function getDarkModeVariant(): DarkModeVariant {
return window.localStorage.getItem(DARK_MODE_VARIANT_KEY) === 'github' ? 'github' : 'default'
}
// The variant currently applied to the DOM (the `github-dark` class) — the
// runtime source of truth, as opposed to the persisted preference above.
export function getAppliedDarkModeVariant(): DarkModeVariant {
return typeof document !== 'undefined' &&
document.documentElement.classList.contains('github-dark')
? 'github'
: 'default'
}
// The `github-dark` class only takes effect when `dark` is also present (see tailwind.config.cjs).
// It lives on <html> permanently so it survives the many independent `dark` toggle sites.
export function applyDarkModeVariant(variant: DarkModeVariant = getDarkModeVariant()): void {
document.documentElement.classList.toggle('github-dark', variant === 'github')
}
export function setDarkModeVariant(variant: DarkModeVariant): void {
window.localStorage.setItem(DARK_MODE_VARIANT_KEY, variant)
applyDarkModeVariant(variant)
}

Some files were not shown because too many files have changed in this diff Show More