diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index e2e7318ab6..f9153c0ce0 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -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 diff --git a/.github/workflows/build_windows_worker_.yml b/.github/workflows/build_windows_worker_.yml index 84f4366235..46befe42b6 100644 --- a/.github/workflows/build_windows_worker_.yml +++ b/.github/workflows/build_windows_worker_.yml @@ -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 diff --git a/.github/workflows/publish_windows_worker.yml b/.github/workflows/publish_windows_worker.yml index d159619fba..b94d5d57c7 100644 --- a/.github/workflows/publish_windows_worker.yml +++ b/.github/workflows/publish_windows_worker.yml @@ -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 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c46e4845e3..ab80d05f6b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.799.0" + ".": "1.800.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9bd16eac..2cc8ecb481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/.sqlx/query-002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7.json b/backend/.sqlx/query-002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7.json deleted file mode 100644 index abae0ba456..0000000000 --- a/backend/.sqlx/query-002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json b/backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json new file mode 100644 index 0000000000..4ae74ff53c --- /dev/null +++ b/backend/.sqlx/query-247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5.json @@ -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" +} diff --git a/backend/.sqlx/query-44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996.json b/backend/.sqlx/query-44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996.json new file mode 100644 index 0000000000..2253944269 --- /dev/null +++ b/backend/.sqlx/query-44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996.json @@ -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" +} diff --git a/backend/.sqlx/query-4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb.json b/backend/.sqlx/query-4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb.json deleted file mode 100644 index 63283a830a..0000000000 --- a/backend/.sqlx/query-4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8.json b/backend/.sqlx/query-734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8.json deleted file mode 100644 index d55722ed24..0000000000 --- a/backend/.sqlx/query-734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-872be44352d4a27a2005f6bdc38f927ac4886755fd17ec755037763aa92f6c0e.json b/backend/.sqlx/query-872be44352d4a27a2005f6bdc38f927ac4886755fd17ec755037763aa92f6c0e.json new file mode 100644 index 0000000000..98e08b8167 --- /dev/null +++ b/backend/.sqlx/query-872be44352d4a27a2005f6bdc38f927ac4886755fd17ec755037763aa92f6c0e.json @@ -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" +} diff --git a/backend/.sqlx/query-87e8e034b1cf5ea7ce43870d77d33fa0fd05b067454f0d4686d8b155fe562ffe.json b/backend/.sqlx/query-87e8e034b1cf5ea7ce43870d77d33fa0fd05b067454f0d4686d8b155fe562ffe.json new file mode 100644 index 0000000000..76456ea8fb --- /dev/null +++ b/backend/.sqlx/query-87e8e034b1cf5ea7ce43870d77d33fa0fd05b067454f0d4686d8b155fe562ffe.json @@ -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" +} diff --git a/backend/.sqlx/query-1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae.json b/backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json similarity index 63% rename from backend/.sqlx/query-1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae.json rename to backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json index d453eb430d..3b9a2e2f34 100644 --- a/backend/.sqlx/query-1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae.json +++ b/backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json @@ -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" } diff --git a/backend/.sqlx/query-8e0461855d05dc03919c8979d8acdc85389c0629847d8fabb2ad0aa043957b2f.json b/backend/.sqlx/query-8e0461855d05dc03919c8979d8acdc85389c0629847d8fabb2ad0aa043957b2f.json deleted file mode 100644 index 96745f9485..0000000000 --- a/backend/.sqlx/query-8e0461855d05dc03919c8979d8acdc85389c0629847d8fabb2ad0aa043957b2f.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-947f7ca06f6f9a3fd50f817bc9b0c06924719189bec8e5c19866db8d0b87df5e.json b/backend/.sqlx/query-947f7ca06f6f9a3fd50f817bc9b0c06924719189bec8e5c19866db8d0b87df5e.json deleted file mode 100644 index 6739d80033..0000000000 --- a/backend/.sqlx/query-947f7ca06f6f9a3fd50f817bc9b0c06924719189bec8e5c19866db8d0b87df5e.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json b/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json new file mode 100644 index 0000000000..3624a32893 --- /dev/null +++ b/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json @@ -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" +} diff --git a/backend/.sqlx/query-acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf.json b/backend/.sqlx/query-acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf.json new file mode 100644 index 0000000000..a741ee1c0d --- /dev/null +++ b/backend/.sqlx/query-acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf.json @@ -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" +} diff --git a/backend/.sqlx/query-c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956.json b/backend/.sqlx/query-c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956.json new file mode 100644 index 0000000000..7078c32f1a --- /dev/null +++ b/backend/.sqlx/query-c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956.json @@ -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" +} diff --git a/backend/.sqlx/query-c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b.json b/backend/.sqlx/query-c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b.json deleted file mode 100644 index ca39442f59..0000000000 --- a/backend/.sqlx/query-c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a.json b/backend/.sqlx/query-df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a.json new file mode 100644 index 0000000000..6ffd197e90 --- /dev/null +++ b/backend/.sqlx/query-df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a.json @@ -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" +} diff --git a/backend/.sqlx/query-e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d.json b/backend/.sqlx/query-e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d.json deleted file mode 100644 index 121d7fe04a..0000000000 --- a/backend/.sqlx/query-e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61.json b/backend/.sqlx/query-e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61.json deleted file mode 100644 index 29ce0c6f2c..0000000000 --- a/backend/.sqlx/query-e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json b/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json new file mode 100644 index 0000000000..32f7e8cf1f --- /dev/null +++ b/backend/.sqlx/query-f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f.json @@ -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" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9bfefb16d9..c79abcb78e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b00dc5c081..491f3cb48a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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 "] 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"] diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 693921efc6..0f92d02c72 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -bc0c7051585194474078b6c1941a3fb73893d9e5 +6efe7a73c745c2e1377a34498523c00d89010a3d diff --git a/backend/migrations/20260622072905_ai_free_token_usage.down.sql b/backend/migrations/20260622072905_ai_free_token_usage.down.sql new file mode 100644 index 0000000000..95879034e4 --- /dev/null +++ b/backend/migrations/20260622072905_ai_free_token_usage.down.sql @@ -0,0 +1,2 @@ +DROP TABLE ai_free_token_daily_usage; +DROP TABLE ai_free_token_usage; diff --git a/backend/migrations/20260622072905_ai_free_token_usage.up.sql b/backend/migrations/20260622072905_ai_free_token_usage.up.sql new file mode 100644 index 0000000000..ae0cf47e45 --- /dev/null +++ b/backend/migrations/20260622072905_ai_free_token_usage.up.sql @@ -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() +); diff --git a/backend/migrations/20260831120907_skills_resource_type.down.sql b/backend/migrations/20260831120907_skills_resource_type.down.sql new file mode 100644 index 0000000000..5cc32da37d --- /dev/null +++ b/backend/migrations/20260831120907_skills_resource_type.down.sql @@ -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'; diff --git a/backend/migrations/20260831120907_skills_resource_type.up.sql b/backend/migrations/20260831120907_skills_resource_type.up.sql new file mode 100644 index 0000000000..4e8f9cfa95 --- /dev/null +++ b/backend/migrations/20260831120907_skills_resource_type.up.sql @@ -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; diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 47d807dd0d..1b09928903 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -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", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index c4a544e545..752d80a1e3 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.799.0" +version = "1.800.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index fa88cf6cdc..5ec78b0b06 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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, pub app: String, pub description: Option, + /// Absent from hubs predating the column, and from caches written before it. + #[serde(default)] + pub format_extension: Option, } + /// 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, pub app: String, pub description: Option, + /// 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>, } 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) -> anyh tracing::info!("Found {} cached resource types", cached_types.len()); - // Get existing resource types in admins workspace - let existing_types: Vec<(String, Option, Option)> = 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, + Option, + Option, + 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) -> anyh let existing_map: std::collections::HashMap< String, - (Option, Option), + (Option, Option, Option, 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) -> 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))?; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 607ef99e0c..dfd0305e57 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -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) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index d99d955de9..05de1183cd 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -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] diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index cead2f4b27..2cda80f458 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -2004,6 +2004,12 @@ struct CachedResourceType { #[allow(dead_code)] app: String, description: Option, + /// 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>, } #[derive(serde::Deserialize)] @@ -2013,6 +2019,8 @@ struct HubResourceTypeRaw { schema: Option, app: String, description: Option, + #[serde(default)] + format_extension: Option, } async fn fetch_resource_types_from_hub() -> error::Result> { @@ -2054,6 +2062,7 @@ async fn fetch_resource_types_from_hub() -> error::Result = 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?; diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 92eeaeaedb..3cba2b7da6 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -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", diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 235c80ccf9..d5a14a717d 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 09a3cf4195..3daa0b6133 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 1a5f9dba87..c684830b27 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -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>, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens_per_model: Option>, + /// 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, /// 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 = 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::(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::(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::(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::(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::(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>>>( "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::(resource.0.get()) - .map_err(|e| Error::BadRequest(e.to_string()))?; + let resource = serde_json::from_str::(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), diff --git a/backend/windmill-api/src/ai_free_tier_oss.rs b/backend/windmill-api/src/ai_free_tier_oss.rs new file mode 100644 index 0000000000..7a183ff8ed --- /dev/null +++ b/backend/windmill-api/src/ai_free_tier_oss.rs @@ -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> { + Ok(None) +} + +#[cfg(not(feature = "private"))] +pub fn enforce_free_tier_body(body: &Bytes) -> Result { + Ok(body.clone()) +} + +#[cfg(not(feature = "private"))] +pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result> { + 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( + upstream: S, + _db: DB, + _lease: FreeTierLease, +) -> impl futures::Stream> +where + S: futures::Stream> + Unpin, +{ + upstream +} diff --git a/backend/windmill-api/src/ai_skills.rs b/backend/windmill-api/src/ai_skills.rs deleted file mode 100644 index 11d99d55da..0000000000 --- a/backend/windmill-api/src/ai_skills.rs +++ /dev/null @@ -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, -} - -#[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> { - 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, - Path(w_id): Path, -) -> JsonResult> { - 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, - Path((w_id, name)): Path<(String, String)>, -) -> JsonResult { - 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, - Path(w_id): Path, - Json(payload): Json, -) -> Result { - 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, - Path((w_id, name)): Path<(String, String)>, -) -> Result { - 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(_)) - )); - } -} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index de49949043..b6337351a4 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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", diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index 2268a63894..a1a9206196 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -99,7 +99,6 @@ fn build_standard_scope_domains() -> Vec { ("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", diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 01eb32b7f6..76378e335a 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -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::(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::(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, Path(w_id): Path, ) -> JsonResult { @@ -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::(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::(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, ) -> JsonResult { - 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 { - 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 } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index b107d34be4..f9bfe2f4df 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -130,6 +130,12 @@ pub struct EditResourceType { pub schema: Option, pub description: Option, pub is_fileset: Option, + /// 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>, } #[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, diff --git a/backend/windmill-types/src/more_serde.rs b/backend/windmill-types/src/more_serde.rs index d4b648d2d5..6eb24b60f4 100644 --- a/backend/windmill-types/src/more_serde.rs +++ b/backend/windmill-types/src/more_serde.rs @@ -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>, +/// ``` +pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(Some) +} diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index b4f7ce465c..9085d6a365 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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 { return await windmill.UserService.login({ diff --git a/cli/src/commands/hub/hub.ts b/cli/src/commands/hub/hub.ts index 1a1f144de1..f752147811 100644 --- a/cli/src/commands/hub/hub.ts +++ b/cli/src/commands/hub/hub.ts @@ -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)"); diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index 96c8429eb4..fd4b72108e 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -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( diff --git a/cli/src/commands/shared_ui.ts b/cli/src/commands/shared_ui.ts index 3c01870fd1..f8e2965994 100644 --- a/cli/src/commands/shared_ui.ts +++ b/cli/src/commands/shared_ui.ts @@ -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 | 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 /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 { - const localDir = path.join(process.cwd(), SHARED_UI_DIR); - if (!fs.existsSync(localDir)) { - return []; - } - const files = await readDirRecursive(localDir); - - let remote: Record = {}; - 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, + remote: Record, + 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 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 /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 { + 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 /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 { +export async function pushSharedUi( + workspace: string, + keepDeleted?: boolean, +): Promise { 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 { /** * Pull the workspace's shared UI store into /ui/. - * Files removed remotely are also removed locally. + * Files removed remotely are also removed locally, unless `keepDeleted`. */ -export async function pullSharedUi(workspace: string): Promise { +export async function pullSharedUi( + workspace: string, + keepDeleted?: boolean, +): Promise { const localDir = path.join(process.cwd(), SHARED_UI_DIR); let got; try { @@ -145,15 +207,17 @@ export async function pullSharedUi(workspace: string): Promise { } // 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 + } } } } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index b1fca78ed2..6aaa72da0d 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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( diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index c6aaf0b173..52b000d4c2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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"; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index ebd6360875..62027ab2ef 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -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 \` - 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 \` - 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) diff --git a/cli/test/dbt_optional_descriptor_unit.test.ts b/cli/test/dbt_optional_descriptor_unit.test.ts index f44bdc6103..685dc20e8f 100644 --- a/cli/test/dbt_optional_descriptor_unit.test.ts +++ b/cli/test/dbt_optional_descriptor_unit.test.ts @@ -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[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 diff --git a/cli/test/shared_ui_diff_unit.test.ts b/cli/test/shared_ui_diff_unit.test.ts index f2fd69210e..052fa58a8e 100644 --- a/cli/test/shared_ui_diff_unit.test.ts +++ b/cli/test/shared_ui_diff_unit.test.ts @@ -11,12 +11,25 @@ import * as os from "node:os"; import * as path from "node:path"; let remoteFiles: Record = {}; +let remoteUnreadable = false; +let pushedFiles: Record | 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 }; + }) => { + 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"); + }); }); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 343ed49ef7..24c4a282a3 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -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"); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8d65ecdaa..4af21899c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index 2fe0532761..ed8c18768f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 0955f68ee5..2d6a98c625 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -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 webSearchEnabledProviders?: Partial> + // 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 }) } } diff --git a/frontend/src/lib/components/FileExplorer.svelte b/frontend/src/lib/components/FileExplorer.svelte index a92972f3e9..e031c21337 100644 --- a/frontend/src/lib/components/FileExplorer.svelte +++ b/frontend/src/lib/components/FileExplorer.svelte @@ -8,9 +8,11 @@ interface Props { /** File path → content map. Keys use / prefix (e.g. /index.html). */ files: Record - /** 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 ? '/' : '')) } } @@ -223,7 +209,7 @@ Files
diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 5078e0a83b..51026f10fd 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -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> } = $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 ). + 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 = $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 + // (`:`) 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 @@ > (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 @@
inputElement?.getBoundingClientRect() ?? new DOMRect()} - innerClass="!max-h-[30rem]" + innerClass="!max-h-[25rem]" strictWidth > @@ -747,6 +848,20 @@ class="border border-border-light rounded min-h-[4rem]" /> + {:else if filter.type === 'string'} + {#if menuItems.length} +
+ {#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} +
+ {/if} {/if} {/snippet} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 4a0bd58715..ce5323fe2b 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1070,8 +1070,9 @@
  • 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)
  • feature adoption (counts of which flow, script, trigger and worker features your @@ -1123,8 +1124,9 @@
  • 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)
  • feature adoption (counts of which flow, script, trigger and worker features your @@ -1152,20 +1154,19 @@
    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.
    {:else if !$enterpriseLicense}
    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 Delete logs from s3 periodically 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 Delete logs from s3 periodically is on under Object Storage.
    {/if} diff --git a/frontend/src/lib/components/TaggedTextInput.svelte b/frontend/src/lib/components/TaggedTextInput.svelte index 23bbc65e6b..9f19195b85 100644 --- a/frontend/src/lib/components/TaggedTextInput.svelte +++ b/frontend/src/lib/components/TaggedTextInput.svelte @@ -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 @@
    - 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 | 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>({}) + $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)) - } - - -
    +{#if cases.length === 0} + +{:else} + 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))} + > + + + + + + + + Question + Expected + + + + + {#each pageCases as c (c.id)} + + + + (c.input = { ...(c.input ?? {}), user_message: v })} + /> + + + setExpected(c, v)} + /> + + +
    -
    + +
    Cases {workingCases.length} @@ -369,13 +372,13 @@ Add a case
    -
    +
    (casesEditing = v)} />
    @@ -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 diff --git a/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte b/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte index d4d1714fd0..63fe366628 100644 --- a/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte +++ b/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte @@ -47,16 +47,6 @@ let hasDraft = $derived(editedConfig !== undefined) let dataset = $state(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 @@ -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() }} > @@ -247,7 +242,7 @@ startIcon={{ icon: Pencil }} iconOnly title="Edit this dataset" - on:click={() => stepAside(() => onEditDataset(dataset ?? ''))} + on:click={() => onEditDataset(dataset ?? '')} />
    {/if} diff --git a/frontend/src/lib/components/aiEvals/EvalRunsList.svelte b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte index 5d84ef88ce..b544781902 100644 --- a/frontend/src/lib/components/aiEvals/EvalRunsList.svelte +++ b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte @@ -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 /** 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(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 @@ } + + - + + @@ -58,12 +135,20 @@ Dataset Cases Scores - When + When + - - {#each experiments as experiment (experiment.id)} - onOpen(experiment)}> + + {#each experiments as experiment, i (experiment.id)} + + onOpen(experiment)} + on:hover={(e) => e.detail && (cursorId = experiment.id)} + >
    @@ -99,7 +184,18 @@ - {experiment.case_count} + {#if experiment.running} + + + + {caseProgress[experiment.id] ?? 0}/{experiment.case_count} + + {:else} + {experiment.case_count} + {/if}
    @@ -117,8 +213,6 @@ {value} {:else if score.failed > 0} failed - {:else if experiment.running} - {:else} {/if} @@ -126,33 +220,34 @@ {/each} {#if (experiment.scores ?? []).length === 0} - {#if experiment.running} - - - scoring - - {:else} - not scored - {/if} + + not scored {/if}
    - + + + + {/each} {#if experiments.length === 0 && !loaded} - + {:else if experiments.length === 0} - +
    No runs yet diff --git a/frontend/src/lib/components/aiEvals/EvalScorers.svelte b/frontend/src/lib/components/aiEvals/EvalScorers.svelte index 68287b0fbc..d00e52d77e 100644 --- a/frontend/src/lib/components/aiEvals/EvalScorers.svelte +++ b/frontend/src/lib/components/aiEvals/EvalScorers.svelte @@ -196,26 +196,31 @@
    -
    +
    {#if scorers.length === 0} -
    +
    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.
    {:else} -
    + +
    {#each scorers as scorer (scorer.id)} -
    +
    {#if scorer.kind === 'agent'} - + {:else} - + {/if}
    - + {scorerLabel(scorer)} - {scorer.path} + {scorer.path}
    {#if scorer.pass_if != undefined} diff --git a/frontend/src/lib/components/aiEvals/EvalsPane.svelte b/frontend/src/lib/components/aiEvals/EvalsPane.svelte index d9e5b16fe4..55792ba8e2 100644 --- a/frontend/src/lib/components/aiEvals/EvalsPane.svelte +++ b/frontend/src/lib/components/aiEvals/EvalsPane.svelte @@ -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(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(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(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>({}) + + 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 { 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 { 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(undefined) + $effect(() => { + if (selectedRow) openRow = selectedRow + }) $effect(() => { if (!ws) return @@ -570,34 +651,52 @@
    + {#if loaded && loadError} +
    + Could not load evals + + The datasets or runs could not be read. Check your access to this agent and reload. + +
    + {:else} + + { + // 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} +
    + +{#snippet listPage()} + +

    + Each run answers a dataset of cases with this agent and scores the answers, so runs can be + compared. +

    - {#if viewingRun} - - {/if}
    - {#if viewingRun && experiment?.run_job_id} - - Open the job - - - {/if} - {#if !viewingRun && loaded && datasets.length > 0} + {#if loaded && datasets.length > 0} -
    - {:else if !viewingRun || !loaded} - openRun(e.id)} - onEditDataset={async (path) => { - if (await useDataset(path)) datasetDrawer?.openDrawer('edit') - }} - onNew={() => (runDialogOpen = true)} - /> - {:else} - - - - - {#each scorers as scorer (scorer.id)} - - {/each} - - - - Case - Answer - {#each scorers as scorer, index (scorer.id)} - {@const mean = means.find((m) => m.scorer_id === scorer.id)} - {@const headline = columnHeadline(scorer, mean)} - - -
    - - {#if scorer.kind === 'agent'} - - {:else} - - {/if} - {scorerLabel(scorer)} - - - {#if headline} - - {headline.value} + + + + + {#each scorers as scorer (scorer.id)} + + {/each} + + + + Case + Answer + {#each scorers as scorer, index (scorer.id)} + {@const mean = means.find((m) => m.scorer_id === scorer.id)} + {@const headline = columnHeadline(scorer, mean)} + + +
    + + {#if scorer.kind === 'agent'} + + {:else} + + {/if} + {scorerLabel(scorer)} + + + {#if headline} + + {headline.value} + + {#if headline.delta && headline.direction !== 0} + 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`} + > + {headline.delta} - {#if headline.delta && headline.direction !== 0} - 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`} - > - {headline.delta} - - {/if} {/if} - -
    -
    - {/each} + {/if} +
    +
    +
    + {/each} + + + + {#if datasetLoading} + + + + + - - + {:else} {#each displayRows as row (row.case_id)} {@const status = statusOf(row.status)} {/each} - -
    - {/if} + {/if} + +
    - {#if selectedRow} - {@const openRow = selectedRow} - + + + {#if openRow}
    -
    - + +
    + {openRow.input?.user_message ?? caseLabel(openRow)} -
    - {#if openRow.job_id} - - Open the case job - - - {/if} -
    -
    + +
    {#if openRow.expected != undefined && openRow.expected !== ''} - +
    +
    + Expected +
    +
    + + {typeof openRow.expected === 'string' + ? openRow.expected + : JSON.stringify(openRow.expected, null, 2)} + +
    +
    {/if} {#if scorers.length > 0 && openRow.scores.length > 0} -
    -
    +{/snippet} { - 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')} /> { - 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 - }} /> + + diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css deleted file mode 100644 index 1ac9f8b31c..0000000000 --- a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts deleted file mode 100644 index 00975955c9..0000000000 --- a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts +++ /dev/null @@ -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 = { - cellEditor: MultilineCellEditor, - suppressKeyboardEvent: (p) => - p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey -} diff --git a/frontend/src/lib/components/common/EditableTextarea.svelte b/frontend/src/lib/components/common/EditableTextarea.svelte new file mode 100644 index 0000000000..26a6574027 --- /dev/null +++ b/frontend/src/lib/components/common/EditableTextarea.svelte @@ -0,0 +1,173 @@ + + + +{#if editing} + + +{:else} + + +{/if} diff --git a/frontend/src/lib/components/common/drawer/Drawer.svelte b/frontend/src/lib/components/common/drawer/Drawer.svelte index d375bf506e..6bb5358940 100644 --- a/frontend/src/lib/components/common/drawer/Drawer.svelte +++ b/frontend/src/lib/components/common/drawer/Drawer.svelte @@ -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) diff --git a/frontend/src/lib/components/common/emptyState/EmptyState.svelte b/frontend/src/lib/components/common/emptyState/EmptyState.svelte index 8715065ad0..49c3cd2b77 100644 --- a/frontend/src/lib/components/common/emptyState/EmptyState.svelte +++ b/frontend/src/lib/components/common/emptyState/EmptyState.svelte @@ -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}
    {#if action} - +
    diff --git a/frontend/src/lib/components/common/modal/PagedContent.svelte b/frontend/src/lib/components/common/modal/PagedContent.svelte new file mode 100644 index 0000000000..430a53e66f --- /dev/null +++ b/frontend/src/lib/components/common/modal/PagedContent.svelte @@ -0,0 +1,202 @@ + + + + + + + +
    + {#each pages as page, i (page.key)} + {#if visited.includes(page.key) || warmed} + + +
    + {@render page.content()} +
    + {/if} + {/each} +
    + + diff --git a/frontend/src/lib/components/common/overlayHost.svelte.ts b/frontend/src/lib/components/common/overlayHost.svelte.ts index 0e76709cbf..7c4374115b 100644 --- a/frontend/src/lib/components/common/overlayHost.svelte.ts +++ b/frontend/src/lib/components/common/overlayHost.svelte.ts @@ -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 +} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 636bd2c9d6..1059a986ce 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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()} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index a155ca3a97..8d88fe8f01 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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()} diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index 3f74356506..ff31eb1aea 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -35,13 +35,13 @@ {#snippet badges()} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 849d77e2d9..77434d3c59 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -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 diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 25b557690e..de0a327b1b 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -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 @@ CI test {/if} - {#if script.kind !== 'script'} + + {#if script.kind && script.kind !== 'script'} {script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)} diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 2a51b1b2e5..e4dba770eb 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -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 = [ diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 933900cf59..601288cd34 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -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(null) @@ -613,6 +630,44 @@ ) +{#snippet freeTierExhaustedBanner()} +
    + +
    + + You have used all of your free Windmill AI tokens. Add your own API key to keep using AI. + + +
    +
    +
    +{/snippet} + +{#snippet freeTierUsageBanner()} +
    + + {freeTierUsedPct}% of your free Windmill AI used + + +
    +{/snippet} +
    script editor to modify selected lines. {/if} + {#if freeTierExhausted} +
    + {@render freeTierExhaustedBanner()} +
    + {/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}
    {#if inputPreface} {@render inputPreface()} {/if} + {#if showFreeTierUsage} + {@render freeTierUsageBanner()} + {/if} {/if} {#if canAttachFiles} [ - { - 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 folder’s current files from disk and refreshes each turn.' - : 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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 folder’s current files from disk and refreshes each turn.' + : 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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} + {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 67d603f210..29b00fc596 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -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() } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index eb1c845f48..7a3994a860 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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([]) 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 diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index f788844135..1292022d25 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -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((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 send’s @ 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 send’s @ 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 mode’s 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 prompt’s 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 mode’s 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( diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index d38caf8f0a..123768d3f3 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -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} · {effortLabel} {/if} + {#if freeTier && !freeTier.exhausted} + Free + {/if}
    diff --git a/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte index 995a84708c..5c507deb33 100644 --- a/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatCommandPicker.svelte @@ -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[]>( 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 })) diff --git a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts index af3b402f9c..9191de5408 100644 --- a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts @@ -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 } diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 2502cd075b..65cef3f5d6 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -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() diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index 170de83f6a..cd6c58b2a3 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -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 @@ {#if visible} - - -
    -
    -
    -
    -
    - {#snippet text()} + + + {#snippet tooltip()}

    Context usage

    @@ -85,5 +71,5 @@ {/if}

    {/snippet} -
    + {/if} diff --git a/frontend/src/lib/components/copilot/chat/SkillsPicker.svelte b/frontend/src/lib/components/copilot/chat/SkillsPicker.svelte new file mode 100644 index 0000000000..b1cf8e4850 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/SkillsPicker.svelte @@ -0,0 +1,857 @@ + + + + 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()} + + {/snippet} + + {#if listNotice} + + {listNotice} + + {/if} + + {#if forkPending} + + 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. + + {/if} + + + + Drop a folder of SKILL.md files to import, or click to choose + one + + + + {#if loading} +
    Loading skills…
    + {:else if loadError} +
    + Failed to load skills: {loadError} +
    + {:else if skills.length === 0} +
    + No skills in this workspace yet. Paste a SKILL.md or import a folder of them. +
    + {:else} +
    + {#each skills as skill (skill.path)} +
    + +
    +
    + {ambiguous.has(skill.name) ? skill.path : skill.name} +
    + {#if skill.description} +
    {skill.description}
    + {/if} +
    + await toggle(skill.path, e.detail)} + /> + openSkill(skill, skill.canWrite ? 'edit' : 'view') + }, + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + disabled: !skill.canWrite || forkPending, + action: () => (toDelete = skill) + } + ]} + /> +
    + {/each} +
    + {/if} + + { + const skill = toDelete + toDelete = undefined + if (skill) await remove(skill) + }} + onCanceled={() => (toDelete = undefined)} + > + + This deletes the resource at {toDelete?.path}, so + everyone who selected it loses the skill. + + + + { + 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 = {} + }} + > +
    + + Skills are added under {defaultOwner()}. Move one to a + shared folder from the resources page to share it. + + {#if pendingNew.length} +
    + Add {pendingNew.length} new skill(s): + {pendingNew.map((s) => s.name).join(', ')} +
    + {/if} + {#if pendingConflicts.length} +
    + + {pendingConflicts.length} skill(s) already exist — choose which to overwrite: + +
    + {#each pendingConflicts as conflict (conflict.name)} +
    + {conflict.name} + +
    + {/each} +
    +
    + {/if} + {#if pendingSkipped.length} + {pendingSkipped.length} file(s) will be skipped. + {/if} +
    +
    +
    +
    + + + {#snippet headerRight()} + {#if editing} + + {#snippet children({ item })} + + + {/snippet} + + {/if} + {/snippet} +
    + {#if detailMode === 'view'} + {#if parsed.description} +

    {parsed.description}

    + {/if} +
    + +
    + {:else} + + +
    + +
    +
    + {contentError ?? ''} +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/copilot/chat/UsageMeter.svelte b/frontend/src/lib/components/copilot/chat/UsageMeter.svelte new file mode 100644 index 0000000000..26e4758953 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/UsageMeter.svelte @@ -0,0 +1,39 @@ + + + +
    +
    +
    +
    +
    + {#snippet text()} + {@render tooltip()} + {/snippet} +
    diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index 6eecb960c9..2275ab1052 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -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' } /** diff --git a/frontend/src/lib/components/copilot/chat/context.ts b/frontend/src/lib/components/copilot/chat/context.ts index bf915515db..f57862c179 100644 --- a/frontend/src/lib/components/copilot/chat/context.ts +++ b/frontend/src/lib/components/copilot/chat/context.ts @@ -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') { diff --git a/frontend/src/lib/components/copilot/chat/enabledPathsPreference.ts b/frontend/src/lib/components/copilot/chat/enabledPathsPreference.ts new file mode 100644 index 0000000000..5290528dba --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/enabledPathsPreference.ts @@ -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 { + if (typeof localStorage === 'undefined') return {} + try { + return JSON.parse(localStorage.getItem(storageKey) ?? '{}') + } catch { + return {} + } + } + + function write(all: Record) { + 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 + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 90301f5493..19050bb7da 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 52704e7cb8..4e08dcebe9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -17,8 +17,7 @@ import { ScriptService, SqsTriggerService, VariableService, - WebsocketTriggerService, - WorkspaceService + WebsocketTriggerService } from '$lib/gen' import { createTwoFilesPatch } from 'diff' import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' @@ -83,6 +82,16 @@ import { } from '../flow/inlineScriptsUtils' import { searchNpmPackagesTool } from '../script/core' import type { McpServer } from './mcpTools' +import { logFeatureUsage } from '$lib/utils/featureUsage' +import { enabledSkillPaths } from '../skills/enabledSkills' +import { + listSkillResources, + readSkillBody, + skillNameFromPath, + truncateChars, + truncateForPrompt +} from '../skills/skillResources' +import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd' import { getDatatableSdkReference, getFlowPrompt, @@ -1362,9 +1371,9 @@ Data Tables: ? ` Skills: -- Skills are reusable instruction sets curated for this workspace, each covering a specific kind of task. The available skills are listed below by name and description. -- When a user's request matches a skill's description, call read_skill with its exact name to load the full instructions BEFORE acting, then follow them. -${skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')}` +- Skills are reusable instruction sets the user selected for this chat, each covering a specific kind of task. The available skills are listed below by resource path and description. +- When a user's request matches a skill's description, call read_skill with its exact path to load the full instructions BEFORE acting, then follow them. +${skills.map((s) => `- ${s.path}: ${s.description}`).join('\n')}` : '' }${ mcpServers.length > 0 @@ -2253,7 +2262,9 @@ function getInstructions( } } -export type AiSkillListItem = { name: string; description: string } +/** A skill the user turned on, as the prompt and the `/` picker see it. `path` + * is the `ai_skill` resource and the model-facing id; `name` is its basename. */ +export type AiSkillListItem = { path: string; name: string; description: string } /** Live session facts appended to the GLOBAL system prompt for session chats. * Provided by the session runtime as a resolver (copilot must not import the @@ -2316,15 +2327,43 @@ export function getSessionContextPromptSection(ctx: SessionPromptContext): strin return lines.join('\n') } -/** `/` picker entry: a workspace skill or a built-in session action. The kind - * drives the picker's category grouping; entries without one are ungrouped. */ -export type ChatCommandItem = AiSkillListItem & { kind?: 'action' | 'skill' } +/** `/` picker entry: a selected skill or a built-in session action. The kind + * drives the picker's category grouping; entries without one are ungrouped. + * Only skills carry a `path` — built-in actions run locally and have no resource. */ +export type ChatCommandItem = { + name: string + description: string + path?: string + kind?: 'action' | 'skill' +} -/** Fetch the workspace's AI skills (name + description) for the global system prompt. */ +/** + * The skills this user turned on in this workspace, for the global system prompt. + * A readable `ai_skill` resource is only a candidate — enabling one is a personal + * choice, since each enabled skill spends context on every turn. + */ export async function loadWorkspaceSkills(workspace: string): Promise { if (!workspace) return [] try { - return await WorkspaceService.listAiSkills({ workspace }) + const enabled = new Set(enabledSkillPaths(workspace)) + if (enabled.size === 0) return [] + // Filtered against what is actually readable now, so a skill that was + // deleted or whose folder access was revoked drops out instead of being + // advertised to the model as something read_skill can load. + // A truncated listing still carries most of the workspace, and the drawer is + // where that is surfaced; dropping everything here would silently empty the + // Skills section instead. + return (await listSkillResources(workspace)).skills + .filter((s) => enabled.has(s.path)) + .map(({ path, name, description }) => ({ + path, + name, + // Every description goes into the system prompt on every turn, and any + // resource of this type can be selected — including ones written through + // git sync or the resource editor, which never saw the authoring form's + // bounds. One unbounded description would crowd out the conversation. + description: truncateChars(description, MAX_SKILL_DESCRIPTION_LENGTH) + })) } catch (e) { console.error('Failed to load AI skills', e) return [] @@ -2332,32 +2371,52 @@ export async function loadWorkspaceSkills(workspace: string): Promise = { def: createToolDef( readSkillSchema, 'read_skill', - 'Load the full instructions for a workspace AI skill by name. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.' + 'Load the full instructions for a selected AI skill by resource path. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.' ), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readSkillSchema.parse(args) - toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${parsed.name}"...` }) + const name = skillNameFromPath(parsed.path) + // The prompt lists only selected skills, but the tool takes a path the model + // composed, so the selection is enforced here too rather than assumed. Without + // it the tool reads any resource holding a string `content` — the user's own + // access, but not what "load a selected skill" says it does. + if (!enabledSkillPaths(workspace).includes(parsed.path)) { + toolCallbacks.setToolStatus(toolId, { content: `Skill "${name}" is not selected` }) + return `"${parsed.path}" is not one of the skills selected for this chat. Only the paths listed under "Skills" in the system prompt can be read.` + } + toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${name}"...` }) try { - const skill = await WorkspaceService.getAiSkill({ workspace, name: parsed.name }) - toolCallbacks.setToolStatus(toolId, { content: `Read skill "${parsed.name}"` }) - return `Skill: ${skill.name}\nDescription: ${skill.description}\n\nInstructions:\n${skill.instructions}` + // Bounded here rather than in the reader: any `ai_skill` resource can be + // selected, including ones written through git sync or the resource editor + // that never passed the authoring form's limits, and an unbounded body + // would exhaust the context on one tool call. The editor reads the same + // resource untruncated, so opening a long skill cannot rewrite it short. + const instructions = truncateForPrompt( + await readSkillBody(workspace, parsed.path), + MAX_SKILL_INSTRUCTIONS_LENGTH + ) + toolCallbacks.setToolStatus(toolId, { content: `Read skill "${name}"` }) + // Whether a selected skill is actually reached for. No key: the path is + // workspace-authored text. + logFeatureUsage('ai_session', 'skill_read', { workspace }) + return `Skill: ${parsed.path}\n\nInstructions:\n${instructions}` } catch (e) { const msg = e instanceof Error ? e.message : String(e) toolCallbacks.setToolStatus(toolId, { - content: `Error reading skill "${parsed.name}"`, + content: `Error reading skill "${name}"`, error: msg }) - return `Failed to read skill "${parsed.name}": ${msg}. Check the name against the Skills list in the system prompt.` + return `Failed to read skill "${parsed.path}": ${msg}. Check the path against the Skills list in the system prompt.` } } } diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index 3321d5a0cd..8fb650aa36 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -9,8 +9,8 @@ * * When the beta ends, replace every call to `isGlobalAiEnabled()` with `true` * and delete this file. The references are intentionally narrow (chat mode - * visibility, custom prompt settings, the `change_mode` tool enum, and the - * AI skills workspace settings tab) so the rip-out is a small grep. + * visibility, custom prompt settings, and the `change_mode` tool enum) so the + * rip-out is a small grep. */ import { logFeatureUsage } from '$lib/utils/featureUsage' diff --git a/frontend/src/lib/components/copilot/chat/skills/enabledSkills.ts b/frontend/src/lib/components/copilot/chat/skills/enabledSkills.ts new file mode 100644 index 0000000000..e91d40bff0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/skills/enabledSkills.ts @@ -0,0 +1,10 @@ +import { createEnabledPathsPreference } from '../enabledPathsPreference' + +/** Which `ai_skill` resources the chat may follow, per workspace and per account. + * Every enabled skill spends context on every turn, so selecting one is a personal + * choice rather than a consequence of being able to read it. */ +const preference = createEnabledPathsPreference('wm_skills_enabled') + +export const enabledSkillPaths = preference.enabledPaths +export const isSkillEnabled = preference.isEnabled +export const setSkillEnabled = preference.setEnabled diff --git a/frontend/src/lib/components/workspaceSettings/aiSkills.test.ts b/frontend/src/lib/components/copilot/chat/skills/skillMd.test.ts similarity index 99% rename from frontend/src/lib/components/workspaceSettings/aiSkills.test.ts rename to frontend/src/lib/components/copilot/chat/skills/skillMd.test.ts index 87f84d865a..b498cc2d24 100644 --- a/frontend/src/lib/components/workspaceSettings/aiSkills.test.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillMd.test.ts @@ -8,7 +8,7 @@ import { parseAndValidateSkill, parseSkillMd, validateSkill -} from './aiSkills' +} from './skillMd' describe('parseSkillMd', () => { it('splits frontmatter name/description from the body', () => { diff --git a/frontend/src/lib/components/workspaceSettings/aiSkills.ts b/frontend/src/lib/components/copilot/chat/skills/skillMd.ts similarity index 90% rename from frontend/src/lib/components/workspaceSettings/aiSkills.ts rename to frontend/src/lib/components/copilot/chat/skills/skillMd.ts index aeb148f7c1..93916c023f 100644 --- a/frontend/src/lib/components/workspaceSettings/aiSkills.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillMd.ts @@ -1,10 +1,13 @@ import YAML from 'yaml' import { z } from 'zod' +/** A SKILL.md split into the three parts a `skills` resource stores: `name` + * becomes the resource path's basename, `description` its description column, + * `instructions` its file body. */ export type SkillUpload = { name: string; description: string; instructions: string } -// `name` + `description` mirror the Claude SKILL.md spec (counted in characters); -// the body is a byte-bounded payload. Keep these in sync with backend `validate_skill`. +// `name` + `description` mirror the Claude SKILL.md spec (counted in characters), +// so a skill stays portable with Claude Code; the body is a byte-bounded payload. export const MAX_SKILL_NAME_LENGTH = 64 export const MAX_SKILL_DESCRIPTION_LENGTH = 1_024 export const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024 @@ -12,8 +15,8 @@ export const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024 const textEncoder = new TextEncoder() // Single source of truth for skill field validation, shared by the paste/edit -// modal and the folder importer. Lengths are code-point / byte bounded to match -// the backend, so `.refine` (not `.max`, which counts UTF-16 units) is used. +// modal and the folder importer. Lengths are code-point / byte bounded, so +// `.refine` (not `.max`, which counts UTF-16 units) is used. export const skillSchema = z.object({ name: z .string() diff --git a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts new file mode 100644 index 0000000000..20737caffa --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts @@ -0,0 +1,159 @@ +import { ResourceService } from '$lib/gen' +import { canWrite } from '$lib/utils' +import type { UserExt } from '$lib/stores' + +/** + * Skills are resources of this type: a file resource (`format_extension = 'md'`) + * whose `value.content` is the SKILL.md body, whose description column is what the + * assistant reads when deciding the skill applies, and whose path names it. + */ +export const SKILLS_RESOURCE_TYPE = 'ai_skill' + +/** A skill as the picker and the system prompt see it — never the body, which + * `read_skill` fetches only once the model commits to using the skill. */ +export type SkillResource = { + path: string + /** Path basename: what the `/` command and the picker row show. */ + name: string + description: string + editedAt?: string + canWrite: boolean +} + +/** The `/`-command and display name for a skill. Paths are `[ufg]/x/y…`, so the + * last segment is always present. */ +export function skillNameFromPath(path: string): string { + return path.split('/').pop() ?? path +} + +/** Basenames carried by more than one of these skills. Two folders can each hold + * a `deploy`, and then the name alone no longer says which one — the picker shows + * the path for these, and the `/` command refuses to guess. */ +export function ambiguousSkillNames(skills: readonly { name: string }[]): Set { + const seen = new Map() + for (const s of skills) seen.set(s.name, (seen.get(s.name) ?? 0) + 1) + return new Set([...seen].filter(([, n]) => n > 1).map(([name]) => name)) +} + +const SKILLS_PAGE_SIZE = 100 +/** Pages to walk before giving up. Ordinary resources and repeated imports can + * make any number of skills, and a single page would drop the rest — including a + * selected one, which would then vanish from the prompt with nothing to explain + * it. The bound is a guard against a paging bug looping forever, not a product + * cap, so reaching it is reported rather than passed off as the whole set. */ +const MAX_SKILLS_PAGES = 100 + +/** The rows read, and whether the walk stopped at the bound rather than the end. + * Reported rather than thrown: a truncated read is still most of the skills, and + * dropping them all would take every selected skill out of the prompt at once. */ +export type SkillListing = { skills: SkillResource[]; truncated: boolean } + +/** Every skill resource readable in the workspace. + * + * `user` decides which rows the drawer offers to edit rather than only view; pass + * the account the workspace is being browsed as. Ownership is mostly implicit in + * the path (`u//…`, a folder the user owns), which is why this goes through + * the shared `canWrite` rather than reading `extra_perms` alone. */ +export async function listSkillResources( + workspace: string, + user?: UserExt +): Promise { + if (!workspace) return { skills: [], truncated: false } + const rows: SkillResource[] = [] + for (let page = 1; page <= MAX_SKILLS_PAGES; page++) { + const resources = await ResourceService.listResource({ + workspace, + resourceType: SKILLS_RESOURCE_TYPE, + page, + perPage: SKILLS_PAGE_SIZE + }) + rows.push( + ...resources.map((r) => ({ + path: r.path, + name: skillNameFromPath(r.path), + description: r.description ?? '', + editedAt: r.edited_at, + canWrite: canWrite(r.path, r.extra_perms ?? {}, user) + })) + ) + if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false } + } + return { skills: rows, truncated: true } +} + +/** Cut `text` to `maxChars` code points. For the description, whose cap is stated + * in characters — cutting that one by bytes would reduce a legal 1,024-character + * CJK description to about a third of itself. */ +export function truncateChars(text: string, maxChars: number): string { + const points = [...text] + return points.length <= maxChars ? text : `${points.slice(0, maxChars).join('')}… [truncated]` +} + +/** Cut `text` to `maxBytes` of UTF-8, marking the cut so a reader (the model + * included) can tell truncation from a body that simply ends there. + * + * For the body, whose cap is a byte budget: 64k CJK characters are ~192 KiB, so a + * code-unit cut would let three times the intended payload through. */ +export function truncateForPrompt(text: string, maxBytes: number): string { + const encoded = new TextEncoder().encode(text) + if (encoded.byteLength <= maxBytes) return text + // `fatal: false` replaces the partial code point a byte-aligned cut can leave + // with U+FFFD; dropping it keeps the tail clean. + const cut = new TextDecoder('utf-8').decode(encoded.slice(0, maxBytes)).replace(/�$/, '') + return `${cut}… [truncated]` +} + +/** The SKILL.md body of one skill. Throws rather than returning `''` when the + * resource holds no readable body: an empty string reaches the model as a + * successful read of a skill with no instructions, which it would then act on. + * + * Deliberately unbounded — the editor loads through here and saves what it loaded, + * so truncating would rewrite an over-long skill the first time someone opened it. + * Bounding belongs at the prompt boundary, where the cost actually is. */ +export async function readSkillBody(workspace: string, path: string): Promise { + const value = (await ResourceService.getResourceValue({ workspace, path })) as + | { content?: unknown } + | undefined + if (typeof value?.content !== 'string') { + throw new Error(`resource ${path} has no string "content" — is it an ${SKILLS_RESOURCE_TYPE}?`) + } + return value.content +} + +export async function saveSkillResource( + workspace: string, + path: string, + description: string, + instructions: string, + { overwrite = false }: { overwrite?: boolean } = {} +): Promise { + await ResourceService.createResource({ + workspace, + updateIfExists: overwrite, + requestBody: { + path, + description, + value: { content: instructions }, + resource_type: SKILLS_RESOURCE_TYPE + } + }) +} + +/** Save an edit to an existing skill, moving it when the path changed. */ +export async function updateSkillResource( + workspace: string, + currentPath: string, + path: string, + description: string, + instructions: string +): Promise { + await ResourceService.updateResource({ + workspace, + path: currentPath, + requestBody: { path, description, value: { content: instructions } } + }) +} + +export async function deleteSkillResource(workspace: string, path: string): Promise { + await ResourceService.deleteResource({ workspace, path }) +} diff --git a/frontend/src/lib/components/copilot/chat/skills/skills.test.ts b/frontend/src/lib/components/copilot/chat/skills/skills.test.ts new file mode 100644 index 0000000000..5b0d2196ca --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/skills/skills.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { session } = vi.hoisted(() => ({ + session: { email: 'first@windmill.dev' } as { email?: string } +})) + +vi.mock('$lib/stores', () => ({ + // Read at call time, so a test can switch accounts the way a logout does. + userStore: { subscribe: (run: (v: unknown) => void) => (run({ ...session }), () => {}) } +})) + +import { enabledSkillPaths, isSkillEnabled, setSkillEnabled } from './enabledSkills' +import { ambiguousSkillNames, truncateChars, truncateForPrompt } from './skillResources' + +describe('enabledSkills', () => { + beforeEach(() => { + localStorage.clear() + session.email = 'first@windmill.dev' + }) + + it('keeps the selection separate per workspace', () => { + setSkillEnabled('ws_a', 'u/me/deploy', true) + expect(isSkillEnabled('ws_a', 'u/me/deploy')).toBe(true) + expect(isSkillEnabled('ws_b', 'u/me/deploy')).toBe(false) + }) + + it('does not hand the next account the previous one’s selection', () => { + setSkillEnabled('ws_a', 'u/me/deploy', true) + session.email = 'second@windmill.dev' + expect(enabledSkillPaths('ws_a')).toEqual([]) + }) + + it('reports failure when there is no account to record the choice against', () => { + session.email = undefined + expect(setSkillEnabled('ws_a', 'u/me/deploy', true)).toBe(false) + expect(enabledSkillPaths('ws_a')).toEqual([]) + }) +}) + +describe('skill names', () => { + it('flags a basename two folders both use, so /name is not resolved by chance', () => { + const ambiguous = ambiguousSkillNames([ + { name: 'deploy' }, + { name: 'deploy' }, + { name: 'release' } + ]) + expect([...ambiguous]).toEqual(['deploy']) + }) +}) + +describe('prompt truncation', () => { + // The two caps are stated in different units, and using one truncator for both + // either lets three times the payload through or cuts a legal value to a third. + it('bounds a skill body by utf-8 bytes, not code units', () => { + const body = '漢'.repeat(100) // 300 bytes + expect(truncateForPrompt(body, 3000)).toBe(body) + const cut = truncateForPrompt(body, 30) + expect(new TextEncoder().encode(cut.replace('… [truncated]', '')).byteLength).toBeLessThanOrEqual(30) + expect(cut).toContain('[truncated]') + // A byte-aligned cut must not leave a broken code point behind. + expect(cut).not.toContain('\ufffd') + }) + + it('bounds a description by code points, so a CJK one is not cut to a third', () => { + const description = '漢'.repeat(100) + expect(truncateChars(description, 100)).toBe(description) + expect([...truncateChars(description, 10)].slice(0, 10).join('')).toBe('漢'.repeat(10)) + }) +}) diff --git a/frontend/src/lib/components/copilot/loadCopilot.ts b/frontend/src/lib/components/copilot/loadCopilot.ts index 4634833b0b..496fb89fdc 100644 --- a/frontend/src/lib/components/copilot/loadCopilot.ts +++ b/frontend/src/lib/components/copilot/loadCopilot.ts @@ -1,6 +1,14 @@ import { WorkspaceService } from '$lib/gen' import { copilotWorkspace, setCopilotInfo } from '$lib/aiStore' import { workspaceAIClients } from './lib' +import { writable } from 'svelte/store' + +// The workspace of the most recent loadCopilot *request*, set synchronously before the +// await — as opposed to `copilotWorkspace`, which only updates once a load resolves. A +// background refresh (e.g. free-tier usage) compares against this so it can't supersede an +// in-flight load for a newer workspace (which would otherwise win the token and restore +// stale state). +export const copilotWorkspaceRequested = writable(undefined) // Lives here, not in $lib/aiStore, purely so that module needs no import of the AI // client — it is the one thing that wanted both. Moving it back recreates the @@ -20,6 +28,7 @@ let loadCopilotToken = 0 let inFlight: { workspace: string; promise: Promise } | undefined export function loadCopilot(workspace: string): Promise { + copilotWorkspaceRequested.set(workspace) if (inFlight?.workspace === workspace) { return inFlight.promise } diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte new file mode 100644 index 0000000000..d0e75538ca --- /dev/null +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -0,0 +1,255 @@ + + + + +
    +
    + {#if showComposer} +
    +
    +

    Build with AI

    + Beta +
    + +
    + + +
    + +
    +
    +
    + {/if} + +
    + {#if showComposer} +
    + {#each homeAIExamples as example (example.label)} + + {/each} +
    + {:else} +
    + {/if} + + +
    + + {#if !$userStore?.operator && HOME_SHOW_HUB} + + {/if} +
    +
    + {#if showComposer && disabled} +
    +

    + {#if $aiUserDisabled} + Windmill AI is disabled in your account settings + {:else if freeTierExhausted} + You have used all of your free Windmill AI tokens + {:else} + No AI provider is configured + {/if} +

    + {#if $aiUserDisabled} + + + {:else} + + {/if} +
    + {/if} +
    +
    + + diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index d7717d1e04..0f0cb6a6c0 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -1,7 +1,7 @@ @@ -125,16 +127,8 @@ {runnable} isSelected={selectedRunnable === id} isEditing={editingId === id} - onSelect={() => { - selectedRunnable = id - onSelect?.(id) - }} - onDelete={() => { - delete runnables[id] - if (selectedRunnable === id) { - selectedRunnable = undefined - } - }} + onSelect={() => onSelect?.(id)} + onDelete={() => onDelete(id)} onRename={(newId) => renameRunnable(id, newId)} onRequestEdit={() => (editingId = id)} onCancelEdit={() => (editingId = undefined)} diff --git a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte index b8d6e33c87..fbe02e1dc9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte @@ -3,6 +3,7 @@ SUBTLE_PANEL_TITLE } from '../apps/editor/settingsPanel/common/PanelSection.svelte' import type { Runnable } from '../apps/inputType' + import { WMILL_TS_PATH } from './utils' import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte' import FileExplorer from '../FileExplorer.svelte' import { Plus, File, Folder, Camera } from 'lucide-svelte' @@ -18,10 +19,14 @@ interface Props { runnables: Record + /** Read-only; the editor switches selection through `onSelectRunnable`. */ selectedRunnable: string | undefined files: Record modules?: Modules - onSelectFile?: (path: string) => void + onSelectRunnable?: (key: string) => void + onDeleteRunnable: (key: string) => void + onSelectPath?: (path: string) => void + /** Read-only; the editor switches selection through `onSelectPath`. */ selectedDocument: string | undefined historyManager?: RawAppHistoryManager historySelectedId?: number | undefined @@ -39,11 +44,13 @@ let { runnables, - selectedRunnable = $bindable(), + selectedRunnable, files = $bindable({}), modules, - onSelectFile, - selectedDocument = $bindable(), + onSelectRunnable, + onDeleteRunnable, + onSelectPath, + selectedDocument, historyManager, historySelectedId, onHistorySelect, @@ -79,13 +86,6 @@ } let fileExplorer: FileExplorer | undefined = $state() - - function handleSelectPath(path: string) { - selectedDocument = path - if (!path.endsWith('/')) { - onSelectFile?.(path) - } - }
    diff --git a/frontend/src/lib/components/text_input/TextInput.svelte b/frontend/src/lib/components/text_input/TextInput.svelte index c57f57120a..03fcedbd8e 100644 --- a/frontend/src/lib/components/text_input/TextInput.svelte +++ b/frontend/src/lib/components/text_input/TextInput.svelte @@ -81,6 +81,11 @@ size?: ButtonType.UnifiedSize unifiedHeight?: boolean underlyingInputEl?: UnderlyingInputElT + /** + * Passed to the `autosize` action on the `textarea` variant. Chiefly `minHeight: 0`, for a + * field that hugs one line instead of reserving the action's 30px floor. + */ + autosizeParams?: import('$lib/autosize').AutosizeParams } export function focus() { @@ -108,7 +113,8 @@ error, size = 'md', unifiedHeight = true, - underlyingInputEl: _underlyingInputEl + underlyingInputEl: _underlyingInputEl, + autosizeParams }: Props = $props() let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const)) @@ -152,7 +158,7 @@ onpointerdown={(e) => e.stopImmediatePropagation()} bind:this={inputEl} bind:value - use:autosize + use:autosize={autosizeParams} > {:else if underlyingInputEl === 'input'} {/if} - - {#if promptScope === 'workspace' && isGlobalAiEnabled()} - - {/if}
    - import { onMount } from 'svelte' - import { createDropdownMenu, melt } from '@melt-ui/svelte' - import Button from '../common/button/Button.svelte' - import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' - import Modal2 from '../common/modal/Modal2.svelte' - import Toggle from '../Toggle.svelte' - import DropdownV2 from '../DropdownV2.svelte' - import Checkbox from '../common/checkbox/Checkbox.svelte' - import Markdown from 'svelte-exmarkdown' - import { gfmPlugin } from 'svelte-exmarkdown/gfm' - import { markdownProse } from '$lib/components/markdownProse' - import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' - import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' - import SettingCard from '../instanceSettings/SettingCard.svelte' - import autosize from '$lib/autosize' - import { conditionalMelt } from '$lib/utils' - import { workspaceStore } from '$lib/stores' - import { sendUserToast } from '$lib/toast' - import { WorkspaceService } from '$lib/gen' - import { buildSkillMd, parseAndValidateSkill, parseSkillMd, type SkillUpload } from './aiSkills' - import { - ChevronDown, - ClipboardPaste, - Eye, - FolderUp, - ListChecks, - Pencil, - Plus, - Trash2 - } from 'lucide-svelte' - - type SkillListItem = { name: string; description: string } - - // `//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 - const MAX_SKILLS_PER_WORKSPACE = 100 - const SAMPLE_SKILL_PLACEHOLDER = - '---\nname: my-skill\ndescription: what this skill helps with\n---\n\n# My skill\n\nInstructions for the assistant…' - const menuItemClass = - 'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover' - - let skills: SkillListItem[] = $state([]) - let uploading: boolean = $state(false) - let pasteContent: string = $state('') - // The content the modal opened with, so Save can be gated on unsaved changes. - let originalContent: string = $state('') - let pasteModalOpen: boolean = $state(false) - // Set while the paste modal is editing an existing skill; holds the skill's - // name before edits so a rename can delete the old entry after save. - let editingOriginalName: string | undefined = $state(undefined) - let dirInput: HTMLInputElement | undefined = $state(undefined) - let toDelete: string | undefined = $state(undefined) - let pendingImport: SkillUpload[] | undefined = $state(undefined) - let pendingSkipped: string[] = $state([]) - // Per-conflict overwrite choice for a folder import, keyed by skill name. - let overwriteChoices: Record = $state({}) - // The skill detail modal opens in read mode with rendered markdown; a header - // toggle flips it to raw SKILL.md editing. - let detailMode: 'view' | 'edit' = $state('view') - // Multi-select "manage" mode: rows gain a checkbox for batch deletion. - let manageMode: boolean = $state(false) - let selected: Record = $state({}) - let confirmBatchDelete: boolean = $state(false) - let listRequestId = 0 - - let existingNames = $derived(new Set(skills.map((s) => s.name))) - let selectedCount = $derived(skills.filter((s) => selected[s.name]).length) - let allSelected = $derived(skills.length > 0 && selectedCount === skills.length) - - // Leave manage mode automatically once a batch delete empties it below the - // two-skill threshold that surfaces the "Manage skills" button. - $effect(() => { - if (manageMode && skills.length <= 1) exitManage() - }) - let pendingConflicts = $derived( - (pendingImport ?? ([] as SkillUpload[])).filter((s) => existingNames.has(s.name)) - ) - let pendingNew = $derived( - (pendingImport ?? ([] as SkillUpload[])).filter((s) => !existingNames.has(s.name)) - ) - // Parsed view of the modal's raw content, for rendering the skill in read mode. - let viewParsed = $derived(parseSkillMd(pasteContent)) - let isDirty = $derived(pasteContent !== originalContent) - // Validate through the shared schema; surfaced inline so Save can be gated - // without a toast. - let pasteResult = $derived(parseAndValidateSkill(pasteContent)) - let pasteError = $derived('error' in pasteResult ? pasteResult.error : undefined) - - // Reset edit mode whenever the paste modal closes so a later "Paste a skill" - // opens a blank creation form. - $effect(() => { - if (!pasteModalOpen) editingOriginalName = undefined - }) - - // melt dropdown for the "+ Add skills" button: arrow-key nav, outside/escape - // close and focus management come for free. - const { - elements: { trigger: addMenuTrigger, menu: addMenu, item: addMenuItem }, - states: { open: addMenuOpen } - } = createDropdownMenu({ - positioning: { placement: 'bottom-end', gutter: 4, fitViewport: true }, - loop: true, - forceVisible: true - }) - - // attach the menu trigger to the design-system -
    - {/if} -{/snippet} - - - {#snippet headerAction()} -
    - {#if manageMode} - - - {:else} - {#if skills.length > 1} - - {/if} - - {/if} -
    - {#if $addMenuOpen} -
    - - -
    - {/if} - {/snippet} - -
    - {#if skills.length === 0} -
    - No custom skills yet -
    - {:else} -
    - {#if manageMode} -
    - 0 && !allSelected} - onChange={toggleSelectAll} - /> - - {selectedCount ? `${selectedCount} selected` : 'Select all'} - -
    - {/if} - {#each skills as skill (skill.name)} -
    - {#if manageMode} - - {:else} -
    -
    {skill.name}
    -
    {skill.description}
    - -
    - openSkill(skill.name, 'edit') - }, - { - displayName: 'Delete', - icon: Trash2, - type: 'delete', - action: () => (toDelete = skill.name) - } - ]} - /> - {/if} -
    - {/each} -
    - {/if} -
    -
    - - - - - - {#snippet headerRight()} - {#if editingOriginalName} - - {#snippet children({ item })} - - - {/snippet} - - {/if} - {/snippet} -
    - {#if detailMode === 'view'} -
    - {#if viewParsed.description} -

    {viewParsed.description}

    - {/if} -
    - -
    -
    - {:else} - {@render pasteZone()} - {/if} -
    -
    - - { - const toImport = [...pendingNew, ...pendingConflicts.filter((s) => overwriteChoices[s.name])] - const skipped = pendingSkipped - pendingImport = undefined - pendingSkipped = [] - overwriteChoices = {} - if (toImport.length) await uploadSkills(toImport, skipped) - else sendUserToast('No skills imported.') - }} - onCanceled={() => { - pendingImport = undefined - pendingSkipped = [] - overwriteChoices = {} - }} -> -
    - {#if pendingNew.length} -
    - Add {pendingNew.length} new skill(s): - {pendingNew.map((s) => s.name).join(', ')} -
    - {/if} - {#if pendingConflicts.length} -
    - - {pendingConflicts.length} skill(s) already exist — choose which to overwrite: - -
    - {#each pendingConflicts as conflict (conflict.name)} -
    - {conflict.name} - -
    - {/each} -
    -
    - {/if} - {#if pendingSkipped.length} - {pendingSkipped.length} file(s) will be skipped. - {/if} -
    -
    - - { - const name = toDelete - toDelete = undefined - if (name) await deleteSkill(name) - }} - onCanceled={() => (toDelete = undefined)} -> - - Delete the skill {toDelete}? The AI chat will no longer be able to use it. - - - - { - confirmBatchDelete = false - await deleteSelected() - }} - onCanceled={() => (confirmBatchDelete = false)} -> - - Delete {selectedCount} selected skill(s)? The AI chat will no longer be able to use them. - - diff --git a/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte index eac0153993..fc9bddcf4e 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte @@ -52,6 +52,8 @@ let loadError = $state(undefined) let loading = $state(false) let busy = $state(false) + // pg_dump can take a while, so the snapshot gets its own flag to spin its button. + let generatingInitial = $state(false) // Only workspace admins and super admins can opt a data table in or out. const canManage = $derived(!!$userStore?.is_admin || !!$superadmin) @@ -280,6 +282,7 @@ }) if (!confirmed) return busy = true + generatingInitial = true try { await WorkspaceService.generateInitialDatatableMigration({ workspace, @@ -291,6 +294,7 @@ sendUserToast(`Failed to generate initial migration: ${e?.body ?? e?.message ?? e}`, true) } finally { busy = false + generatingInitial = false } } @@ -493,12 +497,13 @@
    {#if migrations.length === 0}
    - No migrations yet + {generatingInitial ? 'Snapshotting current schema…' : 'No migrations yet'} - {/if} - - {#if !$userStore?.operator && showCreateButtons} -
    - -
    - {/if} -
    -
    @@ -396,9 +360,8 @@
    {#if tab == 'workspace'} - + {/if}
    - diff --git a/lsp/Pipfile b/lsp/Pipfile index 22eb7440d2..cf1bb1ec17 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.799.0" +wmill = ">=1.800.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 38f43d1878..0350536df4 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.799.0 + version: 1.800.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 3cc5775a77..df980c46a0 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.799.0' + ModuleVersion = '1.800.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 2df87500db..00582eecd2 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.799.0" +version = "1.800.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 8029ae48c7..e51c8371dc 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -605,6 +605,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 ` - 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 @@ -636,6 +637,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 ` - 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) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 2c3b585c20..918b88cb21 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3671,6 +3671,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 \` - 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 @@ -3702,6 +3703,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 \` - 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) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index ebd0d7f4da..1f3c54ea26 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -610,6 +610,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 ` - 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 @@ -641,6 +642,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 ` - 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) diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 1f0f5e2030..122221df80 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.799.0", + "version": "1.800.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a73353f780..802d99056a 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.799.0", + "version": "1.800.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 6e752179bd..16cbd6c8ab 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.799.0 +1.800.1 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 678259b8ff..197da5a8a8 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.799.0", + "version": "1.800.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.799.0", + "version": "1.800.1", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 10a6f9918d..eb1eded0cc 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.799.0", + "version": "1.800.1", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",