mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-15 08:02:27 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ace03c17 | ||
|
|
2e020b2ccc | ||
|
|
4dbf873723 | ||
|
|
b5bd8245d8 | ||
|
|
de6192bec1 | ||
|
|
2a70ccc386 | ||
|
|
83cc5533ee | ||
|
|
42c5e7a3fc | ||
|
|
288318ac26 | ||
|
|
3d48ba7738 | ||
|
|
fada673bb4 | ||
|
|
24b95e9fe1 | ||
|
|
f5828780fd | ||
|
|
5e09c50171 | ||
|
|
8912e21d15 | ||
|
|
55bed4abcf | ||
|
|
e98df38ac4 | ||
|
|
c017f7f891 | ||
|
|
f1b5c43e02 | ||
|
|
250a05f544 | ||
|
|
043c2c05b7 | ||
|
|
ae088fd032 | ||
|
|
9e4cf139b1 | ||
|
|
cfb9f1dbc2 | ||
|
|
cbf54d4eb4 | ||
|
|
9793d01575 | ||
|
|
24446e8009 | ||
|
|
e90b2be8fa | ||
|
|
29c67ced97 | ||
|
|
6dfccd9d88 | ||
|
|
fc797a35fe | ||
|
|
11d0e65f3a | ||
|
|
984ea728d9 |
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.25"
|
||||
version: "0.11.24"
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
node-version: "20"
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.25"
|
||||
version: "0.11.24"
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [1.738.0](https://github.com/windmill-labs/windmill/compare/v1.737.0...v1.738.0) (2026-06-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add resource and infrastructure telemetry ([#9737](https://github.com/windmill-labs/windmill/issues/9737)) ([9793d01](https://github.com/windmill-labs/windmill/commit/9793d01575415963a89609a1baf2cd64f0d050cc))
|
||||
* render mermaid diagrams in chat code blocks ([#9738](https://github.com/windmill-labs/windmill/issues/9738)) ([cfb9f1d](https://github.com/windmill-labs/windmill/commit/cfb9f1dbc23110ecf8f91bb3c8c81fc6e35dc09b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ai-chat:** Fix incorrect editor edits from ai chat [#1](https://github.com/windmill-labs/windmill/issues/1) ([#9741](https://github.com/windmill-labs/windmill/issues/9741)) ([fc797a3](https://github.com/windmill-labs/windmill/commit/fc797a35fe7885630c81453df0fc94769e73873a))
|
||||
* allow object storage test for non-super-admins, harden on cloud ([#9739](https://github.com/windmill-labs/windmill/issues/9739)) ([24446e8](https://github.com/windmill-labs/windmill/commit/24446e80093ade349f7fbf65063d2d1cb5551c1e))
|
||||
* **frontend:** debounce external code→Monaco sync in Editor ([#9743](https://github.com/windmill-labs/windmill/issues/9743)) ([29c67ce](https://github.com/windmill-labs/windmill/commit/29c67ced97bf2919584986f9d9eceb4337c34ad9))
|
||||
* **frontend:** preserve editor content when closing instance settings drawer ([#9740](https://github.com/windmill-labs/windmill/issues/9740)) ([11d0e65](https://github.com/windmill-labs/windmill/commit/11d0e65f3af9a048bc1921bbdd3d676a07483a57))
|
||||
* pipeline annotation false-positives from body comments ([#9736](https://github.com/windmill-labs/windmill/issues/9736)) ([984ea72](https://github.com/windmill-labs/windmill/commit/984ea728d98649b66b1cae899bdab9af3176caa7))
|
||||
* preserve fork parent linkage on workspace id change ([#9716](https://github.com/windmill-labs/windmill/issues/9716)) ([cbf54d4](https://github.com/windmill-labs/windmill/commit/cbf54d4eb432638e27f67c4c8b879cbcc0291da3))
|
||||
* prevent variable push from corrupting is_secret variables ([#9705](https://github.com/windmill-labs/windmill/issues/9705)) ([ba4b368](https://github.com/windmill-labs/windmill/commit/ba4b368706e95e22f346a10e5fe145b0795ac3f6))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **monitor:** skip protected prefix in retention delete via cross-batch watermark (WIN-2088) ([#9744](https://github.com/windmill-labs/windmill/issues/9744)) ([e90b2be](https://github.com/windmill-labs/windmill/commit/e90b2be8fade1eb78cd685890291f5a4553a6a10))
|
||||
|
||||
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -233,7 +233,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
|
||||
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Create a draft Bun script at `f/evals/global/greet_user`.
|
||||
It should take a string `name` input and return `Hello, ${name}!`.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
validate:
|
||||
|
||||
@@ -212,6 +212,9 @@ describe("loadCases", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(caseEntry?.initialPath).toContain(
|
||||
"ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json"
|
||||
);
|
||||
expect(caseEntry?.toolExpect).toMatchObject({
|
||||
requiredToolsUsed: ["write_script"],
|
||||
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"is_admin": true,
|
||||
"folders": ["evals"],
|
||||
"folders_read": ["evals"]
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT SUM(pg_database_size(datname))::BIGINT AS \"v!\" FROM pg_database",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "v!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "074dd26f6427f4ff97e92c35163ad042144e656adcca56a4936a2eb196d3f48c"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT language AS \"language!: _\", COUNT(*)::BIGINT AS \"count!\"\n FROM script\n WHERE archived = false AND deleted = false AND kind = 'script'\n AND (auto_kind IS NULL OR auto_kind <> 'wac')\n GROUP BY language\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "language!: _",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "11813108dbf6b104eba968c3609c74ac5a589542d3e76b1e756a82fb19d49ee8"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*)::INT AS \"v!\" FROM pg_stat_activity",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "v!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3b439ae7af0fcbb9df8e19faf84abf590e5e94898954711d57a602e6fd8a2f84"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM script WHERE archived = false AND deleted = false AND auto_kind = 'wac'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3db1c61295c284725eef9e74a8aa2bc7822d263605a445f1f4a76e58e76a3e79"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a8cf5e87bb489fd172689e9a6f0f1075b878f9916145929b3cd3b1a53b777e"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"\n FROM workspace f\n JOIN workspace p ON p.id = f.parent_workspace_id\n WHERE f.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "deleted!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "42322020ff9cc7dd7ebafc1cb4122ba3d670cc36bdbc6451f29b8f22f8cff688"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_database_size(current_database())::BIGINT AS \"v!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "v!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "48a5df355a2bca557a3a541cf66c8e75790b7c3dd7845359019ff645a1f7c8bf"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT current_setting('max_connections')::INT AS \"v!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "v!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "49e1f5663eed128ed956c9a50bc73a85256c0a3e5a701cc13c944e66f6402617"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow WHERE archived = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "50490ff42fb1f2d78864d7b374d299bf8290c3b969b576cb185c8b5b0abb0265"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT current_setting('server_version_num')::INT AS \"v!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "v!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "56a98a07a2f6af4d694db05d57acbe0b55cc39d64f73a3c90c250ea670f9cdee"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT CASE WHEN (current_setting('is_superuser') = 'on'\n OR pg_has_role(current_user, 'pg_read_all_stats', 'USAGE'))\n AND NOT EXISTS (SELECT 1 FROM pg_prepared_xacts)\n THEN (SELECT min(xact_start) FROM pg_stat_activity WHERE xact_start IS NOT NULL)\n ELSE NULL END AS \"x\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "x",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5d84f1ede2fbe09923a36d80d7c01699bf9968a02675f9f598c97a19c6df089b"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n COUNT(*) FILTER (WHERE av.raw_app = false)::BIGINT AS \"low_code!\",\n COUNT(*) FILTER (WHERE av.raw_app = true)::BIGINT AS \"raw!\"\n FROM app a\n JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "low_code!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "raw!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "60dc0f1fa17bd2946ba7ddf0c41fe58b8a53d071cdc83eca3092194b4a9c9174"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM workspace WHERE deleted = false AND id NOT LIKE 'wm-fork%'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9170a350e1da0b33a421a119d4a5b86575c1be761de158ad664670e981524cbf"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($3::timestamptz IS NULL OR completed_at >= $3)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "completed_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END\n FROM workspace WHERE id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a54efa4a7466e61fd54d8fe293cb775225dcb430026cebe15ba4994ac636514d"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n EXISTS(SELECT 1 FROM pg_proc WHERE proname = 'aurora_version') AS \"aurora!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'rds_superuser') AS \"rds!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'cloudsqlsuperuser') AS \"cloudsql!\",\n EXISTS(SELECT 1 FROM pg_roles WHERE rolname IN ('azure_pg_admin', 'azuresu')) AS \"azure!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "aurora!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "rds!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "cloudsql!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "azure!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a90e3a1d7c7c0dfb422f44b0ed599f681f5630c024ae9a437a301f149636b0db"
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "completed_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"UuidArray",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75"
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN usr.email IS NULL THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441"
|
||||
}
|
||||
Generated
+78
-78
@@ -13735,7 +13735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -13817,7 +13817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -13850,7 +13850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -13863,7 +13863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14001,7 +14001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14024,7 +14024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14037,7 +14037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14063,7 +14063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -14073,7 +14073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14090,7 +14090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -14112,7 +14112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14135,7 +14135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14151,7 +14151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14172,7 +14172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14193,7 +14193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14207,7 +14207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14242,7 +14242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14267,7 +14267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
@@ -14285,7 +14285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14307,7 +14307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14327,7 +14327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14364,7 +14364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14392,7 +14392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -14404,7 +14404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -14429,7 +14429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14443,7 +14443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14476,7 +14476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -14490,7 +14490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14509,7 +14509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -14611,7 +14611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -14630,7 +14630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14645,7 +14645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -14669,7 +14669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14686,7 +14686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14702,7 +14702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14723,7 +14723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14754,7 +14754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -14779,7 +14779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14813,7 +14813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14831,7 +14831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14840,7 +14840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14852,7 +14852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14864,7 +14864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14876,7 +14876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14888,7 +14888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14900,7 +14900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14911,7 +14911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14922,7 +14922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14934,7 +14934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -14945,7 +14945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -14967,7 +14967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14979,7 +14979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14993,7 +14993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -15010,7 +15010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15023,7 +15023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15035,7 +15035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15053,7 +15053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -15069,7 +15069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15085,7 +15085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15096,7 +15096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15134,7 +15134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -15173,7 +15173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -15184,7 +15184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15218,7 +15218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15242,7 +15242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15275,7 +15275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15308,7 +15308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15328,7 +15328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15362,7 +15362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15398,7 +15398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15421,7 +15421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15445,7 +15445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15469,7 +15469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15504,7 +15504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15532,7 +15532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15557,7 +15557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
@@ -15576,7 +15576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -15686,7 +15686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
|
||||
ed89574be9117cda5e2d7d9de02cb5db066e93e3
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- No-op: this migration only re-pins the function's search_path. Reverting would
|
||||
-- mean restoring the hardcoded `SET search_path = public`, which is the very bug
|
||||
-- this repairs, so there is nothing to undo.
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Repair instances that already applied the folder-labels migrations while the
|
||||
-- function hardcoded `SET search_path = public`. On a non-public schema (PG_SCHEMA)
|
||||
-- the function was pinned to `public`, so at runtime it read the wrong `folder`
|
||||
-- table (or a stray public.folder) instead of the workspace's real one.
|
||||
--
|
||||
-- `FROM CURRENT` snapshots the migration connection's search_path (the actual
|
||||
-- Windmill schema) into the function, keeping the SECURITY DEFINER injection
|
||||
-- hardening. On public-schema installs this re-pins to `public`, i.e. a no-op.
|
||||
-- Idempotent: redefining with the same body is harmless on already-correct installs.
|
||||
CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[]
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path FROM CURRENT AS $$
|
||||
SELECT (
|
||||
SELECT array_agg(l ORDER BY first_ord)
|
||||
FROM (
|
||||
SELECT u.l, min(u.ord) AS first_ord
|
||||
FROM unnest(f.labels) WITH ORDINALITY AS u(l, ord)
|
||||
GROUP BY u.l
|
||||
) deduped
|
||||
)
|
||||
FROM folder f
|
||||
WHERE f.workspace_id = w_id AND item_path LIKE 'f/%' AND f.name = split_part(item_path, '/', 2)
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Irreversible data backfill: once a raw app's draft is retyped to 'raw_app' it
|
||||
-- is indistinguishable from one saved as 'raw_app' by the per-kind code, so the
|
||||
-- original typ='app' state cannot be reconstructed. No-op on revert.
|
||||
@@ -0,0 +1,35 @@
|
||||
-- The pre-per-user `DRAFT_TYPE` enum had only ('script','flow','app'): a raw
|
||||
-- app's draft was therefore stored as typ='app'. The new model splits app vs
|
||||
-- raw_app into distinct draft kinds chosen from the deployed app's `raw_app`
|
||||
-- flag, so a raw app's pre-migration draft is invisible to the per-kind lookups
|
||||
-- (editor overlay, migrate-legacy, get-for-user), which all query typ='raw_app'.
|
||||
-- Realign every such draft (any owner, including the legacy NULL-email row) to
|
||||
-- 'raw_app' when the deployed app at that path is a raw app.
|
||||
|
||||
-- Drop, don't retype, a stale 'app' row when a 'raw_app' draft already exists
|
||||
-- for the same owner (the newer 'raw_app' row, saved with the per-kind code, is
|
||||
-- authoritative) — retyping would collide on the draft_pkey_with_user /
|
||||
-- draft_pkey_legacy partial unique indexes over (workspace_id, path, typ, email).
|
||||
DELETE FROM draft d
|
||||
USING app a
|
||||
JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
|
||||
WHERE d.typ = 'app'
|
||||
AND a.workspace_id = d.workspace_id
|
||||
AND a.path = d.path
|
||||
AND av.raw_app IS TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM draft d2
|
||||
WHERE d2.workspace_id = d.workspace_id
|
||||
AND d2.path = d.path
|
||||
AND d2.typ = 'raw_app'
|
||||
AND d2.email IS NOT DISTINCT FROM d.email
|
||||
);
|
||||
|
||||
UPDATE draft d
|
||||
SET typ = 'raw_app'
|
||||
FROM app a
|
||||
JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
|
||||
WHERE d.typ = 'app'
|
||||
AND a.workspace_id = d.workspace_id
|
||||
AND a.path = d.path
|
||||
AND av.raw_app IS TRUE;
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6272,7 +6272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6293,7 +6293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6305,7 +6305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6317,7 +6317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6329,7 +6329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6341,7 +6341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6353,7 +6353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6364,7 +6364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6375,7 +6375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6387,7 +6387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6398,7 +6398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6420,7 +6420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6432,7 +6432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6538,7 +6538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6570,7 +6570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6581,7 +6581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.737.0"
|
||||
version = "1.738.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -487,9 +487,13 @@ fn parse_kv_opts(s: &str) -> BTreeMap<String, String> {
|
||||
out
|
||||
}
|
||||
|
||||
// Scan raw source for pipeline annotations. Language-agnostic: any line
|
||||
// whose first non-whitespace tokens are a comment prefix (`//`, `#`, or
|
||||
// `--`) followed by one of the recognized keywords:
|
||||
// Scan the leading comment header for pipeline annotations. Only the
|
||||
// contiguous block of comment lines at the top of the file is considered
|
||||
// (blank lines tolerated, scan stops at the first line of actual code) so
|
||||
// that ordinary comments in the body can't false-positive as annotations.
|
||||
// Language-agnostic: any header line whose first non-whitespace tokens are
|
||||
// a comment prefix (`//`, `#`, or `--`) followed by one of the recognized
|
||||
// keywords:
|
||||
// - `pipeline` → opt-in marker (must be alone on the line)
|
||||
// - `on <trigger-spec>` → asset / native trigger edge (including
|
||||
// the marker-only `on schedule` form)
|
||||
@@ -527,6 +531,9 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
|
||||
|
||||
for raw_line in code.lines() {
|
||||
let line = raw_line.trim_start();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rest = if let Some(r) = line.strip_prefix("//") {
|
||||
r
|
||||
} else if let Some(r) = line.strip_prefix("--") {
|
||||
@@ -534,7 +541,11 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
|
||||
} else if let Some(r) = line.strip_prefix('#') {
|
||||
r
|
||||
} else {
|
||||
continue;
|
||||
// Annotations live in the leading comment header. Stop at the first
|
||||
// line of actual code so comments inside the body (e.g. a regular
|
||||
// `# tag ...` prose comment) can't false-positive as annotations.
|
||||
// Mirrors BashAnnotations::sandbox_image / ssh_target.
|
||||
break;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
|
||||
@@ -584,7 +595,14 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
|
||||
|
||||
if let Some(after_kw) = consume_keyword(rest, "tag") {
|
||||
let name = after_kw.trim();
|
||||
if !name.is_empty() && out.tag.is_none() {
|
||||
// Worker tags are single-word identifiers (e.g. `heavy`, `gpu`).
|
||||
// A value with whitespace or beyond the `script.tag` column width
|
||||
// is almost certainly a regular comment starting with "# tag ...".
|
||||
if !name.is_empty()
|
||||
&& !name.contains(char::is_whitespace)
|
||||
&& name.len() <= 50
|
||||
&& out.tag.is_none()
|
||||
{
|
||||
out.tag = Some(name.to_string());
|
||||
}
|
||||
continue;
|
||||
@@ -1074,6 +1092,56 @@ mod pipeline_annotation_tests {
|
||||
assert!(out.tag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_with_whitespace_is_skipped() {
|
||||
// A regular English comment starting with "# tag " must not be
|
||||
// mistaken for a worker-tag annotation (worker tags are single words).
|
||||
let out =
|
||||
parse_pipeline_annotations("# tag this function so we remember to refactor it later");
|
||||
assert!(out.tag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_too_long_is_skipped() {
|
||||
let long = "x".repeat(51);
|
||||
let out = parse_pipeline_annotations(&format!("// tag {long}"));
|
||||
assert!(out.tag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_in_body_are_ignored() {
|
||||
// Only the leading comment header is scanned. A regular `# tag ...`
|
||||
// prose comment buried in the body — the WIN-2090 false-positive that
|
||||
// crashed the `script.tag` INSERT — must not be treated as an
|
||||
// annotation once real code has started.
|
||||
let code = concat!(
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def main():\n",
|
||||
" # tag each row with its source so downstream steps can filter\n",
|
||||
" # on s3://should/not/parse\n",
|
||||
" return pd.DataFrame()\n",
|
||||
);
|
||||
let out = parse_pipeline_annotations(code);
|
||||
assert!(out.tag.is_none());
|
||||
assert!(out.triggers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_allows_blank_lines_before_code() {
|
||||
// Blank lines (e.g. after a shebang) don't end the header; the first
|
||||
// line of real code does.
|
||||
let code = concat!(
|
||||
"#!/usr/bin/env python\n",
|
||||
"\n",
|
||||
"# tag heavy\n",
|
||||
"import os\n",
|
||||
"# tag light\n",
|
||||
);
|
||||
let out = parse_pipeline_annotations(code);
|
||||
assert_eq!(out.tag.as_deref(), Some("heavy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_count_only() {
|
||||
let out = parse_pipeline_annotations("// retry 3");
|
||||
|
||||
+108
-33
@@ -1324,6 +1324,9 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
let cleanup_start = Instant::now();
|
||||
let mut total_deleted = 0u64;
|
||||
let mut batch_num = 0i32;
|
||||
// Watermark carried across batches so each one resumes after the rows the previous batch
|
||||
// already processed instead of re-scanning the (potentially undeletable) oldest prefix.
|
||||
let mut completed_at_floor: Option<DateTime<Utc>> = None;
|
||||
|
||||
// Process batches until no more expired jobs or max batches reached
|
||||
loop {
|
||||
@@ -1336,14 +1339,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
}
|
||||
|
||||
// Each batch runs in its own transaction to avoid long-running locks
|
||||
let batch_result = delete_expired_jobs_batch(db, job_retention_secs, batch_size).await;
|
||||
let batch_result =
|
||||
delete_expired_jobs_batch(db, job_retention_secs, batch_size, completed_at_floor)
|
||||
.await;
|
||||
|
||||
match batch_result {
|
||||
Ok(deleted_count) => {
|
||||
Ok((deleted_count, max_completed_at)) => {
|
||||
if deleted_count == 0 {
|
||||
// No more expired jobs to delete
|
||||
break;
|
||||
}
|
||||
completed_at_floor = max_completed_at.or(completed_at_floor);
|
||||
total_deleted += deleted_count as u64;
|
||||
batch_num += 1;
|
||||
}
|
||||
@@ -1510,12 +1516,20 @@ pub async fn check_expiring_tokens(db: &DB) {
|
||||
|
||||
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
|
||||
/// Uses a single transaction per batch to minimize lock duration.
|
||||
/// Returns the number of jobs deleted in this batch.
|
||||
///
|
||||
/// `completed_at_floor` is the watermark from the previous batch in the same cleanup run (the
|
||||
/// max `completed_at` it deleted); pass `None` for the first batch. It is re-applied as
|
||||
/// `completed_at >= floor` so the scan resumes past the rows already processed instead of
|
||||
/// re-walking them (see the inline comment on the DELETE for why this matters).
|
||||
///
|
||||
/// Returns `(jobs deleted in this batch, max completed_at deleted)`. The caller feeds the
|
||||
/// returned watermark back in as `completed_at_floor` for the next batch.
|
||||
async fn delete_expired_jobs_batch(
|
||||
db: &DB,
|
||||
job_retention_secs: i64,
|
||||
batch_size: i64,
|
||||
) -> error::Result<usize> {
|
||||
completed_at_floor: Option<DateTime<Utc>>,
|
||||
) -> error::Result<(usize, Option<DateTime<Utc>>)> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Fetch active ROOT job IDs that started before the retention period. We only care about
|
||||
@@ -1531,34 +1545,70 @@ async fn delete_expired_jobs_batch(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
|
||||
// ORDER BY completed_at ensures we delete oldest jobs first.
|
||||
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
|
||||
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
|
||||
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
|
||||
// membership per candidate instead of a per-row linear array scan (which
|
||||
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
|
||||
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
|
||||
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
)
|
||||
RETURNING id",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
&active_root_job_ids
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
// `completed_at_floor` is a watermark carried across batches within a cleanup run: it is the
|
||||
// max(completed_at) deleted by the previous batch. Re-applying it as `completed_at >= floor`
|
||||
// lets each batch resume after the rows the previous batch already processed instead of
|
||||
// re-scanning them. This matters when the oldest rows are undeletable (children of a
|
||||
// still-active root flow): without the floor the `ORDER BY completed_at ASC` scan walks that
|
||||
// same protected prefix on every batch, turning a cleanup run quadratic in prefix size.
|
||||
// Floor only ever skips rows the current run already deleted, was protecting, or skip-locked —
|
||||
// all correctly deferred to the next run, identical to the unbounded scan's semantics.
|
||||
//
|
||||
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas; ORDER BY completed_at
|
||||
// deletes oldest jobs first.
|
||||
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
|
||||
// Common case: no old root flow is still running, so nothing is protected and the
|
||||
// v2_job join (a PK lookup per candidate) is pure overhead — skip it entirely.
|
||||
let rows = sqlx::query!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT id FROM v2_job_completed
|
||||
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND ($3::timestamptz IS NULL OR completed_at >= $3)
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, completed_at",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
completed_at_floor,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let max = rows.iter().map(|r| r.completed_at).max();
|
||||
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
|
||||
} else {
|
||||
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`:
|
||||
// the subquery form lets the planner build a one-time hashed SubPlan and apply it as a
|
||||
// filter on the ordered index scan, giving O(1) membership per candidate instead of a
|
||||
// per-row linear array scan (which degrades sharply when many root jobs are active). The
|
||||
// `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
|
||||
let rows = sqlx::query!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
)
|
||||
RETURNING id, completed_at",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
&active_root_job_ids,
|
||||
completed_at_floor,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let max = rows.iter().map(|r| r.completed_at).max();
|
||||
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
|
||||
};
|
||||
|
||||
let deleted_count = deleted_jobs.len();
|
||||
|
||||
@@ -1618,7 +1668,7 @@ async fn delete_expired_jobs_batch(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(deleted_count)
|
||||
Ok((deleted_count, max_completed_at))
|
||||
}
|
||||
|
||||
async fn delete_log_files_from_disk_and_store(
|
||||
@@ -4379,9 +4429,32 @@ RETURNING key,job_id
|
||||
// Per-statement cap keeps each delete short and lock-light; the per-cycle batch
|
||||
// cap bounds total work per monitor iteration so monitor_db stays responsive.
|
||||
// A large backlog drains across several iterations rather than one long delete.
|
||||
//
|
||||
// These sweeps anti-join the whole table to find orphans, so their cost tracks the heap's
|
||||
// physical size. job_perms / job_result_stream_v2 are high-churn (one row per job, deleted
|
||||
// here), so their bloat — not the query shape — is what makes the sweep slow. These sweeps run
|
||||
// every monitor cycle, but the bulk vacuuming_tables() runs only ~hourly, so dead tuples pile
|
||||
// up between bulk vacuums; each sweep VACUUMs its own table right after deleting (see below) to
|
||||
// keep the heap near the live working set. The outer `ctid IN (SELECT ... LIMIT)` is
|
||||
// deliberate: a `job_id IN (...)` rewrite adds a second scan/probe for the delete and
|
||||
// benchmarks slower, so don't "simplify" it.
|
||||
const ORPHAN_CLEANUP_BATCH_SIZE: u64 = 100_000;
|
||||
const ORPHAN_CLEANUP_MAX_BATCHES: usize = 10;
|
||||
|
||||
// Reclaim the dead tuples a sweep just created so the next sweep's anti-join scans a lean heap
|
||||
// instead of a bloated one. Plain VACUUM (not FULL) only takes SHARE UPDATE EXCLUSIVE, so
|
||||
// concurrent reads/writes (every job create touches job_perms) keep running, and the visibility
|
||||
// map lets it skip unchanged pages so repeated runs are cheap. SKIP_LOCKED means HA replicas
|
||||
// don't pile up: one vacuums, the rest skip rather than queue behind it.
|
||||
async fn vacuum_after_sweep(db: &DB, table: &str) {
|
||||
if let Err(e) = sqlx::query(&format!("VACUUM (SKIP_LOCKED) {table}"))
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Error vacuuming {table} after orphan cleanup: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
|
||||
let mut total: u64 = 0;
|
||||
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
|
||||
@@ -4404,6 +4477,7 @@ async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
|
||||
|
||||
if total > 0 {
|
||||
tracing::info!("Cleaned up {total} orphaned job_perms rows");
|
||||
vacuum_after_sweep(db, "job_perms").await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4435,6 +4509,7 @@ async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
|
||||
|
||||
if total > 0 {
|
||||
tracing::info!("Cleaned up {total} orphaned job_result_stream_v2 rows");
|
||||
vacuum_after_sweep(db, "job_result_stream_v2").await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! A WM_TOKEN (job JWT) running as a superadmin must not be able to perform
|
||||
//! global user/token management — promotion, password reset, user creation,
|
||||
//! token creation/impersonation, offboarding, or exporting the user table.
|
||||
//! A non-admin `wm_deployers` member can mint
|
||||
//! such a token implicitly via an app/flow `on_behalf_of`, so trusting it would
|
||||
//! let them establish *persistent* superadmin. A real superadmin who needs this
|
||||
//! from a script must use a dedicated superadmin API token (which only a real
|
||||
//! superadmin can create), not `$WM_TOKEN`.
|
||||
//!
|
||||
//! The fixture provides `test@windmill.dev` (instance superadmin, token
|
||||
//! `SECRET_TOKEN`) and `test2@windmill.dev` (non-superadmin, `SECRET_TOKEN_2`).
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::auth::create_jwt_token;
|
||||
use windmill_common::db::Authed;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Mint a WM_TOKEN: an internally-signed job JWT (note the `job_id` claim) for
|
||||
/// `email`, exactly as a running app/flow job is issued.
|
||||
async fn wm_token(email: &str, is_admin: bool) -> String {
|
||||
let authed = Authed {
|
||||
email: email.to_string(),
|
||||
username: "runner".to_string(),
|
||||
is_admin,
|
||||
is_operator: false,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
create_jwt_token(
|
||||
authed,
|
||||
"test-workspace",
|
||||
3600,
|
||||
Some(uuid::Uuid::new_v4()),
|
||||
Some("app".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("mint wm_token")
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
||||
async fn test_wm_token_cannot_manage_superadmin_users(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
// The server decodes WM_TOKENs with the same in-process JWT secret, so
|
||||
// setting it once lets us mint a valid one below.
|
||||
set_jwt_secret().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/users");
|
||||
|
||||
// A superadmin-capable WM_TOKEN — the exact thing a deployer obtains via an
|
||||
// app on_behalf_of pointed at a superadmin.
|
||||
let sa_wm = wm_token("test@windmill.dev", true).await;
|
||||
|
||||
// 1. Cannot mint a (superadmin) token.
|
||||
let resp = authed(client().post(format!("{base}/tokens/create")), &sa_wm)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not create tokens: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 2. Cannot impersonate (mint a token as another user).
|
||||
let resp = authed(client().post(format!("{base}/tokens/impersonate")), &sa_wm)
|
||||
.json(&json!({ "impersonate_email": "test2@windmill.dev" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not impersonate: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 3. Cannot promote a user to superadmin.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/update/test2@windmill.dev")),
|
||||
&sa_wm,
|
||||
)
|
||||
.json(&json!({ "is_super_admin": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not promote users: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 4. Cannot reset its own (the superadmin's) password.
|
||||
let resp = authed(client().post(format!("{base}/setpassword")), &sa_wm)
|
||||
.json(&json!({ "password": "hunter2" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not reset passwords: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 4b. Cannot delete a user.
|
||||
let resp = authed(
|
||||
client().delete(format!("{base}/delete/test2@windmill.dev")),
|
||||
&sa_wm,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not delete users: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 4c. Cannot change a user's login type.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/set_login_type/test2@windmill.dev")),
|
||||
&sa_wm,
|
||||
)
|
||||
.json(&json!({ "login_type": "password" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not change login type: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 4d. Cannot offboard a global user (deletes user, tokens, password, invites,
|
||||
// instance-group membership and reassigns their assets).
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/offboard/test2@windmill.dev")),
|
||||
&sa_wm,
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not offboard users: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 4e. Cannot export the global user table (leaks every user's password_hash).
|
||||
let resp = authed(client().get(format!("{base}/export")), &sa_wm)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"superadmin WM_TOKEN must not export global users: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 5. Escape hatch / no false positive: a real superadmin API token
|
||||
// (SECRET_TOKEN, no job_id) can still create tokens.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/tokens/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "label": "ci" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"a real superadmin token must still create tokens: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 6. No collateral: a non-superadmin WM_TOKEN can still create its own
|
||||
// token — the guard only fires for superadmin-capable job tokens.
|
||||
let user_wm = wm_token("test2@windmill.dev", false).await;
|
||||
let resp = authed(client().post(format!("{base}/tokens/create")), &user_wm)
|
||||
.json(&json!({ "label": "from-script" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"non-superadmin WM_TOKEN must still create its own token: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -205,6 +205,33 @@ pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forbid sensitive global user/token management when authenticated as a
|
||||
/// superadmin *via a job token* (`WM_TOKEN`).
|
||||
///
|
||||
/// A `WM_TOKEN`'s identity is derived from an app/flow `on_behalf_of`, which a
|
||||
/// non-admin `wm_deployers` member can point at a superadmin. Trusting it for
|
||||
/// these operations would let them establish *persistent* superadmin (promote a
|
||||
/// user, reset a superadmin's password, mint a superadmin token, ...). `job_id`
|
||||
/// is set only for `WM_TOKEN`s; regular session/API tokens have it `None`, so a
|
||||
/// real superadmin who needs this from a script uses a dedicated superadmin API
|
||||
/// token (which only a real superadmin can create) instead of `$WM_TOKEN`.
|
||||
pub async fn forbid_superadmin_job_token(
|
||||
db: &DB,
|
||||
email: &str,
|
||||
job_id: Option<uuid::Uuid>,
|
||||
) -> error::Result<()> {
|
||||
if job_id.is_some() && is_super_admin_email(db, email).await? {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This operation cannot be performed with a job token ($WM_TOKEN) that runs as a \
|
||||
superadmin. If a script genuinely needs to do this, create a dedicated superadmin \
|
||||
token from the User settings drawer (the 'Tokens' section), store it as a secret, \
|
||||
and use that token explicitly instead of $WM_TOKEN."
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
|
||||
where
|
||||
F: FnOnce() -> String,
|
||||
|
||||
@@ -646,6 +646,20 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
assert_eq!(resp.json::<bool>().await?, true);
|
||||
|
||||
// Regression: changing a fork's workspace id must preserve its parent
|
||||
// linkage. Dropping it leaves a wm-fork- workspace with no parent — a
|
||||
// "fork of nothing" that can no longer be compared or merged.
|
||||
let parent: Option<String> =
|
||||
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
|
||||
.bind("wm-fork-renamed")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
parent.as_deref(),
|
||||
Some("new-test-ws"),
|
||||
"renamed fork must keep its parent_workspace_id"
|
||||
);
|
||||
|
||||
// --- create_fork over an existing (active) workspace id: clear 400, not a raw SQL 500 ---
|
||||
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
|
||||
.json(&json!({
|
||||
|
||||
@@ -256,52 +256,300 @@ pub async fn test_s3_bucket(
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
|
||||
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
|
||||
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
|
||||
// object store usually lives on the local/private network and all authenticated users are
|
||||
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
|
||||
let is_super_admin = is_super_admin_email(&db, &authed.email).await?;
|
||||
let restrict = !is_super_admin && *CLOUD_HOSTED;
|
||||
if restrict {
|
||||
validate_object_storage_test(&test_s3_bucket).await?;
|
||||
}
|
||||
|
||||
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
|
||||
.await?
|
||||
.store;
|
||||
|
||||
let mut list = client.list(Some(
|
||||
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
|
||||
));
|
||||
let first_file = list.next().await;
|
||||
if first_file.is_some() {
|
||||
if let Err(e) = first_file.as_ref().unwrap() {
|
||||
tracing::error!("error listing bucket: {e:#}");
|
||||
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
|
||||
let run = async {
|
||||
let mut list = client.list(Some(
|
||||
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
|
||||
));
|
||||
let first_file = list.next().await;
|
||||
if first_file.is_some() {
|
||||
if let Err(e) = first_file.as_ref().unwrap() {
|
||||
tracing::error!("error listing bucket: {e:#}");
|
||||
error::Error::internal_err(format!("Failed to list files in blob storage: {e:#}"));
|
||||
}
|
||||
tracing::info!("Listed files: {:?}", first_file.unwrap());
|
||||
} else {
|
||||
tracing::info!("No files in blob storage");
|
||||
}
|
||||
tracing::info!("Listed files: {:?}", first_file.unwrap());
|
||||
|
||||
let path = windmill_object_store::object_store_reexports::Path::from(format!(
|
||||
"/test-s3-bucket-{uuid}",
|
||||
uuid = uuid::Uuid::new_v4()
|
||||
));
|
||||
tracing::info!("Testing blob storage at path: {path}");
|
||||
client
|
||||
.put(
|
||||
&path,
|
||||
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
|
||||
let content = client
|
||||
.get(&path)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
if content != Bytes::from_static(b"hello") {
|
||||
return Err(error::Error::internal_err(
|
||||
"Failed to read back from blob storage".to_string(),
|
||||
));
|
||||
}
|
||||
client.delete(&path).await.map_err(to_anyhow)?;
|
||||
Ok::<String, error::Error>("Tested blob storage successfully".to_string())
|
||||
};
|
||||
|
||||
if restrict {
|
||||
// The object-store client is built with timeouts disabled, so a malicious endpoint could
|
||||
// otherwise hold the API server connection open indefinitely.
|
||||
tokio::time::timeout(Duration::from_secs(15), run)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error::Error::internal_err("Object storage connectivity test timed out".to_string())
|
||||
})?
|
||||
} else {
|
||||
tracing::info!("No files in blob storage");
|
||||
run.await
|
||||
}
|
||||
}
|
||||
|
||||
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
|
||||
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
|
||||
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
|
||||
// requests with the instance role (credential exfiltration), or reading/writing the server's local
|
||||
// disk (filesystem object store).
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Result<()> {
|
||||
fn non_empty(opt: &Option<String>) -> bool {
|
||||
opt.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
let path = windmill_object_store::object_store_reexports::Path::from(format!(
|
||||
"/test-s3-bucket-{uuid}",
|
||||
uuid = uuid::Uuid::new_v4()
|
||||
));
|
||||
tracing::info!("Testing blob storage at path: {path}");
|
||||
client
|
||||
.put(
|
||||
&path,
|
||||
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
|
||||
)
|
||||
// Reject backends that rely on the server's identity or local filesystem, require explicit
|
||||
// credentials for the rest (so the server never falls back to its own ambient credentials), and
|
||||
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
|
||||
// — mirroring build_*_from_settings: the region/account-derived default and the virtual-hosted
|
||||
// bucket prefix — rather than only validating a caller-supplied `endpoint`, so caller-controlled
|
||||
// `region`/`account_name`/`bucket` cannot smuggle an internal host past the check (e.g. an empty
|
||||
// endpoint with region = "@169.254.169.254/" otherwise resolves to the cloud metadata service).
|
||||
let effective_endpoint: Option<String> = match settings {
|
||||
ObjectSettings::Filesystem(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing a local filesystem object store requires a super admin".to_string(),
|
||||
));
|
||||
}
|
||||
ObjectSettings::AwsOidc(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing OIDC-based object storage requires a super admin".to_string(),
|
||||
));
|
||||
}
|
||||
ObjectSettings::S3(s3) => {
|
||||
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing S3 storage without explicit credentials requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let region = s3
|
||||
.region
|
||||
.clone()
|
||||
.filter(|r| !r.is_empty())
|
||||
.or_else(|| std::env::var("AWS_REGION").ok().filter(|r| !r.is_empty()))
|
||||
.unwrap_or_else(|| "us-east-1".to_string());
|
||||
let raw_endpoint = s3
|
||||
.endpoint
|
||||
.clone()
|
||||
.filter(|e| !e.is_empty())
|
||||
.or_else(|| std::env::var("S3_ENDPOINT").ok().filter(|e| !e.is_empty()))
|
||||
.unwrap_or_else(|| format!("s3.{region}.amazonaws.com"));
|
||||
Some(windmill_object_store::render_endpoint(
|
||||
raw_endpoint,
|
||||
!s3.allow_http.unwrap_or(true),
|
||||
s3.port,
|
||||
s3.path_style,
|
||||
s3.bucket.clone().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
ObjectSettings::Azure(azure) => {
|
||||
if !non_empty(&azure.access_key) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing Azure storage without an explicit access key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Some(
|
||||
azure
|
||||
.endpoint
|
||||
.clone()
|
||||
.filter(|e| !e.is_empty())
|
||||
.unwrap_or_else(|| format!("{}.blob.core.windows.net", azure.account_name)),
|
||||
)
|
||||
}
|
||||
ObjectSettings::Gcs(gcs) => {
|
||||
if gcs.service_account_key.is_empty() {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing GCS storage without a service account key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
|
||||
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
|
||||
// Validate every http(s) URL embedded in the key. When none override it, the host stays
|
||||
// the public storage.googleapis.com, so no further check is needed.
|
||||
if let Ok(serde_json::Value::Object(map)) =
|
||||
serde_json::from_str::<serde_json::Value>(&gcs.service_account_key)
|
||||
{
|
||||
for value in map.values() {
|
||||
if let Some(url) = value.as_str() {
|
||||
// Match how the URL parser reads the value: leading whitespace/control is
|
||||
// ignored and the scheme is case-insensitive.
|
||||
let url =
|
||||
url.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
|
||||
if strip_http_scheme(url).is_some() {
|
||||
validate_public_endpoint(url).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Block non-public network targets (internal services, cloud metadata, loopback, ...).
|
||||
if let Some(endpoint) = effective_endpoint {
|
||||
validate_public_endpoint(&endpoint).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
|
||||
let host = extract_host(endpoint).ok_or_else(|| {
|
||||
error::Error::BadRequest(format!("Invalid object storage endpoint: {endpoint}"))
|
||||
})?;
|
||||
|
||||
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host.as_str(), 443u16))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
|
||||
let content = client
|
||||
.get(&path)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
if content != Bytes::from_static(b"hello") {
|
||||
return Err(error::Error::internal_err(
|
||||
"Failed to read back from blob storage".to_string(),
|
||||
));
|
||||
.map_err(|e| {
|
||||
error::Error::BadRequest(format!(
|
||||
"Could not resolve object storage endpoint '{host}': {e}"
|
||||
))
|
||||
})?
|
||||
.collect();
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Could not resolve object storage endpoint '{host}'"
|
||||
)));
|
||||
}
|
||||
|
||||
// Reject if any resolved address is non-public, which also defeats the simplest DNS-rebinding
|
||||
// attempts (a name resolving to both a public and a private address).
|
||||
for addr in addrs {
|
||||
if is_forbidden_ip(addr.ip()) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Strip a leading `http://`/`https://` scheme case-insensitively (URL schemes are
|
||||
// case-insensitive), returning the remainder when one was present.
|
||||
#[cfg(feature = "parquet")]
|
||||
fn strip_http_scheme(s: &str) -> Option<&str> {
|
||||
for scheme in ["https://", "http://"] {
|
||||
let b = scheme.as_bytes();
|
||||
if s.len() >= b.len() && s.as_bytes()[..b.len()].eq_ignore_ascii_case(b) {
|
||||
return Some(&s[b.len()..]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
fn extract_host(endpoint: &str) -> Option<String> {
|
||||
let mut s = endpoint.trim();
|
||||
if let Some(rest) = strip_http_scheme(s) {
|
||||
s = rest;
|
||||
}
|
||||
s = s.split(['/', '?', '#', '\\']).next().unwrap_or(s);
|
||||
if let Some((_, rest)) = s.rsplit_once('@') {
|
||||
s = rest;
|
||||
}
|
||||
let host = if let Some(rest) = s.strip_prefix('[') {
|
||||
// IPv6 literal, e.g. [::1]:9000
|
||||
rest.split(']').next().unwrap_or(rest)
|
||||
} else {
|
||||
// host or host:port
|
||||
s.split(':').next().unwrap_or(s)
|
||||
}
|
||||
.trim();
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
fn is_forbidden_ip(ip: std::net::IpAddr) -> bool {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local() // 169.254.0.0/16, incl. the cloud metadata endpoint
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| v4.is_multicast()
|
||||
|| v4.octets()[0] == 0 // 0.0.0.0/8
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
// Any IPv4 embedded in an IPv6 address (IPv4-mapped ::ffff:0:0/96, IPv4-compatible
|
||||
// ::/96, or NAT64 64:ff9b::/96) is re-checked against the IPv4 rules, so e.g.
|
||||
// 64:ff9b::169.254.169.254 cannot route to the metadata endpoint in a NAT64 network.
|
||||
let seg = v6.segments();
|
||||
let is_v4_compatible = seg[0..6] == [0, 0, 0, 0, 0, 0];
|
||||
let is_nat64 = seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0];
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_forbidden_ip(IpAddr::V4(v4));
|
||||
}
|
||||
if is_v4_compatible || is_nat64 {
|
||||
let embedded = Ipv4Addr::new(
|
||||
(seg[6] >> 8) as u8,
|
||||
(seg[6] & 0xff) as u8,
|
||||
(seg[7] >> 8) as u8,
|
||||
(seg[7] & 0xff) as u8,
|
||||
);
|
||||
if is_forbidden_ip(IpAddr::V4(embedded)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
|| (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
|
||||
|| (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
|
||||
}
|
||||
}
|
||||
client.delete(&path).await.map_err(to_anyhow)?;
|
||||
Ok("Tested blob storage successfully".to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -1861,3 +2109,111 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "parquet"))]
|
||||
mod object_storage_test_hardening {
|
||||
use super::{extract_host, is_forbidden_ip, validate_object_storage_test};
|
||||
use std::net::IpAddr;
|
||||
use windmill_object_store::ObjectSettings;
|
||||
|
||||
// IP literals (not hostnames) keep validate_public_endpoint deterministic — `lookup_host`
|
||||
// parses them without any network round-trip.
|
||||
fn gcs_settings(gcs_base_url: &str) -> ObjectSettings {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "Gcs",
|
||||
"bucket": "b",
|
||||
"serviceAccountKey": { "gcs_base_url": gcs_base_url, "client_email": "x@y.z" }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_gcs_internal_base_url() {
|
||||
// gcs_base_url in the service-account key must not smuggle an internal host past the check,
|
||||
// including via a mixed-case scheme (URL schemes are case-insensitive).
|
||||
for url in [
|
||||
"http://169.254.169.254",
|
||||
"HTTP://169.254.169.254",
|
||||
"Https://10.0.0.5",
|
||||
] {
|
||||
assert!(
|
||||
validate_object_storage_test(&gcs_settings(url))
|
||||
.await
|
||||
.is_err(),
|
||||
"{url} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_gcs_public_base_url() {
|
||||
assert!(
|
||||
validate_object_storage_test(&gcs_settings("https://8.8.8.8"))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbids_internal_ips() {
|
||||
for s in [
|
||||
"127.0.0.1", // loopback
|
||||
"169.254.169.254", // cloud metadata (link-local)
|
||||
"10.0.0.5", // private
|
||||
"172.16.3.4", // private
|
||||
"192.168.1.10", // private
|
||||
"0.0.0.0", // unspecified
|
||||
"100.64.0.1", // CGNAT
|
||||
"::1", // IPv6 loopback
|
||||
"fe80::1", // IPv6 link-local
|
||||
"fc00::1", // IPv6 unique local
|
||||
"::ffff:127.0.0.1", // IPv4-mapped loopback
|
||||
"::ffff:169.254.169.254", // IPv4-mapped metadata
|
||||
"::169.254.169.254", // IPv4-compatible metadata
|
||||
"64:ff9b::169.254.169.254", // NAT64-embedded metadata
|
||||
"64:ff9b::a9fe:a9fe", // NAT64-embedded metadata (hex form)
|
||||
] {
|
||||
assert!(is_forbidden_ip(ip(s)), "{s} should be forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ips() {
|
||||
for s in ["8.8.8.8", "1.1.1.1", "52.95.110.1", "2606:4700:4700::1111"] {
|
||||
assert!(!is_forbidden_ip(ip(s)), "{s} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_host_from_endpoint() {
|
||||
let cases = [
|
||||
("s3.amazonaws.com", Some("s3.amazonaws.com")),
|
||||
("https://minio.internal:9000", Some("minio.internal")),
|
||||
("http://10.0.0.5:9000/bucket", Some("10.0.0.5")),
|
||||
("user:pass@host.example:443", Some("host.example")),
|
||||
("[::1]:9000", Some("::1")),
|
||||
("https://[fe80::1]/x", Some("fe80::1")),
|
||||
("", None),
|
||||
// Injection via region/bucket interpolation into the default endpoint string: the
|
||||
// userinfo `@` and the path `/` must not hide the real authority from the host check.
|
||||
(
|
||||
"https://s3.@169.254.169.254/.amazonaws.com",
|
||||
Some("169.254.169.254"),
|
||||
),
|
||||
(
|
||||
"https://@169.254.169.254/mybucket.s3.amazonaws.com",
|
||||
Some("169.254.169.254"),
|
||||
),
|
||||
("s3.#@169.254.169.254/x.amazonaws.com", Some("s3.")),
|
||||
// Scheme is case-insensitive.
|
||||
("HTTP://169.254.169.254", Some("169.254.169.254")),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(extract_host(input).as_deref(), expected, "input: {input}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,13 +339,15 @@ async fn cleanup_job_logs(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut completed_at_floor: Option<DateTime<Utc>> = None;
|
||||
loop {
|
||||
let (deleted_count, rel_paths) =
|
||||
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH).await?;
|
||||
let (deleted_count, rel_paths, max_completed_at) =
|
||||
delete_expired_jobs_batch(db, retention_secs, JOB_BATCH, completed_at_floor).await?;
|
||||
|
||||
if deleted_count == 0 {
|
||||
break;
|
||||
}
|
||||
completed_at_floor = max_completed_at.or(completed_at_floor);
|
||||
|
||||
let s3_paths: Vec<ObjectPath> = rel_paths
|
||||
.iter()
|
||||
@@ -382,7 +384,8 @@ async fn delete_expired_jobs_batch(
|
||||
db: &DB,
|
||||
job_retention_secs: i64,
|
||||
batch_size: i64,
|
||||
) -> error::Result<(usize, Vec<String>)> {
|
||||
completed_at_floor: Option<DateTime<Utc>>,
|
||||
) -> error::Result<(usize, Vec<String>, Option<DateTime<Utc>>)> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let active_root_job_ids: Vec<Uuid> = sqlx::query_scalar!(
|
||||
@@ -395,33 +398,61 @@ async fn delete_expired_jobs_batch(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
|
||||
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
|
||||
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
)
|
||||
RETURNING id",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
&active_root_job_ids
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
// `completed_at_floor` carries a watermark across batches so each one resumes after the rows
|
||||
// the previous batch processed instead of re-scanning the (potentially undeletable) oldest
|
||||
// prefix; the empty-active-roots branch skips the v2_job join entirely. See
|
||||
// backend/src/monitor.rs::delete_expired_jobs_batch for the full rationale.
|
||||
let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() {
|
||||
let rows = sqlx::query!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT id FROM v2_job_completed
|
||||
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND ($3::timestamptz IS NULL OR completed_at >= $3)
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, completed_at",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
completed_at_floor,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let max = rows.iter().map(|r| r.completed_at).max();
|
||||
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
|
||||
} else {
|
||||
let rows = sqlx::query!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
)
|
||||
RETURNING id, completed_at",
|
||||
job_retention_secs,
|
||||
batch_size,
|
||||
&active_root_job_ids,
|
||||
completed_at_floor,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let max = rows.iter().map(|r| r.completed_at).max();
|
||||
(rows.into_iter().map(|r| r.id).collect::<Vec<Uuid>>(), max)
|
||||
};
|
||||
|
||||
let deleted_count = deleted_jobs.len();
|
||||
if deleted_count == 0 {
|
||||
tx.commit().await?;
|
||||
return Ok((0, Vec::new()));
|
||||
return Ok((0, Vec::new(), max_completed_at));
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -471,7 +502,7 @@ async fn delete_expired_jobs_batch(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((deleted_count, log_paths))
|
||||
Ok((deleted_count, log_paths, max_completed_at))
|
||||
}
|
||||
|
||||
/// Scan S3 under the `logs/` prefix for orphan log files and delete them.
|
||||
|
||||
@@ -27,7 +27,7 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::{header::LOCATION, StatusCode};
|
||||
use windmill_api_auth::require_super_admin;
|
||||
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin, OptJobAuthed};
|
||||
use windmill_common::usernames::{
|
||||
generate_instance_wide_unique_username, get_instance_username_or_create_pending,
|
||||
};
|
||||
@@ -1415,11 +1415,13 @@ async fn convert_user_to_group(
|
||||
|
||||
async fn update_user(
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Path(email_to_update): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(eu): Json<EditUser>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let mut new_super_admin: Option<bool> = None;
|
||||
@@ -1581,10 +1583,12 @@ async fn update_user(
|
||||
|
||||
async fn delete_user(
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Path(email_to_delete): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
|
||||
@@ -1877,9 +1881,11 @@ async fn set_login_type(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(email): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(et): Json<EditLoginType>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -2159,8 +2165,10 @@ pub async fn create_session_token<'c>(
|
||||
async fn create_token(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(token_config): Json<NewToken>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
check_token_create_rate_limit(&authed.username)?;
|
||||
|
||||
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
|
||||
@@ -2176,6 +2184,7 @@ async fn create_token(
|
||||
async fn impersonate(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(new_token): Json<NewToken>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
|
||||
@@ -2189,6 +2198,7 @@ async fn impersonate(
|
||||
Some(&token)
|
||||
};
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
|
||||
if new_token.impersonate_email.is_none() {
|
||||
return Err(Error::BadRequest(
|
||||
@@ -2707,8 +2717,10 @@ struct ExportedGlobalUser {
|
||||
async fn export_global_users(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
) -> JsonResult<Vec<ExportedGlobalUser>> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
let users = sqlx::query_as!(
|
||||
ExportedGlobalUser,
|
||||
@@ -2744,9 +2756,11 @@ async fn export_global_users() -> JsonResult<String> {
|
||||
async fn overwrite_global_users(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(users): Json<Vec<ExportedGlobalUser>>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!("DELETE FROM password")
|
||||
.execute(&mut *tx)
|
||||
|
||||
@@ -717,16 +717,27 @@ async fn create_deployment_request_comment(
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
async fn parent_of_fork(db: &DB, w_id: &str) -> Result<String> {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||||
// Resolve the fork's parent and require it to still exist and be active. A
|
||||
// parent that is archived (soft-deleted) can no longer be accessed, so a
|
||||
// diff or deployment request against it targets an unreachable workspace.
|
||||
let parent = sqlx::query!(
|
||||
"SELECT p.id AS \"id!\", p.deleted AS \"deleted!\"
|
||||
FROM workspace f
|
||||
JOIN workspace p ON p.id = f.parent_workspace_id
|
||||
WHERE f.id = $1",
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
.await?;
|
||||
|
||||
match parent {
|
||||
None => Err(Error::BadRequest(format!(
|
||||
"workspace {w_id} is not a fork (no parent_workspace_id)"
|
||||
))
|
||||
})
|
||||
))),
|
||||
Some(p) if p.deleted => Err(Error::BadRequest(format!(
|
||||
"parent workspace {} of fork {w_id} is archived",
|
||||
p.id
|
||||
))),
|
||||
Some(p) => Ok(p.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub fn global_service() -> Router {
|
||||
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
|
||||
.route("/list", get(list_workspaces))
|
||||
.route("/users", get(user_workspaces))
|
||||
.route("/session_workspace_status", post(session_workspace_status))
|
||||
.route("/create", post(create_workspace))
|
||||
.route("/create_fork", post(deprecated_create_workspace_fork))
|
||||
.route("/exists", post(exists_workspace))
|
||||
@@ -3623,6 +3624,47 @@ async fn user_workspaces(
|
||||
Ok(Json(WorkspaceList { email, workspaces }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionWorkspaceStatusRequest {
|
||||
workspace_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Reconciliation support for client-side AI sessions, which the backend cannot touch
|
||||
/// directly. The client posts the workspace ids its sessions reference and uses the
|
||||
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row /
|
||||
/// no access → unresolvable) drops the sessions, `archived` (soft-deleted, still a
|
||||
/// member) archives them, `active` restores ones previously archived-by-workspace.
|
||||
/// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the
|
||||
/// only way the client learns about a change made while it was away or on another device.
|
||||
async fn session_workspace_status(
|
||||
Extension(db): Extension<DB>,
|
||||
ApiAuthed { email, .. }: ApiAuthed,
|
||||
Json(req): Json<SessionWorkspaceStatusRequest>,
|
||||
) -> JsonResult<HashMap<String, String>> {
|
||||
if req.workspace_ids.len() > 1000 {
|
||||
return Err(Error::BadRequest(
|
||||
"Too many workspace ids (max 1000)".to_string(),
|
||||
));
|
||||
}
|
||||
let rows = sqlx::query!(
|
||||
"SELECT req.id AS \"id!\",
|
||||
(CASE
|
||||
WHEN usr.email IS NULL THEN 'deleted'
|
||||
WHEN workspace.deleted THEN 'archived'
|
||||
ELSE 'active'
|
||||
END) AS \"status!\"
|
||||
FROM unnest($1::text[]) AS req(id)
|
||||
LEFT JOIN workspace ON workspace.id = req.id
|
||||
LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||||
&req.workspace_ids[..],
|
||||
email,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
let statuses = rows.into_iter().map(|r| (r.id, r.status)).collect();
|
||||
Ok(Json(statuses))
|
||||
}
|
||||
|
||||
pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> {
|
||||
if w_id == "global" {
|
||||
return Err(windmill_common::error::Error::BadRequest(
|
||||
|
||||
@@ -65,13 +65,22 @@ pub(crate) async fn change_workspace_id(
|
||||
old_id, rw.new_id
|
||||
);
|
||||
|
||||
// Create new workspace with new id and name
|
||||
// Create new workspace with new id and name. A fork that keeps a wm-fork-
|
||||
// id must carry its parent_workspace_id over, otherwise it becomes a
|
||||
// parentless "fork of nothing" with no source to compare or merge against.
|
||||
// A non-fork target id means the workspace is being promoted out of a fork,
|
||||
// so the parent pointer is intentionally cleared.
|
||||
info!("Creating new workspace row");
|
||||
let new_is_fork = rw.new_id.starts_with(WM_FORK_PREFIX);
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3",
|
||||
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id)
|
||||
SELECT $1, $2, owner, false, premium,
|
||||
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END
|
||||
FROM workspace WHERE id = $3",
|
||||
&rw.new_id,
|
||||
&rw.new_name,
|
||||
&old_id
|
||||
&old_id,
|
||||
new_is_fork
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -347,6 +356,18 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Re-parent child forks: any fork whose parent_workspace_id was the old id
|
||||
// must follow the renamed parent to the new id, otherwise it is left
|
||||
// pointing at the soft-deleted old shell (whose data has moved here).
|
||||
info!("Re-parenting child forks to the new workspace id");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET parent_workspace_id = $1 WHERE parent_workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_protection_rule table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.737.0
|
||||
version: 1.738.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -997,6 +997,39 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserWorkspaceList"
|
||||
|
||||
/workspaces/session_workspace_status:
|
||||
post:
|
||||
summary: get the lifecycle status of workspaces referenced by client-side sessions
|
||||
operationId: getSessionWorkspaceStatus
|
||||
tags:
|
||||
- workspace
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
workspace_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- workspace_ids
|
||||
responses:
|
||||
"200":
|
||||
description: map of workspace id to status (active, archived, or deleted)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
enum:
|
||||
- active
|
||||
- archived
|
||||
- deleted
|
||||
|
||||
/w/{workspace}/workspaces/get_as_superadmin:
|
||||
get:
|
||||
summary: get workspace as super admin (require to be super admin)
|
||||
|
||||
@@ -84,6 +84,12 @@ lazy_static::lazy_static! {
|
||||
(20260228000000, include_str!(
|
||||
"../../migrations/20260228000000_v2_job_completed_failure_index.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
|
||||
(20260610151334, include_str!(
|
||||
"../../migrations/20260610151334_folder_labels.up.sql"
|
||||
).replace("SET search_path = public", "SET search_path FROM CURRENT").to_string()),
|
||||
(20260614075900, include_str!(
|
||||
"../../migrations/20260614075900_dedup_folder_labels.up.sql"
|
||||
).replace("SET search_path = public", "SET search_path FROM CURRENT").to_string()),
|
||||
].into_iter().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::db::{ApiAuthed, OptJobAuthed};
|
||||
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_api_auth::require_super_admin;
|
||||
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin};
|
||||
use windmill_api_users::users::delete_workspace_user_internal;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
@@ -483,11 +483,13 @@ pub(crate) async fn global_offboard_preview(
|
||||
|
||||
pub(crate) async fn offboard_global_user(
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(email): Path<String>,
|
||||
Json(req): Json<GlobalOffboardRequest>,
|
||||
) -> Result<Json<OffboardResponse>> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
|
||||
let workspaces = sqlx::query!(
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1",
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use windmill_api_users::users::*;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::db::{ApiAuthed, OptJobAuthed};
|
||||
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
|
||||
use argon2::Argon2;
|
||||
use axum::{
|
||||
@@ -21,7 +21,7 @@ use axum::{
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use windmill_api_auth::require_super_admin;
|
||||
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::audit::AuditAuthor;
|
||||
@@ -71,11 +71,13 @@ pub fn make_unauthed_service() -> Router {
|
||||
|
||||
async fn create_user(
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(webhook): Extension<windmill_common::webhook::WebhookShared>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
Json(nu): Json<NewUser>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
crate::users_oss::create_user(authed, db, webhook, argon2, nu).await
|
||||
}
|
||||
|
||||
@@ -141,8 +143,10 @@ async fn set_password(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(ep): Json<EditPassword>,
|
||||
) -> Result<String> {
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let email = authed.email.clone();
|
||||
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
|
||||
}
|
||||
@@ -152,9 +156,11 @@ async fn set_password_of_user(
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
Path(email): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(ep): Json<EditPassword>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
|
||||
}
|
||||
|
||||
@@ -165,11 +171,13 @@ struct RenameUser {
|
||||
|
||||
async fn rename_user(
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Path(user_email): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(ru): Json<RenameUser>,
|
||||
) -> Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
|
||||
@@ -693,8 +693,6 @@ pub fn is_allowed_file_location(job_dir: &str, user_defined_path: &str) -> error
|
||||
|
||||
let full_path = job_dir.join(&user_path);
|
||||
|
||||
// let normalized_job_dir = std::fs::canonicalize(job_dir)?;
|
||||
// let normalized_full_path = std::fs::canonicalize(&full_path)?;
|
||||
let normalized_job_dir = normalize_path(job_dir);
|
||||
let normalized_full_path = normalize_path(&full_path);
|
||||
|
||||
@@ -706,6 +704,36 @@ pub fn is_allowed_file_location(job_dir: &str, user_defined_path: &str) -> error
|
||||
.into());
|
||||
}
|
||||
|
||||
// The lexical check above cannot see symlinks: a symlink planted inside the
|
||||
// job dir - e.g. by an earlier Ansible `git_repos` clone whose tracked
|
||||
// content includes one - would let a later `git clone` or file write follow
|
||||
// it out of the job dir while still passing the textual `starts_with` check.
|
||||
// Walk the *normalized* relative path (`..`/`.` already collapsed) so each
|
||||
// step matches the real on-disk resolution, and reject any existing component
|
||||
// that is a symlink. Walking the raw user path would drift on an in-bounds
|
||||
// `..` (e.g. `foo/../link`, which normalizes back inside the job dir) and miss
|
||||
// the real symlinked component. Not-yet-existing components are safe: a path
|
||||
// that does not exist cannot itself be a symlink.
|
||||
let relative = normalized_full_path
|
||||
.strip_prefix(&normalized_job_dir)
|
||||
.unwrap_or(&normalized_full_path);
|
||||
let mut current = normalized_job_dir.clone();
|
||||
for component in relative.components() {
|
||||
if let Component::Normal(c) = component {
|
||||
current.push(c);
|
||||
if std::fs::symlink_metadata(¤t)
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Path traverses a symlink, which is not allowed.",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(normalized_full_path)
|
||||
}
|
||||
|
||||
@@ -2828,4 +2856,71 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_allowed_file_location_allows_plain_relative() {
|
||||
let base = std::env::temp_dir().join(format!("wm_allowed_loc_ok_{}", uuid::Uuid::new_v4()));
|
||||
let job_dir = base.join("job");
|
||||
std::fs::create_dir_all(&job_dir).unwrap();
|
||||
let job_dir_str = job_dir.to_str().unwrap();
|
||||
|
||||
let out = is_allowed_file_location(job_dir_str, "repo/sub/playbook.yml").unwrap();
|
||||
assert_eq!(out, normalize_path(&job_dir.join("repo/sub/playbook.yml")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_allowed_file_location_rejects_parent_and_absolute() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("wm_allowed_loc_esc_{}", uuid::Uuid::new_v4()));
|
||||
let job_dir = base.join("job");
|
||||
std::fs::create_dir_all(&job_dir).unwrap();
|
||||
let job_dir_str = job_dir.to_str().unwrap();
|
||||
|
||||
assert!(is_allowed_file_location(job_dir_str, "../escape").is_err());
|
||||
assert!(is_allowed_file_location(job_dir_str, "a/../../escape").is_err());
|
||||
assert!(is_allowed_file_location(job_dir_str, "/etc/passwd").is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
// Regression for GHSA-v934-cvpf-6fjw: a symlink planted inside the job dir
|
||||
// (e.g. by an earlier `git_repos` clone) must not let a later target traverse
|
||||
// it out of the job dir, even though the lexical path stays "inside".
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_is_allowed_file_location_rejects_symlink_traversal() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("wm_allowed_loc_symlink_{}", uuid::Uuid::new_v4()));
|
||||
let job_dir = base.join("job");
|
||||
std::fs::create_dir_all(&job_dir).unwrap();
|
||||
// Stand-in for the shared cache dir living outside the job dir.
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let job_dir_str = job_dir.to_str().unwrap();
|
||||
|
||||
// Plant `job/repo` -> `../outside`, as a malicious first clone would.
|
||||
let planted = job_dir.join("repo");
|
||||
std::os::unix::fs::symlink(&outside, &planted).unwrap();
|
||||
|
||||
// Both the symlink itself and any path traversing it are rejected.
|
||||
assert!(is_allowed_file_location(job_dir_str, "repo").is_err());
|
||||
assert!(is_allowed_file_location(job_dir_str, "repo/payload").is_err());
|
||||
assert!(is_allowed_file_location(job_dir_str, "repo/sub/payload").is_err());
|
||||
|
||||
// An in-bounds `..` must not bypass the check: `foo/../repo/payload`
|
||||
// normalizes back to `repo/payload` and still traverses the symlink.
|
||||
assert!(is_allowed_file_location(job_dir_str, "foo/../repo/payload").is_err());
|
||||
std::fs::create_dir(job_dir.join("real")).unwrap();
|
||||
assert!(is_allowed_file_location(job_dir_str, "real/../repo/payload").is_err());
|
||||
|
||||
// A dangling symlink (target does not exist yet) is still caught:
|
||||
// `symlink_metadata` does not follow the link.
|
||||
let dangling = job_dir.join("dangling");
|
||||
std::os::unix::fs::symlink(base.join("nonexistent"), &dangling).unwrap();
|
||||
assert!(is_allowed_file_location(job_dir_str, "dangling/payload").is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.737.0";
|
||||
export const VERSION = "v1.738.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -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.737.0";
|
||||
export const VERSION = "1.738.0";
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtime to temp location (will copy with world-writable perms later)
|
||||
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtime to temp location (will copy with world-writable perms later)
|
||||
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
|
||||
|
||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1764517877,
|
||||
"narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=",
|
||||
"lastModified": 1768377964,
|
||||
"narHash": "sha256-RU35vQnfg9NwJUviGCfMH9ChgHANoNSiRaAn4/wINT4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c",
|
||||
"rev": "cadda13afe838615fb74b0a9720905920559c535",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
Generated
+689
-30
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.737.0",
|
||||
"version": "1.738.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
@@ -127,6 +127,7 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"mermaid": "^11.15.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
placement?: Placement
|
||||
usePointerDownOutside?: boolean
|
||||
closeOnOtherDropdownOpen?: boolean
|
||||
// When false the menu stays open after an item is selected (melt's closeOnItemClick).
|
||||
// Consumers that keep the menu open must close it themselves where appropriate.
|
||||
// Read once at menu creation (like `placement`); changing it after mount has no effect.
|
||||
closeOnItemClick?: boolean
|
||||
fixedHeight?: boolean
|
||||
hidePopup?: boolean
|
||||
open?: boolean
|
||||
@@ -41,10 +45,18 @@
|
||||
size?: ButtonType.UnifiedSize
|
||||
btnText?: string
|
||||
buttonReplacement?: import('svelte').Snippet
|
||||
// In customMenu mode the snippet receives the melt-ui `item` action
|
||||
// store so consumers can wrap their own rows in <MenuItem> (or
|
||||
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
|
||||
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
|
||||
// In customMenu mode the snippet receives the melt-ui `item` action store
|
||||
// (so consumers can wrap rows in <MenuItem> for arrow-key navigation + aria)
|
||||
// and `builders` (so they can compose melt submenus, e.g. via DropdownSubmenuItem).
|
||||
menu?: import('svelte').Snippet<
|
||||
[
|
||||
{
|
||||
item: MenubarMenuElements['item']
|
||||
close: () => void
|
||||
builders: ReturnType<typeof createDropdownMenu>['builders']
|
||||
}
|
||||
]
|
||||
>
|
||||
maxHeight?: string | undefined
|
||||
}
|
||||
|
||||
@@ -56,6 +68,7 @@
|
||||
placement = 'bottom-end',
|
||||
usePointerDownOutside = false,
|
||||
closeOnOtherDropdownOpen = true,
|
||||
closeOnItemClick = true,
|
||||
fixedHeight = true,
|
||||
hidePopup = false,
|
||||
open = $bindable(false),
|
||||
@@ -82,6 +95,7 @@
|
||||
positioning: {
|
||||
placement: untrack(() => placement)
|
||||
},
|
||||
closeOnItemClick: untrack(() => closeOnItemClick),
|
||||
loop: true,
|
||||
onOpenChange: ({ next }) => {
|
||||
if (closeOnOtherDropdownOpen) {
|
||||
@@ -176,7 +190,7 @@
|
||||
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
|
||||
>
|
||||
{#if customMenu}
|
||||
{@render menu?.({ item, close })}
|
||||
{@render menu?.({ item, close, builders })}
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
|
||||
@@ -158,11 +158,6 @@
|
||||
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
|
||||
// To execute preview scripts with the right worker group
|
||||
customTag?: string
|
||||
// Opt-in: reflect external `code` prop mutations back into Monaco (see
|
||||
// the effect below). One-way `code={...}` callers that need live
|
||||
// external updates — e.g. the inline flow rawscript — set this. Off by
|
||||
// default so every other caller's behavior is unchanged.
|
||||
syncExternalCode?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -195,8 +190,7 @@
|
||||
enablePreprocessorSnippet = false,
|
||||
rawAppRunnableKey = undefined,
|
||||
preparedAssetsSqlQueries,
|
||||
customTag,
|
||||
syncExternalCode = false
|
||||
customTag
|
||||
}: Props = $props()
|
||||
|
||||
$effect.pre(() => {
|
||||
@@ -375,23 +369,12 @@
|
||||
code = ncode
|
||||
}
|
||||
|
||||
if (noHistory) {
|
||||
editor?.setValue(ncode)
|
||||
} else {
|
||||
if (editor?.getModel()) {
|
||||
// editor.setValue(ncode)
|
||||
editor.pushUndoStop()
|
||||
|
||||
editor.executeEdits('set', [
|
||||
{
|
||||
range: editor.getModel()!.getFullModelRange(), // full range
|
||||
text: ncode
|
||||
}
|
||||
])
|
||||
|
||||
editor.pushUndoStop()
|
||||
}
|
||||
}
|
||||
// setCode is an authoritative overwrite (reset, AI apply, module switch).
|
||||
// Cancel any in-flight keystroke debounce first: otherwise alignCodeWithEditor
|
||||
// skips on the `timeoutModel` guard (leaving Monaco stale), and the pending
|
||||
// updateCode later reads the old buffer and writes it back over `ncode`.
|
||||
cancelPendingChanges()
|
||||
alignCodeWithEditor(!noHistory)
|
||||
// Dispatch change immediately when code actually changed. This ensures
|
||||
// callers like the Reset button and copilot trigger on:change handlers.
|
||||
// The debounced onDidChangeModelContent handler will no-op since code
|
||||
@@ -425,6 +408,7 @@
|
||||
return
|
||||
}
|
||||
code = ncode
|
||||
lastEditorCode = ncode
|
||||
dispatch('change', ncode)
|
||||
}
|
||||
|
||||
@@ -436,12 +420,19 @@
|
||||
* see it. Clears the chain state so the next keystroke after this
|
||||
* flush is a fresh leading fire. */
|
||||
export function flushPendingChanges(): void {
|
||||
cancelPendingChanges()
|
||||
updateCode()
|
||||
}
|
||||
|
||||
/** Discard any in-flight keystroke debounce without materializing it, so a
|
||||
* deferred updateCode can't fire later. Resets chain state to a fresh leading
|
||||
* fire on the next keystroke. */
|
||||
function cancelPendingChanges(): void {
|
||||
if (timeoutModel !== undefined) {
|
||||
clearTimeout(timeoutModel)
|
||||
timeoutModel = undefined
|
||||
}
|
||||
changeChainStart = undefined
|
||||
updateCode()
|
||||
}
|
||||
|
||||
export function append(code: string): void {
|
||||
@@ -1901,29 +1892,6 @@
|
||||
lang = scriptLangToEditorLang(scriptLang)
|
||||
})
|
||||
|
||||
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
|
||||
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
|
||||
// the inline rawscript in the flow editor — otherwise mutate the prop
|
||||
// without Monaco ever showing the change (the AI chat editing a flow
|
||||
// module's content in a session is the motivating case). Gated off by
|
||||
// default: Editor is sensitive and most callers either bind:code (and
|
||||
// carry their own external-sync) or treat code as init-only, so a blanket
|
||||
// setValue would risk clobbering them. The `getValue() !== code` guard
|
||||
// keeps the caret intact when the change originated from typing inside
|
||||
// Monaco (which round-trips code back via `$bindable`, re-firing this
|
||||
// effect with `code === getValue()`).
|
||||
let lastExternalCodeSync = code
|
||||
$effect(() => {
|
||||
if (!syncExternalCode) return
|
||||
if (code === lastExternalCodeSync) return
|
||||
lastExternalCodeSync = code
|
||||
if (!editor) return
|
||||
untrack(() => {
|
||||
if (editor!.getValue() !== code) {
|
||||
editor!.setValue(code ?? '')
|
||||
}
|
||||
})
|
||||
})
|
||||
$effect(() => {
|
||||
filePath = computePath(path)
|
||||
})
|
||||
@@ -2011,25 +1979,50 @@
|
||||
})
|
||||
})
|
||||
|
||||
// External `code` prop changes should flow into the Monaco editor. The
|
||||
// `untrack` block reads/writes Monaco without subscribing — only the
|
||||
// prop read above is tracked — so the editor's own change handler
|
||||
// (`updateCode`) re-running with the same value short-circuits and we
|
||||
// don't loop.
|
||||
$effect(() => {
|
||||
const next = code ?? ''
|
||||
let applyExternalCode = useDebounce(() => alignCodeWithEditor(true), 800)
|
||||
|
||||
// Last `code` value the editor itself produced or aligned to. Used to tell an
|
||||
// echo (the bindable changed because the user typed — Monaco is already
|
||||
// ahead) from a genuine external write. Without this, a typing burst longer
|
||||
// than the debounce window would sync the lagging `code` back over newer
|
||||
// keystrokes. Must be kept in step with every editor↔`code` sync point.
|
||||
let lastEditorCode = code
|
||||
|
||||
function alignCodeWithEditor(history: boolean) {
|
||||
const ed = editor
|
||||
if (!ed) return
|
||||
untrack(() => {
|
||||
if (ed.getValue() === next) return
|
||||
const model = ed.getModel()
|
||||
if (!model) return
|
||||
const next = code ?? ''
|
||||
const value = ed.getValue()
|
||||
const model = ed.getModel()
|
||||
// Some keystrokes are still being debounced, don't overwrite them.
|
||||
// When the debounce is done, updateCode will be called and the code will be aligned with the editor.
|
||||
if (timeoutModel !== undefined) return
|
||||
if (!model) return
|
||||
lastEditorCode = next
|
||||
if (value === next) return
|
||||
if (history) {
|
||||
ed.pushUndoStop()
|
||||
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
|
||||
ed.pushUndoStop()
|
||||
} else {
|
||||
ed.setValue(next)
|
||||
}
|
||||
}
|
||||
|
||||
// External `code` prop changes should flow into the Monaco editor. Skip
|
||||
// echoes: when `code` matches what the editor last produced (`updateCode`)
|
||||
// or aligned to, the change came from the editor itself, so syncing back
|
||||
// would clobber input typed since. Only genuine external writes — where
|
||||
// `code` diverges from `lastEditorCode` — schedule a sync. The `untrack`
|
||||
// block reads/writes Monaco without subscribing, so we don't loop.
|
||||
$effect(() => {
|
||||
;[code, editor]
|
||||
if (!editor) return
|
||||
untrack(() => {
|
||||
if (code === lastEditorCode) return
|
||||
applyExternalCode()
|
||||
})
|
||||
})
|
||||
|
||||
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
|
||||
if (lang !== 'typescript' || !initialized) return false
|
||||
// Use the stable model URI (computed once at mount), not filePath which changes on rename
|
||||
|
||||
@@ -1180,15 +1180,25 @@
|
||||
export type FlowModuleForTimeline = {
|
||||
id: string
|
||||
type: FlowModuleValue['type']
|
||||
suspend?: boolean
|
||||
}
|
||||
|
||||
function allModulesForTimeline(
|
||||
modules: FlowModule[],
|
||||
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
|
||||
): FlowModuleForTimeline[] {
|
||||
const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, {
|
||||
skipToolNodes: true
|
||||
})
|
||||
const ids = dfs(
|
||||
modules,
|
||||
(x) =>
|
||||
({
|
||||
id: x.id,
|
||||
type: x.value.type,
|
||||
suspend: x.suspend != undefined
|
||||
}) as FlowModuleForTimeline,
|
||||
{
|
||||
skipToolNodes: true
|
||||
}
|
||||
)
|
||||
|
||||
function rec(
|
||||
ids: FlowModuleForTimeline[],
|
||||
@@ -1208,7 +1218,8 @@
|
||||
fms,
|
||||
(x) => ({
|
||||
id: x.id.startsWith('subflow:') ? x.id : buildSubflowKey(x.id, nprefix),
|
||||
type: x.value.type
|
||||
type: x.value.type,
|
||||
suspend: x.suspend != undefined
|
||||
}),
|
||||
{ skipToolNodes: true }
|
||||
),
|
||||
|
||||
@@ -72,6 +72,66 @@
|
||||
}
|
||||
|
||||
const barHeight = 32
|
||||
|
||||
// Whole approval-wait machinery below is inert unless a step actually has a suspend config,
|
||||
// so large suspend-free flows pay nothing for the flatten/sort and the per-tick recompute.
|
||||
const hasSuspendModule = $derived(flowModules.some((m) => m.suspend))
|
||||
|
||||
// Push times of every job on the timeline, ascending. Used to locate when the step
|
||||
// that follows an approval step started — i.e. the moment the approval was granted.
|
||||
const allCreatedAts = $derived(
|
||||
hasSuspendModule
|
||||
? Object.values(items ?? {})
|
||||
.flat()
|
||||
.map((j) => j.created_at)
|
||||
.filter((t): t is number => t != undefined)
|
||||
.sort((a, b) => a - b)
|
||||
: []
|
||||
)
|
||||
|
||||
// Heuristic: the grant moment is approximated by the next job pushed anywhere on the
|
||||
// timeline. Exact for sequential flows; for an approval step inside one branch of a
|
||||
// parallel branchall a concurrent sibling job can land first and understate the wait.
|
||||
function nextCreatedAtAfter(t: number): number | undefined {
|
||||
return allCreatedAts.find((c) => c > t)
|
||||
}
|
||||
|
||||
// For a completed suspend/approval step, the time spent waiting for the approval is the
|
||||
// gap between the step finishing and the next step being pushed (or now, if still waiting).
|
||||
function approvalWait(b: {
|
||||
started_at?: number
|
||||
duration_ms?: number
|
||||
}): { start: number; len: number; running: boolean } | undefined {
|
||||
if (b.started_at == undefined || b.duration_ms == undefined) {
|
||||
return undefined
|
||||
}
|
||||
const end = b.started_at + b.duration_ms
|
||||
const next = nextCreatedAtAfter(end)
|
||||
const waitEnd = next ?? (flowDone ? undefined : now)
|
||||
if (waitEnd == undefined) {
|
||||
return undefined
|
||||
}
|
||||
const len = waitEnd - end
|
||||
if (len < 100) {
|
||||
return undefined
|
||||
}
|
||||
return { start: end, len, running: next == undefined }
|
||||
}
|
||||
|
||||
// Approval wait per module id, computed once and consumed by both the rows and the legend.
|
||||
const approvalWaitByModule = $derived.by(() => {
|
||||
const result: Record<string, { start: number; len: number; running: boolean }> = {}
|
||||
for (const m of flowModules) {
|
||||
if (!m.suspend) continue
|
||||
const sub = (items?.[m.id] ?? []).filter((x) => x.created_at && x.started_at)
|
||||
if (sub.length !== 1) continue
|
||||
const aw = approvalWait(sub[0])
|
||||
if (aw) result[m.id] = aw
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasApprovalWait = $derived(Object.keys(approvalWaitByModule).length > 0)
|
||||
</script>
|
||||
|
||||
<OnChange
|
||||
@@ -95,6 +155,12 @@
|
||||
<div class="h-2.5 w-2.5 rounded-sm bg-blue-500/90"></div>
|
||||
<span>Execution</span>
|
||||
</div>
|
||||
{#if hasApprovalWait}
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<div class="h-2.5 w-2.5 rounded-sm bg-purple-400/80"></div>
|
||||
<span>Approval wait</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if max && min}
|
||||
<span class="font-mono">{msToSec(max - min, 1)}s</span>
|
||||
{/if}
|
||||
@@ -113,7 +179,7 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#each flowModules as { id: k, type: typ } (k)}
|
||||
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
|
||||
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
|
||||
<div class="relative px-3 py-1.5">
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
@@ -161,6 +227,7 @@
|
||||
? 0
|
||||
: now - b?.created_at
|
||||
: 0}
|
||||
{@const aw = isSuspend ? approvalWaitByModule[k] : undefined}
|
||||
<div class="flex w-full py-0.5 items-center" {style}>
|
||||
<TimelineBar
|
||||
position="left"
|
||||
@@ -175,7 +242,7 @@
|
||||
/>
|
||||
{#if b.started_at}
|
||||
<TimelineBar
|
||||
position={waitingLen < 100 ? 'center' : 'right'}
|
||||
position={aw || waitingLen < 100 ? 'center' : 'right'}
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
@@ -185,6 +252,20 @@
|
||||
running={b?.duration_ms == undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if aw}
|
||||
<TimelineBar
|
||||
position="right"
|
||||
id={b?.id}
|
||||
{total}
|
||||
{min}
|
||||
concat
|
||||
colorClass="bg-purple-400/80"
|
||||
tooltip={`Waiting for approval — ${msToSec(aw.len, 1)}s`}
|
||||
started_at={aw.start}
|
||||
len={aw.len}
|
||||
running={aw.running}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-full py-0.5"></div>
|
||||
@@ -196,7 +277,6 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
let error: string | undefined = $state(undefined)
|
||||
|
||||
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
// A fork must have a parent to compare/merge against. Treating the wm-fork-
|
||||
// prefix alone as "is a fork" renders a parentless "Fork of ()" banner when
|
||||
// the parent linkage was dropped (e.g. by a workspace id change), so require
|
||||
// both, matching the forks/compare page.
|
||||
let isFork = $derived(($workspaceStore?.startsWith('wm-fork-') ?? false) && !!parentWorkspaceId)
|
||||
|
||||
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
|
||||
// user with only pending drafts should still get the draft CTA (mirrors the
|
||||
|
||||
@@ -1063,6 +1063,14 @@
|
||||
<li
|
||||
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||||
apps, raw apps)</li
|
||||
>
|
||||
<li
|
||||
>infrastructure info (container runtime, managed database provider, database version,
|
||||
size and cluster size, max and active connections, object storage backend)</li
|
||||
>
|
||||
</ul>
|
||||
<br />For air-gapped instances, you can download the telemetry data and send it manually.
|
||||
</div>
|
||||
@@ -1101,6 +1109,10 @@
|
||||
<li
|
||||
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||||
apps, raw apps)</li
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -952,9 +952,7 @@
|
||||
}
|
||||
})
|
||||
})
|
||||
$effect(() => {
|
||||
readFieldsRecursively(script)
|
||||
})
|
||||
|
||||
// Mirror the draft triggers (held in a separate `triggersState` $state)
|
||||
// back into `script.draft_triggers` so the UserDraft autosave — which
|
||||
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
if (activeModuleTab === null && code !== lastSyncedCode) {
|
||||
editorCode = code
|
||||
lastSyncedCode = code
|
||||
editor?.setCode(editorCode) // immediate sync, don't wait for the 800ms debounce
|
||||
untrack(() => inferSchema(code))
|
||||
}
|
||||
})
|
||||
@@ -1591,18 +1592,29 @@
|
||||
let error = $derived(getError(testJob))
|
||||
|
||||
$effect(() => {
|
||||
const options: ScriptOptions = {
|
||||
code,
|
||||
lang: lang as ScriptLang,
|
||||
error,
|
||||
args: args ?? {},
|
||||
path,
|
||||
;[
|
||||
editor,
|
||||
lastSavedCode,
|
||||
lastDeployedCode,
|
||||
diffMode,
|
||||
workflowAsCode: workflowAsCodeAiContext
|
||||
}
|
||||
workflowAsCodeAiContext,
|
||||
args,
|
||||
error,
|
||||
lang,
|
||||
path
|
||||
]
|
||||
untrack(() => {
|
||||
const options: ScriptOptions = {
|
||||
getCode: () => code,
|
||||
lang: lang as ScriptLang,
|
||||
error,
|
||||
args: args ?? {},
|
||||
path,
|
||||
lastSavedCode,
|
||||
lastDeployedCode,
|
||||
diffMode,
|
||||
workflowAsCode: workflowAsCodeAiContext
|
||||
}
|
||||
aiChatManager.scriptEditorOptions = options
|
||||
aiChatManager.scriptEditorApplyCode = async (code: string, opts?: ReviewChangesOpts) => {
|
||||
hideDiffMode()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { goto as gotoUrl } from '$app/navigation'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import Version from './Version.svelte'
|
||||
import Uptodate from './Uptodate.svelte'
|
||||
import InstanceSettings from './InstanceSettings.svelte'
|
||||
@@ -70,7 +70,16 @@
|
||||
const index = page.url.href.lastIndexOf('#')
|
||||
if (index === -1) return
|
||||
const hashRemoved = page.url.href.slice(0, index)
|
||||
gotoUrl(hashRemoved)
|
||||
// Strip the drawer's URL hash without a SvelteKit navigation: a `goto`
|
||||
// here re-fires path-reactive effects on the underlying page (e.g. the
|
||||
// script editor's load effect), wiping unsaved editor content.
|
||||
try {
|
||||
replaceState(hashRemoved, page.state)
|
||||
} catch (e) {
|
||||
// replaceState throws if the router isn't initialized yet — possible
|
||||
// when onDestroy runs during router teardown.
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
concat?: boolean
|
||||
gray?: boolean
|
||||
spacerClass?: string
|
||||
/** Overrides the default gray/blue bar color (e.g. to mark an approval wait). */
|
||||
colorClass?: string
|
||||
/** Tooltip label shown on hover instead of the default job link. */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +31,9 @@
|
||||
running,
|
||||
concat = false,
|
||||
gray = false,
|
||||
spacerClass = ''
|
||||
spacerClass = '',
|
||||
colorClass = undefined,
|
||||
tooltip = undefined
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
@@ -37,20 +43,26 @@
|
||||
{/if}
|
||||
<Popover
|
||||
style="width: {(len / total) * 100}%"
|
||||
class="h-5 relative {gray
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: running
|
||||
? 'bg-blue-400/90'
|
||||
: 'bg-blue-500/90'} {position == 'left'
|
||||
class="h-5 relative {colorClass
|
||||
? colorClass
|
||||
: gray
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: running
|
||||
? 'bg-blue-400/90'
|
||||
: 'bg-blue-500/90'} {position == 'left'
|
||||
? 'rounded-l-md'
|
||||
: position == 'right'
|
||||
? 'rounded-r-md'
|
||||
: 'rounded-md'} center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
|
||||
>
|
||||
{#snippet text()}
|
||||
<a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank"
|
||||
>{id} <ExternalLink size={14} /></a
|
||||
>
|
||||
{#if tooltip}
|
||||
<span>{tooltip}</span>
|
||||
{:else}
|
||||
<a href="{base}/run/{id}" class="inline-flex items-center gap-1" target="_blank"
|
||||
>{id} <ExternalLink size={14} /></a
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if len > 0}
|
||||
{@const narrow = len / total < 0.09}
|
||||
|
||||
@@ -4,6 +4,9 @@ Inline diff renderer for a single workspace item. Mirrors the per-kind
|
||||
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
|
||||
|
||||
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
|
||||
- `raw_app_file` → `<RawAppFileDiff>` (one synthesized raw-app file item: a
|
||||
single diff with a per-file size guard; the metadata item adds a full-app
|
||||
YAML expand). Raw apps are exploded into these items by `rawAppDiffToItems`.
|
||||
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
|
||||
- everything else (apps, resources, variables, schedules, triggers…) →
|
||||
a single Monaco YAML diff over the metadata
|
||||
@@ -18,12 +21,15 @@ doesn't reflow the parent.
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import Tab from './common/tabs/Tab.svelte'
|
||||
import FlowDiffViewer from './FlowDiffViewer.svelte'
|
||||
import RawAppFileDiff from './raw_apps/RawAppFileDiff.svelte'
|
||||
import type { RawAppFileItem } from './raw_apps/rawAppDiffUtils'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
|
||||
interface Props {
|
||||
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
|
||||
/** Any WorkspaceItemDiff['kind'], plus the synthetic `raw_app_file`.
|
||||
* `flow` and `raw_app_file` are special-cased. */
|
||||
kind: string
|
||||
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
|
||||
* for "added" items (don't exist in the parent). */
|
||||
@@ -33,9 +39,11 @@ doesn't reflow the parent.
|
||||
currentRaw?: unknown
|
||||
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
|
||||
inlineDiff?: boolean
|
||||
/** For `raw_app_file`: the synthesized per-file diff item to render. */
|
||||
rawFile?: RawAppFileItem
|
||||
}
|
||||
|
||||
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
|
||||
let { kind, originalRaw, currentRaw, inlineDiff = false, rawFile }: Props = $props()
|
||||
|
||||
type Prepared = { lang?: string; content?: string; metadata: string }
|
||||
|
||||
@@ -102,6 +110,16 @@ doesn't reflow the parent.
|
||||
{inlineDiff}
|
||||
/>
|
||||
</div>
|
||||
{:else if kind === 'raw_app_file' && rawFile}
|
||||
<RawAppFileDiff
|
||||
original={rawFile.original}
|
||||
current={rawFile.current}
|
||||
lang={rawFile.lang}
|
||||
isMetadata={rawFile.isMetadata}
|
||||
fullYamlOriginal={rawFile.fullYamlOriginal}
|
||||
fullYamlCurrent={rawFile.fullYamlCurrent}
|
||||
{inlineDiff}
|
||||
/>
|
||||
{:else if hasContent}
|
||||
<div class="flex flex-col">
|
||||
<Tabs bind:selected={contentTab}>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Visual row for a workspace item (script / flow / app / resource /
|
||||
schedule / trigger / …). Matches the leaf-row layout used by
|
||||
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
|
||||
beneath, or just the mono path when there's no summary.
|
||||
beneath, or just the mono path when there's no summary. With `singleLine`,
|
||||
both collapse to one row showing `summary ?? secondary` (summary in normal
|
||||
text, the mono path as the fallback) — denser, for the diff tree.
|
||||
|
||||
Pure presentation — the caller controls highlighting / current state via
|
||||
props, supplies the onclick/onmouseenter handlers, and can pass an
|
||||
@@ -26,6 +28,9 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
|
||||
* Forwarded to RowIcon. */
|
||||
triggerKind?: string
|
||||
/** For `kind: 'raw_app_file'`, the file name/path — forwarded to RowIcon
|
||||
* to pick an extension-specific icon. */
|
||||
iconPath?: string
|
||||
/** Optional summary text shown above the path. */
|
||||
summary?: string
|
||||
/** Mono path (or any secondary identifier). When summary is empty
|
||||
@@ -47,6 +52,9 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
/** Reserve two lines of height and vertically center the content so
|
||||
* summary and summary-less rows are the same height (diff viewer). */
|
||||
uniformHeight?: boolean
|
||||
/** Collapse to a single line showing `summary ?? secondary` (summary in
|
||||
* normal text, secondary in mono) instead of stacking both. */
|
||||
singleLine?: boolean
|
||||
/** Extra left padding (px) for tree-view indentation. Adds to the
|
||||
* default `px-3` horizontal padding. */
|
||||
indent?: number
|
||||
@@ -69,6 +77,7 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
let {
|
||||
kind,
|
||||
triggerKind,
|
||||
iconPath,
|
||||
summary,
|
||||
secondary,
|
||||
highlighted = false,
|
||||
@@ -82,7 +91,8 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
onclick,
|
||||
onmouseenter,
|
||||
extras,
|
||||
uniformHeight = false
|
||||
uniformHeight = false,
|
||||
singleLine = false
|
||||
}: Props = $props()
|
||||
|
||||
const rootClass = $derived(
|
||||
@@ -96,6 +106,37 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
<RowIcon {kind} {triggerKind} path={iconPath} size={12} />
|
||||
<div class={contentClass}>
|
||||
{#if singleLine}
|
||||
<div
|
||||
class="text-xs font-normal text-primary truncate {summary ? '' : 'font-mono'}"
|
||||
title={summary ?? title ?? secondary}
|
||||
>
|
||||
{summary ?? secondary}
|
||||
</div>
|
||||
{:else if summary}
|
||||
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
|
||||
<div
|
||||
class="text-2xs text-secondary font-normal font-mono truncate"
|
||||
title={title ?? secondary}
|
||||
>
|
||||
{secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
|
||||
{secondary}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extras}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{@render extras()}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
@@ -111,27 +152,7 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
>
|
||||
<RowIcon {kind} {triggerKind} size={12} />
|
||||
<div class={contentClass}>
|
||||
{#if summary}
|
||||
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
|
||||
<div
|
||||
class="text-2xs text-secondary font-normal font-mono truncate"
|
||||
title={title ?? secondary}
|
||||
>
|
||||
{secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
|
||||
{secondary}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extras}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{@render extras()}
|
||||
</div>
|
||||
{/if}
|
||||
{@render body()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
@@ -147,26 +168,6 @@ doesn't steal focus from a sibling search input (matches the picker).
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
>
|
||||
<RowIcon {kind} {triggerKind} size={12} />
|
||||
<div class={contentClass}>
|
||||
{#if summary}
|
||||
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
|
||||
<div
|
||||
class="text-2xs text-secondary font-normal font-mono truncate"
|
||||
title={title ?? secondary}
|
||||
>
|
||||
{secondary}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
|
||||
{secondary}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extras}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{@render extras()}
|
||||
</div>
|
||||
{/if}
|
||||
{@render body()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -25,6 +25,38 @@ describe('parsePipelineAnnotations: tag', () => {
|
||||
const out = parsePipelineAnnotations('// tagged heavy')
|
||||
expect(out.tag).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips a tag value containing whitespace (regular comment false-positive)', () => {
|
||||
const out = parsePipelineAnnotations('# tag this function so we remember to refactor it later')
|
||||
expect(out.tag).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips a tag value longer than 50 chars', () => {
|
||||
const out = parsePipelineAnnotations('// tag ' + 'x'.repeat(51))
|
||||
expect(out.tag).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePipelineAnnotations: header scan', () => {
|
||||
it('ignores annotations in the body once code has started', () => {
|
||||
const code = [
|
||||
'import pandas as pd',
|
||||
'',
|
||||
'def main():',
|
||||
' # tag each row with its source so downstream steps can filter',
|
||||
' # on s3://should/not/parse',
|
||||
' return pd.DataFrame()'
|
||||
].join('\n')
|
||||
const out = parsePipelineAnnotations(code)
|
||||
expect(out.tag).toBeUndefined()
|
||||
expect(out.triggerAssets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tolerates blank lines before code but stops at the first code line', () => {
|
||||
const code = ['#!/usr/bin/env python', '', '# tag heavy', 'import os', '# tag light'].join('\n')
|
||||
const out = parsePipelineAnnotations(code)
|
||||
expect(out.tag).toBe('heavy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePipelineAnnotations: retry', () => {
|
||||
|
||||
@@ -291,8 +291,13 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
|
||||
}
|
||||
|
||||
for (const rawLine of code.split('\n')) {
|
||||
// Annotations live in the leading comment header: skip blank lines but
|
||||
// stop at the first line of actual code, so comments inside the body
|
||||
// (e.g. a regular `# tag ...` prose comment) can't false-positive.
|
||||
// Mirrors the Rust parse_pipeline_annotations header scan.
|
||||
if (rawLine.trim() === '') continue
|
||||
const rest = stripCommentPrefix(rawLine)
|
||||
if (rest === undefined) continue
|
||||
if (rest === undefined) break
|
||||
const inner = rest.trimStart()
|
||||
|
||||
const afterPipeline = consumeKeyword(inner, 'pipeline')
|
||||
@@ -323,7 +328,10 @@ export function parsePipelineAnnotations(code: string): PipelineAnnotations {
|
||||
const afterTag = consumeKeyword(inner, 'tag')
|
||||
if (afterTag !== undefined) {
|
||||
const name = afterTag.trim()
|
||||
if (name && !out.tag) {
|
||||
// Worker tags are single-word identifiers; a value with whitespace
|
||||
// or beyond the script.tag column width is almost certainly a
|
||||
// regular comment starting with "# tag ...".
|
||||
if (name && !out.tag && !/\s/.test(name) && name.length <= 50) {
|
||||
out.tag = name
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
Unplug,
|
||||
Workflow
|
||||
} from 'lucide-svelte'
|
||||
import FileIcon from '$lib/components/raw_apps/FileIcon.svelte'
|
||||
|
||||
interface Props {
|
||||
kind:
|
||||
@@ -26,6 +27,7 @@
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'raw_app_file'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'resource_type'
|
||||
@@ -55,10 +57,13 @@
|
||||
| 'data_pipeline'
|
||||
/** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */
|
||||
triggerKind?: string | undefined
|
||||
/** For 'raw_app_file' kind: the file name/path, used to pick an
|
||||
* extension-specific icon. */
|
||||
path?: string | undefined
|
||||
size?: number
|
||||
}
|
||||
|
||||
let { kind, triggerKind = undefined, size = 16 }: Props = $props()
|
||||
let { kind, triggerKind = undefined, path = undefined, size = 16 }: Props = $props()
|
||||
|
||||
// Map per-kind backend names (e.g. `kafka_trigger`) to the legacy short
|
||||
// names the icon switch already handles, so we don't have to duplicate cases.
|
||||
@@ -85,6 +90,8 @@
|
||||
<BarsStaggered {size} class="text-teal-500" />
|
||||
{:else if effectiveKind === 'app' || effectiveKind === 'raw_app'}
|
||||
<LayoutDashboard {size} class="text-orange-500" />
|
||||
{:else if effectiveKind === 'raw_app_file'}
|
||||
<FileIcon name={path ?? ''} {size} />
|
||||
{:else if effectiveKind === 'script'}
|
||||
<Code2 {size} class="text-blue-500" />
|
||||
{:else if effectiveKind === 'variable'}
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
{headerLeft}
|
||||
hasDiff={aiChatManager.scriptEditorOptions &&
|
||||
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
|
||||
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
|
||||
aiChatManager.scriptEditorOptions.lastDeployedCode !==
|
||||
aiChatManager.scriptEditorOptions.getCode()}
|
||||
diffMode={aiChatManager.scriptEditorOptions?.diffMode ?? false}
|
||||
{disabled}
|
||||
{disabledMessage}
|
||||
|
||||
@@ -28,9 +28,8 @@
|
||||
import { isActiveUserQuestion, type DisplayMessage } from './shared'
|
||||
import type { ContextElement } from './context'
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ProviderModelSelector from './ProviderModelSelector.svelte'
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
import AIChatSettingsMenu from './AIChatSettingsMenu.svelte'
|
||||
import AIChatModelSettings from './AIChatModelSettings.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -253,14 +252,13 @@
|
||||
|
||||
const showTypingIndicator = $derived(aiChatManager.loading)
|
||||
|
||||
// `@` context picker is offered in modes that accept workspace/script/flow
|
||||
// references (SCRIPT, FLOW, GLOBAL → workspace items + code blocks) or in
|
||||
// APP mode (datatables, frontend files, etc.). Other modes (NAVIGATOR,
|
||||
// ASK, API) don't accept @-context.
|
||||
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
|
||||
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
|
||||
// `@`-context is still invoked inline by typing `@` in the input, so the button
|
||||
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
|
||||
const showContextPicker = $derived(
|
||||
aiChatManager.mode === AIMode.SCRIPT ||
|
||||
aiChatManager.mode === AIMode.FLOW ||
|
||||
aiChatManager.mode === AIMode.GLOBAL ||
|
||||
aiChatManager.mode === AIMode.APP
|
||||
)
|
||||
|
||||
@@ -909,8 +907,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
<DatatableCreationPolicy />
|
||||
{/if}
|
||||
<ProviderModelSelector />
|
||||
<AIChatSettingsMenu />
|
||||
<ContextUsageIndicator />
|
||||
<AIChatModelSettings />
|
||||
|
||||
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if appContext.inspectorElement}
|
||||
@@ -958,9 +956,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex px-1 mt-1">
|
||||
<ContextUsageIndicator />
|
||||
</div>
|
||||
</div>
|
||||
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
<div class="px-2 mt-4">
|
||||
|
||||
@@ -113,6 +113,11 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
|
||||
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
|
||||
// flush can tell "the user wants to move on" from "the turn was torn down".
|
||||
const USER_CANCEL_REASON = 'user_cancelled'
|
||||
// Built-in `/compact` session command — summarizes the conversation locally
|
||||
// instead of sending a turn to the model. Matched on the whole input so a
|
||||
// regular message that merely mentions "/compact" mid-sentence is unaffected.
|
||||
const COMPACT_COMMAND_NAME = 'compact'
|
||||
const COMPACT_COMMAND_RE = /^\/compact\s*$/
|
||||
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
|
||||
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
|
||||
const WEB_SEARCH_ERROR_HINT =
|
||||
@@ -325,11 +330,29 @@ export class AIChatManager {
|
||||
sessionId: string | undefined = undefined
|
||||
|
||||
// Workspace AI skills (name + description) advertised in the GLOBAL system
|
||||
// prompt. Loaded asynchronously when entering GLOBAL mode; the system message
|
||||
// is rebuilt once they resolve.
|
||||
private globalSkills: AiSkillListItem[] = []
|
||||
// prompt and surfaced as slash commands in session chat. Loaded
|
||||
// asynchronously when entering GLOBAL mode; the system message is rebuilt
|
||||
// once they resolve.
|
||||
globalSkills = $state<AiSkillListItem[]>([])
|
||||
private globalSkillsRefreshId = 0
|
||||
|
||||
// Built-in session-chat slash commands, listed in the command picker
|
||||
// alongside workspace skills. Unlike a skill, `/compact` runs locally
|
||||
// (compactManually) and never reaches the model; the submit path intercepts
|
||||
// it first, so it shadows any workspace skill of the same name.
|
||||
readonly sessionBuiltinCommands: AiSkillListItem[] = [
|
||||
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' }
|
||||
]
|
||||
|
||||
// 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).
|
||||
sessionCommands: AiSkillListItem[] = $derived([
|
||||
...this.sessionBuiltinCommands,
|
||||
...this.globalSkills.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
|
||||
])
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
script:
|
||||
this.flowAiChatHelpers === undefined &&
|
||||
@@ -431,6 +454,64 @@ export class AIChatManager {
|
||||
return freed
|
||||
}
|
||||
|
||||
/**
|
||||
* Core summarize + rewrite, shared by automatic and manual compaction. Sends
|
||||
* the prefix to the summarizer, then replaces the summarized prefix with a
|
||||
* single summary message in `messages` (as a user message) and
|
||||
* `displayMessages` (as a `summary` boundary). Surviving tail user messages
|
||||
* have their restart `index` re-based onto the new history: the summary
|
||||
* occupies slot 0, so a tail user message that was at `keepFrom` lands at slot
|
||||
* 1. `displayKeepFrom` is where the kept tail begins in `displayMessages`.
|
||||
*
|
||||
* Owns only the `compacting` flag and the history rewrite; callers own trigger
|
||||
* policy (circuit breaker, gates) and persistence. Returns the outcome —
|
||||
* 'aborted' is a user Stop (history left untouched), distinct from 'error'.
|
||||
*/
|
||||
private runSummarization = async (
|
||||
prefix: ChatCompletionMessageParam[],
|
||||
tail: ChatCompletionMessageParam[],
|
||||
keepFrom: number,
|
||||
displayKeepFrom: number,
|
||||
abortController: AbortController
|
||||
): Promise<'ok' | 'empty' | 'aborted' | 'error'> => {
|
||||
this.compacting = true
|
||||
try {
|
||||
const raw = await getNonStreamingCompletion(
|
||||
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
|
||||
abortController
|
||||
)
|
||||
const formatted = formatCompactSummary(raw ?? '')
|
||||
if (!formatted) {
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
|
||||
|
||||
// Replace the summarized display prefix with the boundary marker and
|
||||
// re-base the surviving tail's restart indices (the summary occupies
|
||||
// slot 0, so the tail now starts at slot 1).
|
||||
this.displayMessages = [
|
||||
{ role: 'summary', content: formatted },
|
||||
...this.displayMessages
|
||||
.slice(displayKeepFrom)
|
||||
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
|
||||
]
|
||||
|
||||
// The provider report described the pre-compaction history; the new
|
||||
// history is much smaller, so clear it and let readers re-estimate.
|
||||
this.contextUsage = undefined
|
||||
return 'ok'
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
return 'aborted'
|
||||
}
|
||||
console.error('Conversation summarization failed', err)
|
||||
return 'error'
|
||||
} finally {
|
||||
this.compacting = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary-based partial compaction. Summarizes the older PREFIX of the stored
|
||||
* history into a single user message and keeps the recent tail verbatim,
|
||||
@@ -505,45 +586,94 @@ export class AIChatManager {
|
||||
return false
|
||||
}
|
||||
|
||||
this.compacting = true
|
||||
try {
|
||||
const raw = await getNonStreamingCompletion(
|
||||
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
|
||||
abortController
|
||||
)
|
||||
const formatted = formatCompactSummary(raw ?? '')
|
||||
if (!formatted) {
|
||||
this.consecutiveCompactionFailures++
|
||||
return false
|
||||
}
|
||||
|
||||
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
|
||||
|
||||
// Replace the summarized display prefix with the boundary marker and
|
||||
// re-base the surviving tail's restart indices (the summary occupies
|
||||
// slot 0, so the tail now starts at slot 1).
|
||||
this.displayMessages = [
|
||||
{ role: 'summary', content: formatted },
|
||||
...this.displayMessages
|
||||
.slice(displayKeepFrom)
|
||||
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
|
||||
]
|
||||
|
||||
// The provider report described the pre-compaction history; the new
|
||||
// history is much smaller, so clear it and let readers re-estimate.
|
||||
this.contextUsage = undefined
|
||||
const result = await this.runSummarization(
|
||||
prefix,
|
||||
tail,
|
||||
keepFrom,
|
||||
displayKeepFrom,
|
||||
abortController
|
||||
)
|
||||
if (result === 'ok') {
|
||||
this.consecutiveCompactionFailures = 0
|
||||
return true
|
||||
} catch (err) {
|
||||
// A user Stop aborts the in-flight summary — that's a turn cancel, not a
|
||||
// compaction failure, so it doesn't count toward the circuit breaker.
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error('Conversation summarization failed', err)
|
||||
this.consecutiveCompactionFailures++
|
||||
}
|
||||
// 'aborted' is a user Stop during the in-flight summary — a turn cancel, not
|
||||
// a compaction failure, so it doesn't count toward the circuit breaker.
|
||||
if (result === 'empty' || result === 'error') {
|
||||
this.consecutiveCompactionFailures++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual compaction (the `/compact` session command): summarize the ENTIRE
|
||||
* stored history into a single summary message and keep nothing verbatim, so
|
||||
* the next message continues from the summary alone. Unlike the automatic
|
||||
* trigger it ignores the context-window budget, the circuit breaker, and the
|
||||
* prefix-size gate — the user asked for it explicitly — and runs on its own
|
||||
* abort controller so the Stop button (`cancel`) can interrupt the in-flight
|
||||
* summary, leaving history untouched.
|
||||
*/
|
||||
compactManually = async (): Promise<void> => {
|
||||
if (this.loading) {
|
||||
return
|
||||
}
|
||||
// A summary round-trip only pays off once there's a prior exchange to fold
|
||||
// in; a single message (or none) has nothing to compact.
|
||||
if (this.messages.length < 2) {
|
||||
sendUserToast('Nothing to compact yet.')
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
this.abortController = abortController
|
||||
this.loading = true
|
||||
let result: 'ok' | 'empty' | 'aborted' | 'error' = 'error'
|
||||
try {
|
||||
// Everything is the prefix, nothing is kept verbatim: keepFrom and
|
||||
// displayKeepFrom point past the end so the kept tail is empty.
|
||||
result = await this.runSummarization(
|
||||
[...this.messages],
|
||||
[],
|
||||
this.messages.length,
|
||||
this.displayMessages.length,
|
||||
abortController
|
||||
)
|
||||
switch (result) {
|
||||
case 'ok':
|
||||
await this.historyManager.saveChat(
|
||||
this.displayMessages,
|
||||
this.messages,
|
||||
this.contextUsage
|
||||
)
|
||||
sendUserToast('Conversation compacted.')
|
||||
break
|
||||
case 'empty':
|
||||
sendUserToast(
|
||||
'Compaction produced an empty summary — conversation left unchanged.',
|
||||
true
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
sendUserToast('Failed to compact the conversation.', true)
|
||||
break
|
||||
// 'aborted' (user Stop): history untouched, no toast.
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
this.compacting = false
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
// Flush a message typed while compaction ran. Mirrors the send-turn
|
||||
// epilogue (loading gated its capture): auto-send after a successful
|
||||
// compaction or a deliberate user cancel — the user is ready to move on —
|
||||
// while a failed/empty compaction or a programmatic cancel leaves it queued.
|
||||
if ((result === 'ok' || this.wasCancelledByUser()) && this.queuedMessage) {
|
||||
const next = this.queuedMessage
|
||||
this.queuedMessage = ''
|
||||
const accepted = await this.sendRequest({ instructions: next })
|
||||
if (accepted === false) {
|
||||
this.queuedMessage = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,7 +902,7 @@ export class AIChatManager {
|
||||
this.helpers = {
|
||||
getScriptOptions: () => {
|
||||
return {
|
||||
code: this.scriptEditorOptions?.code ?? '',
|
||||
code: this.scriptEditorOptions?.getCode() ?? '',
|
||||
lang: lang,
|
||||
path: this.scriptEditorOptions?.path ?? '',
|
||||
args: this.scriptEditorOptions?.args ?? {}
|
||||
@@ -844,7 +974,7 @@ export class AIChatManager {
|
||||
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
|
||||
// the system message so the next chat-loop iteration advertises them. Ignore
|
||||
// stale resolves so workspace changes cannot overwrite newer skills.
|
||||
private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
const refreshId = ++this.globalSkillsRefreshId
|
||||
const skills = await loadWorkspaceSkills(workspace)
|
||||
if (refreshId !== this.globalSkillsRefreshId) {
|
||||
@@ -859,6 +989,22 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
private expandGlobalSkillCommand = (instructions: string): string => {
|
||||
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
|
||||
return instructions
|
||||
}
|
||||
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
|
||||
if (!match) {
|
||||
return instructions
|
||||
}
|
||||
const skill = this.globalSkills.find((s) => s.name === match[1])
|
||||
if (!skill) {
|
||||
return instructions
|
||||
}
|
||||
const rest = match[2]?.trim()
|
||||
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
|
||||
}
|
||||
|
||||
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
|
||||
|
||||
private changeModeTool = {
|
||||
@@ -1246,6 +1392,20 @@ export class AIChatManager {
|
||||
if (!this.instructions.trim()) {
|
||||
return false
|
||||
}
|
||||
// Built-in `/compact` session command: summarize the conversation locally
|
||||
// instead of sending a turn to the model. Intercepted here — before the
|
||||
// beforeSend workspace commit, file regrants, and skill expansion — and not
|
||||
// turned into a chat turn. Scoped to session chat GLOBAL mode, where the
|
||||
// slash-command UI lives.
|
||||
if (
|
||||
this.isSessionChat &&
|
||||
this.mode === AIMode.GLOBAL &&
|
||||
COMPACT_COMMAND_RE.test(this.instructions.trim())
|
||||
) {
|
||||
this.instructions = ''
|
||||
await this.compactManually()
|
||||
return false
|
||||
}
|
||||
// Re-grant any locked File System Access handles within this send gesture, so the
|
||||
// file tools can read the live files. requestPermission() needs a user gesture, and
|
||||
// this runs before the first await/network call while the Send click is still active.
|
||||
@@ -1355,6 +1515,10 @@ export class AIChatManager {
|
||||
// The LLM gets the full pasted content; the display message above keeps
|
||||
// the compact tokens + registry so the bubble can render/expand chips.
|
||||
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
const modelInstructions =
|
||||
this.mode === AIMode.GLOBAL
|
||||
? this.expandGlobalSkillCommand(oldInstructions)
|
||||
: oldInstructions
|
||||
this.instructions = ''
|
||||
|
||||
if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) {
|
||||
@@ -1387,7 +1551,7 @@ export class AIChatManager {
|
||||
userMessage = prepareApiUserMessage(oldInstructions)
|
||||
break
|
||||
case AIMode.GLOBAL:
|
||||
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, {
|
||||
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
|
||||
workspace: get(workspaceStore)
|
||||
})
|
||||
break
|
||||
@@ -1922,14 +2086,13 @@ export class AIChatManager {
|
||||
lastDeployedCode: undefined,
|
||||
lastSavedCode: undefined
|
||||
}
|
||||
|
||||
return {
|
||||
args: moduleState?.previewArgs ?? {},
|
||||
error:
|
||||
moduleState && !moduleState.previewSuccess
|
||||
? getStringError(moduleState.previewResult)
|
||||
: undefined,
|
||||
code: module.value.content,
|
||||
getCode: () => module.value.type === 'rawscript' ? module.value.content : '',
|
||||
lang: module.value.language,
|
||||
path: module.id,
|
||||
...editorRelated
|
||||
|
||||
@@ -244,6 +244,31 @@ describe('AIChatManager global skills', () => {
|
||||
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' }
|
||||
])
|
||||
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).not.toContain('/review-code find bugs')
|
||||
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: '/review-code find bugs', mode: AIMode.GLOBAL })
|
||||
|
||||
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager autonomy mode', () => {
|
||||
@@ -915,7 +940,7 @@ describe('AIChatManager context compaction', () => {
|
||||
|
||||
// The request that went out begins with the summary user message, then the
|
||||
// recent tail verbatim, then the new question.
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
const sent = mocks.runChatLoop.mock.calls[mocks.runChatLoop.mock.calls.length - 1][0].messages
|
||||
expect(sent).toHaveLength(4)
|
||||
expect(sent[0].role).toBe('user')
|
||||
expect(sent[0].content).toContain('SUMMARY TEXT')
|
||||
@@ -1004,6 +1029,178 @@ describe('AIChatManager context compaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager manual compaction', () => {
|
||||
const model = { provider: 'openai', model: 'gpt-4o' }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mocks.getCurrentModel.mockReturnValue(model)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
|
||||
mocks.listAiSkills.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function seedExchange(manager: AIChatManager) {
|
||||
manager.messages = [
|
||||
{ role: 'user', content: 'q1' },
|
||||
{ role: 'assistant', content: 'a1' },
|
||||
{ role: 'user', content: 'q2' },
|
||||
{ role: 'assistant', content: 'a2' }
|
||||
]
|
||||
manager.displayMessages = [
|
||||
{ role: 'user', content: 'q1', index: 0 },
|
||||
{ role: 'assistant', content: 'a1' },
|
||||
{ role: 'user', content: 'q2', index: 2 },
|
||||
{ role: 'assistant', content: 'a2' }
|
||||
]
|
||||
}
|
||||
|
||||
it('folds the whole history into a single summary boundary, keeping nothing verbatim', async () => {
|
||||
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>MANUAL SUMMARY</summary>')
|
||||
const manager = new AIChatManager()
|
||||
seedExchange(manager)
|
||||
manager.contextUsage = 123
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat')
|
||||
|
||||
await manager.compactManually()
|
||||
|
||||
// The summarizer saw the entire history, then the summary instruction.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0]
|
||||
expect(summaryReq).toHaveLength(5)
|
||||
expect(summaryReq[0].content).toBe('q1')
|
||||
expect(summaryReq[3].content).toBe('a2')
|
||||
expect(summaryReq[4].content).toContain('detailed summary')
|
||||
|
||||
// Nothing kept verbatim: messages collapse to just the summary user message.
|
||||
expect(manager.messages).toHaveLength(1)
|
||||
expect(manager.messages[0].role).toBe('user')
|
||||
expect(manager.messages[0].content).toContain('MANUAL SUMMARY')
|
||||
expect(manager.messages[0].content).toContain('continued from a previous conversation')
|
||||
expect(manager.messages[0].content).not.toContain('<summary>')
|
||||
|
||||
// The transcript shows one summary boundary in place of the old bubbles.
|
||||
expect(manager.displayMessages).toHaveLength(1)
|
||||
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'MANUAL SUMMARY' })
|
||||
|
||||
expect(manager.contextUsage).toBeUndefined()
|
||||
expect(saveChat).toHaveBeenCalled()
|
||||
expect(mocks.sendUserToast).toHaveBeenCalledWith('Conversation compacted.')
|
||||
expect(manager.loading).toBe(false)
|
||||
expect(manager.compacting).toBe(false)
|
||||
})
|
||||
|
||||
it('no-ops with a toast when there is nothing worth compacting', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.messages = [{ role: 'user', content: 'only one' }]
|
||||
|
||||
await manager.compactManually()
|
||||
|
||||
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
|
||||
expect(mocks.sendUserToast).toHaveBeenCalledWith('Nothing to compact yet.')
|
||||
expect(manager.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves history untouched when the user stops mid-summary', async () => {
|
||||
mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => {
|
||||
ac.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
const manager = new AIChatManager()
|
||||
seedExchange(manager)
|
||||
|
||||
await manager.compactManually()
|
||||
|
||||
expect(manager.messages).toHaveLength(4)
|
||||
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
|
||||
// An abort is a user cancel, not a failure — no toast, no destructive change.
|
||||
expect(mocks.sendUserToast).not.toHaveBeenCalled()
|
||||
expect(manager.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('routes the /compact session command to manual compaction instead of the model', async () => {
|
||||
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>VIA COMMAND</summary>')
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
seedExchange(manager)
|
||||
|
||||
const sent = await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
|
||||
|
||||
// The command never became a chat turn...
|
||||
expect(sent).toBe(false)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
// ...it ran the summarizer and compacted in place, clearing the composer.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'VIA COMMAND' })
|
||||
expect(manager.instructions).toBe('')
|
||||
})
|
||||
|
||||
it('auto-sends a message queued while compaction was running', async () => {
|
||||
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>S</summary>')
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
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
|
||||
manager.changeMode(AIMode.GLOBAL)
|
||||
seedExchange(manager)
|
||||
// A message typed while loading was true gets queued, not sent.
|
||||
manager.queuedMessage = 'follow-up question'
|
||||
|
||||
await manager.compactManually()
|
||||
|
||||
// Compaction ran once, then the queued message went out as a real turn.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
expect(sent[sent.length - 1].content).toContain('follow-up question')
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
|
||||
it('does not intercept /compact outside session chat', async () => {
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
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 = false
|
||||
|
||||
await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
|
||||
|
||||
// Without the session-chat command surface, /compact is a normal message.
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shadows a workspace 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' }
|
||||
]
|
||||
|
||||
// Built-in `compact` comes first and the colliding skill is dropped, so the
|
||||
// picker never renders two leaves with the same `skill:compact` key.
|
||||
const names = manager.sessionCommands.map((c) => c.name)
|
||||
expect(names).toEqual(['compact', 'review-code'])
|
||||
expect(manager.sessionCommands[0].description).toBe(
|
||||
'Summarize the conversation to free up context'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const assistantToolCall = (id: string): ChatCompletionMessageParam => ({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import {
|
||||
COPILOT_SESSION_MODEL_SETTING_NAME,
|
||||
COPILOT_SESSION_PROVIDER_SETTING_NAME,
|
||||
COPILOT_SESSION_REASONING_SETTING_NAME,
|
||||
userStore,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { storeLocalSetting, type Item } from '$lib/utils'
|
||||
import {
|
||||
copilotInfo,
|
||||
copilotSessionModel,
|
||||
getUserCustomPrompts,
|
||||
setCopilotInfo,
|
||||
setUserCustomPrompts
|
||||
} from '$lib/aiStore'
|
||||
import { WorkspaceService, type AIProvider, type AIProviderModel } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$lib/base'
|
||||
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import {
|
||||
getReasoningCapability,
|
||||
resolveEffectiveReasoning,
|
||||
REASONING_OFF,
|
||||
type ReasoningProviderModel
|
||||
} from '../reasoningRegistry'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
|
||||
|
||||
let providerModel = $derived(
|
||||
($copilotSessionModel ??
|
||||
$copilotInfo.defaultModel ??
|
||||
$copilotInfo.aiModels[0] ?? {
|
||||
model: 'No model',
|
||||
provider: 'No provider'
|
||||
}) as ReasoningProviderModel
|
||||
)
|
||||
let models = $derived($copilotInfo.aiModels)
|
||||
|
||||
let capability = $derived(
|
||||
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
|
||||
)
|
||||
// Effective effort accounts for the default-on level on capable models.
|
||||
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
|
||||
// Slider stops: an off position only where the model can truly disable (else the
|
||||
// provider would coerce it to the lowest level), then the provider-native levels.
|
||||
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
|
||||
let currentStop = $derived(
|
||||
providerModel.reasoning === REASONING_OFF
|
||||
? REASONING_OFF
|
||||
: (currentEffort ?? stops[stops.length - 1])
|
||||
)
|
||||
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
|
||||
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
|
||||
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
|
||||
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
|
||||
// for models with no reasoning support.
|
||||
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
|
||||
|
||||
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
|
||||
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
|
||||
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
|
||||
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
|
||||
let menuOpen = $state(false)
|
||||
let triggerEl: HTMLElement | undefined = $state(undefined)
|
||||
let lockedWidth = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
if (lockedWidth === undefined && triggerEl) {
|
||||
lockedWidth = triggerEl.getBoundingClientRect().width
|
||||
}
|
||||
} else {
|
||||
lockedWidth = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function selectModel(m: AIProviderModel) {
|
||||
// Carry the effort onto the new model only if it supports that level ('off'
|
||||
// only where the model can truly disable); otherwise drop it so the model's
|
||||
// default applies.
|
||||
const carried = providerModel.reasoning
|
||||
const cap = getReasoningCapability(m.provider, m.model)
|
||||
const keep =
|
||||
carried === REASONING_OFF
|
||||
? cap.canDisable
|
||||
: carried !== undefined && cap.levels.includes(carried)
|
||||
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
|
||||
}
|
||||
|
||||
function selectReasoning(value: string) {
|
||||
const reasoning = value === REASONING_OFF ? REASONING_OFF : value
|
||||
$copilotSessionModel = {
|
||||
...providerModel,
|
||||
provider: providerModel.provider as AIProvider,
|
||||
reasoning
|
||||
}
|
||||
// Pin the current model selection so the reasoning choice persists with it.
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, providerModel.provider)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, reasoning)
|
||||
}
|
||||
|
||||
// ---- prompt parameters (User / Workspace custom prompts) ----
|
||||
let mode = $derived(aiChatManager.mode)
|
||||
let modalOpen = $state(false)
|
||||
let modalScope = $state<'user' | 'workspace'>('user')
|
||||
let customPrompts = $state<Record<string, string>>({})
|
||||
let initialPrompt = $state('')
|
||||
// Snapshot of the mode the modal was opened for. The live chat mode can change
|
||||
// while the modal is open (mode selector sits behind it), so all edit/save/reset
|
||||
// operations must key off this snapshot, not the reactive `mode`.
|
||||
let activeMode = $state(aiChatManager.mode)
|
||||
|
||||
let isAdmin = $derived(Boolean($userStore?.is_admin || $userStore?.is_super_admin))
|
||||
// True when the workspace has no AI providers of its own (it uses instance defaults).
|
||||
// In that case the backend never makes workspace custom_prompts effective, so a saved
|
||||
// workspace prompt would be dead config — mirror the settings page and surface it read-only.
|
||||
let workspaceMissingProviders = $state(false)
|
||||
let modalReadOnly = $derived(
|
||||
modalScope === 'workspace' && (!isAdmin || workspaceMissingProviders)
|
||||
)
|
||||
let readOnlyReason = $derived(
|
||||
modalScope === 'workspace' && isAdmin && workspaceMissingProviders
|
||||
? 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
|
||||
: undefined
|
||||
)
|
||||
let hasChanges = $derived((customPrompts[activeMode] ?? '') !== initialPrompt)
|
||||
|
||||
function openUserPrompt() {
|
||||
activeMode = mode
|
||||
initialPrompt = getUserCustomPrompts()[activeMode] ?? ''
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
modalScope = 'user'
|
||||
modalOpen = true
|
||||
}
|
||||
|
||||
// Seed from the same source save() writes to (the raw workspace ai_config) and detect
|
||||
// whether the workspace has its own providers in the same fetch. Non-admins can't read
|
||||
// raw settings, so they see the effective prompt from copilotInfo (read-only).
|
||||
async function openWorkspacePrompt() {
|
||||
activeMode = mode
|
||||
modalScope = 'workspace'
|
||||
workspaceMissingProviders = false
|
||||
if (!isAdmin) {
|
||||
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
|
||||
} else {
|
||||
const workspace = $workspaceStore
|
||||
try {
|
||||
const settings = workspace ? await WorkspaceService.getSettings({ workspace }) : undefined
|
||||
const providers = settings?.ai_config?.providers ?? {}
|
||||
workspaceMissingProviders = Object.keys(providers).length === 0
|
||||
initialPrompt = settings?.ai_config?.custom_prompts?.[activeMode] ?? ''
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
|
||||
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
|
||||
}
|
||||
}
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
modalOpen = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const value = (customPrompts[activeMode] ?? '').trim()
|
||||
if (modalScope === 'user') {
|
||||
const prompts = getUserCustomPrompts()
|
||||
if (value) {
|
||||
prompts[activeMode] = value
|
||||
} else {
|
||||
delete prompts[activeMode]
|
||||
}
|
||||
setUserCustomPrompts(prompts)
|
||||
initialPrompt = value
|
||||
customPrompts = { [activeMode]: value }
|
||||
sendUserToast('User AI prompt saved')
|
||||
return
|
||||
}
|
||||
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
try {
|
||||
// Saving prompts requires a full ai_config round-trip; fetch the current
|
||||
// config so we don't clobber providers/models/etc.
|
||||
const settings = await WorkspaceService.getSettings({ workspace })
|
||||
const config = settings.ai_config ?? {}
|
||||
const custom_prompts = { ...(config.custom_prompts ?? {}) }
|
||||
if (value) {
|
||||
custom_prompts[activeMode] = value
|
||||
} else {
|
||||
delete custom_prompts[activeMode]
|
||||
}
|
||||
const response = await WorkspaceService.editCopilotConfig({
|
||||
workspace,
|
||||
requestBody: { ...config, custom_prompts }
|
||||
})
|
||||
setCopilotInfo(response.effective_ai_config)
|
||||
initialPrompt = value
|
||||
customPrompts = { [activeMode]: value }
|
||||
sendUserToast('Workspace AI prompt saved')
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
|
||||
// Re-throw so AIPromptsModal keeps the modal open on a failed save.
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
|
||||
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
|
||||
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
|
||||
function paramItems(close: () => void): Item {
|
||||
return {
|
||||
displayName: 'Parameters',
|
||||
icon: Settings,
|
||||
submenuItems: [
|
||||
{
|
||||
displayName: 'User prompt',
|
||||
icon: User,
|
||||
action: () => {
|
||||
close()
|
||||
openUserPrompt()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Workspace prompt',
|
||||
icon: Building2,
|
||||
action: () => {
|
||||
close()
|
||||
openWorkspacePrompt()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'AI settings',
|
||||
icon: Settings,
|
||||
href: AI_SETTINGS_HREF,
|
||||
hrefTarget: '_blank',
|
||||
separatorTop: true,
|
||||
hide: !isAdmin,
|
||||
extra: externalLinkIcon
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
|
||||
// roving focus blurs the focused element on pointermove, which would abort the native
|
||||
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
|
||||
function isolatePointer(node: HTMLElement) {
|
||||
const stop = (e: Event) => e.stopPropagation()
|
||||
node.addEventListener('pointerdown', stop)
|
||||
node.addEventListener('pointermove', stop)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', stop)
|
||||
node.removeEventListener('pointermove', stop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
|
||||
function adjustEffort(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const next = Math.min(
|
||||
stops.length - 1,
|
||||
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
|
||||
)
|
||||
selectReasoning(stops[next])
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkIcon()}
|
||||
<ExternalLink size={14} class="shrink-0 text-secondary" />
|
||||
{/snippet}
|
||||
|
||||
<DropdownV2
|
||||
customMenu
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
bind:open={menuOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
bind:this={triggerEl}
|
||||
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
|
||||
>
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
btnClasses="w-full max-w-[200px] text-secondary font-normal"
|
||||
title="Model & reasoning settings"
|
||||
>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate">{providerModel.model}</span>
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu({ item, builders, close })}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#each models as m (m.provider + m.model)}
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => selectModel(m)}
|
||||
>
|
||||
<span class="truncate grow min-w-0">{m.model}</span>
|
||||
{#if m.model === providerModel.model && m.provider === providerModel.provider}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{#if capability.supported}
|
||||
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
|
||||
up/down navigation), and so hovering it takes the highlight off the Parameters
|
||||
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
|
||||
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
|
||||
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
|
||||
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
|
||||
</div>
|
||||
{#if stops.length > 1}
|
||||
<!-- Only the slider area reflects the item's highlight, not the header. -->
|
||||
<div
|
||||
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={stops.length - 1}
|
||||
step="1"
|
||||
value={stopIndex}
|
||||
style="--fill: {fillPct}%"
|
||||
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
|
||||
use:isolatePointer
|
||||
class="lean-range no-default-style w-full"
|
||||
aria-label="Reasoning effort"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</MenuItemWrapper>
|
||||
{:else}
|
||||
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
|
||||
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
|
||||
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
|
||||
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
|
||||
rules — so they are wrapped in :global (the class is unique to this component). */
|
||||
.lean-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* override the global `input { background-color: ... !important }` so only the
|
||||
thin track shows, not a full-height band behind it */
|
||||
background-color: transparent !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.lean-range:focus,
|
||||
.lean-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-runnable-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
|
||||
rgb(var(--color-surface-secondary)) var(--fill, 0%)
|
||||
);
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-thumb) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -3.5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-secondary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-progress) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-thumb) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Building2, ExternalLink, Settings, User } from 'lucide-svelte'
|
||||
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import {
|
||||
copilotInfo,
|
||||
getUserCustomPrompts,
|
||||
setCopilotInfo,
|
||||
setUserCustomPrompts
|
||||
} from '$lib/aiStore'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
|
||||
|
||||
// Quick access to the AI prompt preferences (user + workspace) for the
|
||||
// current chat mode, surfaced next to the model selector in the AI chat.
|
||||
const aiChatManager = getAiChatManager()
|
||||
let mode = $derived(aiChatManager.mode)
|
||||
|
||||
let modalOpen = $state(false)
|
||||
let modalScope = $state<'user' | 'workspace'>('user')
|
||||
let customPrompts = $state<Record<string, string>>({})
|
||||
let initialPrompt = $state('')
|
||||
// Snapshot of the mode the modal was opened for. The live chat mode can change
|
||||
// while the modal is open (mode selector sits behind it), so all edit/save/reset
|
||||
// operations must key off this snapshot, not the reactive `mode`.
|
||||
let activeMode = $state(aiChatManager.mode)
|
||||
|
||||
let isAdmin = $derived(Boolean($userStore?.is_admin || $userStore?.is_super_admin))
|
||||
// True when the workspace has no AI providers of its own (it uses instance defaults).
|
||||
// In that case the backend never makes workspace custom_prompts effective
|
||||
// (get_copilot_info returns the instance config verbatim), so a saved workspace prompt
|
||||
// would be dead config — mirror the settings page and surface the prompt read-only.
|
||||
let workspaceMissingProviders = $state(false)
|
||||
// Workspace prompts are admin-only to edit; non-admins (and admins on a workspace
|
||||
// without its own providers) get a read-only view.
|
||||
let modalReadOnly = $derived(
|
||||
modalScope === 'workspace' && (!isAdmin || workspaceMissingProviders)
|
||||
)
|
||||
let readOnlyReason = $derived(
|
||||
modalScope === 'workspace' && isAdmin && workspaceMissingProviders
|
||||
? 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
|
||||
: undefined
|
||||
)
|
||||
let hasChanges = $derived((customPrompts[activeMode] ?? '') !== initialPrompt)
|
||||
|
||||
function openUserPrompt() {
|
||||
activeMode = mode
|
||||
initialPrompt = getUserCustomPrompts()[activeMode] ?? ''
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
modalScope = 'user'
|
||||
modalOpen = true
|
||||
}
|
||||
|
||||
// Seed from the same source save() writes to (the raw workspace ai_config) and detect
|
||||
// whether the workspace has its own providers in the same fetch. Non-admins can't read
|
||||
// raw settings, so they see the effective prompt from copilotInfo (read-only).
|
||||
async function openWorkspacePrompt() {
|
||||
activeMode = mode
|
||||
modalScope = 'workspace'
|
||||
workspaceMissingProviders = false
|
||||
if (!isAdmin) {
|
||||
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
|
||||
} else {
|
||||
const workspace = $workspaceStore
|
||||
try {
|
||||
const settings = workspace ? await WorkspaceService.getSettings({ workspace }) : undefined
|
||||
const providers = settings?.ai_config?.providers ?? {}
|
||||
workspaceMissingProviders = Object.keys(providers).length === 0
|
||||
initialPrompt = settings?.ai_config?.custom_prompts?.[activeMode] ?? ''
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
|
||||
initialPrompt = $copilotInfo.customPrompts?.[activeMode] ?? ''
|
||||
}
|
||||
}
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
modalOpen = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
customPrompts = { [activeMode]: initialPrompt }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const value = (customPrompts[activeMode] ?? '').trim()
|
||||
if (modalScope === 'user') {
|
||||
const prompts = getUserCustomPrompts()
|
||||
if (value) {
|
||||
prompts[activeMode] = value
|
||||
} else {
|
||||
delete prompts[activeMode]
|
||||
}
|
||||
setUserCustomPrompts(prompts)
|
||||
initialPrompt = value
|
||||
// Sync the editor to the trimmed value so hasChanges resets to false.
|
||||
customPrompts = { [activeMode]: value }
|
||||
sendUserToast('User AI prompt saved')
|
||||
return
|
||||
}
|
||||
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
try {
|
||||
// Saving prompts requires a full ai_config round-trip; fetch the
|
||||
// current config so we don't clobber providers/models/etc.
|
||||
const settings = await WorkspaceService.getSettings({ workspace })
|
||||
const config = settings.ai_config ?? {}
|
||||
const custom_prompts = { ...(config.custom_prompts ?? {}) }
|
||||
if (value) {
|
||||
custom_prompts[activeMode] = value
|
||||
} else {
|
||||
delete custom_prompts[activeMode]
|
||||
}
|
||||
const response = await WorkspaceService.editCopilotConfig({
|
||||
workspace,
|
||||
requestBody: { ...config, custom_prompts }
|
||||
})
|
||||
// Editing is gated on the workspace having its own providers, so effective_ai_config
|
||||
// equals the saved config (it only falls back to the instance config when the
|
||||
// workspace has none) and already carries the custom_prompts we just wrote.
|
||||
setCopilotInfo(response.effective_ai_config)
|
||||
initialPrompt = value
|
||||
// Sync the editor to the trimmed value so hasChanges resets to false.
|
||||
customPrompts = { [activeMode]: value }
|
||||
sendUserToast('Workspace AI prompt saved')
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
|
||||
// Re-throw so AIPromptsModal keeps the modal open on a failed save.
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
let items = $derived<Item[]>([
|
||||
{ displayName: 'User prompt', icon: User, action: openUserPrompt },
|
||||
{ displayName: 'Workspace prompt', icon: Building2, action: openWorkspacePrompt },
|
||||
{
|
||||
displayName: 'AI settings',
|
||||
icon: Settings,
|
||||
href: AI_SETTINGS_HREF,
|
||||
hrefTarget: '_blank',
|
||||
separatorTop: true,
|
||||
hide: !isAdmin,
|
||||
extra: externalLinkIcon
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkIcon()}
|
||||
<ExternalLink size={14} class="shrink-0 text-secondary" />
|
||||
{/snippet}
|
||||
|
||||
<DropdownV2 {items} placement="bottom-end" fixedHeight={false}>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: Settings }}
|
||||
btnClasses="text-secondary"
|
||||
title="AI prompt settings"
|
||||
/>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
@@ -101,6 +101,7 @@
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<div
|
||||
id="content"
|
||||
class={classNames(
|
||||
'flex-1 min-h-0 flex flex-col',
|
||||
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles } from 'lucide-svelte'
|
||||
import DrillPicker from '$lib/components/DrillPicker.svelte'
|
||||
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import type { AiSkillListItem } from './global/core'
|
||||
|
||||
interface Props {
|
||||
skills: AiSkillListItem[]
|
||||
onSelect: (skill: AiSkillListItem) => void
|
||||
setShowing?: (showing: boolean) => void
|
||||
externalFilter?: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
let { skills, onSelect, setShowing, externalFilter, autoFocus = true }: Props = $props()
|
||||
|
||||
type DrillPickerHandle = {
|
||||
handleKeydown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
let inner = $state<DrillPickerHandle | undefined>(undefined)
|
||||
|
||||
const tree = $derived<DrillNode<AiSkillListItem>[]>(
|
||||
skills.map((skill) => ({
|
||||
type: 'leaf' as const,
|
||||
key: `skill:${skill.name}`,
|
||||
label: `/${skill.name}`,
|
||||
secondary: skill.description,
|
||||
searchableText: `${skill.name} ${skill.description}`,
|
||||
data: skill
|
||||
}))
|
||||
)
|
||||
|
||||
export function handleKeydown(e: KeyboardEvent) {
|
||||
inner?.handleKeydown(e)
|
||||
}
|
||||
|
||||
function handlePick(leaf: DrillLeaf<AiSkillListItem>) {
|
||||
onSelect(leaf.data)
|
||||
}
|
||||
|
||||
function onDocumentKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) {
|
||||
setShowing?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
return () => document.removeEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet skillIcon(_leaf: DrillLeaf<AiSkillListItem>)}
|
||||
<Sparkles size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-hidden">
|
||||
<DrillPicker
|
||||
bind:this={inner}
|
||||
{tree}
|
||||
onPick={handlePick}
|
||||
{externalFilter}
|
||||
{autoFocus}
|
||||
leafIcon={skillIcon}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@ import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
|
||||
export interface ScriptOptions {
|
||||
lang: ScriptLang | 'bunnative'
|
||||
code: string
|
||||
getCode: () => string
|
||||
error: string | undefined
|
||||
args: Record<string, any>
|
||||
path: string | undefined
|
||||
@@ -192,7 +192,7 @@ export default class ContextManager {
|
||||
{
|
||||
type: 'code',
|
||||
title: this.getContextCodePath(scriptOptions) ?? '',
|
||||
content: scriptOptions.code,
|
||||
content: scriptOptions.getCode(),
|
||||
lang: scriptOptions.lang
|
||||
}
|
||||
]
|
||||
@@ -209,22 +209,22 @@ export default class ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.code) {
|
||||
if (scriptOptions.lastSavedCode && scriptOptions.lastSavedCode !== scriptOptions.getCode()) {
|
||||
newAvailableContext.push({
|
||||
type: 'diff',
|
||||
title: 'diff_with_last_saved_draft', // can't use spaces in the title, because it will break the word match in the context text area hightlighting logic
|
||||
content: scriptOptions.lastSavedCode ?? '',
|
||||
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.code),
|
||||
diff: diffLines(scriptOptions.lastSavedCode ?? '', scriptOptions.getCode()),
|
||||
lang: scriptOptions.lang
|
||||
})
|
||||
}
|
||||
|
||||
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.code) {
|
||||
if (scriptOptions.lastDeployedCode && scriptOptions.lastDeployedCode !== scriptOptions.getCode()) {
|
||||
newAvailableContext.push({
|
||||
type: 'diff',
|
||||
title: 'diff_with_last_deployed_version',
|
||||
content: scriptOptions.lastDeployedCode ?? '',
|
||||
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.code),
|
||||
diff: diffLines(scriptOptions.lastDeployedCode ?? '', scriptOptions.getCode()),
|
||||
lang: scriptOptions.lang
|
||||
})
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export default class ContextManager {
|
||||
{
|
||||
type: 'code',
|
||||
title: this.getContextCodePath(scriptOptions) ?? '',
|
||||
content: scriptOptions.code,
|
||||
content: scriptOptions.getCode(),
|
||||
lang: scriptOptions.lang,
|
||||
deletable: false
|
||||
},
|
||||
@@ -277,7 +277,7 @@ export default class ContextManager {
|
||||
newSelectedContext = newSelectedContext
|
||||
.filter(
|
||||
(c) =>
|
||||
(c.type === 'code_piece' && scriptOptions.code.includes(c.content)) ||
|
||||
(c.type === 'code_piece' && scriptOptions.getCode().includes(c.content)) ||
|
||||
c.type === 'code' ||
|
||||
// Workspace references are user-picked via @-mention and not in
|
||||
// availableContext; preserve so badges survive editor refreshes.
|
||||
@@ -289,7 +289,7 @@ export default class ContextManager {
|
||||
if (c.type === 'code') {
|
||||
return {
|
||||
...c,
|
||||
content: scriptOptions.code,
|
||||
content: scriptOptions.getCode(),
|
||||
title: this.getContextCodePath(scriptOptions)
|
||||
}
|
||||
}
|
||||
@@ -403,7 +403,7 @@ export default class ContextManager {
|
||||
type: 'diff' as const,
|
||||
title: 'diff_with_last_deployed_version',
|
||||
content: this.scriptOptions.lastDeployedCode ?? '',
|
||||
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.code),
|
||||
diff: diffLines(this.scriptOptions.lastDeployedCode ?? '', this.scriptOptions.getCode()),
|
||||
lang: this.scriptOptions.lang
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import autosize from '$lib/autosize'
|
||||
import { tick } from 'svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import ChatCommandPicker from './ChatCommandPicker.svelte'
|
||||
import ChatContextPicker from './ChatContextPicker.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
@@ -68,11 +70,22 @@
|
||||
|
||||
let showContextTooltip = $state(false)
|
||||
let contextTooltipWord = $state('')
|
||||
let showCommandTooltip = $state(false)
|
||||
let commandTooltipWord = $state('')
|
||||
let textarea = $state<HTMLTextAreaElement | undefined>(undefined)
|
||||
let tooltipElement = $state<HTMLDivElement | undefined>(undefined)
|
||||
let chatContextPicker: ChatContextPicker | undefined = $state()
|
||||
let chatCommandPicker: ChatCommandPicker | undefined = $state()
|
||||
let commandSkillsRefreshInFlight = false
|
||||
|
||||
// Virtual reference anchored at the `@` that opened the mention (not the
|
||||
const commandSkills = $derived(
|
||||
aiChatManager.mode === AIMode.GLOBAL && aiChatManager.isSessionChat
|
||||
? aiChatManager.sessionCommands
|
||||
: []
|
||||
)
|
||||
const activeTooltipWord = $derived(showContextTooltip ? contextTooltipWord : commandTooltipWord)
|
||||
|
||||
// Virtual reference anchored at the trigger that opened the picker (not the
|
||||
// caret), so the picker stays put while the user types the query.
|
||||
// svelte-floating-ui's `createVirtualElement` takes a raw ClientRect and
|
||||
// wraps it in a function internally — re-`update()` on each anchor move.
|
||||
@@ -526,19 +539,33 @@
|
||||
showContextTooltip = false
|
||||
}
|
||||
|
||||
function refreshCommandSkills() {
|
||||
if (commandSkillsRefreshInFlight) return
|
||||
commandSkillsRefreshInFlight = true
|
||||
void aiChatManager.refreshGlobalSkills().finally(() => {
|
||||
commandSkillsRefreshInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
function getCommandFilter(text: string): string | undefined {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
|
||||
const match = /^\/([a-z0-9-]*)$/.exec(text)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
function updateAnchorRect() {
|
||||
if (!textarea) return
|
||||
const triggerWord = activeTooltipWord
|
||||
if (!triggerWord) return
|
||||
try {
|
||||
// Index of the `@` that started the current mention. handleInput
|
||||
// only opens the picker when `contextTooltipWord` (= `@xxx`) is the
|
||||
// LAST whitespace-separated word in `value`, so the `@` always sits
|
||||
// at `value.length - contextTooltipWord.length`.
|
||||
const atIndex = value.length - contextTooltipWord.length
|
||||
const coords = getCaretCoordinates(textarea, atIndex)
|
||||
// Inline `@` anchors to the last word; slash commands only open when
|
||||
// `/...` is the whole input, so the trigger sits at index 0.
|
||||
const triggerIndex = triggerWord.startsWith('/') ? 0 : value.length - triggerWord.length
|
||||
const coords = getCaretCoordinates(textarea, triggerIndex)
|
||||
const rect = textarea.getBoundingClientRect()
|
||||
// getCaretCoordinates returns content-relative coords; subtract the
|
||||
// textarea's own scroll so the anchor tracks the `@` once the input is
|
||||
// capped (max-height) and scrolls internally.
|
||||
// textarea's own scroll so the anchor tracks the trigger once the input
|
||||
// is capped (max-height) and scrolls internally.
|
||||
anchorRect = new DOMRect(
|
||||
rect.left + coords.left - textarea.scrollLeft,
|
||||
rect.top + coords.top - textarea.scrollTop,
|
||||
@@ -558,6 +585,19 @@
|
||||
function handleInput(e: Event) {
|
||||
textarea = e.target as HTMLTextAreaElement
|
||||
|
||||
const commandFilter = getCommandFilter(value)
|
||||
if (commandFilter !== undefined) {
|
||||
const wasShowing = showCommandTooltip
|
||||
showCommandTooltip = true
|
||||
commandTooltipWord = `/${commandFilter}`
|
||||
showContextTooltip = false
|
||||
contextTooltipWord = ''
|
||||
if (!wasShowing) refreshCommandSkills()
|
||||
return
|
||||
}
|
||||
showCommandTooltip = false
|
||||
commandTooltipWord = ''
|
||||
|
||||
const words = value.split(/\s+/)
|
||||
const lastWord = words[words.length - 1]
|
||||
|
||||
@@ -574,6 +614,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandSelection(skill: { name: string }) {
|
||||
value = `/${skill.name} `
|
||||
showCommandTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Pass to parent first if provided
|
||||
if (onKeyDown) {
|
||||
@@ -585,6 +631,22 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (showCommandTooltip) {
|
||||
if (
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'Tab' ||
|
||||
e.key === 'Escape'
|
||||
) {
|
||||
chatCommandPicker?.handleKeydown(e)
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showContextTooltip) {
|
||||
// Forward navigation keys to the picker so the textarea-focused
|
||||
// user can drive it. The picker preventDefault/stopPropagation's
|
||||
@@ -622,11 +684,11 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-track on every value change. The `@` position can shift when the
|
||||
// user adds/deletes text BEFORE it (line wrap, etc.); the picker should
|
||||
// follow. floating-ui's autoUpdate only fires on scroll/resize.
|
||||
// Re-track on every value change. The trigger position can shift when
|
||||
// the user adds/deletes text before it (line wrap, etc.); the picker
|
||||
// should follow. floating-ui's autoUpdate only fires on scroll/resize.
|
||||
void value
|
||||
if (showContextTooltip) updateAnchorRect()
|
||||
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -700,9 +762,9 @@
|
||||
ondragstart={handlePasteDragStart}
|
||||
onscroll={(e) => {
|
||||
scrollTop = e.currentTarget.scrollTop
|
||||
// Keep the `@` picker pinned to its anchor while the input scrolls
|
||||
// Keep the picker pinned to its anchor while the input scrolls
|
||||
// internally (autoUpdate can't observe a virtual ref's scroll).
|
||||
if (showContextTooltip) updateAnchorRect()
|
||||
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
|
||||
}}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
@@ -711,6 +773,7 @@
|
||||
return
|
||||
}
|
||||
showContextTooltip = false
|
||||
showCommandTooltip = false
|
||||
}, 200)
|
||||
}}
|
||||
{placeholder}
|
||||
@@ -724,7 +787,7 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if showContextTooltip}
|
||||
{#if showContextTooltip || showCommandTooltip}
|
||||
<Portal target="body">
|
||||
<div
|
||||
bind:this={tooltipElement}
|
||||
@@ -732,33 +795,46 @@
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md shadow-lg overflow-hidden"
|
||||
style="z-index: {zIndexes.tooltip};"
|
||||
>
|
||||
<ChatContextPicker
|
||||
bind:this={chatContextPicker}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
handleContextSelection(element)
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
onAddContext(element)
|
||||
updateInstructionsWithContext(element)
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
externalFilter={contextTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showContextTooltip = showing
|
||||
}}
|
||||
onSelectFile={(name) => {
|
||||
// Replace the in-progress `@word` with the chosen mention (bracketed if the
|
||||
// filename has spaces, so the highlighter captures it whole).
|
||||
const index = value.lastIndexOf('@')
|
||||
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
/>
|
||||
{#if showCommandTooltip}
|
||||
<ChatCommandPicker
|
||||
bind:this={chatCommandPicker}
|
||||
skills={commandSkills}
|
||||
onSelect={handleCommandSelection}
|
||||
externalFilter={commandTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showCommandTooltip = showing
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ChatContextPicker
|
||||
bind:this={chatContextPicker}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
handleContextSelection(element)
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
onAddContext(element)
|
||||
updateInstructionsWithContext(element)
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
externalFilter={contextTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showContextTooltip = showing
|
||||
}}
|
||||
onSelectFile={(name) => {
|
||||
// Replace the in-progress `@word` with the chosen mention (bracketed if the
|
||||
// filename has spaces, so the highlighter captures it whole).
|
||||
const index = value.lastIndexOf('@')
|
||||
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
import { getKnownModelContextWindow } from '../modelConfig'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
@@ -19,6 +20,21 @@
|
||||
// the user can watch context grow toward the compaction threshold.
|
||||
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
|
||||
|
||||
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
|
||||
// gauge fills toward that point and turns red once it is reached.
|
||||
const COMPACTION_TRIGGER_RATIO = 0.8
|
||||
let ratio = $derived(contextWindow ? Math.min(usedTokens / contextWindow, 1) : undefined)
|
||||
let fillPct = $derived(ratio !== undefined ? Math.round(ratio * 100) : undefined)
|
||||
let fillClass = $derived(
|
||||
ratio === undefined
|
||||
? 'bg-tertiary'
|
||||
: ratio >= COMPACTION_TRIGGER_RATIO
|
||||
? 'bg-red-500'
|
||||
: ratio >= COMPACTION_TRIGGER_RATIO * 0.75
|
||||
? 'bg-amber-500'
|
||||
: 'bg-surface-accent-primary'
|
||||
)
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) {
|
||||
return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
@@ -31,9 +47,35 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<span class="text-[0.6rem] text-tertiary tabular-nums" aria-label="Context window usage">
|
||||
Context usage: ~{formatTokenCount(usedTokens)}{contextWindow
|
||||
? ` / ${formatTokenCount(contextWindow)}`
|
||||
: ''}
|
||||
</span>
|
||||
<Tooltip small placement="top">
|
||||
<!-- Only a meter when we know the window: it's a 0–100% reading. With an unknown
|
||||
window there's no max to measure against, so it's a plain labeled indicator
|
||||
(the bar is decorative/full and the token count lives in the tooltip). -->
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label="Context window usage"
|
||||
role={fillPct !== undefined ? 'meter' : undefined}
|
||||
aria-valuenow={fillPct}
|
||||
aria-valuemin={fillPct !== undefined ? 0 : undefined}
|
||||
aria-valuemax={fillPct !== undefined ? 100 : undefined}
|
||||
>
|
||||
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet text()}
|
||||
<div class="text-xs whitespace-nowrap">
|
||||
<p class="font-semibold">Context usage</p>
|
||||
<p class="mt-1 tabular-nums">
|
||||
~{formatTokenCount(usedTokens)}{contextWindow
|
||||
? ` / ${formatTokenCount(contextWindow)}`
|
||||
: ''}{fillPct !== undefined ? ` (${fillPct}%)` : ''}
|
||||
</p>
|
||||
{#if ratio !== undefined && ratio >= COMPACTION_TRIGGER_RATIO}
|
||||
<p class="mt-1 text-tertiary">History will be compacted soon to free up space.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -34,6 +34,37 @@ chronological thinking the model should not keep
|
||||
expect(formatCompactSummary(raw)).toBe('a\n\nb')
|
||||
})
|
||||
|
||||
it('keeps the summary content when <summary> has no closing tag', () => {
|
||||
const raw = '<summary>\n1. Primary Request and Intent: build the thing\n2. Pending Tasks: none'
|
||||
const formatted = formatCompactSummary(raw)
|
||||
expect(formatted).not.toContain('<summary>')
|
||||
expect(formatted).toContain('Primary Request and Intent: build the thing')
|
||||
expect(formatted).toContain('Pending Tasks: none')
|
||||
})
|
||||
|
||||
it('drops the analysis scratchpad even when <summary> is left unclosed', () => {
|
||||
const raw =
|
||||
'<analysis>\nchronological thinking the model should not keep\n</analysis>\n<summary>\nthe real summary'
|
||||
const formatted = formatCompactSummary(raw)
|
||||
expect(formatted).not.toContain('chronological thinking')
|
||||
expect(formatted).not.toContain('<analysis>')
|
||||
expect(formatted).not.toContain('<summary>')
|
||||
expect(formatted).toBe('the real summary')
|
||||
})
|
||||
|
||||
it('strips an orphaned closing summary tag', () => {
|
||||
expect(formatCompactSummary('plain summary</summary>')).toBe('plain summary')
|
||||
})
|
||||
|
||||
it('does not leak analysis scratchpad that mentions a literal <summary> tag', () => {
|
||||
const raw = `<analysis>scratchpad mentions <summary> before output</analysis>
|
||||
<summary>real summary</summary>`
|
||||
const formatted = formatCompactSummary(raw)
|
||||
expect(formatted).toBe('real summary')
|
||||
expect(formatted).not.toContain('scratchpad')
|
||||
expect(formatted).not.toContain('before output')
|
||||
})
|
||||
|
||||
it('strips every analysis block, not just the first, when the summary is untagged', () => {
|
||||
const raw = '<analysis>first</analysis>\nkept one\n<analysis>second</analysis>\nkept two'
|
||||
const formatted = formatCompactSummary(raw)
|
||||
|
||||
@@ -100,13 +100,27 @@ export function getCompactionSummaryPrompt(): string {
|
||||
* well-formed-but-untagged summary is still usable.
|
||||
*/
|
||||
export function formatCompactSummary(raw: string): string {
|
||||
// Strip the analysis scratchpad first: it precedes the summary and may itself
|
||||
// mention <summary>/<analysis> tokens that would otherwise be mistaken for the
|
||||
// real summary boundary.
|
||||
let formatted = raw.replace(/<analysis>[\s\S]*?<\/analysis>/gi, '')
|
||||
|
||||
const summaryMatch = formatted.match(/<summary>([\s\S]*?)<\/summary>/i)
|
||||
if (summaryMatch) {
|
||||
formatted = (summaryMatch[1] ?? '').trim()
|
||||
} else {
|
||||
// A truncated response or a weaker model sometimes opens <summary> without
|
||||
// closing it. The text after the opener is still the summary, so keep it
|
||||
// rather than leak the bare tag.
|
||||
const openIdx = formatted.search(/<summary>/i)
|
||||
if (openIdx !== -1) {
|
||||
formatted = formatted.slice(openIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// An orphaned opener or closer left by either branch must never reach the user.
|
||||
formatted = formatted.replace(/<\/?(?:analysis|summary)>/gi, '')
|
||||
|
||||
// Collapse the blank-line runs left behind by stripping the analysis block.
|
||||
return formatted.replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
@@ -2757,6 +2757,18 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
expect(content).not.toContain('frontend AI draft store')
|
||||
})
|
||||
|
||||
it('honors user-supplied shared folder paths without asking first', () => {
|
||||
const content = prepareGlobalSystemMessage(undefined, {
|
||||
user: { username: 'admin', is_admin: true, folders: ['evals'] }
|
||||
}).content as string
|
||||
|
||||
expect(content).toContain(
|
||||
'If the user supplies a fully qualified `f/<folder>/...` path, use that exact path'
|
||||
)
|
||||
expect(content).toContain('Do not ask for folder confirmation')
|
||||
expect(content).toContain('substitute a `u/admin/...` path unless a tool rejects it')
|
||||
})
|
||||
|
||||
describe('folder guidance', () => {
|
||||
const guidanceOf = (user: {
|
||||
username: string
|
||||
|
||||
@@ -748,6 +748,7 @@ Path conventions:
|
||||
- A workspace path starts with one of two namespaces; its trailing <name> may itself contain "/", so a path has three or more segments:
|
||||
- \`u/${username}/<name>\` — your personal scope. Default for ad-hoc, exploratory, or scratch work.
|
||||
- \`f/<folder>/<name>\` — a shared folder scope; the <folder> must already exist (a bare \`f/<name>\` with no folder segment is INVALID and will fail).
|
||||
- If the user supplies a fully qualified \`f/<folder>/...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it.
|
||||
- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/<name>\`. Never invent an \`f/<folder>/...\` path for a folder that does not exist.${folderGuidanceBlock}
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* When the mode is ready to ship to everyone, 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 `/global_drafts` dev route) so the rip-out
|
||||
* is a small grep.
|
||||
* `change_mode` tool enum, the AI skills workspace settings tab, and the
|
||||
* `/global_drafts` dev route) so the rip-out is a small grep.
|
||||
*/
|
||||
const STORAGE_KEY = 'wm_dev_global_ai'
|
||||
|
||||
|
||||
@@ -129,9 +129,17 @@ export class AIChatEditorHandler {
|
||||
const deletedChange = group.changes[0]
|
||||
const addedChange = group.changes[1]
|
||||
if (deletedChange.type === 'deleted' && addedChange.type === 'added_block') {
|
||||
applyChange(this.editor, deletedChange)
|
||||
addedChange.position.afterLineNumber = deletedChange.range.startLine - 1
|
||||
applyChange(this.editor, addedChange)
|
||||
this.editor.executeEdits('chat', [
|
||||
{
|
||||
range: {
|
||||
startLineNumber: deletedChange.range.startLine,
|
||||
startColumn: 1,
|
||||
endLineNumber: deletedChange.range.endLine + 1,
|
||||
endColumn: 0
|
||||
},
|
||||
text: addedChange.value + '\n'
|
||||
}
|
||||
])
|
||||
} else {
|
||||
throw new Error('Invalid group')
|
||||
}
|
||||
@@ -284,7 +292,7 @@ export class AIChatEditorHandler {
|
||||
})
|
||||
|
||||
if (!opts?.applyAll) {
|
||||
;({ collection, ids } = await displayVisualChanges(
|
||||
; ({ collection, ids } = await displayVisualChanges(
|
||||
'editor-windmill-chat-style',
|
||||
this.editor,
|
||||
changes,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { AIMode } from '../AIChatManager.svelte'
|
||||
import { getAiChatManager } from '../aiChatManagerContext'
|
||||
import { Check, Play } from 'lucide-svelte'
|
||||
import MermaidDisplay from './MermaidDisplay.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
@@ -90,7 +91,7 @@
|
||||
if (
|
||||
aiChatManager.mode !== AIMode.SCRIPT ||
|
||||
!aiChatManager.scriptEditorApplyCode ||
|
||||
code === aiChatManager.scriptEditorOptions?.code
|
||||
code === aiChatManager.scriptEditorOptions?.getCode()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -108,14 +109,18 @@
|
||||
<div
|
||||
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
|
||||
>
|
||||
<HighlightCode
|
||||
className="p-1"
|
||||
code={code ?? ''}
|
||||
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
|
||||
language={undefined}
|
||||
onApplyCode={handleApplyCode}
|
||||
{showApplyButton}
|
||||
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
|
||||
/>
|
||||
{#if language === 'mermaid'}
|
||||
<MermaidDisplay code={code ?? ''} />
|
||||
{:else}
|
||||
<HighlightCode
|
||||
className="p-1"
|
||||
code={code ?? ''}
|
||||
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
|
||||
language={undefined}
|
||||
onApplyCode={handleApplyCode}
|
||||
{showApplyButton}
|
||||
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
|
||||
|
||||
let { code }: { code: string } = $props()
|
||||
|
||||
const isDarkMode = useIsDarkMode()
|
||||
|
||||
let svg = $state<string | undefined>(undefined)
|
||||
// The exact source that produced `svg`. The diagram is only shown while this
|
||||
// still matches the current `code`, so a later edit that fails to parse falls
|
||||
// back to the raw source instead of leaving a stale, mismatched diagram.
|
||||
let renderedCode = $state<string | undefined>(undefined)
|
||||
// Monotonic token so an earlier-started render that resolves late can't
|
||||
// overwrite the result of a newer one (out-of-order async on rapid code/theme changes).
|
||||
let renderSeq = 0
|
||||
|
||||
async function render(source: string, dark: boolean) {
|
||||
const seq = ++renderSeq
|
||||
if (!source?.trim()) {
|
||||
svg = undefined
|
||||
renderedCode = undefined
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mermaid = (await import('mermaid')).default
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: dark ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
|
||||
suppressErrorRendering: true
|
||||
})
|
||||
// mermaid.render needs a fresh element id per attempt to avoid id collisions.
|
||||
const result = await mermaid.render(`mermaid-${randomUUID()}`, source)
|
||||
if (seq !== renderSeq) return
|
||||
svg = result.svg
|
||||
renderedCode = source
|
||||
} catch {
|
||||
// Parse failure (often a partial block still streaming in): fall back to the
|
||||
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
|
||||
// no longer matches the current `code`.
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void render(code, isDarkMode.val)
|
||||
})
|
||||
|
||||
// Only show the diagram while it corresponds to the current source.
|
||||
let showSvg = $derived(svg !== undefined && renderedCode === code)
|
||||
</script>
|
||||
|
||||
{#if showSvg}
|
||||
<div class="p-2 flex justify-center overflow-x-auto">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html svg}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Fallback while loading or when rendering fails: show the raw source -->
|
||||
<pre class="overflow-auto max-h-screen text-xs p-2">{code}</pre>
|
||||
{/if}
|
||||
@@ -867,7 +867,6 @@
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
syncExternalCode
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
@@ -931,7 +930,6 @@
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
syncExternalCode
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
import type { FlowState } from './flowState'
|
||||
import { nextId } from './flowModuleNextId'
|
||||
|
||||
function flowWith(ids: string[]): OpenFlow {
|
||||
return {
|
||||
summary: '',
|
||||
value: {
|
||||
modules: ids.map((id) => ({ id, value: { type: 'identity' } as any }))
|
||||
}
|
||||
} as OpenFlow
|
||||
}
|
||||
|
||||
function stateWith(keys: string[]): FlowState {
|
||||
return Object.fromEntries(keys.map((k) => [k, {}])) as FlowState
|
||||
}
|
||||
|
||||
describe('nextId', () => {
|
||||
it('produces a, b, c, ... for a fresh flow', () => {
|
||||
expect(nextId(stateWith(['failure']), flowWith([]))).toBe('a')
|
||||
expect(nextId(stateWith(['a', 'failure']), flowWith(['a']))).toBe('b')
|
||||
expect(nextId(stateWith(['a', 'b', 'c', 'failure']), flowWith(['a', 'b', 'c']))).toBe('d')
|
||||
})
|
||||
|
||||
it('ignores the reserved failure/preprocessor keys always present in flowState', () => {
|
||||
expect(nextId(stateWith(['failure', 'preprocessor']), flowWith([]))).toBe('a')
|
||||
})
|
||||
|
||||
// Regression: copy ids ("z2"), subflow result keys and other non-canonical keys land in
|
||||
// flowState; charsToNumber on them used to leak into the max and made new steps jump to
|
||||
// garbage ids like "bzw".
|
||||
it('is not poisoned by copy ids', () => {
|
||||
const ids = ['a', 'b', 'c']
|
||||
const state = stateWith([...ids, 'c2', 'a2', 'z2', 'c10', 'failure'])
|
||||
expect(nextId(state, flowWith(ids))).toBe('d')
|
||||
})
|
||||
|
||||
it('is not poisoned by subflow result keys', () => {
|
||||
const ids = ['a', 'b']
|
||||
const state = stateWith([...ids, 'subflow:abcd', 'Result', 'failure'])
|
||||
expect(nextId(state, flowWith(ids))).toBe('c')
|
||||
})
|
||||
|
||||
// A step renamed to a long lowercase word ("process") is a valid base-26 string and would
|
||||
// otherwise inflate the max; the length cutoff keeps such renames out of the sequence.
|
||||
it('is not poisoned by renames to long lowercase words or underscored ids', () => {
|
||||
const ids = ['a', 'b']
|
||||
const state = stateWith([...ids, 'process', 'my_step', 'failure'])
|
||||
expect(nextId(state, flowWith(ids))).toBe('c')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,35 @@
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
import { dfs } from './dfs'
|
||||
import type { FlowState } from './flowState'
|
||||
import { charsToNumber, numberToChars } from './idUtils'
|
||||
import { charsToNumber, forbiddenIds, numberToChars } from './idUtils'
|
||||
|
||||
const reservedIds = new Set(forbiddenIds)
|
||||
|
||||
// Returns the base-26 value of a key only if it is a short, auto-generated step id
|
||||
// (a, b, ..., z, aa, ...). flowState/module-id keys also include copy ids ("a2"), subflow
|
||||
// result keys ("subflow:..."), reserved keys and user-renamed ids; feeding those through
|
||||
// charsToNumber yields meaningless (often huge) numbers that would poison id generation and
|
||||
// make new steps jump to ids like "bzw". Short non-canonical keys are rejected via a
|
||||
// round-trip check; longer keys are skipped entirely, which also leaves user renames to long
|
||||
// lowercase words (e.g. "process") out of the sequence.
|
||||
function autoIdNumber(key: string): number | undefined {
|
||||
if (key.length >= 4 || reservedIds.has(key)) {
|
||||
return undefined
|
||||
}
|
||||
const num = charsToNumber(key)
|
||||
if (num < 0 || numberToChars(num) !== key) {
|
||||
return undefined
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
// Computes the next available id
|
||||
export function nextId(flowState: FlowState, fullFlow: OpenFlow): string {
|
||||
const allIds = dfs(fullFlow.value.modules, (fm) => fm.id)
|
||||
|
||||
const max = allIds.concat(Object.keys(flowState)).reduce((acc, key) => {
|
||||
if (key.length >= 4) {
|
||||
return acc
|
||||
} else {
|
||||
const num = charsToNumber(key)
|
||||
return Math.max(acc, num + 1)
|
||||
}
|
||||
const num = autoIdNumber(key)
|
||||
return num === undefined ? acc : Math.max(acc, num + 1)
|
||||
}, 0)
|
||||
return numberToChars(max)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
import { Database, Square } from 'lucide-svelte'
|
||||
import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte'
|
||||
import type { Job } from '$lib/gen'
|
||||
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
|
||||
import {
|
||||
getNodeColorClasses,
|
||||
aiActionToNodeState,
|
||||
type FlowNodeState
|
||||
} from '$lib/components/graph'
|
||||
import { getGraphContext } from '$lib/components/graph/graphContext'
|
||||
|
||||
interface Props {
|
||||
@@ -39,6 +43,9 @@
|
||||
job?: Job
|
||||
showJobStatus?: boolean
|
||||
flowHasChanged?: boolean
|
||||
/** When set, overrides the node outline with this run-state's colored outline.
|
||||
* Used to mark the branch taken at runtime on branchone/branchall nodes. */
|
||||
borderState?: FlowNodeState
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -67,14 +74,13 @@
|
||||
individualStepTests = false,
|
||||
job,
|
||||
showJobStatus = false,
|
||||
flowHasChanged = false
|
||||
flowHasChanged = false,
|
||||
borderState = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const flowGraphContext = getGraphContext()
|
||||
|
||||
let isMultiSelected = $derived(
|
||||
(flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1
|
||||
)
|
||||
let isMultiSelected = $derived((flowGraphContext?.selectionManager?.selectedIds?.length ?? 0) > 1)
|
||||
|
||||
const outputPickerVisible = $derived(
|
||||
(nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode
|
||||
@@ -96,6 +102,10 @@
|
||||
// AI action colors take priority over execution state, fallback to _VirtualItem
|
||||
const effectiveState = $derived(aiActionToNodeState(action) ?? outputType ?? '_VirtualItem')
|
||||
let colorClasses = $derived(getNodeColorClasses(effectiveState, selected))
|
||||
// The branch taken at runtime keeps its outline regardless of selection so it stays visible.
|
||||
let outlineClasses = $derived(
|
||||
borderState ? getNodeColorClasses(borderState, true).outline : colorClasses.outline
|
||||
)
|
||||
</script>
|
||||
|
||||
<VirtualItemWrapper
|
||||
@@ -109,7 +119,7 @@
|
||||
{#snippet children({ hover })}
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class="flex flex-row justify-between {colorClasses.outline} {center
|
||||
class="flex flex-row justify-between {outlineClasses} {center
|
||||
? 'items-center'
|
||||
: 'items-baseline'} w-full overflow-hidden rounded-md p-2 text-2xs module text-primary"
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { X } from 'lucide-svelte'
|
||||
import type { BranchAllStartN } from '../../graphBuilder.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
import { computeBorderStatus } from '../utils'
|
||||
interface Props {
|
||||
data: BranchAllStartN['data']
|
||||
id: string
|
||||
@@ -14,6 +15,10 @@
|
||||
let { data, id }: Props = $props()
|
||||
|
||||
const { selectionManager } = getGraphContext()
|
||||
|
||||
let borderStatus = $derived(
|
||||
computeBorderStatus(data.branchIndex, 'branchall', data.flowModuleState)
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper nodeId={id}>
|
||||
@@ -22,6 +27,7 @@
|
||||
label={data.label}
|
||||
selectable
|
||||
selected={selectionManager && selectionManager.isNodeSelected(id)}
|
||||
borderState={borderStatus}
|
||||
on:select={() => {
|
||||
setTimeout(() => data.eventHandlers.select(data.id))
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { X } from 'lucide-svelte'
|
||||
import type { BranchOneStartN } from '../../graphBuilder.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
import { computeBorderStatus } from '../utils'
|
||||
interface Props {
|
||||
data: BranchOneStartN['data']
|
||||
id: string
|
||||
@@ -13,6 +14,12 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
|
||||
let { data, id }: Props = $props()
|
||||
|
||||
// branchIndex is -1 for the default branch and 0-based for explicit branches;
|
||||
// branchChosen is 0 for default and 1-based, hence the +1.
|
||||
let borderStatus = $derived(
|
||||
computeBorderStatus(data.branchIndex + 1, 'branchone', data.flowModuleState)
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper nodeId={id}>
|
||||
@@ -22,6 +29,7 @@
|
||||
preLabel={data.preLabel}
|
||||
selectable
|
||||
selected={selectionManager && selectionManager.isNodeSelected(id)}
|
||||
borderState={borderStatus}
|
||||
on:select={() => {
|
||||
setTimeout(() => data?.eventHandlers?.select(data.id))
|
||||
}}
|
||||
|
||||
@@ -19,7 +19,8 @@ export function computeBorderStatus(
|
||||
} else {
|
||||
let flow_jobs_success = graphModuleState?.flow_jobs_success
|
||||
if (!flow_jobs_success) {
|
||||
return 'WaitingForPriorSteps'
|
||||
// No run yet: leave the branch border neutral instead of forcing a highlight.
|
||||
return undefined
|
||||
} else {
|
||||
let status = flow_jobs_success?.[branchIndex]
|
||||
if (status == undefined) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user