Merge remote-tracking branch 'origin/enable-operator-ai-chat-nav' into improve-data-pipelines

This commit is contained in:
Diego Imbert
2026-09-30 11:57:28 +02:00
154 changed files with 7916 additions and 2638 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.819.0"
".": "1.820.0"
}
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## [1.820.0](https://github.com/windmill-labs/windmill/compare/v1.819.0...v1.820.0) (2026-09-29)
### Features
* add pull_batch to claim jobs for many waiting workers at once ([#11350](https://github.com/windmill-labs/windmill/issues/11350)) ([9f40cdc](https://github.com/windmill-labs/windmill/commit/9f40cdca6258a9218770ebe1e7b89179e7d6667f))
* add tree view to the schedules page ([#11360](https://github.com/windmill-labs/windmill/issues/11360)) ([e68ff0d](https://github.com/windmill-labs/windmill/commit/e68ff0d969edaacafc0fc5e246ac3945ae28109b))
* **ai-agent:** add compaction memory that summarizes older context ([#10928](https://github.com/windmill-labs/windmill/issues/10928)) ([c1b59f7](https://github.com/windmill-labs/windmill/commit/c1b59f70dd0e1f4a7597d31cff031ce6c0e7d0ab))
* detect and alert when a schedule skips occurrences ([#10917](https://github.com/windmill-labs/windmill/issues/10917)) ([97fa55b](https://github.com/windmill-labs/windmill/commit/97fa55b719a874067d1a81a1d680a42d1f1a4c98))
* external instance cluster for data tables and Ducklake catalogs ([#11197](https://github.com/windmill-labs/windmill/issues/11197)) ([8741843](https://github.com/windmill-labs/windmill/commit/8741843d2e884783eed1c00bbc2293e526f3e80d))
* refuse OIDC tokens to previews that impersonate a path their user cannot write ([#11396](https://github.com/windmill-labs/windmill/issues/11396)) ([d1260d7](https://github.com/windmill-labs/windmill/commit/d1260d7c5a3d132d4427079b794452309e03e28e))
* restart perpetual runs on the version a deploy makes runnable ([#11200](https://github.com/windmill-labs/windmill/issues/11200)) ([cf5c49c](https://github.com/windmill-labs/windmill/commit/cf5c49c3dca2201f739fb872d68bcd6c0c7665f7))
* run turns in several flow chat conversations at once ([#11202](https://github.com/windmill-labs/windmill/issues/11202)) ([f367eaf](https://github.com/windmill-labs/windmill/commit/f367eaf6d0258649ca0b3d47b1270af9a3fcd484))
* share AI guidance with the CLI skills and lint flow groups ([#11397](https://github.com/windmill-labs/windmill/issues/11397)) ([ede4103](https://github.com/windmill-labs/windmill/commit/ede4103b00c6ada3b5ea8ba3e98bf6b78a791d63))
* show native web search in the assistant settings modal ([#11394](https://github.com/windmill-labs/windmill/issues/11394)) ([235410c](https://github.com/windmill-labs/windmill/commit/235410c1e417bb64b432c96efed870cab74cb364))
* stop cross-origin isolating the raw app editor ([#11411](https://github.com/windmill-labs/windmill/issues/11411)) ([6064aec](https://github.com/windmill-labs/windmill/commit/6064aecdecc49facde3870446b570abd8e8124b2))
### Bug Fixes
* bound a resumed session fork's wait, keep its intent while in flight ([#11413](https://github.com/windmill-labs/windmill/issues/11413)) ([e6df8d7](https://github.com/windmill-labs/windmill/commit/e6df8d78d496cf9640bd5a1e9d7e8f4c2cc2adf7))
* create workspace forks in the background, with progress ([#11406](https://github.com/windmill-labs/windmill/issues/11406)) ([054109d](https://github.com/windmill-labs/windmill/commit/054109d8559cc0a41ef361786c4d1d99eab9c502))
* drop caller-supplied _MODULES at push for every job ([#11418](https://github.com/windmill-labs/windmill/issues/11418)) ([c5f59d6](https://github.com/windmill-labs/windmill/commit/c5f59d6ccc71d08fc34ede47e7a77b35a197b9db))
* keep new-menu submenus open while crossing the gutter ([#11414](https://github.com/windmill-labs/windmill/issues/11414)) ([2f87bc6](https://github.com/windmill-labs/windmill/commit/2f87bc6a2d237382641ad3f0e7a8df36f0b74c15))
* keep secret variable values hidden when toggling secret ([#11383](https://github.com/windmill-labs/windmill/issues/11383)) ([da5c58d](https://github.com/windmill-labs/windmill/commit/da5c58de2a0c297cfa81a601f9f543f46107e3f6))
* refuse flow preview restarts from runs the caller cannot read ([#11407](https://github.com/windmill-labs/windmill/issues/11407)) ([c34abf7](https://github.com/windmill-labs/windmill/commit/c34abf7330e7f3fbbf5b68e325f0f0991c6c60d5))
* report a worker's last job when it ran under one poll interval ([#11399](https://github.com/windmill-labs/windmill/issues/11399)) ([797147e](https://github.com/windmill-labs/windmill/commit/797147ea7a4a911d3c82e9ac9cdaf066ad2795d6))
* run custom schedule handlers as the schedule ([#11398](https://github.com/windmill-labs/windmill/issues/11398)) ([bda00c6](https://github.com/windmill-labs/windmill/commit/bda00c6cad2de2ce0927651fe0e53653bdd20a96))
* surface raw app build errors to the session AI ([#11415](https://github.com/windmill-labs/windmill/issues/11415)) ([50ffad5](https://github.com/windmill-labs/windmill/commit/50ffad52d765497fa3a561ebe8842b40fc8bd58e))
* turn the default raw app into a feature tour ([#11417](https://github.com/windmill-labs/windmill/issues/11417)) ([83b8954](https://github.com/windmill-labs/windmill/commit/83b8954ae26f8d453d38e867e62366341cfe7729))
## [1.819.0](https://github.com/windmill-labs/windmill/compare/v1.818.0...v1.819.0) (2026-09-28)
@@ -1,10 +1,14 @@
import { expect, it, vi } from 'vitest'
// @ts-ignore - Node.js fs/promises
import { mkdir, writeFile } from 'fs/promises'
import { mkdir, readFile, writeFile } from 'fs/promises'
// @ts-ignore - Node.js path
import { dirname, resolve } from 'path'
// @ts-ignore - Node.js url
import { fileURLToPath } from 'url'
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
const FRONTEND_DIR = fileURLToPath(new URL('../../../frontend/', import.meta.url))
// Some tools reach the backend by relative fetch('/api/...'), which has no meaning
// in the vitest environment — serve the ones the benchmark handles.
// Every other relative fetch keeps its normal behavior (it fails the same way
@@ -24,9 +28,30 @@ globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
if (typeof url === 'string' && hasBenchmarkApiHandler(url)) {
return handleBenchmarkApiFetch(url, init)
}
// The parsers behind inferArgs load their wasm from a vite `?url` path, which only a dev
// server serves. Unserved, every script schema infers as empty, and the tools then tell the
// model its arguments are undeclared, so it rewrites a correct script until it runs out of turns.
const wasmPath = typeof url === 'string' ? wasmFilePath(url) : undefined
if (wasmPath) {
return new Response(await readFile(wasmPath), {
headers: { 'Content-Type': 'application/wasm' }
})
}
return ORIGINAL_FETCH(input as Parameters<typeof fetch>[0], init)
}) as typeof fetch
function wasmFilePath(url: string): string | undefined {
const raw = url.split(/[?#]/)[0]
if (!raw.endsWith('.wasm')) return undefined
const pathname = decodeURIComponent(raw)
// Located past whatever base path the config prefixes.
const fs = pathname.indexOf('/@fs/')
if (fs !== -1) return pathname.slice(fs + '/@fs'.length)
const modules = pathname.indexOf('/node_modules/')
if (modules !== -1) return resolve(FRONTEND_DIR, `.${pathname.slice(modules)}`)
return undefined
}
vi.mock('monaco-editor', () => ({
editor: {},
languages: {},
@@ -640,6 +665,13 @@ benchmarkIt(
async () => {
const { resetBenchmarkMockBackend } = await import('./mockBackend')
resetBenchmarkMockBackend()
// The tools swallow inference failures, so a wasm the fetch stub stops serving would
// only show up as empty schemas the model chases, never as an error.
const { inferArgs } = await import('$lib/infer')
const { emptySchema } = await import('$lib/utils')
const probe = emptySchema()
await inferArgs('bun', 'export async function main(name: string) {}', probe)
expect(Object.keys(probe.properties)).toEqual(['name'])
const { runFrontendBenchmarkFromEnv } = await import('./benchmarkRunner')
try {
const payload = await runFrontendBenchmarkFromEnv()
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE descendants AS (\n SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id\n WHERE j.workspace_id = $2\n ) SELECT d.id AS \"id!\" FROM descendants d JOIN v2_job_queue q ON q.id = d.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771"
}
@@ -1,83 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind,\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n -- An app editor preview also runs an app script, at an app path it does not\n -- have to own; only deployed-app runs are stamped with their app.\n WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'\n AND starts_with(runnable_path, trigger || '/'), false)\n ELSE true\n END AS \"origin_verified!\"\n FROM lineage ORDER BY depth",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 3,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "trigger_kind",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "origin_verified!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
null
]
},
"hash": "201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "started_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a"
}
@@ -0,0 +1,77 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n j.tag AS \"tag!\", j.priority, j.kind AS \"kind!: JobKind\"\n FROM v2_job j\n WHERE j.id = $1 and j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "tag!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 4,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
true,
false,
true,
false
]
},
"hash": "4a257470121ee6bffd9c68e88aabb7563c0ba31233854c8922f9449da7b43e9e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
null
]
},
"hash": "dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c"
"hash": "4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a"
}
@@ -0,0 +1,101 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind, permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n ELSE true\n END AS \"origin_verified!\",\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\"\n FROM lineage ORDER BY depth",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 3,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "trigger_kind",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "permissioned_as!",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "origin_verified!",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "app_stamped!",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "args_modules!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "5176c354a8c14b8acfbcc7e32da2fcd5013543d600a07a36f3451ddb00cad6aa"
}
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id IS NOT NULL AS \"completed!\",\n c.result AS \"result: sqlx::types::Json<Box<RawValue>>\",\n c.status = 'success' AS \"success\",\n EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\"\n FROM (SELECT 1) one\n LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "completed!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "result: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "queued!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
true,
null,
null
]
},
"hash": "52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"\n FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "success!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "result: Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false,
null,
true
]
},
"hash": "789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token\n WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET worker = $1, running = true, started_at = now()\n WHERE id = $2 AND tag = ANY($3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"TextArray"
]
},
"nullable": []
},
"hash": "f5c87b6fc1decd6dc53590fe4d16f59430530b8b7428e42d1777927e63395ff8"
}
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n j.tag AS \"tag!\", j.priority\n FROM v2_job j\n WHERE j.id = $1 and j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "tag!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "priority",
"type_info": "Int2"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
true,
false,
true
]
},
"hash": "f7753f8f7b40aebf66de607203f97681d06ac04f9d547b4c49c032d9e933a29e"
}
+89 -79
View File
@@ -14792,7 +14792,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14876,11 +14876,12 @@ dependencies = [
"windmill-worker-volumes",
"windows-service",
"windows-sys 0.52.0",
"winresource",
]
[[package]]
name = "windmill-ai"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14914,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14927,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"argon2",
@@ -15067,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15090,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15107,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15133,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15143,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15160,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15182,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15205,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15221,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15243,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15265,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15279,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15315,7 +15316,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15340,7 +15341,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15368,7 +15369,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15390,7 +15391,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15410,7 +15411,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15452,7 +15453,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15481,7 +15482,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"lazy_static",
"serde",
@@ -15493,7 +15494,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15517,7 +15518,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15531,7 +15532,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15570,7 +15571,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15584,7 +15585,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15603,7 +15604,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15716,7 +15717,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"chrono",
"futures",
@@ -15736,7 +15737,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"regex",
"serde",
@@ -15753,7 +15754,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15780,7 +15781,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"futures",
@@ -15797,7 +15798,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15813,7 +15814,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15834,7 +15835,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15865,7 +15866,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15890,7 +15891,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15926,7 +15927,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"futures",
@@ -15944,7 +15945,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15953,7 +15954,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15965,7 +15966,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15977,7 +15978,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15989,7 +15990,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16001,7 +16002,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16013,7 +16014,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16024,7 +16025,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16035,7 +16036,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16047,7 +16048,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16058,7 +16059,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16080,7 +16081,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16092,7 +16093,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16106,7 +16107,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16123,7 +16124,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16136,7 +16137,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde",
@@ -16148,7 +16149,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16166,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16182,7 +16183,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16198,7 +16199,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16212,7 +16213,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16251,7 +16252,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"const_format",
@@ -16291,7 +16292,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16302,7 +16303,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16337,7 +16338,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16361,7 +16362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16394,7 +16395,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16421,7 +16422,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16454,7 +16455,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16474,7 +16475,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16508,7 +16509,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16545,7 +16546,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16568,7 +16569,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16592,7 +16593,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16616,7 +16617,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16651,7 +16652,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16679,7 +16680,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16704,7 +16705,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16723,7 +16724,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16841,7 +16842,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"bytes",
"futures",
@@ -17382,6 +17383,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"version_check",
]
[[package]]
name = "winsafe"
version = "0.0.19"
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.819.0"
version = "1.820.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.819.0"
version = "1.820.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -329,6 +329,9 @@ strum.workspace = true
windows-service = "0.7"
windows-sys = { version = "0.52", features = ["Win32_System_Services", "Win32_System_Console", "Win32_Foundation"] }
[target.'cfg(windows)'.build-dependencies]
winresource = { version = "0.1.31", default-features = false }
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = { optional = true, workspace = true }
tikv-jemalloc-sys = { optional = true, workspace = true }
+15
View File
@@ -2,4 +2,19 @@
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
// Embed a VERSIONINFO resource so the exe's file properties carry the release
// version (from Cargo.toml), which Windows worker update scripts read.
// `cfg(windows)` is the host, matching the Windows-only build-dependency, so
// cross builds to Windows get no version resource.
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
winresource::WindowsResource::new()
.set("ProductName", "Windmill")
.set("FileDescription", "Windmill")
.set("CompanyName", "Windmill Labs, Inc.")
.set("LegalCopyright", "Copyright Windmill Labs, Inc.")
.compile()
.expect("failed to embed the Windows version resource");
}
}
+1 -1
View File
@@ -1 +1 @@
12a83524b81368043bc939caeb3764fd27a32523
5a2b6b8527250bd12cf856d8a667a9ef3106ec60
+24 -24
View File
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6302,7 +6302,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6314,7 +6314,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"convert_case",
"serde",
@@ -6323,7 +6323,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6335,7 +6335,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6347,7 +6347,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6359,7 +6359,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6371,7 +6371,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6383,7 +6383,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6394,7 +6394,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6405,7 +6405,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6417,7 +6417,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6428,7 +6428,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6450,7 +6450,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6462,7 +6462,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde",
@@ -6518,7 +6518,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6536,7 +6536,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6552,7 +6552,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6568,7 +6568,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6600,7 +6600,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6614,7 +6614,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.819.0"
version = "1.820.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.819.0"
version = "1.820.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+220
View File
@@ -0,0 +1,220 @@
use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use std::{sync::Arc, time::Duration};
use tokio::sync::Notify;
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::{
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
};
async fn model(Json(body): Json<Value>) -> ([(&'static str, &'static str); 1], String) {
let finished = body["messages"]
.as_array()
.unwrap()
.iter()
.any(|m| m["role"] == "tool");
let delta = if finished {
json!({"role":"assistant", "content":"done"})
} else {
json!({"role":"assistant", "tool_calls": (0..3).map(|i| json!({
"index":i, "id":format!("call_{i}"), "type":"function",
"function":{"name":format!("tool_{i}"), "arguments":"{}"}
})).collect::<Vec<_>>()})
};
let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]});
let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if finished {"stop"} else {"tool_calls"}}]});
(
[("content-type", "text/event-stream")],
format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"),
)
}
async fn run_batch(
db: Pool<Postgres>,
parallel: bool,
limited: bool,
unsupported: bool,
) -> anyhow::Result<()> {
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let server = ApiServer::start(db.clone()).await?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let base = format!("http://{}", listener.local_addr()?);
let gate = Arc::new(Notify::new());
let router = Router::new()
.route("/v1/chat/completions", post(model))
.route(
"/first",
get(|State(gate): State<Arc<Notify>>| async move {
gate.notified().await;
"ok"
}),
)
.route(
"/second",
get(|State(gate): State<Arc<Notify>>| async move {
gate.notify_one();
"ok"
}),
)
.with_state(gate);
let stub = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let tools: Vec<Value> = (0..3).map(|i| {
let wait = if parallel && !limited && i < 2 {
format!("await fetch('{base}/{}');", if i == 0 {"first"} else {"second"})
} else { String::new() };
let finish = if i == 2 { "throw new Error('expected tool failure');".to_string() }
else { format!("return {i};") };
json!({"id":format!("t{i}"),"summary":format!("tool_{i}"),"value":{
"type":"rawscript", "language":"bun", "input_transforms":{},
"tag": if unsupported && i != 1 { "unserved-tool-tag" } else { "bun" },
"concurrent_limit": if limited { Some(1) } else { None },
"custom_concurrency_key": if limited { Some("agent-tool-test") } else { None },
"content":format!("export async function main() {{ {wait} await Bun.sleep({}); {finish} }}", if i == 0 {200} else {0})
}})
}).collect();
let flow: FlowValue = serde_json::from_value(
json!({"modules":[{"id":"agent","timeout":if unsupported {Some(3)} else {None},"value":{
"type":"aiagent", "tools":tools, "input_transforms":{
"provider":{"type":"static","value":{"kind":"customai","model":"queue-test","resource":{"base_url":format!("{base}/v1")}}},
"user_message":{"type":"static","value":"run the tools"},
"max_iterations":{"type":"static","value":3}
}
}}]}),
)?;
let id = RunJob::from(JobPayload::RawFlow {
value: flow,
path: Some("u/test/agent_queue".into()),
restarted_from: None,
})
.push(&db)
.await;
let notifications = listen_for_completed_jobs(&db).await;
let wait = async {
if parallel {
in_test_worker(&db, notifications.find(&id), server.addr.port()).await;
} else {
notifications.find(&id).await;
}
};
tokio::time::timeout(
Duration::from_secs(45),
in_test_worker(&db, wait, server.addr.port()),
)
.await?;
let result = completed_job(id, &db).await;
if unsupported {
assert!(
!result.success,
"agent must time out waiting for unsupported tools"
);
let children: Vec<(String, bool, bool)> = sqlx::query_as(
"SELECT j.runnable_path, c.status = 'success', c.started_at IS NULL
FROM v2_job j JOIN v2_job_completed c USING(id)
WHERE j.parent_job IN (SELECT id FROM v2_job WHERE parent_job = $1)
ORDER BY j.runnable_path",
)
.bind(id)
.fetch_all(&db)
.await?;
assert_eq!(children.len(), 3);
assert_eq!(
children.iter().map(|c| (c.1, c.2)).collect::<Vec<_>>(),
vec![(false, true), (true, false), (false, true)],
"neither the first-tool reservation nor fallback may execute unsupported tags"
);
stub.abort();
server.close().await?;
return Ok(());
}
assert!(result.success, "{:?}", result.result);
let result = result.json_result().expect("agent result");
let messages: Vec<_> = result["messages"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["role"] == "tool")
.collect();
assert_eq!(messages.len(), 3);
for (index, message) in messages.iter().enumerate() {
assert_eq!(message["tool_call_id"], format!("call_{index}"));
}
assert_eq!(messages[0]["content"], "0");
assert_eq!(messages[1]["content"], "1");
assert!(messages[2]["content"]
.as_str()
.unwrap()
.contains("expected tool failure"));
let (parent_id, parent_worker): (uuid::Uuid, String) = sqlx::query_as(
"SELECT j.id, c.worker FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1"
).bind(id).fetch_one(&db).await?;
let children: Vec<(String, String, bool)> = sqlx::query_as(
"SELECT j.runnable_path, c.worker, c.status = 'success' FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1 ORDER BY j.runnable_path"
).bind(parent_id).fetch_all(&db).await?;
assert_eq!(children.len(), 3);
assert_eq!(
children[0].1, parent_worker,
"first tool must stay on the parent worker"
);
assert_eq!(
children.iter().map(|c| c.2).collect::<Vec<_>>(),
vec![true, true, false]
);
if limited {
let overlapping: i64 = sqlx::query_scalar(
"SELECT count(*) FROM v2_job j1 JOIN v2_job_completed c1 ON c1.id = j1.id
JOIN v2_job j2 ON j2.parent_job = j1.parent_job AND j2.id > j1.id
JOIN v2_job_completed c2 ON c2.id = j2.id
WHERE j1.parent_job = $1 AND c1.started_at < c2.completed_at AND c2.started_at < c1.completed_at"
).bind(parent_id).fetch_one(&db).await?;
assert_eq!(
overlapping, 0,
"the reserved first job must count toward the shared limit"
);
} else if parallel {
assert_ne!(
children[1].1, parent_worker,
"second tool must unblock the first from another worker"
);
} else {
assert!(children.iter().all(|child| child.1 == parent_worker));
}
stub.abort();
server.close().await?;
Ok(())
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn parent_drains_tools_without_another_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
run_batch(db, false, false, false).await
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn parent_keeps_first_tool_while_another_worker_runs_siblings(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
run_batch(db, true, false, false).await
}
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn reserved_tool_obeys_shared_concurrency_limit(db: Pool<Postgres>) -> anyhow::Result<()> {
run_batch(db, true, true, false).await
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn parent_leaves_unsupported_first_and_remaining_tools_queued(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
run_batch(db, false, false, true).await
}
+110
View File
@@ -0,0 +1,110 @@
//! A caller's `_MODULES` never reaches a job: the worker builds that arg in as module code,
//! so on a deployed runnable it would run caller code as the runnable (and its
//! `on_behalf_of`). `push` drops it from `args` and `extra` alike and only sets it from a
//! preview's own `RawCode::modules`.
use std::collections::HashMap;
use serde_json::{json, value::RawValue};
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
runnable_settings::{ConcurrencySettings, DebouncingSettings},
scripts::{ScriptHash, ScriptLang, ScriptModule},
};
use windmill_queue::{PushArgs, PushIsolationLevel};
fn modules(v: &str) -> serde_json::Value {
json!({ "helper.ts": { "content": format!("export const v = \"{v}\""), "language": "bun" } })
}
/// Pushes `payload` with a caller `_MODULES` both in `args` and in `extra` (where webhook
/// query and headers land), and returns the stored `_MODULES`.
async fn stored_modules(db: &Pool<Postgres>, payload: JobPayload) -> Option<serde_json::Value> {
let caller: Box<RawValue> = serde_json::value::to_raw_value(&modules("caller")).unwrap();
let args = HashMap::from([("_MODULES".to_string(), caller.clone())]);
let extra = HashMap::from([("_MODULES".to_string(), caller)]);
let (id, tx) = windmill_queue::push(
db,
PushIsolationLevel::IsolatedRoot(db.clone()),
"test-workspace",
payload,
PushArgs { args: &args, extra: Some(extra) },
"test-user",
"test@windmill.dev",
"u/test-user".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await
.expect("push must succeed");
tx.commit().await.unwrap();
sqlx::query_scalar::<_, Option<serde_json::Value>>(
"SELECT args->'_MODULES' FROM v2_job WHERE id = $1",
)
.bind(id)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(fixtures("base"))]
async fn caller_modules_never_reach_a_job(db: Pool<Postgres>) {
let deployed = JobPayload::ScriptHash {
hash: ScriptHash(123412),
path: "f/system/hello".to_string(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
language: ScriptLang::Bun,
priority: None,
apply_preprocessor: false,
concurrency_settings: ConcurrencySettings::default(),
debouncing_settings: DebouncingSettings::default(),
labels: None,
};
assert_eq!(stored_modules(&db, deployed).await, None);
let preview = |modules: Option<HashMap<String, ScriptModule>>| {
JobPayload::Code(RawCode {
hash: None,
content: "import { v } from \"./helper\"; export function main() { return v }"
.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: ConcurrencySettings::default().into(),
debouncing_settings: DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules,
tag: None,
})
};
assert_eq!(stored_modules(&db, preview(None)).await, None);
let own = serde_json::from_value(modules("preview")).unwrap();
assert_eq!(
stored_modules(&db, preview(Some(own))).await,
Some(modules("preview"))
);
}
@@ -0,0 +1,40 @@
//! Turning Windmill's own Postgres off as a data table substrate takes the data tables already on
//! it out of use, not only new ones: resolution is the one path every job, API call and trigger
//! reaches a data table through.
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
#[sqlx::test]
async fn test_instance_datatable_refused_once_instance_pg_disabled(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('dtoff-ws', 'DTOFF', 'dtoff')")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('dtoff-ws', \
'{\"datatables\": {\"main\": {\"database\": {\"resource_type\": \"instance\", \
\"resource_path\": \"dt_dtoff\"}}}}')",
)
.execute(&db)
.await?;
let resolved = get_datatable_resource_from_db_unchecked(&db, "dtoff-ws", "main").await?;
assert_eq!(resolved["dbname"], "dt_dtoff");
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ('instance_pg_disabled', 'true')",
)
.execute(&db)
.await?;
let refused = get_datatable_resource_from_db_unchecked(&db, "dtoff-ws", "main")
.await
.expect_err("an instance data table must not resolve while Windmill's database is off");
assert!(
refused.to_string().contains("disabled on this instance"),
"unexpected error: {refused}"
);
Ok(())
}
+31
View File
@@ -30,3 +30,34 @@ INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger
VALUES
('3bb0c0de-0000-4000-8000-000000000007', 'test-workspace', 'appscript', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000008', 'test-workspace', 'appscript', 'f/t/app/comp', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev');
-- Inline steps of the deployed flow f/t/agent run as previews (no flow node, or
-- DISABLE_FLOW_SCRIPT): a direct step, a step in a loop body, and a step whose path
-- leaves the flow's namespace.
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-000000000009', 'test-workspace', 'preview', 'f/t/agent/a', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-00000000000a', 'test-workspace', 'flowpreview', 'f/t/agent/loop-0', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-00000000000b', 'test-workspace', 'preview', 'f/t/agent/loop-0/a', '3bb0c0de-0000-4000-8000-00000000000a', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-00000000000c', 'test-workspace', 'preview', 'f/t/agentx', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev');
-- Steps of the flow preview claiming f/t/agent: one nested in a branch, and one whose
-- path names another item.
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-00000000000d', 'test-workspace', 'flowpreview', 'f/t/agent/branchone-1', '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-00000000000e', 'test-workspace', 'preview', 'f/t/agent/branchone-1/a', '3bb0c0de-0000-4000-8000-00000000000d', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-00000000000f', 'test-workspace', 'preview', 'f/prod/deploy', '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev');
-- A deployed app without app_script entries runs its inline script as a stamped preview.
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-000000000010', 'test-workspace', 'preview', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev');
-- A step of the deployed flow whose modules came in its args, and a flow preview at the
-- bare `f` whose step names another item.
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, args, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-000000000011', 'test-workspace', 'preview', 'f/t/agent/m', '3bb0c0de-0000-4000-8000-000000000001', '{"_MODULES": {}}', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000012', 'test-workspace', 'flowpreview', 'f', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000013', 'test-workspace', 'preview', 'f/prod/deploy', '3bb0c0de-0000-4000-8000-000000000012', NULL, 'test-user', 'u/test-user', 'test@windmill.dev');
+62 -3
View File
@@ -3,14 +3,32 @@
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::job_provenance::job_provenance;
use windmill_common::job_provenance::{
job_provenance, ClaimedItem, ClaimedPath, JobProvenance,
};
async fn deployed(db: &Pool<Postgres>, job: &str) -> bool {
async fn provenance(db: &Pool<Postgres>, job: &str) -> JobProvenance {
job_provenance(db, &Uuid::parse_str(job).unwrap(), "test-workspace")
.await
.unwrap()
.unwrap()
.deployed
}
async fn deployed(db: &Pool<Postgres>, job: &str) -> bool {
provenance(db, job).await.deployed
}
async fn claims(db: &Pool<Postgres>, job: &str) -> Vec<(String, ClaimedItem)> {
provenance(db, job)
.await
.claimed_paths
.into_iter()
.map(|ClaimedPath { path, item, .. }| (path, item))
.collect()
}
fn claim(path: &str, item: ClaimedItem) -> (String, ClaimedItem) {
(path.to_string(), item)
}
#[sqlx::test(fixtures("base", "job_provenance"))]
@@ -40,5 +58,46 @@ async fn flow_running_another_flows_version_is_not_deployed(db: Pool<Postgres>)
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn app_script_is_deployed_only_from_a_deployed_app_run(db: Pool<Postgres>) {
assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000007").await);
assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000010").await);
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000008").await);
assert_eq!(
claims(&db, "3bb0c0de-0000-4000-8000-000000000008").await,
[claim("f/t/app", ClaimedItem::App)]
);
}
/// Inline steps of a flow deployed before flow nodes run as previews, at paths the
/// worker derives under the flow's.
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn preview_step_of_deployed_flow_is_deployed_within_its_path(db: Pool<Postgres>) {
for step in ["09", "0b"] {
let p = provenance(&db, &format!("3bb0c0de-0000-4000-8000-0000000000{step}")).await;
assert!(p.deployed && p.claimed_paths.is_empty(), "step {step}");
}
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-00000000000c").await);
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000011").await);
assert_eq!(
claims(&db, "3bb0c0de-0000-4000-8000-00000000000c").await,
[claim("f/t/agentx", ClaimedItem::Script)]
);
}
/// A token acts as every path its chain took from a request: the top-most preview, and
/// any step whose path leaves its parent's.
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn claimed_paths_are_the_request_supplied_origins(db: Pool<Postgres>) {
let flow = || claim("f/t/agent", ClaimedItem::Flow);
assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-000000000004").await, [flow()]);
assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-00000000000e").await, [flow()]);
assert_eq!(
claims(&db, "3bb0c0de-0000-4000-8000-00000000000f").await,
[flow(), claim("f/prod/deploy", ClaimedItem::Script)]
);
assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-000000000006").await, [flow()]);
assert!(claims(&db, "3bb0c0de-0000-4000-8000-000000000002").await.is_empty());
// A parent whose path names no item cannot cover the claims below it.
assert_eq!(
claims(&db, "3bb0c0de-0000-4000-8000-000000000013").await,
[claim("f/prod/deploy", ClaimedItem::Script)]
);
}
+6 -3
View File
@@ -303,7 +303,7 @@ pub struct GeminiUsageMetadata {
/// 60 tool-use + 17 candidates + 52 thoughts against a `totalTokenCount` of 146, so
/// leaving them out under-reports the input of every tool-using turn. Cached tokens
/// are not added here, being already part of `promptTokenCount`.
fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 {
pub(crate) fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 {
usage
.prompt_token_count
.unwrap_or(0)
@@ -313,7 +313,7 @@ fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 {
/// Output tokens as billed: Gemini counts thinking apart from `candidatesTokenCount`
/// but charges it at the output rate, so a reply that thought would otherwise be
/// reported as far cheaper than it was.
fn gemini_completion_tokens(usage: &GeminiUsageMetadata) -> i32 {
pub(crate) fn gemini_completion_tokens(usage: &GeminiUsageMetadata) -> i32 {
usage
.candidates_token_count
.unwrap_or(0)
@@ -1041,7 +1041,10 @@ mod tests {
// through unchanged and the cached share is reported alongside it; thinking
// is billed as output but counted apart from the candidates.
assert_eq!(usage_chunk["usage"]["prompt_tokens"], 1000);
assert_eq!(usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"], 900);
assert_eq!(
usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"],
900
);
assert_eq!(usage_chunk["usage"]["completion_tokens"], 120);
}
+1
View File
@@ -6,6 +6,7 @@ pub mod ai_providers;
pub mod ai_types;
pub mod credentials;
pub mod image_handler;
pub mod model_context;
pub mod providers;
pub mod proxy;
pub mod query_builder;
+165
View File
@@ -0,0 +1,165 @@
//! What a model's context window holds, for the code that has to keep a conversation
//! inside it.
/// Assumed window for a model that is not in the table. Conservative on purpose: a
/// guess that is too small only compacts earlier, which is recoverable, while one that
/// is too large overflows and the provider raises the context error itself.
pub const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
/// Context windows of the models we know, most specific entry first — the first name
/// found in the bare model id wins, so vendor-namespaced and date-suffixed ids
/// (`anthropic.claude-sonnet-4-6-…-v1:0`, `gpt-5.2-2026-01-01`) still resolve.
/// Conservative family fallbacks sit below the explicit entries.
///
/// **Mirrors `MODEL_CONTEXT_WINDOWS` in
/// `frontend/src/lib/components/copilot/modelConfig.ts`**, which the AI session's own
/// compaction reads. Add a model to one and it must go in the other, or the same model
/// compacts at one size in a chat and another in an agent step.
const MODEL_CONTEXT_WINDOWS: &[(&str, usize)] = &[
// Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA);
// Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4
// base ids (claude-sonnet-4-20250514) fall through to 200K
("claude-fable-5", 1_000_000),
("claude-mythos-5", 1_000_000),
("claude-opus-5", 1_000_000),
("claude-sonnet-5", 1_000_000),
("claude-opus-4-8", 1_000_000),
("claude-opus-4-7", 1_000_000),
("claude-opus-4-6", 1_000_000),
("claude-sonnet-4-6", 1_000_000),
("claude", 200_000),
// OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2
// revisions, all 400K; 5.4/5.5 moved to 1M and 5.6 to 1.05M
("gpt-5-6", 1_050_000),
("gpt-5-5", 1_000_000),
("gpt-5-4", 1_000_000),
("gpt-5", 400_000),
("gpt-4-1", 1_000_000),
("gpt-4o", 128_000),
("o4-mini", 200_000),
("o3", 200_000),
// Google — the 2.5 / 3 / 3.1 Gemini families are all 1M
("gemini-3-1", 1_000_000),
("gemini-3", 1_000_000),
("gemini-2-5", 1_000_000),
// DeepSeek — the V4 family (pro / flash) is 1M. The deepseek-chat /
// deepseek-reasoner aliases were retired 2026-07-24 but can still sit in a
// saved selection, so they keep resolving to the window they had.
("deepseek-v4", 1_000_000),
("deepseek-chat", 1_000_000),
("deepseek-reasoner", 1_000_000),
("deepseek", 128_000),
// Alibaba — Qwen3-Max is 256K. No qwen family fallback: variant windows range
// from 8K (character models) to 1M, too wide for even a conservative guess
("qwen3-max", 256_000),
// Others — Mistral Medium 3.5 is 256K, reachable under both its version and
// the `-latest` alias. There is deliberately no `mistral-medium` family row:
// pinned older snapshots are 128K, and over-claiming a window overflows it.
("mistral-medium-3-5", 256_000),
("mistral-medium-latest", 256_000),
("llama", 128_000),
("codestral", 32_000),
];
/// The bare model id an entry is matched against: lowercased, the leading `~` and any
/// vendor path segment dropped, the `:`-suffixed route removed, and `.` collapsed to
/// `-`. Version separators differ by route to the same model — Anthropic writes
/// `claude-opus-4-8`, OpenRouter `anthropic/claude-opus-4.8` — so collapsing both sides
/// keeps one entry covering every route.
fn bare_model_id(model: &str) -> String {
let normalized = model.trim().to_lowercase();
let normalized = normalized.strip_prefix('~').unwrap_or(&normalized);
let last = normalized.rsplit('/').next().unwrap_or(normalized);
let base = match last.find(':') {
Some(0) | None => last,
Some(colon) => &last[..colon],
};
base.replace('.', "-")
}
/// Whether this is one of OpenAI's reasoning models (gpt-5 and later, the o-series),
/// which think by default. Mirrors `requiresMaxCompletionTokens` in the frontend's
/// `modelConfig.ts`: the o-series match wants a digit after the `o` so that ids such
/// as `open-mistral-*` stay out.
pub fn is_openai_reasoning_model(model: &str) -> bool {
let id = bare_model_id(model);
id.starts_with("gpt-5")
|| (id.starts_with('o') && id[1..].starts_with(|c: char| c.is_ascii_digit()))
}
/// The window this model is known to hold, `None` for one not in the table.
fn known_model_context_window(model: &str) -> Option<usize> {
let id = bare_model_id(model);
MODEL_CONTEXT_WINDOWS
.iter()
.find(|(name, _)| matches_entry(&id, name))
.map(|(_, window)| *window)
}
/// The window to plan against: the model's where it is known, the conservative
/// assumption otherwise.
pub fn model_context_window(model: &str) -> usize {
known_model_context_window(model).unwrap_or(DEFAULT_CONTEXT_WINDOW)
}
/// An entry matches anywhere in the id, so a vendor prefix or a date suffix does not
/// hide it. One ending on a version digit must not run into a longer version:
/// `gpt-4-1` would otherwise claim `gpt-4-1106-preview`. A family fallback ending on a
/// letter gets no such guard — a version welded straight onto the name (`llama3-1`) is
/// what it exists to catch.
fn matches_entry(id: &str, name: &str) -> bool {
let guard_digits = name.ends_with(|c: char| c.is_ascii_digit());
id.match_indices(name).any(|(at, _)| {
!guard_digits
|| !id[at + name.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
})
}
#[cfg(test)]
mod tests {
use super::*;
/// One entry has to cover every route to the same model: the vendor path and dot
/// versions OpenRouter uses, Bedrock's prefixed and `-v1:0`-suffixed ids, and the
/// date suffixes the providers' own ids carry.
#[test]
fn every_route_to_a_model_resolves_to_one_window() {
for id in [
"claude-opus-4-8",
"anthropic/claude-opus-4.8",
"~anthropic/claude-opus-4.8",
"anthropic.claude-opus-4-8-20260101-v1:0",
] {
assert_eq!(known_model_context_window(id), Some(1_000_000), "{id}");
}
// Falls through the explicit rows to the family fallback.
assert_eq!(
known_model_context_window("claude-3-5-haiku"),
Some(200_000)
);
}
/// The guard that keeps an entry ending on a version digit from claiming a longer
/// version of the same family.
#[test]
fn a_version_entry_does_not_claim_a_longer_version() {
assert_eq!(known_model_context_window("gpt-4.1"), Some(1_000_000));
assert_eq!(known_model_context_window("gpt-4-1106-preview"), None);
assert_eq!(known_model_context_window("gpt-5-mini"), Some(400_000));
assert_eq!(known_model_context_window("gpt-5.6"), Some(1_050_000));
}
/// An unknown model still gets a number, so nothing downstream has to carry a
/// "no limit" case that would let a conversation grow unbounded.
#[test]
fn an_unknown_model_falls_back_to_the_assumed_window() {
assert_eq!(known_model_context_window("some-local-llm"), None);
assert_eq!(
model_context_window("some-local-llm"),
DEFAULT_CONTEXT_WINDOW
);
}
}
@@ -782,7 +782,7 @@ impl QueryBuilder for AnthropicQueryBuilder {
// Convert Anthropic usage to TokenUsage
let usage = anthropic_usage.map(|u| {
TokenUsage::from_input_output(u.input_tokens, u.output_tokens)
.with_cache(u.cache_read_input_tokens, u.cache_creation_input_tokens)
.with_cache_beside_input(u.cache_read_input_tokens, u.cache_creation_input_tokens)
});
Ok(ParsedResponse::Text {
+1 -1
View File
@@ -1148,7 +1148,7 @@ impl BedrockQueryBuilder {
Some(token_usage.output_tokens()),
Some(token_usage.total_tokens()),
)
.with_cache(
.with_cache_beside_input(
token_usage
.cache_read_input_tokens()
.map(|v| i32::try_from(v).unwrap_or(i32::MAX)),
+50 -11
View File
@@ -1,10 +1,11 @@
use crate::{
ai_google::{
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini,
openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event,
sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig,
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
GeminiPredictContent, GeminiTextRequest, GeminiThinkingConfig, GeminiTool,
gemini_completion_tokens, gemini_event_to_openai_sse_chunks, gemini_prompt_tokens,
gemini_response_to_openai, openai_messages_to_gemini, openai_tools_to_gemini,
parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google,
GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest,
GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest,
GeminiThinkingConfig, GeminiTool,
},
image_handler::{download_and_encode_s3_image, prepare_messages_for_api},
proxy::{ProxyBuildArgs, ProxyRequest},
@@ -685,12 +686,32 @@ impl QueryBuilder for GoogleAIQueryBuilder {
stream_event_processor.send(event, &mut events_str).await?;
}
let usage = gemini_usage.map(|u| {
TokenUsage::new(
u.prompt_token_count,
u.candidates_token_count,
u.total_token_count,
)
// Through the shared helpers, which fold in the two counts Gemini reports beside
// the headline ones: tool-use prompt tokens, disjoint from `promptTokenCount` and
// present on every tool-using turn, and thinking tokens. Reading the headline
// fields alone under-reports the prompt of exactly the conversations compaction
// has to notice.
let usage = gemini_usage.and_then(|u| {
let has_prompt =
u.prompt_token_count.is_some() || u.tool_use_prompt_token_count.is_some();
let has_completion =
u.candidates_token_count.is_some() || u.thoughts_token_count.is_some();
if !has_prompt && !has_completion && u.total_token_count.is_none() {
// An endpoint that reported nothing has to stay `None`. A zeroed count
// reads downstream as an empty prompt, where absent means "fall back to
// estimating the conversation".
return None;
}
let prompt = has_prompt.then(|| gemini_prompt_tokens(&u));
let completion = has_completion.then(|| gemini_completion_tokens(&u));
Some(TokenUsage::new(
prompt,
completion,
u.total_token_count.or_else(|| match (prompt, completion) {
(Some(p), Some(c)) => Some(p.saturating_add(c)),
_ => None,
}),
))
});
Ok(ParsedResponse::Text {
@@ -1014,4 +1035,22 @@ mod tests {
.headers
.contains(&("x-goog-api-key".to_string(), "api-key".to_string())));
}
/// Gemini reports a tool-using turn's input across two disjoint fields. Reading only
/// the headline one hides the tool results from the agent step's usage — and from the
/// compaction trigger, which is what would notice them.
#[test]
fn gemini_agent_usage_counts_tool_use_and_thinking_tokens() {
let usage = crate::ai_google::GeminiUsageMetadata {
prompt_token_count: Some(17),
candidates_token_count: Some(17),
total_token_count: Some(146),
tool_use_prompt_token_count: Some(60),
thoughts_token_count: Some(52),
..Default::default()
};
assert_eq!(gemini_prompt_tokens(&usage), 77);
assert_eq!(gemini_completion_tokens(&usage), 69);
}
}
+43
View File
@@ -6,6 +6,49 @@ use crate::ai_types::OpenAIToolCall;
use crate::proxy::{ProxyBuildArgs, ProxyRequest};
use crate::types::*;
/// Only explicit input-context rejections warrant dropping conversation history.
/// Rate limits, output-token limits and generic validation errors must propagate.
pub fn is_context_length_error(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("context_length_exceeded")
|| message.contains("maximum context length")
|| message.contains("prompt is too long")
|| message.contains("exceed context limit")
|| message.contains("input is too long for requested model")
|| (message.contains("input token count") && message.contains("exceeds the maximum"))
|| message.contains("too many input tokens")
}
#[cfg(test)]
mod context_error_tests {
use super::is_context_length_error;
#[test]
fn recognizes_context_rejections_without_retrying_other_provider_errors() {
for message in [
r#"{"error":{"code":"context_length_exceeded"}}"#,
"This model's maximum context length is 128000 tokens",
"prompt is too long: 210000 tokens > 200000 maximum",
"input length and `max_tokens` exceed context limit: 199000 + 8192 > 200000",
"The input token count (10000) exceeds the maximum number of tokens allowed (8192)",
"ValidationException: Input is too long for requested model.",
"ValidationException: Too many input tokens",
] {
assert!(is_context_length_error(message), "{message}");
}
for message in [
"Rate limit exceeded: tokens per minute",
"max_tokens exceeds the maximum output tokens",
"Invalid tool schema",
"Additional properties are not allowed: stream_options",
"Request body too large",
"Internal server error",
] {
assert!(!is_context_length_error(message), "{message}");
}
}
}
/// Arguments for building an AI request
pub struct BuildRequestArgs<'a> {
pub messages: &'a [OpenAIMessage],
+84 -8
View File
@@ -304,7 +304,16 @@ pub enum AnthropicDelta {
Unknown,
}
/// Anthropic usage information from message_delta event
/// The `message` envelope of a `message_start` event. Only its usage is read: the
/// prompt-side counts appear here and nowhere else in the stream.
#[derive(Deserialize, Debug)]
pub struct AnthropicStreamMessage {
#[serde(default)]
pub usage: Option<AnthropicUsage>,
}
/// Anthropic usage information, reported across `message_start` (prompt side) and
/// `message_delta` (completion side)
#[derive(Deserialize, Debug, Clone)]
pub struct AnthropicUsage {
#[serde(default)]
@@ -322,7 +331,10 @@ pub struct AnthropicUsage {
#[serde(tag = "type")]
pub enum AnthropicSSEEvent {
#[serde(rename = "message_start")]
MessageStart {},
MessageStart {
#[serde(default)]
message: Option<AnthropicStreamMessage>,
},
#[serde(rename = "content_block_start")]
ContentBlockStart { index: usize, content_block: AnthropicContentBlockStart },
#[serde(rename = "content_block_delta")]
@@ -371,7 +383,8 @@ pub struct AnthropicSSEParser {
pub annotations: Vec<UrlCitation>,
/// Whether web search was used in this response
pub used_websearch: bool,
/// Token usage from message_delta event
/// Token usage, merged from the `message_start` (prompt side) and `message_delta`
/// (completion side) events
pub usage: Option<AnthropicUsage>,
/// Claude thinking block accumulated from `thinking`/`signature` deltas
/// (or a redacted block). Attached to the first tool call of the turn so it
@@ -574,14 +587,35 @@ impl SSEParser for AnthropicSSEParser {
let error_msg = message.unwrap_or_else(|| "Unknown error".to_string());
tracing::error!("Anthropic streaming error: {}", error_msg);
}
AnthropicSSEEvent::MessageStart { message } => {
// The only event carrying the prompt-side counts. `message_delta`
// reports the completion, so dropping this one leaves the request
// with no input token count at all.
if let Some(usage) = message.and_then(|message| message.usage) {
self.usage = Some(usage);
}
}
AnthropicSSEEvent::MessageDelta { usage } => {
if let Some(u) = usage {
self.usage = Some(u);
match &mut self.usage {
// Field by field, so the prompt counts from `message_start`
// survive a delta that only reports the completion.
Some(existing) => {
existing.input_tokens = u.input_tokens.or(existing.input_tokens);
existing.output_tokens = u.output_tokens.or(existing.output_tokens);
existing.cache_read_input_tokens = u
.cache_read_input_tokens
.or(existing.cache_read_input_tokens);
existing.cache_creation_input_tokens = u
.cache_creation_input_tokens
.or(existing.cache_creation_input_tokens);
}
None => self.usage = Some(u),
}
}
}
// Ignore other events
AnthropicSSEEvent::MessageStart {}
| AnthropicSSEEvent::MessageStop {}
AnthropicSSEEvent::MessageStop {}
| AnthropicSSEEvent::Ping {}
| AnthropicSSEEvent::Unknown => {}
}
@@ -794,6 +828,11 @@ pub enum OpenAIResponsesSSEEvent {
#[serde(rename = "response.completed")]
Completed { response: OpenAIResponsesResponse },
/// Response ended by `max_output_tokens` or a filter: the same object, with the
/// tokens spent so far, so it is billed the same
#[serde(rename = "response.incomplete")]
Incomplete { response: OpenAIResponsesResponse },
/// Response created
#[serde(rename = "response.created")]
Created {},
@@ -969,8 +1008,8 @@ impl SSEParser for OpenAIResponsesSSEParser {
});
}
OpenAIResponsesSSEEvent::Completed { response } => {
// Extract usage from response.completed event
OpenAIResponsesSSEEvent::Completed { response }
| OpenAIResponsesSSEEvent::Incomplete { response } => {
if let Some(usage) = response.usage {
self.usage = Some(usage);
}
@@ -1041,6 +1080,43 @@ mod tests {
assert_eq!(token_usage.total_tokens, Some(4820));
}
struct NoopSink;
#[async_trait::async_trait]
impl StreamEventSink for NoopSink {
async fn send(
&self,
_event: StreamingEvent,
_events_str: &mut String,
) -> Result<(), Error> {
Ok(())
}
}
/// The prompt-side counts arrive only on `message_start` and the completion total
/// only on `message_delta`; a parser that keeps just the last one reports a request
/// with no input tokens at all.
#[tokio::test]
async fn anthropic_usage_merges_message_start_and_message_delta() {
let mut parser = AnthropicSSEParser::new(Box::new(NoopSink));
parser
.parse_event_data(
r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":4821,"output_tokens":1,"cache_read_input_tokens":4096,"cache_creation_input_tokens":128}}}"#,
)
.await
.unwrap();
parser
.parse_event_data(r#"{"type":"message_delta","usage":{"output_tokens":312}}"#)
.await
.unwrap();
let usage = parser.usage.expect("usage");
assert_eq!(usage.input_tokens, Some(4821));
assert_eq!(usage.output_tokens, Some(312));
assert_eq!(usage.cache_read_input_tokens, Some(4096));
assert_eq!(usage.cache_creation_input_tokens, Some(128));
}
#[test]
fn openai_responses_usage_maps_cached_input_tokens() {
// Payload shape returned by the OpenAI Responses API.
+44 -1
View File
@@ -82,6 +82,14 @@ pub enum Memory {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_length: usize,
},
Compaction {
/// Overrides the window looked up from the model. Only a step the lookup cannot
/// serve — a Custom AI deployment, a model id not in the table — needs one, and
/// a value larger than the model actually serves lets the conversation overflow
/// before the summary is ever taken.
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_window: usize,
},
/// Written before `window`. Its `memory_id` stays a fallback behind the run's memory id.
Auto {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
@@ -388,13 +396,28 @@ impl TokenUsage {
Self::new(input, output, total)
}
/// Add cache token information
/// Records a cached prefix the provider counted *inside* `input_tokens`, which the
/// OpenAI-shaped ones do. The counts are kept only for the cost split.
pub fn with_cache(mut self, read: Option<i32>, write: Option<i32>) -> Self {
self.cache_read_input_tokens = read;
self.cache_write_input_tokens = write;
self
}
/// Records a cached prefix the provider reported *beside* `input_tokens` rather than
/// inside it, which Anthropic and Bedrock do. `input_tokens` is raised to the whole
/// prompt, so `input_tokens` means the same thing whatever served the request; the
/// cache counts stay as the subsets they have become, and the total follows.
pub fn with_cache_beside_input(mut self, read: Option<i32>, write: Option<i32>) -> Self {
let beside = read.unwrap_or(0).saturating_add(write.unwrap_or(0));
self.input_tokens = self.input_tokens.map(|input| input.saturating_add(beside));
self.total_tokens = match (self.input_tokens, self.output_tokens) {
(Some(input), Some(output)) => Some(input.saturating_add(output)),
_ => self.total_tokens.map(|total| total.saturating_add(beside)),
};
self.with_cache(read, write)
}
pub fn is_empty(&self) -> bool {
self.input_tokens.is_none()
&& self.output_tokens.is_none()
@@ -939,6 +962,26 @@ mod tests {
use super::*;
use std::collections::HashMap;
/// Whichever provider served the request, `input_tokens` has to end up meaning the
/// whole prompt. Leaving the Anthropic shape as reported under-states it by the
/// entire cached prefix, which is exactly what compaction has to notice.
#[test]
fn both_provider_cache_shapes_report_the_whole_prompt() {
// OpenAI-shaped: cached_tokens is already inside input_tokens.
let openai = TokenUsage::new(Some(1000), Some(10), Some(1010)).with_cache(Some(800), None);
assert_eq!(openai.input_tokens, Some(1000));
assert_eq!(openai.total_tokens, Some(1010));
// Anthropic-shaped: the cached prefix is reported beside input_tokens, and the
// total follows the prompt it is folded into.
let anthropic = TokenUsage::from_input_output(Some(200), Some(10))
.with_cache_beside_input(Some(5000), Some(300));
assert_eq!(anthropic.input_tokens, Some(5500));
assert_eq!(anthropic.total_tokens, Some(5510));
// Kept as the subsets they have become, for the cost split.
assert_eq!(anthropic.cache_read_input_tokens, Some(5000));
}
/// Helper to create a simple string type schema
fn string_schema() -> OpenAPISchema {
OpenAPISchema {
@@ -993,3 +993,56 @@ async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool<Postgres>) -> an
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_impersonation_session_is_never_refreshed(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/users", server.addr.port());
let get = |token: &'static str, path: &str| {
client()
.get(format!("{base}/{path}"))
.header("Authorization", format!("Bearer {token}"))
.send()
};
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, email, label, expiration)
VALUES (encode(sha256('IMPERSONATION_TOKEN'::bytea), 'hex'), 'IMPERSONAT',
'test2@windmill.dev', 'impersonation:test@windmill.dev', now() + interval '1 day')",
)
.execute(&db)
.await?;
let refreshed = get("IMPERSONATION_TOKEN", "refresh_token")
.await?
.text()
.await?;
assert_eq!(refreshed, "this session cannot be refreshed");
// Once the row is gone, as when the monitor sweeps an expired one, the auth cache still
// accepts the token for a while.
sqlx::query("DELETE FROM token WHERE label LIKE 'impersonation:%'")
.execute(&db)
.await?;
assert_eq!(get("IMPERSONATION_TOKEN", "whoami").await?.status(), 200);
let refreshed = get("IMPERSONATION_TOKEN", "refresh_token")
.await?
.text()
.await?;
assert_eq!(refreshed, "this session cannot be refreshed");
let sessions: i64 = sqlx::query_scalar(
"SELECT count(*) FROM token WHERE email = 'test2@windmill.dev' AND label = 'session'",
)
.fetch_one(&db)
.await?;
assert_eq!(sessions, 0);
let refreshed = get("SECRET_TOKEN_3", "refresh_token").await?.text().await?;
assert_eq!(
refreshed, "token refreshed",
"an ordinary token still refreshes"
);
Ok(())
}
+19 -1
View File
@@ -2866,8 +2866,26 @@ async fn refresh_token(
.to_string(),
));
}
let t_hash = windmill_common::auth::hash_token(&token);
// Only a live, non-impersonation row may be exchanged for a session: an impersonation must
// end at its 24h expiry, not become a renewable service-account session billed as active.
// Read the row rather than trust `authed`, which the auth cache keeps serving for up to
// 120s after the row expired or was swept. `jwt_` tokens have no row.
if !token.starts_with("jwt_") {
let refreshable = sqlx::query_scalar!(
"SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token
WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())",
&t_hash
)
.fetch_optional(&db)
.await?
.flatten()
.unwrap_or(false);
if !refreshable {
return Ok("this session cannot be refreshed".to_string());
}
}
if let Some(thresh_s) = query.if_expiring_in_less_than_s {
let t_hash = windmill_common::auth::hash_token(&token);
let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s)
.fetch_optional(&db)
.await?
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.819.0
version: 1.820.0
title: Windmill API
contact:
+4 -6
View File
@@ -4362,12 +4362,10 @@ async fn execute_component(
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
// `_MODULES` and `_TEMP_SCRIPT_REFS` are server-injected control keys (into
// `extra`) that the worker reads back for a `Preview` job — which an inline run
// is. A caller supplying them in `args` would inject module content/locks or
// redirect relative-import resolution, unpinned, as the app identity. Drop them;
// legitimate values ride in `extra`, never the request `args`.
payload.args.remove("_MODULES");
// `_TEMP_SCRIPT_REFS` is a server-injected control key (into `extra`) that the
// worker reads back for a `Preview` job, which an inline run is. A caller
// supplying it in `args` would redirect relative-import resolution, unpinned, as
// the app identity. Drop it; the legitimate value rides in `extra`.
payload.args.remove("_TEMP_SCRIPT_REFS");
let (mut args, job_id) = build_args(
+25 -7
View File
@@ -7152,7 +7152,7 @@ pub async fn restart_flow(
let completed_job = sqlx::query!(
"SELECT
j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",
j.tag AS \"tag!\", j.priority
j.tag AS \"tag!\", j.priority, j.kind AS \"kind!: JobKind\"
FROM v2_job j
WHERE j.id = $1 and j.workspace_id = $2",
job_id,
@@ -7169,6 +7169,13 @@ pub async fn restart_flow(
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
let mut run_query = run_query;
drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?;
// A restarted flow preview reruns the value its request supplied, while a flow preview
// under a parent is read (by job provenance) as that parent's own definition.
let (parent_job, root_job) = if completed_job.kind == JobKind::FlowPreview {
(None, None)
} else {
(run_query.parent_job, run_query.root_job)
};
let ehm = HashMap::new();
let push_args = completed_job
@@ -7212,9 +7219,9 @@ pub async fn restart_flow(
authed.username_override.as_deref(),
scheduled_for,
None,
run_query.parent_job,
parent_job,
None,
run_query.root_job,
root_job,
run_query.job_id,
false,
false,
@@ -7414,6 +7421,20 @@ pub async fn run_workflow_as_code(
)
.await?;
// A task re-runs a preview with the preview's modules: the ones `push` stored for it, never
// the task's args. Read on their own, as `fetch_queued` swaps oversized args for a placeholder.
let preview_modules = if job.job_kind == JobKind::Preview {
sqlx::query_scalar::<_, Option<sqlx::types::Json<HashMap<String, ScriptModule>>>>(
"SELECT args->'_MODULES' FROM v2_job WHERE id = $1",
)
.bind(job.id)
.fetch_one(&db)
.await?
.map(|modules| modules.0)
} else {
None
};
let (job_payload, tag, _delete_after_use, _delete_after_secs, timeout, on_behalf_of) =
match job.job_kind {
JobKind::Preview => (
@@ -7437,7 +7458,7 @@ pub async fn run_workflow_as_code(
dedicated_worker: None,
// TODO(debouncing): enable for this mode
debouncing_settings: DebouncingSettings::default(),
modules: None,
modules: preview_modules,
tag: None,
}),
Some(job.tag.clone()),
@@ -8623,9 +8644,6 @@ async fn run_preview_script(
if let Some(fp) = &preview.flow_path {
extra.insert("_FLOW_PATH".to_string(), to_raw_value(fp));
}
if let Some(ref modules) = preview.modules {
extra.insert("_MODULES".to_string(), to_raw_value(modules));
}
if let Some(ref temp_script_refs) = preview.temp_script_refs {
extra.insert(
"_TEMP_SCRIPT_REFS".to_string(),
+152 -20
View File
@@ -7,6 +7,15 @@ pub struct JobProvenance {
/// True only when the job and every ancestor run code at a path taken from a
/// deployed version, never from the request that created the job.
pub deployed: bool,
/// The chain reached a parent it cannot read (other workspace, depth cap), so
/// nothing above that point is known.
pub unproven_ancestry: bool,
/// The item paths the chain took from a request, top-most first: for the job to act as
/// them, its identity must be one that could have deployed there. A job whose code
/// and path both derive from a claimed parent adds no claim of its own.
pub claimed_paths: Vec<ClaimedPath>,
/// The identity the job runs as.
pub permissioned_as: String,
pub parent_path: Option<String>,
pub root_id: Uuid,
pub root_path: Option<String>,
@@ -14,13 +23,36 @@ pub struct JobProvenance {
pub root_trigger_kind: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ClaimedPath {
/// The deployable item the claim stands for: an app script's claim is its app.
pub path: String,
pub item: ClaimedItem,
/// The identity of the job that made the claim: a deployed child can run as another
/// identity (`on_behalf_of`) than the preview it runs under.
pub permissioned_as: String,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ClaimedItem {
Script,
/// A preview without a parent at `<app>/<component>`: deploying the app runs its
/// inline script at that path with the same identity, so writing the app suffices.
ScriptOrAppComponent,
Flow,
App,
}
struct LineageJob {
id: Uuid,
parent_job: Option<Uuid>,
kind: JobKind,
runnable_path: Option<String>,
trigger_kind: Option<String>,
permissioned_as: String,
origin_verified: bool,
app_stamped: bool,
args_modules: bool,
}
/// Reads any job of `w_id` regardless of the caller: authorize access to `job_id` first.
@@ -29,16 +61,16 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
LineageJob,
r#"WITH RECURSIVE lineage AS (
SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,
0 AS depth
permissioned_as, args, 0 AS depth
FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,
p.trigger, l.depth + 1
p.trigger, p.permissioned_as, p.args, l.depth + 1
FROM v2_job p JOIN lineage l ON p.id = l.parent_job
WHERE p.workspace_id = $2 AND l.depth < 100
)
SELECT id AS "id!", parent_job, kind AS "kind!: JobKind", runnable_path,
trigger_kind::text AS trigger_kind,
trigger_kind::text AS trigger_kind, permissioned_as AS "permissioned_as!",
-- A restart takes its flow version from the request, so a trusted flow path
-- can carry another flow's code: the version must belong to that path.
CASE kind
@@ -46,12 +78,15 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)
WHEN 'script' THEN EXISTS (SELECT 1 FROM script s
WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)
-- An app editor preview also runs an app script, at an app path it does not
-- have to own; only deployed-app runs are stamped with their app.
WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'
AND starts_with(runnable_path, trigger || '/'), false)
ELSE true
END AS "origin_verified!"
END AS "origin_verified!",
-- Only deployed-app runs are stamped with their app; an app editor preview
-- runs app code at an app path it does not have to own.
COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)
AS "app_stamped!",
-- A preview's modules come from its args, which its parent may have taken from
-- the caller.
COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS "args_modules!"
FROM lineage ORDER BY depth"#,
job_id,
w_id
@@ -63,12 +98,30 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
return Ok(None);
};
// An ancestor missing from v2_job (other workspace, depth cap) leaves the chain unproven.
let deployed = root.parent_job.is_none()
&& lineage
.iter()
.all(|j| runs_stored_code(j.kind) && j.origin_verified);
let unproven_ancestry = root.parent_job.is_some();
// Walk from the root down: whether a job runs stored code can depend on its parent.
let mut claimed_paths = vec![];
let mut all_stored = true;
let mut parent: Option<(&LineageJob, bool)> = None;
for job in lineage.iter().rev() {
let stored = runs_stored_code(job, parent);
if !stored {
all_stored = false;
if !derives_from_claimed_parent(job, parent) {
if let Some(claim) = claimed_path(job, parent.is_none()) {
claimed_paths.push(claim);
}
}
}
parent = Some((job, stored));
}
Ok(Some(JobProvenance {
deployed,
deployed: !unproven_ancestry && all_stored,
unproven_ancestry,
claimed_paths,
permissioned_as: lineage[0].permissioned_as.clone(),
parent_path: lineage.get(1).and_then(|j| j.runnable_path.clone()),
root_id: root.id,
root_path: root.runnable_path.clone(),
@@ -79,19 +132,30 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result<Option
/// Whether the job's code and its path are both fixed by what was deployed (or, for a
/// hub script, published), rather than supplied by the request that created the job.
fn runs_stored_code(kind: JobKind) -> bool {
match kind {
/// `parent` is the job's parent and whether it runs stored code.
fn runs_stored_code(job: &LineageJob, parent: Option<(&LineageJob, bool)>) -> bool {
match job.kind {
JobKind::Script
| JobKind::Script_Hub
| JobKind::Flow
| JobKind::SingleStepFlow
| JobKind::FlowScript
| JobKind::FlowNode
| JobKind::AIAgent
| JobKind::AppScript => true,
JobKind::Preview
| JobKind::FlowPreview
| JobKind::Dependencies
| JobKind::AIAgent => job.origin_verified,
JobKind::AppScript => job.app_stamped,
// A deployed app without `app_script` entries runs its inline scripts as previews
// of the content its policy pins.
JobKind::Preview if job.app_stamped => true,
// No API route takes a preview's code together with a parent (`restart_flow` drops
// the parent of a restarted flow preview): a preview with a parent runs its parent's
// own definition (a flow step, loop or branch body, agent tool, workflow-as-code
// task or module). A flow deployed before flow nodes, or run with
// `DISABLE_FLOW_SCRIPT`, runs its inline steps this way.
JobKind::Preview | JobKind::FlowPreview => {
!job.args_modules
&& parent.is_some_and(|(p, p_stored)| p_stored && path_within(job, p))
}
JobKind::Dependencies
| JobKind::FlowDependencies
| JobKind::AppDependencies
| JobKind::Identity
@@ -102,3 +166,71 @@ fn runs_stored_code(kind: JobKind) -> bool {
| JobKind::UnassignedSinglestepFlow => false,
}
}
/// A job pushed by a worker running a request-supplied parent, at or under that
/// parent's path, claims nothing the parent did not already claim. The parent's claim
/// covers it only if the parent's path names an item, so that its claim is checked.
fn derives_from_claimed_parent(job: &LineageJob, parent: Option<(&LineageJob, bool)>) -> bool {
parent.is_some_and(|(p, p_stored)| {
!p_stored && p.runnable_path.as_deref().is_some_and(names_an_item) && path_within(job, p)
})
}
/// Items only live under these namespaces (a preview without a path runs at `tmp/main`):
/// a path outside them cannot be an item's path in a `sub`, so claiming it claims nothing.
fn names_an_item(path: &str) -> bool {
["u/", "f/", "g/", "hub/"]
.iter()
.any(|ns| path.starts_with(ns))
}
fn path_within(job: &LineageJob, parent: &LineageJob) -> bool {
match (
job.runnable_path.as_deref(),
parent.runnable_path.as_deref(),
) {
(Some(path), Some(parent_path)) if !parent_path.is_empty() => {
path == parent_path
|| path
.strip_prefix(parent_path)
.is_some_and(|rest| rest.starts_with('/'))
}
_ => false,
}
}
fn claimed_path(job: &LineageJob, top_level: bool) -> Option<ClaimedPath> {
let path = job.runnable_path.as_deref().filter(|p| names_an_item(p))?;
let (path, item) = match job.kind {
// The server derives an app script's path as `<app path>/<component>`, with the
// component a single segment.
JobKind::AppScript => (
path.rsplit_once('/').map_or(path, |(app, _)| app),
ClaimedItem::App,
),
JobKind::AppDependencies => (path, ClaimedItem::App),
JobKind::Preview if top_level => (path, ClaimedItem::ScriptOrAppComponent),
JobKind::Flow
| JobKind::FlowPreview
| JobKind::FlowDependencies
| JobKind::FlowNode
| JobKind::FlowScript
| JobKind::SingleStepFlow
| JobKind::AIAgent
| JobKind::UnassignedFlow
| JobKind::UnassignedSinglestepFlow => (path, ClaimedItem::Flow),
JobKind::Script
| JobKind::Script_Hub
| JobKind::Preview
| JobKind::Dependencies
| JobKind::Identity
| JobKind::Noop
| JobKind::DeploymentCallback
| JobKind::UnassignedScript => (path, ClaimedItem::Script),
};
Some(ClaimedPath {
path: path.to_string(),
item,
permissioned_as: job.permissioned_as.clone(),
})
}
+18
View File
@@ -864,6 +864,24 @@ pub fn make_pull_query(tags: &[String]) -> String {
query
}
/// Claim a parent's tool jobs matching its worker tags. The caller must supply only child IDs
/// it owns; this query is an internal scheduling primitive and does not authorize job access.
pub fn make_tool_job_pull_query(job_ids: &[uuid::Uuid], tags: &[String]) -> String {
// pull() binds only the worker name. These literals come from typed UUIDs, never input SQL.
let ids = job_ids.iter().map(|id| format!("'{id}'::uuid")).join(", ");
let tags = tags
.iter()
.map(|tag| format!("'{}'", tag.replace('\'', "''")))
.join(", ");
format_pull_query(format!(
"SELECT id FROM v2_job_queue
WHERE running = false AND id = ANY(ARRAY[{ids}]::uuid[]) AND scheduled_for <= now()
AND tag = ANY(ARRAY[{tags}]::text[])
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED LIMIT 1"
))
}
// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the
// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing
// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes
+27 -13
View File
@@ -184,7 +184,7 @@ pub enum ObjectType {
DatatableMigration,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28994/sync-script-to-git-repo-windmill";
/// Hub script that applies a repository's state back into a workspace
/// (the repo → Windmill / "pull" direction). Same script the UI runs from
@@ -192,7 +192,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo
/// ignores the slug, so the slug is kept free of characters that would be
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
/// reverse proxies reject as double-encoding when the client re-encodes it).
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28970/git-sync-init-repository-windmill";
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28993/git-sync-init-repository-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
@@ -1556,9 +1556,21 @@ impl DataTableCatalogResourceType {
}
}
/// Whether an operator turned Windmill's own Postgres off as a data table and Ducklake substrate.
pub async fn instance_pg_disabled<'c>(executor: impl sqlx::PgExecutor<'c>) -> Result<bool> {
Ok(sqlx::query_scalar::<_, Option<serde_json::Value>>(
"SELECT value FROM global_settings WHERE name = $1",
)
.bind(crate::global_settings::INSTANCE_PG_DISABLED_SETTING)
.fetch_optional(executor)
.await?
.flatten()
.is_some_and(|v| v.as_bool().unwrap_or(false)))
}
/// Refuse a new use of Windmill's own Postgres as a data table or Ducklake substrate where an
/// operator turned it off, and on the managed cloud, which never had it. Entries already on it
/// keep resolving: this gates what a save may newly name, not what runs.
/// operator turned it off, and on the managed cloud, which never had it. Data tables already on
/// it are refused at resolution instead ([`resolve_datatable_connection_unchecked`]).
pub async fn ensure_instance_pg_available<'c>(executor: impl sqlx::PgExecutor<'c>) -> Result<()> {
if *crate::worker::CLOUD_HOSTED {
return Err(Error::BadRequest(
@@ -1566,15 +1578,7 @@ pub async fn ensure_instance_pg_available<'c>(executor: impl sqlx::PgExecutor<'c
.to_string(),
));
}
let disabled = sqlx::query_scalar::<_, Option<serde_json::Value>>(
"SELECT value FROM global_settings WHERE name = $1",
)
.bind(crate::global_settings::INSTANCE_PG_DISABLED_SETTING)
.fetch_optional(executor)
.await?
.flatten()
.is_some_and(|v| v.as_bool().unwrap_or(false));
if disabled {
if instance_pg_disabled(executor).await? {
return Err(Error::BadRequest(
"Windmill's own database is disabled as a data table and Ducklake substrate on this \
instance. Use the external instance cluster, or turn it back on in the instance \
@@ -1960,6 +1964,16 @@ async fn resolve_datatable_connection_unchecked(
serde_json::to_value(&pg_creds)
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))
} else if database.resource_type == DataTableCatalogResourceType::Instance {
// Turning Windmill's database off takes the data tables on it out of use, not only new
// ones: every job, API call and trigger reaches a data table through here.
if instance_pg_disabled(db).await? {
return Err(Error::BadRequest(format!(
"data table {} is on Windmill's own database, which is disabled on this instance. \
Move it to the external instance cluster or a Postgres resource, or turn Windmill's \
database back on in the instance settings.",
governing.name
)));
}
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
pg_creds.dbname = database.resource_path.clone();
if replication {
+79 -7
View File
@@ -43,6 +43,7 @@ use windmill_common::auth::JobPerms;
use windmill_common::bench::BenchmarkIter;
use windmill_common::jobs::{
script_path_to_payload, JobTriggerKind, TriggerKindLabel, EMAIL_ERROR_HANDLER_USER_EMAIL,
MODULES_ARG,
};
use windmill_common::min_version::{
MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2,
@@ -4908,6 +4909,45 @@ pub fn has_active_concurrency_limit(concurrent_limit: Option<i32>) -> bool {
concurrent_limit.is_some_and(|n| n > 0)
}
/// Admit a job already owned by a worker without releasing its queue reservation.
/// The caller must maintain its heartbeat while waiting and complete it on failure.
pub async fn try_admit_owned_job(db: &DB, job: &MiniPulledJob) -> error::Result<bool> {
#[cfg(all(feature = "private", feature = "enterprise"))]
{
let settings = windmill_common::runnable_settings::prefetch_cached_from_handle(
job.runnable_settings_handle,
db,
)
.await?
.1
.maybe_fallback(None, job.concurrent_limit, job.concurrency_time_window_s);
if has_active_concurrency_limit(settings.concurrent_limit)
&& !*DISABLE_CONCURRENCY_LIMIT
&& job.canceled_by.is_none()
{
let key = concurrency_key(db, &job.id).await?.ok_or_else(|| {
Error::internal_err(format!("No concurrency key found for job {}", job.id))
})?;
if !key.is_empty() {
return Ok(crate::jobs_ee::update_concurrency_counter(
db,
&job.id,
key,
serde_json::json!({ job.id.to_string(): {} }),
job.id.to_string(),
settings.concurrency_time_window_s.unwrap_or(0),
settings.concurrent_limit.unwrap_or_default(),
)
.await?
.0);
}
}
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
let _ = (db, job);
Ok(true)
}
pub async fn custom_concurrency_key(
db: &Pool<Postgres>,
job_id: &Uuid,
@@ -5191,10 +5231,15 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin
for cap in RE_ARG_TAG.captures_iter(&workspaced) {
let arg_name = cap.get(1).unwrap().as_str();
let (root, rest) = arg_name.split_once('.').unwrap_or((arg_name, ""));
let root_value = args
.args
.get(root)
.or(args.extra.as_ref().and_then(|x| x.get(root)));
// `push` strips a caller's `_MODULES` only after a run handler has authorized the
// tag, so reading it here would let the authorized tag and the queued one differ.
let root_value = (root != MODULES_ARG)
.then(|| {
args.args
.get(root)
.or(args.extra.as_ref().and_then(|x| x.get(root)))
})
.flatten();
let arg_value = render_tag_path(root_value.map(|x| &**x), rest);
interpolated =
interpolated.replace(format!("$args[{}]", arg_name).as_str(), &arg_value);
@@ -5977,7 +6022,7 @@ async fn push_inner<'c, 'd>(
mut tx: PushIsolationLevel<'c>,
workspace_id: &str,
job_payload: JobPayload,
mut args: PushArgs<'d>,
args: PushArgs<'d>,
user: &str,
mut email: &str,
mut permissioned_as: String,
@@ -6003,6 +6048,27 @@ async fn push_inner<'c, 'd>(
trigger: Option<TriggerMetadata>,
suspended_mode: Option<bool>,
) -> Result<(Uuid, Transaction<'c, Postgres>), Error> {
// The worker builds a preview's `_MODULES` arg into the job as its module code. Every
// caller-reachable value lands in `args` or `extra` (webhook query and headers go to
// `extra`, WAC children copy their parent's args), so it is dropped from both and only
// the `JobPayload::Code` arm below sets it, from the server-side `RawCode::modules`.
let args_without_modules;
let mut args = {
let mut extra = args.extra;
if let Some(extra) = extra.as_mut() {
extra.remove(MODULES_ARG);
}
let args = if args.args.contains_key(MODULES_ARG) {
let mut stripped = args.args.clone();
stripped.remove(MODULES_ARG);
args_without_modules = stripped;
&args_without_modules
} else {
args.args
};
PushArgs { extra, args }
};
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
// A fork/dev workspace draws its plan and usage from the root (billing) workspace, so its
@@ -6375,12 +6441,11 @@ async fn push_inner<'c, 'd>(
language = ScriptLang::Bun;
}
}
// Inject modules into job args as _MODULES so the worker can extract them
if let Some(ref modules) = modules {
match serde_json::to_string(modules).and_then(|s| RawValue::from_string(s)) {
Ok(raw) => {
let extra = args.extra.get_or_insert_with(HashMap::new);
extra.insert("_MODULES".to_string(), raw);
extra.insert(MODULES_ARG.to_string(), raw);
}
Err(e) => {
tracing::warn!("Failed to serialize modules for preview job: {e}");
@@ -8572,6 +8637,13 @@ mod render_tag_path_tests {
interpolate_args("w-$args[cfg.lang]-$args[e]".to_string(), &push_args, "ws"),
"w-eu-x"
);
let args = HashMap::from([("_MODULES".to_string(), raw(r#""allowed-""#))]);
let push_args = PushArgs { args: &args, extra: None };
assert_eq!(
interpolate_args("$args[_MODULES]private".to_string(), &push_args, "ws"),
"private"
);
}
fn raw(json: &str) -> Box<RawValue> {
+4
View File
@@ -716,6 +716,10 @@ pub struct OnBehalfOf {
pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE";
/// Job-arg key carrying a preview's module code. Only `push` writes it, from
/// `RawCode::modules`: a caller-supplied one is dropped there.
pub const MODULES_ARG: &str = "_MODULES";
/// Reserved job-arg key holding the inbound W3C `traceparent` captured from the
/// request that enqueued the job (run endpoints). It rides the `args` jsonb like
/// [`ENTRYPOINT_OVERRIDE`]; normal scripts never see it because args are bound by
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
// AI executor module structure
// This module will contain all AI-related execution logic
pub mod compaction;
pub mod stream_event_processor;
pub mod tools;
pub mod utils;
+351 -413
View File
@@ -7,16 +7,15 @@ use crate::ai::utils::{
use crate::common::OccupancyMetrics;
use crate::result_processor::handle_non_flow_job_error;
use crate::worker_flow::{
evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag,
};
use crate::{
create_job_dir, handle_queued_job, JobCompletedReceiver, JobCompletedSender, SendResult,
SendResultPayload,
evaluate_input_transform, raw_script_to_payload, resolve_flow_step_tag, script_to_payload,
JobPayloadWithTag,
};
use crate::{create_job_dir, handle_queued_job, JobCompletedSender};
use anyhow::Context;
use mappable_rc::Marc;
use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use sqlx::types::Json;
use std::{collections::HashMap, sync::Arc, time::Duration};
use uuid::Uuid;
use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*};
use windmill_common::jobs::JobPayload;
@@ -36,11 +35,12 @@ use windmill_common::{
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
worker::{make_tool_job_pull_query, to_raw_value, Connection, WORKER_CONFIG},
};
use windmill_queue::{
add_completed_job, add_completed_job_error, check_tag_available_for_push, get_mini_pulled_job,
push, resolve_push_tag, MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel,
append_logs, check_tag_available_for_push, get_mini_pulled_job, pull, push, resolve_push_tag,
tag_reads_flow_expr, try_admit_owned_job, MiniCompletedJob, MiniPulledJob, PushArgs,
PushIsolationLevel,
};
/// Shared collection of abort handles for spawned tool tasks.
@@ -74,6 +74,8 @@ pub struct ToolExecutionContext<'a> {
pub stream_event_processor: Option<&'a StreamEventProcessor>,
pub flow_context: &'a mut FlowContext,
pub omit_output_from_conversation: bool,
/// The agent's flow `preserve_step_tags`: its tools pick their tag as that flow's steps do.
pub preserve_step_tags: bool,
/// The thinking that led to this round's calls, stored on the first tool row written.
/// None when the round wrote text, whose row carries it.
pub reasoning: Option<String>,
@@ -82,6 +84,7 @@ pub struct ToolExecutionContext<'a> {
// Abort handles for spawned tool tasks (used for force-cancel cleanup)
pub tool_abort_handles: ToolAbortHandles,
pub job_completed_tx: JobCompletedSender,
}
/// Execute all tool calls from an AI response
@@ -98,7 +101,8 @@ pub async fn execute_tool_calls(
let mut used_structured_output_tool = false;
let mut final_content = None;
for tool_call in tool_calls.iter() {
let mut calls = tool_calls.iter().peekable();
while let Some(tool_call) = calls.next() {
// Stream tool call progress
if let Some(stream_event_processor) = ctx.stream_event_processor {
let event = StreamingEvent::ToolExecution {
@@ -150,15 +154,24 @@ pub async fn execute_tool_calls(
)
.await?;
} else if tool.module.is_some() {
execute_windmill_tool(
&mut ctx,
tool_call,
tool,
actions,
&mut messages,
final_events_str,
)
.await?;
let mut batch = vec![(tool_call, tool)];
while let Some(next_call) = calls.peek() {
if structured_output_tool_name.as_deref()
== Some(next_call.function.name.as_str())
{
break;
}
let Some(next_tool) = tools.iter().find(|t| {
t.def.function.name == next_call.function.name
&& t.mcp_source.is_none()
&& t.module.is_some()
}) else {
break;
};
batch.push((calls.next().unwrap(), next_tool));
}
execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str)
.await?;
} else {
return Err(Error::internal_err(format!(
"Tool type not supported: {}",
@@ -313,14 +326,13 @@ async fn execute_mcp_tool_call(
}
/// Execute a Windmill tool (script or flow)
async fn execute_windmill_tool(
ctx: &mut ToolExecutionContext<'_>,
async fn enqueue_windmill_tool(
ctx: &ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool: &Tool,
actions: &mut Vec<AgentAction>,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
reserved: bool,
) -> Result<(Uuid, bool), Error> {
// Regular Windmill tools must have a module
let tool_module = tool.module.as_ref().ok_or_else(|| {
Error::internal_err(format!("Tool {} has no module", tool_call.function.name))
@@ -402,8 +414,6 @@ async fn execute_windmill_tool(
tool_call_args.insert(key.clone(), result);
}
let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. });
let job_payload = match tool_value {
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
script_to_payload(
@@ -473,7 +483,6 @@ async fn execute_windmill_tool(
));
}
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
// a nested agent always runs inline on this worker, under this agent's tag
JobPayloadWithTag {
payload: JobPayload::AIAgent { path },
tag: None,
@@ -502,18 +511,34 @@ async fn execute_windmill_tool(
let push_args = PushArgs { args: &tool_call_args, extra: None };
// A tool with a tag of its own naming another queue than this agent's must run on a worker
// that tag selects, and like any tag the caller chose it must pass the workspace's custom tag
// restrictions. It runs inline when this worker serves that queue: the agent holds this
// worker while it waits, so dispatching to a queue it serves could wait on itself.
let mut resolved_tool_tag = None;
if let Some(tag) = job_payload.tag.as_deref() {
resolved_tool_tag = resolve_push_tag(tag, &push_args, &ctx.job.workspace_id, ctx.db)
.await
.filter(|resolved| *resolved != ctx.job.tag);
// The agent's tag stands in for the flow's, as a sub-flow's does for its own steps.
let tool_tag = resolve_flow_step_tag(
false,
&ctx.job.tag,
&ctx.job.workspace_id,
ctx.preserve_step_tags,
job_payload.tag.as_deref(),
);
if tool_tag.as_deref().is_some_and(tag_reads_flow_expr) {
append_logs(
&ctx.job.id,
&ctx.job.workspace_id,
format!(
"Tool '{}' is tagged with `$flow_expr[...]`, which AI agent tools do not resolve, so it runs on its default tag.\n",
tool_call.function.name
),
ctx.conn,
)
.await;
}
let tool_tag = match (job_payload.tag.as_deref(), resolved_tool_tag.as_ref()) {
(Some(tag), Some(_)) => {
// A tool's own tag routes its job, so like any tag the caller chose it must pass the
// workspace's custom tag restrictions. The agent's tag was already checked when it was pushed.
if let Some(tag) = tool_tag.as_deref() {
if resolve_push_tag(tag, &push_args, &ctx.job.workspace_id, ctx.db)
.await
.is_some_and(|resolved| resolved != ctx.job.tag)
{
let is_super_admin = windmill_common::auth::is_super_admin_email(ctx.db, email).await?;
check_tag_available_for_push(
ctx.db,
@@ -531,16 +556,8 @@ async fn execute_windmill_tool(
)),
e => e,
})?;
Some(tag.to_string())
}
_ => None,
};
let run_inline = resolved_tool_tag.is_none_or(|resolved| {
windmill_common::worker::WORKER_CONFIG
.load()
.worker_tags
.contains(&resolved)
});
}
let mut tx = ctx.db.begin().await?;
@@ -573,77 +590,71 @@ async fn execute_windmill_tool(
false,
None,
ctx.job.visible_to_owner,
Some(tool_tag.unwrap_or_else(|| ctx.job.tag.clone())),
tool_tag,
job_payload.timeout,
None,
job_priority,
job_perms.as_ref(),
run_inline,
false,
None,
None,
None,
)
.await?;
tx.commit().await?;
if !run_inline {
// Like an inline tool's failure, a lost wait reaches the model rather than failing the
// agent.
let (result, success) =
match wait_for_dispatched_tool_job(ctx.db, &uuid, &ctx.job.workspace_id).await {
Ok(outcome) => outcome,
Err(e) => (
to_raw_value(&serde_json::json!({ "error": { "message": e.to_string() } })),
false,
),
};
return report_tool_result(
ctx,
tool_call,
tool_module,
job_id,
&result,
success,
is_ai_agent_tool && success,
messages,
final_events_str,
let mut tx = tx;
let reserved = if reserved {
let tags = local_tool_tags();
// Reserve a supported first tool before commit so other workers cannot race it.
let claimed = sqlx::query!(
"UPDATE v2_job_queue SET worker = $1, running = true, started_at = now()
WHERE id = $2 AND tag = ANY($3)",
ctx.worker_name,
uuid,
&tags,
)
.await;
}
let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?;
let Some(tool_job) = tool_job else {
return Err(Error::internal_err("Tool job not found".to_string()));
.execute(&mut *tx)
.await?
.rows_affected()
== 1;
if claimed {
sqlx::query!("UPDATE v2_job_runtime SET ping = now() WHERE id = $1", uuid)
.execute(&mut *tx)
.await?;
}
claimed
} else {
false
};
tx.commit().await?;
Ok((uuid, reserved))
}
// The tool gets a token of its own, as it would on any worker: the agent's token would
// identify the tool as the agent job (its OIDC path and job id).
let tool_client = AuthedClient::new(
ctx.base_internal_url.to_string(),
tool_job.workspace_id.clone(),
windmill_queue::create_token(ctx.db, &tool_job, None).await,
None,
);
let tool_job = Arc::new(tool_job);
fn local_tool_tags() -> Vec<String> {
WORKER_CONFIG
.load()
.priority_tags_sorted
.iter()
.flat_map(|group| group.tags.iter().cloned())
.collect()
}
let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1);
let inner_job_completed_rx = inner_job_completed_rx.expect(
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
);
fn spawn_local_tool(
ctx: &ToolExecutionContext<'_>,
tool_job: MiniPulledJob,
reserved: bool,
) -> tokio::task::JoinHandle<Result<OccupancyMetrics, Error>> {
let mut tool_job = Arc::new(tool_job);
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
// Clone everything needed for the spawned task
let tool_job_spawn = tool_job.clone();
let db = ctx.db.clone();
let conn_spawn = ctx.conn.clone();
let client_spawn = tool_client;
let hostname_spawn = ctx.hostname.to_string();
let worker_name_spawn = ctx.worker_name.to_string();
let worker_dir_spawn = ctx.worker_dir.to_string();
let base_internal_url_spawn = ctx.base_internal_url.to_string();
let inner_job_completed_tx_spawn = inner_job_completed_tx.clone();
let job_completed_tx = ctx.job_completed_tx.clone();
let mut occupancy_metrics_spawn = ctx.occupancy_metrics.clone();
let mut killpill_rx_spawn = ctx.killpill_rx.resubscribe();
@@ -652,147 +663,270 @@ async fn execute_windmill_tool(
#[cfg(feature = "benchmark")]
let mut bench_spawn = windmill_common::bench::BenchmarkIter::new();
let job_dir = create_job_dir(&worker_dir_spawn, tool_job_spawn.id).await;
let result = async {
if reserved {
loop {
let queued = get_mini_pulled_job(&db, &tool_job.id)
.await?
.ok_or_else(|| Error::AlreadyCompleted("Tool job already completed".to_string()))?;
tool_job = Arc::new(queued);
sqlx::query!(
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1",
tool_job.id,
)
.execute(&db)
.await?;
if try_admit_owned_job(&db, &tool_job).await? {
let started_at = sqlx::query_scalar!(
"UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
tool_job.id,
)
.fetch_optional(&db)
.await?
.flatten();
Arc::make_mut(&mut tool_job).started_at = started_at;
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
let perms =
windmill_common::auth::get_job_perms(&db, &tool_job.id, &tool_job.workspace_id).await?;
let token = windmill_queue::create_token(&db, &tool_job, perms).await;
let client_spawn = AuthedClient::new(
base_internal_url_spawn.clone(),
tool_job.workspace_id.clone(),
token,
None,
);
let job_dir = create_job_dir(&worker_dir_spawn, tool_job.id).await;
let result = handle_queued_job(
tool_job_spawn,
None,
None,
None,
None,
&conn_spawn,
&client_spawn,
&hostname_spawn,
&worker_name_spawn,
&worker_dir_spawn,
&job_dir,
None,
&base_internal_url_spawn,
inner_job_completed_tx_spawn,
&mut occupancy_metrics_spawn,
&mut killpill_rx_spawn,
None,
None,
#[cfg(feature = "benchmark")]
&mut bench_spawn,
)
handle_queued_job(
tool_job.clone(),
None,
None,
None,
None,
&conn_spawn,
&client_spawn,
&hostname_spawn,
&worker_name_spawn,
&worker_dir_spawn,
&job_dir,
None,
&base_internal_url_spawn,
job_completed_tx,
&mut occupancy_metrics_spawn,
&mut killpill_rx_spawn,
None,
None,
#[cfg(feature = "benchmark")]
&mut bench_spawn,
)
.await
}
.await;
// Return both result and updated metrics
(result, occupancy_metrics_spawn)
match result {
Err(err) => {
let err_string = format!("{}: {}", err.name(), err);
handle_non_flow_job_error(
&db,
&MiniCompletedJob::from(tool_job),
0,
None,
err_string,
windmill_common::worker::error_to_value(&err),
&worker_name_spawn,
)
.await?;
}
Ok(_) => {}
}
Ok(occupancy_metrics_spawn)
});
// Register abort handle so the task can be killed on force-cancel
let abort_handle = join_handle.abort_handle();
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
ctx.tool_abort_handles.lock().unwrap().push(abort_handle);
// Await the spawned task
let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| {
if e.is_cancelled() {
Error::ExecutionErr("Tool execution task was cancelled".to_string())
} else {
Error::internal_err(format!("Tool execution task failed: {}", e))
}
})?;
// Merge occupancy metrics back
ctx.occupancy_metrics.total_duration_of_running_jobs =
updated_occupancy.total_duration_of_running_jobs;
match handle_result {
Err(err) => {
handle_tool_execution_error(
ctx,
tool_call,
tool_module,
&MiniCompletedJob::from(tool_job),
job_id,
err,
messages,
final_events_str,
)
.await?;
}
Ok(outcome) => {
handle_tool_execution_success(
ctx,
tool_call,
tool_module,
job_id,
outcome.is_success(),
is_ai_agent_tool,
inner_job_completed_rx,
messages,
final_events_str,
)
.await?;
}
}
Ok(())
join_handle
}
/// Handle tool execution error
async fn handle_tool_execution_error(
async fn execute_windmill_tools(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool_module: &windmill_common::flows::FlowModule,
tool_job: &MiniCompletedJob,
job_id: Uuid,
err: Error,
batch: &[(&OpenAIToolCall, &Tool)],
actions: &mut Vec<AgentAction>,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let err_string = format!("{}: {}", err.name(), err.to_string());
let err_json = windmill_common::worker::error_to_value(&err);
let _ = handle_non_flow_job_error(
ctx.db,
tool_job,
0,
None,
err_string.clone(),
err_json,
ctx.worker_name,
)
.await;
let error_message = format!("Error running tool: {}", err_string);
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(error_message.clone())),
tool_call_id: Some(tool_call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: tool_call.function.name.clone(),
module_id: tool_module.id.clone(),
}),
..Default::default()
});
// Stream tool result (error case)
if let Some(stream_event_processor) = ctx.stream_event_processor {
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: error_message.clone(),
success: false,
};
stream_event_processor
.send(tool_result_event, final_events_str)
.await?;
let mut job_ids = Vec::with_capacity(batch.len());
let mut local = None;
for (index, (call, tool)) in batch.iter().enumerate() {
if index > 0 {
if let Some(processor) = ctx.stream_event_processor {
processor
.send(
StreamingEvent::ToolExecution {
call_id: call.id.clone(),
function_name: call.function.name.clone(),
},
final_events_str,
)
.await?;
}
}
let (id, reserved) = enqueue_windmill_tool(ctx, call, tool, actions, index == 0).await?;
job_ids.push(id);
if reserved {
let job = get_mini_pulled_job(ctx.db, &id)
.await?
.ok_or_else(|| Error::internal_err("Reserved tool job not found".to_string()))?;
local = Some(spawn_local_tool(ctx, job, true));
}
}
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
let mut results: HashMap<Uuid, (bool, String)> = HashMap::new();
let mut next_result = 0;
let mut poll = tokio::time::interval(Duration::from_millis(100));
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
while next_result < batch.len() || local.is_some() {
tokio::select! {
result = async { local.as_mut().unwrap().await }, if local.is_some() => {
local = None;
let metrics = result.map_err(|e| Error::internal_err(format!("Tool task failed: {e}")))??;
ctx.occupancy_metrics.total_duration_of_running_jobs = metrics.total_duration_of_running_jobs;
ctx.tool_abort_handles.lock().unwrap().retain(|handle| !handle.is_finished());
}
_ = poll.tick() => {}
}
let pending: Vec<Uuid> = job_ids[next_result..]
.iter()
.filter(|id| !results.contains_key(id))
.copied()
.collect();
if !pending.is_empty() {
let completed = sqlx::query!(
"SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"
FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
ctx.job.workspace_id,
&pending,
).fetch_all(ctx.db).await?;
for completed in completed {
let index = job_ids
.iter()
.position(|id| *id == completed.id)
.ok_or_else(|| Error::internal_err("Unexpected tool completion".to_string()))?;
let (call, tool) = batch[index];
let result = completed
.result
.map(|value| value.0)
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
let is_agent = tool.module.as_ref().is_some_and(|module| {
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
});
let content = if is_agent && completed.success {
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
} else {
result.get().to_string()
};
if let Some(processor) = ctx.stream_event_processor {
processor
.send(
StreamingEvent::ToolResult {
call_id: call.id.clone(),
function_name: call.function.name.clone(),
result: content.clone(),
success: completed.success,
},
final_events_str,
)
.await?;
}
results.insert(completed.id, (completed.success, content));
}
}
// Transcript rows, model messages, and positional action statuses share call order.
while next_result < batch.len() {
let job_id = job_ids[next_result];
let Some((success, content)) = results.remove(&job_id) else {
break;
};
let (call, tool) = batch[next_result];
let module = tool
.module
.as_ref()
.ok_or_else(|| Error::internal_err("Windmill tool has no module".to_string()))?;
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(content.clone())),
tool_call_id: Some(call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: call.function.name.clone(),
module_id: module.id.clone(),
}),
..Default::default()
});
if let Some(parent) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent, success).await?;
}
let (content, extras) = windmill_tool_row(call, success, &content);
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
next_result += 1;
}
if local.is_none() && next_result < batch.len() {
let pending: Vec<Uuid> = job_ids[next_result..]
.iter()
.filter(|id| !results.contains_key(id))
.copied()
.collect();
if !pending.is_empty() {
local = claim_local_tool(ctx, &pending).await?;
}
}
}
let (content, extras) = windmill_tool_row(tool_call, false, &error_message);
add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await;
Ok(())
}
async fn claim_local_tool(
ctx: &ToolExecutionContext<'_>,
pending: &[Uuid],
) -> Result<Option<tokio::task::JoinHandle<Result<OccupancyMetrics, Error>>>, Error> {
let query = (
String::new(),
make_tool_job_pull_query(pending, &local_tool_tags()),
);
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
let mut pulled = pull(
ctx.db,
false,
ctx.worker_name,
Some(&query),
#[cfg(feature = "benchmark")]
&mut bench,
)
.await?;
if let Err(err) = pulled.maybe_apply_debouncing(ctx.db).await {
pulled.error_while_preprocessing = Some(err.to_string());
}
match pulled.to_pulled_job() {
Ok(job) => Ok(job.map(|job| spawn_local_tool(ctx, job.job, false))),
Err(
windmill_queue::PulledJobResultToJobErr::MissingConcurrencyKey(job)
| windmill_queue::PulledJobResultToJobErr::ErrorWhilePreprocessing(job),
) => {
ctx.job_completed_tx.send_job(job, true).await?;
Ok(None)
}
}
}
/// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON.
/// Returns `None` if the result is not a JSON object carrying an `output` field.
fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
@@ -802,200 +936,6 @@ fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
.map(|output| output.get().to_string())
}
/// Handle tool execution success
async fn handle_tool_execution_success(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool_module: &windmill_common::flows::FlowModule,
job_id: Uuid,
success: bool,
is_ai_agent_tool: bool,
inner_job_completed_rx: JobCompletedReceiver,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
let (result, job_success) = if let Some(SendResult {
result: SendResultPayload::JobCompleted(ref jc),
..
}) = send_result
{
let result = jc.result.clone();
// Write tool completion to the DB inline instead of forwarding through
// the parent channel. Forwarding would deadlock for nested agents: the
// sub-tool result would fill the parent's bounded(1) channel, leaving
// no room for the agent's own completion from process_result.
if jc.success {
add_completed_job(
ctx.db,
&jc.job,
true,
false,
sqlx::types::Json(&*jc.result),
jc.result_columns.clone(),
jc.mem_peak,
jc.canceled_by.clone(),
false,
jc.duration,
jc.from_cache.unwrap_or(false),
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
} else {
let error_value: serde_json::Value =
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
});
add_completed_job_error(
ctx.db,
&jc.job,
jc.mem_peak,
jc.canceled_by.clone(),
error_value,
ctx.worker_name,
false,
jc.duration,
)
.await
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
}
(result, jc.success)
} else {
return Err(Error::internal_err(
"Tool job completed but no result".to_string(),
));
};
report_tool_result(
ctx,
tool_call,
tool_module,
job_id,
&result,
success,
is_ai_agent_tool && job_success,
messages,
final_events_str,
)
.await
}
/// Wait for a tool job another worker runs; returns its result and whether it succeeded.
/// Canceling the agent cancels this job along with it, as a child of the agent's job.
async fn wait_for_dispatched_tool_job(
db: &DB,
id: &Uuid,
w_id: &str,
) -> Result<(Box<RawValue>, bool), Error> {
const MAX_CONSECUTIVE_POLL_ERRORS: u32 = 10;
let mut interval = std::time::Duration::from_millis(50);
let mut poll_errors = 0;
loop {
// One statement reads both tables from one snapshot, so a job completing between two
// reads cannot look like one that vanished.
let state = match sqlx::query!(
"SELECT c.id IS NOT NULL AS \"completed!\",
c.result AS \"result: sqlx::types::Json<Box<RawValue>>\",
c.status = 'success' AS \"success\",
EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\"
FROM (SELECT 1) one
LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2",
id,
w_id
)
.fetch_one(db)
.await
{
Ok(state) => {
poll_errors = 0;
state
}
Err(e) if poll_errors < MAX_CONSECUTIVE_POLL_ERRORS => {
poll_errors += 1;
tracing::warn!("polling tool job {id} failed, retrying: {e}");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
Err(e) => return Err(e.into()),
};
if state.completed {
let result = state
.result
.map(|r| r.0)
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
return Ok((result, state.success.unwrap_or(false)));
}
if !state.queued {
return Err(Error::internal_err(format!(
"tool job {id} is neither queued nor completed"
)));
}
tokio::time::sleep(interval).await;
interval = std::cmp::min(interval * 2, std::time::Duration::from_secs(1));
}
}
/// Hand a finished tool job's result to the model, the stream, the flow status and the chat.
async fn report_tool_result(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
tool_module: &windmill_common::flows::FlowModule,
job_id: Uuid,
result: &RawValue,
success: bool,
is_ai_agent_output: bool,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
// A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries
// the child's entire message history, stream log and token usage. Feeding that back would
// grow the caller's context by the child's full transcript on every call, so the caller only
// sees `output`. The envelope stays intact in the tool job's completed row.
let tool_result = if is_ai_agent_output {
extract_ai_agent_output(result).unwrap_or_else(|| result.get().to_string())
} else {
result.get().to_string()
};
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(tool_result.clone())),
tool_call_id: Some(tool_call.id.clone()),
agent_action: Some(AgentAction::ToolCall {
job_id,
function_name: tool_call.function.name.clone(),
module_id: tool_module.id.clone(),
}),
..Default::default()
});
let (content, extras) = windmill_tool_row(tool_call, success, &tool_result);
// The job ran; whether it ran successfully is `success`, and the row stored below is
// worded from it. The stream has to carry the same value, or the card the reader watches
// and the row that replaces it describe the same call differently.
if let Some(stream_event_processor) = ctx.stream_event_processor {
let tool_result_event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: tool_result,
success,
};
stream_event_processor
.send(tool_result_event, final_events_str)
.await?;
}
if let Some(parent_job) = ctx.parent_job {
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
}
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
Ok(())
}
/// A Windmill tool's conversation row: worded from the tool, carrying the model's call and
/// the exact text the model got back, the same text agent memory keeps for that tool
/// message, so a card needs no job fetch. The call is the model's arguments, not the job's
@@ -1060,9 +1000,7 @@ async fn add_tool_message_to_chat(
.or(ctx.job.flow_step_id.as_deref());
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
// Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows
// must commit in the order of its calls. Calls run one after another; running them
// in parallel would need their rows written in call order all the same.
// created_seq defines transcript order, so these writes must stay sequential.
if let Err(e) = add_message_to_conversation(
ctx.db,
&memory_id,
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -2940,7 +2940,9 @@ pub async fn handle_wac_v2_output(
dedicated_worker: None,
concurrency_settings: ConcurrencySettingsWithCustom::default(),
debouncing_settings: DebouncingSettings::default(),
modules: None,
// `push` drops the `_MODULES` the children inherit with the
// parent's args, so the preview's own modules are handed over here.
modules: modules.clone(),
tag: None,
}))
}
@@ -2969,7 +2969,7 @@ mod tests {
assert!(
attach.starts_with(&format!(
"ATTACH 'sslmode=verify-full sslrootcert=''{}''",
root
root.replace('\\', "\\\\")
)),
"{attach}"
);
+36 -4
View File
@@ -30,16 +30,21 @@ pub async fn read_from_db(
}
}
/// Write AI agent memory to database with size checking and truncation
/// Write AI agent memory to database with size checking and truncation.
///
/// Returns how many of the oldest messages were dropped to fit, so the step can say so
/// on the run: from the flow's side a truncation is invisible, the agent simply having
/// forgotten the start of its conversation by the next one.
pub async fn write_to_db(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
step_id: &str,
messages: &[OpenAIMessage],
) -> Result<(), Error> {
allow_truncation: bool,
) -> Result<usize, Error> {
if messages.is_empty() {
return Ok(());
return Ok(0);
}
// Serialize messages and check size
@@ -49,6 +54,13 @@ pub async fn write_to_db(
// Truncate if necessary
if size_bytes > MAX_MEMORY_SIZE_BYTES {
// Compaction owns its exchange boundaries, including after an object-store failure.
if !allow_truncation {
return Err(Error::ExecutionErr(format!(
"Memory exceeds database capacity ({} > {} bytes); existing memory was not updated",
size_bytes, MAX_MEMORY_SIZE_BYTES
)));
}
tracing::warn!(
"Memory size ({} bytes) exceeds limit ({} bytes) for workspace={} conversation={} step={}. Truncating messages. Use S3 storage in workspace settings to store full conversation history.",
size_bytes,
@@ -78,7 +90,7 @@ pub async fn write_to_db(
.execute(db)
.await?;
Ok(())
Ok(messages.len() - messages_to_store.len())
}
/// Delete all memory for a conversation from database
@@ -121,3 +133,23 @@ fn truncate_messages(
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn oversized_compaction_memory_is_rejected_before_touching_the_database() {
let db = sqlx::postgres::PgPoolOptions::new()
.connect_lazy_with(sqlx::postgres::PgConnectOptions::new());
db.close().await;
let message: OpenAIMessage = serde_json::from_value(serde_json::json!({
"role": "user", "content": "x".repeat(MAX_MEMORY_SIZE_BYTES)
}))
.unwrap();
let error = write_to_db(&db, "test", Uuid::nil(), "a", &[message], false)
.await
.unwrap_err();
assert!(error.to_string().contains("Memory exceeds database capacity"));
}
}
+20 -5
View File
@@ -30,14 +30,29 @@ pub async fn write_to_memory(
conversation_id: Uuid,
step_id: &str,
messages: &[OpenAIMessage],
) -> anyhow::Result<()> {
allow_truncation: bool,
) -> anyhow::Result<usize> {
if messages.is_empty() {
return Ok(());
return Ok(0);
}
memory_common::write_to_db(db, workspace_id, conversation_id, step_id, messages)
.await
.map_err(|e| anyhow::anyhow!("Database write failed: {e:?}"))
memory_common::write_to_db(
db,
workspace_id,
conversation_id,
step_id,
messages,
allow_truncation,
)
.await
.map_err(|e| anyhow::anyhow!("Database write failed: {e:?}"))
}
/// How many bytes a persisted memory can hold, `None` where nothing bounds it.
/// In OSS: always the database limit
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn memory_storage_capacity_bytes() -> Option<usize> {
Some(memory_common::MAX_MEMORY_SIZE_BYTES)
}
/// Delete all memory for a conversation from storage
+11 -5
View File
@@ -21,6 +21,7 @@ use windmill_common::get_latest_deployed_hash_for_path;
use windmill_common::jobs::InlineScriptTarget;
use windmill_common::jobs::RunInlineScriptFnParams;
use windmill_common::jobs::WorkerInternalServerInlineUtils;
use windmill_common::jobs::MODULES_ARG;
use windmill_common::jobs::WORKER_INTERNAL_SERVER_INLINE_UTILS;
use windmill_common::otel_oss::{
otel_incr_worker_execution_count, otel_incr_worker_started,
@@ -4989,6 +4990,7 @@ pub async fn handle_queued_job(
hostname,
killpill_rx,
&mut has_stream,
job_completed_tx.clone(),
))
.await
}
@@ -5543,12 +5545,16 @@ async fn handle_code_execution_job(
None => job,
};
// Any job kind, not just previews: whatever is here is what gets written to the job dir
// and built in, so the agent-worker server precomputing a cache name has to resolve
// modules the same way (`windmill-api-agent-workers`, `get_code_and_lock`).
// Whatever is here is what gets written to the job dir and built in, so the agent-worker
// server precomputing a cache name has to resolve modules the same way
// (`windmill-api-agent-workers`, `get_code_and_lock`). `push` stores `_MODULES` for a
// preview only, from its `RawCode`; a job queued by a server predating that may still carry
// a caller's, and honoring it on a deployed runnable would run caller code as that runnable
// (and as its `on_behalf_of` identity).
let modules = modules_from_data.clone().or_else(|| {
job.args.as_ref().and_then(|args| {
args.get("_MODULES").and_then(|raw| {
let args = job.args.as_ref().filter(|_| job.kind == JobKind::Preview);
args.and_then(|args| {
args.get(MODULES_ARG).and_then(|raw| {
serde_json::from_str::<std::collections::HashMap<String, ScriptModule>>(raw.get())
.ok()
})
+1 -1
View File
@@ -3239,7 +3239,7 @@ struct PushNextFlowJobRec {
/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag,
/// that tag is honored instead of being overridden by the flow tag;
/// - otherwise the child inherits the parent flow job's tag.
fn resolve_flow_step_tag(
pub(crate) fn resolve_flow_step_tag(
is_preprocessor_step: bool,
flow_tag: &str,
workspace_id: &str,
+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.819.0";
export const VERSION = "v1.820.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-chat",
"version": "1.819.0",
"version": "1.820.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.819.0",
"version": "1.820.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-chat",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.819.0",
"version": "1.820.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+11 -4
View File
@@ -77,6 +77,7 @@ import {
import {
getEffectiveSettings,
inferWsNameFromProfile,
inferWsNameFromForkBranch,
resolveWsNameForConfigFromFlags,
mergeConfigWithConfigFile,
parseSyncBehavior,
@@ -3752,9 +3753,12 @@ export async function pull(
}
}
// If wsNameForConfig wasn't set from flags, infer from the resolved profile
// If wsNameForConfig wasn't set from flags, infer from the resolved profile,
// then (for a fork) from the parent's entry
if (!wsNameForConfig) {
wsNameForConfig = inferWsNameFromProfile(opts, workspace);
wsNameForConfig =
inferWsNameFromProfile(opts, workspace) ??
inferWsNameFromForkBranch(opts, workspace.workspaceId);
}
// Resolve effective sync options with workspace awareness
@@ -4802,9 +4806,12 @@ export async function push(
const workspace = await resolveWorkspace(opts, wsNameForConfig);
await requireLogin(opts);
// If wsNameForConfig wasn't set from flags, infer from the resolved profile
// If wsNameForConfig wasn't set from flags, infer from the resolved profile,
// then (for a fork) from the parent's entry
if (!wsNameForConfig) {
wsNameForConfig = inferWsNameFromProfile(opts, workspace);
wsNameForConfig =
inferWsNameFromProfile(opts, workspace) ??
inferWsNameFromForkBranch(opts, workspace.workspaceId);
}
// Resolve effective sync options with workspace awareness
+25
View File
@@ -5,6 +5,7 @@ import { stringify as yamlStringify } from "yaml";
import {
getCurrentGitBranch,
getOriginalBranchForWorkspaceForks,
getWorkspaceIdForWorkspaceForkFromBranchName,
isGitRepository,
} from "../utils/git.ts";
import { join, dirname, resolve, relative } from "node:path";
@@ -725,6 +726,30 @@ export function inferWsNameFromProfile(
return undefined;
}
/**
* The workspace config entry a fork inherits from its parent: the one bound to
* the base branch of the checked-out `wm-fork/<base>/<id>` branch, when
* `workspaceId` is the fork that branch names. A fork id never has an entry of
* its own, so without this its specific items and file suffix fall back to
* the common/base files and clobber what the fork cloned from its parent.
*/
export function inferWsNameFromForkBranch(
opts: SyncOptions,
workspaceId: string
): string | undefined {
if (!opts.workspaces || !isGitRepository()) return undefined;
const branch = getCurrentGitBranch();
const baseBranch = getOriginalBranchForWorkspaceForks(branch);
if (
!branch ||
!baseBranch ||
getWorkspaceIdForWorkspaceForkFromBranchName(branch) !== workspaceId
) {
return undefined;
}
return findWorkspaceByGitBranch(opts.workspaces, baseBranch)?.[0];
}
/**
* `syncBehavior` as the workspace being pushed to sees it. The top level alone
* misses a `workspaces.<name>.overrides.syncBehavior`, which is where a repo
+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.819.0";
export const VERSION = "1.820.0";
+1 -1
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { execSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { inferWsNameFromForkBranch, type SyncOptions } from "../src/core/conf.ts";
// A fork id never has a wmill.yaml entry of its own. Unless it resolves to its
// parent's entry, a sync on the fork branch pushes the base files over the
// workspace-specific values the fork cloned from its parent.
test("a fork branch resolves to its parent's workspace entry", async () => {
const repoDir = await mkdtemp(path.join(os.tmpdir(), "wmill_fork_specific_"));
const originalCwd = process.cwd();
try {
execSync(
`git init -q -b wm-fork/dev-branch/t1 && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init`,
{ cwd: repoDir },
);
process.chdir(repoDir);
const config: SyncOptions = {
workspaces: {
dev: {
baseUrl: "http://localhost:8000/",
workspaceId: "dev-ws",
gitBranch: "dev-branch",
specificItems: { folders: ["f/team"] },
},
},
};
expect(inferWsNameFromForkBranch(config, "wm-fork-t1")).toEqual("dev");
// Only the fork the branch names inherits the parent's entry.
expect(inferWsNameFromForkBranch(config, "other-ws")).toBeUndefined();
} finally {
process.chdir(originalCwd);
await rm(repoDir, { recursive: true, force: true });
}
});
+8 -2
View File
@@ -455,6 +455,12 @@ schema:
// SCRIPT WITH MODULES PREVIEW TESTS
// =============================================================================
// Pinned: a release commit runs these tests while the same release publishes a new
// windmill-client, and the worker resolving that version before npm serves it fails the job.
// These are the only CLI tests running task/taskScript/workflow on a real client: bump the pin
// to a published release whenever the WAC client surface changes.
const WINDMILL_CLIENT = "windmill-client@1.819.0";
test("script preview: script with modules (taskScript pattern)", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
@@ -463,7 +469,7 @@ test("script preview: script with modules (taskScript pattern)", async () => {
await createScript(
tempDir,
"f/test/wac_script.ts",
`import { task, taskScript, workflow } from "windmill-client";
`import { task, taskScript, workflow } from "${WINDMILL_CLIENT}";
const helper = taskScript("./helper.ts");
@@ -512,7 +518,7 @@ test("script preview: script with modules (folder layout)", async () => {
// Entry point script
await writeFile(
`${modDir}/script.ts`,
`import { task, taskScript, workflow } from "windmill-client";
`import { task, taskScript, workflow } from "${WINDMILL_CLIENT}";
const helper = taskScript("./helper.ts");
+1 -2
View File
@@ -61,8 +61,7 @@ root.render(<App/>);
const PACKAGE_JSON = `{
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"windmill-client": "^1"
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/react-dom": "^19.0.0",
+78 -11
View File
@@ -44,8 +44,10 @@ which memory it is:
- **Agent: managed memory.** `memory` is a brain key, so it moves with a saved agent.
`{ kind: window, context_length }` has Windmill store the conversation and replay its last N
messages; `{ kind: off }` keeps none. An absent `memory` means off, the default: the editor turns
it on when chat input is enabled. `auto` and `manual` are the older spellings and are still read.
messages; `{ kind: compaction }` replays all of it and summarizes the older part as it fills the
model's context window (see below); `{ kind: off }` keeps none. An absent `memory` means off, the
default: the editor turns it on when chat input is enabled, as compaction, since a chat has no
end. `auto` and `manual` are the older spellings and are still read.
- **Run: memory id.** `flow_status.memory_id`, set when the run is queued: the chat conversation
id, an app chat session id, or the `memory_id` run parameter. Any string is accepted, and one
that is not a uuid is hashed to a v5 uuid scoped to the workspace and the flow the run started
@@ -69,25 +71,90 @@ The worker reconciles them once per agent invocation, nested agent tools include
1. A legacy `auto` or `manual` memory: read as the editor that wrote it ran it. `manual` replays
its list; `auto` uses the run's memory id, else the id baked into it, else runs stateless.
Neither history input is read. An `auto` without a count, or with 0, is off and read as such.
2. Managed memory: the memory id is the step's, else the run's. With no memory id the agent runs
stateless, and a step `previous_messages` is ignored.
2. Managed memory, `window` or `compaction`: the memory id is the step's, else the run's. With no
memory id the agent runs stateless, and a step `previous_messages` is ignored. Compaction still
bounds a stateless run's own loop, which is where a long tool sequence overflows.
3. Memory off: the history is `previous_messages`, else nothing. Memory is neither read nor
written, and a step `memory_id` is ignored.
Each ignored input and each stateless fallback is written to the job log.
### Compaction
`{ kind: compaction }` keeps the whole conversation and lets a summary, rather than a message count,
decide what leaves the prompt.
The window it plans against comes from the model, through `MODEL_CONTEXT_WINDOWS` in
`windmill-ai/src/model_context.rs`, falling back to 128000 for an id the table does not list. That
table mirrors the one the AI session's own compaction reads
(`frontend/src/lib/components/copilot/modelConfig.ts`) and the two have to be updated together. The
step's `context_window` overrides it, for a Custom AI deployment or a model the table cannot name;
setting it too large never trips the trigger and the provider raises the context error itself.
Workspace AI chat `context_window_per_model` overrides are separate and are not inherited by
agent steps; configure a custom deployment's size on the step itself.
`windmill-worker/src/ai/compaction.rs` keeps two histories: model context, which can be compacted,
and the execution record, which retains the loaded history and every message produced by the run.
Returned results and max-iteration partial results use the execution record. Compaction cannot remove or
reorder the action messages the flow viewer indexes, or the MCP results it finds by call ID.
Before each provider request, including the first, the worker checks the projected context size.
It reserves the larger of 20% of the model window and the configured maximum output tokens.
At the remaining input budget it summarizes an older prefix, retaining up to 20K estimated tokens
of recent complete exchanges (at most half the input budget on smaller models).
The newest exchange is always retained. A user prompt stays with its
first response; later tool rounds can be compacted within a single turn, but a call and its results
are never split. A prefix containing only an earlier summary is not summarized again.
The summarization request carries no tools; its tool exchanges are rendered as text because
Bedrock rejects tool blocks without definitions. A dedicated system instruction asks for a factual
handoff from a labelled transcript, keeping the compaction instruction outside that transcript;
media parts remain available. Its output cap matches the reserved summary budget,
and its temperature and reasoning settings are independent of the step's answer settings.
A replacement is built separately and installed only if it reduces projected context and fits
the estimated input budget. An empty, oversized or failed summary leaves the context and its
usage measurement untouched; compaction never evicts messages. Three consecutive failed attempts
disable summary requests for the run. Estimates schedule compaction but do not reject model calls.
The projection uses normalized `TokenUsage::input_tokens` from the last request plus a `bytes/4`
estimate of appended messages. Without usage, or after rewriting context, it estimates the whole
prompt including tools. S3 descriptors get a nominal attachment allowance; actual attachment costs
and tokenizer differences remain approximate. Provider parsing owns usage normalization: Anthropic
and Bedrock report cached input separately, while OpenAI-shaped providers include it in input tokens.
If a provider explicitly rejects the context size, the worker summarizes all older complete
exchanges and retries that request once if a usable replacement was produced. This also recovers
from undercounted attachments loaded from a previous run, without provider-specific tokenizers.
Unrelated errors are not retried this way. The newest exchange is still retained, so a request
that cannot fit even after recovery fails; estimates do not guarantee every first request fits.
If recovery fails or the retry is rejected, the provider error is returned without saving changed
memory. A successful checkpoint uses the same execution record as before compaction.
Memory is stored per (memory id, step id), in `ai_agent_memory` or S3 at
`memory/{workspace}/{memory id}/{step}.json`. The chat transcript (`flow_conversation_message`)
always follows the run's id, even when a step sets its own. Nothing expires stored memory: deleting
a chat conversation deletes its memory, and a memory named by a string id stays until it is
overwritten.
always follows the run's id, even when a step sets its own. Persistence independently checks
serialized memory against the database's 100KB limit (`MAX_MEMORY_SIZE_BYTES`),
when `memory_storage_capacity_bytes` reports one. System messages and tool definitions are not
stored, so they do not count towards this byte limit. If oversized, it requests one checkpoint
of all older exchanges, then measures bytes again. If memory still cannot fit, persistence saves
the largest suffix of complete exchanges that fits and starts with a user message, dropping
older exchanges and logging that loss. This selection changes neither model context nor the
returned execution record.
If no user-starting suffix fits, the write is skipped and the flow log explains that the
next run will load the previous saved memory. The completed answer succeeds in either case.
If object storage fails and falls back to the database, an oversized compaction write is
rejected without changing existing memory; the flow log reports the failed save.
There is no model-window compaction after the final answer unless storage needs
it. Persistence reads model context independently of the execution record returned by the step.
Nothing expires stored memory: deleting a chat conversation deletes its memory, and a memory named
by a string id stays until it is overwritten.
Compatibility runs one way. New workers read every older shape. The editor rewrites a legacy step
only when the author changes it, so a flow nobody edits keeps running on older workers, while a
step saved with `window` or a history input needs a worker that knows them. An id an older editor
baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as memory
id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id
always took precedence there.
step saved with `window`, `compaction` or a history input needs a worker that knows them. An id an
older editor baked into `memory` stays a fallback behind the run's id until the author chooses
*Keep as memory id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the
conversation id always took precedence there.
## Drafts
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.819.0",
"version": "1.820.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.819.0",
"version": "1.820.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.819.0",
"version": "1.820.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+2 -2
View File
@@ -261,13 +261,13 @@
<ToggleButton
value="developer"
label="Developer"
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 1 seat. Use this for CLI sync tokens."
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 0.5 seat. Use this for CLI sync tokens."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="Full workspace admin. Counts as 1 seat. Grant only when the service account needs to manage workspace settings."
tooltip="Full workspace admin. Counts as 0.5 seat. Grant only when the service account needs to manage workspace settings."
{item}
/>
{/snippet}
@@ -5,7 +5,6 @@
import SchemaForm from './SchemaForm.svelte'
import Toggle from './Toggle.svelte'
import TestConnection from './TestConnection.svelte'
import SupabaseIcon from './icons/SupabaseIcon.svelte'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -16,8 +15,6 @@
import { isCloudHosted } from '$lib/cloud'
import ResourceGen from './copilot/ResourceGen.svelte'
import SyncResourceTypes from './SyncResourceTypes.svelte'
import { base } from '$lib/base'
import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -139,10 +136,6 @@
let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined)
let textFileContent: string | undefined = $state(undefined)
// The wizard's Supabase entry point is opt-in for now; without it the form keeps the link
// that hands the whole leg over to the resources page.
const wizardEnabled = isDataTableWizardEnabled()
function applySupabasePick(value: Record<string, any>) {
args = { ...(args ?? {}), ...value }
rawCode = JSON.stringify(args, null, 2)
@@ -228,28 +221,13 @@
</Popover>
{/if}
{#if resourceType == 'postgresql' && supabaseWizard}
{#if wizardEnabled}
<!-- Imported here rather than at the top so the wizard's Supabase graph stays out of
this form's chunk, which loads on the resources page and in every resource drawer. -->
{#await import('./workspaceSettings/SupabaseResourceConnect.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default onPicked={applySupabasePick} />
{/await}
{:else}
<!-- `noopener` is what the callback reads to tell this leg from the wizard's popup,
which hands its token back through `window.opener`. Browsers imply it for
`target="_blank"`, but only since 2021 -- stating it keeps older ones on this path. -->
<a
target="_blank"
rel="noopener"
href="{base}/api/oauth/connect/supabase_wizard"
class="border rounded-lg flex flex-row gap-2 items-center text-xs px-3 py-1.5 h-8 bg-[#F1F3F5] hover:bg-[#E6E8EB] dark:bg-[#1C1C1C] dark:hover:bg-black"
>
<SupabaseIcon height="16px" width="16px" />
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
</a>
{/if}
<!-- Imported here rather than at the top so the wizard's Supabase graph stays out of
this form's chunk, which loads on the resources page and in every resource drawer. -->
{#await import('./workspaceSettings/SupabaseResourceConnect.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default onPicked={applySupabasePick} />
{/await}
{/if}
<GitHubAppIntegration
{resourceType}
@@ -16,6 +16,9 @@
containOverflow?: boolean
/** The Windmill version and update notice in the header. */
showVersion?: boolean
/** Set on the pages that render inside the app shell, under the page header: there the
* window is not the card's box, and taking the whole of it pushes 44px past the bottom. */
inPage?: boolean
children?: import('svelte').Snippet
}
@@ -28,6 +31,7 @@
loading = false,
containOverflow = false,
showVersion = true,
inPage = false,
children
}: Props = $props()
@@ -37,7 +41,9 @@
</script>
<div
class="flex justify-center h-screen p-4 relative bg-surface-secondary {containOverflow
class="flex justify-center {inPage
? 'h-full'
: 'h-screen'} p-4 relative bg-surface-secondary {containOverflow
? 'overflow-hidden'
: 'overflow-auto'}"
class:items-center={centerVertically}
@@ -38,6 +38,7 @@
import { getContext, onDestroy, setContext, untrack } from 'svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -223,6 +224,11 @@
// (session pane, drawer, etc.) where the viewport stays wide.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
// stand down, since there is no room to run a flow beside its own name.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
)
const diffEnabled = $derived(customUi?.topBar?.diff != false)
// Nothing to compare against until a deployed version exists.
@@ -233,7 +239,7 @@
// The narrow bar (sessions) and the width-collapsed one have no room for a Diff
// button, so it moves into the menu ahead of Deployment History instead of
// dropping out of reach.
const diffInMenu = $derived(condensedHeader || compactTopbar)
const diffInMenu = $derived(condensedHeader || compactTopbar || phoneTopbar)
const diffMenuItems: Item[] = $derived(
diffEnabled && diffInMenu
? [
@@ -1682,7 +1688,7 @@
</Button>
</div>
{/if}
{#if !compactTopbar}
{#if !compactTopbar && !phoneTopbar}
{@render previewButtons()}
{/if}
@@ -5,6 +5,7 @@
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Button } from '$lib/components/common'
import AddDataTableWizard from '$lib/components/workspaceSettings/AddDataTableWizard.svelte'
import { useManagedInstances } from '$lib/components/workspaceSettings/utils.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
@@ -212,6 +213,7 @@
let appConnect: AppConnectDrawer | undefined = $state(undefined)
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
const managed = useManagedInstances(() => !!$superadmin)
const confirmationModal = createAsyncConfirmationModal()
let wizardOpen = $state(false)
let wizard = $state<AddDataTableWizard | undefined>(undefined)
@@ -1090,6 +1092,10 @@
existingDataTables={configuredNames}
onDone={() => void afterWizard()}
{customInstanceDbs}
externalInstanceDbs={managed.externalDbs}
externalInstanceAvailable={managed.externalAvailable}
instanceAvailable={managed.instanceAvailable}
refreshManagedInstances={managed.refresh}
{confirmationModal}
{defaultInstanceDbName}
/>
@@ -33,7 +33,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
item,
section,
afterName,
actingWorkspaceId
actingWorkspaceId,
narrow = false
}: {
item?: PageHeaderItem
section?: PageHeaderSection
@@ -42,6 +43,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
afterName?: Snippet
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
actingWorkspaceId?: string
/** The bar is short of room: the workspace part drops its names and keeps its marks. */
narrow?: boolean
} = $props()
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
@@ -119,6 +122,14 @@ same weight, size and icon size, so the line reads as one control rather than fo
// fork's own name inside, so the family's name is not painted as if it were the fork.
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
const forkChipClass = $derived(
twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)
)
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
const scopeKind = $derived(item?.path?.split('/')[0])
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
@@ -176,7 +187,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
included, in its own colour — leading home, and one chevron whose picker changes it. The
picker lists the families and expands one to reach its forks, so a fork needs no part of
its own here. -->
<div class="flex items-center min-w-0">
<div class={narrow ? 'flex items-center shrink-0' : 'flex items-center min-w-0'}>
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
boxes reacting to one pass of the pointer read as two controls fighting. -->
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
@@ -188,25 +199,33 @@ same weight, size and icon size, so the line reads as one control rather than fo
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it is
part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span
class={twMerge(
'flex items-center gap-1 min-w-0 px-1 rounded',
forkAccent &&
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
)}
style={forkAccent}
>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{#if narrow}
<!-- Short of room, the part keeps what it cannot be read without: the disc in the
workspace's own colour, the fork mark, and the environment. The names go — the
hover title still carries them, and the picker beside it names them all. -->
{@render workspaceDisc()}
{#if showFork}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
</span>
{/if}
{:else}
{@render envBadgeMark()}
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it
is part of, and either alone leaves the other to be guessed. -->
{@render slash()}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
{@render envBadgeMark()}
</span>
{:else}
{@render envBadgeMark()}
{/if}
{/if}
</svelte:element>
</div>
@@ -247,8 +266,15 @@ same weight, size and icon size, so the line reads as one control rather than fo
<div class="relative flex items-center min-w-0">
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
pointer and the popup after the click are affordance enough. -->
<!-- Capped: a draft's path is a 40-character uuid slug, and the trail yields so
grudgingly that one would push a page's buttons off the end of the bar. Past the
cap it truncates; the click still copies the path whole. -->
<button
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
class={twMerge(
SEGMENT,
'gap-1.5 hover:bg-transparent overflow-hidden',
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
)}
title="Copy path"
onclick={copyPath}
aria-label="Copy path {item.path}"
@@ -260,7 +286,12 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/if}
{#each pathLevels as level, i (i)}
{#if i > 0}{@render slash()}{/if}
<span class="truncate">{level}</span>
<!-- The last level absorbs the squeeze: the scopes above it are a couple of
characters each, and cutting them first leaves "m… / draft_a5b1…". -->
<span
class={i === pathLevels.length - 1 ? 'truncate' : 'truncate shrink-0 max-w-[8rem]'}
>{level}</span
>
{/each}
</button>
{#if afterName}{@render afterName()}{/if}
@@ -11,18 +11,24 @@ The row's height matches the sidebar's own header row, so the two read as one ba
import { navDetached } from './sidebar/navDetached.svelte'
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
import NavBreadcrumb from './NavBreadcrumb.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
import { pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte'
import ContextBridge from './ContextBridge.svelte'
import { afterNavigate } from '$app/navigation'
import { twMerge } from 'tailwind-merge'
let {
navHidden = false,
hideNavHandle = false,
panelled = false,
onUnpin
}: {
navHidden?: boolean
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
* along with this band — two handles for one gesture would be one too many. */
hideNavHandle?: boolean
/** The sidebar is a panel over the page rather than a rail beside it — detached, or a
* window too narrow to seat one. Either way this bar carries the switch that opens it. */
panelled?: boolean
/** Sends the band back behind its handle, on a page that owns the viewport. */
onUnpin?: () => void
} = $props()
@@ -32,10 +38,56 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const item = $derived(content?.item)
const section = $derived(content?.section)
// Below this, the bar is short enough that the workspace's name is the first thing worth
// giving up: it is the part of the trail the user changes least and the picker beside it still
// names every workspace.
const NARROW_BAR = 1000
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
// The band's edge is there to say "something is passing under me", so it is drawn only while
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
// sees every box under the band — which matters because most pages scroll inside their own
// (the audit table, a detail page's panes) and never move this layout's own scroller.
let scrolledUnder = $state(false)
/** Sub-pixel layout and a border or two, between a box's top and the content box's. */
const AT_THE_TOP_PX = 4
function onScrollCapture(e: Event) {
const el = e.target as HTMLElement | null
if (!el || el.nodeType !== 1) return
const box = document.getElementById('content')
if (!box || !box.contains(el)) return
// Only a box that starts where the content does has anything passing under the band. The
// audit page's list begins 285px lower, behind its own chart and column headers, and a
// select's option list floats wherever it opens: neither moves anything under the header.
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top
if (top > AT_THE_TOP_PX) return
scrolledUnder = el.scrollTop > 0
}
// A page left while scrolled would otherwise hand its edge to the next one, which may have
// nothing to scroll at all.
afterNavigate(() => (scrolledUnder = false))
</script>
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
{#if navDetached.val && !navHidden && !hideNavHandle}
<svelte:window onscrollcapture={onScrollCapture} />
<!-- The band draws its own bottom edge, and only while a page is scrolled under it: at the top of
a page there is nothing to divide, and pages that draw their own first line have dropped it —
two would stack. An inset shadow rather than a border: a border would make the row 45px and
every page below it would move a pixel the moment the edge appeared. -->
<div
data-page-header
bind:clientWidth={() => pageHeader.barWidth, (w) => pageHeader.setBarWidth(w)}
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
phone ? 'flex-wrap content-center min-h-11 py-1 pl-1 pr-2' : 'h-11 pl-2 pr-4',
scrolledUnder &&
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
{#if (navDetached.val || panelled) && !navHidden && !hideNavHandle}
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
detaching it happened — a control that hides the thing it sits on cannot also be the
@@ -75,13 +127,17 @@ The row's height matches the sidebar's own header row, so the two read as one ba
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
controls, they are what should narrow, not the name of where the user is. It still gives
way rather than pushing them off the bar once there is nothing left to take.
On a phone it stops yielding at 10rem, and that floor is what makes the bar wrap: the
page's buttons no longer fit beside a trail that wide, so they take the line below
instead of squeezing the name down to an ellipsis.
An embed (`navHidden`) gets the page's controls without the trail that would offer to
navigate the host's workspace. -->
{#if !navHidden}
<div class="flex min-w-0 shrink-[0.1]">
<div class={twMerge('flex min-w-0', phone ? 'shrink min-w-[10rem]' : 'shrink-[0.1]')}>
<NavBreadcrumb
{item}
{section}
{narrow}
afterName={content?.afterName}
actingWorkspaceId={content?.actingWorkspaceId}
/>
@@ -89,12 +145,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{/if}
{#if item && !navHidden && (item.summaryContent || item.summary)}
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
not another level of it. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
editor tells the reader what they can already see. -->
<div class="flex items-center gap-1 min-w-0">
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
<!-- A dot rather than a slash: the summary names the same item the path just located,
it is not another level of it. Inside this box so that a bar wrapping on a phone
carries it down with the summary instead of stranding it on the line above. -->
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
{#if item.summaryContent}
{@render item.summaryContent()}
{:else}
@@ -105,8 +162,16 @@ The row's height matches the sidebar's own header row, so the two read as one ba
{#if actions.length > 0}
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
here that can give width back, and it can only do that if this box may shrink. -->
<div class="ml-auto flex items-center gap-2 min-w-0 pl-4">
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<div
class={twMerge(
'ml-auto flex items-center',
phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0',
phone ? 'gap-1 pl-1' : 'gap-2 pl-4'
)}
>
{#each actions as entry, i (i)}
{#key entry.contexts}
<ContextBridge contexts={entry.contexts}>
@@ -16,6 +16,7 @@ the header, so nesting is decided by the caller rather than guessed from context
afterName,
actions,
actionsOrder,
actionsFlexible,
contexts,
actingWorkspaceId,
barRightInset,
@@ -29,6 +30,7 @@ the header, so nesting is decided by the caller rather than guessed from context
afterName,
actions,
actionsOrder,
actionsFlexible,
contexts,
actingWorkspaceId,
barRightInset,
+21 -12
View File
@@ -44,6 +44,7 @@
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import { pageHref, RUNS_PATH } from '$lib/components/sessions/previewPaths'
import { twMerge } from 'tailwind-merge'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
@@ -580,6 +581,7 @@
let manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve' = $state()
let resolutionNote = $state('')
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
</script>
{#snippet runsHint()}
@@ -594,8 +596,10 @@
{#snippet headerActions()}
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
so without it the badge butts against the first filter. -->
<div class="flex items-center pr-2">
so without it the badge butts against the first filter.
A phone's bar has room for the filter field and the timeframe and nothing else: the
counters are a glance, and the same numbers are on the chart below. -->
<div class="flex items-center pr-2 {phone ? 'hidden' : ''}">
<RunsQueue
success={filters.val.status ?? null}
{queue_count}
@@ -766,7 +770,10 @@
search field takes what is left, down to a width that still shows a filter chip. -->
<FilterSearchbar
class={twMerge(
'relative flex-1 min-w-[8rem]',
'relative flex-1',
// A phone's bar cannot spare 8rem before the timeframe beside it: the field gives back
// whatever is left over once the trail and the timeframe have theirs.
phone ? 'min-w-0' : 'min-w-[8rem]',
Object.keys(filters.val).length <= 3 ? 'max-w-[20rem]' : 'max-w-[26rem]',
ButtonType.UnifiedMinHeightClasses.sm
)}
@@ -789,13 +796,15 @@
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<OpenInSessionButton
source={{
page: () => pageHref(RUNS_PATH) + window.location.search,
workspaceId: $workspaceStore ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
<div class={phone ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => pageHref(RUNS_PATH) + window.location.search,
workspaceId: $workspaceStore ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
</div>
{/snippet}
<ConfirmationModal
@@ -865,10 +874,10 @@
<p>Page not available for operators</p>
</div>
{:else}
<div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}>
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
<!-- Everything that used to sit in a row above the graph — the page's name, its queue, the
filters and the timeframe — is in the page header. -->
<PageHeaderContent afterName={runsHint} actions={headerActions} />
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
@@ -13,7 +13,9 @@
unifiedSize = undefined,
variant = 'accent'
}: {
onSave: () => void | Promise<void>
/** Resolving to `false` means the save was called off (e.g. a confirmation was declined):
* nothing was written, so neither success nor error is shown. */
onSave: () => void | boolean | Promise<void | boolean>
disabled?: boolean
label?: string
size?: ButtonType.Size | undefined
@@ -40,7 +42,8 @@
clearStatusTimeout()
Promise.resolve(onSave())
.then(() => {
.then((result) => {
if (result === false) return
saveStatus = 'success'
statusTimeout = setTimeout(() => {
saveStatus = null
+240 -147
View File
@@ -94,6 +94,9 @@
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onDestroy, onMount, setContext, tick, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import EditableInput from './common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
import Badge from './common/badge/Badge.svelte'
import Modal from './common/modal/Modal.svelte'
@@ -161,12 +164,14 @@
loadedFromDraft = false,
othersDraftsCount = 0,
onOpenOthersDrafts,
condensedHeader = false
condensedHeader = false,
ownsPageHeader = false
}: ScriptBuilderProps = $props()
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
// In the page header the buttons ride a 44px band, which takes the same size.
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
@@ -196,7 +201,19 @@
// full group crowds the path into heavy truncation; ~900 is where the path
// keeps a usable width given the right group's natural ~440px.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900)
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
// the trail and the summary take ~560px before either truncates, and the full button group is
// ~515px — about 1100px between them, plus slack for a longer path than most.
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
@@ -219,7 +236,21 @@
function getCompactMenuItems(): Item[] {
const hasTags = (scriptWorkerTags?.length ?? 0) > 0
const isDraftOnly = savedScript?.no_deployed === true
return [
...(phoneTopbar && customUi?.topBar?.diff != false
? [
{
displayName: 'Diff',
icon: DiffIcon,
disabled: !savedScript || !diffDrawer || isDraftOnly,
action: () => openDiffDrawer()
}
]
: []),
...(phoneTopbar && customUi?.topBar?.settings != false
? [{ displayName: 'Settings', icon: Settings, action: () => (metadataOpen = true) }]
: []),
...(customUi?.topBar?.tagEdit != false && hasTags
? [
{
@@ -2245,164 +2276,226 @@
</Drawer>
<div class="flex flex-col h-full">
<div
bind:clientWidth={topbarWidth}
class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}"
>
<div class="flex gap-2 lg:gap-2 w-full items-center">
<div class="flex flex-row items-center gap-2 min-w-0 shrink">
<button
disabled={customUi?.topBar?.settings == false}
class="shrink-0"
onclick={async () => {
metadataOpen = true
}}
>
<LanguageIcon lang={script.language} size={condensedHeader ? 18 : 24} />
</button>
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{#if opWorkspace}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="script"
path={indicatorPath}
draftOnly={savedScript?.no_deployed === true}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
{/if}
{#if !condensedHeader}
{@const canOpenRuntime =
customUi?.topBar?.settings != false &&
customUi?.settingsPanel?.disableRuntime !== true}
<ScriptSettingsBadges
settings={script}
onclick={canOpenRuntime
? () => {
selectedTab = 'runtime'
metadataOpen = true
}
: undefined}
/>
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the script's path and
summary are the breadcrumb's, and its buttons are the header's actions. -->
<PageHeaderContent
item={{ kind: 'script', path: script.path, summaryContent: scriptSummary }}
afterName={scriptMarks}
actions={scriptHeaderActions}
/>
{:else}
<div
bind:clientWidth={topbarWidth}
class="flex items-center px-4 {condensedHeader ? 'h-9' : 'h-12'}"
>
<div class="flex gap-2 lg:gap-2 w-full items-center">
<div class="flex flex-row items-center gap-2 min-w-0 shrink">
{@render languageButton(condensedHeader ? 18 : 24)}
{#if customUi?.topBar?.path != false}
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
hidePath={condensedHeader}
workspaceId={autosaveWorkspace}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{@render autosaveIndicator()}
{#if !condensedHeader}
{@render settingsBadges()}
{/if}
</div>
<!-- Separator -->
<div class="flex-1"></div>
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
<!-- Separator -->
<div class="flex-1"></div>
{@render rightButtons()}
</div>
</div>
{/if}
<!-- Separator -->
<div class="flex-1"></div>
{#snippet scriptSummary()}
<!-- The summary stays editable where the editor's own bar had it; the path beside it is
the breadcrumb, which this editor renames from its settings drawer. -->
<EditableInput
value={script.summary ?? ''}
placeholder="Add a summary..."
editable={customUi?.topBar?.editableSummary != false}
commitOnInput
size="sm"
onSave={(v) => (script.summary = v.trim())}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
/>
{/snippet}
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{#snippet scriptMarks()}
<!-- Concurrency, cache, a dedicated worker: settings that hold whatever the editor shows,
so they belong with the script's name. The language icon the editor's own bar carried
does not come along — the code under it says which language this is. -->
{@render settingsBadges('pl-1.5')}
{/snippet}
{#snippet scriptHeaderActions()}
<!-- Saving state and other people's drafts: the editor's own bar carries them beside the
path, so the header has to carry them when it owns the bar — without them a
full-page editor saves with no word either way. -->
{@render autosaveIndicator()}
{#if $enterpriseLicense && initialPath != '' && !inSessionPane}
<Awareness />
{/if}
{@render rightButtons()}
{/snippet}
{#snippet languageButton(size: number)}
<button
disabled={customUi?.topBar?.settings == false}
class="shrink-0"
title="Settings"
onclick={async () => {
metadataOpen = true
}}
>
<LanguageIcon lang={script.language} {size} />
</button>
{/snippet}
{#snippet autosaveIndicator()}
{#if opWorkspace}
<AutosaveIndicator
workspace={opWorkspace}
itemKind="script"
path={indicatorPath}
draftOnly={savedScript?.no_deployed === true}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
{/if}
{/snippet}
{#snippet settingsBadges(className?: string)}
{@const canOpenRuntime =
customUi?.topBar?.settings != false && customUi?.settingsPanel?.disableRuntime !== true}
<ScriptSettingsBadges
class={className}
settings={script}
onclick={canOpenRuntime
? () => {
selectedTab = 'runtime'
metadataOpen = true
}
: undefined}
/>
{/snippet}
{#snippet rightButtons()}
{#snippet settingsButton()}
{#if customUi?.topBar?.settings != false}
<Button
aiId="script-builder-settings"
aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI."
variant="default"
unifiedSize={headerBtnSize}
on:click={() => (metadataOpen = true)}
startIcon={{ icon: Settings }}
iconOnly={compactTopbar}
title="Settings"
>
<span> Settings </span>
</Button>
{/if}
<!-- Separator -->
<div class="flex-1"></div>
{#snippet settingsButton()}
{#if customUi?.topBar?.settings != false}
<Button
aiId="script-builder-settings"
aiDescription="Script builder settings to configure metadata, runtime, triggers, and generated UI."
variant="default"
unifiedSize={headerBtnSize}
on:click={() => (metadataOpen = true)}
startIcon={{ icon: Settings }}
iconOnly={compactTopbar}
title="Settings"
>
<span> Settings </span>
</Button>
{/if}
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = savedScript?.no_deployed === true}
{@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly}
{@const diffTitle = isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = savedScript?.no_deployed === true}
{@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly}
{@const diffTitle = isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
never shows on hover. pointer-events-none on the button lets the hover
reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize={headerBtnSize}
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize={headerBtnSize}
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
iconOnly
title="More"
/>
{/snippet}
</DropdownV2>
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize={headerBtnSize}
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize={headerBtnSize}
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
iconOnly
title="More"
/>
{/snippet}
</DropdownV2>
{#if !phoneTopbar}
{@render diffButton()}
{@render settingsButton()}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if scriptWorkerTags}
{#if scriptWorkerTags?.length ?? 0 > 0}
<div class="max-w-[200px]">
<WorkerTagSelect
nullTag={script.language}
placeholder={customUi?.tagSelectPlaceholder}
bind:tag={script.tag}
size={headerBtnSize}
workspaceId={opWorkspace}
/>
</div>
{/if}
{/if}
{/if}
{@render settingsButton()}
{/if}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if scriptWorkerTags}
{#if scriptWorkerTags?.length ?? 0 > 0}
<div class="max-w-[200px]">
<WorkerTagSelect
nullTag={script.language}
placeholder={customUi?.tagSelectPlaceholder}
bind:tag={script.tag}
size={headerBtnSize}
workspaceId={opWorkspace}
/>
</div>
{/if}
{/if}
{/if}
{@render settingsButton()}
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
unifiedSize={headerBtnSize}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
</div>
</div>
<DeployButton
loading={!fullyLoaded}
{loadingSave}
unifiedSize={headerBtnSize}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
{/snippet}
{#if showPipelineHint}
<div
class="flex items-center gap-2 px-4 py-1 border-y bg-surface-secondary text-xs text-secondary"
class="flex items-center gap-2 px-4 py-1 border-b bg-surface-secondary text-xs text-secondary"
>
<Network size={14} class="shrink-0 text-tertiary" />
<span class="truncate">
@@ -1,4 +1,5 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Popover from '$lib/components/Popover.svelte'
import {
@@ -12,15 +13,18 @@
// (e.g. to open the settings drawer focused on that section).
onclick?: (key: string) => void
small?: boolean
/** On the row that holds the badges — so a script with none of these settings leaves no
* gap where they would have been. */
class?: string
}
let { settings, onclick, small = true }: Props = $props()
let { settings, onclick, small = true, class: className = '' }: Props = $props()
let badges = $derived(getActiveScriptSettingsBadges(settings))
</script>
{#if badges.length > 0}
<div class="flex flex-row flex-wrap gap-1 items-center">
<div class={twMerge('flex flex-row flex-wrap gap-1 items-center', className)}>
{#each badges as badge (badge.key)}
<!-- Icon-only chips to save space in crowded top bars; the label and current
value are shown in the hover popover. -->
@@ -5,6 +5,7 @@
import { goto } from '$app/navigation'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
// Surfaces pending drafts (scripts/flows/apps) for the current workspace and
// links to the compare page in draft mode. Mutually exclusive with
@@ -26,6 +27,7 @@
})
}
}
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
</script>
{#if !isFork && draftCount > 0}
@@ -35,12 +37,20 @@
{/if}
{#snippet draftsAction()}
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it. -->
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}>
Review & deploy drafts
</Button>
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it.
On a phone the sentence goes and the badge becomes the button: the count is the part that
has to be seen, and the bar has room for one thing. -->
{#if phone}
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare} title="Review & deploy drafts">
<Badge color="blue" small>{draftCount}</Badge>
</Button>
{:else}
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}>
Review & deploy drafts
</Button>
{/if}
{/snippet}
@@ -123,7 +123,13 @@
</script>
<InitializeComponent {id} />
<div class="w-full h-full overflow-auto {customComponent?.name ?? 'no_name'}">
<!-- The host stays mounted so the renderer can attach, but the user's code draws regardless of
render: hide it, or a not-yet-rendered component lands in its parent's layout flow. Inline so
CSS the bundle puts on its root class cannot override it. -->
<div
class="w-full h-full overflow-auto {customComponent?.name ?? 'no_name'}"
style:display={render ? undefined : 'none'}
>
<RunnableWrapper {outputs} render autoRefresh {componentInput} {id} bind:result>
{#if !loaded && render}
<Loader2 class="animate-spin" />
@@ -86,7 +86,8 @@
othersDraftsCount = 0,
onOpenOthersDrafts,
onRestore,
onDeploy
onDeploy,
ownsPageHeader = false
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
@@ -885,6 +886,7 @@
{#if !$userStore?.operator}
{#if $appStore}
<AppEditorHeader
{ownsPageHeader}
{newPath}
{newApp}
{labels}
@@ -914,7 +916,7 @@
onHideBottomPanel={() => hideBottomPanel()}
/>
{#if $mode === 'preview'}
<SplitPanesWrapper class="border-t">
<SplitPanesWrapper>
<div
class={twMerge(
'h-full w-full relative',
@@ -959,7 +961,7 @@
</div>
{/if}
<SplitPanesWrapper class="border-t">
<SplitPanesWrapper>
<Splitpanes id="o1" class="max-w-full overflow-hidden">
<Pane bind:size={leftPanelSize} minSize={5} maxSize={33}>
<div
@@ -52,6 +52,9 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
@@ -114,6 +117,8 @@
// the deploy wrote, for the next draft's fork base, and `head` what is deployed
// now, with its author and time.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
/** True for the route's own editor: its top bar becomes the page header. */
ownsPageHeader?: boolean
}
let {
@@ -142,7 +147,8 @@
othersDraftsCount = 0,
onOpenOthersDrafts,
onRestore,
onDeploy
onDeploy,
ownsPageHeader = false
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -223,6 +229,14 @@
// the preview to the deployed version.
const inSessionPane = !!getContext('aiChatManager')
// The header's buttons render under the page header, not under this component, so the contexts
// they look up have to travel with them. The app's own two carry everything the canvas toggles,
// the panel buttons, Debug runs and the preview switch read.
const headerContexts = new Map<any, any>([
['AppViewerContext', getContext('AppViewerContext')],
['AppEditorContext', getContext('AppEditorContext')]
])
const loading = $state({
publish: false,
save: false
@@ -243,7 +257,19 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
// with the summary takes ~430px of the same row.
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
const compactTopbar = $derived.by(() => {
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
return w > 0 && w < compactBelow
})
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
// editor/preview switch join the menu compact already shows.
const phoneTopbar = $derived(
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
)
function closeSaveDrawer() {
saveDrawerOpen = false
@@ -913,141 +939,191 @@
<AppReportsDrawer bind:open={appReportingDrawerOpen} appPath={$appPath ?? ''} />
<div
bind:clientWidth={topbarWidth}
class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0"
>
<!-- Identity block shrinks/truncates first (min-w-0) so the cloud indicator
{#if ownsPageHeader}
<!-- The editor's own top bar is the page header on this route: the app's path and summary are
the breadcrumb's, and everything else the bar carried rides along as the header's actions. -->
<PageHeaderContent
item={{ kind: 'app', path: $appPath || newPath || undefined, summaryContent: appSummary }}
actions={appHeaderActions}
contexts={headerContexts}
/>
{:else}
<div
bind:clientWidth={topbarWidth}
class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto max-h-12 h-12 shrink-0"
>
<!-- Identity block shrinks/truncates first (min-w-0) so the cloud indicator
and the pinned action groups (shrink-0 below) stay visible on narrow
widths instead of the breadcrumb overflowing and hiding them. Kept at
min-w-0 (not flex-1) so justify-between still positions the panel-hide
buttons between the identity and the actions. -->
<div class="flex flex-row gap-2 items-center min-w-0">
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={$summary}
bind:path={newEditedPath}
savedPath={$appPath || newPath || undefined}
kind="app"
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
</div>
<div class="flex gap-2 shrink-0 {compactTopbar ? 'hidden' : ''}">
{#if $app}
<ToggleButtonGroup
selected={$app.fullscreen ? 'true' : 'false'}
on:selected={({ detail }) => {
$app.fullscreen = detail === 'true'
}}
>
{#snippet children({ item })}
<ToggleButton
icon={AlignHorizontalSpaceAround}
iconOnly
value={'false'}
tooltip="The max width is 1168px and the content stay centered instead of taking the full page width"
{item}
size="md"
/>
<ToggleButton
tooltip="The width is of the app if the full width of its container"
icon={Expand}
iconOnly
value={'true'}
{item}
size="md"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if $app}
<ToggleButtonGroup
on:selected={({ detail }) => {
const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined
setTheme(theme)
}}
selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'}
>
{#snippet children({ item })}
<ToggleButton
icon={SunMoon}
iconOnly
value={'auto'}
tooltip="The app mode between dark/light is automatic"
{item}
size="md"
/>
<ToggleButton
icon={Sun}
iconOnly
value={'sun'}
tooltip="Force light mode"
{item}
size="md"
/>
<ToggleButton
tooltip="Force dark mode"
icon={Moon}
iconOnly
value={'dark'}
{item}
size="md"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
<div class="flex flex-row gap-2">
<ToggleButtonGroup bind:selected={$breakpoint}>
{#snippet children({ item })}
<ToggleButton
tooltip="Computer View"
icon={Laptop2}
iconOnly
value={'lg'}
{item}
size="md"
/>
<ToggleButton
tooltip="Mobile View"
icon={Smartphone}
iconOnly
value={'sm'}
{item}
size="md"
/>
{#if $breakpoint === 'sm'}
<Toggle
size="xs"
options={{
right: 'Enable mobile view for smaller screens',
rightTooltip:
'Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view'
}}
textClass="text-2xs whitespace-nowrap white !w-full"
bind:checked={$app.mobileViewOnSmallerScreens}
class="flex flex-row px-2 items-center"
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
</div>
{#if $workspaceStore}
<div class="ml-4">
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="app"
path={userDraftPath}
draftOnly={newApp}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
<div class="flex flex-row gap-2 items-center min-w-0">
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={$summary}
bind:path={newEditedPath}
savedPath={$appPath || newPath || undefined}
kind="app"
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
</div>
{/if}
</div>
{@render canvasToggles()}
{@render autosaveIndicator()}
</div>
{@render panelButtons()}
{@render awarenessMark()}
{@render appActions()}
</div>
{/if}
{#snippet appSummary()}
<!-- The summary stays editable where the editor's own bar had it; the path beside it is the
breadcrumb, which this editor renames from its deploy drawer. -->
<EditableInput
value={$summary ?? ''}
placeholder="Add a summary..."
commitOnInput
size="sm"
onSave={(v) => ($summary = v.trim())}
textClass="text-xs font-medium text-emphasis leading-tight"
class="max-w-full min-w-0"
/>
{/snippet}
{#snippet appHeaderActions()}
<!-- The bar's own order kept: what the canvas looks like, how the draft is doing, which panels
are open, then what to do with the app. -->
{@render canvasToggles()}
{@render autosaveIndicator()}
{#if !phoneTopbar}
{@render panelButtons()}
{@render awarenessMark()}
{/if}
{@render appActions()}
{/snippet}
{#snippet canvasToggles()}
<div class="flex gap-2 shrink-0 {compactTopbar ? 'hidden' : ''}">
{#if $app}
<ToggleButtonGroup
selected={$app.fullscreen ? 'true' : 'false'}
on:selected={({ detail }) => {
$app.fullscreen = detail === 'true'
}}
>
{#snippet children({ item })}
<ToggleButton
icon={AlignHorizontalSpaceAround}
iconOnly
value={'false'}
tooltip="The max width is 1168px and the content stay centered instead of taking the full page width"
{item}
size="md"
/>
<ToggleButton
tooltip="The width is of the app if the full width of its container"
icon={Expand}
iconOnly
value={'true'}
{item}
size="md"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if $app}
<ToggleButtonGroup
on:selected={({ detail }) => {
const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined
setTheme(theme)
}}
selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'}
>
{#snippet children({ item })}
<ToggleButton
icon={SunMoon}
iconOnly
value={'auto'}
tooltip="The app mode between dark/light is automatic"
{item}
size="md"
/>
<ToggleButton
icon={Sun}
iconOnly
value={'sun'}
tooltip="Force light mode"
{item}
size="md"
/>
<ToggleButton
tooltip="Force dark mode"
icon={Moon}
iconOnly
value={'dark'}
{item}
size="md"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
<div class="flex flex-row gap-2">
<ToggleButtonGroup bind:selected={$breakpoint}>
{#snippet children({ item })}
<ToggleButton
tooltip="Computer View"
icon={Laptop2}
iconOnly
value={'lg'}
{item}
size="md"
/>
<ToggleButton
tooltip="Mobile View"
icon={Smartphone}
iconOnly
value={'sm'}
{item}
size="md"
/>
{#if $breakpoint === 'sm'}
<Toggle
size="xs"
options={{
right: 'Enable mobile view for smaller screens',
rightTooltip:
'Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view'
}}
textClass="text-2xs whitespace-nowrap white !w-full"
bind:checked={$app.mobileViewOnSmallerScreens}
class="flex flex-row px-2 items-center"
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
</div>
{/snippet}
{#snippet autosaveIndicator()}
{#if $workspaceStore}
<div class="ml-4">
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="app"
path={userDraftPath}
draftOnly={newApp}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
/>
</div>
{/if}
{/snippet}
{#snippet panelButtons()}
{#if $mode !== 'preview'}
<div class="flex gap-1 shrink-0">
<HideButton
@@ -1085,11 +1161,17 @@
/>
</div>
{/if}
{/snippet}
{#snippet awarenessMark()}
{#if $enterpriseLicense && $appPath != '' && !inSessionPane}
<div class="shrink-0">
<Awareness />
</div>
{/if}
{/snippet}
{#snippet appActions()}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
<Dropdown items={moreItems} />
@@ -1131,14 +1213,20 @@
</div>
</Button>
</div>
<AppExportButton bind:this={appExport} />
<PreviewToggle loading={loading.save} />
<!-- The export is in the menu on a phone; the preview switch keeps its place there, since
seeing the app is most of what an app editor is good for on one. -->
<div class={phoneTopbar ? 'hidden' : 'contents'}>
<AppExportButton bind:this={appExport} />
</div>
<PreviewToggle loading={loading.save} iconOnly={phoneTopbar} />
<Button
variant="accent"
loading={loading.save}
startIcon={{ icon: Save }}
on:click={save}
unifiedSize="md"
iconOnly={phoneTopbar}
title="Deploy"
unifiedSize={phoneTopbar ? 'sm' : 'md'}
dropdownItems={$appPath != ''
? () => [
{
@@ -1163,4 +1251,4 @@
Deploy
</Button>
</div>
</div>
{/snippet}
@@ -13,7 +13,6 @@
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
@@ -126,15 +125,7 @@
})
</script>
<!-- The line separating the app from the band exists only while the band does: unpinned there is
nothing above the app to separate it from. It goes on the app's wrapper rather than on the
frame, which the header-less public page shares — and as an inset shadow rather than a border,
which would take a pixel off an app sized to fill the page exactly and leave it scrolling. -->
<div
class="h-full {appHeaderPinned.val
? 'shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]'
: ''}"
>
<div class="h-full">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
@@ -8,9 +8,11 @@
interface Props {
loading?: boolean
/** Drops the two labels, for a bar with no room for them: the pen and the eye carry it. */
iconOnly?: boolean
}
let { loading = false }: Props = $props()
let { loading = false, iconOnly = false }: Props = $props()
const { mode, jobs, jobsById } = getContext<AppViewerContext>('AppViewerContext')
</script>
@@ -24,7 +26,8 @@
>
{#snippet children({ item })}
<ToggleButton
label="Editor"
label={iconOnly ? undefined : 'Editor'}
tooltip={iconOnly ? 'Editor mode' : undefined}
value="dnd"
icon={Pen}
disabled={loading}
@@ -33,7 +36,7 @@
{item}
/>
<ToggleButton
label="Preview"
label={iconOnly ? undefined : 'Preview'}
value="preview"
icon={Eye}
tooltip="Preview mode"
@@ -188,6 +188,8 @@ export interface AppEditorProps {
// with its author and time. `version` and `head` differ when another deploy landed
// beside this one, which is when the out-of-date prompt has something to say.
onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void
/** True for the route's own editor: its top bar becomes the page header. */
ownsPageHeader?: boolean
}
export type App = {
@@ -59,7 +59,8 @@ import { getEffectiveModelContextWindow } from '../modelConfig'
import {
getCompactionSummaryPrompt,
formatCompactSummary,
buildSummaryMessageContent
buildSummaryMessageContent,
toolExchangesAsText
} from './compactionPrompt'
import { dfs } from '$lib/components/flows/previousResults'
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
@@ -88,10 +89,11 @@ import {
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { fromStore, get } from 'svelte/store'
import type { ProviderToolSummary } from './agentContext'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
@@ -109,7 +111,12 @@ import {
import type { Selection } from 'monaco-editor'
import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
import {
closeInterruptedToolBatch,
runChatLoop,
sendsWebSearch,
truncateToToolPairedPrefix
} from './chatLoop'
import { FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE, OutputTokenLimitError } from './outputTokenLimit'
import { sanitizeToolCallArguments } from './toolCallArguments'
import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage'
@@ -171,7 +178,10 @@ import { PlanModeController, type PlanModeHost } from './planModeController.svel
// cannot see — the upcoming completion and tool results, system-prompt/tool-
// schema changes from mode switches, and the estimate's chars/4 error.
const COMPACTION_TRIGGER_RATIO = 0.8
const COMPACTION_TARGET_RATIO = 0.7
// The gap below the trigger is what one compaction buys: each summarization request
// carries most of the window, and a target close to the trigger spends that on a few
// turns of room and summarizes its own previous summary again soon after.
const COMPACTION_TARGET_RATIO = 0.5
// How often a running turn is offered to the mid-turn checkpoint (see
// sendRequest). The whole transcript is rewritten on each accepted checkpoint,
// so this bounds the write rate; it also bounds how much of a turn a tab that
@@ -485,7 +495,7 @@ export class AIChatManager implements ChatViewHost {
abortController: AbortController | undefined = undefined
inlineAbortController: AbortController | undefined = undefined
// Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it)
skipResponsesApi = false
skipResponsesApi = $state(false)
mode = $state<AIMode>(AIMode.NAVIGATOR)
pipelineAiChatHelpers = $state<PipelineAIChatHelpers | undefined>(undefined)
@@ -746,6 +756,28 @@ export class AIChatManager implements ChatViewHost {
* transitions, whichever one is offered right now. A reference answer, so flipping the
* autonomy picker must not change it. */
availableTools: Tool<any>[] = $derived(this.#shipped(this.planMode.availableTools))
#copilotInfo = fromStore(copilotInfo)
#sessionModel = fromStore(copilotSessionModel)
/** Capabilities the provider runs on its own servers, attached by the adapter at send
* time. Never part of `tools`: the request path would try to dispatch them. The two
* `void` reads only subscribe: the model helpers read through `get()`, which tracks
* nothing, so a change of model or of the workspace's AI config would otherwise not
* re-derive. A workspace switch re-derives through the AI config it loads. */
get providerTools(): ProviderToolSummary[] {
void this.#copilotInfo.current
void this.#sessionModel.current
const model = tryGetCurrentModel()
if (!model || (model.provider === 'openai' && this.skipResponsesApi)) return []
const enabled = isWebSearchEnabledForProvider(model.provider)
return sendsWebSearch(this.operatingWorkspace ?? '', model, enabled)
? [
{
name: 'Web search',
description: "Runs on the provider's servers, no confirmation."
}
]
: []
}
helpers = $state<any | undefined>(undefined)
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
@@ -1552,7 +1584,7 @@ export class AIChatManager implements ChatViewHost {
[
// Strip image blobs from the summarizer input — the summary text stands in
// for them, so re-sending base64 to the summarizer only wastes tokens.
...stripImagePartsFromMessages(sanitizeToolCallArguments(prefix)),
...toolExchangesAsText(stripImagePartsFromMessages(sanitizeToolCallArguments(prefix))),
{ role: 'user', content: getCompactionSummaryPrompt() }
],
abortController,
@@ -127,6 +127,7 @@ vi.mock('$lib/aiStore', () => ({
// `sendRequest` reads it before anything else, so a test that goes through a real turn
// rather than driving the manager directly needs it present and enabled.
copilotInfo: writable({ enabled: true, workspaceDisabled: false, aiModels: [] }),
copilotSessionModel: writable(undefined),
getCurrentModel: mocks.getCurrentModel,
tryGetCurrentModel: mocks.tryGetCurrentModel,
getCombinedCustomPrompt: () => '',
@@ -142,7 +143,8 @@ vi.mock('../lib', () => ({
getOpenaiClient: mocks.getOpenaiClient,
getAnthropicClient: mocks.getAnthropicClient
},
getNonStreamingCompletion: mocks.getNonStreamingCompletion
getNonStreamingCompletion: mocks.getNonStreamingCompletion,
providerSupportsWebSearch: (provider: string) => provider === 'openai' || provider === 'anthropic'
}))
vi.mock('./api/apiTools', () => ({
@@ -590,6 +592,27 @@ describe('AIChatManager request errors', () => {
})
})
describe('AIChatManager provider tools', () => {
it('lists web search exactly when a turn would attach it', () => {
const manager = new AIChatManager()
const withModel = (provider: string) =>
mocks.tryGetCurrentModel.mockReturnValue({ provider, model: 'm' })
withModel('anthropic')
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
expect(manager.providerTools.map((t) => t.name)).toEqual(['Web search'])
// Never mixed into what the request path dispatches.
expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('web_search')
mocks.isWebSearchEnabledForProvider.mockReturnValue(false)
expect(manager.providerTools).toEqual([])
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
withModel('mistral')
expect(manager.providerTools).toEqual([])
})
})
describe('AIChatManager global skills', () => {
const model = { provider: 'openai', model: 'gpt-4o' }
@@ -3061,14 +3084,14 @@ describe('AIChatManager context compaction', () => {
it('compacts the stored history before sending once reported usage projects over the trigger', async () => {
const manager = new AIChatManager()
manager.messages = [
{ role: 'user', content: 'a'.repeat(400_000) }, // ~100k estimated tokens
{ role: 'assistant', content: 'b'.repeat(400_000) }, // ~100k
{ role: 'user', content: 'a'.repeat(1_200_000) }, // ~300k estimated tokens
{ role: 'assistant', content: 'b'.repeat(1_200_000) }, // ~300k
{ role: 'user', content: 'c'.repeat(400) },
{ role: 'assistant', content: 'd'.repeat(400) }
]
// Provider fact: 850k used. Projected past the 800k trigger, so ~150k
// must be freed to come back to the 700k target — the first user +
// assistant pair (~200k estimated).
// Provider fact: 850k used. Projected past the 800k trigger, so ~350k
// must be freed to come back to the 500k target — the first user +
// assistant pair (~600k estimated).
manager.contextUsage = 850_000
manager.instructions = 'next question'
const saveChat = vi.spyOn(manager.historyManager, 'saveChat')
@@ -3086,7 +3109,7 @@ describe('AIChatManager context compaction', () => {
// compaction-time save) so a rolled-back turn keeps a consistent value
// 4th arg: the modified-items mask rides on every save (undefined here —
// this bare manager never initialised tracking).
expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000, undefined)
expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 250_000, undefined)
// At commit, the no-report turn clears the stored value; the readable
// number falls back to estimating the now-tiny compacted history
expect(manager.contextUsage).toBeUndefined()
@@ -9,8 +9,6 @@
import { copilotInfo } from '$lib/aiStore'
import { aiChatManager } from './AIChatManager.svelte'
import { onDestroy } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Menu } from 'lucide-svelte'
import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte'
interface Props {
@@ -20,7 +18,6 @@
transitionClass?: string
isMobile?: boolean
children: any
onMenuOpen?: () => void
disableAi?: boolean
// Whether this layout loads the workspace AI config. It gates far more than
// the docked pane (code completion, metadata generation, the "open in AI
@@ -40,7 +37,6 @@
transitionClass = 'transition-all ease-in-out duration-200',
isMobile = false,
children,
onMenuOpen,
disableAi,
loadAiConfig = true,
showSessionsBetaBanner = false
@@ -81,24 +77,8 @@
aiChatManager.cancel('aiChatLayout destroyed')
historyManager.close()
})
</script>
{#snippet burgerRow()}
<div
class={classNames(
'py-0.5 px-4 sm:px-4 shadow-sm max-w-7xl md:hidden justify-start flex',
noBorder ? 'hidden' : ''
)}
>
<Button
variant="subtle"
unifiedSize="lg"
onClick={() => onMenuOpen?.()}
startIcon={{ icon: Menu }}
iconOnly
/>
</div>
{/snippet}
</script>
{#if !disableAi}
<CreatedResourceActionDrawers />
@@ -111,7 +91,6 @@
>
<main class="flex-1 flex flex-col min-h-0">
<div class="relative w-full flex-1 flex flex-col min-h-0">
{@render burgerRow()}
<div class="flex-1 min-h-0">
{@render children?.()}
</div>
@@ -138,12 +117,14 @@
{/if}
</Splitpanes>
{:else}
<!-- The scroll lives here, below the page header, exactly as it does in the docked-chat layout
above: a page taller than the window scrolls inside this box, so the band stays on screen
instead of leaving with the content. -->
<div
id="content"
class={classNames('flex-1 min-h-0 flex flex-col', transitionClass)}
class={classNames('flex-1 min-h-0 flex flex-col overflow-y-auto', transitionClass)}
style:padding-left="{contentPadLeft}rem"
>
{@render burgerRow()}
<div class="flex-1 min-h-0 flex flex-col">
{@render children?.()}
</div>
@@ -110,11 +110,12 @@ callers that already know which section they mean, such as the "+" menu's Manage
// The session's capability surface, not this turn's: the heading answers what the
// assistant can call here, so it must not shift when the autonomy picker does.
let tools = $derived(summarizeTools(aiChatManager.availableTools))
let providerTools = $derived(aiChatManager.providerTools)
// `SidebarNavigation`'s item shape, which is what the instance and workspace settings
// navs are built from too.
let sections = $derived([
{ id: 'tools', label: 'Tools', icon: Boxes, count: tools.length },
{ id: 'tools', label: 'Tools', icon: Boxes, count: tools.length + providerTools.length },
{ id: 'skills', label: 'Skills', icon: BookOpen, count: skillCount },
{ id: 'instructions', label: 'Instructions', icon: ScrollText },
{ id: 'mcp', label: 'MCP connections', icon: Plug, count: mcpCount },
@@ -227,7 +228,12 @@ callers that already know which section they mean, such as the "+" menu's Manage
own their own scrolling — each is a list and a detail page laid over each
other — so only Instructions scrolls here. -->
<div class="{section === 'tools' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantToolsSection {tools} active={section === 'tools'} bind:blocksClose={toolsBusy} />
<AssistantToolsSection
{tools}
{providerTools}
active={section === 'tools'}
bind:blocksClose={toolsBusy}
/>
</div>
<!-- Skills owns its own scrolling: its list and its editor are PagedContent pages
laid over each other, and each keeps a scroll position of its own. -->
@@ -2,8 +2,10 @@
@component
The Tools section of the assistant settings modal: every tool definition this chat
sends with each turn. The list carries the model-facing name and description; opening
one shows the description in full and the arguments it takes. Read-only — tools are not
individually switchable, they follow the mode and the connected servers.
one shows the description in full and the arguments it takes. Below them, apart, the
capabilities the provider runs itself (native web search): they carry no definition of
ours, so they have no detail page. Read-only — tools are not individually switchable,
they follow the mode, the connected servers and the workspace's AI settings.
-->
<script lang="ts">
import { Button, ListRow, Section } from '$lib/components/common'
@@ -13,14 +15,16 @@ individually switchable, they follow the mode and the connected servers.
import SearchItems from '$lib/components/SearchItems.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ArrowLeft } from 'lucide-svelte'
import type { ToolSummary } from './agentContext'
import type { ProviderToolSummary, ToolSummary } from './agentContext'
let {
tools,
providerTools,
active,
blocksClose = $bindable()
}: {
tools: ToolSummary[]
providerTools: ProviderToolSummary[]
/** Whether this is the panel on screen. Gates the detail page's build, which pulls
* in the schema table and its syntax highlighter. */
active: boolean
@@ -45,6 +49,11 @@ individually switchable, they follow the mode and the connected servers.
const rowDomId = (index: number) => `assistant-tool-row-${index}`
let searching = $derived(filter.trim().length > 0)
// Too few to rank: a plain substring match over what the row shows.
let providerRows = $derived.by(() => {
const needle = filter.trim().toLowerCase()
return providerTools.filter((t) => `${t.name} ${t.description}`.toLowerCase().includes(needle))
})
// Name matches first, then the tools that only matched on what they do. Within each
// half the order is the one uFuzzy ranked.
let rows: { tool: ToolSummary; name?: string; description?: string }[] = $derived.by(() => {
@@ -138,7 +147,7 @@ individually switchable, they follow the mode and the connected servers.
>
<Section
label="Tools"
description="What the assistant can call in this session: the built-in tools, plus whatever the connected MCP servers expose."
description="What the assistant can call in this session: the built-in tools, whatever the connected MCP servers expose, and what the model's provider runs itself."
>
<!-- Sticks to the top of the scrolling panel so a 70-row list stays searchable. -->
<div class="sticky top-0 z-10 bg-surface pb-2">
@@ -148,9 +157,11 @@ individually switchable, they follow the mode and the connected servers.
inputProps={{ placeholder: 'Search tools', id: SEARCH_INPUT_ID }}
/>
</div>
{#if rows.length === 0}
{#if rows.length === 0 && providerRows.length === 0}
<div class="py-2 text-xs text-hint">
{tools.length === 0 ? 'This chat carries no tools.' : 'No tool matches this search.'}
{tools.length === 0 && providerTools.length === 0
? 'This chat carries no tools.'
: 'No tool matches this search.'}
</div>
{:else}
<!-- Borderless rows on their own hover, the shape the resource-type picker uses:
@@ -175,6 +186,20 @@ individually switchable, they follow the mode and the connected servers.
/>
{/each}
</div>
{#if providerRows.length > 0}
<div
class="{rows.length > 0 ? 'mt-4' : ''} px-3 pb-1 text-xs font-semibold text-emphasis"
>
Provided by the model
</div>
<div class="flex flex-col gap-0.5">
{#each providerRows as tool (tool.name)}
{#snippet title()}<span class="truncate leading-5">{tool.name}</span>{/snippet}
{#snippet subtitle()}{tool.description}{/snippet}
<ListRow {title} {subtitle} />
{/each}
</div>
{/if}
{/if}
</Section>
</div>
@@ -208,9 +208,12 @@
// Keyed by call id: a bare flag would carry one card's collapse onto the next message
// reusing this instance. Open by default, since the run is what was asked for —
// except for an inspection, which is usually a step in the reasoning rather than
// the answer, and which pays a fetch for being opened.
// the answer, and which pays a fetch for being opened. A run cancelled before it started
// is closed too: the row already says Cancelled, and there is no run to show.
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : !inspected)
const expanded = $derived(
toggled?.id === message.tool_call_id ? toggled.open : !inspected && (ran || !canceled)
)
$effect(() => {
const target = inspected
@@ -12,6 +12,9 @@ export type ToolSummary = {
parameters: Record<string, any>
}
/** A capability the provider runs itself: no arguments schema, nothing dispatched here. */
export type ProviderToolSummary = Pick<ToolSummary, 'name' | 'description'>
/** Tool definitions as the modal lists them: name-sorted, so a list of dozens is
* scannable and stays put as the set changes between turns. */
export function summarizeTools(tools: readonly Tool<any>[]): ToolSummary[] {
@@ -1,4 +1,5 @@
import OpenAI from 'openai'
import { SvelteSet } from 'svelte/reactivity'
import Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
@@ -157,7 +158,9 @@ export function closeInterruptedToolBatch(
return [...paired, batch, ...answers, ...interrupted]
}
const unsupportedWebSearchCache = new Set<string>()
// Reactive so the assistant settings modal stops listing web search the moment a
// provider rejects it.
const unsupportedWebSearchCache = new SvelteSet<string>()
const WEB_SEARCH_UNAVAILABLE_STATUS_CODES = new Set([400, 403, 404])
// Reasoning-summary availability is an org-level property of the provider
@@ -176,6 +179,20 @@ function getWebSearchCacheKey(workspace: string, modelProvider: ReasoningProvide
return [workspace, modelProvider.provider, modelProvider.model].join(':')
}
/** Whether a turn attaches the provider's native web search. `enabled` is the workspace's
* toggle for the provider. */
export function sendsWebSearch(
workspace: string,
modelProvider: ReasoningProviderModel,
enabled: boolean
): boolean {
return (
enabled &&
providerSupportsWebSearch(modelProvider.provider) &&
!unsupportedWebSearchCache.has(getWebSearchCacheKey(workspace, modelProvider))
)
}
function getReasoningSummaryCacheKey(
workspace: string,
modelProvider: ReasoningProviderModel
@@ -399,10 +416,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
const modelProvider = config.modelProvider
iterationModel = modelProvider
const webSearchCacheKey = getWebSearchCacheKey(workspace, modelProvider)
const webSearch =
(config.webSearch ?? true) &&
providerSupportsWebSearch(modelProvider.provider) &&
!unsupportedWebSearchCache.has(webSearchCacheKey)
const webSearch = sendsWebSearch(workspace, modelProvider, config.webSearch ?? true)
if (onBeforeIteration) {
await onBeforeIteration(modelProvider)
@@ -2,9 +2,38 @@ import { describe, expect, it } from 'vitest'
import {
buildSummaryMessageContent,
formatCompactSummary,
getCompactionSummaryPrompt
getCompactionSummaryPrompt,
toolExchangesAsText
} from './compactionPrompt'
describe('toolExchangesAsText', () => {
it('renders tool calls and results as plain text messages', () => {
const sent = toolExchangesAsText([
{ role: 'user', content: 'weather?' },
{
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Paris"}' }
}
]
},
{ role: 'tool', tool_call_id: 'call_1', content: 'sunny' },
{ role: 'assistant', content: 'It is sunny.' }
])
expect(sent.every((m) => m.role !== 'tool' && !('tool_calls' in m))).toBe(true)
expect(sent[1]).toEqual({
role: 'assistant',
content: '[Called tool `get_weather` with arguments: {"city":"Paris"}]'
})
expect(sent[2]).toEqual({ role: 'user', content: '[Result of tool `get_weather`: sunny]' })
expect(sent[3]).toEqual({ role: 'assistant', content: 'It is sunny.' })
})
})
describe('formatCompactSummary', () => {
it('strips the analysis scratchpad and unwraps the summary block', () => {
const raw = `<analysis>
@@ -65,6 +94,15 @@ chronological thinking the model should not keep
expect(formatted).not.toContain('before output')
})
it('keeps the whole summary when it quotes its own tags', () => {
const raw = `<analysis>notes</analysis>
<summary>1. The user asked for an <analysis> block then a <summary></summary> block.
2. Work continued.</summary>`
expect(formatCompactSummary(raw)).toBe(
'1. The user asked for an block then a block.\n2. Work continued.'
)
})
it('strips every analysis block, not just the first, when the summary is untagged', () => {
const raw = '<analysis>first</analysis>\nkept one\n<analysis>second</analysis>\nkept two'
const formatted = formatCompactSummary(raw)
@@ -1,3 +1,5 @@
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
// Summary-based compaction: when a conversation approaches the model's context
// window, the older prefix is replaced by an LLM-generated structured summary
// while the recent tail is kept verbatim. The summary precedes the kept tail,
@@ -24,6 +26,8 @@ const NO_TOOLS_TRAILER =
// strips before the summary reaches context.
const SUMMARY_PROMPT = `Your task is to create a detailed summary of the conversation so far. This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after it (you do not see them here). Summarize thoroughly so that someone reading only your summary and then the newer messages can fully understand what happened and continue the work without losing context.
The message you are reading now is an instruction, not part of the conversation. Summarize only the messages above it: do not describe this instruction, do not list it among the user's messages, pending tasks or current work, and do not mention the <analysis> or <summary> tags inside your summary.
This is a conversation with Windmill's global workspace assistant. It inspects workspace items and authors them as per-user drafts — scripts, flows, apps, resources, variables, triggers, and schedules — then deploys those drafts and test-runs scripts and flows. It works with items by their workspace path (e.g. \`u/alice/sync_orders\`, \`f/team/my_flow\`); it does NOT edit files on a filesystem. Frame the summary in those terms.
Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts. In your analysis:
@@ -105,7 +109,9 @@ export function formatCompactSummary(raw: string): string {
// real summary boundary.
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '')
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i)
// Greedy to the last closer: the summary describes the instruction that asked for
// it, tags included, and stopping at a quoted </summary> cuts it off mid-sentence.
const summaryMatch = formatted.match(/<summary>([\s\S]*)<\/summary>/i)
if (summaryMatch) {
formatted = (summaryMatch[1] ?? '').trim()
} else {
@@ -125,6 +131,34 @@ export function formatCompactSummary(raw: string): string {
return formatted.replace(/\n{3,}/g, '\n\n').trim()
}
/**
* The prefix with every tool exchange rendered as text. The summarization request
* carries no tool definitions, and Bedrock rejects tool-use and tool-result blocks
* that arrive without them; the summary only needs what was called and what came back.
*/
export function toolExchangesAsText(
messages: ChatCompletionMessageParam[]
): ChatCompletionMessageParam[] {
const toolNames = new Map<string, string>()
return messages.map((m) => {
if (m.role === 'assistant' && m.tool_calls?.length) {
const lines = m.tool_calls.map((t) => {
if (t.type !== 'function') return `[Called tool ${t.type}]`
toolNames.set(t.id, t.function.name)
return `[Called tool \`${t.function.name}\` with arguments: ${t.function.arguments}]`
})
const text = typeof m.content === 'string' && m.content ? m.content + '\n\n' : ''
return { role: 'assistant', content: text + lines.join('\n') }
}
if (m.role === 'tool') {
const name = toolNames.get(m.tool_call_id) ?? 'unknown'
const result = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
return { role: 'user', content: `[Result of tool \`${name}\`: ${result}]` }
}
return m
})
}
/**
* Wraps a formatted summary as the content of the user message that replaces
* the summarized prefix in the conversation.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -53,8 +53,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte'
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
import {
FRAMEWORK_TEMPLATES,
STARTER_RUNNABLE,
STARTER_RUNNABLE_KEY,
STARTER_RUNNABLES,
type FrameworkKey
} from '$lib/components/raw_apps/templates'
import {
@@ -1189,7 +1188,13 @@ const getRuntimeLogsSchema = z.object({
.min(1)
.max(100)
.optional()
.describe('How many of the most recent runtime log lines to return. Defaults to 10.')
.describe('How many of the most recent runtime log lines to return. Defaults to 10.'),
app_path: z
.string()
.optional()
.describe(
'Path of the raw app whose preview to read. Pass it when several raw app previews are open; defaults to the visible one.'
)
})
const listAppRunsSchema = z.object({
@@ -1466,6 +1471,7 @@ ${pipelineBullet}`
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
)}
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
- Writing an app file does not compile it: the open preview rebuilds it afterwards. After editing a raw app's frontend files with its preview open, call get_app_runtime_logs to check the build — when it failed, it returns the build errors (e.g. syntax or import errors) and bundler logs to fix.
- To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open.
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected.
${
@@ -4438,7 +4444,7 @@ export const globalTools: SessionTool<{}>[] = [
def: createToolDef(
getRuntimeLogsSchema,
'get_app_runtime_logs',
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session.'
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session. Also reports the build: right after an edit it waits (up to 20s) for the rebuild, and when the build failed it returns the build error and bundler logs first.'
),
planModeSafe: true,
showDetails: true,
@@ -4446,7 +4452,11 @@ export const globalTools: SessionTool<{}>[] = [
fn: async (ctx) => {
const parsed = getRuntimeLogsSchema.parse(ctx.args)
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Reading app runtime logs...' })
const result = await getSessionRuntimeLogs(parsed.limit ?? 10, sessionIdFromCtx(ctx))
const result = await getSessionRuntimeLogs(
parsed.limit ?? 10,
sessionIdFromCtx(ctx),
parsed.app_path
)
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: result.uiMessage,
result: result.toolResult
@@ -4810,6 +4820,7 @@ function closeSessionPreviewTabs(
export type GetRuntimeLogsHandler = (req: {
sessionId: string | undefined
limit: number
appPath?: string
}) => Promise<SessionToolResult>
let getRuntimeLogsHandler: GetRuntimeLogsHandler | undefined
@@ -4820,7 +4831,8 @@ export function setGetRuntimeLogsHandler(handler: GetRuntimeLogsHandler | undefi
function getSessionRuntimeLogs(
limit: number,
sessionId: string | undefined
sessionId: string | undefined,
appPath: string | undefined
): Promise<SessionToolResult> {
if (!getRuntimeLogsHandler) {
return Promise.resolve({
@@ -4830,7 +4842,7 @@ function getSessionRuntimeLogs(
toolResult: 'Runtime logs unavailable'
})
}
return getRuntimeLogsHandler({ sessionId, limit })
return getRuntimeLogsHandler({ sessionId, limit, appPath })
}
export type ListAppRunsHandler = (req: {
@@ -6398,13 +6410,13 @@ async function initApp(
const value: AppDraftValue = {
summary,
files: { ...template },
runnables: { [STARTER_RUNNABLE_KEY]: { ...STARTER_RUNNABLE } }
runnables: structuredClone(STARTER_RUNNABLES)
}
await recomputeAppPolicy(value)
const result = await saveAppDraft(workspace, path, value)
return finishAppDraftWrite(result, ctx, () => ({
content: `Saved app "${path}" draft (${framework})`,
message: `Initialized a per-user draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (saved server-side, not a deployed workspace item). Use write_app_file / write_app_runnable to evolve it.`
message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.`
}))
}
@@ -69,6 +69,9 @@ export function requiresMaxCompletionTokens(model: string) {
// (trim/compaction, the usage indicator) go through
// getEffectiveModelContextWindow, whose conservative 128K fallback keeps a
// limit enforced and is surfaced to the user as an assumed window.
//
// Keep entries aligned with MODEL_CONTEXT_WINDOWS in
// `backend/windmill-ai/src/model_context.rs`, used by AI agent compaction.
const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [
// Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA);
// Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4

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