Merge remote-tracking branch 'origin/main' into glm/update-chat-mode-ui

# Conflicts:
#	frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
#	frontend/src/lib/components/copilot/chat/AIChatInput.svelte
This commit is contained in:
Guilhem Lemouel
2026-09-01 15:41:07 +02:00
139 changed files with 5564 additions and 3465 deletions
@@ -101,7 +101,6 @@ jobs:
- name: Install OpenSSL via vcpkg
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
@@ -62,7 +62,6 @@ jobs:
- name: Cargo build binary windows
timeout-minutes: 180
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
$env:VCPKGRS_DYNAMIC=1
@@ -51,7 +51,6 @@ jobs:
- name: Cargo build windows
timeout-minutes: 180
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
$env:VCPKGRS_DYNAMIC=1
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.799.0"
".": "1.800.1"
}
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01)
### Bug Fixes
* add top margin to the home Build with AI section ([#10909](https://github.com/windmill-labs/windmill/issues/10909)) ([bedf5ae](https://github.com/windmill-labs/windmill/commit/bedf5ae57445025729e94e16f1b5f13f6ff38ffa))
* **ai-chat:** consume an @ mention with the message that carried it ([#10907](https://github.com/windmill-labs/windmill/issues/10907)) ([c512110](https://github.com/windmill-labs/windmill/commit/c512110a1f8d0d3437c20048f6446ef62b10222c))
* keep a local dbt descriptor under sync pull --keep-deleted ([#10911](https://github.com/windmill-labs/windmill/issues/10911)) ([4b5be38](https://github.com/windmill-labs/windmill/commit/4b5be386ce0f851a087f43c0b0ac6e4b1b055a47))
* keep windmill-indexer out of builds without tantivy ([#10908](https://github.com/windmill-labs/windmill/issues/10908)) ([db0f004](https://github.com/windmill-labs/windmill/commit/db0f004613e3f90428fea4c824cc53f1b2fc03b0))
## [1.800.0](https://github.com/windmill-labs/windmill/compare/v1.799.0...v1.800.0) (2026-08-31)
### Features
* add --keep-deleted flag to wmill sync pull and push ([#10878](https://github.com/windmill-labs/windmill/issues/10878)) ([66123f3](https://github.com/windmill-labs/windmill/commit/66123f3a9b8978c0084b02f50b44cffba125a13a))
* day-partition the service log index and expire whole chunks ([#10893](https://github.com/windmill-labs/windmill/issues/10893)) ([d91ee46](https://github.com/windmill-labs/windmill/commit/d91ee4614a70f20a194f47e190327129f499ec63))
* free AI tokens + home search/filter revamp ([#10020](https://github.com/windmill-labs/windmill/issues/10020)) ([716ce2e](https://github.com/windmill-labs/windmill/commit/716ce2ece00cd5cfb8641afc6432636bc4aa46e9))
* make the service log retention period an instance setting ([#10889](https://github.com/windmill-labs/windmill/issues/10889)) ([815de49](https://github.com/windmill-labs/windmill/commit/815de49e2322f85ca92b1e41a2bcd22591ebe93f))
* rework the evals dataset drawer and run navigation ([#10884](https://github.com/windmill-labs/windmill/issues/10884)) ([1462f17](https://github.com/windmill-labs/windmill/commit/1462f17643302127b4bd76bacfde80cc03f9d606))
* serve service log context from parquet and retire the raw log files ([#10892](https://github.com/windmill-labs/windmill/issues/10892)) ([338d75c](https://github.com/windmill-labs/windmill/commit/338d75cc5227e352cb84828c99bfd3b984cf0fa5))
* serve service log retrieval from a columnar parquet store ([#10886](https://github.com/windmill-labs/windmill/issues/10886)) ([7c1a785](https://github.com/windmill-labs/windmill/commit/7c1a785f756ed27e4425f6534709b19971a73a97))
### Bug Fixes
* correct the service log ingest flush boundary ([#10898](https://github.com/windmill-labs/windmill/issues/10898)) ([ac56586](https://github.com/windmill-labs/windmill/commit/ac56586c0e56d4022761d3c80306a03d57f8bfcb))
* harden the service log indexer's recovery and read paths ([#10904](https://github.com/windmill-labs/windmill/issues/10904)) ([831370c](https://github.com/windmill-labs/windmill/commit/831370cdde8e06f4298b17baa1a0041bacdd98c7))
* keep raw-app editor selection consistent across sidebar and tabs ([#10885](https://github.com/windmill-labs/windmill/issues/10885)) ([b57e231](https://github.com/windmill-labs/windmill/commit/b57e231c2bf5e5fe007f0aa7b958a51e32b47141))
* register every rotated service log file exactly once ([#10891](https://github.com/windmill-labs/windmill/issues/10891)) ([c817248](https://github.com/windmill-labs/windmill/commit/c8172480b0b1be6c57210212afc71d6ec8711235))
* show a loading indicator while the initial data table migration is generated ([#10900](https://github.com/windmill-labs/windmill/issues/10900)) ([b998267](https://github.com/windmill-labs/windmill/commit/b998267c91b9dcf02787768f6205cc5aeda494fb))
* track outstanding service log files on the rows themselves ([#10894](https://github.com/windmill-labs/windmill/issues/10894)) ([aa4a6ff](https://github.com/windmill-labs/windmill/commit/aa4a6ffd66813010a79c07741b01a984ed4e7df6))
### Performance Improvements
* add service log documents to the index one batch at a time ([#10906](https://github.com/windmill-labs/windmill/issues/10906)) ([0c2eb0a](https://github.com/windmill-labs/windmill/commit/0c2eb0ae3d18f49c21370131d15011e8dd103746))
## [1.799.0](https://github.com/windmill-labs/windmill/compare/v1.798.1...v1.799.0) (2026-08-28)
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "replacing!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, $1::bigint, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + $1::bigint,\n updated_at = now()\n RETURNING cost_nanos",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Date"
]
},
"nullable": [
false
]
},
"hash": "44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)\n VALUES ('admins', $1, $2, $3, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "WITH moved AS (\n UPDATE log_file SET indexed_at = CASE\n WHEN log_ts > NOW() - make_interval(secs => $1) THEN NULL\n ELSE now() END\n WHERE indexed_at = 'epoch' RETURNING 1)\n SELECT count(*) AS \"n!\" FROM moved",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Float8"
]
},
"nullable": [
null
]
},
"hash": "872be44352d4a27a2005f6bdc38f927ac4886755fd17ec755037763aa92f6c0e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE log_file SET indexed_at = now()\n FROM unnest($1::text[], $2::timestamp[]) AS c(hostname, log_ts)\n WHERE log_file.hostname = c.hostname AND log_file.log_ts = c.log_ts",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TimestampArray"
]
},
"nullable": []
},
"hash": "87e8e034b1cf5ea7ce43870d77d33fa0fd05b067454f0d4686d8b155fe562ffe"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"describe": {
"columns": [
{
@@ -13,12 +13,14 @@
"Left": [
"Text",
"Jsonb",
"Text"
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae"
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH moved AS (\n UPDATE log_file SET indexed_at = CASE\n WHEN log_ts > NOW() - make_interval(secs => $1) THEN NULL\n ELSE now() END\n WHERE indexed_at = 'epoch' RETURNING 1)\n SELECT count(*) AS \"n!\" FROM moved",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Float8"
]
},
"nullable": [
null
]
},
"hash": "8e0461855d05dc03919c8979d8acdc85389c0629847d8fabb2ad0aa043957b2f"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE log_file SET indexed_at = now()\n FROM unnest($1::text[], $2::timestamp[]) AS c(hostname, log_ts)\n WHERE log_file.hostname = c.hostname AND log_file.log_ts = c.log_ts",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TimestampArray"
]
},
"nullable": []
},
"hash": "947f7ca06f6f9a3fd50f817bc9b0c06924719189bec8e5c19866db8d0b87df5e"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, GREATEST(0, $1::bigint), now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_daily_usage.cost_nanos + $1::bigint),\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Date"
]
},
"nullable": []
},
"hash": "acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, $2::bigint, now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + $2::bigint,\n updated_at = now()\n RETURNING cost_nanos",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_fileset, format_extension FROM resource_type\n WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "format_extension",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "instructions",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, GREATEST(0, $2::bigint), now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_usage.cost_nanos + $2::bigint),\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f"
}
+127 -127
View File
@@ -430,7 +430,7 @@ dependencies = [
"arrow-schema",
"chrono",
"half",
"indexmap 2.14.0",
"indexmap 2.14.1",
"lexical-core",
"memchr",
"num",
@@ -3247,7 +3247,7 @@ dependencies = [
"base64 0.22.1",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"libc",
"log",
"object_store",
@@ -3426,7 +3426,7 @@ dependencies = [
"datafusion-functions-aggregate-common",
"datafusion-functions-window-common",
"datafusion-physical-expr-common",
"indexmap 2.14.0",
"indexmap 2.14.1",
"paste",
"recursive",
"serde_json",
@@ -3441,7 +3441,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839"
dependencies = [
"arrow",
"datafusion-common",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"paste",
]
@@ -3595,7 +3595,7 @@ dependencies = [
"datafusion-common",
"datafusion-expr",
"datafusion-physical-expr",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"recursive",
@@ -3618,7 +3618,7 @@ dependencies = [
"datafusion-physical-expr-common",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"paste",
@@ -3680,7 +3680,7 @@ dependencies = [
"futures",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"parking_lot",
@@ -3722,7 +3722,7 @@ dependencies = [
"bigdecimal",
"datafusion-common",
"datafusion-expr",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"recursive",
"regex",
@@ -3853,7 +3853,7 @@ dependencies = [
"deno_path_util",
"deno_unsync",
"futures",
"indexmap 2.14.0",
"indexmap 2.14.1",
"libc",
"parking_lot",
"percent-encoding",
@@ -4122,7 +4122,7 @@ version = "0.228.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro-rules",
"proc-macro2",
"quote",
@@ -5177,9 +5177,9 @@ dependencies = [
[[package]]
name = "frostem"
version = "1.20260821.2"
version = "1.20260821.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ed6437a2ed7fc408115e25cdb41e15ba4b17742a4c8d3d1b5276fe9b687209"
checksum = "ca2e81eab151ba68484704bb3d21b4b4d2747314d3081fa86fa6c7300a4c42e2"
[[package]]
name = "fs3"
@@ -5755,9 +5755,9 @@ dependencies = [
[[package]]
name = "gosyn"
version = "0.2.14"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9758a950dc61a15bc65162f72f5bec7e8efde91f102dc7dce7317f67019a6ba9"
checksum = "28f4ed3cbcb66ddad22553bb3b94087573cf492a34501bc025a23167ed57e856"
dependencies = [
"anyhow",
"strum",
@@ -5797,7 +5797,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.14.0",
"indexmap 2.14.1",
"slab",
"tokio",
"tokio-util",
@@ -5816,7 +5816,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.5.0",
"indexmap 2.14.0",
"indexmap 2.14.1",
"slab",
"tokio",
"tokio-util",
@@ -5898,7 +5898,7 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -5951,9 +5951,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
@@ -6570,9 +6570,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.14.0"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
@@ -6958,7 +6958,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
]
[[package]]
@@ -8124,7 +8124,7 @@ dependencies = [
"dirs-sys 0.4.1",
"fancy-regex 0.14.0",
"heck",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"lru 0.12.5",
"miette",
@@ -8302,7 +8302,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi 0.5.3",
"libc",
]
@@ -9123,7 +9123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
"indexmap 2.14.1",
]
[[package]]
@@ -9389,7 +9389,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"hermit-abi 0.5.3",
"pin-project-lite",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -9619,7 +9619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1"
dependencies = [
"futures",
"indexmap 2.14.0",
"indexmap 2.14.1",
"nix 0.30.1",
"tokio",
"tracing",
@@ -10523,9 +10523,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.1.4"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780"
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -10565,7 +10565,7 @@ dependencies = [
"convert_case 0.10.0",
"fnv",
"ident_case",
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -11398,7 +11398,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"memchr",
"serde",
@@ -11492,7 +11492,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"indexmap 2.14.1",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
@@ -11520,7 +11520,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"ryu",
"serde",
@@ -11533,7 +11533,7 @@ version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"libyml",
"memchr",
@@ -11953,7 +11953,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"memchr",
"once_cell",
@@ -12310,7 +12310,7 @@ checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97"
dependencies = [
"anyhow",
"bytes-str",
"indexmap 2.14.0",
"indexmap 2.14.1",
"serde",
"serde_json",
"swc_config_macro",
@@ -12444,7 +12444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c6f1b8f4232e7a7f614ff7c0f6ccb89c2d028cdf7629f79ad710cff5b28b62c"
dependencies = [
"better_scoped_tls",
"indexmap 2.14.0",
"indexmap 2.14.1",
"once_cell",
"par-core",
"phf 0.11.3",
@@ -12510,7 +12510,7 @@ checksum = "69ea0052ac23b5b9fbc85bbdb1791b36b918f9d55f594b0ed8e25babb4c32d16"
dependencies = [
"base64 0.22.1",
"bytes-str",
"indexmap 2.14.0",
"indexmap 2.14.1",
"once_cell",
"rustc-hash 2.1.3",
"serde",
@@ -12550,7 +12550,7 @@ version = "21.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83259addd99ed4022aa9fc4d39428c008d3d42533769e1a005529da18cde4568"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"num_cpus",
"once_cell",
"par-core",
@@ -13549,7 +13549,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
@@ -13562,7 +13562,7 @@ version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
@@ -13670,7 +13670,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.14.0",
"indexmap 2.14.1",
"pin-project-lite",
"slab",
"sync_wrapper",
@@ -13885,9 +13885,9 @@ dependencies = [
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
[[package]]
name = "tree-sitter-r"
@@ -14746,7 +14746,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-nats",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"async-stream",
"async-trait",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14877,7 +14877,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"argon2",
@@ -14908,7 +14908,7 @@ dependencies = [
"hmac",
"http 1.5.0",
"hyper 1.11.1",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
"lazy_static",
@@ -15017,7 +15017,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15040,7 +15040,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15057,7 +15057,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15083,7 +15083,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15093,7 +15093,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15110,7 +15110,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15132,7 +15132,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15155,7 +15155,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15171,7 +15171,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15193,7 +15193,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15214,7 +15214,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15228,7 +15228,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15263,7 +15263,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15288,7 +15288,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15316,12 +15316,12 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
"http 1.5.0",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"lazy_static",
"serde",
@@ -15338,7 +15338,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15358,7 +15358,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15396,7 +15396,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15424,7 +15424,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"lazy_static",
"serde",
@@ -15436,7 +15436,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15460,7 +15460,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15474,7 +15474,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15509,7 +15509,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"chrono",
"lazy_static",
@@ -15523,7 +15523,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15542,7 +15542,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15580,7 +15580,7 @@ dependencies = [
"hex",
"hmac",
"hyper 1.11.1",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
"lazy_static",
@@ -15646,7 +15646,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15665,7 +15665,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"regex",
"serde",
@@ -15680,7 +15680,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15707,7 +15707,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"futures",
@@ -15724,7 +15724,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15740,7 +15740,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15761,7 +15761,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15792,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"arc-swap",
@@ -15817,7 +15817,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-stream",
@@ -15851,7 +15851,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"futures",
@@ -15869,7 +15869,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15878,7 +15878,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15890,7 +15890,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15902,7 +15902,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"gosyn",
@@ -15914,7 +15914,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15926,7 +15926,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15938,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -15949,7 +15949,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15960,7 +15960,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15972,7 +15972,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15983,7 +15983,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16005,7 +16005,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16017,7 +16017,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16031,7 +16031,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16048,7 +16048,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16061,7 +16061,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde",
@@ -16073,7 +16073,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16107,7 +16107,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16123,7 +16123,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16137,7 +16137,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16176,7 +16176,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"const_format",
@@ -16216,7 +16216,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16227,7 +16227,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16262,7 +16262,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16286,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16319,7 +16319,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16399,7 +16399,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16433,7 +16433,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16469,7 +16469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16492,7 +16492,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16516,7 +16516,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16540,7 +16540,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16575,7 +16575,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16603,7 +16603,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16628,7 +16628,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16647,7 +16647,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16764,7 +16764,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"bytes",
"futures",
@@ -17563,7 +17563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
dependencies = [
"crc32fast",
"indexmap 2.14.0",
"indexmap 2.14.1",
"memchr",
"typed-path",
]
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.799.0"
version = "1.800.1"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.799.0"
version = "1.800.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -131,6 +131,10 @@ quickjs = ["windmill-worker/quickjs", "windmill-api/quickjs"]
openidconnect = ["windmill-api/openidconnect", "windmill-common/openidconnect", "windmill-object-store/openidconnect"]
cloud = ["windmill-queue/cloud", "windmill-worker/cloud", "windmill-common/cloud", "windmill-api/cloud"]
jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"]
# `tantivy` must stay the only feature that enables windmill-indexer: it is the only one that
# also gives it `enterprise` + `parquet`, and its EE sources gate nearly everything on that
# pair, so a bare windmill-indexer is a crate of dead code that `-D warnings` rejects. Any
# other feature wanting one of its features has to use the optional `windmill-indexer?/` form.
tantivy = ["dep:windmill-indexer", "windmill-api/tantivy", "windmill-indexer/enterprise", "windmill-indexer/parquet", "windmill-common/tantivy", "enterprise", "parquet"]
sqlx = ["windmill-worker/sqlx"]
deno_core = ["windmill-worker/deno_core", "dep:windmill-runtime-nativets", "windmill-test-utils/deno_core"]
+1 -1
View File
@@ -1 +1 @@
bc0c7051585194474078b6c1941a3fb73893d9e5
6efe7a73c745c2e1377a34498523c00d89010a3d
@@ -0,0 +1,2 @@
DROP TABLE ai_free_token_daily_usage;
DROP TABLE ai_free_token_usage;
@@ -0,0 +1,19 @@
-- One-time grant of the Windmill-provided free AI tier, measured as cost in nano-dollars
-- (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction of a fresh input
-- token, so a token count wildly overstates the real bill. The grant never resets: once
-- spent, the user must bring their own API key. Keyed by normalized email so the allowance
-- is shared across a user's workspaces (and is resistant to +tag / gmail-dot aliasing).
CREATE TABLE ai_free_token_usage (
email VARCHAR(255) PRIMARY KEY,
cost_nanos BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Instance-wide daily cost ceiling (nano-dollars) for the free tier — a kill-switch
-- independent of the per-user grant, bounding the blast radius of a bad day. One row per
-- UTC day.
CREATE TABLE ai_free_token_daily_usage (
day DATE PRIMARY KEY,
cost_nanos BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,13 @@
-- The up migration only ever added: `ai_skill` still holds every skill it copied,
-- so there is nothing to restore and nothing to delete. Removing the resources
-- would destroy any a user has since edited or created, and removing a folder
-- would take whatever else was put in it.
--
-- The seeded resource type goes. `created_by` only distinguishes this migration's
-- row from one a user created by hand: a hub sync updates the schema in place and
-- leaves `created_by` alone, so a synced-over row is still removed here and the
-- next sync puts it back.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_skill'
AND created_by = 'system';
@@ -0,0 +1,88 @@
-- AI chat skills move from the `ai_skill` table onto ordinary resources, so they
-- gain folder ACLs, version history, workspace export and git-sync. An `ai_skill`
-- resource holds the SKILL.md body in `value.content`; its description lives in
-- the resource's own `description` column and its name is the path basename.
--
-- Nothing here is destructive. `ai_skill` is left in place, unread, for a later
-- release to drop once operators have confirmed the copy. That is what lets every
-- step below skip on conflict rather than resolve one: a skipped row is still in
-- the table, so it is not lost, and the migration needs no record of what it did
-- in order to be reversible.
-- `format_extension` makes the resource editor render `value.content` as a plain
-- .md file. Seeded under 'admins' so every workspace sees it.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_skill',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Shared home matching the admin-only upload these skills had. A workspace that
-- already has a `skills` folder keeps it untouched, ACL and all: adopting one
-- would hand its own grants — possibly write for everyone — over a set of
-- instructions the assistant follows.
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
SELECT DISTINCT workspace_id, 'skills', 'Skills', ARRAY[]::TEXT[], '{"g/all": false}'::jsonb
FROM ai_skill
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Copied only where the destination is free and the folder matches the one above
-- exactly, owners included: a pre-existing folder carrying the same ACL but an
-- owner would hand that owner update and delete over skills the removed API let
-- only workspace admins touch. Anything else stays in `ai_skill` for an operator
-- to place deliberately.
--
-- What was actually inserted is recorded rather than inferred. Inferring it from
-- "is there an ai_skill resource at the destination" reports nothing when the
-- blocker is itself an ai_skill with different instructions — the one case where
-- the skipped skill is least likely to be noticed.
CREATE TEMP TABLE ai_skill_copied AS
WITH inserted AS (
INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at)
SELECT
s.workspace_id,
'f/skills/' || s.name,
jsonb_build_object('content', s.instructions),
s.description,
'ai_skill',
s.edited_by,
s.edited_at
FROM ai_skill s
JOIN folder f
ON f.workspace_id = s.workspace_id
AND f.name = 'skills'
AND f.extra_perms = '{"g/all": false}'::jsonb
AND cardinality(f.owners) = 0
ON CONFLICT (workspace_id, path) DO NOTHING
RETURNING workspace_id, path
)
SELECT workspace_id, path FROM inserted;
-- Anything not copied is still in `ai_skill`, but nothing reads that table any
-- more, so from the app's side the skill is missing until an operator places it.
-- Name them rather than leaving that to be discovered.
DO $$
DECLARE
leftover RECORD;
BEGIN
FOR leftover IN
SELECT s.workspace_id, s.name
FROM ai_skill s
WHERE NOT EXISTS (
SELECT 1 FROM ai_skill_copied c
WHERE c.workspace_id = s.workspace_id
AND c.path = 'f/skills/' || s.name
)
LOOP
RAISE WARNING 'ai_skill %/% was not copied to a resource (its destination or the f/skills folder is already taken); it remains in the ai_skill table',
leftover.workspace_id, leftover.name;
END LOOP;
END $$;
DROP TABLE ai_skill_copied;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.799.0"
version = "1.800.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.799.0"
version = "1.800.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+68 -15
View File
@@ -7,14 +7,14 @@
*/
use anyhow::Context;
use monitor::{
load_base_url, load_otel, reload_critical_alerts_on_db_oversize,
reload_delete_logs_periodically_setting, reload_indexer_config,
reload_instance_python_version_setting, reload_maven_repos_setting,
flush_pending_log_files_to_object_store, load_base_url, load_otel,
reload_critical_alerts_on_db_oversize, reload_delete_logs_periodically_setting,
reload_indexer_config, reload_instance_python_version_setting, reload_maven_repos_setting,
reload_maven_settings_xml_setting, reload_no_default_maven_setting,
reload_nuget_config_setting, reload_powershell_repo_pat_setting,
reload_powershell_repo_url_setting, reload_ruby_repos_setting,
reload_timeout_wait_result_setting, reload_workspace_registries_setting,
flush_pending_log_files_to_object_store, send_logs_to_object_store, WORKERS_NAMES,
send_logs_to_object_store, WORKERS_NAMES,
};
use rand::Rng;
use sqlx::{Pool, Postgres};
@@ -406,8 +406,12 @@ struct HubResourceTypeRaw {
pub schema: Option<String>,
pub app: String,
pub description: Option<String>,
/// Absent from hubs predating the column, and from caches written before it.
#[serde(default)]
pub format_extension: Option<String>,
}
/// Processed resource type with parsed schema
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct HubResourceType {
@@ -416,6 +420,18 @@ pub struct HubResourceType {
pub schema: Option<serde_json::Value>,
pub app: String,
pub description: Option<String>,
/// Doubly optional on purpose. A cache written before this column has no key at
/// all (`None`) and must leave the stored extension alone; one written since
/// always writes the key, so an explicit null (`Some(None)`) is the hub genuinely
/// dropping it and must clear. A single `Option` conflates the two, and picking
/// either meaning breaks the other — as does plain serde, which folds `null`
/// into the outer `None`, hence the wrapping deserializer.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option",
skip_serializing_if = "Option::is_none"
)]
pub format_extension: Option<Option<String>>,
}
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
@@ -462,6 +478,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect();
@@ -503,9 +520,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
tracing::info!("Found {} cached resource types", cached_types.len());
// Get existing resource types in admins workspace
let existing_types: Vec<(String, Option<serde_json::Value>, Option<String>)> = sqlx::query_as(
"SELECT name, schema, description FROM resource_type WHERE workspace_id = 'admins'",
// Get existing resource types in admins workspace. `format_extension` is part of
// the comparison below, so a type whose only change is gaining or losing it is
// not mistaken for unchanged; `is_fileset` decides whether it may take one.
let existing_types: Vec<(
String,
Option<serde_json::Value>,
Option<String>,
Option<String>,
bool,
)> = sqlx::query_as(
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
)
.fetch_all(db)
.await
@@ -513,19 +538,42 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
let existing_map: std::collections::HashMap<
String,
(Option<serde_json::Value>, Option<String>),
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
> = existing_types
.into_iter()
.map(|(name, schema, desc)| (name, (schema, desc)))
.map(|(name, schema, desc, format_extension, is_fileset)| {
(name, (schema, desc, format_extension, is_fileset))
})
.collect();
let mut synced_count = 0;
let mut skipped_count = 0;
for rt in cached_types {
// Check if resource type already exists with same schema and description
if let Some((existing_schema, existing_desc)) = existing_map.get(&rt.name) {
if existing_schema == &rt.schema && existing_desc == &rt.description {
let existing = existing_map.get(&rt.name);
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
// A fileset is a set of files, so it cannot also be one file. Create, update
// and the manual sync all reject the pair; this writer would otherwise
// persist it onto a same-named local fileset.
//
// A cache with no key at all leaves the stored value alone, so the target is
// what is already there — which is also what makes the comparison below
// agree with the write instead of re-upserting the row on every boot.
let format_extension = if is_fileset {
None
} else {
match &rt.format_extension {
Some(from_cache) => from_cache.clone(),
None => stored_extension.clone(),
}
};
if let Some((existing_schema, existing_desc, _, _)) = existing {
if existing_schema == &rt.schema
&& existing_desc == &rt.description
&& stored_extension == format_extension
{
skipped_count += 1;
continue;
}
@@ -533,14 +581,19 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
// Insert or update resource type
sqlx::query(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// `format_extension` is resolved above rather than coalesced here: a
// COALESCE could never clear one, so a hub that dropped an extension
// would leave the stale value behind forever.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
format_extension = EXCLUDED.format_extension, edited_at = now()",
)
.bind(&rt.name)
.bind(&rt.schema)
.bind(&rt.description)
.bind(&format_extension)
.execute(db)
.await
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
+2
View File
@@ -38,6 +38,8 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien
FK: (workspace_id) -> workspace(id)
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
FK: (workspace_id) -> workspace(id)
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
-49
View File
@@ -288,7 +288,6 @@ pub enum ScopeDomain {
Configs,
OAuth,
AI,
AiSkills,
AiEvals, // AI agent eval datasets
Indexer,
@@ -349,7 +348,6 @@ impl ScopeDomain {
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::AiSkills => "ai_skills",
Self::AiEvals => "ai_evals",
Self::Capture => "capture",
Self::Drafts => "drafts",
@@ -405,7 +403,6 @@ impl ScopeDomain {
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"ai_skills" => Some(Self::AiSkills),
"ai_evals" => Some(Self::AiEvals),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
@@ -1202,12 +1199,6 @@ mod tests {
assert_eq!(domain, ScopeDomain::FlowConversations);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
assert_eq!(domain, ScopeDomain::AiSkills);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
}
#[test]
@@ -1368,11 +1359,6 @@ mod tests {
ScopeDomain::from_str("flow_conversations"),
Some(ScopeDomain::FlowConversations)
);
assert_eq!(
ScopeDomain::from_str("ai_skills"),
Some(ScopeDomain::AiSkills)
);
// Test canonical string conversion
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
@@ -1381,41 +1367,6 @@ mod tests {
ScopeDomain::FlowConversations.as_str(),
"flow_conversations"
);
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
}
#[test]
fn test_ai_skills_scope_access() {
let read_scopes = vec!["ai_skills:read".to_string()];
assert!(
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
);
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/get/foo",
"GET"
)
.is_ok());
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_err());
let write_scopes = vec!["ai_skills:write".to_string()];
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_ok());
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/delete/foo",
"DELETE"
)
.is_ok());
}
#[test]
+29 -4
View File
@@ -2004,6 +2004,12 @@ struct CachedResourceType {
#[allow(dead_code)]
app: String,
description: Option<String>,
/// Doubly optional, and read through a wrapping deserializer: this struct also
/// decodes the on-disk cache, where an absent key means "written before the
/// column, leave the stored extension alone" and an explicit null means the hub
/// dropped it. Plain serde folds both into `None`.
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
format_extension: Option<Option<String>>,
}
#[derive(serde::Deserialize)]
@@ -2013,6 +2019,8 @@ struct HubResourceTypeRaw {
schema: Option<String>,
app: String,
description: Option<String>,
#[serde(default)]
format_extension: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
@@ -2054,6 +2062,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect())
@@ -2107,10 +2116,12 @@ async fn sync_cached_resource_types(
for rt in &resource_types {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.fetch_one(&db)
.await?;
@@ -2120,13 +2131,27 @@ async fn sync_cached_resource_types(
}
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// Whether the payload carried the key at all is what decides: present
// (even as null) is authoritative and may clear, absent means a cache
// written before the column and must leave the stored value alone.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
-- A fileset is a set of files, so it cannot also be one file.
-- Create and update reject the pair; this writer bypasses both, so
-- it declines the extension rather than persisting the forbidden
-- combination onto a same-named local fileset.
format_extension = CASE
WHEN resource_type.is_fileset THEN NULL
WHEN $5 THEN EXCLUDED.format_extension
ELSE resource_type.format_extension END,
edited_at = now()",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.execute(&db)
.await?;
-196
View File
@@ -16532,202 +16532,6 @@
}
}
},
"/w/{workspace}/ai_skills/list": {
"get": {
"summary": "list the workspace AI chat skills (name + description only)",
"operationId": "listAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"responses": {
"200": {
"description": "skill listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"required": [
"name",
"description"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/get/{name}": {
"get": {
"summary": "get a workspace AI chat skill including its instructions",
"operationId": "getAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "skill",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/upload": {
"post": {
"summary": "upsert workspace AI chat skills (admin only)",
"operationId": "uploadAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"skills"
],
"properties": {
"skills": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9-]+$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"instructions": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "uploaded",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/delete/{name}": {
"delete": {
"summary": "delete a workspace AI chat skill (admin only)",
"operationId": "deleteAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "deleted",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
"get": {
"summary": "get raw app data by",
-135
View File
@@ -17063,141 +17063,6 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
responses:
'200':
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: ^[a-z0-9-]+$
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
'200':
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
+25 -128
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.799.0
version: 1.800.1
title: Windmill API
contact:
@@ -12534,133 +12534,6 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: "^[a-z0-9-]+$"
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
"200":
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -27256,11 +27129,29 @@ components:
type: integer
minimum: 1
maximum: 2000000
free_tier:
$ref: "#/components/schemas/FreeTierInfo"
model_pricing:
type: object
additionalProperties:
$ref: "#/components/schemas/ModelPriceOverride"
FreeTierInfo:
type: object
description: >-
Read-only. Present when the workspace has no AI provider of its own and is running
on Windmill's free tier. Ignored on write.
properties:
exhausted:
type: boolean
description: The one-time grant is spent; no provider is served and the user must add their own API key.
used_ratio:
type: number
description: Fraction of the grant consumed, 0 to 1.
required:
- exhausted
- used_ratio
ModelPriceOverride:
type: object
description: negotiated rates in USD per million tokens, keyed `provider:model`
@@ -29594,6 +29485,12 @@ components:
type: string
is_fileset:
type: boolean
format_extension:
type: string
nullable: true
description: >-
File extension for a type whose value is one file rather than a set
of fields. Omit to leave it unchanged; send null to clear it.
TriggerHistoryEntry:
type: object
+182 -97
View File
@@ -409,6 +409,19 @@ impl ExpiringProviderCredentials {
}
}
/// Set on the copilot config when the workspace has no AI provider of its own and is
/// running on Windmill's free tier, so the client can label the lent model as free, warn
/// before the grant runs out, and tell the user to add their own key once it has — rather
/// than showing the same "no provider configured" state a never-configured workspace gets.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct FreeTierInfo {
/// The grant is spent: no provider is served and the user must bring their own key.
pub exhausted: bool,
/// Fraction of the grant consumed, 0.0..=1.0. A ratio, not a dollar amount — the
/// pricing model stays server-side.
pub used_ratio: f64,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -423,6 +436,11 @@ pub struct AIConfig {
pub custom_prompts: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_per_model: Option<HashMap<String, i32>>,
/// Response-only: this same struct is the request body for saving a workspace's AI
/// config, and `skip_deserializing` is what stops a client from storing a forged
/// free-tier marker. Only the server sets it, per-request.
#[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
pub free_tier: Option<FreeTierInfo>,
/// Per-model price overrides, keyed `provider:model` like `max_tokens_per_model`.
/// Only models whose rates differ from the built-in table are stored.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1013,83 +1031,119 @@ async fn proxy(
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
}
let mut credentials = match workspace_cache {
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
request_cache.credentials
}
_ => {
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
// Set when serving the request through Windmill's free AI tier (the lent key). Holds
// the per-user concurrency lock and drives response metering.
let mut free_lease: Option<crate::ai_free_tier_oss::FreeTierLease> = None;
let mut credentials = 'cred: {
match workspace_cache {
Some(request_cache)
if !request_cache.is_expired() && forced_resource_path.is_none() =>
{
request_cache.credentials
}
_ => {
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
let instance_has_config =
instance_config.as_ref().is_some_and(|v| {
serde_json::from_value::<AIConfig>(v.clone())
.ok()
.is_some_and(|c| c.has_providers())
});
match instance_config {
// An instance `ai_config` row with no usable provider (e.g. `{}`
// or `{"providers":{}}`) is treated as unconfigured, exactly as
// build_copilot_settings_state does — otherwise its mere presence
// would suppress the free-tier fallback below.
Some(config) if instance_has_config => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
_ => {
// Nothing configured: fall back to Windmill's free AI tier
// (EE-only) if a lent key is set and both the user's
// one-time grant and the instance's daily cap have room.
// Errors once the grant is spent, the day is capped, or the
// user already has a request in flight; None otherwise.
// Ineligible identities (e.g. service accounts) are refused
// inside the helper, so every path treats them alike.
let free =
crate::ai_free_tier_oss::resolve_free_tier_credentials(
&provider,
&db,
&ai_path,
&authed.email,
&body,
)
.await?;
if let Some((free_credentials, lease)) = free {
free_lease = Some(lease);
break 'cred free_credentials;
}
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
let mut tx = user_db.clone().begin(&authed).await?;
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
@@ -1112,38 +1166,45 @@ async fn proxy(
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
// Enforce RLS on $var: resolution when the resource path was
// user-specified (X-Resource-Path header) so users can only read
// variables they have permission to access.
let enforce_authed = if is_user_specified_resource {
Some(&authed)
} else {
None
};
let credentials = resolve_provider_credentials(
&provider,
&db,
&resource_workspace,
resource,
enforce_authed,
)
.await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringProviderCredentials::new(
credentials.clone(),
instance_ai_config_revision,
),
);
// Enforce RLS on $var: resolution when the resource path was
// user-specified (X-Resource-Path header) so users can only read
// variables they have permission to access.
let enforce_authed = if is_user_specified_resource {
Some(&authed)
} else {
None
};
let credentials = resolve_provider_credentials(
&provider,
&db,
&resource_workspace,
resource,
enforce_authed,
)
.await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringProviderCredentials::new(
credentials.clone(),
instance_ai_config_revision,
),
);
}
credentials
}
credentials
}
};
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
// since the request body is otherwise client-controlled.
if free_lease.is_some() {
body = crate::ai_free_tier_oss::enforce_free_tier_body(&body)?;
}
if let Some(fim_transform) =
maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)?
{
@@ -1291,8 +1352,32 @@ async fn proxy(
let status_code = response.status();
let headers = response.headers().clone();
let is_sse = is_sse_response(&headers);
// Free tier: reconcile the cost reserved up-front against what the response actually
// used, holding the per-user lock (via the lease) until it is recorded. The chat
// streams (SSE), where the usage report only arrives in the final chunk; the
// non-streaming JSON path is handled for completeness.
if let Some(lease) = free_lease {
let body = if is_sse {
axum::body::Body::from_stream(inject_keepalives(
Box::pin(crate::ai_free_tier_oss::meter_usage(
response.bytes_stream(),
db.clone(),
lease,
)),
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
))
} else {
let bytes = response.bytes().await.map_err(to_anyhow)?;
crate::ai_free_tier_oss::record_json_usage(db.clone(), lease, &bytes);
axum::body::Body::from(bytes)
};
return Ok((status_code, headers, body));
}
let stream = response.bytes_stream();
let body = if is_sse_response(&headers) {
let body = if is_sse {
axum::body::Body::from_stream(inject_keepalives(
stream,
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
@@ -0,0 +1,65 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::ai_free_tier_ee::*;
// Open-source build: Windmill's free AI tier does not exist. These stubs make the
// callers in `ai.rs` / `workspaces.rs` compile while disabling the feature entirely —
// `resolve_free_tier_credentials` never opts in, so the proxy falls through to its
// normal "AI resource not configured" path and the copilot stays hidden.
//
// Caller contract (enforced by the private impl, restated here for parity): the `email`
// passed to `resolve_free_tier_credentials` / `free_tier_copilot_config` MUST be the
// authenticated caller's own identity (an `ApiAuthed` email), never a client-supplied one —
// it selects whose lent-key grant is spent and whose usage is read.
#[cfg(not(feature = "private"))]
use crate::ai::AIConfig;
#[cfg(not(feature = "private"))]
use crate::db::DB;
#[cfg(not(feature = "private"))]
use axum::body::Bytes;
#[cfg(not(feature = "private"))]
use windmill_ai::ai_providers::AIProvider;
#[cfg(not(feature = "private"))]
use windmill_ai::credentials::ProviderCredentials;
#[cfg(not(feature = "private"))]
use windmill_common::error::Result;
#[cfg(not(feature = "private"))]
pub struct FreeTierLease;
#[cfg(not(feature = "private"))]
pub async fn resolve_free_tier_credentials(
_provider: &AIProvider,
_db: &DB,
_ai_path: &str,
_email: &str,
_body: &Bytes,
) -> Result<Option<(ProviderCredentials, FreeTierLease)>> {
Ok(None)
}
#[cfg(not(feature = "private"))]
pub fn enforce_free_tier_body(body: &Bytes) -> Result<Bytes> {
Ok(body.clone())
}
#[cfg(not(feature = "private"))]
pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result<Option<AIConfig>> {
Ok(None)
}
#[cfg(not(feature = "private"))]
pub fn record_json_usage(_db: DB, _lease: FreeTierLease, _bytes: &[u8]) {}
#[cfg(not(feature = "private"))]
pub fn meter_usage<S>(
upstream: S,
_db: DB,
_lease: FreeTierLease,
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
where
S: futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>> + Unpin,
{
upstream
}
-394
View File
@@ -1,394 +0,0 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{Extension, Json, Path},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_skills))
.route("/get/{name}", get(get_skill))
.route("/upload", post(upload_skills))
.route("/delete/{name}", delete(delete_skill))
}
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
#[derive(Serialize)]
pub struct SkillListItem {
pub name: String,
pub description: String,
}
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
#[derive(Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
}
#[derive(Deserialize)]
pub struct UploadSkills {
pub skills: Vec<SkillUpload>,
}
#[derive(Deserialize)]
pub struct SkillUpload {
pub name: String,
pub description: String,
pub instructions: String,
}
const MAX_SKILLS_PER_UPLOAD: usize = 50;
// Every stored skill's name + description is advertised in the global AI chat
// system prompt, so bound the total a workspace can accumulate across uploads.
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
// `name` and `description` follow the Claude SKILL.md spec
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
// so matching the upstream limits keeps skills portable with Claude Code.
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
fn validate_skill(skill: &SkillUpload) -> Result<()> {
let name = skill.name.trim();
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
return Err(Error::BadRequest(format!(
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
skill.name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(Error::BadRequest(format!(
"skill name {name:?} must only contain lowercase letters, digits or '-'"
)));
}
if skill.description.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
)));
}
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
return Err(Error::BadRequest(format!(
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
)));
}
if skill.instructions.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} has an empty SKILL.md body"
)));
}
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
return Err(Error::BadRequest(format!(
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
)));
}
Ok(())
}
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
/// The insert upserts by name, so a duplicate would silently keep only the last
/// and make the reported/audited count wrong.
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
let mut names = Vec::with_capacity(skills.len());
let mut seen = HashSet::with_capacity(skills.len());
for skill in skills {
let name = skill.name.trim().to_string();
if !seen.insert(name.clone()) {
return Err(Error::BadRequest(format!(
"duplicate skill name {name:?} in upload"
)));
}
names.push(name);
}
Ok(names)
}
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
/// Uploads upsert, so names already present (`replacing`) don't count as new.
fn check_workspace_skill_capacity(
existing_total: i64,
replacing: i64,
upload_count: usize,
) -> Result<()> {
let new_count = upload_count as i64 - replacing;
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
return Err(Error::BadRequest(format!(
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
)));
}
Ok(())
}
async fn list_skills(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<SkillListItem>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
&w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| SkillListItem { name: r.name, description: r.description })
.collect(),
))
}
async fn get_skill(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Skill> {
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
row.map(|r| {
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
})
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
}
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
/// are left untouched — removal goes through `delete_skill`.
async fn upload_skills(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(payload): Json<UploadSkills>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
if payload.skills.is_empty() {
return Err(Error::BadRequest("no skills to upload".to_string()));
}
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
return Err(Error::BadRequest(format!(
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
)));
}
for skill in &payload.skills {
validate_skill(skill)?;
}
let names = collect_upload_names(&payload.skills)?;
let mut tx = db.begin().await?;
let counts = sqlx::query!(
r#"SELECT
COUNT(*)::bigint AS "total!",
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
FROM ai_skill
WHERE workspace_id = $1"#,
&w_id,
&names
)
.fetch_one(&mut *tx)
.await?;
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
for (skill, name) in payload.skills.iter().zip(names.iter()) {
sqlx::query!(
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (workspace_id, name) DO UPDATE
SET description = EXCLUDED.description,
instructions = EXCLUDED.instructions,
edited_at = now(),
edited_by = EXCLUDED.edited_by"#,
&w_id,
name,
skill.description,
skill.instructions,
&authed.username,
)
.execute(&mut *tx)
.await?;
}
let audit_resource = names.join(",");
audit_log(
&mut *tx,
&authed,
"ai_skills.upload",
ActionKind::Update,
&w_id,
Some(&audit_resource),
Some([("skill_count", &names.len().to_string()[..])].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"Uploaded {} skill(s) to workspace {}",
payload.skills.len(),
&w_id
))
}
async fn delete_skill(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
let deleted = sqlx::query_scalar!(
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
if deleted.is_none() {
tx.commit().await?;
return Err(Error::NotFound(format!(
"no skill named {name:?} in workspace {w_id}"
)));
}
audit_log(
&mut *tx,
&authed,
"ai_skills.delete",
ActionKind::Delete,
&w_id,
Some(&name),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Deleted skill {name} from workspace {w_id}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn skill() -> SkillUpload {
SkillUpload {
name: "test-skill".to_string(),
description: "Useful for tests".to_string(),
instructions: "# Test\n\nDo the thing.".to_string(),
}
}
#[test]
fn validate_skill_rejects_oversized_description() {
let mut skill = skill();
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_instructions() {
let mut skill = skill();
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_name() {
let mut skill = skill();
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_non_slug_name() {
// Uppercase, underscore, space and punctuation are all outside the
// Claude SKILL.md `[a-z0-9-]` name charset.
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
let mut skill = skill();
skill.name = bad.to_string();
assert!(
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
"{bad:?} should be rejected"
);
}
}
#[test]
fn validate_skill_counts_description_in_characters() {
// 1024 two-byte chars exceed the byte limit but sit exactly on the
// character limit, so they must be accepted.
let mut skill = skill();
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
assert!(validate_skill(&skill).is_ok());
}
#[test]
fn workspace_capacity_allows_replacement_at_cap() {
// Already at the cap, but the upload only replaces an existing skill.
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
}
#[test]
fn workspace_capacity_rejects_new_skill_over_cap() {
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(matches!(
check_workspace_skill_capacity(at_cap, 0, 1),
Err(Error::BadRequest(_))
));
}
#[test]
fn collect_upload_names_trims_and_collects() {
let names = collect_upload_names(&[skill()]).unwrap();
assert_eq!(names, vec!["test-skill".to_string()]);
}
#[test]
fn collect_upload_names_rejects_duplicates() {
// Names are compared after trimming, so whitespace can't smuggle a dup in.
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
assert!(matches!(
collect_upload_names(&[skill(), dup]),
Err(Error::BadRequest(_))
));
}
}
+3 -2
View File
@@ -66,7 +66,9 @@ use crate::scim_oss::has_scim_token;
use windmill_common::error::AppError;
mod ai;
mod ai_skills;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -710,7 +712,6 @@ pub async fn run_server(
Router::new()
})
.nest("/ai", ai::workspaced_service())
.nest("/ai_skills", ai_skills::workspaced_service())
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
.nest(
"/path_autocomplete",
-1
View File
@@ -99,7 +99,6 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
("configs", "Configs", "Configuration management", false),
("oauth", "OAuth", "OAuth management", false),
("ai", "AI", "AI feature management", false),
("ai_skills", "AI Skills", "AI skill management", false),
(
"ai_evals",
"AI Evals",
+47 -11
View File
@@ -18,7 +18,6 @@ use crate::teams_oss::{
connect_teams, edit_teams_command, run_teams_message_test_job,
workspaces_list_available_teams_channels, workspaces_list_available_teams_ids,
};
use axum::{
extract::{Extension, Path},
routing::{get, post},
@@ -153,10 +152,23 @@ async fn edit_copilot_config(
.await?;
let settings_state =
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
// A provider-less instance config (e.g. `{}`) is unconfigured, same as build_copilot_settings_state
// treats it — so it must not shadow the free-tier fallback here either.
let instance_config_with_providers = instance_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.filter(|c| c.has_providers());
let effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_ai_config) = instance_ai_config {
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
} else if let Some(instance_config) = instance_config_with_providers {
instance_config
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
// Same fallback as get_copilot_info: with nothing configured, surface Windmill's free
// tier (EE-only) so clearing a workspace provider activates it immediately, instead of
// returning an empty config that disables AI until the next page reload re-fetches it.
free_config
} else {
AIConfig::default()
};
@@ -179,6 +191,7 @@ struct EditCopilotConfigResponse {
}
async fn get_copilot_info(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<AIConfig> {
@@ -194,16 +207,25 @@ async fn get_copilot_info(
))
})?;
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) =
let instance_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?
.and_then(|v| serde_json::from_value::<AIConfig>(v).ok())
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
// free-tier fallback, matching the proxy and edit_copilot_config paths.
.filter(|c| c.has_providers());
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) = instance_config {
Ok(Json(instance_config))
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
Ok(Json(
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
))
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
// carries a `free_tier` marker even once the user's grant is spent — with no
// providers, but telling the client *why* AI is off.
Ok(Json(free_config))
} else {
Ok(Json(AIConfig::default()))
}
@@ -216,7 +238,14 @@ pub async fn get_critical_alerts(
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<serde_json::Value> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
require_admin_or_devops(
authed.is_admin,
&authed.username,
&authed.email,
authed.job_id.is_some(),
&db,
)
.await?;
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
}
@@ -232,7 +261,14 @@ pub async fn acknowledge_critical_alert(
Path((w_id, id)): Path<(String, i32)>,
authed: ApiAuthed,
) -> Result<String> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
require_admin_or_devops(
authed.is_admin,
&authed.username,
&authed.email,
authed.job_id.is_some(),
&db,
)
.await?;
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
}
+38
View File
@@ -130,6 +130,12 @@ pub struct EditResourceType {
pub schema: Option<serde_json::Value>,
pub description: Option<String>,
pub is_fileset: Option<bool>,
/// Doubly optional so an edit can distinguish the two things a plain
/// `Option` conflates: an absent field leaves the extension alone, while an
/// explicit `null` clears it. A hub pull relies on both — a type that stops
/// being a file type has to stop being one locally too.
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
pub format_extension: Option<Option<String>>,
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -2803,10 +2809,42 @@ async fn update_resource_type(
if let Some(is_fileset) = ns.is_fileset {
sqlb.set("is_fileset", if is_fileset { "TRUE" } else { "FALSE" });
}
if let Some(format_extension) = ns.format_extension.clone() {
match format_extension {
Some(ext) => sqlb.set_str("format_extension", ext),
None => sqlb.set("format_extension", "NULL"),
};
}
sqlb.set_str("edited_at", "now()");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
// Creation refuses the pair outright, so an edit must too — otherwise the same
// impossible type (a set of files that is also one file) is reachable by setting
// either half on an existing row. Whichever half the request omits is read from
// the row being edited, inside this transaction and with the row locked: read
// outside it, two concurrent edits each supplying one half would both pass.
let current = sqlx::query!(
"SELECT is_fileset, format_extension FROM resource_type
WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
&name,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
let effective_is_fileset = ns
.is_fileset
.unwrap_or_else(|| current.as_ref().map(|c| c.is_fileset).unwrap_or(false));
let effective_format_extension = match &ns.format_extension {
Some(value) => value.clone(),
None => current.and_then(|c| c.format_extension),
};
if effective_is_fileset && effective_format_extension.is_some() {
return Err(Error::BadRequest(
"A fileset resource type cannot have a format_extension".to_string(),
));
}
sqlx::query(&sql).execute(&mut *tx).await?;
audit_log(
&mut *tx,
+19
View File
@@ -66,3 +66,22 @@ where
NumericOrNull::Null => Ok(None),
}
}
/// Deserializer for a doubly-optional field, so a struct can tell an absent key
/// (`None`) from an explicit `null` (`Some(None)`).
///
/// Plain serde collapses both into the outer `None`, which makes the distinction
/// unusable exactly where it matters: a payload that omits a field means "leave it
/// alone", while one that sends `null` means "clear it".
///
/// ```ignore
/// #[serde(default, deserialize_with = "double_option", skip_serializing_if = "Option::is_none")]
/// pub field: Option<Option<String>>,
/// ```
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
+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.799.0";
export const VERSION = "v1.800.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+5 -1
View File
@@ -17,6 +17,9 @@ interface HubResourceType {
app: string;
description: string;
is_fileset?: boolean;
// Absent from hubs predating the column, so a missing value is "ordinary type",
// not "unset it".
format_extension?: string | null;
}
export async function pull(opts: GlobalOptions) {
@@ -116,7 +119,8 @@ export async function pull(opts: GlobalOptions) {
typeof y.schema !== "string" &&
deepEqual(y.schema, x.schema) &&
y.description === x.description &&
(y.is_fileset ?? false) === (x.is_fileset ?? false)
(y.is_fileset ?? false) === (x.is_fileset ?? false) &&
(y.format_extension ?? null) === (x.format_extension ?? null)
)
) {
log.info("skipping " + x.name + " (same as current)");
@@ -25,6 +25,9 @@ export interface ResourceTypeFile {
schema?: any;
description?: string;
is_fileset?: boolean;
// Extension for a type whose value is one file rather than a set of fields; it
// is what makes the resource editor a file editor for that language.
format_extension?: string | null;
}
export async function pushResourceType(
+108 -44
View File
@@ -26,37 +26,33 @@ async function readDirRecursive(
return out;
}
/** The workspace's shared UI store, or undefined when it cannot be read. */
async function fetchSharedUi(
workspace: string,
): Promise<Record<string, string> | undefined> {
try {
const got = await wmill.getSharedUi({ workspace });
return got.files ?? {};
} catch {
return undefined;
}
}
export type SharedUiChange =
| { type: "added"; path: string }
| { type: "edited"; path: string; before: string; after: string }
| { type: "deleted"; path: string };
/**
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
* the push direction (local -> remote), returning entries whose `path` is
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
* dry-run preview and the real push never diverge.
*
* Mirrors pushSharedUi's no-op: with no local ui/ folder there is nothing to
* push, so the apply is a no-op and the preview must be empty (even when the
* remote store is non-empty) to avoid phantom diffs the apply won't perform.
* The push-direction diff (local -> remote) of two already-read file maps.
* Split out so the preview and the push itself compare identically off one
* read of each side.
*/
export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return [];
}
const files = await readDirRecursive(localDir);
let remote: Record<string, string> = {};
try {
const got = await wmill.getSharedUi({ workspace });
remote = got.files ?? {};
} catch {
// If endpoint missing or unauthorized, treat remote as empty (the push
// would attempt the PUT anyway).
}
function sharedUiChanges(
files: Record<string, string>,
remote: Record<string, string>,
keepDeleted?: boolean,
): SharedUiChange[] {
// Use Object.hasOwn, not `in`: a file named after an Object.prototype member
// (e.g. ui/toString) would otherwise register as always-present and be
// misdiffed.
@@ -69,34 +65,97 @@ export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]>
changes.push({ type: "edited", path: p, before: remote[rel], after: content });
}
}
for (const rel of Object.keys(remote)) {
if (!Object.hasOwn(files, rel)) {
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
if (!keepDeleted) {
for (const rel of Object.keys(remote)) {
if (!Object.hasOwn(files, rel)) {
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
}
}
}
return changes;
}
/**
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
* the push direction (local -> remote), returning entries whose `path` is
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
* dry-run preview and the real push never diverge.
*
* Mirrors pushSharedUi's no-ops: with no local ui/ folder, or under
* `keepDeleted` with an unreadable store, the apply does nothing, so the
* preview must be empty (even when the remote store is non-empty) to avoid
* phantom diffs the apply won't perform.
*/
export async function diffSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<SharedUiChange[]> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return [];
}
const files = await readDirRecursive(localDir);
const remote = await fetchSharedUi(workspace);
if (remote === undefined && keepDeleted) {
return [];
}
// If endpoint missing or unauthorized, treat remote as empty (the push
// would attempt the PUT anyway).
return sharedUiChanges(files, remote ?? {}, keepDeleted);
}
/**
* Push the local <cwd>/ui/ folder to the workspace's shared UI store.
* Returns true if a push was performed, false if the folder is missing or
* already matches the remote store. Note an empty-but-existing folder still
* pushes an empty map (clearing the remote store) if the remote is non-empty.
* pushes an empty map (clearing the remote store) if the remote is non-empty
* unless `keepDeleted`, which folds remote-only files back into the map.
*/
export async function pushSharedUi(workspace: string): Promise<boolean> {
export async function pushSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<boolean> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return false;
}
// Skip if no change — reuse diffSharedUi so preview and push never diverge.
const diff = await diffSharedUi(workspace);
if (diff.length === 0) {
const files = await readDirRecursive(localDir);
const remote = await fetchSharedUi(workspace);
// The store is written whole, so a remote-only file is pruned by omission
// alone. An unreadable store leaves no way to carry those files over, so
// the shared UI is left untouched rather than cleared.
if (remote === undefined && keepDeleted) {
log.warn(
colors.yellow(
"Could not read the shared UI folder from the remote; skipping its push so --keep-deleted does not clear it.",
),
);
return false;
}
// Same comparison as diffSharedUi, off the same two maps, so preview and
// push never diverge.
if (sharedUiChanges(files, remote ?? {}, keepDeleted).length === 0) {
log.info(colors.gray("Shared UI folder up to date"));
return false;
}
const files = await readDirRecursive(localDir);
if (keepDeleted) {
for (const [rel, content] of Object.entries(remote!)) {
// defineProperty, not assignment: `files.__proto__ = "..."` runs the
// inherited setter and creates no own property, so a remote `ui/__proto__`
// would be dropped from the whole-map PUT — i.e. deleted despite the flag.
if (!Object.hasOwn(files, rel)) {
Object.defineProperty(files, rel, {
value: content,
writable: true,
enumerable: true,
configurable: true,
});
}
}
}
await wmill.updateSharedUi({
workspace,
requestBody: { files },
@@ -111,9 +170,12 @@ export async function pushSharedUi(workspace: string): Promise<boolean> {
/**
* Pull the workspace's shared UI store into <cwd>/ui/.
* Files removed remotely are also removed locally.
* Files removed remotely are also removed locally, unless `keepDeleted`.
*/
export async function pullSharedUi(workspace: string): Promise<boolean> {
export async function pullSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<boolean> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
let got;
try {
@@ -145,15 +207,17 @@ export async function pullSharedUi(workspace: string): Promise<boolean> {
}
// Delete locally-orphaned files
const known = new Set(Object.keys(files));
const local = await readDirRecursive(localDir);
for (const rel of Object.keys(local)) {
if (!known.has(rel)) {
const full = path.join(localDir, rel);
try {
fs.unlinkSync(full);
} catch {
// ignore
if (!keepDeleted) {
const known = new Set(Object.keys(files));
const local = await readDirRecursive(localDir);
for (const rel of Object.keys(local)) {
if (!known.has(rel)) {
const full = path.join(localDir, rel);
try {
fs.unlinkSync(full);
} catch {
// ignore
}
}
}
}
+110 -26
View File
@@ -3174,6 +3174,53 @@ export function untrackedDatatableMigrationDeletions<
);
}
/**
* Whether a pull change removes a local dbt descriptor. A dbt project's
* descriptor is optional and the remote spells "this project names none" as
* empty content, so its removal reaches the apply loop as an add or an edit
* whose content is `""` a `deleted` change is never produced for it.
*/
function removesDbtDescriptor(change: Change): boolean {
return (
isDbtDescriptorPath(change.path) &&
((change.name === "added" && change.content === "") ||
(change.name === "edited" && change.after === ""))
);
}
/**
* `--keep-deleted`: strip every deletion from the changeset, in place, so the
* sync only adds and updates. A path missing on one side is not on its own
* evidence that it should go from the other a partial clone, a scoped
* checkout or an item authored in the UI all read as deletions here.
*
* A dbt descriptor the remote no longer names counts too, but only on a pull,
* where applying it removes a local file `added` as much as `edited`, since a
* stateful pull compares `.wmill` and absence from that map says nothing about
* the working tree the apply loop deletes from. A push removes nothing: the
* descriptor is a script's content, so an empty one updates the script in place.
*/
export function dropDeletions(
changes: Change[],
keptOn: "local" | "remote",
): void {
const isDeletion = (c: Change) =>
c.name === "deleted" || (keptOn === "local" && removesDbtDescriptor(c));
const deletions = changes.filter(isDeletion);
if (deletions.length === 0) return;
const kept = changes.filter((c) => !isDeletion(c));
changes.length = 0;
changes.push(...kept);
log.info(
colors.yellow(
`--keep-deleted: keeping ${deletions.length} item(s) that exist only ` +
(keptOn === "local"
? `on disk instead of deleting them locally`
: `on the remote instead of deleting them from the workspace`),
),
);
}
interface ChangeTracker {
scripts: string[];
flows: string[];
@@ -3433,6 +3480,7 @@ export async function pull(
repository?: string;
promotion?: string;
branch?: string;
keepDeleted?: boolean;
useIndividualBranch?: boolean;
groupByFolder?: boolean;
gitDeployItems?: string;
@@ -3684,6 +3732,10 @@ export async function pull(
await isCaseInsensitiveFilesystem(process.cwd()),
);
if (opts.keepDeleted) {
dropDeletions(changes, "local");
}
log.info(
`remote (${workspace.name}) -> local: ${changes.length} changes to apply`,
);
@@ -3781,22 +3833,17 @@ export async function pull(
const target = path.join(process.cwd(), targetPath);
const stateTarget = path.join(process.cwd(), ".wmill", targetPath);
// An empty dbt descriptor is not a file: the remote spells "this project
// named no descriptor" as empty content, and writing that would put a
// Windmill file inside a project that has none. ABSENCE is the state to
// reach, so both copies are removed if present and their being missing —
// a project pulled for the first time — is the goal, not an error. The
// `.wmill` copy goes too, or the same change is reported on every pull.
// ABSENCE is the state to reach, never an empty file: writing one would
// put a Windmill file inside a project that has none. So both copies are
// removed if present, and their being missing — a project pulled for the
// first time — is the goal, not an error. The `.wmill` copy goes too, or
// the same change is reported on every pull.
//
// `force` covers the missing file and NOTHING else: a permission or
// read-only-filesystem failure has to surface, or the pull reports
// success while the old descriptor — its warehouse, its command, its
// arguments — is still what runs locally.
if (
isDbtDescriptorPath(change.path) &&
((change.name === "added" && change.content === "") ||
(change.name === "edited" && change.after === ""))
) {
if (removesDbtDescriptor(change)) {
await rm(target, { force: true });
if (opts.stateful) {
await rm(stateTarget, { force: true });
@@ -4117,10 +4164,14 @@ export async function pull(
}
}
try {
await pullSharedUi(workspace.workspaceId);
} catch (e) {
log.warn(`Failed to pull shared UI folder: ${e}`);
// Skipped under --dry-run since pullSharedUi writes to the local ui/ folder.
// An empty changeset falls through the return above and reaches here.
if (!opts.dryRun) {
try {
await pullSharedUi(workspace.workspaceId, opts.keepDeleted);
} catch (e) {
log.warn(`Failed to pull shared UI folder: ${e}`);
}
}
// Datatable migrations are part of the workspace export now, so they flow
@@ -4425,12 +4476,19 @@ function removeSuffix(str: string, suffix: string) {
}
// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push`
// deploys to the remote workspace and is destructive (it overwrites and prunes
// remote items that differ from or are absent locally), so the preview reminds
// the caller — especially an AI agent that ran the dry-run to inspect changes —
// to get explicit user confirmation before applying it for real.
const SYNC_PUSH_DESTRUCTIVE_WARNING =
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation.";
// deploys to the remote workspace and is destructive (it overwrites remote items
// that differ from local, and prunes those absent locally unless --keep-deleted),
// so the preview reminds the caller — especially an AI agent that ran the dry-run
// to inspect changes — to get explicit user confirmation before applying it for real.
function syncPushDestructiveWarning(keepDeleted?: boolean): string {
return (
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites " +
(keepDeleted
? "remote items that differ from local"
: "or deletes remote items that differ from or are absent locally") +
" — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."
);
}
// A script pushed without a local lock queues a server-side dependency job; if
// that job fails the script deploys broken (no lock/assets) with no CLI signal.
@@ -4490,6 +4548,7 @@ export async function push(
SyncOptions & {
repository?: string;
branch?: string;
keepDeleted?: boolean;
acceptOverridingPermissionedAsWithSelf?: boolean;
},
) {
@@ -4791,6 +4850,13 @@ export async function push(
);
}
// After the shared-lock pass, which reads a shared lockfile's deletion as the
// signal that this checkout is not deduplicated — an advisory about the local
// tree that holds whether or not remote items are being kept.
if (opts.keepDeleted) {
dropDeletions(changes, "remote");
}
const autoRegenerate = !!(opts as any).autoMetadata;
const staleScripts: string[] = [];
const staleFlows: string[] = [];
@@ -5103,7 +5169,10 @@ export async function push(
// unchanged (pushSharedUi still runs) and the summary count includes ui/.
if (opts.dryRun) {
try {
for (const c of await diffSharedUi(workspace.workspaceId)) {
for (const c of await diffSharedUi(
workspace.workspaceId,
opts.keepDeleted,
)) {
if (c.type === "added") {
changes.push({ name: "added", path: c.path, content: "" });
} else if (c.type === "deleted") {
@@ -5275,7 +5344,9 @@ export async function push(
: {}),
})),
total: changes.length,
...(changes.length > 0 ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } : {}),
...(changes.length > 0
? { warning: syncPushDestructiveWarning(opts.keepDeleted) }
: {}),
};
console.log(JSON.stringify(result, null, 2));
return;
@@ -5339,7 +5410,9 @@ export async function push(
if (opts.dryRun) {
log.info(colors.gray(`Dry run complete.`));
log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`));
log.warn(
colors.yellow(`\n⚠ ${syncPushDestructiveWarning(opts.keepDeleted)}`),
);
return;
}
@@ -6313,7 +6386,7 @@ export async function push(
}
}
try {
await pushSharedUi(workspace.workspaceId);
await pushSharedUi(workspace.workspaceId, opts.keepDeleted);
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
@@ -6415,7 +6488,10 @@ export async function push(
let sharedUiPushed = false;
if (!opts.dryRun) {
try {
sharedUiPushed = await pushSharedUi(workspace.workspaceId);
sharedUiPushed = await pushSharedUi(
workspace.workspaceId,
opts.keepDeleted,
);
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
@@ -6480,6 +6556,10 @@ const command = new Command()
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option("--include-key", "Include workspace encryption key")
.option(
"--keep-deleted",
"Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
@@ -6543,6 +6623,10 @@ const command = new Command()
"--skip-reencrypt-on-key-change",
"When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.",
)
.option(
"--keep-deleted",
"Do not delete remote items that no longer exist locally. Only adds and updates.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
+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.799.0";
export const VERSION = "1.800.1";
+2
View File
@@ -7519,6 +7519,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes
@@ -7550,6 +7551,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
+46 -1
View File
@@ -7,7 +7,11 @@ import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { FSFSElement, elementsToMap } from "../src/commands/sync/sync.ts";
import {
FSFSElement,
dropDeletions,
elementsToMap,
} from "../src/commands/sync/sync.ts";
import { listWorkspacePaths } from "../src/commands/dev/dev.ts";
import {
DbtPathCollisionError,
@@ -293,6 +297,47 @@ describe("a dbt project without a descriptor", () => {
});
});
// Unguarded, `--keep-deleted` lets a pull remove the very file it promises to
// keep — the descriptor's warehouse and run arguments with it.
describe("--keep-deleted and a descriptor the remote no longer names", () => {
const DESCRIPTOR = "f/analytics/analytics__dbt/wm_dbt.yaml";
const emptied = {
name: "edited",
path: DESCRIPTOR,
before: "warehouse: wh\n",
after: "",
};
const unknownToTheMap = { name: "added", path: DESCRIPTOR, content: "" };
const changeset = (descriptor: unknown) =>
[
descriptor,
{ name: "edited", path: "f/other/script.ts", before: "a", after: "b" },
] as Parameters<typeof dropDeletions>[0];
test("survives a pull", () => {
const changes = changeset(emptied);
dropDeletions(changes, "local");
expect(changes.map((c) => c.path)).toEqual(["f/other/script.ts"]);
});
// A stateful pull compares `.wmill`, so a descriptor missing from the compared
// map can still be a working-tree file holding real settings.
test("survives a pull that compared a map without it", () => {
const changes = changeset(unknownToTheMap);
dropDeletions(changes, "local");
expect(changes.map((c) => c.path)).toEqual(["f/other/script.ts"]);
});
test("is still pushed, deleting no remote item", () => {
const changes = changeset(emptied);
dropDeletions(changes, "remote");
expect(changes.map((c) => c.path)).toEqual([
DESCRIPTOR,
"f/other/script.ts",
]);
});
});
// `wmill dev` walks basenames, and a dbt script is a DIRECTORY whose descriptor
// may not exist — so it is recognized by the project folder or not at all. The
// walk must also stop there: the project's own `.sql` models match the script
+59 -2
View File
@@ -11,12 +11,25 @@ import * as os from "node:os";
import * as path from "node:path";
let remoteFiles: Record<string, string> = {};
let remoteUnreadable = false;
let pushedFiles: Record<string, string> | undefined;
mock.module("../gen/services.gen.ts", () => ({
getSharedUi: async (_args: { workspace: string }) => ({ files: remoteFiles }),
getSharedUi: async (_args: { workspace: string }) => {
if (remoteUnreadable) throw new Error("shared UI store unreadable");
return { files: remoteFiles };
},
updateSharedUi: async (args: {
workspace: string;
requestBody: { files: Record<string, string> };
}) => {
pushedFiles = args.requestBody.files;
},
}));
const { diffSharedUi } = await import("../src/commands/shared_ui.ts");
const { diffSharedUi, pushSharedUi } = await import(
"../src/commands/shared_ui.ts"
);
describe("diffSharedUi", () => {
const ws = "test-workspace";
@@ -25,6 +38,8 @@ describe("diffSharedUi", () => {
beforeEach(() => {
remoteFiles = {};
remoteUnreadable = false;
pushedFiles = undefined;
prevCwd = process.cwd();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-shared-ui-"));
process.chdir(tmpDir);
@@ -89,4 +104,46 @@ describe("diffSharedUi", () => {
const changes = await diffSharedUi(ws);
expect(changes).toEqual([]);
});
test("emits nothing under keepDeleted when the remote store is unreadable", async () => {
// pushSharedUi skips the push rather than clearing a store it can't read,
// so the preview must show that same nothing.
remoteUnreadable = true;
writeUi("theme.json", "{}");
expect(await diffSharedUi(ws, true)).toEqual([]);
});
});
describe("pushSharedUi with keepDeleted", () => {
const ws = "test-workspace";
let tmpDir: string;
let prevCwd: string;
beforeEach(() => {
remoteFiles = {};
remoteUnreadable = false;
pushedFiles = undefined;
prevCwd = process.cwd();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-shared-ui-push-"));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("carries remote-only files, including ui/__proto__, into the pushed map", async () => {
// JSON.parse, not a literal: `{__proto__: …}` sets the prototype instead of
// creating the own property the API response really has.
remoteFiles = JSON.parse('{"__proto__":"keep me","extra.json":"1"}');
fs.mkdirSync(path.join(tmpDir, "ui"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "ui", "theme.json"), "{}", "utf-8");
expect(await pushSharedUi(ws, true)).toBe(true);
// The store is written whole, so anything missing here is deleted.
expect(pushedFiles!["extra.json"]).toEqual("1");
expect(Object.getOwnPropertyDescriptor(pushedFiles!, "__proto__")?.value)
.toEqual("keep me");
});
});
+99
View File
@@ -2774,3 +2774,102 @@ kind: script
});
});
});
describe("keep deleted", () => {
test("Integration: --keep-deleted keeps items absent from the other side", async () => {
await withTestBackend(async (backend, tempDir) => {
const uniqueId = Date.now();
const scriptPath = `f/test/keep_deleted_${uniqueId}`;
const resp = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: scriptPath,
content: 'export async function main() { return "keep me"; }',
language: "bun",
summary: "Kept by --keep-deleted",
schema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {},
required: [],
},
}),
}
);
expect(resp.status).toBeLessThan(300);
await resp.text();
await writeWmillYaml(tempDir);
expect(
(await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code
).toEqual(0);
const contentFile = `${scriptPath}.ts`;
const metadataFile = `${scriptPath}.script.yaml`;
expect(await listFilesRecursive(tempDir)).toContain(contentFile);
// Push direction: the remote script survives losing its local files.
await rm(join(tempDir, contentFile));
await rm(join(tempDir, metadataFile));
expect(
(
await backend.runCLICommand(
["sync", "push", "--yes", "--keep-deleted"],
tempDir
)
).code
).toEqual(0);
// A push deletion archives the script rather than removing the row, so
// `archived` — not the status code — is what says it survived.
const remote = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`
);
expect(remote.status).toEqual(200);
expect((await remote.json()).archived).not.toEqual(true);
// Pull direction: a file with no remote counterpart survives the pull.
const localOnly = `f/test/local_only_${uniqueId}.ts`;
await writeFile(
join(tempDir, localOnly),
'export async function main() { return "local only"; }',
"utf-8"
);
expect(
(
await backend.runCLICommand(
["sync", "pull", "--yes", "--keep-deleted"],
tempDir
)
).code
).toEqual(0);
const afterPull = await listFilesRecursive(tempDir);
expect(afterPull).toContain(localOnly);
// Adds still apply: the script deleted above is written back.
expect(afterPull).toContain(contentFile);
// An empty changeset falls past the dry-run return, on to the shared-UI
// step — which writes to disk, so a dry run must skip it.
// Including the metadata and lock the pull's auto-fill generated for it.
for (const ext of [".ts", ".script.yaml", ".script.lock"]) {
await rm(join(tempDir, `f/test/local_only_${uniqueId}${ext}`), {
force: true,
});
}
await mkdir(join(tempDir, "ui"), { recursive: true });
await writeFile(join(tempDir, "ui", "custom.css"), "body{}", "utf-8");
const dryRun = await backend.runCLICommand(
["sync", "pull", "--dry-run"],
tempDir
);
expect(dryRun.code).toEqual(0);
// Guards against a vacuous pass: a non-empty changeset would return at
// the dry-run check above and never reach the shared-UI step.
expect(dryRun.stdout + dryRun.stderr).toContain("0 changes to apply");
expect(await listFilesRecursive(tempDir)).toContain("ui/custom.css");
});
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.1",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+11 -2
View File
@@ -7,6 +7,7 @@ import {
type AIProviderModel,
type AIProvider,
type AIConfig,
type FreeTierInfo,
type ModelPriceOverride
} from './gen'
import {
@@ -49,6 +50,10 @@ export const copilotInfo = writable<{
/** Negotiated rates per `provider:model`, overriding the built-in price table. */
modelPricing?: Record<string, ModelPriceOverride>
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
// Set only when the workspace has no AI provider of its own and is running on
// Windmill's free tier. `exhausted` means the grant is spent: there is no model, but
// that is a different state from "never configured" and the UI must say so.
freeTier?: FreeTierInfo
}>({
enabled: false,
codeCompletionModel: undefined,
@@ -132,8 +137,9 @@ export function setCopilotInfo(aiConfig: AIConfig) {
aiModels: aiModels,
customPrompts: aiConfig.custom_prompts ?? {},
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
webSearchEnabledProviders,
modelPricing: aiConfig.model_pricing ?? {},
webSearchEnabledProviders
freeTier: aiConfig.free_tier
})
} else {
copilotSessionModel.set(undefined)
@@ -146,8 +152,11 @@ export function setCopilotInfo(aiConfig: AIConfig) {
aiModels: [],
customPrompts: {},
maxTokensPerModel: {},
webSearchEnabledProviders: {},
modelPricing: {},
webSearchEnabledProviders: {}
// An exhausted free grant lands here — no providers, but the reason AI is off
// is "you used it up", not "you never set it up".
freeTier: aiConfig.free_tier
})
}
}
+29 -44
View File
@@ -8,9 +8,11 @@
interface Props {
/** File path → content map. Keys use / prefix (e.g. /index.html). */
files: Record<string, string>
/** Currently selected path (/-prefixed). Read-only; changes via onSelectPath callback. */
/** Currently selected file (/-prefixed). Read-only; changes via onSelectPath callback. */
selectedPath?: string | undefined
/** Called when user clicks a path (file or folder). */
/** Called when the user clicks a file. Folders aren't selectable — clicking
* one only expands it — so the only non-file paths this reports are the root
* row under `showRoot`, and '' when the last file is deleted. */
onSelectPath?: (path: string) => void
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
extraNodes?: TreeNode[]
@@ -80,6 +82,12 @@
onSelectPath?.(path)
}
function parentFolderOfSelection(): string {
if (!selectedPath || selectedPath === '/') return '/'
const pathParts = selectedPath.split('/').filter(Boolean)
return pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
}
function handleAddFile(folderPath: string) {
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfile.txt'
@@ -88,20 +96,10 @@
pathToEdit = newPath
}
export function handleAddRootFile() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfile.txt'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfile.txt'
}
} else {
basePath = '/newfile.txt'
}
// New entries land beside the selected file; with nothing selected, at the
// root. To create inside another folder, use that folder row's own menu.
export function handleAddFileBesideSelection() {
const basePath = parentFolderOfSelection() + 'newfile.txt'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
@@ -115,20 +113,8 @@
pathToEdit = newPath
}
export function handleAddRootFolder() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfolder/'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfolder/'
}
} else {
basePath = '/newfolder/'
}
export function handleAddFolderBesideSelection() {
const basePath = parentFolderOfSelection() + 'newfolder/'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
@@ -168,9 +154,7 @@
}
// Also rename in emptyFolders
emptyFolders = emptyFolders.map((f) =>
f === oldPath || f.startsWith(oldPath)
? newPath + f.substring(oldPath.length)
: f
f === oldPath || f.startsWith(oldPath) ? newPath + f.substring(oldPath.length) : f
)
}
} else {
@@ -187,7 +171,13 @@
files = nfiles
pathToEdit = undefined
onSelectPath?.(newPath)
if (!isFolder) {
onSelectPath?.(newPath)
} else if (selectedPath?.startsWith(oldPath)) {
// A folder isn't selectable, but the selected file moved with it — follow
// it to its new path, or the caller keeps editing a key that's now gone.
onSelectPath?.(newPath + selectedPath.slice(oldPath.length))
}
}
function handleDelete(path: string) {
@@ -208,12 +198,8 @@
files = nfiles
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
const remaining = Object.keys(nfiles)
if (remaining.length > 0) {
onSelectPath?.(remaining[0])
} else {
onSelectPath?.(showRoot ? '/' : '')
}
const remainingFile = Object.keys(nfiles).find((key) => !key.endsWith('/'))
onSelectPath?.(remainingFile ?? (showRoot ? '/' : ''))
}
}
</script>
@@ -223,7 +209,7 @@
<span class="text-xs font-semibold text-emphasis">Files</span>
<div class="flex gap-1">
<Button
onClick={handleAddRootFile}
onClick={handleAddFileBesideSelection}
title="Add file"
unifiedSize="xs"
variant="subtle"
@@ -233,7 +219,7 @@
<File size={12} />
</Button>
<Button
onClick={handleAddRootFolder}
onClick={handleAddFolderBesideSelection}
title="Add folder"
unifiedSize="xs"
variant="subtle"
@@ -255,8 +241,7 @@
: ''}"
>
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
<span
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
<span class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
>/</span
>
</button>
@@ -9,6 +9,11 @@
type: 'string' | 'number' | 'boolean'
allowMultiple?: boolean
format?: 'json'
/** Boolean only: the value the filter holds while unset (defaults to false).
* Selecting a filter whose default is false sets it to true immediately rather
* than opening a true/false picker whose only useful choice is true. A default-true
* boolean still opens the picker, since choosing false is the meaningful action. */
default?: boolean
}
| {
type: 'date'
@@ -121,16 +126,34 @@
// Create the filter instance object
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
// Sync URL params to filter instance on initialization and when URL changes
// Sync URL params to filter instance, reactively. Reading urlFilter[key] tracked
// means browser Back/Forward — which mutates useSearchParams' cells on popstate —
// flows into the instance (chips, kind toggle, results), not just the first render.
// The write happens untracked so it can't self-trigger, and the equality check plus
// the reverse effect's own guard keep the two directions from ping-ponging.
for (const key of Object.keys(schemaRec)) {
let urlValue = urlFilter[key]
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
const d = new Date(urlValue)
urlValue = isNaN(d.getTime()) ? null : d
}
if (urlValue !== undefined && urlValue !== null) {
;(filterInstance.val as any)[key] = urlValue
}
$effect(() => {
let urlValue = urlFilter[key]
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
const d = new Date(urlValue)
urlValue = isNaN(d.getTime()) ? null : d
}
untrack(() => {
const current = (filterInstance.val as any)[key]
const same =
urlValue instanceof Date && current instanceof Date
? urlValue.getTime() === current.getTime()
: current === (urlValue ?? undefined)
if (same) return
if (urlValue !== undefined && urlValue !== null) {
;(filterInstance.val as any)[key] = urlValue
} else if (current !== undefined) {
// Key dropped from the URL (Back to a state without it): clear it so a
// stale chip / filter doesn't linger against the navigated-to URL.
delete (filterInstance.val as any)[key]
}
})
})
}
// Sync filter instance changes back to URL params
@@ -275,6 +298,17 @@
class?: string
placeholder?: string
autofocus?: boolean
// Applied as the id of the underlying editable, so a parent can focus it or recognise its
// key events by id (the searchbar is a contenteditable, not an <input>).
inputId?: string
// Free-text mode: while the input holds only free text (no specific filter tag is
// being edited and no non-default filter is set), suppress the suggestions dropdown
// so it behaves like a plain search box. This frees the arrow keys for the
// surrounding UI (e.g. a results list). The dropdown returns the moment a specific
// filter is present (e.g. `path: u/me/abc`).
hideDropdownOnFreeText?: boolean
// Notified whenever the dropdown's effective visibility changes
onDropdownVisibleChange?: (visible: boolean) => void
}
type SchemaT = FilterSchemaRec // TODO: Generic
@@ -284,7 +318,10 @@
presets: _presets = [],
class: className,
placeholder = 'Filter...',
autofocus
autofocus,
hideDropdownOnFreeText = false,
onDropdownVisibleChange,
inputId
}: Props<SchemaT> = $props()
let _value = new DebouncedTempValue(
@@ -298,6 +335,24 @@
let currentTag: keyof SchemaT | undefined = $state()
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
let open = $state(false)
// A specific filter is in play when a tag is being edited or any non-free-text filter
// is set.
let hasSpecificFilter = $derived(
!!currentTag || Object.keys(value).some((k) => k !== '_default_')
)
// A plain search term is being typed (free text, no specific filter).
let hasFreeText = $derived(!!String(value['_default_'] ?? '').trim())
// Effective dropdown visibility. Free-text mode suppresses the dropdown ONLY while the
// user is typing a plain search term: it still opens when the input is empty (so the
// available filters stay discoverable) and whenever a specific filter is set or being
// edited. That leaves the arrow keys for the surrounding list only during free-text search.
let dropdownVisible = $derived(
open && (!hideDropdownOnFreeText || hasSpecificFilter || !hasFreeText)
)
$effect(() => {
onDropdownVisibleChange?.(dropdownVisible)
})
let inputElement: HTMLDivElement | undefined = $state()
let highlightedIndex = $state(0)
let taggedTextInput: TaggedTextInput | undefined = $state()
@@ -347,9 +402,17 @@
key,
filterSchema,
onClick: () => {
// Replace the text segment with the new filter tag
const before = asText.val.slice(0, currentTextSegment.start)
const after = asText.val.slice(currentTextSegment.end)
if (schema[key].type === 'boolean' && schema[key].default !== true) {
// Set the only useful value and reparse to canonical text. The space is
// required: dropping the segment must not fuse the tags that flanked it.
asText.val = `${before} ${after}`
value[key] = true as any
asText.reparse()
return
}
// Replace the text segment with the new (empty) filter tag; the value picker opens.
asText.val =
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
'\u00A0'
@@ -406,6 +469,28 @@
onClick: () => setValueForCurrentTag(false)
}
]
} else if (filter.type === 'string' && filter.format !== 'json') {
// A plain string filter has no fixed options, but any presets targeting this tag
// (`<tag>:<value>`) are exactly its useful values — surface them as suggestions so
// picking one is a click, matching the top-level preset row. Unescape the tagged
// syntax's `\ ` back to a real space for the stored value.
const prefix = `${String(currentTag)}:`
const suffix = String(value[currentTag!] ?? '')
.trim()
.toLowerCase()
return _presets
.filter((p) => p.value.startsWith(prefix) && !asText.val.includes(p.value))
.map((p) => {
const raw = p.value.slice(prefix.length).replace(/^\\ /, '').replace(/\\ /g, ' ')
return { name: p.name, raw }
})
.filter((p) => !suffix || p.raw.toLowerCase().includes(suffix))
.map((p) => ({
type: 'option' as const,
option: { value: p.raw, label: p.name },
onClick: () => appendOrSetValueForCurrentTag(p.raw),
onNegativeClick: undefined
}))
}
}
return []
@@ -514,7 +599,9 @@
}
function handleKeyDown(e: KeyboardEvent) {
if (!open) return
// In free-text mode the dropdown is hidden; let arrow/enter keys pass through to
// the surrounding UI (e.g. list navigation) rather than steering a hidden menu.
if (!dropdownVisible) return
if (e.key === 'Escape') {
open = false
return
@@ -601,6 +688,7 @@
>
<TaggedTextInput
bind:this={taggedTextInput}
id={inputId}
bind:value={asText.val}
{tags}
highlights={[
@@ -617,7 +705,19 @@
inputSizeClasses.md
)}
{placeholder}
onKeyDown={() => (open = true)}
onKeyDown={(e) => {
// In free-text mode the searchbar coexists with a list that owns Arrow/Enter, so opening
// the dropdown on a bare navigation key would steal them from an empty box. Typing, click,
// or an already-open dropdown still open/keep it. Other searchbars keep opening on any key.
if (
!hideDropdownOnFreeText ||
!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'].includes(
e.key
)
) {
open = true
}
}}
{autofocus}
/>
{#if asText.val}
@@ -630,9 +730,10 @@
</div>
<GenericDropdown
{open}
open={dropdownVisible}
instantClose={hideDropdownOnFreeText}
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
innerClass="!max-h-[30rem]"
innerClass="!max-h-[25rem]"
strictWidth
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -747,6 +848,20 @@
class="border border-border-light rounded min-h-[4rem]"
/>
</div>
{:else if filter.type === 'string'}
{#if menuItems.length}
<div class="max-h-60 overflow-y-auto">
{#each menuItems as item, index}
{#if item.type === 'option' && item.option}
{@render menuItem({
onClick: item.onClick,
label: item.option.label || item.option.value,
highlighted: index === highlightedIndex
})}
{/if}
{/each}
</div>
{/if}
{/if}
{/snippet}
@@ -1070,8 +1070,9 @@
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
are started for, whether AI chat skills are turned on or off and how often one is
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1123,8 +1124,9 @@
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
are started for, whether AI chat skills are turned on or off and how often one is
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1152,20 +1154,19 @@
<div class="pb-4">
<Alert type="info" title="Log files stay on local disk" size="xs">
Instance object storage is not configured, so every server and worker keeps its log
files on its own disk. This page lists what each host wrote, but can only open the
files belonging to the replica serving the request — another host's are listed and
not readable — and a host's files go with it when it is replaced. Retention below
still governs the entries in the database and the files on disk.
files on its own disk. This page lists what each host wrote, but can only open the files
belonging to the replica serving the request — another host's are listed and not
readable — and a host's files go with it when it is replaced. Retention below still
governs the entries in the database and the files on disk.
</Alert>
</div>
{:else if !$enterpriseLicense}
<div class="pb-4">
<Alert type="info" title="Raw log files accumulate without the indexer" size="xs">
Log files are uploaded to instance object storage, and the indexer that would ingest
them into the columnar store and delete each one afterwards is an enterprise
feature. Retention below expires the database entries and the local files; the
uploaded copies are only removed when <b>Delete logs from s3 periodically</b> is on
under Object Storage.
them into the columnar store and delete each one afterwards is an enterprise feature.
Retention below expires the database entries and the local files; the uploaded copies
are only removed when <b>Delete logs from s3 periodically</b> is on under Object Storage.
</Alert>
</div>
{/if}
@@ -8,6 +8,7 @@
onTextSegmentAtCursorChange,
onKeyDown,
autofocus,
id,
class: className = ''
}: {
tags: { regex: RegExp; id: string; onClear?: () => void }[]
@@ -18,6 +19,7 @@
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
onKeyDown?: (e: KeyboardEvent) => void
autofocus?: boolean
id?: string
class?: string
} = $props()
@@ -337,7 +339,13 @@
function handleKeyDown(e: KeyboardEvent) {
onKeyDown?.(e)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
// Single-line filter input: block Enter's default newline insertion. Surrounding handlers
// (suggestion select, list open) still run on bubble; only the contenteditable break is gone.
if (e.key === 'Enter') {
e.preventDefault()
return
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') return
const cursorPos = getCursorPosition()
const text = getTextContent()
@@ -509,6 +517,7 @@
<!-- svelte-ignore a11y_autofocus -->
<div
bind:this={contentEditableDiv}
{id}
contenteditable="true"
oninput={handleInput}
onpaste={handlePaste}
@@ -27,18 +27,14 @@
let trail = $derived(
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
)
let description = $derived(
location
? undefined
: 'Each run answers a dataset of cases with this agent and scores the answers, so runs can be compared.'
)
</script>
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
<Modal
title={TITLE}
{trail}
{description}
paginated
enterConfirms={false}
bind:open
kind="X"
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
@@ -1,82 +1,76 @@
<script lang="ts">
import { createGrid, type GridApi } from 'ag-grid-community'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
import { transformColumnDefs } from '$lib/components/apps/components/display/table/utils'
import { multilineCellColDef } from '$lib/components/apps/components/display/table/multilineCellEditor'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import Row from '$lib/components/table/Row.svelte'
import EditableTextarea from '$lib/components/common/EditableTextarea.svelte'
import { Button, EmptyState } from '$lib/components/common'
import { ListPlus, Plus, Trash2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import type { CaseDraft } from './evalUtils'
let {
cases = $bindable(),
onRemove,
locked = false,
onEditingChange
onAdd,
focusCaseId = undefined,
locked = false
}: {
/** The drawer's working copy. Edits land here as they are made; the drawer writes them. */
cases: CaseDraft[]
/** Asked before a row goes, since a stored case has runs that executed it. */
onRemove: (c: CaseDraft) => void
/** Adds a case. The empty table offers this itself, where the first row would be. */
onAdd: () => void
/** A case to open for typing as soon as it appears — the one just added, so a new row is
* ready to be filled in rather than waiting to be clicked. */
focusCaseId?: string
/** The cases are being written: an edit made now is one the request already left behind. */
locked?: boolean
/** A cell opened or closed. Reported up so Save can be pressed for an edit the list does not
* hold yet: the press is what commits the cell, so it has to reach a live button. */
onEditingChange?: (editing: boolean) => void
} = $props()
type Row = { id: string; question: string; expected: string }
/** A dataset is capped at 1000 cases and every row here mounts two editors and a button, so the
* whole set at once is thousands of components for a table you read a screenful of. ag-grid
* virtualised its rows; `DataTable` paginates instead, which is the primitive already here. */
const CASES_PER_PAGE = 25
let currentPage = $state(1)
/** Bound to `DataTable`'s own per-page selector, so changing it there re-slices here rather
* than only relabelling the footer. */
let perPage = $state(CASES_PER_PAGE)
let lastPage = $derived(Math.max(1, Math.ceil(cases.length / perPage)))
let pageCases = $derived(cases.slice((currentPage - 1) * perPage, currentPage * perPage))
// A case removed from the last page leaves it empty; step back rather than show nothing.
$effect(() => {
if (currentPage > lastPage) currentPage = lastPage
})
/** `expected` is whatever the case holds: a bare string, or a value shown as JSON. Read back
* the same way, so a case that held an object keeps holding one. */
function expectedToText(value: unknown): string {
if (value == undefined) return ''
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function toRow(c: CaseDraft): Row {
return {
id: c.id ?? '',
question: c.input?.user_message ?? '',
expected: expectedToText(c.expected)
}
}
let api: GridApi<any> | undefined = $state()
let eGui: HTMLDivElement | undefined = $state()
let darkMode = $state(false)
const defaultColDef = {
flex: 1,
minWidth: 120,
...multilineCellColDef
}
$effect(() => eGui && untrack(() => mountGrid()))
function mountGrid() {
if (!eGui || api) return
createGrid(eGui, {
rowData: untrack(() => cases.map(toRow)),
columnDefs: columnDefs(),
defaultColDef: { ...defaultColDef, editable: untrack(() => !locked) },
onCellValueChanged: (e) => {
const target = cases.find((c) => c.id === (e.data as Row).id)
if (!target) return
if (e.colDef.field === 'question') {
target.input = { ...target.input, user_message: e.newValue ?? '' }
} else if (e.colDef.field === 'expected') {
setExpected(target, e.newValue ?? '')
}
},
onCellEditingStarted: () => onEditingChange?.(true),
onCellEditingStopped: () => onEditingChange?.(false),
suppressColumnMoveAnimation: true,
suppressDragLeaveHidesColumns: true,
onGridReady: (e) => (api = e.api)
/** The question editors, by case id, so a newly added row can be opened. */
let questionEditors = $state<Record<string, EditableTextarea | undefined>>({})
$effect(() => {
const id = focusCaseId
if (!id) return
untrack(() => {
// Onto the page the case is on before reaching for its editor: paginated, a case added to
// a full page is not rendered yet, and there would be nothing to open.
const at = cases.findIndex((c) => c.id === id)
if (at < 0) return
currentPage = Math.floor(at / perPage) + 1
// After the row has been rendered and registered itself.
requestAnimationFrame(() => questionEditors[id]?.edit())
})
}
})
/** Text that parses as JSON is stored as JSON, so a structured answer can be written by hand. */
function setExpected(c: CaseDraft, text: string) {
// Cleared means the case has no expected answer, which is not the same as expecting the
// empty string: a scorer reads `undefined` as "nothing to measure here" and leaves the case
// out of its mean, where `''` scores it a hard zero.
const trimmed = text.trim()
if (!trimmed) {
c.expected = undefined
@@ -88,45 +82,93 @@
c.expected = text
}
}
function columnDefs() {
return transformColumnDefs({
columnDefs: [
{ field: 'question', headerName: 'Question', flex: 3 },
{ field: 'expected', headerName: 'Expected', flex: 2 }
] as any,
onDelete: (values) => {
if (locked) return
const target = cases.find((c) => c.id === (values as Row).id)
if (target) onRemove(target)
}
})
}
// Keyed on which cases are in the list rather than on what is in them: pushing rows back on
// each keystroke would reset the cell being typed in.
let rowKey = $derived(cases.map((c) => c.id).join(','))
$effect(() => {
rowKey
untrack(() => api?.updateGridOptions({ rowData: cases.map(toRow) }))
})
$effect(() => {
const editable = !locked
untrack(() => api?.updateGridOptions({ defaultColDef: { ...defaultColDef, editable } }))
})
/** Commit whatever cell is open into `cases`, and wait for it to land there: the drawer reads
* the list to save it, and a cell still being typed in is an edit that press is saving. */
export async function flush() {
api?.stopEditing()
// The commit reaches `cases` through the grid's own event queue, a macrotask away: reading in
// the same turn, or after a microtask, reads the list as it was before the cell was typed in.
await new Promise((resolve) => setTimeout(resolve, 0))
}
</script>
<DarkModeObserver bind:darkMode />
<div bind:this={eGui} class="h-full w-full ag-theme-alpine {darkMode ? 'ag-theme-alpine-dark' : ''}"
></div>
{#if cases.length === 0}
<EmptyState
icon={ListPlus}
title="No cases yet"
description="A case is one question this agent is asked, and the answer it is measured against. A dataset needs at least one before it can be saved or run."
action={{
label: 'Add a case',
icon: Plus,
onClick: onAdd,
// The same lock the header's own button takes: a save reads the case list before it is
// sent, so a case added while one is in flight would be dropped when it lands.
disabled: locked,
// The only live call to action here: Save stays disabled until a case exists.
variant: 'accent'
}}
/>
{:else}
<DataTable
size="xs"
tableFixed
containerClass="bg-surface-tertiary"
paginated={cases.length > perPage}
bind:currentPage
bind:perPage
rowCount={cases.length}
hasMore={currentPage < lastPage}
showPrev={currentPage > 1}
on:next={() => (currentPage = Math.min(currentPage + 1, lastPage))}
on:previous={() => (currentPage = Math.max(currentPage - 1, 1))}
>
<colgroup>
<col style="width: 55%" />
<col />
<col style="width: 3rem" />
</colgroup>
<Head>
<tr>
<Cell head first>Question</Cell>
<Cell head>Expected</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each pageCases as c (c.id)}
<Row>
<Cell first>
<!-- `commitOnInput`: the drawer's Save reads this list, so an edit has to be in it by
the time the button is pressed rather than waiting on a blur that the press
itself would cause. -->
<EditableTextarea
bind:this={questionEditors[c.id ?? '']}
value={c.input?.user_message ?? ''}
placeholder="Question"
editable={!locked}
commitOnInput
class="w-full"
textClass="text-xs font-normal"
onSave={(v) => (c.input = { ...(c.input ?? {}), user_message: v })}
/>
</Cell>
<Cell>
<EditableTextarea
value={expectedToText(c.expected)}
placeholder="Expected"
editable={!locked}
commitOnInput
class="w-full"
textClass="text-xs font-normal"
onSave={(v) => setExpected(c, v)}
/>
</Cell>
<Cell last numeric>
<Button
unifiedSize="sm"
variant="subtle"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
title="Delete case"
disabled={locked}
onclick={() => onRemove(c)}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
@@ -26,8 +26,7 @@
onRenamed,
onDeleted,
onCasesChanged,
onScorersChanged,
onClosed
onScorersChanged
}: {
workspace: string | undefined
/** The agent the dataset is named after and belongs to. */
@@ -45,8 +44,6 @@
onDeleted: (path: string) => void | Promise<void>
onCasesChanged: () => void | Promise<void>
onScorersChanged: () => void | Promise<void>
/** The drawer is done, whether it saved anything or not. */
onClosed?: () => void
} = $props()
let drawer: Drawer | undefined = $state()
@@ -92,11 +89,6 @@
let storedIds = $state<Set<string>>(new Set())
let removingCase = $state<CaseDraft | undefined>(undefined)
/** The grid, so an open cell can be committed before the list is read to write it. */
let casesGrid: EvalCasesGrid | undefined = $state()
/** A cell is open in the grid: an edit the list does not hold yet, so Save stays live for the
* press that commits it. */
let casesEditing = $state(false)
/** The next free `<agent>_datasetN`, which is what a dataset is called until it is named. */
function nextDatasetIndex(): number {
@@ -109,9 +101,9 @@
export function openDrawer(next: 'new' | 'edit') {
mode = next
pathError = ''
// The grid is rebuilt per open and reports afresh; a cell left open when the drawer closed
// would otherwise leave Save live over a set nothing has changed.
casesEditing = false
// Or the case added last time opens itself when the drawer is next opened on this dataset:
// ids survive `fromStoredCase`, so the id would still match a row.
focusCaseId = undefined
// What was collected for a dataset that was never created belongs to that attempt.
pendingScorers = []
workingCases = next === 'edit' ? cases.map((c) => fromStoredCase(c) as CaseDraft) : []
@@ -145,7 +137,6 @@
async function createDataset() {
if (!workspace || !path || pathError) return
await casesGrid?.flush()
creating = true
const created = path
const submittedSummary = summary || undefined
@@ -164,9 +155,9 @@
creating = false
return
}
// The pane moves onto the created dataset before the drawer closes: closing reopens the Run
// dialog, which reads the pane's selection as it opens. A refresh that fails must still not
// read as a create that failed, or the retry hits "already exists".
// The pane moves onto the created dataset while the drawer is still open the Run dialog
// stands underneath and follows that selection. A refresh that fails must still not read as
// a create that failed, or the retry hits "already exists".
try {
await onCreated(created)
} catch (e) {
@@ -193,8 +184,6 @@
* refuses the case edits with it instead of leaving them written under the old name. */
async function saveDataset() {
if (!workspace || !datasetPath || !dataset || !path || pathError) return
// Before anything reads the list: a cell still open is an edit that must go in with it.
await casesGrid?.flush()
// Read once, so every step of the save agrees on what was submitted: the fields are locked
// while it runs, and the one the drawer navigates to afterwards must be the one written.
// `summary` always travels: the server keeps the stored one when it is absent, so clearing
@@ -233,8 +222,13 @@
saving = false
}
/** The case the grid should open for typing: the one just added. */
let focusCaseId = $state<string | undefined>(undefined)
function addCase() {
workingCases = [...workingCases, { ...emptyCase(), id: randomUUID() }]
const id = randomUUID()
workingCases = [...workingCases, { ...emptyCase(), id }]
focusCaseId = id
}
function deleteCase(id: string) {
@@ -263,9 +257,15 @@
)
})
let nothingToSave = $derived(metadataUnchanged && !casesChanged)
/** A dataset with no cases cannot be run — the server refuses it (`run.rs`, "has no case to
* run") — so there is no point creating one. Only creation is blocked: a saved dataset must
* stay editable down to its last case, or removing that case, or renaming while it is the only
* one left, would have no way to be saved. */
let noCases = $derived(workingCases.length === 0)
let noCasesTitle = $derived(noCases ? 'Add at least one case first' : undefined)
</script>
<Drawer bind:this={drawer} size="900px" on:close={() => onClosed?.()}>
<Drawer bind:this={drawer} size="900px">
<!-- Inside the drawer, not beside it. The drawer is portalled to the body and stacked above the
pane that opened it, so a modal rendered from here is otherwise trapped under it. -->
<ConfirmationModal
@@ -354,7 +354,10 @@
onWriting={(w) => (scorersWriting = w)}
/>
</div>
<div class="flex flex-col gap-2 grow min-h-0">
<!-- Sized by its rows, not by what is left of the drawer: a dataset with one case should
not be followed by an empty half-screen of table. The drawer body scrolls when the
list outgrows it. -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-emphasis">Cases</span>
<span class="text-2xs text-tertiary">{workingCases.length}</span>
@@ -369,13 +372,13 @@
Add a case
</Button>
</div>
<div class="grow min-h-0">
<div>
<EvalCasesGrid
bind:this={casesGrid}
bind:cases={workingCases}
onRemove={removeCase}
onAdd={addCase}
{focusCaseId}
locked={writing}
onEditingChange={(v) => (casesEditing = v)}
/>
</div>
</div>
@@ -397,7 +400,7 @@
unifiedSize="md"
variant="accent"
loading={saving}
disabled={writing || !path || !!pathError || (nothingToSave && !casesEditing)}
disabled={writing || !path || !!pathError || nothingToSave}
onclick={saveDataset}
>
Save
@@ -408,7 +411,8 @@
variant="accent"
startIcon={{ icon: Plus }}
loading={creating}
disabled={creating || !path || !!pathError}
disabled={creating || !path || !!pathError || noCases}
title={noCasesTitle}
onclick={createDataset}
>
Create dataset
@@ -47,16 +47,6 @@
let hasDraft = $derived(editedConfig !== undefined)
let dataset = $state<string | undefined>(undefined)
let hoveringDataset = $state(false)
/** Set while the dialog stands aside for the dataset drawer, holding the pane's dataset as it
* was on the way out: coming back onto a different one is the detour's answer, and adopted. */
let steppedAside = $state<{ dataset: string | undefined } | undefined>(undefined)
/** Hands the screen to the dataset drawer, to be given back when it closes. */
function stepAside(go: () => void) {
steppedAside = { dataset: defaultDataset }
open = false
go()
}
/** The agent's versions, for pinning one. Loaded when the dialog opens rather than held: a
* version list goes stale the moment the agent is saved again. */
@@ -76,21 +66,26 @@
}
}
/** Whether the dialog was already open on the previous run of the effect below, which reads it
* to tell an open from the pane's dataset moving underneath. */
let wasOpen = false
$effect(() => {
if (!open) return
const isOpen = open
const pane = defaultDataset
untrack(() => {
loadVersions()
const aside = steppedAside
steppedAside = undefined
if (aside) {
// Back from the drawer: a dataset created or edited there moves the pane onto it, and
// anything else leaves the field as it was left.
if (defaultDataset !== aside.dataset) dataset = defaultDataset
if (!isOpen) {
wasOpen = false
return
}
// Seeded on every open: the dataset last worked in, and the state of the agent there is
// most reason to measure.
dataset = defaultDataset
// Followed while the dialog stands, not only read at open: the dataset drawer opens over
// this dialog rather than in place of it, so creating, renaming or deleting a dataset
// there moves the pane's selection with the dialog still up. Nothing else moves it then.
dataset = pane
if (wasOpen) return
wasOpen = true
loadVersions()
// The state of the agent there is most reason to measure.
choice = hasDraft ? 'draft' : 'deployed'
})
})
@@ -184,7 +179,7 @@
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => stepAside(onNewDataset)}
onclick={onNewDataset}
>
New dataset
</Button>
@@ -215,7 +210,7 @@
title="Edit this dataset"
on:click={() => {
close()
stepAside(() => onEditDataset(item.value ?? ''))
onEditDataset(item.value ?? '')
}}
/>
{/snippet}
@@ -228,7 +223,7 @@
btnClasses="w-full !h-auto !justify-start !rounded-none flex items-center gap-2 px-3 py-2 text-xs !font-normal text-secondary hover:bg-surface-hover"
onClick={() => {
close()
stepAside(onNewDataset)
onNewDataset()
}}
>
<Plus size={13} />
@@ -247,7 +242,7 @@
startIcon={{ icon: Pencil }}
iconOnly
title="Edit this dataset"
on:click={() => stepAside(() => onEditDataset(dataset ?? ''))}
on:click={() => onEditDataset(dataset ?? '')}
/>
</div>
{/if}
@@ -7,17 +7,21 @@
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import { Button } from '$lib/components/common'
import { Bot, Code2, Loader2, Plus } from 'lucide-svelte'
import { Bot, ChevronRight, Code2, Loader2, Plus } from 'lucide-svelte'
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
import type { EvalDataset, EvalExperiment, ExperimentScore } from '$lib/gen'
import { datasetSummary, experimentName, formatScore, subjectLabel } from './evalUtils'
let {
experiments,
datasets,
caseProgress,
loaded,
active = false,
deployedHash = undefined,
currentVersion = undefined,
onOpen,
onHighlight,
onEditDataset,
onNew
}: {
@@ -25,17 +29,87 @@
experiments: EvalExperiment[]
/** Whether the list has been read: an empty table is a statement about the agent. */
loaded: boolean
/** Whether this list is the page on screen. The keyboard is only answered while it is: the
* run page keeps its own rows, and both would otherwise move on one press. */
active?: boolean
/** The workspace's datasets, for naming the one a run is of by what it is for. */
datasets: EvalDataset[]
/** How many cases each still-running run has finished, keyed by run id. A run the flow has
* not been read for yet is at none of them rather than absent: the count is on the row from
* the moment it appears, so it never arrives late and shifts the column. */
caseProgress: Record<string, number>
/** What the agent hashes to as deployed, and the version it is on: they resolve a run of
* edits that were later saved, so a run is labelled here as the run picker labels it. */
deployedHash?: string
currentVersion?: number
onOpen: (experiment: EvalExperiment) => void
/** The highlighted run, reported up so the surface can act on it — arrowing into the run
* page opens the run under the highlight rather than whichever was opened last. */
onHighlight?: (id: string | undefined) => void
onEditDataset: (dataset: string) => void
onNew: () => void
} = $props()
/** The highlighted run, by id. One state for both the pointer and the keyboard, as a melt menu
* does it: hovering a row moves the highlight to it, so the arrows carry on from wherever the
* pointer left off instead of running a second, invisible cursor of their own. It says where
* the highlight is, not what is chosen — a run is not opened until Enter.
*
* By id and not by index: the list is newest-first and the poll prepends to it, so an index
* would quietly come to mean a different run and Enter would open the wrong one. */
let cursorId = $state<string | undefined>(undefined)
let cursor = $derived(
cursorId === undefined ? -1 : experiments.findIndex((e) => e.id === cursorId)
)
let body: HTMLTableSectionElement | undefined = $state()
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
// still the one on top, rather than under a drawer or a dialog opened since.
const hostActive = overlayHostActive()
const onTop = topmostSurface()
const listening = () => hostActive() && onTop()
$effect(() => {
onHighlight?.(cursorId)
})
// A highlight on a run that has since gone, and the highlight itself when the list is not the
// page on screen.
$effect(() => {
if (!active || (cursorId !== undefined && cursor < 0)) cursorId = undefined
})
function move(by: number) {
if (experiments.length === 0) return
const from = cursor < 0 ? (by > 0 ? -1 : experiments.length) : cursor
const at = Math.max(0, Math.min(experiments.length - 1, from + by))
cursorId = experiments[at]?.id
// `nearest`, so arrowing through a long list scrolls by a row rather than jumping the table.
requestAnimationFrame(() =>
body?.querySelectorAll('tr')[at]?.scrollIntoView({ block: 'nearest' })
)
}
function onKeydown(event: KeyboardEvent) {
if (!active || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
const el = event.target as HTMLElement | null
if (el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')) return
if (event.key === 'ArrowDown') {
event.preventDefault()
move(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
move(-1)
} else if (event.key === 'Enter' && experiments[cursor]) {
// Enter belongs to whatever is focused if that thing does something with it. A highlighted
// row is not a reason to swallow the press on `New evaluation` or a row's dataset button.
if (el?.closest?.('button, a[href], [role="button"], summary')) return
event.preventDefault()
onOpen(experiments[cursor])
}
}
/** The one number a column reports: a pass rate where it has a line to pass, the mean where it
* does not. */
function headline(score: ExperimentScore): string | undefined {
@@ -44,13 +118,16 @@
}
</script>
<svelte:window onkeydown={onKeydown} />
<DataTable size="sm" tableFixed>
<colgroup>
<col style="width: 20%" />
<col style="width: 22%" />
<col style="width: 5rem" />
<col style="width: 6rem" />
<col />
<col style="width: 7rem" />
<col style="width: 2rem" />
</colgroup>
<Head>
<tr>
@@ -58,12 +135,20 @@
<Cell head>Dataset</Cell>
<Cell head numeric>Cases</Cell>
<Cell head>Scores</Cell>
<Cell head last numeric>When</Cell>
<Cell head numeric>When</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each experiments as experiment (experiment.id)}
<Row hoverable on:click={() => onOpen(experiment)}>
<tbody class="divide-y" bind:this={body}>
{#each experiments as experiment, i (experiment.id)}
<!-- No `hoverable`, and not `selected` either: the first is a second hover tint competing
with this highlight, the second is the blue that means "chosen". Nothing here is
chosen until Enter, so the highlight wears the ordinary hover surface. -->
<Row
class="cursor-pointer {i === cursor ? 'bg-surface-hover' : ''}"
on:click={() => onOpen(experiment)}
on:hover={(e) => e.detail && (cursorId = experiment.id)}
>
<Cell first>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
@@ -99,7 +184,18 @@
</Button>
</Cell>
<Cell numeric>
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
{#if experiment.running}
<!-- The run's only spinner. One per score badge instead read as the judge being
slow, when what is unfinished is the run. -->
<span
class="inline-flex items-center gap-1 tabular-nums text-secondary whitespace-nowrap"
>
<Loader2 size={11} class="animate-spin text-blue-500 shrink-0" />
{caseProgress[experiment.id] ?? 0}/{experiment.case_count}
</span>
{:else}
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
{/if}
</Cell>
<Cell>
<div class="flex flex-wrap gap-1 min-w-0">
@@ -117,8 +213,6 @@
<span class="tabular-nums font-semibold text-emphasis">{value}</span>
{:else if score.failed > 0}
<span class="text-red-500">failed</span>
{:else if experiment.running}
<Loader2 size={11} class="animate-spin text-blue-500 self-center" />
{:else}
<span class="text-tertiary"></span>
{/if}
@@ -126,33 +220,34 @@
</Badge>
{/each}
{#if (experiment.scores ?? []).length === 0}
{#if experiment.running}
<span class="text-2xs text-tertiary inline-flex items-center gap-1">
<Loader2 size={11} class="animate-spin text-blue-500" />
scoring
</span>
{:else}
<span class="text-2xs text-tertiary">not scored</span>
{/if}
<!-- Not a pending state: a scorer's column is on the run from the moment it is
created, with or without a number in it, so nothing arrives here later. -->
<span class="text-2xs text-tertiary">not scored</span>
{/if}
</div>
</Cell>
<Cell last numeric>
<Cell numeric>
<span class="text-2xs text-tertiary whitespace-nowrap">
<TimeAgo date={experiment.created_at} agoOnlyIfRecent />
</span>
</Cell>
<Cell last numeric>
<ChevronRight
size={14}
class="text-tertiary transition-opacity {i === cursor ? 'opacity-100' : 'opacity-0'}"
/>
</Cell>
</Row>
{/each}
{#if experiments.length === 0 && !loaded}
<tr>
<td colspan="5" class="p-3">
<td colspan="6" class="p-3">
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
</td>
</tr>
{:else if experiments.length === 0}
<tr>
<td colspan="5" class="p-6">
<td colspan="6" class="p-6">
<div class="flex flex-col items-center justify-center gap-3 text-center">
<span class="text-sm text-emphasis">No runs yet</span>
<span class="text-xs text-secondary max-w-md">
@@ -196,26 +196,31 @@
</DropdownV2>
</div>
<div class="border rounded-md">
<div>
{#if scorers.length === 0}
<div class="p-3 text-2xs text-tertiary">
<div class="border rounded-md p-3 text-2xs text-tertiary">
A scorer reads one run and returns a number. Every run of this dataset is measured by all of
them, which is what makes two runs comparable.
</div>
{:else}
<div class="divide-y">
<!-- Laid out as `RadioCard` is — same padding, icon size, and label-over-description type
scale — so a scorer reads as a card of the same family. Not that component: nothing
here is being selected. -->
<div class="flex flex-col gap-2">
{#each scorers as scorer (scorer.id)}
<div class="flex items-center gap-2 px-2 py-1.5">
<div
class="flex items-center gap-2 rounded-md border border-border-light bg-surface-tertiary p-3"
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
<Bot size={16} class="text-secondary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
<Code2 size={16} class="text-secondary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 grow">
<span class="text-xs text-emphasis truncate leading-tight">
<span class="text-xs font-semibold text-emphasis truncate">
{scorerLabel(scorer)}
</span>
<span class="text-2xs text-tertiary truncate leading-tight">{scorer.path}</span>
<span class="text-xs font-normal text-secondary truncate mt-0.5">{scorer.path}</span>
</div>
{#if scorer.pass_if != undefined}
<span class="text-2xs text-tertiary shrink-0" title="Pass threshold">
@@ -9,9 +9,11 @@
import Label from '$lib/components/Label.svelte'
import Popover from '$lib/components/Popover.svelte'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
import {
type AgentDraft,
AiEvalsService,
JobService,
type EvalCase,
type EvalDataset,
type EvalExperiment,
@@ -35,9 +37,11 @@
Code2,
ExternalLink
} from 'lucide-svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import EvalDatasetDrawer from './EvalDatasetDrawer.svelte'
import EvalRunsList from './EvalRunsList.svelte'
import EvalRunDialog from './EvalRunDialog.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import {
caseLabel,
@@ -52,6 +56,10 @@
/** A dataset is capped at this many cases, so one page holds the whole set. */
const CASE_PAGE_SIZE = 1000
/** The id the run's flow gives the loop over its cases (`CASES_NODE_ID` in `ai_evals/run.rs`).
* Looked up by id rather than by position: the flow has a step after the loop too. */
const CASES_MODULE_ID = 'cases'
let {
agentPath,
opWorkspace = undefined,
@@ -91,12 +99,56 @@
/** What the agent hashes to as deployed: a run of edits carrying it ran what was then saved. */
let deployedHash = $state<string | undefined>(undefined)
let running = $state(false)
let scorers = $derived(dataset?.scorers ?? [])
/** The run on screen belongs to a dataset still being read. Until it arrives the rows and the
* scorer columns would both be built from the *previous* dataset, so both are held back. */
let datasetLoading = $state(false)
let scorers = $derived(datasetLoading ? [] : (dataset?.scorers ?? []))
let selectedCaseId = $state<string | undefined>(undefined)
let datasetDrawer: EvalDatasetDrawer | undefined = $state()
let runDialogOpen = $state(false)
let resumeRunDialog = $state(false)
/** The run the list has highlighted, so arrowing into the run page opens that one. Without it
* the arrow could only fall back to whichever run was opened last, which on a dialog just
* opened is none at all. */
let highlightedRunId = $state<string | undefined>(undefined)
/** How many cases each still-running run has finished, keyed by run id. Read from the flow
* executing the run: the list carries the case total, and counting the finished ones there
* would be a per-case query for every run listed. The flow already records it — one slot per
* case in `flow_jobs_success`, null until that case's iteration is over. */
let caseProgress = $state<Record<string, number>>({})
async function readCaseProgress() {
const workspace = ws
const live = experiments.filter((e) => e.running)
if (!workspace || live.length === 0) {
if (Object.keys(caseProgress).length > 0) caseProgress = {}
return
}
const read = await Promise.all(
live.map(async (e) => {
try {
const update = await JobService.getJobUpdates({
workspace,
id: e.run_job_id,
running: true,
noLogs: true
})
const cases = update.flow_status?.modules?.find((m) => m.id === CASES_MODULE_ID)
if (!cases) return undefined
return [
e.id,
(cases.flow_jobs_success ?? []).filter((s) => s != undefined).length
] as const
} catch {
// Left out of the map, so the row reads `0/total` until a later poll answers. A
// flow that cannot be read is already the list's problem to report, not this one's.
return undefined
}
})
)
caseProgress = Object.fromEntries(read.filter((e) => e !== undefined))
}
let experiment = $derived(experiments.find((e) => e.id === experimentId))
@@ -120,6 +172,7 @@
runsLoadError = false
try {
experiments = await listSubjectExperiments()
await readCaseProgress()
} catch (e) {
runsLoadError = true
sendUserToast(`Failed to load the runs: ${e}`, true)
@@ -183,6 +236,8 @@
// Switching datasets leaves the previous request in flight; only the newest may write, or a
// slow response for the dataset you just left replaces the one you are looking at.
let loadGeneration = 0
/** Which run the pane is opening; only the newest may clear `datasetLoading`. */
let openGeneration = 0
async function loadDataset(path: string | undefined): Promise<boolean> {
const generation = ++loadGeneration
@@ -345,6 +400,7 @@
await loadResults()
} else {
experiments = await listSubjectExperiments()
await readCaseProgress()
}
} finally {
refreshing = false
@@ -354,27 +410,46 @@
/** Opens a run, bringing its dataset with it and offering the run before it as the baseline.
* Reading the cells is left to the effect on the selection, so every way in opens one alike. */
async function openRun(id: string) {
// Against the dataset that is loaded, not the one that is selected: skipping on the selection
// alone would leave a run open over a dataset whose cases and scorers were never read.
const target = experiments.find((e) => e.id === id)
if (target && target.dataset !== dataset?.path) {
await useDataset(target.dataset)
// Only when the run itself changes: re-showing the one already open — arrowing back into it
// from the list — must keep whatever comparison the user picked.
if (id !== experimentId) {
const index = experiments.findIndex((e) => e.id === id)
// The run before it *of the same dataset*: the list spans datasets, and a run of another
// set of cases is not a baseline for this one.
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
}
const index = experiments.findIndex((e) => e.id === id)
// The run before it *of the same dataset*: the list spans datasets, and a run of another set
// of cases is not a baseline for this one.
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
experimentId = id
viewingRun = true
selectedCaseId = undefined
// Opened first, read second: the dataset is a request, and waiting on it here is a click
// that does nothing at all until the network answers. The page carries the wait instead.
//
// Against what is *selected* as well as what is loaded. `selectedDataset` moves the moment a
// read starts, so a load still in flight for another dataset shows up here: without that
// test, opening a run of the dataset already committed would skip `useDataset` entirely and
// leave the in-flight one free to commit its cases under this run.
const needsDataset =
!!target && (target.dataset !== dataset?.path || target.dataset !== selectedDataset)
datasetLoading = needsDataset
viewingRun = true
if (needsDataset) {
// Numbered like `loadDataset`'s own read, and for the same reason: opening a second run
// while the first is still loading leaves two `finally`s racing, and the loser clearing
// the flag would uncover the table with neither dataset in hand.
const generation = ++openGeneration
try {
await useDataset(target!.dataset)
} finally {
if (generation === openGeneration) datasetLoading = false
}
}
}
async function runAll(runSubject: EvalSubject, path: string): Promise<boolean> {
if (!ws || !path) return false
running = true
let id: string
try {
id = await AiEvalsService.runExperiment({
await AiEvalsService.runExperiment({
workspace: ws,
requestBody: { dataset: path, subject: runSubject }
})
@@ -386,9 +461,10 @@
// From here the run exists and is billing: what can still fail is reading it back, and
// saying "failed to run" to that invites a second, duplicate run.
try {
// Onto the list rather than into the run: a run that has just started has no answers and
// no scores, and the list already fills its row in as they land. Reading it is a click.
if (path !== dataset?.path) await useDataset(path)
await loadRuns()
await openRun(id)
} catch (e) {
sendUserToast(
`The run started but could not be read back: ${e}. Reload the runs list to see it.`,
@@ -407,8 +483,6 @@
/** The dataset is gone and every run of it with it: back to the list, on no dataset. */
async function datasetDeleted(path: string) {
// A run dialog waiting behind the drawer has nothing to come back to.
resumeRunDialog = false
if (selectedDataset === path) {
viewingRun = false
selectedCaseId = undefined
@@ -442,6 +516,13 @@
}
let selectedRow = $derived(displayRows.find((row) => row.case_id === selectedCaseId))
/** The case the side panel is showing. Held rather than read straight off the selection: the
* pane animates shut over a few hundred milliseconds, and the selection is gone on the first
* of them, which would empty the panel before it had finished closing. */
let openRow = $state<ExperimentRow | undefined>(undefined)
$effect(() => {
if (selectedRow) openRow = selectedRow
})
$effect(() => {
if (!ws) return
@@ -570,34 +651,52 @@
</script>
<div class="flex flex-col h-full min-h-0">
{#if loaded && loadError}
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
<span class="text-sm text-emphasis">Could not load evals</span>
<span class="text-xs text-secondary max-w-md">
The datasets or runs could not be read. Check your access to this agent and reload.
</span>
</div>
{:else}
<!-- Warmed: the run page is a table, a splitter and two selects, and building it on the first
click lands that work inside the transition — only the first navigation stutters, which
reads as the animation being unreliable rather than as a cost. -->
<PagedContent
warm
class="grow min-h-0"
current={!viewingRun || !loaded ? 'list' : 'run'}
onNavigate={(key) => {
// Right opens the run under the highlight, falling back to whichever was open before;
// left is the way back, the same as the breadcrumb.
if (key === 'run') {
// Both branches go through `openRun`: it is what brings the run's own dataset back,
// and the fallback run may be of a dataset the list has since moved off.
const id = highlightedRunId ?? experimentId
if (id) openRun(id)
} else if (key === 'list') {
viewingRun = false
selectedCaseId = undefined
}
}}
pages={[
{ key: 'list', content: listPage },
{ key: 'run', content: runPage }
]}
/>
{/if}
</div>
{#snippet listPage()}
<!-- Carried by the level rather than by the dialog: as the dialog's own `description` it
vanished the moment a run opened, shrinking the header and jolting the page under it. -->
<p class="text-xs text-secondary">
Each run answers a dataset of cases with this agent and scores the answers, so runs can be
compared.
</p>
<div class="flex flex-wrap items-end gap-2 py-2">
{#if viewingRun}
<Label label="Run" class="w-52 shrink">
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
</Label>
<Label label="Compare to" class="w-48 shrink">
<Select
items={experimentItems.filter((i) => i.value !== experimentId)}
bind:value={baselineId}
placeholder="No comparison"
clearable
disabled={experiments.length < 2}
class="text-xs"
/>
</Label>
{/if}
<div class="grow"></div>
{#if viewingRun && experiment?.run_job_id}
<a
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
target="_blank"
>
Open the job
<ExternalLink size={12} />
</a>
{/if}
{#if !viewingRun && loaded && datasets.length > 0}
{#if loaded && datasets.length > 0}
<Button
unifiedSize="md"
variant="default"
@@ -622,98 +721,115 @@
{/if}
{/if}
</div>
<div class="grow min-h-0 overflow-auto">
<EvalRunsList
{experiments}
{datasets}
{caseProgress}
{loaded}
active={!viewingRun}
{deployedHash}
{currentVersion}
onOpen={(e) => openRun(e.id)}
onHighlight={(id) => (highlightedRunId = id)}
onEditDataset={async (path) => {
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNew={() => (runDialogOpen = true)}
/>
</div>
{/snippet}
{#snippet runPage()}
<div class="flex flex-wrap items-end gap-2 py-2">
<Label label="Run" class="w-52 shrink">
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
</Label>
<Label label="Compare to" class="w-48 shrink">
<Select
items={experimentItems.filter((i) => i.value !== experimentId)}
bind:value={baselineId}
placeholder="No comparison"
clearable
disabled={experiments.length < 2}
class="text-xs"
/>
</Label>
<div class="grow"></div>
{#if experiment?.run_job_id}
<a
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
target="_blank"
>
Open the job
<ExternalLink size={12} />
</a>
{/if}
</div>
<div class="grow min-h-0">
<Splitpanes class="h-full">
<Pane size={selectedRow ? 60 : 100} minSize={35}>
<Splitpanes class="h-full splitter-hidden">
<Pane minSize={35}>
<div class="h-full overflow-auto">
{#if loaded && loadError}
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
<span class="text-sm text-emphasis">Could not load evals</span>
<span class="text-xs text-secondary max-w-md">
The datasets or runs could not be read. Check your access to this agent and reload.
</span>
</div>
{:else if loaded && datasets.length === 0}
<div class="h-full flex flex-col items-center justify-center gap-3 p-6 text-center">
<span class="text-sm text-emphasis">No dataset yet</span>
<span class="text-xs text-secondary max-w-md">
A dataset is the set of cases this agent is measured on. Runs are of a dataset, so
it is the first thing to make.
</span>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
onclick={() => datasetDrawer?.openDrawer('new')}
>
New dataset
</Button>
</div>
{:else if !viewingRun || !loaded}
<EvalRunsList
{experiments}
{datasets}
{loaded}
{deployedHash}
{currentVersion}
onOpen={(e) => openRun(e.id)}
onEditDataset={async (path) => {
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNew={() => (runDialogOpen = true)}
/>
{:else}
<DataTable size="sm" tableFixed rounded={!selectedRow}>
<colgroup>
<col style="width: 24%" />
<col style="width: 32%" />
{#each scorers as scorer (scorer.id)}
<col style="width: 9rem" />
{/each}
</colgroup>
<Head>
<tr>
<Cell head first>Case</Cell>
<Cell head last={scorers.length === 0}>Answer</Cell>
{#each scorers as scorer, index (scorer.id)}
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
{@const headline = columnHeadline(scorer, mean)}
<Cell head numeric last={index === scorers.length - 1}>
<!-- The second row keeps its height while there is nothing in it, so the table
does not move when the first score lands. -->
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
<span
class="flex items-center gap-1 min-w-0 max-w-full"
title={columnTitle(scorer, mean)}
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
{/if}
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
</span>
<span class="h-4 flex items-baseline gap-1.5 font-normal">
{#if headline}
<span class="tabular-nums text-emphasis font-semibold">
{headline.value}
<DataTable size="sm" tableFixed>
<colgroup>
<col style="width: 24%" />
<col style="width: 32%" />
{#each scorers as scorer (scorer.id)}
<col style="width: 9rem" />
{/each}
</colgroup>
<Head>
<tr>
<Cell head first>Case</Cell>
<Cell head last={scorers.length === 0}>Answer</Cell>
{#each scorers as scorer, index (scorer.id)}
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
{@const headline = columnHeadline(scorer, mean)}
<Cell head numeric last={index === scorers.length - 1}>
<!-- The second row keeps its height while there is nothing in it, so the table
does not move when the first score lands. -->
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
<span
class="flex items-center gap-1 min-w-0 max-w-full"
title={columnTitle(scorer, mean)}
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
{/if}
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
</span>
<span class="h-4 flex items-baseline gap-1.5 font-normal">
{#if headline}
<span class="tabular-nums text-emphasis font-semibold">
{headline.value}
</span>
{#if headline.delta && headline.direction !== 0}
<span
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
>
{headline.delta}
</span>
{#if headline.delta && headline.direction !== 0}
<span
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
>
{headline.delta}
</span>
{/if}
{/if}
</span>
</div>
</Cell>
{/each}
{/if}
</span>
</div>
</Cell>
{/each}
</tr>
</Head>
<tbody class="divide-y">
{#if datasetLoading}
<!-- A run whose *results* are loading keeps its rows instead: `displayRows` already
names every case from the dataset in hand, which beats a skeleton. -->
<tr>
<td colspan={2 + scorers.length} class="p-3">
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
</td>
</tr>
</Head>
<tbody class="divide-y">
{:else}
{#each displayRows as row (row.case_id)}
{@const status = statusOf(row.status)}
<Row
@@ -813,52 +929,74 @@
{/each}
</Row>
{/each}
</tbody>
</DataTable>
{/if}
{/if}
</tbody>
</DataTable>
</div>
</Pane>
{#if selectedRow}
{@const openRow = selectedRow}
<Pane size={40} minSize={25}>
<!-- Animated rather than mounted and unmounted: the pane's own width is what moves, so the
cells table reflows alongside it instead of being shoved aside. -->
<AnimatedPane size={40} minSize={25} duration={180} opened={!!selectedRow}>
{#if openRow}
<div class="h-full overflow-auto flex flex-col">
<div class="flex items-start gap-2 px-3 py-2 border-b">
<span class="text-xs font-semibold text-emphasis break-words">
<!-- The title and the actions are different type sizes, so nothing lines them up on
its own. `leading-7` gives the title's first line the same box height as the
close button, and the actions centre in a row of that height beside it — so a
title that wraps grows downwards and leaves the row where it is. -->
<div class="flex items-start gap-2 px-3 py-2">
<span
class="text-xs font-semibold text-emphasis break-words leading-7 flex-1 min-w-0"
>
{openRow.input?.user_message ?? caseLabel(openRow)}
</span>
<div class="grow"></div>
{#if openRow.job_id}
<a
class="text-2xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 mt-0.5"
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
target="_blank"
>
Open the case job
<ExternalLink size={12} />
</a>
{/if}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: X }}
iconOnly
title="Close"
onclick={() => (selectedCaseId = undefined)}
/>
<div class="flex items-center gap-2 shrink-0 h-7">
{#if openRow.job_id}
<a
class="text-2xs text-accent hover:underline inline-flex items-center gap-1"
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
target="_blank"
>
Open the case job
<ExternalLink size={12} />
</a>
{/if}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: X }}
iconOnly
title="Close"
onclick={() => (selectedCaseId = undefined)}
/>
</div>
</div>
<div class="p-3 flex flex-col gap-4">
<!-- One card per thing the case has, rather than sections divided by rules: each
stands on its own and the panel reads as a stack. -->
<div class="px-3 pb-3 flex flex-col gap-2">
{#if openRow.expected != undefined && openRow.expected !== ''}
<Label label="Expected">
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
{typeof openRow.expected === 'string'
? openRow.expected
: JSON.stringify(openRow.expected, null, 2)}
</span>
</Label>
<div class="rounded-md border border-light overflow-hidden">
<div
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
>
<span class="text-2xs font-semibold text-secondary truncate">Expected</span>
</div>
<div class="p-2">
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
{typeof openRow.expected === 'string'
? openRow.expected
: JSON.stringify(openRow.expected, null, 2)}
</span>
</div>
</div>
{/if}
{#if scorers.length > 0 && openRow.scores.length > 0}
<Label label="Scores">
<div class="flex flex-col divide-y border rounded-md">
<div class="rounded-md border border-light overflow-hidden">
<div
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
>
<span class="text-2xs font-semibold text-secondary truncate">Scores</span>
</div>
<div class="flex flex-col divide-y divide-light">
{#each scorers as scorer (scorer.id)}
{@const cell = openRow.scores.find((s) => s.scorer_id === scorer.id)}
<div class="flex flex-col gap-1 px-2 py-1.5">
@@ -899,7 +1037,7 @@
</div>
{/each}
</div>
</Label>
</div>
{/if}
{#if experiment && (openRow.job_id || openRow.output != undefined)}
<div class="rounded-md border border-light overflow-hidden">
@@ -912,9 +1050,7 @@
</div>
<div class="p-2">
{#if openRow.output != undefined}
<div class="text-xs text-secondary break-words">
<GfmMarkdown md={openRow.output} noPadding />
</div>
<GfmMarkdown md={openRow.output} prose="sm" noPadding />
{:else if openRow.status === 'running'}
<span class="text-xs text-tertiary inline-flex items-center gap-1.5">
<Loader2 size={12} class="animate-spin text-blue-500" />
@@ -928,11 +1064,11 @@
{/if}
</div>
</div>
</Pane>
{/if}
{/if}
</AnimatedPane>
</Splitpanes>
</div>
</div>
{/snippet}
<EvalRunDialog
bind:open={runDialogOpen}
@@ -944,15 +1080,9 @@
{running}
onRun={runAll}
onEditDataset={async (path) => {
if (await useDataset(path)) {
resumeRunDialog = true
datasetDrawer?.openDrawer('edit')
}
}}
onNewDataset={() => {
resumeRunDialog = true
datasetDrawer?.openDrawer('new')
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNewDataset={() => datasetDrawer?.openDrawer('new')}
/>
<EvalDatasetDrawer
@@ -968,11 +1098,14 @@
onDeleted={datasetDeleted}
onCasesChanged={casesChanged}
onScorersChanged={scorersChanged}
onClosed={() => {
if (!resumeRunDialog) return
resumeRunDialog = false
// On the dataset the drawer was just in: the dialog opens on the pane's own, which
// creating or editing one has already moved to it.
runDialogOpen = true
}}
/>
<style>
/* Direct child only, so the rule cannot reach a Splitpanes nested inside a page. Transparent
rather than `opacity: 0`: the gutter stays there to be dragged, it just stops drawing a line
between the cells and the case beside them. */
:global(.splitter-hidden > .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
}
</style>
@@ -1,26 +0,0 @@
/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame
rather than inherit it. */
.ag-theme-alpine .wm-multiline-cell-editor,
.ag-theme-alpine-dark .wm-multiline-cell-editor {
background-color: var(--ag-background-color);
}
.ag-theme-alpine .wm-multiline-cell-editor textarea,
.ag-theme-alpine-dark .wm-multiline-cell-editor textarea {
display: block;
box-sizing: border-box;
/* Horizontal only: the vertical padding is set by the editor, which knows the height of the row
it is replacing. `line-height` here is what it computes against. */
padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px);
border: 1px solid var(--ag-input-focus-border-color);
border-radius: 3px;
outline: none;
resize: none;
/* Past this it scrolls rather than growing. */
max-height: 40vh;
overflow-y: auto;
background-color: var(--ag-background-color);
color: var(--ag-foreground-color);
font: inherit;
line-height: 20px;
white-space: pre-wrap;
}
@@ -1,108 +0,0 @@
import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'
// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule
// added to it is one the next copy of it drops.
import './multilineCellEditor.css'
/** Kept in step with the `line-height` the stylesheet gives the textarea. */
const LINE_HEIGHT = 20
/**
* A text cell editor that starts the height of the cell and grows as lines are added, for columns
* holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels.
*
* Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so
* growing is only visible if the editor is allowed to paint outside it.
*/
export class MultilineCellEditor implements ICellEditorComp {
private eGui!: HTMLDivElement
private textarea!: HTMLTextAreaElement
private params!: ICellEditorParams
private wasEmpty = false
init(params: ICellEditorParams) {
this.params = params
this.eGui = document.createElement('div')
this.eGui.className = 'wm-multiline-cell-editor'
this.wasEmpty = params.value == undefined
this.textarea = document.createElement('textarea')
this.textarea.rows = 1
// A keystroke that opened the edit replaces the value, as it does in every other cell; F2
// and double-click keep it to be edited.
this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '')
this.textarea.style.width = `${params.column.getActualWidth() - 2}px`
// Padded so one line fills the cell it replaces and a second costs a line rather than a row.
// From the row rather than from `--ag-row-height`, which is the theme's figure and not
// necessarily this grid's.
const rowHeight = params.node.rowHeight ?? 28
const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2)
this.textarea.style.paddingTop = `${padding}px`
this.textarea.style.paddingBottom = `${padding}px`
this.textarea.addEventListener('input', () => this.resize())
this.textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Kept from whatever is around the grid: a grid in a drawer or a dialog is under a
// surface that closes on Escape, and leaving an edit is not asking to leave that.
e.preventDefault()
e.stopPropagation()
this.params.api.stopEditing(true)
return
}
if (e.key !== 'Enter' || e.isComposing) return
// Both branches keep the key from the grid, which ends the edit on Enter whether or not
// Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter
// ends the edit here instead.
e.stopPropagation()
if (!e.shiftKey) {
e.preventDefault()
this.params.stopEditing()
}
})
this.eGui.appendChild(this.textarea)
}
private resize() {
this.textarea.style.height = 'auto'
this.textarea.style.height = `${this.textarea.scrollHeight}px`
}
getGui() {
return this.eGui
}
afterGuiAttached() {
this.resize()
this.textarea.focus()
// At the end rather than selected: a selection is a keystroke away from erasing the cell.
const end = this.textarea.value.length
this.textarea.setSelectionRange(end, end)
}
getValue() {
// Nothing typed into a cell that held nothing is not an edit: returning '' here would write
// an empty string over a null, which the grid would see as a change and commit.
if (this.wasEmpty && this.textarea.value === '') return this.params.value
return this.textarea.value
}
isPopup() {
return true
}
getPopupPosition(): 'over' | 'under' {
return 'over'
}
}
/**
* What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as
* the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit
* under, so the editor cannot keep Shift+Enter for itself on its own.
*/
export const multilineCellColDef: Pick<ColDef, 'cellEditor' | 'suppressKeyboardEvent'> = {
cellEditor: MultilineCellEditor,
suppressKeyboardEvent: (p) =>
p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey
}
@@ -0,0 +1,173 @@
<!--
@component
Inline-editable prose. The multiline counterpart to `EditableInput`: idle renders
the whole text, wrapped, as a clickable button; clicking swaps to an auto-growing
`textarea`. `Enter` or `blur` commits via `onSave`, `Shift+Enter` adds a line,
`Escape` discards — except under `commitOnInput`, where every keystroke has
already been propagated and there is nothing left to discard.
Escape is stopped from propagating, which keeps a surrounding `Drawer` open. A `Modal`
is the exception: it takes both Enter and Escape on `window` in the capture phase and
stops them there, so neither reaches this. Inside one, pass `enterConfirms={false}` to
the `Modal` for Enter to commit here rather than confirm the dialog; Escape stays the
dialog's either way, so expect it to leave the edit and close the dialog together.
Use it where a cell or a field holds a sentence rather than a label — a prompt, a
description, an expected answer. For a one-line label, use `EditableInput`.
```svelte
<EditableTextarea
value={question}
placeholder="Question"
onSave={(v) => (question = v)}
textClass="text-xs font-normal"
/>
```
Like `EditableInput`, the value isn't bound: `onSave` fires whenever the draft
differs from the prior `value`, including `''` when cleared. Unlike it, the draft
goes back verbatim — whitespace a caller stored on purpose survives a focus and a
blur. The parent owns the canonical state.
-->
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
interface Props {
/** Current value shown in idle mode and pre-filled when entering edit mode. */
value: string
/** Shown when `value` is empty, in both idle and editing modes. */
placeholder?: string
/**
* Called when the user commits a changed value (Enter or blur, or every keystroke when
* {@link commitOnInput} is set). Fires with the draft exactly as typed, including `''` if the
* field was cleared. Not called on Escape, or when the draft matches the prior `value`.
*/
onSave?: (newValue: string) => void
/**
* Fire `onSave` on every keystroke instead of only on Enter/blur. Use when the parent
* autosaves, or when a control outside this one reads the value on click — the click blurs
* the textarea, and a commit-on-blur would land after the read. Escape no longer discards
* with this on: the live commits have already propagated.
*/
commitOnInput?: boolean
/** When false, renders as plain text and is not clickable. */
editable?: boolean
/** Textarea size in editing mode. Idle mode is text only and unaffected. */
size?: 'xs' | 'sm' | 'md' | 'lg'
/** Wrapper classes. Layout only — margin, width, alignment — not text styling. */
class?: string
/** Extra classes on the `<textarea>`. Background, border and shadow are reset over these. */
inputClass?: string
/**
* Text styling applied to *both* the idle text and the textarea, so the two render
* identically and clicking in does not shift the text.
*/
textClass?: string
}
let {
value,
placeholder = '',
onSave,
commitOnInput = false,
editable = true,
size = 'sm',
class: className = '',
inputClass = '',
textClass = ''
}: Props = $props()
let editing = $state(false)
let draft = $state('')
// `TextInput` is generic over its underlying element and defaults to `'input'`; this one renders
// a textarea, so the binding has to say so or it is typed as the wrong component.
let textInputComponent: TextInput<'textarea'> | undefined = $state(undefined)
function startEditing() {
if (!editable) return
editing = true
draft = value ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
/** Open the editor from outside — a row that was just added, say, so the caller does not have
* to click into it. */
export function edit() {
startEditing()
}
function save() {
// Re-entry guard: Enter calls `save()` and clears `editing`, which unmounts the textarea and
// synchronously fires its `blur` handler — also `save()`. Without this, `onSave` fires twice.
if (!editing) return
editing = false
// Verbatim, not trimmed: a caller's value may hold whitespace that means something, and
// merely focusing and blurring a field must never count as an edit.
if (draft !== (value ?? '')) {
onSave?.(draft)
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Kept from a surrounding drawer, which would otherwise close on it. A `Modal` takes
// Escape at `window` in the capture phase and is past us before this runs.
e.preventDefault()
e.stopPropagation()
editing = false
return
}
// Shift+Enter falls through to the textarea's own newline; plain Enter commits, which is
// what makes this usable in a table where Enter means "done with this cell".
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
save()
}
}
function handleLiveInput(e: Event) {
const next = (e.currentTarget as HTMLTextAreaElement).value
if (next !== (value ?? '')) onSave?.(next)
}
</script>
{#if editing}
<!-- Every override below exists so the two states are the same size with the text in the same
place, and clicking in moves nothing: `!min-h-0` (TextInput floors it at 28px), `!block` (an
inline-block textarea baseline-aligns and the idle button does not), and never `!h-auto` —
`use:autosize` grows the field with an inline `style.height` an `!important` class beats. -->
<TextInput
bind:this={textInputComponent}
bind:value={draft}
{size}
underlyingInputEl="textarea"
autosizeParams={{ minHeight: 0 }}
class="!block !bg-transparent !border-0 !shadow-none !m-0 !min-w-0 !min-h-0 !py-0 {textClass} {inputClass} {className}"
inputProps={{
placeholder,
rows: 1,
onblur: save,
onkeydown: handleKeydown,
oninput: commitOnInput ? handleLiveInput : undefined,
spellcheck: false,
style: 'padding: 2px !important; resize: none'
}}
/>
{:else}
<!-- Padding 2px over, 4px under: the same 22px box as the textarea *and* the same 2px above the
first line. An even 3px gets the box right and leaves the text a pixel low. -->
<button
type="button"
onclick={startEditing}
disabled={!editable}
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
class="w-full text-left whitespace-pre-wrap break-words rounded px-0.5 pt-[2px] pb-[4px] {editable
? 'cursor-text hover:bg-surface-hover'
: 'cursor-default'} {textClass} {className}"
>
{value || placeholder}
</button>
{/if}
@@ -8,6 +8,7 @@
import { onMount, createEventDispatcher, setContext, untrack } from 'svelte'
import { BROWSER } from 'esm-env'
import Disposable from './Disposable.svelte'
import { setTopmostSurface } from '$lib/components/common/overlayHost.svelte'
import ConditionalPortal from './ConditionalPortal.svelte'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
@@ -51,6 +52,11 @@
let disposable: Disposable | undefined = $state(undefined)
// A drawer stacks like a dialog does, so content inside it gets the same answer about whether
// its keys are meant for it. Without this, a drawer opened over a dialog would inherit the
// dialog's answer — false, because the drawer itself is now on top — and go deaf.
setTopmostSurface(() => disposable?.isTopmost() ?? true)
let reducedMotion = useReducedMotion()
let duration = $derived(reducedMotion.val ? 0 : _duration)
let durationMs = $derived(duration * 1000)
@@ -10,6 +10,16 @@
label: string
icon?: any
onClick: () => void
/**
* `default` unless the surface has no other live call to action. Accent is for the case
* where this button is the only thing to press — a form whose submit is disabled until
* this is done, say — so it is not competing with one.
*/
variant?: 'default' | 'accent'
/** Same write lock the surface's other controls take. An empty state is still a live
* control: without this it stays clickable while a request that has already read the
* empty list is in flight, and whatever it adds is discarded when that request lands. */
disabled?: boolean
aiId?: string
aiDescription?: string
}
@@ -32,11 +42,13 @@
{/if}
</div>
{#if action}
<!-- `default`, not `accent`: the page header already carries the accent CTA for
this same action, and one accent per view is the rule. -->
<!-- `default` by default: the page header usually carries the accent CTA for this same
action, and one accent per view is the rule. A caller whose other CTA is disabled until
this is done opts into `accent`. -->
<Button
unifiedSize="md"
variant="default"
variant={action.variant ?? 'default'}
disabled={action.disabled}
startIcon={action.icon ? { icon: action.icon } : undefined}
onClick={action.onClick}
aiId={action.aiId}
@@ -11,7 +11,11 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
import { getOverlayHost, overlayHostActive } from '$lib/components/common/overlayHost.svelte'
import {
getOverlayHost,
overlayHostActive,
setTopmostSurface
} from '$lib/components/common/overlayHost.svelte'
const bubble = createBubbler()
import { createEventDispatcher, untrack } from 'svelte'
@@ -38,6 +42,18 @@
/** A line under the title saying what the dialog is for; in the header so it does not
* scroll away with the body. */
description?: string
/** The body holds pages that are laid over each other rather than stacked, so the header
* keeps its own height and only the pages move. Requires `fillHeight`: pages are absolutely
* positioned and need a definite height to fill. Pair with `PagedContent`, which does the
* laying over; this only makes room for it. */
paginated?: boolean
/**
* Whether Enter confirms the dialog. On by default, which is right for a form. Turn it off
* where the body is a surface with its own meaning for Enter: the handler runs at `window`
* in the capture phase and stops propagation, so while it is on nothing inside the dialog
* can see the key at all.
*/
enterConfirms?: boolean
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
* the dialog otherwise hugs its content, and percentage heights inside it do not
@@ -65,6 +81,8 @@
trail = undefined,
description = undefined,
fillHeight = false,
paginated = false,
enterConfirms = true,
minZIndex: minZIndexProp = undefined,
titleBadge,
settings,
@@ -95,6 +113,10 @@
// 1200 to stack above.
const minZIndex = $derived(minZIndexProp ?? (chatState.size > 0 ? zIndexes.aiChat + 1 : 0))
// So content in the body can tell a key meant for this dialog from one meant for whatever was
// opened over it. Read lazily: `disposable` is bound after this runs.
setTopmostSurface(() => disposable?.isTopmost() ?? true)
// Both `bind:open` and this $effect are needed: bind:open syncs the
// boolean, while the effect calls openDrawer/closeDrawer to register
// the disposable in the stacking system (same pattern as Drawer.svelte).
@@ -114,6 +136,7 @@
if (open) {
switch (event.key) {
case 'Enter':
if (!enterConfirms) break
event.stopPropagation()
event.preventDefault()
dispatch('confirmed')
@@ -231,7 +254,9 @@
{@render titleBadge?.()}
{/if}
{#if i > 0}
<ChevronRight size={18} class="text-tertiary shrink-0" />
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
<ChevronRight size={18} class="text-tertiary shrink-0" />
</span>
{/if}
{#if i === 0}
<!-- flex: an inline child holding an icon sits on the baseline and adds
@@ -250,7 +275,11 @@
{:else if segment.onclick}
{@render crumb(segment, i === crumbs.length - 2)}
{:else}
<span class="text-emphasis truncate" aria-current="page">
<span
class="text-emphasis truncate"
aria-current="page"
in:fade={{ duration: 150 }}
>
{segment.label}
</span>
{/if}
@@ -276,7 +305,13 @@
<p class="mt-1 text-xs text-secondary">{description}</p>
{/if}
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
<!-- `mt-1` when paginated: a page carries its own description as its first line,
and it belongs where the dialog's own sat, right under the title. -->
<div
class="{paginated ? 'mt-1' : 'mt-4'} text-sm text-primary {fillHeight
? 'flex-1 min-h-0'
: ''}"
>
{@render children_render?.()}
</div>
</div>
@@ -0,0 +1,202 @@
<script lang="ts" module>
import type { Snippet } from 'svelte'
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
* and every other waits off the side it is listed on, so a deeper page arrives from the right
* and the way back arrives from the left without anyone naming a direction. */
export type ModalPage = { key: string; content: Snippet }
</script>
<script lang="ts">
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
let {
pages,
current,
onNavigate,
warm = false,
class: c = ''
}: {
pages: ModalPage[]
/** The `key` of the page on screen. */
current: string
/**
* Go to a page. Given, the component answers the left and right arrow keys, stepping between
* pages the way the surface's own controls do. Escape is deliberately not taken: the dialog
* around this owns it, and a page component that swallowed it would stop the dialog from
* closing. Without this prop the pages are still navigable, just not from the keyboard —
* the caller owns `current` either way.
*/
onNavigate?: (key: string) => void
/**
* Build every page once this one has settled, rather than each on its first visit. Turn it
* on where a page is heavy enough that building it costs a frame: otherwise that build
* lands on the first click, inside the transition, and only the first navigation stutters.
* The cost moves to a moment when nothing is animating and nobody is waiting.
*/
warm?: boolean
class?: string
} = $props()
/** A page is mounted the first time it is opened and never unmounted again, which is the whole
* point of this over an `{#if}`: a page keeps its state, its loaded data and its scroll
* position while another is on screen, and returning to it costs nothing. */
let visited = $state<string[]>([])
$effect(() => {
if (!visited.includes(current)) visited = [...visited, current]
})
/** Whether the rest of the pages have been built. Deferred a frame past mount rather than done
* outright: the page being opened *now* is what the user is waiting on, and building its
* neighbours in the same frame would make opening the dialog pay for them. */
let warmed = $state(false)
$effect(() => {
if (!warm || warmed) return
const frame = requestAnimationFrame(() => (warmed = true))
return () => cancelAnimationFrame(frame)
})
let box: HTMLDivElement | undefined = $state()
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
// still the one on top, rather than under a drawer or a dialog opened since.
const hostActive = overlayHostActive()
const onTop = topmostSurface()
const listening = () => hostActive() && onTop()
/** Whether the keyboard is ours to answer.
*
* Deliberately not "focus is inside the pages". Navigating leaves focus wherever the page that
* was left put it — the body, once `inert` lands on it, or a button in the surrounding dialog
* chrome — so a containment test answers the first press and refuses every one after it. What
* is actually being asked is whether the key is meant for something else, and only a control
* that reads arrows itself qualifies. */
function ownsKeyboard(target: EventTarget | null): boolean {
if (!box || !box.isConnected) return false
const el = target as HTMLElement | null
return !el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')
}
function onKeydown(event: KeyboardEvent) {
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
if (!ownsKeyboard(event.target)) return
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (step === 0) return
const next = pages[index + step]
if (!next) return
event.preventDefault()
onNavigate(next.key)
}
/** Whether focus is inside the pages right now. Tracked as focus moves, not read when the page
* changes: a surrounding `Modal` handles Escape at `window` in the capture phase and steps a
* level back from there, and Svelte flushes this component's effects inside that handler —
* before a keydown listener of ours would ever run. Only a value already settled survives that.
* Plain state, not `$state`: the effect below is keyed on the page, and reading this must not
* key it too. */
let focusInside = false
$effect(() => {
if (!box) return
const enter = () => (focusInside = true)
const leave = (event: FocusEvent) => {
focusInside = !!event.relatedTarget && !!box?.contains(event.relatedTarget as Node)
}
box.addEventListener('focusin', enter)
box.addEventListener('focusout', leave)
return () => {
box?.removeEventListener('focusin', enter)
box?.removeEventListener('focusout', leave)
}
})
/** Focus follows the page, or a keyboard user is dropped out of the dialog: `inert` on the page
* being left resets focus to the body, and so does the trail's back button, which is removed
* as the level it returns from closes. Focus is taken when it was in the pages, or when it has
* ended up nowhere — never off a control the user is actually on, such as dialog chrome that
* outlives the navigation. */
let previous = current
$effect(() => {
const next = current
if (next === previous) return
const wasInside = focusInside
previous = next
// Claimed over a few frames rather than one: the browser runs its own fixup for the page
// that just became inert, and it lands after this on some paths.
let tries = 3
let frame = 0
const claim = () => {
const page = box?.querySelector<HTMLElement>('.paged-content-page:not(.is-hidden)')
if (!page) return
if ((wasInside && tries === 3) || document.activeElement === document.body) {
page.focus({ preventScroll: true })
}
if (--tries > 0) frame = requestAnimationFrame(claim)
}
frame = requestAnimationFrame(claim)
return () => cancelAnimationFrame(frame)
})
/** How far a page travels, as a share of the box. Deliberately a fraction and not the whole
* width: at 100% a page has left the box before its opacity has gone anywhere, so the fade is
* spent off-screen and all that is left to see is a slide. */
const TRAVEL_PERCENT = 24
let index = $derived(
Math.max(
pages.findIndex((p) => p.key === current),
0
)
)
</script>
<!-- The clipper. `overflow` clips descendants and not an element's own transform, so the box that
hides a page off the side can never be the page that travels. -->
<svelte:window onkeydown={onKeydown} />
<div bind:this={box} class="relative overflow-hidden {c}">
{#each pages as page, i (page.key)}
{#if visited.includes(page.key) || warmed}
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
height as one replaces another. That needs a definite height from the caller.
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
tab order and every screen reader still walk through it. -->
<!-- `tabindex="-1"`: focusable programmatically, never a tab stop of its own. -->
<div
tabindex="-1"
class="paged-content-page absolute inset-0 flex flex-col"
class:is-hidden={i !== index}
class:opacity-0={i !== index}
class:pointer-events-none={i !== index}
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
inert={i !== index}
>
{@render page.content()}
</div>
{/if}
{/each}
</div>
<style>
/* Arriving: slides in and fades up. A transition reads the duration of the state it is moving
*to*, which is what lets the two directions differ here. */
.paged-content-page {
transition-property: transform, opacity;
transition-duration: 130ms, 90ms;
transition-timing-function: ease-out;
}
/* Leaving: gone at once. Both pages share the box while one replaces the other, so an outgoing
page is painted over the incoming one for as long as it still has any opacity — any fade at
all is a ghost laid over the page you are trying to look at. It still travels, which costs
nothing to watch because it is already invisible. */
.paged-content-page.is-hidden {
transition-duration: 130ms, 0s;
}
@media (prefers-reduced-motion: reduce) {
.paged-content-page {
transition: none;
}
}
</style>
@@ -72,3 +72,23 @@ export function overlayHostActive(): () => boolean {
const host = getOverlayHost()
return () => host?.active() ?? true
}
const TOPMOST_SURFACE_KEY = 'topmostSurface'
/**
* Declare whether the surface enclosing this subtree is the one on top. Set by whatever owns the
* stacking a dialog, a drawer so content inside it can tell a key meant for itself from one
* meant for something opened over it.
*/
export function setTopmostSurface(isTopmost: () => boolean) {
setContext(TOPMOST_SURFACE_KEY, isTopmost)
}
/**
* Whether the enclosing surface is on top. True when nothing declared otherwise, so content that
* is not inside such a surface is not silently made deaf.
*/
export function topmostSurface(): () => boolean {
const isTopmost = getContext<(() => boolean) | undefined>(TOPMOST_SURFACE_KEY)
return () => isTopmost?.() ?? true
}
@@ -109,13 +109,13 @@
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
kind="app"
{keyboardSelected}
{marked}
path={(app as any).draft_path ?? app.path}
summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={!app.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -132,13 +132,13 @@
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{keyboardSelected}
{marked}
path={flow.draft_path ?? flow.path}
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -35,13 +35,13 @@
<Row
href="{base}/apps_raw/get/{app.path}"
kind="raw_app"
{keyboardSelected}
{marked}
path={app.path}
summary={app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={true}
{depth}
{keyboardSelected}
>
{#snippet badges()}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
@@ -13,6 +13,8 @@
interface Props {
marked: string | undefined
selected?: boolean
/** Highlighted by the list's keyboard arrow-navigation (distinct from `selected`,
* which is the checkbox multi-select state). Scrolls itself into view. */
keyboardSelected?: boolean
disabled?: boolean
canFavorite?: boolean
@@ -147,6 +147,7 @@
? `${base}/scripts/edit/${script.path}`
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
{keyboardSelected}
{marked}
path={script.draft_path ?? script.path}
summary={script.is_draft
@@ -156,7 +157,6 @@
workspaceId={$workspaceStore ?? ''}
canFavorite={!script.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -187,7 +187,9 @@
<Badge small color="yellow" baseClass="border">CI test</Badge>
</Popover>
{/if}
{#if script.kind !== 'script'}
<!-- Guard on a non-empty kind: a draft-only script can carry an empty `kind`, which
still isn't 'script' and would render an empty blue badge. -->
{#if script.kind && script.kind !== 'script'}
<Badge color="blue" baseClass="border"
>{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)}</Badge
>
@@ -51,20 +51,26 @@
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
)
// A spent free grant is not an unconfigured workspace: AIChatDisplay already shows an
// in-thread banner naming the real cause and linking to the key settings, so the generic
// "enable Windmill AI" line would both duplicate it and misstate why the chat is off.
const freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
const disabledMessage = $derived(
forceDisabled
? forceDisabledMessage
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -7,6 +7,7 @@
AlertTriangle,
ArrowDown,
AtSign,
BookOpen,
ChevronDown,
ChevronsRight,
CheckIcon,
@@ -15,6 +16,7 @@
Folder,
Hand,
HistoryIcon,
KeyRound,
MousePointer2,
Plug,
Plus,
@@ -34,6 +36,7 @@
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import McpConnections from './McpConnections.svelte'
import SkillsPicker from './SkillsPicker.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
@@ -60,9 +63,22 @@
readDroppedEntries
} from './files/fsAccess'
import { sendUserToast } from '$lib/toast'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { copilotInfo } from '$lib/aiStore'
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const chatHost = getChatViewHost()
// The user spent their one-time free Windmill AI grant: there is no model left to send
// to, so say so in the thread itself rather than only failing on send.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
// Still on the free grant: keep how much is left in view right above the composer, so
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
let freeTier = $derived($copilotInfo.freeTier)
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
// One row per autonomy posture, in picker order, so adding one touches only this
// table. `isAvailable` hides the postures that would do nothing in the current AI
// mode, which is why the picker can be shorter than this list.
@@ -209,6 +225,7 @@
let aiChatInput: AIChatInput | undefined = $state()
let mcpConnections: McpConnections | undefined = $state()
let skillsPicker: SkillsPicker | undefined = $state()
let plusMenuOpen = $state(false)
let editingMessageIndex = $state<number | null>(null)
@@ -613,6 +630,44 @@
)
</script>
{#snippet freeTierExhaustedBanner()}
<div class="my-2">
<Alert type="info" size="xs" title="Free Windmill AI used up">
<div class="flex flex-col items-start gap-2">
<span>
You have used all of your free Windmill AI tokens. Add your own API key to keep using AI.
</span>
<Button
unifiedSize="2xs"
variant="accent"
startIcon={{ icon: KeyRound }}
href="{base}/workspace_settings?tab=ai"
>
Add your own API key
</Button>
</div>
</Alert>
</div>
{/snippet}
{#snippet freeTierUsageBanner()}
<div
class="my-1 flex items-center justify-between gap-2 rounded-md border bg-surface-secondary px-2 py-1"
>
<span class="text-xs text-secondary tabular-nums">
{freeTierUsedPct}% of your free Windmill AI used
</span>
<Button
unifiedSize="2xs"
variant="default"
startIcon={{ icon: KeyRound }}
href="{base}/workspace_settings?tab=ai"
>
Configure your API key
</Button>
</div>
{/snippet}
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div
@@ -726,6 +781,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
script editor to modify selected lines.</span
>
{/if}
{#if freeTierExhausted}
<div class={wideLayout ? 'w-full max-w-3xl mx-auto px-7' : 'w-full max-w-2xl mx-auto px-3'}>
{@render freeTierExhaustedBanner()}
</div>
{/if}
{/if}
{#if messages.length > 0}
@@ -750,6 +810,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
isLast={messageIndex === messages.length - 1}
/>
{/each}
{#if freeTierExhausted}
{@render freeTierExhaustedBanner()}
{/if}
{#if showTypingIndicator}
<div
class={twMerge(
@@ -849,6 +912,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if inputPreface}
{@render inputPreface()}
{/if}
{#if showFreeTierUsage}
{@render freeTierUsageBanner()}
{/if}
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
@@ -921,43 +987,64 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
{#if canAttachFiles}
<DropdownV2
items={async () => [
{
displayName: 'Attach file or image',
icon: FileText,
action: () => {
plusMenuOpen = false
linkFiles()
}
},
...(canLinkFolders
? [
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
items={async () => {
// Both submenus fetch on the menu's first open, so they start
// together: awaited inline they queue, and the whole menu —
// attachments included — waits out two round trips.
const closeMenu = () => (plusMenuOpen = false)
const inGlobal = chatHost.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined,
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined
])
return [
{
displayName: 'Attach file or image',
icon: FileText,
action: () => {
plusMenuOpen = false
linkFiles()
}
},
...(canLinkFolders
? [
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
}
}
]
: []),
...(chatHost.mode === AIMode.GLOBAL && mcpConnections
? [
{
displayName: 'MCP connections',
icon: Plug,
separatorTop: true,
submenuItems: await mcpConnections.menuItems(() => (plusMenuOpen = false))
}
]
: [])
]}
]
: []),
...(skillItems
? [
{
displayName: 'Skills',
icon: BookOpen,
separatorTop: true,
submenuItems: skillItems
}
]
: []),
...(mcpItems
? [
{
displayName: 'MCP connections',
icon: Plug,
separatorTop: !skillItems,
submenuItems: mcpItems
}
]
: [])
]
}}
placement="bottom-start"
fixedHeight={false}
closeOnItemClick={false}
@@ -1100,6 +1187,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
{@render footerSettings?.()}
{#if chatHost.mode === AIMode.GLOBAL}
<SkillsPicker bind:this={skillsPicker} />
<McpConnections bind:this={mcpConnections} />
{/if}
@@ -13,6 +13,7 @@
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { getAiChatManager } from './aiChatManagerContext'
import { formatMention } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -746,6 +747,17 @@
selectedContext = [...selectedContext, contextToAdd]
}
/** Consume inside the submit gesture, never after it: the manager's preflight
* awaits attachment upkeep and a session fork that can take seconds, and
* consuming past them would hand this message a mention the user picked for
* the next one. */
function consumeMentionsIfGlobal() {
if (chatHost.mode !== AIMode.GLOBAL) return
// The mention context belongs to the copilot's own ContextManager, which only
// the manager has — the GLOBAL guard above means this host is always it.
getAiChatManager().contextManager?.consumeMentionContext()
}
function sendRequest() {
// The send button is disabled while decoding, but Enter reaches here directly.
// Sending now would drop the in-flight attachments onto the following message.
@@ -763,6 +775,10 @@
])
) {
draft.take()
// The answer carries only its choice strings, so a mention here rides
// nothing — but clearForSend below keeps the selection, which would hand
// it to the next turn.
consumeMentionsIfGlobal()
contextTextareaComponent?.clearForSend()
return
}
@@ -786,6 +802,8 @@
[...selectedContext],
sent.files
)
// Consumed at enqueue, not at flush: the entry above pinned them.
consumeMentionsIfGlobal()
contextTextareaComponent?.clearForSend()
}
return
@@ -806,18 +824,25 @@
onEditEnd()
} else {
const sent = draft.take()
// Pin before consuming: the manager falls back to the live selection only
// when given no override, and the consume below empties it.
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
files: sent.files,
blobs: sent.blobs
blobs: sent.blobs,
contextOverride: carried,
contextOverrideOrigin: carried ? 'pinned' : undefined
})
// clearForSend() pre-zaps the textarea's mention-sync so the wipe
// doesn't drop `selectedContext` before `AIChatManager.beforeSend`
// snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the
// fallback textarea still rely on the draft reset alone (no
// `@`-mention state to coordinate).
// clearForSend() pre-zaps the textarea's mention-sync so the wipe doesn't
// drop `selectedContext` before the send has settled its context: the pin
// above in GLOBAL, the manager's own read of the live selection in
// SCRIPT/FLOW. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the fallback
// textarea still rely on the draft reset alone (no `@`-mention state to
// coordinate).
contextTextareaComponent?.clearForSend()
}
}
@@ -86,6 +86,8 @@ import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
@@ -94,6 +96,7 @@ import {
createAppBackendRunnableContextElement,
createAppFrontendFileContextElement,
flattenDatatablesToAppContextElements,
isMentionContext,
isSameContextElement,
type ContextElement,
type AppDatatableElement
@@ -366,6 +369,25 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
return appendWebSearchErrorHint(message, webSearchUnavailable)
}
/** Re-fetch copilotInfo after a free-tier turn so the usage banner tracks spend live and the
* exhausting turn flips `freeTier.exhausted`; otherwise these update only on the next workspace
* load. Scoped to a live (non-exhausted) free tier so configured-key users pay no extra request. */
async function refreshFreeTierUsage(workspace: string | undefined) {
if (!workspace) return
// copilotInfo is a singleton shared across sessions: a warm session finishing after a
// workspace switch must not loadCopilot for its now-background workspace. Gate on the
// most-recently-*requested* workspace (set synchronously) so a refresh can't win the
// monotonic token over a newer load still in flight.
if (get(copilotWorkspaceRequested) !== workspace) return
const info = get(copilotInfo)
if (!info.freeTier || info.freeTier.exhausted) return
try {
await loadCopilot(workspace)
} catch (err) {
console.error('Failed to refresh free-tier usage', err)
}
}
/** A message queued while a turn streams: the draft lanes and the pinned
* context snapshot always move together so a flush can't drop one. */
type QueuedEntry = {
@@ -1132,9 +1154,10 @@ export class AIChatManager implements ChatViewHost {
}
}
// Workspace AI skills (name + description) advertised in the GLOBAL system
// prompt and surfaced as slash commands in session chat. Loaded
// asynchronously when entering GLOBAL mode; the system message is rebuilt
// The `ai_skill` resources this user turned on for the operating workspace,
// advertised in the GLOBAL system prompt and surfaced as slash commands in
// session chat. Loaded asynchronously when entering GLOBAL mode and again
// whenever the picker changes the selection; the system message is rebuilt
// once they resolve.
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
@@ -1170,9 +1193,10 @@ export class AIChatManager implements ChatViewHost {
]
// Built-ins followed by workspace skills, with any skill whose name collides
// with a built-in dropped: the picker keys leaves by name, so a duplicate
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
// already shadow same-named skills at execution (the submit interception).
// with a built-in dropped. Built-ins win — they already shadow same-named
// skills at execution (the submit interception), so listing both would offer
// a row that cannot run. Two skills may still share a name; the picker keys
// those by path and the submit path declines to guess between them.
sessionCommands: ChatCommandItem[] = $derived([
...this.sessionBuiltinCommands,
...this.globalSkills
@@ -1851,6 +1875,25 @@ export class AIChatManager implements ChatViewHost {
}
}
/** Give back the mentions a send carried when its text returns to the composer,
* so its `@` tokens still have entries to bind to. Additive, unlike the DOM
* restore above: dropping the entries this send did not carry would strand the
* tokens naming them in a draft whose text now shares the same composer.
*
* `originMode` is the mode the send was submitted in, and is required: the
* composer only consumes in GLOBAL, so reading the mode at restore time would
* strand a send whose mode changed mid-turn and resurrect chips for one that
* never consumed. Every caller states which mode it means. */
#restoreMentionContext(context: ContextElement[] | undefined, originMode: AIMode) {
if (originMode !== AIMode.GLOBAL) return
const mentions = (context ?? []).filter(isMentionContext)
if (mentions.length === 0) return
const selection = this.contextManager?.getSelectedContext() ?? []
const missing = mentions.filter((m) => !selection.some((s) => isSameContextElement(s, m)))
if (missing.length === 0) return
this.contextManager?.setSelectedContext([...selection, ...missing])
}
/** Send `text` as a turn, or queue it when one is already streaming. Callers
* that send programmatically (an editor button, an arriving hand-off) must go
* through this rather than `sendRequest`: a second concurrent loop shares this
@@ -1883,6 +1926,10 @@ export class AIChatManager implements ChatViewHost {
// is selected now. If its text was prepended onto an existing draft, that
// draft's chips are kept too — both instructions now share one composer.
this.#restoreDomContext(queued.context, mergedIntoDraft)
// The queue aggregates several enqueues into one entry and records no
// originating mode, so the mode now is the closest signal available. A
// recall after a mid-turn mode switch can therefore miss a restore.
this.#restoreMentionContext(queued.context, this.mode)
}
/** Put what the user typed back where they can see it: into the input
@@ -2110,7 +2157,11 @@ export class AIChatManager implements ChatViewHost {
if (refreshId !== this.globalSkillsRefreshId) {
return
}
this.globalSkills = skills
// Newest-wins is not enough: a refresh for the workspace just left can still
// hold the newest id, and installing it would advertise that workspace's
// skills to a chat now acting elsewhere. Same check the identity and MCP
// refreshes make.
this.globalSkills = workspace === (this.operatingWorkspace ?? '') ? skills : []
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
@@ -2198,16 +2249,25 @@ export class AIChatManager implements ChatViewHost {
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
return instructions
}
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
// Accepts a bare name or a whole resource path: names are what people type,
// but the picker inserts the path when two folders answer to the same name.
// Unicode-aware rather than `\w`, which is ASCII-only — a resource path may
// hold any word character, and the picker can insert one the user must then
// be able to send (`f/équipe/deploy`).
const match = /^\/([\p{L}\p{N}_\-/]+)(?:\s+([\s\S]*))?$/u.exec(instructions)
if (!match) {
return instructions
}
const skill = this.globalSkills.find((s) => s.name === match[1])
if (!skill) {
// A path identifies one skill; a name shared by two would otherwise silently
// apply instructions the user did not choose, so it is left unexpanded.
const byPath = this.globalSkills.find((s) => s.path === match[1])
const matches = byPath ? [byPath] : this.globalSkills.filter((s) => s.name === match[1])
if (matches.length !== 1) {
return instructions
}
const rest = match[2]?.trim()
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
const use = `Use the skill at "${matches[0].path}".`
return rest ? `${use} ${rest}` : use
}
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
@@ -2811,8 +2871,8 @@ export class AIChatManager implements ChatViewHost {
lang?: ScriptLang | 'bunnative'
isPreprocessor?: boolean
// Use this selected-context snapshot for the turn instead of the live
// contextManager. Set when flushing a queued message that captured its
// context at submit time; the live selection is left untouched.
// contextManager. Set whenever a send settles its context ahead of the
// turn: a composer submit at the click, a queued message at enqueue.
contextOverride?: ContextElement[]
/** Where `contextOverride` came from. 'pinned' (default): the chips were
* selected for THIS message, so they are consumed from the live selection
@@ -2962,12 +3022,18 @@ export class AIChatManager implements ChatViewHost {
// re-reserves them, so release this send's outgoing-files reservation.
this.#releaseOutgoingReservation(reservationKey)
if (!options.queued) {
this.aiChatInput?.restoreInstructions(
this.instructions,
pastes,
options.images ?? [],
options.files ?? []
)
// Reached only once the mode has already moved off GLOBAL, so the
// restore is keyed to requestedMode: a GLOBAL submit whose mode
// flipped during the upkeep awaits above still gets its mentions
// back, while a send that started outside GLOBAL consumed none.
const taken =
this.aiChatInput?.restoreInstructions(
this.instructions,
pastes,
options.images ?? [],
options.files ?? []
) === true
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
}
return false
}
@@ -2988,6 +3054,11 @@ export class AIChatManager implements ChatViewHost {
// put them back in the composer instead of silently discarding them
// (the input already cleared itself optimistically on send). Queued
// drafts are the caller's to restore (it re-queues on false).
//
// No mention restore: an entry exists only while its `@` token is in the
// text (the picker adds both, the textarea's sync drops the entry when
// the token goes), and this branch requires empty text. A mention source
// that does not write a token would break that and need one here.
if (!this.instructions.trim() && files.length === 0) {
sendUserToast(`${sendModel.model} can't read images. Switch to a vision model first.`, true)
if (!options.queued) this.restoreToInput('', requestedImages)
@@ -3044,7 +3115,13 @@ export class AIChatManager implements ChatViewHost {
console.error('AIChatManager beforeSend hook failed', e)
rollbackOptimisticSend()
if (!options.queued) {
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
// Mentions were consumed at submit, so they come back with the text or
// not at all. Only when the composer took it: it declines when the
// user has started a new draft, and restoring then would put these
// mentions on that draft and every turn after it.
const taken =
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
}
sendUserToast(
`Could not prepare the session before sending: ${
@@ -3084,7 +3161,11 @@ export class AIChatManager implements ChatViewHost {
})
if (accepted === false) this.#restoreQueue(next)
} else {
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
// Same pairing as the beforeSend catch above: mentions ride back with the
// text, only if the composer took it.
const taken =
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
}
return true
}
@@ -3179,9 +3260,10 @@ export class AIChatManager implements ChatViewHost {
hideTarget?.removeEventListener('visibilitychange', checkpointOnHide)
}
try {
// A queued message carries its own context snapshot (contextOverride); use
// it verbatim and leave the live selection alone (it belongs to whatever the
// user has selected since). Otherwise read the current selection.
// A pinned snapshot (a queued message, or a composer submit settling its
// context at the click) is used verbatim, leaving the live selection alone
// it belongs to whatever the user has selected since. Otherwise read the
// current selection.
const oldSelectedContext =
options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
// DOM selector chips are one-shot: they ride with this message (captured in
@@ -3192,9 +3274,9 @@ export class AIChatManager implements ChatViewHost {
// context, consumed on its original send. The live selection belongs to
// the composer's own draft — touching it here would strip it.
} else if (options.contextOverride) {
// Queued message: only the chips it carried are consumed. Drop just
// those from the live selection (still there if the user didn't
// re-select); a newer selection made since is left intact.
// A pinned submit consumes only the chips it carried. Drop just those
// from the live selection (still there if the user didn't re-select); a
// newer selection made since is left intact.
for (const c of options.contextOverride) {
if (c.type === 'app_dom_selector') {
// Match appPath too: another app's live chip can share this
@@ -3613,6 +3695,7 @@ export class AIChatManager implements ChatViewHost {
// retarget whatever draft is sitting there.
if (textRestored) {
this.#restoreDomContext(oldSelectedContext)
this.#restoreMentionContext(oldSelectedContext, requestedMode)
}
if (this.displayMessages.length === 0) {
// saveChat no-ops on an empty transcript; the chat persisted earlier
@@ -3728,6 +3811,9 @@ export class AIChatManager implements ChatViewHost {
// releases the loop; it never discards uncommitted text.
this.replyReveal.reset()
this.reasoningReveal.reset()
// Refresh the free-tier usage meter after every turn (success or error), and
// let the turn that exhausts the grant flip to the exhausted state live.
void refreshFreeTierUsage(this.operatingWorkspace)
}
// Flush the queued message. Send it after a cleanly committed turn OR a
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
@@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({
getAnthropicClient: vi.fn(),
getNonStreamingCompletion: vi.fn(),
runChatLoop: vi.fn(),
listAiSkills: vi.fn(),
listResource: vi.fn(),
getJob: vi.fn(),
whoami: vi.fn(),
workspace: 'test_workspace' as string | undefined,
@@ -49,8 +49,9 @@ vi.mock('monaco-editor', () => ({
vi.mock('$lib/utils/featureUsage', () => ({ logFeatureUsage: vi.fn() }))
vi.mock('$lib/gen', () => ({
WorkspaceService: {
listAiSkills: mocks.listAiSkills
WorkspaceService: {},
ResourceService: {
listResource: mocks.listResource
},
ScriptService: {},
FlowService: {},
@@ -169,7 +170,7 @@ beforeEach(() => {
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
mocks.getOpenaiClient.mockReturnValue({})
mocks.getAnthropicClient.mockReturnValue({})
mocks.listAiSkills.mockResolvedValue([])
mocks.listResource.mockResolvedValue([])
mocks.workspace = 'test_workspace'
mocks.runChatLoop.mockResolvedValue({
addedMessages: [],
@@ -330,17 +331,29 @@ describe('AIChatManager global skills', () => {
mocks.tryGetCurrentModel.mockReturnValue(model)
})
// Only selected skills reach the prompt, and the selection is keyed by
// workspace and account (see skills/enabledSkills.ts).
function selectSkills(workspace: string, ...paths: string[]) {
const stored = JSON.parse(localStorage.getItem('wm_skills_enabled') ?? '{}')
stored[`${workspace}:${TEST_EMAIL}`] = paths
localStorage.setItem('wm_skills_enabled', JSON.stringify(stored))
}
it('loads skills after beforeSend commits the session workspace', async () => {
let resolveParentSkills: ((skills: { name: string; description: string }[]) => void) | undefined
const parentSkills = new Promise<{ name: string; description: string }[]>((resolve) => {
let resolveParentSkills: ((skills: unknown[]) => void) | undefined
const parentSkills = new Promise<unknown[]>((resolve) => {
resolveParentSkills = resolve
})
mocks.workspace = 'parent'
mocks.listAiSkills.mockImplementation(({ workspace }: { workspace: string }) => {
selectSkills('parent', 'f/skills/parent-skill')
selectSkills('child', 'f/skills/child-skill')
mocks.listResource.mockImplementation(({ workspace }: { workspace: string }) => {
if (workspace === 'parent') {
return parentSkills
}
return Promise.resolve([{ name: 'child-skill', description: 'child workspace skill' }])
return Promise.resolve([
{ path: 'f/skills/child-skill', description: 'child workspace skill' }
])
})
mocks.runChatLoop.mockImplementation(async (config: any) => {
expect(config.workspace).toBe('child')
@@ -362,22 +375,45 @@ describe('AIChatManager global skills', () => {
}
await manager.sendRequest({ instructions: 'first', mode: AIMode.GLOBAL })
resolveParentSkills?.([{ name: 'parent-skill', description: 'parent workspace skill' }])
resolveParentSkills?.([
{ path: 'f/skills/parent-skill', description: 'parent workspace skill' }
])
await Promise.resolve()
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'parent' })
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'child' })
expect(mocks.listResource).toHaveBeenCalledWith(
expect.objectContaining({ workspace: 'parent', resourceType: 'ai_skill' })
)
expect(mocks.listResource).toHaveBeenCalledWith(
expect.objectContaining({ workspace: 'child', resourceType: 'ai_skill' })
)
expect(manager.systemMessage.content).toContain('child-skill')
expect(manager.systemMessage.content).not.toContain('parent-skill')
})
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
mocks.listAiSkills.mockResolvedValue([
{ name: 'review-code', description: 'review code for bugs' }
it('leaves a readable but unselected skill out of the prompt', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'f/skills/selected', description: 'the one turned on' },
{ path: 'f/skills/unselected', description: 'readable but never turned on' }
])
selectSkills('test_workspace', 'f/skills/selected')
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.refreshGlobalSkills('test_workspace')
await manager.changeMode(AIMode.GLOBAL)
expect(manager.systemMessage.content).toContain('f/skills/selected')
expect(manager.systemMessage.content).not.toContain('f/skills/unselected')
})
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'u/admin/review-code', description: 'review code for bugs' }
])
selectSkills('test_workspace', 'u/admin/review-code')
mocks.runChatLoop.mockImplementation(async (config: any) => {
const userMessage = config.messages[config.messages.length - 1]
expect(userMessage.content).toContain('Use the "review-code" skill. find bugs')
expect(userMessage.content).toContain('Use the skill at "u/admin/review-code". find bugs')
expect(userMessage.content).not.toContain('/review-code find bugs')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
@@ -395,6 +431,33 @@ describe('AIChatManager global skills', () => {
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
})
it('does not expand a slash command two folders both answer to', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'u/admin/deploy', description: 'personal deploy steps' },
{ path: 'f/team/deploy', description: 'the team deploy steps' }
])
selectSkills('test_workspace', 'u/admin/deploy', 'f/team/deploy')
mocks.runChatLoop.mockImplementation(async (config: any) => {
// Picking either one would silently apply instructions the user did not
// choose, so the text is left alone for the model to ask about.
const userMessage = config.messages[config.messages.length - 1]
expect(userMessage.content).toContain('/deploy ship it')
expect(userMessage.content).not.toContain('Use the skill at')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.sendRequest({ instructions: '/deploy ship it', mode: AIMode.GLOBAL })
})
})
describe('AIChatManager global prompt identity', () => {
@@ -404,7 +467,7 @@ describe('AIChatManager global prompt identity', () => {
localStorage.clear()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
mocks.listAiSkills.mockResolvedValue([])
mocks.listResource.mockResolvedValue([])
})
afterEach(() => {
@@ -2164,6 +2227,98 @@ describe('AIChatManager queued messages', () => {
expect(chips.map((c) => c.selector)).toEqual(['div.card'])
})
// The composer consumes an `@` mention on send, so a send that never became a
// turn has to give it back — otherwise the restored text keeps its `@` token
// with nothing behind it.
it('restores a cancelled GLOBAL sends @ mentions', async () => {
const manager = createManager(createInputMock())
manager.mode = AIMode.GLOBAL
const cm = manager.contextManager
const mention = {
type: 'workspace_script' as const,
path: 'f/etl/sync',
title: 'f/etl/sync'
}
// What the composer does at submit: pin what it carries, then consume.
const carried = [mention]
cm.setSelectedContext([])
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
abortController.abort('user_cancelled')
throw new Error('aborted')
})
await manager.sendRequest({
instructions: 'why does this retry',
contextOverride: carried,
contextOverrideOrigin: 'pinned'
})
expect(cm.getSelectedContext()).toEqual([mention])
})
// The mode switcher stays live while a turn streams, so the restore is keyed
// to the mode the send was submitted in. Reading the mode at rollback time
// would strand the mention behind a `@` token that resolves to nothing.
it('restores a GLOBAL sends @ mentions after a mid-turn switch to SCRIPT', async () => {
const manager = createManager(createInputMock())
manager.mode = AIMode.GLOBAL
const cm = manager.contextManager
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
cm.setSelectedContext([])
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
// The user navigates to a script editor while the turn is streaming.
manager.mode = AIMode.SCRIPT
abortController.abort('user_cancelled')
throw new Error('aborted')
})
await manager.sendRequest({
instructions: 'why does this retry',
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
expect(cm.getSelectedContext()).toEqual([mention])
})
// Attachments are refused outside GLOBAL, and the refusal sits past the
// preflight awaits — so it is reached exactly when a GLOBAL submit's mode
// changed underneath it, and owes that send its mentions back.
it('restores mentions when a GLOBAL send is refused for switching modes with attachments', async () => {
const manager = createManager(createInputMock())
manager.mode = AIMode.GLOBAL
const cm = manager.contextManager
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
cm.setSelectedContext([])
const pending = manager.sendRequest({
instructions: 'describe this image',
images: [{ id: 'img-1', dataUrl: 'data:image/png;base64,AAAA' } as any],
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
manager.mode = AIMode.SCRIPT
await pending
expect(cm.getSelectedContext()).toEqual([mention])
})
// The editor modes show mentions as chips the user deletes by hand, so the
// restore must never re-add one they removed.
it('leaves an editor modes context alone when a queued draft comes back', () => {
const manager = createManager(createInputMock())
manager.mode = AIMode.SCRIPT
const cm = manager.contextManager
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
manager.queueMessage('fix this', [], [mention])
// The user deletes the chip while the turn streams.
cm.setSelectedContext([])
manager.dequeueMessage()
expect(cm.getSelectedContext()).toEqual([])
})
it('restores a dequeued inline prompts pinned DOM context, replacing the live selection', () => {
const manager = createManager(createInputMock())
const cm = manager.contextManager
@@ -2281,6 +2436,90 @@ describe('AIChatManager queued messages', () => {
expect(manager.displayMessages).toHaveLength(0)
})
// The composer consumes mentions before calling in, so a send that never left
// the preflight has to give them back with the text. GLOBAL renders no chip for
// them, so a mention lost here is invisible: the restored `@` token silently
// resolves to nothing, and a mention-only draft comes back empty.
it('restores consumed mentions when beforeSend rejects a GLOBAL send', async () => {
const input = createInputMock()
const manager = createManager(input)
manager.mode = AIMode.GLOBAL
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
manager.contextManager.setSelectedContext([])
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
const accepted = await manager.sendRequest({
instructions: '@f/etl/sync fix this',
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
expect(accepted).toBe(false)
expect(manager.contextManager.getSelectedContext()).toEqual([mention])
})
it('restores consumed mentions when a GLOBAL send is cancelled during the preflight', async () => {
const input = createInputMock()
const manager = createManager(input)
manager.mode = AIMode.GLOBAL
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
manager.contextManager.setSelectedContext([])
// Stop/Escape while "Creating workspace fork..." is showing.
manager.beforeSend = vi.fn().mockImplementation(async () => {
manager.cancel('user_cancelled')
})
await manager.sendRequest({
instructions: '@f/etl/sync fix this',
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.contextManager.getSelectedContext()).toEqual([mention])
})
// The gate, and the reason the restore is not unconditional: an occupied
// composer declines the dead draft's text, and its mentions would then ride the
// draft the user is writing now and every turn after it.
it('does not restore mentions into a draft the composer kept', async () => {
const input = createInputMock()
input.restoreInstructions.mockReturnValue(false)
const manager = createManager(input)
manager.mode = AIMode.GLOBAL
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
manager.contextManager.setSelectedContext([])
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
await manager.sendRequest({
instructions: '@f/etl/sync fix this',
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
expect(manager.contextManager.getSelectedContext()).toEqual([])
})
// The bit-identity tripwire: an editor mode reaches these same bailouts with a
// contextOverride (a replay, a queued flush) and must come out of them with the
// selection main would have left.
it('leaves an editor modes selection untouched through the same bailout', async () => {
const input = createInputMock()
const manager = createManager(input)
manager.mode = AIMode.SCRIPT
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
manager.contextManager.setSelectedContext([])
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
await manager.sendRequest({
instructions: 'fix this',
contextOverride: [mention],
contextOverrideOrigin: 'pinned'
})
expect(manager.contextManager.getSelectedContext()).toEqual([])
})
it('drops the queued message when switching conversations (no cross-chat leak)', async () => {
const manager = createManager(createInputMock())
@@ -2923,8 +3162,8 @@ describe('AIChatManager manual compaction', () => {
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
mocks.listAiSkills.mockResolvedValue([])
// changeMode(GLOBAL) refreshes the selected skills; keep it a no-op here.
mocks.listResource.mockResolvedValue([])
})
function seedExchange(manager: AIChatManager) {
@@ -3139,15 +3378,19 @@ describe('AIChatManager manual compaction', () => {
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
})
it('shadows a workspace skill that collides with a built-in command', () => {
it('shadows a selected skill that collides with a built-in command', () => {
const manager = new AIChatManager()
manager.globalSkills = [
{ name: 'compact', description: 'a workspace skill that happens to be named compact' },
{ name: 'review-code', description: 'review code for bugs' }
{
path: 'u/admin/compact',
name: 'compact',
description: 'a skill that happens to be named compact'
},
{ path: 'u/admin/review-code', name: 'review-code', description: 'review code for bugs' }
]
// Built-ins come first and the colliding skill is dropped, so the picker
// never renders two leaves with the same `skill:compact` key.
// Built-ins come first and the colliding skill is dropped: the built-in
// wins at execution too, so listing both would offer a row that cannot run.
const names = manager.sessionCommands.map((c) => c.name)
expect(names).toEqual(['compact', 'clear', 'review-code'])
expect(manager.sessionCommands[0].description).toBe(
@@ -46,6 +46,13 @@
)
let models = $derived($copilotInfo.aiModels)
// Free tier: the workspace has no key of its own and is spending Windmill's one-time
// grant. Label it so the user knows whose budget this is, and warn before it runs out
// rather than letting the grant die mid-task.
let freeTier = $derived($copilotInfo.freeTier)
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
@@ -312,6 +319,13 @@
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
@@ -2,6 +2,7 @@
import DrillPicker from '$lib/components/DrillPicker.svelte'
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
import type { ChatCommandItem } from './global/core'
import { ambiguousSkillNames } from './skills/skillResources'
interface Props {
skills: ChatCommandItem[]
@@ -24,15 +25,22 @@
skill: 'Skills'
}
// No `secondary`: rows show just the command; the full description lives in
// the hover tooltip (rowTooltip below). It stays in `searchableText` so
// filtering by description keeps working.
// Two folders can each hold a skill of the same name, and `/name` cannot then
// say which one is meant. Those rows show their path so the two are at least
// distinguishable; unambiguous rows stay bare, with the description in the
// hover tooltip (rowTooltip below) and in `searchableText` so filtering by it
// keeps working.
const ambiguous = $derived(ambiguousSkillNames(skills.filter((s) => s.path !== undefined)))
const tree = $derived<DrillNode<ChatCommandItem>[]>(
skills.map((skill) => ({
type: 'leaf' as const,
key: `skill:${skill.name}`,
// Keyed by path where there is one: names are not unique across folders,
// and a duplicate key breaks the keyed list and its ambiguous-resolve nav.
key: `skill:${skill.path ?? skill.name}`,
label: `/${skill.name}`,
searchableText: `${skill.name} ${skill.description}`,
secondary: ambiguous.has(skill.name) ? skill.path : undefined,
searchableText: `${skill.name} ${skill.path ?? ''} ${skill.description}`,
section: skill.kind ? SECTION_LABELS[skill.kind] : undefined,
data: skill
}))
@@ -2,7 +2,12 @@ import { ResourceService, type Flow, type ListResourceResponse, type ScriptLang
import { scriptLangToEditorLang } from '$lib/scripts'
import { SQLSchemaLanguages, type DBSchemas } from '$lib/stores'
import { diffLines } from 'diff'
import { createAppDomSelectorElement, type ContextElement, type FlowModuleElement } from './context'
import {
createAppDomSelectorElement,
isMentionContext,
type ContextElement,
type FlowModuleElement
} from './context'
import type { FlowModule } from '$lib/gen'
import type { DisplayMessage } from './shared'
@@ -330,6 +335,13 @@ export default class ContextManager {
this.selectedContext = newSelectedContext
}
/** Callers must gate on mode: only a GLOBAL send owns its mentions. Outside
* GLOBAL these are chips the user removes by hand, and dropping them here
* would delete a selection they still expect to see. */
consumeMentionContext() {
this.selectedContext = this.selectedContext.filter((c) => !isMentionContext(c))
}
getAvailableContext() {
return this.availableContext
}
@@ -575,7 +575,10 @@
function getCommandFilter(text: string): string | undefined {
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
const match = /^\/([a-z0-9-]*)$/.exec(text)
// Same character set the submit path expands, so a name the picker can insert
// does not close the picker as soon as it is typed. Paths reach here too, via
// the row inserted for an ambiguous name.
const match = /^\/([\p{L}\p{N}_\-/]*)$/u.exec(text)
return match?.[1]
}
@@ -640,8 +643,12 @@
}
}
function handleCommandSelection(skill: { name: string }) {
value = `/${skill.name} `
function handleCommandSelection(skill: { name: string; path?: string }) {
// The picker lists a row per skill, so two folders holding the same name are
// two distinct rows — but `/name` could not say which one was clicked, and
// submission refuses to guess. Those insert the path the row stands for.
const ambiguous = commandSkills.filter((c) => c.name === skill.name).length > 1
value = `/${ambiguous && skill.path ? skill.path : skill.name} `
showCommandTooltip = false
setTimeout(() => textarea?.focus(), 0)
}
@@ -736,9 +743,9 @@
textarea?.focus()
}
// Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the
// effect above sees no diff when `value` clears, leaving `selectedContext`
// untouched until `AIChatManager.beforeSend` snapshots it. A manual
// Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the effect
// above sees no diff when `value` clears, leaving `selectedContext` for the
// send that is already carrying it to settle (see the caller). A manual
// textarea clear by the user keeps the old behaviour (badges drop).
export function clearForSend() {
prevMentionedTitles = new Set()
@@ -3,7 +3,7 @@
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
import { getChatViewHost } from './chatViewHost'
import { AIMode } from './AIChatManager.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import UsageMeter from './UsageMeter.svelte'
import { formatTokenCount } from './tokenUsage'
const chatHost = getChatViewHost()
@@ -49,25 +49,11 @@
</script>
{#if visible}
<Tooltip small placement="top">
<!-- Only a meter when a model is configured: it's a 0100% reading against the
window compaction enforces (known or assumed). With no model there's no max
to measure against, so it's a plain labeled indicator (the bar is
decorative/full and the token count lives in the tooltip). -->
<div
class="flex items-center h-5"
aria-label="Context window usage"
role={fillPct !== undefined ? 'meter' : undefined}
aria-valuenow={fillPct}
aria-valuemin={fillPct !== undefined ? 0 : undefined}
aria-valuemax={fillPct !== undefined ? 100 : undefined}
>
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
></div>
</div>
</div>
{#snippet text()}
<!-- Only a meter when we know the window: it's a 0100% reading. With an unknown
window there's no max to measure against, so it's a plain labeled indicator
(the bar is decorative/full and the token count lives in the tooltip). -->
<UsageMeter {fillPct} {fillClass} ariaLabel="Context window usage">
{#snippet tooltip()}
<div class="text-xs whitespace-nowrap">
<p class="font-semibold">Context usage</p>
<p class="mt-1 tabular-nums">
@@ -85,5 +71,5 @@
{/if}
</div>
{/snippet}
</Tooltip>
</UsageMeter>
{/if}
@@ -0,0 +1,857 @@
<script lang="ts">
import { Alert, Button, Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import Path from '$lib/components/Path.svelte'
import FileInput from '$lib/components/common/fileInput/FileInput.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { markdownProse } from '$lib/components/markdownProse'
import { workspaceStore, userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import type { Item } from '$lib/utils'
import { untrack } from 'svelte'
import {
BookOpen,
ClipboardPaste,
Eye,
List,
Pencil,
Plus,
RotateCcw,
Trash2
} from 'lucide-svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { isSkillEnabled, setSkillEnabled } from './skills/enabledSkills'
import {
ambiguousSkillNames,
deleteSkillResource,
listSkillResources,
readSkillBody,
saveSkillResource,
updateSkillResource,
type SkillResource
} from './skills/skillResources'
import {
buildSkillMd,
parseAndValidateSkill,
parseSkillMd,
type SkillUpload
} from './skills/skillMd'
const aiChatManager = getAiChatManager()
// A session chat operates on its own (possibly forked) workspace without
// switching `workspaceStore`, and that is the workspace the chat reads the
// enabled set under. Key everything here the same way or a toggle lands under
// a key nothing reads.
//
// `operatingWorkspace` is a plain getter over untracked state, so the store is
// read unconditionally rather than behind `??`: short-circuiting it would leave
// this derived with no dependency at all, frozen on the workspace it first saw.
let ws = $derived.by(() => {
const active = $workspaceStore
return aiChatManager.operatingWorkspace ?? active!
})
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// resolves to the PARENT. Authoring through the picker would then edit the live
// parent, and a toggle would be stored under it and quietly stop applying the
// moment the first send commits the fork. Read at use, not once: the fork
// commits mid-session.
function pendingForkParent(): string | undefined {
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
}
let forkPending = $state(false)
function refreshForkPending() {
forkPending = pendingForkParent() !== undefined
}
/** Guards every mutating action. Returns true when the caller must not proceed. */
function blockedByPendingFork(): boolean {
const parent = pendingForkParent()
if (parent === undefined) return false
refreshForkPending()
sendUserToast(
`This session has not created its workspace yet, so a skill would be written to "${parent}" instead. Send a message first.`,
true
)
return true
}
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
// are likely vendored/incidental and are skipped so importing a parent dir
// doesn't sweep in unrelated skills.
const MAX_SKILL_DEPTH = 3
const MAX_SKILLS_PER_IMPORT = 50
/** Stands in for the name when only the description and body are being checked. */
const VALID_NAME_PLACEHOLDER = 'placeholder'
// A new skill opens on this as real, editable text rather than ghost placeholder
// text: the format is the point of the sample, and a SKILL.md is easier to adapt
// than to recall. `name` seeds the path above until the user edits the path
// themselves, so renaming here renames the skill.
const SKILL_TEMPLATE = `---
name: my-skill
description: What this skill covers, and when the assistant should reach for it
---
# My skill
What the assistant should do when this skill applies.
## Steps
1. First thing to do.
2. Second thing to do.
`
type Row = SkillResource & { enabled: boolean }
let drawer: Drawer | undefined = $state(undefined)
let skills = $state<Row[]>([])
let loading = $state(false)
let loadError = $state<string | undefined>(undefined)
let listNotice = $state<string | undefined>(undefined)
let saving = $state(false)
let toDelete: Row | undefined = $state(undefined)
let importFiles = $state<File[] | undefined>(undefined)
// Paste/edit modal. `editing` is the row being edited (undefined while
// creating), held so a path change can be applied as a move.
let editorOpen = $state(false)
let editing = $state<Row | undefined>(undefined)
let content = $state('')
let originalContent = $state('')
let path = $state('')
let pathError = $state('')
// Set by Path once the user edits the path themselves, which is what stops the
// frontmatter from overwriting their choice below.
let pathDirty = $state(false)
let detailMode: 'view' | 'edit' = $state('view')
// Staged folder import, confirmed before anything is written.
let pendingImport: SkillUpload[] | undefined = $state(undefined)
let pendingSkipped: string[] = $state([])
let overwriteChoices: Record<string, boolean> = $state({})
let ambiguous = $derived(ambiguousSkillNames(skills))
let parsed = $derived(parseSkillMd(content))
// The Path field is what names the skill, and Path validates it. The frontmatter
// `name` only seeds that field and is never persisted, so validating it here
// would block saving a skill whose path is legal but whose name is not — resource
// paths admit `_` and uppercase, SKILL.md names do not. The folder importer keeps
// validating, because there the name really does become the path segment.
let validated = $derived(parseAndValidateSkill(content, VALID_NAME_PLACEHOLDER))
let contentError = $derived('error' in validated ? validated.error : undefined)
// Measured against what the modal opened with, which for a new skill is the
// sample. Saving it untouched would store a skill called "my-skill", so an
// unedited body is not something to save; Reset restores exactly this baseline.
let contentChanged = $derived(content !== originalContent)
let pathChanged = $derived(!!editing && path !== editing.path)
let canSave = $derived(
!saving && !contentError && !!path && !pathError && (contentChanged || pathChanged)
)
// Keyed by the path the import would write to, not by bare name: a `deploy` that
// exists only in someone else's folder is not something this import overwrites,
// and offering it as a conflict would ask the user about a collision that isn't.
let existingPaths = $derived(new Set(skills.map((s) => s.path)))
let importTargets = $derived(
(pendingImport ?? ([] as SkillUpload[])).map((s) => ({
skill: s,
path: `${defaultOwner()}/${s.name}`
}))
)
let pendingConflicts = $derived(
importTargets.filter((t) => existingPaths.has(t.path)).map((t) => t.skill)
)
let pendingNew = $derived(
importTargets.filter((t) => !existingPaths.has(t.path)).map((t) => t.skill)
)
// Rows describe one workspace. A switch while the drawer is open must not leave
// A's rows on screen while the actions below target B: same path, different
// skill, and delete would remove the wrong one. Dropping them is all this does:
// this component mounts with the chat toolbar, so loading here would list
// resources for every user who never opens the menu. The two entry points load
// what they need.
let loadSeq = 0
$effect(() => {
const target = ws
untrack(() => {
loadSeq++
skills = []
listNotice = undefined
toDelete = undefined
pendingImport = undefined
// The editor holds one workspace's skill but saves to whatever `ws` is by
// then, so a switch mid-edit would write A's body into B at the same path.
// Closing it is the honest outcome: there is no version of that save the
// user asked for.
editorOpen = false
editing = undefined
// A drawer already on screen is neither entry point, and would sit there
// reporting that the new workspace has no skills.
if (drawer?.isOpen()) void loadSkills(target)
})
})
async function loadSkills(target = ws) {
// Checked before the sequence is taken, not only after the await: a stale
// action calling refresh(A) would otherwise claim the newest sequence and
// make the legitimate load for B discard its own result, leaving the drawer
// blank for the workspace actually on screen.
if (!target || target !== ws) return
refreshForkPending()
listNotice = undefined
const seq = ++loadSeq
loading = true
loadError = undefined
try {
const { skills: found, truncated } = await listSkillResources(target, $userStore ?? undefined)
// Newest-request-wins is not enough on its own: an action started in A and
// finishing after a switch to B holds the newest sequence, and would put
// A's rows on screen under B — where the next row action would edit or
// delete that path in B. The workspace has to still be the one asked for.
if (seq !== loadSeq || target !== ws) return
skills = found.map((s) => ({ ...s, enabled: isSkillEnabled(target, s.path) }))
// A notice, not `loadError`: that one replaces the list, and a truncated
// read still has skills worth showing.
listNotice = truncated
? `Showing the first ${found.length} skills; this workspace has more. Delete unused ones so the rest can be selected.`
: undefined
} catch (e) {
if (seq !== loadSeq || target !== ws) return
// Without this the drawer would render the empty state, which reads as
// "this workspace has no skills" rather than "we could not load them".
loadError = e.body ?? e.message
} finally {
if (seq === loadSeq) loading = false
}
}
export async function open() {
drawer?.openDrawer()
await loadSkills()
}
// A menu is a shortcut, not a directory: past this many the list stops being
// scannable, so the rest are reached through the drawer rather than dropped.
const MAX_MENU_SKILLS = 8
/** Rows for the chat's "+" menu: one per skill, checked when it is on, then
* the way to manage them. Loaded on open so the checks are current. */
export async function menuItems(closeMenu?: () => void): Promise<Item[]> {
// The menu opens on what is already known and refreshes behind it: waiting on
// a round trip would stall the whole `+` menu, attachments included.
if (skills.length === 0) {
await loadSkills()
} else {
void loadSkills()
}
// Enabled first: those are the ones a quick visit is most likely about.
const ordered = [...skills].sort(
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
)
const shown = ordered.slice(0, MAX_MENU_SKILLS)
return [
...shown.map(({ path: p, name }) => ({
// Ambiguous names are shown by path — two rows reading `deploy` would
// leave the choice between them to chance.
displayName: ambiguous.has(name) ? p : name,
icon: BookOpen,
// Getters, not snapshots: the menu stays open across a click, and it has
// to read through the live list rather than the row captured here, since
// a reload replaces every row object and a getter bound to the old one
// would go on reporting the state it was built with.
get toggle() {
return row(p)?.enabled ?? false
},
action: () => toggle(p, !row(p)?.enabled)
})),
...(ordered.length > shown.length
? [
{
displayName: `Show all ${ordered.length}`,
icon: List,
action: () => {
closeMenu?.()
void open()
}
}
]
: []),
{
displayName: skills.length > 0 ? 'Manage skills' : 'Add a skill',
icon: Plus,
separatorTop: skills.length > 0,
action: () => {
closeMenu?.()
void open()
}
}
]
}
function row(p: string) {
return skills.find((s) => s.path === p)
}
async function toggle(p: string, enabled: boolean) {
if (blockedByPendingFork()) return
// Pinned for the whole call, like the other actions: the selection is stored
// per workspace, and the refresh below must not hand these skills to a chat
// that has since moved on.
const target = ws
if (!setSkillEnabled(target, p, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
const skill = skills.find((s) => s.path === p)
if (skill) skill.enabled = enabled
// Whether people select skills at all. Never the skill itself: a path is
// workspace-authored text.
logFeatureUsage('ai_session', 'skill_toggle', {
key: enabled ? 'on' : 'off',
workspace: target
})
// The prompt lists exactly the enabled skills, so it has to be rebuilt
// before the next message rather than on the next mode change. A workspace
// switch during that rebuild is discarded inside refreshGlobalSkills.
await aiChatManager.refreshGlobalSkills(target)
}
/** Personal folder the folder import writes into. The username is not always a
* legal path segment — a superadmin who is not a member of the workspace gets
* their email back from `whoami` — and `resource.path` is CHECK-constrained, so
* it is narrowed the same way Path.svelte narrows it. */
function defaultOwner() {
const username = $userStore?.username ?? 'user'
const narrowed = username.includes('@')
? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: username
// Narrowing can empty the string outright (an all-punctuation local part),
// and `u//name` fails the path CHECK with nothing to explain it.
return `u/${narrowed || 'user'}`
}
function openCreate() {
if (blockedByPendingFork()) return
editing = undefined
content = SKILL_TEMPLATE
originalContent = SKILL_TEMPLATE
path = ''
pathDirty = false
detailMode = 'edit'
editorOpen = true
}
/** Back to what the modal opened with: the sample for a new skill, the saved
* body for one being edited. */
function resetContent() {
content = originalContent
}
async function openSkill(skill: Row, mode: 'view' | 'edit') {
// Pinned across the await: the workspace can change while the body loads, and
// closing the editor then would not stop this from reopening it with the old
// workspace's content — which submitEditor would save into the new one.
const source = ws
try {
const instructions = await readSkillBody(source, skill.path)
if (source !== ws) return
content = buildSkillMd({
name: skill.name,
description: skill.description,
instructions
})
originalContent = content
editing = skill
path = skill.path
pathDirty = false
detailMode = mode
editorOpen = true
} catch (e) {
sendUserToast(`Failed to load skill: ${e.body ?? e.message}`, true)
}
}
// Path opens on a generated name, so the frontmatter `name` has to replace that
// last segment rather than fill a blank. Its owner is left alone (Path picks up
// where the user last created something), and the whole thing stops as soon as
// they edit the path themselves.
//
// `path` is a dependency, not just a value read: Path settles it asynchronously,
// after the template's name is already parsed, so an effect watching only the
// content would run once against an empty path and never again. Writing back the
// value it already holds is what would loop, hence the equality guard.
$effect(() => {
const suggested = parsed.name
const current = path
untrack(() => {
if (!editorOpen || editing || pathDirty || !suggested) return
const owner = current.split('/').slice(0, -1).join('/')
if (!owner) return
const next = `${owner}/${suggested}`
if (next !== current) path = next
})
})
async function submitEditor() {
if (!('skill' in validated) || !path || pathError) return
if (blockedByPendingFork()) return
const target = ws
saving = true
try {
if (editing) {
await updateSkillResource(
target,
editing.path,
path,
validated.skill.description,
validated.skill.instructions
)
// A move leaves the old path selected but gone; carry the choice over
// so an edit that renames does not silently switch the skill off.
if (editing.path !== path && isSkillEnabled(target, editing.path)) {
setSkillEnabled(target, editing.path, false)
setSkillEnabled(target, path, true)
}
} else {
await saveSkillResource(
target,
path,
validated.skill.description,
validated.skill.instructions
)
// Authoring a skill is the act of choosing it.
setSkillEnabled(target, path, true)
}
editorOpen = false
await refresh(target)
sendUserToast(editing ? `Saved ${path}` : `Added ${path}`)
} catch (e) {
sendUserToast(`Failed to save skill: ${e.body ?? e.message}`, true)
} finally {
saving = false
}
}
async function remove(skill: Row) {
if (blockedByPendingFork()) return
const target = ws
try {
await deleteSkillResource(target, skill.path)
// A later skill at this path is a different one; it must be turned on
// deliberately rather than inherit this one's selection.
setSkillEnabled(target, skill.path, false)
sendUserToast(`Deleted ${skill.path}`)
await refresh(target)
} catch (e) {
sendUserToast(`Failed to delete skill: ${e.body ?? e.message}`, true)
}
}
async function refresh(target = ws) {
await loadSkills(target)
// Same reason: refreshing the chat with a workspace it has since left would
// advertise A's skills to a session now acting on B.
if (target !== ws) return
await aiChatManager.refreshGlobalSkills(target)
}
/** Where the file sat in the chosen folder. Clicking through sets
* `webkitRelativePath`; dropping sets `path`, which the picker's tree walk fills. */
function relativePathOf(f: File & { path?: string }): string {
return f.webkitRelativePath || f.path || f.name
}
/**
* Filter a folder's files down to in-depth SKILL.md, read them, and stage the
* result for confirmation.
*/
async function processFolderFiles(files: File[]) {
if (blockedByPendingFork()) return
// Pick SKILL.md files within the depth limit BEFORE reading any content,
// so a huge tree never gets read in full.
const skipped: string[] = []
const eligible: File[] = []
for (const f of files) {
const filePath = relativePathOf(f)
const segments = filePath.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
if (segments.length > MAX_SKILL_DEPTH) {
skipped.push(`${filePath} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
continue
}
eligible.push(f)
}
if (eligible.length === 0) {
sendUserToast(
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
}`,
true
)
return
}
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
true
)
return
}
const collected: SkillUpload[] = []
const parseSkipped: string[] = []
for (const f of eligible) {
const filePath = relativePathOf(f)
const segments = filePath.split('/')
// The skill's id is the folder holding its SKILL.md, which is what
// becomes the resource path's name segment.
const folderName = segments.length >= 2 ? segments[segments.length - 2] : ''
const result = parseAndValidateSkill(await f.text(), folderName)
if ('error' in result) {
parseSkipped.push(`${folderName || filePath} (${result.error})`)
continue
}
// Two folders under different parents can share a leaf name, and both
// would target one path — the second silently replacing the first.
if (collected.some((s) => s.name === result.skill.name)) {
parseSkipped.push(
`${filePath} (another skill in this folder is already named ${result.skill.name})`
)
continue
}
collected.push(result.skill)
}
const allSkipped = [...skipped, ...parseSkipped]
if (collected.length === 0) {
sendUserToast(
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
true
)
return
}
// Confirm before writing — the import can pull in several skills at once,
// and any that collide with existing skills default to overwrite.
pendingSkipped = allSkipped
const owner = defaultOwner()
overwriteChoices = Object.fromEntries(
collected.filter((s) => existingPaths.has(`${owner}/${s.name}`)).map((s) => [s.name, true])
)
pendingImport = collected
}
async function onDirSelected(event: CustomEvent<File[] | undefined>) {
const files = event.detail ?? []
// Clearing lets the same folder be chosen again — the component keeps the
// last selection on screen otherwise, and re-picking would look inert.
importFiles = undefined
if (files.length) await processFolderFiles(files)
}
/** `overwrite` is granted only for destinations the confirmation listed as an
* existing skill. Everything else is created, never upserted: a path the user
* was told was free may hold an unrelated resource — a credential, say — and an
* upsert would replace its value and type with a skill. Losing the import of a
* name is recoverable; losing what was there is not. */
async function importSkills(
toImport: { skill: SkillUpload; overwrite: boolean }[],
skipped: string[]
) {
const target = ws
const owner = defaultOwner()
saving = true
let written = 0
const failed: string[] = []
try {
for (const { skill, overwrite } of toImport) {
const dest = `${owner}/${skill.name}`
try {
await saveSkillResource(target, dest, skill.description, skill.instructions, {
overwrite
})
setSkillEnabled(target, dest, true)
written++
} catch (e) {
failed.push(`${skill.name} (${e.body ?? e.message})`)
}
}
let message = `Added ${written} skill(s) under ${owner}`
if (skipped.length) message += `; skipped ${skipped.length}`
if (failed.length) message += `; failed: ${failed.join(', ')}`
sendUserToast(message, failed.length > 0)
await refresh(target)
} finally {
saving = false
}
}
</script>
<Drawer bind:this={drawer} size="700px">
<DrawerContent
title="Skills"
on:close={() => drawer?.closeDrawer()}
tooltip="Reusable instruction sets for this chat, stored as ai_skill resources. Turning one on is personal to you and to this workspace — the assistant only sees the ones you selected."
>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: ClipboardPaste }}
disabled={saving || forkPending}
onclick={openCreate}
>
New skill
</Button>
{/snippet}
{#if listNotice}
<Alert type="warning" title="Not all skills are shown" size="xs" class="mb-4">
{listNotice}
</Alert>
{/if}
{#if forkPending}
<Alert type="info" title="This session has no workspace yet" size="xs" class="mb-4">
Skills are read-only until the first message creates this session's fork. Editing or
selecting one now would apply to the parent workspace and stop applying once the fork is
created.
</Alert>
{/if}
<FileInput
disabled={forkPending}
folderOnly
bind:files={importFiles}
on:change={onDirSelected}
class="mb-4 !py-5"
iconSize={20}
>
<span class="text-xs text-secondary">
Drop a folder of <span class="font-mono">SKILL.md</span> files to import, or click to choose
one
</span>
</FileInput>
{#if loading}
<div class="text-xs text-secondary p-4 text-center">Loading skills…</div>
{:else if loadError}
<div class="text-xs text-red-600 dark:text-red-400">
Failed to load skills: {loadError}
</div>
{:else if skills.length === 0}
<div class="rounded-md border border-dashed px-3 py-6 text-center text-xs text-secondary">
No skills in this workspace yet. Paste a SKILL.md or import a folder of them.
</div>
{:else}
<div class="flex flex-col divide-y border rounded-md bg-surface-tertiary">
{#each skills as skill (skill.path)}
<div class="flex items-center gap-3 px-4 py-3">
<BookOpen size={16} class="shrink-0 text-tertiary" />
<div class="min-w-0 grow">
<div class="text-xs font-semibold text-emphasis truncate">
{ambiguous.has(skill.name) ? skill.path : skill.name}
</div>
{#if skill.description}
<div class="text-xs text-secondary truncate">{skill.description}</div>
{/if}
</div>
<Toggle
size="xs"
disabled={forkPending}
checked={skill.enabled}
on:change={async (e) => await toggle(skill.path, e.detail)}
/>
<DropdownV2
size="sm"
items={[
{
displayName: skill.canWrite ? 'Edit' : 'View',
icon: skill.canWrite ? Pencil : Eye,
action: () => openSkill(skill, skill.canWrite ? 'edit' : 'view')
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: !skill.canWrite || forkPending,
action: () => (toDelete = skill)
}
]}
/>
</div>
{/each}
</div>
{/if}
<ConfirmationModal
open={toDelete !== undefined}
title="Delete skill"
confirmationText="Delete"
onConfirmed={async () => {
const skill = toDelete
toDelete = undefined
if (skill) await remove(skill)
}}
onCanceled={() => (toDelete = undefined)}
>
<span class="text-xs text-primary">
This deletes the resource at <span class="font-semibold">{toDelete?.path}</span>, so
everyone who selected it loses the skill.
</span>
</ConfirmationModal>
<ConfirmationModal
open={pendingImport !== undefined}
title="Import skills"
type="info"
confirmationText="Import"
onConfirmed={async () => {
const toImport = [
...pendingNew.map((skill) => ({ skill, overwrite: false })),
...pendingConflicts
.filter((s) => overwriteChoices[s.name])
.map((skill) => ({ skill, overwrite: true }))
]
const skipped = pendingSkipped
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
if (toImport.length) await importSkills(toImport, skipped)
else sendUserToast('No skills imported.')
}}
onCanceled={() => {
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
}}
>
<div class="flex flex-col gap-3 text-xs">
<span class="text-secondary">
Skills are added under <span class="font-mono">{defaultOwner()}</span>. Move one to a
shared folder from the resources page to share it.
</span>
{#if pendingNew.length}
<div>
<span class="font-medium text-primary">Add {pendingNew.length} new skill(s):</span>
<span class="font-mono text-secondary">{pendingNew.map((s) => s.name).join(', ')}</span>
</div>
{/if}
{#if pendingConflicts.length}
<div class="flex flex-col gap-1.5">
<span class="font-medium text-primary">
{pendingConflicts.length} skill(s) already exist — choose which to overwrite:
</span>
<div class="rounded-md border divide-y">
{#each pendingConflicts as conflict (conflict.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="font-mono truncate">{conflict.name}</span>
<Toggle
bind:checked={overwriteChoices[conflict.name]}
size="xs"
options={{ right: 'Overwrite' }}
/>
</div>
{/each}
</div>
</div>
{/if}
{#if pendingSkipped.length}
<span class="text-secondary">{pendingSkipped.length} file(s) will be skipped.</span>
{/if}
</div>
</ConfirmationModal>
</DrawerContent>
</Drawer>
<Modal2
title={editing ? editing.name : 'New skill'}
bind:isOpen={editorOpen}
fixedWidth="md"
fixedHeight="adaptive"
>
{#snippet headerRight()}
{#if editing}
<ToggleButtonGroup bind:selected={detailMode}>
{#snippet children({ item })}
<ToggleButton value="view" label="View" icon={Eye} {item} small />
<ToggleButton
value="edit"
label="Edit"
icon={Pencil}
{item}
small
disabled={!editing?.canWrite}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
<div class="w-full flex flex-col gap-3">
{#if detailMode === 'view'}
{#if parsed.description}
<p class="text-xs text-secondary">{parsed.description}</p>
{/if}
<div class="border rounded-md p-3 overflow-auto max-h-[60vh] space-y-2 {markdownProse.sm}">
<Markdown md={parsed.instructions} plugins={[gfmPlugin()]} />
</div>
{:else}
<Path
bind:path
bind:error={pathError}
bind:dirty={pathDirty}
initialPath={editing?.path ?? ''}
namePlaceholder="skill"
kind="resource"
workspaceOverride={ws}
autofocus={false}
/>
<!-- The same editor `/resources` gives these resources, so a skill reads the
same in both places. `fixedOverflowWidgets` off keeps its autocomplete
inside the modal instead of clipped behind it. -->
<div class="border border-border-light rounded-md overflow-hidden">
<SimpleEditor
autoHeight
lang="md"
bind:code={content}
fixedOverflowWidgets={false}
class="min-h-24"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-2xs text-red-500 min-w-0">{contentError ?? ''}</span>
<div class="flex items-center gap-2">
<Button
onclick={resetContent}
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
disabled={saving || !contentChanged}
title={editing ? 'Discard unsaved changes to this skill' : 'Restore the sample skill'}
>
Reset
</Button>
<Button
onclick={submitEditor}
variant="accent"
unifiedSize="sm"
startIcon={{ icon: editing ? Pencil : Plus }}
disabled={!canSave}
>
{editing ? 'Save skill' : 'Add skill'}
</Button>
</div>
</div>
{/if}
</div>
</Modal2>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
// Presentational gauge used by the context-usage indicator: a thin bar in a tooltip.
// The owner computes the fill and supplies the tooltip content.
let {
fillPct,
fillClass,
ariaLabel,
tooltip
}: {
// 0100. Undefined means "no known max": the bar is decorative (full) and carries
// no meter role.
fillPct?: number
fillClass: string
ariaLabel: string
tooltip: Snippet
} = $props()
</script>
<Tooltip small placement="top">
<div
class="flex items-center h-5"
aria-label={ariaLabel}
role={fillPct !== undefined ? 'meter' : undefined}
aria-valuenow={fillPct}
aria-valuemin={fillPct !== undefined ? 0 : undefined}
aria-valuemax={fillPct !== undefined ? 100 : undefined}
>
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
></div>
</div>
</div>
{#snippet text()}
{@render tooltip()}
{/snippet}
</Tooltip>
@@ -20,6 +20,14 @@ export type ChatSendRequestOptions = {
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
/** Selected-context snapshot for this turn, in place of the live selection. Set
* whenever a send settles its context ahead of the turn. A host with no context
* of its own ignores it. */
contextOverride?: ContextElement[]
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
* they are consumed from the live selection on send. 'replay': an edit or retry
* resending an older message's context, already consumed long ago. */
contextOverrideOrigin?: 'pinned' | 'replay'
}
/**
@@ -331,6 +331,13 @@ export function contextElementKey(c: ContextElement): string {
return c.type === 'app_dom_selector' ? `dom:${c.appPath}:${c.selector}` : `${c.type}:${c.title}`
}
/** An `@`-mentioned workspace item, attached to one message the way a DOM pick
* is. Membership only: this does not gate on mode, so every caller must. Outside
* GLOBAL these stay selected as chips the user removes by hand. */
export function isMentionContext(c: ContextElement): boolean {
return c.type === 'workspace_script' || c.type === 'workspace_flow' || c.type === 'workspace_app'
}
export function isSameContextElement(a: ContextElement, b: ContextElement): boolean {
if (a.type !== b.type) return false
if (a.type === 'app_dom_selector' && b.type === 'app_dom_selector') {
@@ -0,0 +1,74 @@
import { get } from 'svelte/store'
import { userStore } from '$lib/stores'
/**
* A set of workspace-object paths the chat may act through, remembered per
* workspace and per account.
*
* Being able to read a resource is not the same as wanting the chat to use it: a
* resource in a shared folder is readable by a whole team, and each enabled entry
* costs something on every turn an MCP server puts its tool descriptions in the
* model's context and reaches an external system, a skill puts its description
* there. So an entry is off until it is turned on.
*
* Stored per browser, like the chat's other per-user preferences, but keyed by
* email as well as workspace: browser storage outlives a logout, and inheriting
* the previous account's selection would hand the next person capabilities they
* never turned on. Workspace ids cannot contain `:`, so the composite key is
* unambiguous.
*/
export type EnabledPathsPreference = {
enabledPaths: (workspace: string) => string[]
isEnabled: (workspace: string, path: string) => boolean
/** Returns false when there is no account to record the preference against, so
* a caller that just created the object can say it did not stay on. */
setEnabled: (workspace: string, path: string, enabled: boolean) => boolean
}
export function createEnabledPathsPreference(storageKey: string): EnabledPathsPreference {
function scope(workspace: string): string | undefined {
const email = get(userStore)?.email
return email ? `${workspace}:${email}` : undefined
}
function read(): Record<string, string[]> {
if (typeof localStorage === 'undefined') return {}
try {
return JSON.parse(localStorage.getItem(storageKey) ?? '{}')
} catch {
return {}
}
}
function write(all: Record<string, string[]>) {
try {
localStorage.setItem(storageKey, JSON.stringify(all))
} catch (e) {
console.error(`Failed to persist ${storageKey}`, e)
}
}
function enabledPaths(workspace: string): string[] {
const key = scope(workspace)
return key ? (read()[key] ?? []) : []
}
return {
enabledPaths,
isEnabled: (workspace, path) => enabledPaths(workspace).includes(path),
setEnabled: (workspace, path, enabled) => {
const key = scope(workspace)
if (!key) return false
const all = read()
const current = new Set(all[key] ?? [])
if (enabled) {
current.add(path)
} else {
current.delete(path)
}
all[key] = [...current]
write(all)
return true
}
}
}
@@ -210,7 +210,8 @@ vi.mock('$lib/gen', async () => {
}),
createResource: vi.fn(async () => 'created'),
updateResource: vi.fn(async () => 'updated'),
deleteResource: vi.fn(async () => 'deleted')
deleteResource: vi.fn(async () => 'deleted'),
getResourceValue: vi.fn(async () => ({ content: 'skill body' }))
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false),
@@ -5435,6 +5436,18 @@ describe('session-only preview tools gating', () => {
})
})
describe('read_skill', () => {
it('refuses a path the user has not selected, without reading it', async () => {
localStorage.clear()
userStore.set({ username: 'bob', email: 'bob@windmill.dev', workspace_id: WORKSPACE } as any)
const res = await callGlobalTool('read_skill', { path: 'u/someone/private-notes' })
expect(res).toContain('not one of the skills selected')
expect(vi.mocked(ResourceService.getResourceValue)).not.toHaveBeenCalled()
})
})
describe('update_user_instructions', () => {
function makeHelpers(initial = '') {
let value = initial

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