mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-16 16:02:33 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ec0aec3a | ||
|
|
9d348f84c7 | ||
|
|
02e47de8b4 | ||
|
|
64dffe6106 | ||
|
|
3d08197182 | ||
|
|
c3e11bd223 | ||
|
|
6885226c26 | ||
|
|
49d0310ecc | ||
|
|
54553b2add | ||
|
|
b51c0eabbe | ||
|
|
73dc892f9c | ||
|
|
57a99f66a8 | ||
|
|
b9b5988ebd | ||
|
|
e8078f2a96 | ||
|
|
a48ae656ae | ||
|
|
ee6d317e31 | ||
|
|
a9a9335a34 | ||
|
|
d0d5b295b9 | ||
|
|
9a8a9c480c | ||
|
|
f082fddf41 | ||
|
|
1c17b3c8db |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.812.0"
|
||||
".": "1.813.0"
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
`cargo run`; a normal build cannot start one at all.
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
|
||||
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
|
||||
Read before designing anything that creates users, tokens or sessions.
|
||||
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
|
||||
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
|
||||
users, tokens or sessions.
|
||||
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
|
||||
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
|
||||
silently, so frontend-only instrumentation records nothing.
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [1.813.0](https://github.com/windmill-labs/windmill/compare/v1.812.0...v1.813.0) (2026-09-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* back AI sessions up to the workspace object storage ([#11116](https://github.com/windmill-labs/windmill/issues/11116)) ([796b6e5](https://github.com/windmill-labs/windmill/commit/796b6e5297d8cceb842ec097f33ec1c3115058bd))
|
||||
* delete a browser's copy of an AI session past its workspace retention ([#11156](https://github.com/windmill-labs/windmill/issues/11156)) ([a48ae65](https://github.com/windmill-labs/windmill/commit/a48ae656ae59d600311f81ef357d08df5226515a))
|
||||
* rename saved agents from the agent editor and flag broken links ([#11147](https://github.com/windmill-labs/windmill/issues/11147)) ([57a99f6](https://github.com/windmill-labs/windmill/commit/57a99f66a88f195cac8f583b59d69d627cc1ec1d))
|
||||
* retention for AI sessions on the object store and in the browser ([#11152](https://github.com/windmill-labs/windmill/issues/11152)) ([ee6d317](https://github.com/windmill-labs/windmill/commit/ee6d317e318fa8a1506fb18d51c45b627070627a))
|
||||
* return an ai agent step's thinking in its job result ([#11140](https://github.com/windmill-labs/windmill/issues/11140)) ([c4e878e](https://github.com/windmill-labs/windmill/commit/c4e878e8313a72a16bfbe81fbb3935ee7728ec6f))
|
||||
* stream reasoning summaries in AI agent Responses API steps ([#11124](https://github.com/windmill-labs/windmill/issues/11124)) ([b51c0ea](https://github.com/windmill-labs/windmill/commit/b51c0eabbe774c78a9cbf824a3df6528d970b8f6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **apps:** re-check access in place after a password sign-in ([#11166](https://github.com/windmill-labs/windmill/issues/11166)) ([49d0310](https://github.com/windmill-labs/windmill/commit/49d0310ecc08040b6c6fa4f402584543d679e2e8))
|
||||
* **apps:** run-mode inline app component uses only pinned content ([#11135](https://github.com/windmill-labs/windmill/issues/11135)) ([781b5a5](https://github.com/windmill-labs/windmill/commit/781b5a57e81eb721d97d7b87e23dd84f23895400))
|
||||
* **cli:** keep the workspace color when settings are synced from git ([#11144](https://github.com/windmill-labs/windmill/issues/11144)) ([129c045](https://github.com/windmill-labs/windmill/commit/129c04559548cd1bcf67758ec416fb2a48e7b928))
|
||||
* **cli:** resolve lockgen imports through modules a push leaves alone ([#11160](https://github.com/windmill-labs/windmill/issues/11160)) ([54553b2](https://github.com/windmill-labs/windmill/commit/54553b2add6941395f03ca34ee24cf335a3b23c2))
|
||||
* dispatch workflow-as-code tasks from a deployed flow's inline step ([#11146](https://github.com/windmill-labs/windmill/issues/11146)) ([e8078f2](https://github.com/windmill-labs/windmill/commit/e8078f2a963166b09849650424583f5dcfd28a84))
|
||||
* keep sidebar confirmation dialogs from being confined to the rail ([#11158](https://github.com/windmill-labs/windmill/issues/11158)) ([b9b5988](https://github.com/windmill-labs/windmill/commit/b9b5988ebdf3edd75add445282977c516ef5ed51))
|
||||
* keep the instance users table's actions and header in view ([#11145](https://github.com/windmill-labs/windmill/issues/11145)) ([a9a9335](https://github.com/windmill-labs/windmill/commit/a9a9335a34a13ffd8cd2699adc92087b679548ca))
|
||||
* stop reading an array job result as wm_failure or http response ([#11154](https://github.com/windmill-labs/windmill/issues/11154)) ([9a8a9c4](https://github.com/windmill-labs/windmill/commit/9a8a9c480cf008761ec6ceaff694085a252a32ae))
|
||||
* walk the whole fork ancestry for app installations and fork conflicts ([#11151](https://github.com/windmill-labs/windmill/issues/11151)) ([73dc892](https://github.com/windmill-labs/windmill/commit/73dc892f9c9c840a5f0fb12fcbb28bbe0f38795f))
|
||||
* **worker:** bound cache transfers and import fetches in bun jobs ([#11138](https://github.com/windmill-labs/windmill/issues/11138)) ([31c4325](https://github.com/windmill-labs/windmill/commit/31c43255fdcc827c3fdf65e40238f8f3a83201cd))
|
||||
|
||||
## [1.812.0](https://github.com/windmill-labs/windmill/compare/v1.811.1...v1.812.0) (2026-09-15)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
|
||||
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236"
|
||||
"hash": "0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314"
|
||||
"hash": "139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru\n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_settings.workspace_id AS \"id!\",\n workspace_settings.ai_config->'sessions_retention_days' AS retention\n FROM workspace_settings\n LEFT JOIN usr ON usr.workspace_id = workspace_settings.workspace_id AND usr.email = $2\n WHERE workspace_settings.workspace_id = ANY($1)\n AND ($3 OR (usr.email IS NOT NULL AND NOT usr.disabled))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "retention",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8eee14066c86b4a4ef921576277c8a287bf1ff4b9d4301e3ea0efd8077936aff"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET ai_sessions_backup_generation = ai_sessions_backup_generation + 1 WHERE workspace_id = $1 AND large_file_storage IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c2f3492c2d80f5c6d157d8c1dab7f9ed1c0f4b4d1f07789b40945cfb8a3a7b39"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
|
||||
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT large_file_storage IS NOT NULL AS \"has_storage!\" FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_storage!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "dfa82a3f291cdc8f05cc4af6114e67c90e86d35dd5f74b1366f802dbb951ef9a"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ai_sessions_backup_generation FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "ai_sessions_backup_generation",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ea7bc2e5f53144ca23f8b3dea71cb6a994e969d915d50afbf0cb8547e6decf11"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71"
|
||||
}
|
||||
Generated
+104
-93
@@ -549,7 +549,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -561,7 +561,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10572,9 +10572,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "3.3.0"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873b730df6f0a9b74b13eb514e0dca4c2db0d8b68b74af98a2e9bf3f9d436585"
|
||||
checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025"
|
||||
dependencies = [
|
||||
"darling 0.24.1",
|
||||
"proc-macro2",
|
||||
@@ -12737,6 +12737,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
@@ -14783,7 +14794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14871,7 +14882,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14904,7 +14915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14917,7 +14928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15057,7 +15068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15080,7 +15091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15097,7 +15108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15123,7 +15134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15133,7 +15144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15150,7 +15161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15172,7 +15183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15195,7 +15206,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15211,7 +15222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15233,7 +15244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15255,7 +15266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15269,7 +15280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15304,7 +15315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15329,7 +15340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15357,7 +15368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15379,7 +15390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15399,7 +15410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15437,7 +15448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15465,7 +15476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15477,7 +15488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15501,7 +15512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15515,7 +15526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15550,7 +15561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15564,7 +15575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15583,7 +15594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15689,7 +15700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15709,7 +15720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15726,7 +15737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15753,7 +15764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15770,7 +15781,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15786,7 +15797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15807,7 +15818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15838,7 +15849,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15863,7 +15874,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15898,7 +15909,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15916,7 +15927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15925,7 +15936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15937,7 +15948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15949,7 +15960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15961,7 +15972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15973,7 +15984,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15985,7 +15996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15996,7 +16007,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16007,7 +16018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16019,7 +16030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16030,7 +16041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16052,7 +16063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16064,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16078,7 +16089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16095,7 +16106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16108,7 +16119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16120,7 +16131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16138,7 +16149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16154,7 +16165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16170,7 +16181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16184,7 +16195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16223,7 +16234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16263,7 +16274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16274,7 +16285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16309,7 +16320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16333,7 +16344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16366,7 +16377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16393,7 +16404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16426,7 +16437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16446,7 +16457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16480,7 +16491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16516,7 +16527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16539,7 +16550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16563,7 +16574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16587,7 +16598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16622,7 +16633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16650,7 +16661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16675,7 +16686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -16694,7 +16705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16812,7 +16823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -17500,14 +17511,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"syn 3.0.5",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -17541,14 +17552,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
"syn 3.0.5",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -17620,9 +17631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1c1dab33563c4907aff8b0da825fb66db60af82a
|
||||
d252afcc80e77fcc4f9a2a346b80908c8605a6c0
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
DELETE FROM workspace_runnable_dependencies WHERE runnable_is_agent;
|
||||
|
||||
DROP INDEX flow_workspace_without_hash_unique_idx;
|
||||
|
||||
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
|
||||
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, workspace_id)
|
||||
WHERE script_hash IS NULL;
|
||||
|
||||
ALTER TABLE workspace_runnable_dependencies DROP COLUMN runnable_is_agent;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- A flow step linked to a saved agent (an `ai_agent` resource) is recorded next to the scripts and
|
||||
-- subflows the flow runs, so renaming the agent can name the flows it would break. An agent row is
|
||||
-- neither a script nor a flow: readers of script usages have to exclude it.
|
||||
ALTER TABLE workspace_runnable_dependencies
|
||||
ADD COLUMN runnable_is_agent BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- A script step and a linked agent can share a path. Without the flag in the key, the second
|
||||
-- insert's ON CONFLICT DO NOTHING would silently drop one of the two rows.
|
||||
DROP INDEX flow_workspace_without_hash_unique_idx;
|
||||
|
||||
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
|
||||
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
|
||||
WHERE script_hash IS NULL;
|
||||
|
||||
-- The worker only records a flow when it is next deployed, so seed the ones already linking an
|
||||
-- agent from their current value.
|
||||
INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
|
||||
SELECT DISTINCT f.path, agent_ref #>> '{}', false, true, f.workspace_id
|
||||
FROM flow f
|
||||
CROSS JOIN LATERAL jsonb_path_query(f.value, 'lax $.** ? (@.type == "aiagent" && @.agent.type() == "string").agent') AS agent_ref
|
||||
ON CONFLICT DO NOTHING;
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.812.0"
|
||||
version = "1.813.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+21
-15
@@ -4380,6 +4380,23 @@ pub async fn monitor_db(
|
||||
}
|
||||
};
|
||||
|
||||
// Delete the AI session backups older than their workspace's retention. Every ~40 min
|
||||
// (240 iterations at the default 10 s, the most a u8 `should_run` counts): the retention
|
||||
// counts in days. Spawned for the same reason as the credential maintenance above, a
|
||||
// sweep of many sessions outlasting the join's deadline; the sweep's own advisory lock
|
||||
// keeps one server at a time at it.
|
||||
let ai_session_retention_f = async {
|
||||
#[cfg(feature = "parquet")]
|
||||
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(240) {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
let db = db.clone();
|
||||
tokio::spawn(
|
||||
async move { windmill_api::sweep_expired_ai_session_backups(&db).await },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// run every 2 iterations (~20s at the default LISTEN_NEW_EVENTS_INTERVAL_SEC).
|
||||
// Enterprise feature: the active `// freshness` backstop lives in
|
||||
// windmill-queue's `freshness_watchdog` (`private`); OSS gets a no-op stub.
|
||||
@@ -4434,6 +4451,7 @@ pub async fn monitor_db(
|
||||
cleanup_scheduled_job_deletions_f,
|
||||
git_auto_pull_f,
|
||||
git_credential_maintenance_f,
|
||||
ai_session_retention_f,
|
||||
pipeline_freshness_watchdog_f,
|
||||
reconcile_unarmed_schedules_f,
|
||||
);
|
||||
@@ -4694,17 +4712,11 @@ const GIT_AUTO_PULL_LOCK_ID: i64 = 737_483_921;
|
||||
/// Poll every git-sync repository with auto-pull enabled and enqueue a pull when
|
||||
/// the tracked branch has new commits (repo → Windmill direction).
|
||||
///
|
||||
/// Runs on a single replica at a time (advisory lock) and only on
|
||||
/// Enterprise-licensed instances. Detection is `git ls-remote`; GitHub-App
|
||||
/// repositories are skipped here and sync via webhooks instead (phase 2).
|
||||
/// Runs on a single replica at a time (advisory lock). Detection is
|
||||
/// `git ls-remote`; GitHub-App repositories are skipped here and sync via
|
||||
/// webhooks instead (phase 2).
|
||||
#[cfg(feature = "private")]
|
||||
pub async fn poll_git_auto_pull(db: &Pool<Postgres>) {
|
||||
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
|
||||
|
||||
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lock_conn = match db.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -4774,12 +4786,6 @@ const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923;
|
||||
/// sync down on its expiry date.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
async fn maintain_git_credentials(db: &Pool<Postgres>) {
|
||||
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
|
||||
|
||||
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transaction-scoped advisory lock, as for the schedule reconcile above: a
|
||||
// session lock on a pooled connection would ride back into the pool still
|
||||
// held if the sweep died before unlocking, and wedge the pass on every
|
||||
|
||||
@@ -232,7 +232,7 @@ workspace_key: workspace_id(char), kind(workspace_key_kind), key(char)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
|
||||
@@ -34,6 +34,54 @@ async fn configure_primary_lfs(db: &Pool<Postgres>, root_path: &str) -> anyhow::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configures the primary storage through the route, which is what sweeps the workspace's
|
||||
/// backups out of the instance store.
|
||||
async fn configure_primary_lfs_via_route(base: &str, root_path: &str) -> anyhow::Result<()> {
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/edit_large_file_storage_config")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "large_file_storage": {
|
||||
"type": "FilesystemStorage",
|
||||
"root_path": root_path,
|
||||
"public_resource": false,
|
||||
"advanced_permissions": null,
|
||||
"secondary_storage": {}
|
||||
}}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The instance setting allowing the instance store to stand in for a workspace without
|
||||
/// storage: `None` leaves it unset, which is on.
|
||||
async fn set_instance_fallback(db: &Pool<Postgres>, on: Option<bool>) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM global_settings WHERE name = 'ai_sessions_instance_storage_fallback'")
|
||||
.execute(db)
|
||||
.await?;
|
||||
if let Some(on) = on {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ('ai_sessions_instance_storage_fallback', $1)",
|
||||
)
|
||||
.bind(json!(on))
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls until nothing is under the directory, for a deletion that runs off the request.
|
||||
async fn wait_until_empty(dir: &std::path::Path, what: &str) {
|
||||
for _ in 0..100 {
|
||||
if files_under(dir).is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
panic!("{what}: objects left under {}", dir.display());
|
||||
}
|
||||
|
||||
async fn list(base: &str, token: &str) -> anyhow::Result<Value> {
|
||||
let resp = authed(client().get(format!("{base}/ai/sessions/list")), token)
|
||||
.send()
|
||||
@@ -87,7 +135,8 @@ async fn rotate(base: &str, key: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// The user's prefix on disk, `windmill_ai_sessions/{w_id}/g{generation}/{email hash}`,
|
||||
/// under whichever generation is current.
|
||||
/// under the newest generation: deleting an older generation's objects leaves its
|
||||
/// directories behind, and `read_dir` order differs across filesystems.
|
||||
fn user_root(storage_dir: &std::path::Path, email: &str) -> std::path::PathBuf {
|
||||
let workspace = storage_dir.join("windmill_ai_sessions/test-workspace");
|
||||
let hash = calculate_hash(email);
|
||||
@@ -96,8 +145,18 @@ fn user_root(storage_dir: &std::path::Path, email: &str) -> std::path::PathBuf {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|entry| entry.path().join(&hash))
|
||||
.find(|path| path.exists())
|
||||
.filter_map(|entry| {
|
||||
let generation: i64 = entry
|
||||
.file_name()
|
||||
.to_str()?
|
||||
.strip_prefix('g')?
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some((generation, entry.path().join(&hash)))
|
||||
})
|
||||
.filter(|(_, path)| path.exists())
|
||||
.max_by_key(|(generation, _)| *generation)
|
||||
.map(|(_, path)| path)
|
||||
.expect("the user has backups under the current key")
|
||||
}
|
||||
|
||||
@@ -132,7 +191,9 @@ async fn test_backups_round_trip_encrypted_and_scoped_to_the_user(
|
||||
server.addr.port()
|
||||
);
|
||||
|
||||
// No storage configured: the browser is told to stop trying.
|
||||
// No storage configured, and the instance store (another test of this process may
|
||||
// have loaded one) not allowed to stand in: the browser is told to stop trying.
|
||||
set_instance_fallback(&db, Some(false)).await?;
|
||||
let listing = list(&base, "SECRET_TOKEN").await?;
|
||||
assert_eq!(listing["enabled"], false);
|
||||
assert_eq!(listing["sessions"], json!([]));
|
||||
@@ -945,6 +1006,96 @@ async fn test_backups_round_trip_encrypted_and_scoped_to_the_user(
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// An incremental part changing more than one object unlists the session before its
|
||||
// writes, so one write failing after another landed leaves it absent rather than listed
|
||||
// as a mix of old and new pieces. A directory planted at `artifacts.json` fails that write.
|
||||
let s10_head =
|
||||
json!({ "id": "s10", "workspace_id": "test-workspace", "createdAt": 10, "chatId": "c1" });
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({
|
||||
"owner": "test@windmill.dev",
|
||||
"sessions": [{ "id": "s10", "whole": true, "head": s10_head, "chats": s9_chats(&["c1"]), "artifacts": { "items": ["a1"] } }]
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let s10_dir = user_root(storage_dir.path(), "test@windmill.dev").join("sessions/s10");
|
||||
let artifacts_path = s10_dir.join("artifacts.json");
|
||||
std::fs::remove_file(&artifacts_path)?;
|
||||
std::fs::create_dir(&artifacts_path)?;
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({
|
||||
"owner": "test@windmill.dev",
|
||||
"sessions": [{ "id": "s10", "chats": s9_chats(&["c2"]), "artifacts": { "items": ["a2"] } }]
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let answer: Value = resp.json().await?;
|
||||
assert!(
|
||||
answer["results"][0]["error"].is_string(),
|
||||
"the artifacts write must fail: {answer}"
|
||||
);
|
||||
assert!(answer["results"][0]["needs_whole"].is_null());
|
||||
assert!(
|
||||
s10_dir.join("chats/c2.json").is_file(),
|
||||
"the chat landed before the artifacts failed"
|
||||
);
|
||||
let s10_listed = |listing: Value| {
|
||||
listing["sessions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["id"] == "s10")
|
||||
};
|
||||
assert!(!s10_listed(list(&base, "SECRET_TOKEN").await?));
|
||||
assert_eq!(
|
||||
pull(&base, "SECRET_TOKEN", &["s10"]).await?["sessions"],
|
||||
json!([])
|
||||
);
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({
|
||||
"owner": "test@windmill.dev",
|
||||
"sessions": [{ "id": "s10", "chats": s9_chats(&["c3"]) }]
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let answer: Value = resp.json().await?;
|
||||
assert_eq!(answer["results"][0]["needs_whole"], true);
|
||||
assert!(!s10_listed(list(&base, "SECRET_TOKEN").await?));
|
||||
std::fs::remove_dir(&artifacts_path)?;
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({
|
||||
"owner": "test@windmill.dev",
|
||||
"sessions": [{ "id": "s10", "whole": true, "head": s10_head, "chats": s9_chats(&["c1", "c2", "c3"]), "artifacts": { "items": ["a2"] } }]
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(s10_listed(list(&base, "SECRET_TOKEN").await?));
|
||||
let pulled = pull(&base, "SECRET_TOKEN", &["s10"]).await?;
|
||||
assert_eq!(
|
||||
pulled["sessions"][0]["artifacts"],
|
||||
json!({ "items": ["a2"] })
|
||||
);
|
||||
assert_eq!(pulled_chats(pulled), vec!["c1", "c2", "c3"]);
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({ "owner": "test@windmill.dev", "removed": ["s10"] }),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Removal empties both prefixes.
|
||||
let resp = push(
|
||||
&base,
|
||||
@@ -1083,3 +1234,349 @@ async fn test_backup_writes_are_refused_for_the_wrong_owner_token_or_id(
|
||||
assert!(files_under(storage_dir.path()).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the object's modification time `days` back: the FilesystemStorage answers
|
||||
/// `last_modified` from it, so this is a session no push touched since.
|
||||
fn age_object(path: &std::path::Path, days: u64) -> std::io::Result<()> {
|
||||
let at = std::time::SystemTime::now() - std::time::Duration::from_secs(days * 86_400);
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(path)?
|
||||
.set_modified(at)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_expired_backups_are_swept_by_age_and_left_out_of_the_listing(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
configure_primary_lfs(&db, &storage_dir.path().to_string_lossy()).await?;
|
||||
|
||||
let chat = |sid: &str, cid: &str| {
|
||||
json!({ "id": cid, "record": { "id": cid, "sessionId": sid, "lastModified": 2,
|
||||
"actualMessages": [], "displayMessages": [] } })
|
||||
};
|
||||
let whole = |sid: &str| {
|
||||
json!({
|
||||
"id": sid, "whole": true, "epoch": 0,
|
||||
"head": { "id": sid, "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" },
|
||||
"chats": [chat(sid, "c1")],
|
||||
"images": [{ "chat_id": "c1", "id": "img1", "data_url": "data:image/png;base64,AAAA" }],
|
||||
"artifacts": { "items": [], "versions": [] }
|
||||
})
|
||||
};
|
||||
// Two pushes split over parts of which only the first part landed: one a browser
|
||||
// abandoned long ago (its token aged past the retention), one still in flight.
|
||||
let opening = |sid: &str| {
|
||||
json!({
|
||||
"id": sid, "whole": true, "epoch": 0, "push": format!("t-{sid}"), "opens": true,
|
||||
"partial": true, "chats": [chat(sid, "c1")],
|
||||
"head": { "id": sid, "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" }
|
||||
})
|
||||
};
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({ "owner": "test@windmill.dev", "sessions": [
|
||||
whole("old"), whole("live"), opening("abandoned"), opening("inflight")
|
||||
] }),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let root = user_root(storage_dir.path(), "test@windmill.dev");
|
||||
age_object(&root.join("index/old/0"), 40)?;
|
||||
age_object(&root.join("index/abandoned/push"), 40)?;
|
||||
|
||||
let listed = |listing: Value| -> Vec<String> {
|
||||
let mut ids: Vec<String> = listing["sessions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
let objects = |root: &std::path::Path| -> Vec<String> {
|
||||
files_under(root)
|
||||
.into_iter()
|
||||
.map(|(p, _)| p.strip_prefix(root).unwrap().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Without a retention nothing is swept, however old.
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live", "old"]);
|
||||
|
||||
let set_retention = |days: Value| {
|
||||
authed(
|
||||
client().post(format!("{base}/workspaces/edit_copilot_config")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "sessions_retention_days": days }))
|
||||
.send()
|
||||
};
|
||||
let resp = set_retention(json!(0)).await?;
|
||||
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
||||
let resp = set_retention(json!(30)).await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
// The listing leaves the expired session out before the sweep reaches it.
|
||||
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
|
||||
assert!(root.join("sessions/old/head.json").exists());
|
||||
|
||||
// A removal cut short (a directory stands where the head is, so it cannot be unlinked)
|
||||
// leaves the sweep's record with the markers gone; the next pass finds it and finishes.
|
||||
let head = root.join("sessions/old/head.json");
|
||||
std::fs::remove_file(&head)?;
|
||||
std::fs::create_dir(&head)?;
|
||||
std::fs::write(head.join("planted"), b"")?;
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
assert!(root.join("index/old/sweep").exists());
|
||||
assert!(!root.join("index/old/0").exists());
|
||||
assert!(root.join("sessions/old/chats/c1.json").exists());
|
||||
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
|
||||
std::fs::remove_dir_all(&head)?;
|
||||
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
let remaining = objects(&root);
|
||||
assert!(
|
||||
remaining
|
||||
.iter()
|
||||
.all(|p| !p.contains("/old/") && !p.contains("/abandoned/")),
|
||||
"{remaining:?}"
|
||||
);
|
||||
for kept in [
|
||||
"index/live/0",
|
||||
"sessions/live/head.json",
|
||||
"sessions/live/chats/c1.json",
|
||||
"images/live/c1/img1",
|
||||
"index/inflight/push",
|
||||
"sessions/inflight/head.json",
|
||||
] {
|
||||
assert!(
|
||||
remaining.iter().any(|p| p == kept),
|
||||
"{kept} in {remaining:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
|
||||
|
||||
// A second pass has nothing to do; a session pushed again since its marker aged is
|
||||
// renewed by the push, which rewrites the marker.
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
age_object(&root.join("index/live/0"), 40)?;
|
||||
let resp = push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({ "owner": "test@windmill.dev",
|
||||
"sessions": [{ "id": "live", "epoch": 0, "chats": [chat("live", "c2")] }] }),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
let mut after = objects(&root);
|
||||
after.sort();
|
||||
let mut expected = remaining.clone();
|
||||
expected.push("sessions/live/chats/c2.json".to_string());
|
||||
expected.sort();
|
||||
assert_eq!(after, expected);
|
||||
assert_eq!(listed(list(&base, "SECRET_TOKEN").await?), ["live"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts the process-wide instance store back to none, even when an assertion fails.
|
||||
struct ResetInstanceStore;
|
||||
impl Drop for ResetInstanceStore {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut store) = windmill_object_store::OBJECT_STORE_SETTINGS.try_write() {
|
||||
*store = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the process-wide license key id back to none, an Enterprise plan in this build,
|
||||
/// even when an assertion fails.
|
||||
struct ResetLicensePlan;
|
||||
impl Drop for ResetLicensePlan {
|
||||
fn drop(&mut self) {
|
||||
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace without storage of its own backs up to the instance object store, every
|
||||
/// answer saying so (`fallback`); a storage of its own, once configured, answers instead,
|
||||
/// under a generation past everything the workspace left in the instance store, which the
|
||||
/// change deletes; a plan switched to Pro stops the fallback with the store still loaded.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_backups_fall_back_to_the_instance_storage(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
|
||||
// The instance store, built from its settings as `reload_object_store_setting` does.
|
||||
let instance_dir = tempfile::tempdir()?;
|
||||
let instance_root = instance_dir.path().to_string_lossy().to_string();
|
||||
*windmill_object_store::OBJECT_STORE_SETTINGS.write().await = Some(
|
||||
windmill_object_store::build_object_store_from_settings(
|
||||
windmill_object_store::ObjectSettings::Filesystem(
|
||||
windmill_object_store::FilesystemSettings { root_path: instance_root.clone() },
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let _reset = ResetInstanceStore;
|
||||
let in_instance = instance_dir
|
||||
.path()
|
||||
.join("windmill_ai_sessions/test-workspace");
|
||||
|
||||
// Turned off by the instance setting: the browser is told to stop trying.
|
||||
set_instance_fallback(&db, Some(false)).await?;
|
||||
assert_eq!(list(&base, "SECRET_TOKEN").await?["enabled"], false);
|
||||
set_instance_fallback(&db, None).await?;
|
||||
|
||||
// On, as it is unless turned off: the backups land in the instance store, under the
|
||||
// workspace's prefix, and every answer says which kind of store it came from.
|
||||
let head =
|
||||
json!({ "id": "s1", "workspace_id": "test-workspace", "createdAt": 1, "chatId": "c1" });
|
||||
let entry = json!({ "id": "s1", "whole": true, "head": head, "chats": [{ "id": "c1", "record": { "id": "c1" } }] });
|
||||
let push_whole = || {
|
||||
push(
|
||||
&base,
|
||||
"SECRET_TOKEN",
|
||||
json!({ "owner": "test@windmill.dev", "sessions": [entry.clone()] }),
|
||||
)
|
||||
};
|
||||
let resp = push_whole().await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let pushed: Value = resp.json().await?;
|
||||
assert_eq!(pushed["fallback"], true);
|
||||
assert_eq!(pushed["results"], json!([{ "id": "s1" }]));
|
||||
let listing = list(&base, "SECRET_TOKEN").await?;
|
||||
assert_eq!(listing["enabled"], true);
|
||||
assert_eq!(listing["fallback"], true);
|
||||
assert_eq!(listing["sessions"][0]["id"], "s1");
|
||||
let fallback_storage_id = listing["storage_id"].clone();
|
||||
let pulled = pull(&base, "SECRET_TOKEN", &["s1"]).await?;
|
||||
assert_eq!(pulled["fallback"], true);
|
||||
assert_eq!(pulled["sessions"][0]["head"], head);
|
||||
assert!(!files_under(&in_instance).is_empty());
|
||||
|
||||
// The workspace's storage usage counts them, under a name of their own.
|
||||
let resp = authed(
|
||||
client().get(format!("{base}/job_helpers/storage_usage")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let usage: Value = resp.json().await?;
|
||||
let fallback_usage = usage["storages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["storage"] == "_ai_sessions_fallback_")
|
||||
.unwrap_or_else(|| panic!("no fallback usage in {usage}"));
|
||||
assert!(fallback_usage["bytes"].as_i64().unwrap() > 0);
|
||||
|
||||
// The retention sweep reaches what the instance store keeps for the workspace, choosing
|
||||
// that store from the row it reads the generation from.
|
||||
let instance_user = user_root(instance_dir.path(), "test@windmill.dev");
|
||||
age_object(&instance_user.join("index/s1/0"), 40)?;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET ai_config = '{\"sessions_retention_days\": 30}' \
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
windmill_api::sweep_expired_ai_session_backups(&db).await;
|
||||
assert!(!instance_user.join("index/s1/0").exists());
|
||||
assert!(!instance_user.join("sessions/s1/head.json").exists());
|
||||
// Pushed again, so the rotation below has a backup to delete.
|
||||
assert_eq!(push_whole().await?.status(), 200);
|
||||
assert!(!files_under(&in_instance).is_empty());
|
||||
|
||||
// A key rotation sweeps the older generation out of the instance store too.
|
||||
rotate(&base, &"c".repeat(64)).await?;
|
||||
wait_until_empty(&in_instance, "a rotation on the instance store").await;
|
||||
assert_eq!(list(&base, "SECRET_TOKEN").await?["sessions"], json!([]));
|
||||
assert_eq!(push_whole().await?.status(), 200);
|
||||
assert!(!files_under(&in_instance).is_empty());
|
||||
|
||||
// A storage of its own answers instead, under a generation the configuration moved past
|
||||
// everything the workspace left in the instance store: nothing there is read again,
|
||||
// whichever store a later return to the fallback finds, and it is deleted.
|
||||
let before = list(&base, "SECRET_TOKEN").await?;
|
||||
let storage_dir = tempfile::tempdir()?;
|
||||
configure_primary_lfs_via_route(&base, &storage_dir.path().to_string_lossy()).await?;
|
||||
wait_until_empty(&in_instance, "configuring a workspace storage").await;
|
||||
let listing = list(&base, "SECRET_TOKEN").await?;
|
||||
assert_eq!(listing["enabled"], true);
|
||||
assert!(listing.get("fallback").is_none(), "{listing}");
|
||||
assert_ne!(listing["storage_id"], fallback_storage_id);
|
||||
assert_eq!(
|
||||
listing["backup_generation"].as_i64(),
|
||||
before["backup_generation"].as_i64().map(|g| g + 1),
|
||||
"configuring a storage over the fallback must move the generation on"
|
||||
);
|
||||
assert_eq!(listing["sessions"], json!([]));
|
||||
assert_eq!(push_whole().await?.status(), 200);
|
||||
assert!(!files_under(storage_dir.path()).is_empty());
|
||||
assert!(files_under(&in_instance).is_empty());
|
||||
let resp = authed(
|
||||
client().get(format!("{base}/job_helpers/storage_usage?refresh=true")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let usage: Value = resp.json().await?;
|
||||
assert!(
|
||||
!usage.to_string().contains("_ai_sessions_fallback_"),
|
||||
"nothing is counted in the instance store for a workspace with storage: {usage}"
|
||||
);
|
||||
|
||||
// Pointed at the instance store's own bucket, a storage of its own keeps its live
|
||||
// backups there under the current generation, which no storage change deletes.
|
||||
configure_primary_lfs_via_route(&base, &instance_root).await?;
|
||||
assert_eq!(push_whole().await?.status(), 200);
|
||||
assert!(!files_under(&in_instance).is_empty());
|
||||
let same = list(&base, "SECRET_TOKEN").await?;
|
||||
configure_primary_lfs_via_route(&base, &instance_root).await?;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
assert!(!files_under(&in_instance).is_empty());
|
||||
let listing = list(&base, "SECRET_TOKEN").await?;
|
||||
assert!(listing.get("fallback").is_none(), "{listing}");
|
||||
assert_eq!(listing["backup_generation"], same["backup_generation"]);
|
||||
assert_eq!(listing["sessions"][0]["id"], "s1");
|
||||
|
||||
// Back to no storage of its own, the fallback answers; a plan switched to Pro while the
|
||||
// instance store stays loaded stops it at once, for the listing and the push alike.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/edit_large_file_storage_config")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "large_file_storage": null }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
assert_eq!(list(&base, "SECRET_TOKEN").await?["fallback"], true);
|
||||
let _enterprise_again = ResetLicensePlan;
|
||||
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new("test_pro".to_string()));
|
||||
assert_eq!(list(&base, "SECRET_TOKEN").await?["enabled"], false);
|
||||
let resp = push_whole().await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let pushed: Value = resp.json().await?;
|
||||
assert_eq!(pushed["enabled"], false, "{pushed}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1209,6 +1209,100 @@ export function main() {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A deployed flow runs an inline step as the `flow_node` its deploy rewrote it into,
|
||||
/// a `FlowScript` job rather than the preview job the editor runs. A workflow-as-code
|
||||
/// step's `task()` children must dispatch from that kind too, as re-runs of the same
|
||||
/// node, or the step passes its editor test and fails once deployed.
|
||||
///
|
||||
/// The step is cached: a child that shared the parent's result-cache key would hand
|
||||
/// its own result (`10`) back to the parent on resume, in place of the workflow's.
|
||||
#[sqlx::test(fixtures("base", "wac_flow_script"))]
|
||||
async fn test_bun_wac_task_dispatch_from_flow_script(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::flows::FlowNodeId;
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let node = FlowNodeId(3000000000000011);
|
||||
let job = RunJob::from(JobPayload::FlowScript {
|
||||
id: node,
|
||||
path: "f/system/wac_flow_script/a".to_string(),
|
||||
language: ScriptLang::Bun,
|
||||
cache_ttl: Some(60),
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
})
|
||||
.arg("n", serde_json::json!(5))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
job.json_result().unwrap(),
|
||||
serde_json::json!({"doubled": 10})
|
||||
);
|
||||
|
||||
let children: Vec<(String, Option<i64>, Option<i32>)> = sqlx::query_as(
|
||||
"SELECT kind::text, runnable_id, cache_ttl FROM v2_job WHERE parent_job = $1",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
children,
|
||||
vec![("flowscript".to_string(), Some(node.0), None)],
|
||||
"the task child re-runs the parent's flow node, outside the result cache"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `task(fn, { cache_ttl })` on an inline task of a deployed flow's step: the child runs
|
||||
/// the parent's code with the parent's arguments, so its result-cache key carries its
|
||||
/// step key, or the parent and every sibling would read its result back as their own.
|
||||
#[sqlx::test(fixtures("base", "wac_flow_script"))]
|
||||
async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::flows::FlowNodeId;
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let payload = || JobPayload::FlowScript {
|
||||
id: FlowNodeId(3000000000000012),
|
||||
path: "f/system/wac_flow_script/a".to_string(),
|
||||
language: ScriptLang::Bun,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
};
|
||||
|
||||
let mut children_from_cache = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let job = RunJob::from(payload())
|
||||
.arg("n", serde_json::json!(5))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
assert_eq!(
|
||||
job.json_result().unwrap(),
|
||||
serde_json::json!({"doubled": 10, "tripled": 15})
|
||||
);
|
||||
let from_cache: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM job_logs l JOIN v2_job j ON j.id = l.job_id \
|
||||
WHERE j.parent_job = $1 AND l.logs LIKE '%found in cache%'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
children_from_cache.push(from_cache);
|
||||
}
|
||||
assert_eq!(
|
||||
children_from_cache,
|
||||
vec![0, 2],
|
||||
"the second run serves each task from its own cache entry"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Environment Variable Tests
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- A three-deep fork chain whose schedule rows were cloned down at fork time.
|
||||
-- The middle fork has since deleted its copy, so the leaf's schedule shares
|
||||
-- its cron only with the root — the shape a direct-parent check misses.
|
||||
|
||||
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
|
||||
('sfc-root', 'sfc-root', 'sfc-admin', NULL),
|
||||
('sfc-mid', 'sfc-mid', 'sfc-admin', 'sfc-root'),
|
||||
('sfc-leaf', 'sfc-leaf', 'sfc-admin', 'sfc-mid');
|
||||
|
||||
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
|
||||
('sfc-root', 'cloud', 'sfc-root-key'),
|
||||
('sfc-mid', 'cloud', 'sfc-mid-key'),
|
||||
('sfc-leaf', 'cloud', 'sfc-leaf-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('sfc-root'), ('sfc-mid'), ('sfc-leaf');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('sfc-root', 'all', 'All users', '{}'),
|
||||
('sfc-mid', 'all', 'All users', '{}'),
|
||||
('sfc-leaf', 'all', 'All users', '{}');
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
||||
VALUES ('sfc-admin@windmill.dev', 'x', 'password', true, true, 'SFC Admin', 'sfc-admin');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('sfc-root', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
|
||||
('sfc-mid', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
|
||||
('sfc-leaf', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin');
|
||||
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('SFC_ADMIN_TOKEN'::bytea), 'hex'), 'SFC_ADMIN_', 'SFC_ADMIN_TOKEN', 'sfc-admin@windmill.dev', 't', true);
|
||||
|
||||
-- Enabling pushes the next run, which needs the scheduled script to exist.
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES
|
||||
('sfc-leaf', 'sfc-admin', 'export async function main() { return "ok" }', '{}', '', '', 'f/shared/job', 7788001, 'deno', '');
|
||||
|
||||
INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
|
||||
VALUES
|
||||
('sfc-root', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', true, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
|
||||
('sfc-leaf', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
|
||||
-- A path only the leaf has: nothing above shares it.
|
||||
('sfc-leaf', 'f/shared/own', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin');
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
-- A deployed flow whose inline bun step is workflow-as-code calling task(), in the
|
||||
-- shape the deploy leaves behind: the RawScript module rewritten into a flow_node that
|
||||
-- the step then runs as a FlowScript job. No lock, so the worker resolves
|
||||
-- windmill-client at run time like the other bun fixtures.
|
||||
INSERT INTO public.flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES (
|
||||
'test-workspace', '', '',
|
||||
'f/system/wac_flow_script',
|
||||
'{}',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"n":{"type":"integer","description":""}},"required":[],"type":"object"}',
|
||||
'{"modules":[{"id":"a","value":{"type":"flowscript","id":3000000000000011,"language":"bun","input_transforms":{"n":{"expr":"flow_input.n","type":"javascript"}}}}]}',
|
||||
'system'
|
||||
);
|
||||
|
||||
INSERT INTO public.flow_node(id, workspace_id, path, hash_v2, lock, code) VALUES (
|
||||
3000000000000011,
|
||||
'test-workspace',
|
||||
'f/system/wac_flow_script',
|
||||
'0000000000000000000000000000000000000000000000000000000000000011',
|
||||
NULL,
|
||||
E'import { workflow, task } from "windmill-client";
|
||||
|
||||
const double = task(async (n: number) => {
|
||||
return n * 2;
|
||||
});
|
||||
|
||||
export const main = workflow(async (n: number) => {
|
||||
const d = await double(n);
|
||||
return { doubled: d };
|
||||
});'
|
||||
);
|
||||
|
||||
-- The same flow's step with two tasks that cache their own result.
|
||||
INSERT INTO public.flow_node(id, workspace_id, path, hash_v2, lock, code) VALUES (
|
||||
3000000000000012,
|
||||
'test-workspace',
|
||||
'f/system/wac_flow_script',
|
||||
'0000000000000000000000000000000000000000000000000000000000000012',
|
||||
NULL,
|
||||
E'import { workflow, task } from "windmill-client";
|
||||
|
||||
const double = task(async (n: number) => {
|
||||
return n * 2;
|
||||
}, { cache_ttl: 60 });
|
||||
const triple = task(async (n: number) => {
|
||||
return n * 3;
|
||||
}, { cache_ttl: 60 });
|
||||
|
||||
export const main = workflow(async (n: number) => {
|
||||
const d = await double(n);
|
||||
const t = await triple(n);
|
||||
return { doubled: d, tripled: t };
|
||||
});'
|
||||
);
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::git_sync_ee::{
|
||||
create_repo_webhook, git_credential_for_url, repo_provider, repo_supports_managed_git_features,
|
||||
set_git_credential, GitProvider,
|
||||
create_repo_webhook, git_app_installations_for, git_credential_for_url, managed_pr_base_branch,
|
||||
repo_provider, repo_supports_managed_git_features, set_git_credential, GitProvider,
|
||||
};
|
||||
use windmill_common::workspaces::GitCredentialProvider;
|
||||
|
||||
@@ -278,3 +278,99 @@ async fn an_unreachable_gitlab_host_is_the_reported_error(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GitHub App installations are normally copied into a fork, but a workspace
|
||||
/// attached as a dev workspace, or forked before its parent connected the App,
|
||||
/// holds none, and neither does anything forked from it. The lookup reaches the
|
||||
/// nearest workspace up the chain that holds some, and the background App path
|
||||
/// (PR base resolution here) authenticates with that installation's token.
|
||||
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
||||
async fn app_installations_come_from_the_nearest_ancestor_holding_some(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use axum::{routing::get, Router};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// A stand-in GitHub API: one repository, and a record of who asked for it.
|
||||
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
|
||||
let app = Router::new().route(
|
||||
"/api/v3/repos/acme/repo",
|
||||
get({
|
||||
let seen = seen.clone();
|
||||
move |headers: axum::http::HeaderMap| {
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
let auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
seen.lock().unwrap().push(auth);
|
||||
axum::Json(serde_json::json!({ "default_branch": "trunk" }))
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let stub = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// The root holds the installation, with a cached token so nothing is minted.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'parent-ws'",
|
||||
)
|
||||
.bind(serde_json::json!([{
|
||||
"installation_id": 42, "account_id": "acme", "jwt_token": "x",
|
||||
"github_base_url": stub,
|
||||
"installation_token": "root-token", "installation_token_expiration": 4102444800i64
|
||||
}]))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// The fork's copy of the resource names the App-backed repository.
|
||||
sqlx::query("UPDATE resource SET value = $1::jsonb WHERE workspace_id = 'deep-fork-ws' AND path = 'u/admin/repo'")
|
||||
.bind(serde_json::json!({ "url": format!("{stub}/acme/repo.git"), "is_github_app": true }))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
git_app_installations_for(&db, "deep-fork-ws").await?,
|
||||
("parent-ws".to_string(), vec![(42, Some(stub.clone()))]),
|
||||
"two levels down, the root's installations are the ones to use"
|
||||
);
|
||||
assert_eq!(
|
||||
git_app_installations_for(&db, "orphan-ws").await?,
|
||||
("orphan-ws".to_string(), vec![]),
|
||||
"a workspace with nothing above it resolves nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
managed_pr_base_branch(&db, "deep-fork-ws", REPO)
|
||||
.await?
|
||||
.as_deref(),
|
||||
Some("trunk"),
|
||||
"the background App path reaches the repository through the root's installation"
|
||||
);
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert!(
|
||||
!seen.is_empty() && seen.iter().all(|auth| auth == "Bearer root-token"),
|
||||
"every call authenticated with the root's cached token: {seen:?}"
|
||||
);
|
||||
|
||||
// A closer holder takes precedence over the root.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'fork-ws'",
|
||||
)
|
||||
.bind(serde_json::json!([{
|
||||
"installation_id": 7, "account_id": "acme", "jwt_token": "x",
|
||||
"github_base_url": stub,
|
||||
"installation_token": "mid-token", "installation_token_expiration": 4102444800i64
|
||||
}]))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
git_app_installations_for(&db, "deep-fork-ws").await?.0,
|
||||
"fork-ws",
|
||||
"the nearest holder wins over the root"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Enabling a schedule in a fork warns about every ancestor sharing the path,
|
||||
//! not only the direct parent: the row was cloned down the whole chain, so the
|
||||
//! cron is shared with whichever ancestors still hold a copy.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
async fn set_enabled(
|
||||
base: &str,
|
||||
path: &str,
|
||||
enabled: bool,
|
||||
force: bool,
|
||||
) -> anyhow::Result<(u16, String)> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("{base}/api/w/sfc-leaf/schedules/setenabled/{path}"))
|
||||
.header("Authorization", "Bearer SFC_ADMIN_TOKEN")
|
||||
.json(&json!({ "enabled": enabled, "force": force }))
|
||||
.send()
|
||||
.await?;
|
||||
Ok((resp.status().as_u16(), resp.text().await?))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("schedule_fork_conflict"))]
|
||||
async fn enabling_in_a_fork_names_the_nearest_ancestor_sharing_the_path(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!("http://localhost:{}", server.addr.port());
|
||||
|
||||
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
|
||||
assert_eq!(status, 400, "{body}");
|
||||
assert!(
|
||||
body.contains("fork-conflict:schedule:sfc-root"),
|
||||
"the middle fork deleted its copy, so the root is the one still sharing the cron: {body}"
|
||||
);
|
||||
|
||||
let (status, body) = set_enabled(&base, "f/shared/own", true, false).await?;
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"a path nothing upstream has enables freely: {body}"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
|
||||
VALUES ('sfc-mid', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
|
||||
assert_eq!(status, 400, "{body}");
|
||||
assert!(
|
||||
body.contains("fork-conflict:schedule:sfc-mid"),
|
||||
"with the parent holding a copy again, it is the nearest and gets named: {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,18 +3,23 @@
|
||||
//! extractor actually grants. Membership is not the only path: a superadmin is authed into
|
||||
//! any existing workspace without a `usr` row, and `admins` has no `usr` rows at all, so
|
||||
//! answering from `usr` alone reports live workspaces as unresolvable and the client deletes
|
||||
//! sessions that still work.
|
||||
//! sessions that still work. `POST /workspaces/session_workspace_retention`, the AI session
|
||||
//! retention the same client deletes its own copies by, is a workspace setting and answers to
|
||||
//! the stricter bar, which is why the two are separate routes and tested together.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::collections::HashMap;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, String>> {
|
||||
async fn post<T: serde::de::DeserializeOwned>(
|
||||
port: u16,
|
||||
route: &str,
|
||||
token: &str,
|
||||
ids: &[&str],
|
||||
) -> anyhow::Result<T> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/workspaces/session_workspace_status"
|
||||
))
|
||||
.post(format!("http://localhost:{port}/api/workspaces/{route}"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&json!({ "workspace_ids": ids }))
|
||||
.send()
|
||||
@@ -23,6 +28,14 @@ async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, String>> {
|
||||
post(port, "session_workspace_status", token, ids).await
|
||||
}
|
||||
|
||||
async fn retention(port: u16, token: &str, ids: &[&str]) -> anyhow::Result<HashMap<String, u32>> {
|
||||
post(port, "session_workspace_retention", token, ids).await
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "session_workspace_status"))]
|
||||
async fn test_superadmin_reaches_workspaces_without_a_usr_row(
|
||||
db: Pool<Postgres>,
|
||||
@@ -60,3 +73,48 @@ async fn test_superadmin_reaches_workspaces_without_a_usr_row(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The retention a browser deletes its own copies by is a workspace setting, so unlike the
|
||||
/// status it is told only to a caller the authed extractor would let in.
|
||||
#[sqlx::test(fixtures("base", "session_workspace_status"))]
|
||||
async fn test_session_retention_is_told_only_to_members_who_can_be_authed(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let ids = ["foreign-workspace", "test-workspace", "no-such-workspace"];
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET ai_config = '{\"sessions_retention_days\": 7}' \
|
||||
WHERE workspace_id IN ('test-workspace', 'foreign-workspace')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// test@windmill.dev is a superadmin: authed into every workspace that exists.
|
||||
let sa = retention(port, "SECRET_TOKEN", &ids).await?;
|
||||
assert_eq!(sa["test-workspace"], 7);
|
||||
assert_eq!(sa["foreign-workspace"], 7);
|
||||
assert!(!sa.contains_key("no-such-workspace"));
|
||||
|
||||
// test2@windmill.dev is a member of test-workspace only.
|
||||
let usr = retention(port, "SECRET_TOKEN_2", &ids).await?;
|
||||
assert_eq!(usr["test-workspace"], 7);
|
||||
assert!(!usr.contains_key("foreign-workspace"));
|
||||
|
||||
// A disabled membership still reconciles its sessions — the status stays `active` — but
|
||||
// cannot be authed into the workspace, so it is told no setting.
|
||||
sqlx::query("UPDATE usr SET disabled = true WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
status(port, "SECRET_TOKEN_2", &ids).await?["test-workspace"],
|
||||
"active"
|
||||
);
|
||||
assert!(!retention(port, "SECRET_TOKEN_2", &ids)
|
||||
.await?
|
||||
.contains_key("test-workspace"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -902,6 +902,7 @@ mod tests {
|
||||
attachments: None,
|
||||
has_websearch: false,
|
||||
prompt_cache_key: None,
|
||||
reasoning_summary: false,
|
||||
};
|
||||
|
||||
AnthropicQueryBuilder::new(AIProvider::Anthropic, platform)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
ai_types::OpenAIToolCall,
|
||||
credentials::ProviderCredentials,
|
||||
image_handler::{prepare_messages_for_api, s3_object_to_content_part},
|
||||
proxy::{build_openai_compatible_proxy_request, ProxyBuildArgs, ProxyRequest},
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
@@ -11,7 +12,14 @@ use crate::{
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use windmill_common::{cache::Cache, client::AuthedClient, error::Error};
|
||||
|
||||
use super::REASONING_OFF_SENTINEL;
|
||||
|
||||
// Responses API structures
|
||||
#[derive(Deserialize)]
|
||||
@@ -192,13 +200,79 @@ pub struct ResponsesApiTextFormat {
|
||||
pub format: ResponsesApiTextFormatConfig,
|
||||
}
|
||||
|
||||
/// Reasoning config for the Responses API (`reasoning: { effort }`).
|
||||
/// The summary is intentionally not requested, mirroring the copilot chat: OpenAI
|
||||
/// gates reasoning summaries behind organization verification, so asking for one
|
||||
/// would fail the request for unverified orgs.
|
||||
/// Reasoning config for the Responses API (`reasoning: { effort, summary }`).
|
||||
#[derive(Serialize)]
|
||||
pub struct ResponsesApiReasoning {
|
||||
pub effort: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Refused reasoning summaries, so later requests skip asking instead of paying a
|
||||
/// rejected call each. A refusal belongs to the organization the request bills or to the
|
||||
/// model, so the key holds the model and everything that authenticates (the API key and
|
||||
/// the resource headers, which can carry it instead). Entries expire as an org gets verified.
|
||||
static ref REASONING_SUMMARY_UNAVAILABLE: Cache<(String, u64), Instant> = Cache::new(500);
|
||||
}
|
||||
|
||||
const REASONING_SUMMARY_UNAVAILABLE_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
fn reasoning_summary_cache_key(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
api_key: Option<&str>,
|
||||
custom_headers: &HashMap<String, String>,
|
||||
) -> (String, u64) {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
model.hash(&mut hasher);
|
||||
api_key.hash(&mut hasher);
|
||||
// Sorted: two maps with the same entries can iterate them in different orders.
|
||||
custom_headers
|
||||
.iter()
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.hash(&mut hasher);
|
||||
(base_url.to_string(), hasher.finish())
|
||||
}
|
||||
|
||||
fn credentials_cache_key(credentials: &ProviderCredentials, model: &str) -> (String, u64) {
|
||||
reasoning_summary_cache_key(
|
||||
&credentials.base_url,
|
||||
model,
|
||||
credentials.api_key.as_deref(),
|
||||
&credentials.custom_headers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this model is known to be refused reasoning summaries with these credentials.
|
||||
pub fn is_reasoning_summary_unavailable(credentials: &ProviderCredentials, model: &str) -> bool {
|
||||
REASONING_SUMMARY_UNAVAILABLE
|
||||
.get(&credentials_cache_key(credentials, model))
|
||||
.is_some_and(|learned_at| learned_at.elapsed() < REASONING_SUMMARY_UNAVAILABLE_TTL)
|
||||
}
|
||||
|
||||
/// Record that this model was refused a reasoning summary with these credentials.
|
||||
pub fn remember_reasoning_summary_unavailable(credentials: &ProviderCredentials, model: &str) {
|
||||
REASONING_SUMMARY_UNAVAILABLE.insert(credentials_cache_key(credentials, model), Instant::now());
|
||||
}
|
||||
|
||||
/// Whether a rejected request was refused over its reasoning summary, e.g. `Your
|
||||
/// organization must be verified to generate reasoning summaries` (param
|
||||
/// `reasoning.summary`). An OpenAI-kind resource can also point at a gateway that validates
|
||||
/// the body strictly and names only the unknown `summary` property.
|
||||
pub fn rejects_reasoning_summary(status: u16, body: &str) -> bool {
|
||||
// 422 is how FastAPI-based gateways reject a body that fails validation.
|
||||
if !matches!(status, 400 | 403 | 422) {
|
||||
return false;
|
||||
}
|
||||
let body = body.to_lowercase();
|
||||
let unknown_field = body.contains("additional properties are not allowed")
|
||||
|| body.contains("unrecognized request argument")
|
||||
|| body.contains("extra inputs are not permitted");
|
||||
body.contains("reasoning.summary")
|
||||
|| body.contains("verified to generate reasoning summar")
|
||||
|| body.contains("verified to stream reasoning summar")
|
||||
|| (unknown_field && body.contains("summary"))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -435,9 +509,13 @@ impl OpenAIQueryBuilder {
|
||||
tools,
|
||||
stream: Some(true),
|
||||
temperature: args.temperature,
|
||||
reasoning: args
|
||||
.reasoning_effort
|
||||
.map(|effort| ResponsesApiReasoning { effort: effort.to_string() }),
|
||||
reasoning: args.reasoning_effort.map(|effort| ResponsesApiReasoning {
|
||||
effort: effort.to_string(),
|
||||
// A request that does not reason has nothing to summarize, yet asking still
|
||||
// gets an unverified organization's request rejected.
|
||||
summary: (args.reasoning_summary && effort != REASONING_OFF_SENTINEL)
|
||||
.then(|| "auto".to_string()),
|
||||
}),
|
||||
max_output_tokens: args.max_tokens,
|
||||
text,
|
||||
prompt_cache_key: args.prompt_cache_key,
|
||||
@@ -538,9 +616,8 @@ impl QueryBuilder for OpenAIQueryBuilder {
|
||||
} else {
|
||||
Some(parser.accumulated_content)
|
||||
},
|
||||
// The Responses stream has no reasoning-summary event in
|
||||
// `OpenAIResponsesSSEEvent`, so nothing thinks out loud on this path yet.
|
||||
reasoning: None,
|
||||
reasoning: (!parser.accumulated_reasoning.is_empty())
|
||||
.then_some(parser.accumulated_reasoning),
|
||||
tool_calls: parser.accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(parser.events_str),
|
||||
annotations: parser.annotations,
|
||||
@@ -640,8 +717,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
|
||||
let args = BuildRequestArgs {
|
||||
fn text_args<'a>(
|
||||
messages: &'a [OpenAIMessage],
|
||||
system_prompt: Option<&'a str>,
|
||||
) -> BuildRequestArgs<'a> {
|
||||
BuildRequestArgs {
|
||||
messages,
|
||||
tools: None,
|
||||
model: "gpt-5",
|
||||
@@ -655,14 +735,98 @@ mod tests {
|
||||
attachments: None,
|
||||
has_websearch: false,
|
||||
prompt_cache_key: Some(PROMPT_CACHE_KEY),
|
||||
};
|
||||
reasoning_summary: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_body(args: &BuildRequestArgs<'_>) -> String {
|
||||
OpenAIQueryBuilder::new(AIProvider::OpenAI)
|
||||
.build_request(&args, &client(), "test-workspace")
|
||||
.build_request(args, &client(), "test-workspace")
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
|
||||
build_body(&text_args(messages, system_prompt)).await
|
||||
}
|
||||
|
||||
async fn reasoning_of(effort: Option<&str>, reasoning_summary: bool) -> serde_json::Value {
|
||||
let messages = vec![message("user", "hi")];
|
||||
let args = BuildRequestArgs {
|
||||
reasoning_effort: effort,
|
||||
reasoning_summary,
|
||||
..text_args(&messages, None)
|
||||
};
|
||||
let request: serde_json::Value = serde_json::from_str(&build_body(&args).await).unwrap();
|
||||
request["reasoning"].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_a_reasoning_summary_only_when_the_model_reasons() {
|
||||
assert_eq!(
|
||||
reasoning_of(Some("high"), true).await,
|
||||
serde_json::json!({ "effort": "high", "summary": "auto" })
|
||||
);
|
||||
assert_eq!(
|
||||
reasoning_of(Some("none"), true).await,
|
||||
serde_json::json!({ "effort": "none" })
|
||||
);
|
||||
assert_eq!(
|
||||
reasoning_of(Some("high"), false).await,
|
||||
serde_json::json!({ "effort": "high" })
|
||||
);
|
||||
assert!(reasoning_of(None, true).await.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_a_refused_reasoning_summary() {
|
||||
let unverified = r#"{"error":{"message":"Your organization must be verified to generate reasoning summaries. Please go to: https://platform.openai.com/settings/organization/general and click on Verify Organization.","type":"invalid_request_error","param":"reasoning.summary","code":"unsupported_value"}}"#;
|
||||
assert!(rejects_reasoning_summary(400, unverified));
|
||||
assert!(!rejects_reasoning_summary(500, unverified));
|
||||
assert!(rejects_reasoning_summary(
|
||||
400,
|
||||
r#"{"detail":"Additional properties are not allowed ('summary' was unexpected)"}"#
|
||||
));
|
||||
assert!(rejects_reasoning_summary(
|
||||
422,
|
||||
r#"{"detail":[{"type":"extra_forbidden","loc":["body","reasoning","summary"],"msg":"Extra inputs are not permitted","input":"auto"}]}"#
|
||||
));
|
||||
assert!(!rejects_reasoning_summary(
|
||||
400,
|
||||
r#"{"error":{"message":"Invalid 'prompt_cache_key': string too long","param":"prompt_cache_key"}}"#
|
||||
));
|
||||
}
|
||||
|
||||
/// A resource can authenticate through `headers` with no API key: one organization's
|
||||
/// refusal must not withhold summaries from another's.
|
||||
#[test]
|
||||
fn keys_a_refused_summary_by_the_header_credential() {
|
||||
let headers = |pairs: &[(&str, &str)]| {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(name, value)| (name.to_string(), value.to_string()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
};
|
||||
let url = "https://api.openai.com/v1";
|
||||
let org_a = headers(&[("Authorization", "Bearer org-a"), ("X-Trace", "1")]);
|
||||
let org_a_reordered = headers(&[("X-Trace", "1"), ("Authorization", "Bearer org-a")]);
|
||||
let org_b = headers(&[("Authorization", "Bearer org-b"), ("X-Trace", "1")]);
|
||||
|
||||
assert_ne!(
|
||||
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
|
||||
reasoning_summary_cache_key(url, "gpt-5", None, &org_b)
|
||||
);
|
||||
assert_eq!(
|
||||
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
|
||||
reasoning_summary_cache_key(url, "gpt-5", None, &org_a_reordered)
|
||||
);
|
||||
// A model can refuse summaries that another model on the same credentials streams.
|
||||
assert_ne!(
|
||||
reasoning_summary_cache_key(url, "gpt-5", None, &org_a),
|
||||
reasoning_summary_cache_key(url, "gpt-5-mini", None, &org_a)
|
||||
);
|
||||
}
|
||||
|
||||
/// The worker prepends the system prompt as a system message *and* passes it as
|
||||
/// `system_prompt`; the request must still carry it exactly once.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -27,6 +27,9 @@ pub struct BuildRequestArgs<'a> {
|
||||
/// the prefix (the step), never from the request. `None` retries a key the
|
||||
/// endpoint rejected.
|
||||
pub prompt_cache_key: Option<&'a str>,
|
||||
/// Ask for a summary of the model's reasoning where the provider streams one.
|
||||
/// `false` once the provider refused summaries to these credentials.
|
||||
pub reasoning_summary: bool,
|
||||
}
|
||||
|
||||
/// Response from AI provider
|
||||
|
||||
@@ -815,6 +815,14 @@ pub enum OpenAIResponsesSSEEvent {
|
||||
#[serde(rename = "response.output_text.annotation.added")]
|
||||
AnnotationAdded { annotation: OpenAIUrlCitationEvent },
|
||||
|
||||
/// A new reasoning summary part starts (only sent when `reasoning.summary` was requested)
|
||||
#[serde(rename = "response.reasoning_summary_part.added")]
|
||||
ReasoningSummaryPartAdded {},
|
||||
|
||||
/// Reasoning summary text delta
|
||||
#[serde(rename = "response.reasoning_summary_text.delta")]
|
||||
ReasoningSummaryTextDelta { delta: String },
|
||||
|
||||
/// Catch-all for unknown event types
|
||||
#[serde(other)]
|
||||
Other,
|
||||
@@ -823,6 +831,8 @@ pub enum OpenAIResponsesSSEEvent {
|
||||
/// OpenAI Responses API SSE Parser for streaming responses
|
||||
pub struct OpenAIResponsesSSEParser {
|
||||
pub accumulated_content: String,
|
||||
/// The reasoning summary streamed before the answer, kept so it can be stored with it.
|
||||
pub accumulated_reasoning: String,
|
||||
pub accumulated_tool_calls: HashMap<String, OpenAIToolCall>,
|
||||
/// Maps item_id -> (name, call_id) for function calls
|
||||
tool_call_metadata: HashMap<String, (String, String)>,
|
||||
@@ -836,12 +846,15 @@ pub struct OpenAIResponsesSSEParser {
|
||||
pub used_websearch: bool,
|
||||
/// Token usage from response.completed event
|
||||
pub usage: Option<OpenAIResponsesUsage>,
|
||||
/// Reasoning summary parts seen so far, to separate them as paragraphs
|
||||
reasoning_summary_parts: usize,
|
||||
}
|
||||
|
||||
impl OpenAIResponsesSSEParser {
|
||||
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
|
||||
Self {
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
accumulated_tool_calls: HashMap::new(),
|
||||
tool_call_metadata: HashMap::new(),
|
||||
tool_call_arguments: HashMap::new(),
|
||||
@@ -850,6 +863,7 @@ impl OpenAIResponsesSSEParser {
|
||||
annotations: Vec::new(),
|
||||
used_websearch: false,
|
||||
usage: None,
|
||||
reasoning_summary_parts: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,6 +973,28 @@ impl SSEParser for OpenAIResponsesSSEParser {
|
||||
}
|
||||
}
|
||||
|
||||
OpenAIResponsesSSEEvent::ReasoningSummaryPartAdded {} => {
|
||||
self.reasoning_summary_parts += 1;
|
||||
if self.reasoning_summary_parts > 1 {
|
||||
self.accumulated_reasoning.push_str("\n\n");
|
||||
let event =
|
||||
StreamingEvent::ReasoningTokenDelta { content: "\n\n".to_string() };
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
OpenAIResponsesSSEEvent::ReasoningSummaryTextDelta { delta } => {
|
||||
if !delta.is_empty() {
|
||||
self.accumulated_reasoning.push_str(&delta);
|
||||
let event = StreamingEvent::ReasoningTokenDelta { content: delta };
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore other event types
|
||||
OpenAIResponsesSSEEvent::Done {}
|
||||
| OpenAIResponsesSSEEvent::Created {}
|
||||
@@ -1015,6 +1051,33 @@ mod tests {
|
||||
assert_eq!(token_usage.total_tokens, Some(4821));
|
||||
}
|
||||
|
||||
struct ReasoningSink;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl StreamEventSink for ReasoningSink {
|
||||
async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error> {
|
||||
if let StreamingEvent::ReasoningTokenDelta { content } = event {
|
||||
events_str.push_str(&content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streams_openai_responses_reasoning_summary_parts_as_paragraphs() {
|
||||
let mut parser = OpenAIResponsesSSEParser::new(Box::new(ReasoningSink));
|
||||
for data in [
|
||||
r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}"#,
|
||||
r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"summary_index":0,"delta":"**Planning**"}"#,
|
||||
r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","output_index":0,"summary_index":1,"part":{"type":"summary_text","text":""}}"#,
|
||||
r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"summary_index":1,"delta":"Then answer"}"#,
|
||||
] {
|
||||
parser.parse_event_data(data).await.unwrap();
|
||||
}
|
||||
assert_eq!(parser.events_str, "**Planning**\n\nThen answer");
|
||||
assert_eq!(parser.accumulated_reasoning, "**Planning**\n\nThen answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_delta_parses_reasoning_content() {
|
||||
// DeepSeek and similar stream reasoning under `reasoning_content`.
|
||||
|
||||
@@ -377,8 +377,7 @@ pub struct AIAgentResult<'a> {
|
||||
/// The model's thinking across every iteration of the loop, in order, blank-line
|
||||
/// separated. Present whenever the provider's parser surfaced any, whether or not
|
||||
/// the step streams, so a downstream step never has to pick it out of `wm_stream`.
|
||||
/// Absent when the model thought nothing, and on the OpenAI Responses path, whose
|
||||
/// parser does not return reasoning yet.
|
||||
/// Absent when the model thought nothing.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1141,6 +1141,9 @@ impl NewToken {
|
||||
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
|
||||
/// skip it, since their scopes derive from the action being authorized, not the
|
||||
/// caller's token).
|
||||
///
|
||||
/// A token the system mints for itself with an `expiration` needs a label reserved in
|
||||
/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md).
|
||||
pub async fn create_token_internal(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
db: &DB,
|
||||
|
||||
@@ -77,6 +77,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
|
||||
get(list_paths_from_workspace_runnable),
|
||||
)
|
||||
.route(
|
||||
"/list_paths_linking_agent/{*path}",
|
||||
get(list_paths_linking_agent),
|
||||
)
|
||||
.route("/history_update/v/{version}", post(update_flow_history))
|
||||
.route("/get/v/{version}", get(get_flow_version_by_id))
|
||||
.route("/get/v/{version}/p/{*path}", get(get_flow_version))
|
||||
@@ -150,6 +154,7 @@ async fn list_flows(
|
||||
"favorite.path IS NOT NULL as starred",
|
||||
"ws_error_handler_muted",
|
||||
"o.labels",
|
||||
"(o.value->>'chat_input_enabled')::bool as chat_input_enabled",
|
||||
"draft.email IS NOT NULL as is_draft",
|
||||
// Per-path draft owners as a JSON array; see scripts.rs for the rationale
|
||||
// (non-member superadmin identity fallback via `password`, legacy NULL-email row).
|
||||
@@ -297,6 +302,10 @@ async fn list_flows(
|
||||
ws_error_handler_muted: None,
|
||||
deployment_msg: None,
|
||||
labels: None,
|
||||
chat_input_enabled: v
|
||||
.get("value")
|
||||
.and_then(|fv| fv.get("chat_input_enabled"))
|
||||
.and_then(|b| b.as_bool()),
|
||||
// No deployed row to inherit folder labels from.
|
||||
inherited_labels: None,
|
||||
is_draft: true,
|
||||
@@ -508,7 +517,7 @@ async fn list_paths_from_workspace_runnable(
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
|
||||
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
|
||||
path,
|
||||
matches!(runnable_kind, RunnableKind::Flow),
|
||||
w_id
|
||||
@@ -521,7 +530,7 @@ async fn list_paths_from_workspace_runnable(
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
|
||||
path,
|
||||
matches!(runnable_kind, RunnableKind::Flow),
|
||||
w_id
|
||||
@@ -534,6 +543,30 @@ async fn list_paths_from_workspace_runnable(
|
||||
Ok(Json(runnables))
|
||||
}
|
||||
|
||||
/// Flows with a step linked to the `ai_agent` resource at `path`, as of their last deploy.
|
||||
async fn list_paths_linking_agent(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:agent/{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let flows = sqlx::query_scalar!(
|
||||
r#"SELECT DISTINCT f.path
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2"#,
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(flows))
|
||||
}
|
||||
|
||||
async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if new_flow.ws_error_handler_muted.is_some_and(|val| val) {
|
||||
|
||||
@@ -179,9 +179,11 @@ async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow
|
||||
"http-test-user-2-cd34",
|
||||
"email-test-user-2-ef56",
|
||||
"my-ci-token",
|
||||
// Minted client-side by the editor (every TypeScript editor load) and the debugger.
|
||||
// Minted client-side by the editor (every TypeScript editor load), the debugger and
|
||||
// the object-storage "Test from a worker" button.
|
||||
"Ephemeral lsp token",
|
||||
"debugger-token",
|
||||
"ephemeral-test-connection: s3_bucket",
|
||||
] {
|
||||
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
|
||||
assert_eq!(
|
||||
|
||||
@@ -38,8 +38,8 @@ use windmill_common::{
|
||||
FlowVersionInfo, DB,
|
||||
};
|
||||
use windmill_queue::{
|
||||
cancel_job, get_result_and_success_by_id_from_flow, push, PushArgs, PushArgsOwned,
|
||||
PushIsolationLevel,
|
||||
cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs,
|
||||
PushArgsOwned, PushIsolationLevel,
|
||||
};
|
||||
|
||||
use crate::types::RunJobQuery;
|
||||
@@ -374,9 +374,9 @@ pub async fn run_wait_result_internal(
|
||||
}
|
||||
|
||||
pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result<Response> {
|
||||
let composite_result = serde_json::from_str::<WindmillCompositeResult>(result.get());
|
||||
let composite_result = parse_result_object::<WindmillCompositeResult>(result.get());
|
||||
match composite_result {
|
||||
Ok(WindmillCompositeResult {
|
||||
Some(WindmillCompositeResult {
|
||||
windmill_status_code,
|
||||
windmill_content_type,
|
||||
windmill_headers,
|
||||
@@ -1192,4 +1192,13 @@ mod result_to_response_tests {
|
||||
assert!(res.is_err(), "hop-by-hop header must be rejected: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn array_result_is_not_a_composite_response() {
|
||||
let json = r#"[201,"text/html",null,null,"<h1>hi</h1>"]"#;
|
||||
let resp = result_to_response(raw(json), true).expect("response");
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_bytes(resp).await, json.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,35 +1129,23 @@ pub async fn set_enabled(
|
||||
check_scopes(&authed, || format!("schedules:write:{}", path))?;
|
||||
reject_reserved_schedule_path(path)?;
|
||||
|
||||
// Block enabling a schedule in a fork when the parent has the same path
|
||||
// (regardless of parent's enabled flag), unless force=true. Two enabled
|
||||
// crons fire in lockstep; even when the parent is currently disabled the
|
||||
// user is likely to re-enable it later, at which point both fire — better
|
||||
// to surface that risk at every fork-side enable. There's no namespacing
|
||||
// fix for schedules (Phase 3 doesn't help cron); the user has to confirm
|
||||
// or point the script at fork-only side effects.
|
||||
// Block enabling a schedule in a fork when an ancestor has the same path
|
||||
// (regardless of its enabled flag), unless force=true. Two enabled crons
|
||||
// fire in lockstep; even when the ancestor is currently disabled the user
|
||||
// is likely to re-enable it later, at which point both fire — better to
|
||||
// surface that risk at every fork-side enable. There's no namespacing fix
|
||||
// for schedules (Phase 3 doesn't help cron); the user has to confirm or
|
||||
// point the script at fork-only side effects.
|
||||
if payload.enabled && !payload.force {
|
||||
let parent_id: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||||
&w_id
|
||||
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
|
||||
&mut *tx, "schedule", &w_id, path,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
if let Some(parent_id) = parent_id {
|
||||
let exists: Option<bool> = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM schedule WHERE workspace_id = $1 AND path = $2)",
|
||||
&parent_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if exists == Some(true) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"fork-conflict:schedule:{}",
|
||||
parent_id
|
||||
)));
|
||||
}
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"fork-conflict:schedule:{}",
|
||||
ancestor_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
|
||||
@@ -1699,9 +1687,9 @@ pub use windmill_queue::schedule::clear_schedule;
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetEnabled {
|
||||
pub enabled: bool,
|
||||
/// Bypass the parent-state warning when enabling a schedule in a fork
|
||||
/// whose parent has the same path enabled. The frontend sets this after
|
||||
/// the user confirms the duplicate-firing dialog.
|
||||
/// Bypass the fork-conflict warning when enabling a schedule in a fork
|
||||
/// while an ancestor workspace has the same path. The frontend sets this
|
||||
/// after the user confirms the duplicate-firing dialog.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
@@ -3882,8 +3882,8 @@ async fn update_token_label(
|
||||
Path(token_prefix): Path<String>,
|
||||
Json(req): Json<UpdateTokenLabelRequest>,
|
||||
) -> Result<String> {
|
||||
// The new label must not collide with a system-token namespace (`session`,
|
||||
// `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are
|
||||
// The new label must not collide with a system-token namespace (see
|
||||
// `windmill_common::auth::is_user_token`): those labels are
|
||||
// load-bearing, and a user-set collision would orphan the token — hidden
|
||||
// from the UI (`isUserToken`) and rejected by the editability guard below —
|
||||
// while it still authenticates. (`is_user_token(None)` is true, so clearing
|
||||
@@ -3922,6 +3922,9 @@ async fn update_token_label(
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
AND NOT starts_with(label, 'embed_app:')
|
||||
AND NOT starts_with(label, 'sdk_app:')
|
||||
AND NOT starts_with(label, 'impersonation:')
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! What the workspace key rotation and the AI session backup routes
|
||||
//! (`windmill-api/src/ai_sessions.rs`) share about the backups in the workspace storage.
|
||||
//! What the workspace key rotation, the workspace storage settings and the AI session backup
|
||||
//! routes (`windmill-api/src/ai_sessions.rs`) share about the backups: the store they live
|
||||
//! in, and what a rotation or a storage change deletes.
|
||||
//!
|
||||
//! The backups are ciphertext under the workspace key and live under a prefix named by a
|
||||
//! generation the rotation bumps (`workspace_settings.ai_sessions_backup_generation`) in the
|
||||
@@ -13,17 +14,29 @@
|
||||
//! one to read with and nothing to rewrite in place. A generation is never reused, so no
|
||||
//! deletion, however late, can touch live objects; a rotation that fails before its commit
|
||||
//! bumps nothing and deletes nothing; two rotations racing serialize on the key row.
|
||||
//!
|
||||
//! A workspace without storage of its own keeps its backups in the instance object store
|
||||
//! instead, under the same layout and key, while `ai_sessions_instance_storage_fallback`
|
||||
//! allows it. Configuring a storage for such a workspace bumps the generation in the
|
||||
//! transaction that sets it, so everything the workspace left in any instance store sits
|
||||
//! under a generation the routes never read again: a later return to the instance store,
|
||||
//! whichever it is by then, starts from a newer one. That is what lets a storage change
|
||||
//! delete the older generations from the instance store without fencing against what
|
||||
//! happens next, and a browser retire a removal owed to an instance store once the
|
||||
//! workspace's own storage answered.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use windmill_common::error::{Error, Result};
|
||||
use windmill_common::utils::calculate_hash;
|
||||
use windmill_common::DB;
|
||||
use windmill_object_store::object_store_reexports::{
|
||||
ObjectStore, ObjectStoreError, Path as ObjectPath,
|
||||
};
|
||||
use windmill_object_store::{object_store_error_to_error, ObjectStoreResource};
|
||||
use windmill_object_store::{
|
||||
object_store_error_to_error, object_store_location, ObjectStoreResource,
|
||||
};
|
||||
use windmill_types::s3::LargeFileStorage;
|
||||
|
||||
/// The root of every AI session backup key in a workspace's storage.
|
||||
@@ -31,6 +44,9 @@ pub const ROOT: &str = "windmill_ai_sessions";
|
||||
/// The push body cap: no object written through the routes is larger. One that is was
|
||||
/// planted by whoever holds the bucket's credentials, and is left unread.
|
||||
pub const MAX_OBJECT_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// The storage name the workspace's backups in the instance store count under in its
|
||||
/// storage usage, next to `_default_` and the secondary storages.
|
||||
pub const FALLBACK_STORAGE: &str = "_ai_sessions_fallback_";
|
||||
|
||||
const IO_CONCURRENCY: usize = 8;
|
||||
|
||||
@@ -39,34 +55,77 @@ pub fn generation_prefix(w_id: &str, generation: i64) -> String {
|
||||
format!("{ROOT}/{w_id}/g{generation}")
|
||||
}
|
||||
|
||||
/// The prefix of everything the workspace ever backed up, whatever the generation.
|
||||
fn workspace_prefix(w_id: &str) -> ObjectPath {
|
||||
ObjectPath::from(format!("{ROOT}/{w_id}"))
|
||||
}
|
||||
|
||||
/// Names the storage the backups are in, by what locates its objects (endpoint, region,
|
||||
/// bucket; never the credentials, which rotate), so a browser tells that its sync state was
|
||||
/// recorded against another storage; the generation, answered alongside, tells it a
|
||||
/// rotation happened in this one.
|
||||
pub fn storage_id(resource: &ObjectStoreResource) -> String {
|
||||
let location = match resource {
|
||||
ObjectStoreResource::S3(s) => format!(
|
||||
"s3:{}:{}:{}:{}",
|
||||
s.endpoint,
|
||||
s.port.unwrap_or_default(),
|
||||
s.region,
|
||||
s.bucket
|
||||
),
|
||||
ObjectStoreResource::Azure(a) => format!(
|
||||
"azure:{}:{}:{}",
|
||||
a.endpoint.as_deref().unwrap_or_default(),
|
||||
a.account_name,
|
||||
a.container_name
|
||||
),
|
||||
ObjectStoreResource::Gcs(g) => format!("gcs:{}", g.bucket),
|
||||
ObjectStoreResource::Filesystem(f) => format!("fs:{}", f.root_path),
|
||||
};
|
||||
calculate_hash(&location)[..16].to_string()
|
||||
calculate_hash(&object_store_location(resource))[..16].to_string()
|
||||
}
|
||||
|
||||
/// The workspace's primary storage, resolved without a caller: a rotation runs the
|
||||
/// deletion off its own request.
|
||||
async fn primary_store(db: &DB, w_id: &str) -> Result<Option<Arc<dyn ObjectStore>>> {
|
||||
/// Where a workspace's backups live: its primary storage, or the instance object store
|
||||
/// standing in for it.
|
||||
pub struct BackupStore {
|
||||
pub store: Arc<dyn ObjectStore>,
|
||||
pub storage_id: String,
|
||||
pub fallback: bool,
|
||||
}
|
||||
|
||||
/// The instance object store, for a workspace without storage of its own: loaded from
|
||||
/// settings that say where its objects are, and not turned off by
|
||||
/// `ai_sessions_instance_storage_fallback`, which is on unless set to false. Named like a
|
||||
/// workspace storage, by that location, in a namespace of its own. Never on the Pro plan,
|
||||
/// checked on every call: a store loaded before a switch to Pro stays loaded. Never in a
|
||||
/// build without `private`, which has neither workspace storage nor the quota the fallback
|
||||
/// counts toward.
|
||||
///
|
||||
/// Authorizes nothing, and the store reaches every workspace's objects: the caller must have
|
||||
/// authorized the user for the workspace and keep what it reads and writes under that
|
||||
/// user's prefix in it, as the backup routes do.
|
||||
pub async fn fallback_store(db: &DB) -> Result<Option<BackupStore>> {
|
||||
#[cfg(not(feature = "private"))]
|
||||
{
|
||||
let _ = db;
|
||||
Ok(None)
|
||||
}
|
||||
#[cfg(feature = "private")]
|
||||
{
|
||||
if matches!(
|
||||
windmill_common::ee_oss::get_license_plan().await,
|
||||
windmill_common::ee_oss::LicensePlan::Pro
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((store, Some(location))) =
|
||||
windmill_object_store::get_object_store_with_location().await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let setting = windmill_common::global_settings::load_value_from_global_settings(
|
||||
db,
|
||||
windmill_common::global_settings::AI_SESSIONS_INSTANCE_STORAGE_FALLBACK_SETTING,
|
||||
)
|
||||
.await?;
|
||||
if matches!(setting, Some(serde_json::Value::Bool(false))) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(BackupStore {
|
||||
storage_id: calculate_hash(&format!("instance:{location}"))[..16].to_string(),
|
||||
store,
|
||||
fallback: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace's primary storage, resolved without a caller: a rotation runs its deletion
|
||||
/// off its own request, and the retention sweep off any. The caller must be the server
|
||||
/// itself; nothing here checks who asks.
|
||||
pub async fn primary_store(db: &DB, w_id: &str) -> Result<Option<BackupStore>> {
|
||||
let Some(lfs_json) = sqlx::query_scalar!(
|
||||
"SELECT large_file_storage FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
@@ -91,9 +150,19 @@ async fn primary_store(db: &DB, w_id: &str) -> Result<Option<Arc<dyn ObjectStore
|
||||
.await?
|
||||
};
|
||||
let resource = windmill_object_store::lfs_to_object_store_resource(&lfs, resource_value)?;
|
||||
Ok(Some(
|
||||
windmill_object_store::build_object_store_client(&resource).await?,
|
||||
))
|
||||
Ok(Some(BackupStore {
|
||||
store: windmill_object_store::build_object_store_client(&resource).await?,
|
||||
storage_id: storage_id(&resource),
|
||||
fallback: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The store the workspace's backups live in, resolved without a caller.
|
||||
async fn workspace_store(db: &DB, w_id: &str) -> Result<Option<BackupStore>> {
|
||||
if let Some(primary) = primary_store(db, w_id).await? {
|
||||
return Ok(Some(primary));
|
||||
}
|
||||
fallback_store(db).await
|
||||
}
|
||||
|
||||
/// The generation an object key sits under, `None` for a key of no generation (an older
|
||||
@@ -107,40 +176,41 @@ fn generation_of(w_id: &str, key: &ObjectPath) -> Option<i64> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Deletes, off the request and as the listing streams, every object of the workspace's
|
||||
/// backups from a generation older than `current`, once the rotation that made `current`
|
||||
/// the generation has committed: nothing writes there any more but a push that resolved its
|
||||
/// prefix before the commit, junk the browser's next push of that session rewrites under the
|
||||
/// current prefix, as is anything a deletion cut short left behind. For the rotation route,
|
||||
/// which authorized its caller as a superadmin.
|
||||
/// Deletes, as the listing streams, every object of the workspace's backups in the store
|
||||
/// from a generation older than `current`.
|
||||
async fn delete_older(store: &Arc<dyn ObjectStore>, w_id: &str, current: i64) -> Result<()> {
|
||||
store
|
||||
.list(Some(&workspace_prefix(w_id)))
|
||||
.map_err(object_store_error_to_error)
|
||||
.try_for_each_concurrent(IO_CONCURRENCY, |meta| async move {
|
||||
if generation_of(w_id, &meta.location).is_some_and(|g| g >= current) {
|
||||
return Ok(());
|
||||
}
|
||||
match store.delete(&meta.location).await {
|
||||
Ok(()) | Err(ObjectStoreError::NotFound { .. }) => Ok(()),
|
||||
Err(e) => Err(object_store_error_to_error(e)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes, off the request, every object of the workspace's backups from a generation
|
||||
/// older than `current`, once the rotation that made `current` the generation has
|
||||
/// committed: nothing writes there any more but a push that resolved its prefix before the
|
||||
/// commit, junk the browser's next push of that session rewrites under the current prefix,
|
||||
/// as is anything a deletion cut short left behind. For the rotation route, which
|
||||
/// authorized its caller as a superadmin.
|
||||
pub(crate) fn spawn_delete_older(db: DB, w_id: String, current: i64) {
|
||||
tokio::spawn(async move {
|
||||
let store = match primary_store(&db, &w_id).await {
|
||||
Ok(Some(store)) => store,
|
||||
let store = match workspace_store(&db, &w_id).await {
|
||||
Ok(Some(store)) => store.store,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
tracing::warn!("older AI session backups of {w_id} left in place: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let prefix = ObjectPath::from(format!("{ROOT}/{w_id}"));
|
||||
let deleted = store
|
||||
.list(Some(&prefix))
|
||||
.map_err(object_store_error_to_error)
|
||||
.try_for_each_concurrent(IO_CONCURRENCY, |meta| {
|
||||
let (store, w_id) = (&store, &w_id);
|
||||
async move {
|
||||
if generation_of(w_id, &meta.location).is_some_and(|g| g >= current) {
|
||||
return Ok(());
|
||||
}
|
||||
match store.delete(&meta.location).await {
|
||||
Ok(()) | Err(ObjectStoreError::NotFound { .. }) => Ok(()),
|
||||
Err(e) => Err(object_store_error_to_error(e)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match deleted {
|
||||
match delete_older(&store, &w_id, current).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("deleted the AI session backups of {w_id} older than g{current}")
|
||||
}
|
||||
@@ -148,3 +218,57 @@ pub(crate) fn spawn_delete_older(db: DB, w_id: String, current: i64) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Deletes, off the request, what the workspace's backups left in the instance store under
|
||||
/// a generation older than `current`, the one a storage settings change committed. Nothing
|
||||
/// reads there: the routes use the workspace's own storage, or, back in the instance store,
|
||||
/// `current` or a newer generation, since configuring a storage over the fallback bumped
|
||||
/// it. So it runs whatever the storage is now and whatever the setting says (copies from
|
||||
/// when it was on may be there), and a deletion that is slow, cut short or overtaken by a
|
||||
/// later change deletes nothing live. For the storage settings route, which authorized its
|
||||
/// caller as a workspace admin.
|
||||
pub(crate) fn spawn_delete_fallback(w_id: String, current: i64) {
|
||||
tokio::spawn(async move {
|
||||
let Some(instance) = windmill_object_store::get_object_store().await else {
|
||||
return;
|
||||
};
|
||||
match delete_older(&instance, &w_id, current).await {
|
||||
Ok(()) => tracing::info!(
|
||||
"deleted the AI session backups of {w_id} older than g{current} from the instance store"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"deleting the AI session backups of {w_id} from the instance store: {e:#}"
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The bytes of the workspace's backups in the instance store, for its storage usage while
|
||||
/// it has no storage of its own (once it has one nothing writes there, and the change
|
||||
/// deleted what was): `None` when it has one, when there is no instance store, or when
|
||||
/// there is nothing, so no empty usage entry shows up. Whether the setting is on or off,
|
||||
/// since copies from when it was on may be there.
|
||||
///
|
||||
/// Authorizes nothing: for the storage usage recount, which reports a total for the
|
||||
/// workspace it was run for and hands out nothing it read.
|
||||
pub async fn fallback_bytes(db: &DB, w_id: &str) -> Result<Option<i64>> {
|
||||
let has_storage = sqlx::query_scalar!(
|
||||
r#"SELECT large_file_storage IS NOT NULL AS "has_storage!" FROM workspace_settings WHERE workspace_id = $1"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if has_storage {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(instance) = windmill_object_store::get_object_store().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut total: i64 = 0;
|
||||
let mut stream = instance.list(Some(&workspace_prefix(w_id)));
|
||||
while let Some(meta) = stream.next().await {
|
||||
total += meta.map_err(object_store_error_to_error)?.size as i64;
|
||||
}
|
||||
Ok((total > 0).then_some(total))
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ pub fn global_service() -> Router {
|
||||
.route("/list", get(list_workspaces))
|
||||
.route("/users", get(user_workspaces))
|
||||
.route("/session_workspace_status", post(session_workspace_status))
|
||||
.route(
|
||||
"/session_workspace_retention",
|
||||
post(session_workspace_retention),
|
||||
)
|
||||
.route("/create", post(create_workspace))
|
||||
.route("/create_fork", post(deprecated_create_workspace_fork))
|
||||
.route("/exists", post(exists_workspace))
|
||||
@@ -1304,26 +1308,22 @@ async fn get_git_sync_deploy_mode(
|
||||
|
||||
let configured = !settings.repositories.is_empty();
|
||||
|
||||
// Auto-pull runs only on Enterprise-licensed instances (see poll_git_auto_pull);
|
||||
// without a caller branch there is nothing to match. Either way deploy_on_push
|
||||
// stays false and the caller falls back (git push via CI, or wmill sync push).
|
||||
// Auto-pull runs only in builds that compile the poller (`private`); without a
|
||||
// caller branch there is nothing to match. Either way deploy_on_push stays
|
||||
// false and the caller falls back (git push via CI, or wmill sync push).
|
||||
let Some(branch) = q.branch.as_deref() else {
|
||||
return Ok(Json(GitSyncDeployMode {
|
||||
configured,
|
||||
deploy_on_push: false,
|
||||
}));
|
||||
};
|
||||
let licensed = matches!(
|
||||
windmill_common::ee_oss::get_license_plan().await,
|
||||
windmill_common::ee_oss::LicensePlan::Enterprise
|
||||
);
|
||||
|
||||
// Count the auto-pull repos that would deploy this branch. We deliberately do
|
||||
// not check the caller's remote URL: with exactly one such repo the local
|
||||
// checkout is unambiguously it, and with several we can't tell which is the
|
||||
// caller's, so we report false and let the CLI ask the user.
|
||||
let mut matches = 0u32;
|
||||
if licensed && !root_deleted {
|
||||
if cfg!(feature = "private") && !root_deleted {
|
||||
for repo in &settings.repositories {
|
||||
let Some(auto_pull) = repo.auto_pull.as_ref() else {
|
||||
continue;
|
||||
@@ -2093,6 +2093,17 @@ async fn edit_large_file_storage_config(
|
||||
serde_json::to_value::<LargeFileStorageWithSecondary>(lfs_config)
|
||||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||||
|
||||
// A workspace whose AI session backups fell back to the instance store leaves it
|
||||
// here: the generation moves on, so nothing it left in any instance store is read
|
||||
// again, whichever one a later return to the fallback finds (`ai_session_backups`).
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET ai_sessions_backup_generation = \
|
||||
ai_sessions_backup_generation + 1 \
|
||||
WHERE workspace_id = $1 AND large_file_storage IS NULL",
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||||
serialized_lfs_config,
|
||||
@@ -2108,8 +2119,23 @@ async fn edit_large_file_storage_config(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let backups_generation = sqlx::query_scalar!(
|
||||
"SELECT ai_sessions_backup_generation FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Read by nothing any more, whatever the storage is now: what the AI session backups
|
||||
// left in the instance store under a generation older than the one just committed.
|
||||
#[cfg(feature = "parquet")]
|
||||
if let Some(generation) = backups_generation {
|
||||
crate::ai_session_backups::spawn_delete_fallback(w_id.clone(), generation);
|
||||
}
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
let _ = backups_generation;
|
||||
|
||||
// Trigger git sync for large file storage changes
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
@@ -3732,54 +3758,6 @@ fn cleanup_legacy_git_sync_settings_in_memory(
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
|
||||
|
||||
/// Auto-pull is licensed per plan, not just per build: the poller only serves
|
||||
/// Enterprise plans at runtime, so the save path must reject the setting too —
|
||||
/// otherwise an EE binary without the plan could still register a webhook and
|
||||
/// receive webhook-driven pulls.
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_git_sync_ee_license(feature: &str) -> Result<()> {
|
||||
if !matches!(
|
||||
windmill_common::ee_oss::get_license_plan().await,
|
||||
windmill_common::ee_oss::LicensePlan::Enterprise
|
||||
) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{feature} requires an Enterprise license"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_auto_pull_license() -> Result<()> {
|
||||
check_git_sync_ee_license("Automatic pull from git").await
|
||||
}
|
||||
|
||||
/// In-app PR creation (promotion/fork deploy branches) drives GitHub API calls
|
||||
/// from the deploy completion hook; runtime-gate it like auto-pull.
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_open_prs_license<'a>(
|
||||
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
|
||||
) -> Result<()> {
|
||||
if repos.any(|r| r.promotion_open_prs || r.fork_open_prs) {
|
||||
check_git_sync_ee_license("Opening pull requests from Windmill").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promotion mode (`use_individual_branch`: per-item `wm_deploy/**` deploy
|
||||
/// branches) is an EE feature; runtime-gate it like auto-pull and PR creation
|
||||
/// so an enterprise binary without an active plan can't enable it via either
|
||||
/// git-sync edit endpoint.
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_promotion_license<'a>(
|
||||
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
|
||||
) -> Result<()> {
|
||||
if repos.any(|r| r.use_individual_branch.unwrap_or(false)) {
|
||||
check_git_sync_ee_license("Promotion mode").await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796):
|
||||
/// an older pinned script bundles a CLI that force-disables per-item branches
|
||||
/// on every fork, so enabling promotion would silently keep deploying to the
|
||||
@@ -4031,18 +4009,6 @@ async fn edit_git_sync_config(
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
if git_sync_settings
|
||||
.repositories
|
||||
.iter()
|
||||
.any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled))
|
||||
{
|
||||
check_auto_pull_license().await?;
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_open_prs_license(git_sync_settings.repositories.iter()).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_promotion_license(git_sync_settings.repositories.iter()).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter())
|
||||
.await?;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
@@ -4280,19 +4246,6 @@ async fn edit_git_sync_repository(
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
if new_config
|
||||
.repository
|
||||
.auto_pull
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.enabled)
|
||||
{
|
||||
check_auto_pull_license().await?;
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_open_prs_license(std::iter::once(&new_config.repository)).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_promotion_license(std::iter::once(&new_config.repository)).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository))
|
||||
@@ -4398,13 +4351,6 @@ async fn edit_git_sync_repository(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// The request-side license gate above only saw the submitted config; the
|
||||
// preservation can resurrect an enabled auto_pull (None arm), so re-check
|
||||
// the effective state before it gets written and reconciled.
|
||||
#[cfg(feature = "enterprise")]
|
||||
if updated.auto_pull.as_ref().is_some_and(|a| a.enabled) {
|
||||
check_auto_pull_license().await?;
|
||||
}
|
||||
*existing_repo = updated;
|
||||
} else {
|
||||
// Repository doesn't exist, add it as a new repository
|
||||
@@ -5603,6 +5549,14 @@ struct SessionWorkspaceStatusRequest {
|
||||
workspace_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// `ai_config.sessions_retention_days` as stored, `None` when unset or not a count of days.
|
||||
pub fn sessions_retention_days(value: Option<&serde_json::Value>) -> Option<u32> {
|
||||
value
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|days| *days >= 1)
|
||||
.and_then(|days| u32::try_from(days).ok())
|
||||
}
|
||||
|
||||
/// 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, or
|
||||
@@ -5652,6 +5606,42 @@ async fn session_workspace_status(
|
||||
Ok(Json(statuses))
|
||||
}
|
||||
|
||||
/// The AI session retention a browser deletes its local copies by (docs/ai-session-backups.md).
|
||||
/// Its own route, not a field on the status above, whose shape an older tab still reads. Unlike
|
||||
/// a status, it answers only for a workspace this caller can be authed into: a setting is the
|
||||
/// workspace's to tell, so a disabled membership gets none though its sessions still reconcile.
|
||||
async fn session_workspace_retention(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
Json(req): Json<SessionWorkspaceStatusRequest>,
|
||||
) -> JsonResult<HashMap<String, u32>> {
|
||||
if req.workspace_ids.len() > 1000 {
|
||||
return Err(Error::BadRequest(
|
||||
"Too many workspace ids (max 1000)".to_string(),
|
||||
));
|
||||
}
|
||||
let email = &authed.email;
|
||||
let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||||
let rows = sqlx::query!(
|
||||
"SELECT workspace_settings.workspace_id AS \"id!\",
|
||||
workspace_settings.ai_config->'sessions_retention_days' AS retention
|
||||
FROM workspace_settings
|
||||
LEFT JOIN usr ON usr.workspace_id = workspace_settings.workspace_id AND usr.email = $2
|
||||
WHERE workspace_settings.workspace_id = ANY($1)
|
||||
AND ($3 OR (usr.email IS NOT NULL AND NOT usr.disabled))",
|
||||
&req.workspace_ids[..],
|
||||
email,
|
||||
is_superadmin,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
let days = rows
|
||||
.into_iter()
|
||||
.filter_map(|r| sessions_retention_days(r.retention.as_ref()).map(|days| (r.id, days)))
|
||||
.collect();
|
||||
Ok(Json(days))
|
||||
}
|
||||
|
||||
/// The instance critical alert channels belong to the instance operator, who on cloud is
|
||||
/// not the workspace owner and never opted into a tenant's job failures. Fork workspaces run
|
||||
/// throwaway copies of their parent's runnables, so instance-wide operational alerting must
|
||||
@@ -7397,8 +7387,8 @@ async fn clone_workspace_runnable_dependencies(
|
||||
) -> Result<()> {
|
||||
// Clone workspace_runnable_dependencies
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)
|
||||
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)
|
||||
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path
|
||||
FROM workspace_runnable_dependencies
|
||||
WHERE workspace_id = $2",
|
||||
target_workspace_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.812.0
|
||||
version: 1.813.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1277,6 +1277,37 @@ paths:
|
||||
- archived
|
||||
- deleted
|
||||
|
||||
/workspaces/session_workspace_retention:
|
||||
post:
|
||||
summary: get the AI session retention of workspaces referenced by client-side sessions
|
||||
operationId: getSessionWorkspaceRetention
|
||||
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 its `ai_config.sessions_retention_days`; a workspace
|
||||
without a retention, or one the caller cannot be authenticated into, is absent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
|
||||
/w/{workspace}/workspaces/get_as_superadmin:
|
||||
get:
|
||||
summary: get workspace as super admin (require to be super admin)
|
||||
@@ -11979,6 +12010,13 @@ paths:
|
||||
properties:
|
||||
draft_only:
|
||||
type: boolean
|
||||
chat_input_enabled:
|
||||
type: boolean
|
||||
description: |
|
||||
`chat_input_enabled` of the flow's value,
|
||||
projected so the list can mark flows that open
|
||||
as a chat. Omitted when the value has no such
|
||||
field.
|
||||
is_draft:
|
||||
type: boolean
|
||||
description: |
|
||||
@@ -12072,6 +12110,25 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/list_paths_linking_agent/{path}:
|
||||
get:
|
||||
summary: list flow paths with a step linked to a saved agent
|
||||
operationId: listFlowPathsLinkingAgent
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: paths of the flows linking the `ai_agent` resource, as of their last deploy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/get/v/{version}:
|
||||
get:
|
||||
summary: get flow version
|
||||
@@ -13222,6 +13279,9 @@ paths:
|
||||
backup_generation:
|
||||
type: integer
|
||||
description: bumped by every workspace key rotation; sync state recorded under another one is void
|
||||
fallback:
|
||||
type: boolean
|
||||
description: the storage answered from is the instance object store, standing in for a workspace without storage of its own; a removal owed to it is retired by any answer from the workspace's own storage
|
||||
sessions:
|
||||
type: array
|
||||
description: the newest 500 at most
|
||||
@@ -13273,6 +13333,8 @@ paths:
|
||||
type: string
|
||||
backup_generation:
|
||||
type: integer
|
||||
fallback:
|
||||
type: boolean
|
||||
sessions:
|
||||
type: array
|
||||
items:
|
||||
@@ -13327,6 +13389,8 @@ paths:
|
||||
type: string
|
||||
backup_generation:
|
||||
type: integer
|
||||
fallback:
|
||||
type: boolean
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
@@ -28441,6 +28505,14 @@ components:
|
||||
description: >-
|
||||
Stops browsers from backing their AI sessions up to the workspace's object
|
||||
storage. Read from the workspace's own settings like `copilot_disabled`.
|
||||
sessions_retention_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3650
|
||||
description: >-
|
||||
The server deletes the backup of a session no push has reached for this many
|
||||
days. Unset keeps backups until the user deletes the session. Read from the
|
||||
workspace's own settings like `copilot_disabled`.
|
||||
|
||||
AISessionBackupListing:
|
||||
type: object
|
||||
@@ -28886,6 +28958,12 @@ components:
|
||||
type: boolean
|
||||
has_deploy_errors:
|
||||
type: boolean
|
||||
chat_input_enabled:
|
||||
type: boolean
|
||||
description: >-
|
||||
flow-only. `chat_input_enabled` of the flow's value, projected so
|
||||
the list can mark flows that open as a chat. Omitted when the
|
||||
value has no such field.
|
||||
raw_app:
|
||||
type: boolean
|
||||
execution_mode:
|
||||
|
||||
@@ -455,6 +455,12 @@ pub struct AIConfig {
|
||||
/// (`ai_sessions.rs`). Read from the workspace's own row like `copilot_disabled`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sessions_storage_disabled: bool,
|
||||
/// The server's sweep (`ai_sessions.rs`) deletes the backup of a session no push has
|
||||
/// reached for this many days. The copies in members' browsers are untouched. Unset
|
||||
/// keeps backups until the user deletes the session. Read from the workspace's own row
|
||||
/// like `copilot_disabled`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sessions_retention_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// Negotiated rates in USD per million tokens. An unset cache rate is read as the
|
||||
@@ -491,6 +497,9 @@ impl ModelPriceOverride {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ten years: past any plausible retention, and well within what a day count is turned into.
|
||||
pub const MAX_SESSIONS_RETENTION_DAYS: u32 = 3650;
|
||||
|
||||
impl AIConfig {
|
||||
pub fn validate_model_pricing(&self) -> Result<()> {
|
||||
for (key, price) in self.model_pricing.iter().flatten() {
|
||||
@@ -499,6 +508,17 @@ impl AIConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_sessions_retention(&self) -> Result<()> {
|
||||
match self.sessions_retention_days {
|
||||
Some(days) if !(1..=MAX_SESSIONS_RETENTION_DAYS).contains(&days) => {
|
||||
Err(Error::BadRequest(format!(
|
||||
"AI session retention must be between 1 and {MAX_SESSIONS_RETENTION_DAYS} days (got {days})"
|
||||
)))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.providers
|
||||
.as_ref()
|
||||
|
||||
@@ -30,8 +30,9 @@ use serde_json::value::RawValue;
|
||||
use std::sync::Arc;
|
||||
use windmill_api_auth::is_effectively_unscoped;
|
||||
use windmill_api_workspaces::ai_session_backups::{
|
||||
generation_prefix, storage_id, MAX_OBJECT_BYTES,
|
||||
fallback_store, generation_prefix, primary_store, storage_id, MAX_OBJECT_BYTES,
|
||||
};
|
||||
use windmill_api_workspaces::workspaces::sessions_retention_days;
|
||||
use windmill_common::error::{Error, JsonResult, Result};
|
||||
use windmill_common::utils::calculate_hash;
|
||||
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key};
|
||||
@@ -65,6 +66,46 @@ const MAX_LISTED_OBJECTS: usize = 5000;
|
||||
const MAX_LIST_SCAN: usize = 50_000;
|
||||
const LIST_MAX: usize = 500;
|
||||
const IO_CONCURRENCY: usize = 8;
|
||||
/// Sessions the retention sweep deletes per workspace and pass at most; the rest wait for
|
||||
/// the next pass.
|
||||
const SWEEP_MAX_PER_WORKSPACE: usize = 1000;
|
||||
/// Session-level advisory lock of the retention sweep: one server at a time runs it.
|
||||
const SWEEP_LOCK_ID: i64 = 0x5745_4550_4149;
|
||||
/// The name of the sweep's record next to a session's markers (see `Backend::sweep_key`).
|
||||
const SWEEP_RECORD: &str = "sweep";
|
||||
/// The name of a split push's token next to a session's markers (see `Backend::push_key`).
|
||||
const PUSH_TOKEN: &str = "push";
|
||||
|
||||
/// A marker modified before this is past a retention of `days`.
|
||||
fn retention_cutoff(days: u32) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() - chrono::Duration::days(i64::from(days))
|
||||
}
|
||||
|
||||
/// What a key under the `index/` prefix is.
|
||||
enum IndexEntry {
|
||||
/// The marker that lists the session, named by its epoch.
|
||||
Marker(u32),
|
||||
/// The retention sweep's record (see `Backend::sweep_key`).
|
||||
Sweep,
|
||||
/// The token of a push split over parts (see `Backend::push_key`).
|
||||
Push,
|
||||
}
|
||||
|
||||
/// The session a key under the `index/` prefix belongs to, and what the key is.
|
||||
fn index_entry<'a>(index: &ObjectPath, key: &'a ObjectPath) -> Option<(&'a str, IndexEntry)> {
|
||||
// `Path` drops the trailing delimiter, so the remainder starts with one.
|
||||
let rel = key.as_ref().strip_prefix(index.as_ref())?;
|
||||
let (sid, name) = rel.trim_start_matches('/').split_once('/')?;
|
||||
if sid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = match name {
|
||||
SWEEP_RECORD => IndexEntry::Sweep,
|
||||
PUSH_TOKEN => IndexEntry::Push,
|
||||
epoch => IndexEntry::Marker(epoch.parse().ok()?),
|
||||
};
|
||||
Some((sid, entry))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -101,6 +142,12 @@ struct Backend {
|
||||
/// owed to the storage alone (a rotation deleted the older generation's copy anyway).
|
||||
storage_id: String,
|
||||
generation: i64,
|
||||
/// `ai_config.sessions_retention_days`: a session whose marker is older is not listed,
|
||||
/// whether or not the sweep has deleted it yet.
|
||||
retention_days: Option<u32>,
|
||||
/// The store is the instance object store standing in for a workspace without storage
|
||||
/// of its own (`ai_session_backups::fallback_store`).
|
||||
fallback: bool,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
@@ -108,6 +155,12 @@ impl Backend {
|
||||
ObjectPath::from(format!("{}/index/", self.prefix))
|
||||
}
|
||||
|
||||
/// The moment a marker's modification time must reach to count as live, under the
|
||||
/// workspace's retention; `None` without one.
|
||||
fn retention_cutoff(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
self.retention_days.map(retention_cutoff)
|
||||
}
|
||||
|
||||
/// The marker that lists the session, named by the session's move count so that of a
|
||||
/// session two workspaces list, the copy moved last is told from the listing alone.
|
||||
fn index_key(&self, sid: &str, epoch: u32) -> ObjectPath {
|
||||
@@ -118,10 +171,18 @@ impl Backend {
|
||||
ObjectPath::from(format!("{}/index/{sid}/", self.prefix))
|
||||
}
|
||||
|
||||
/// The token of the push split over parts in progress, under the session so a removal
|
||||
/// or the next whole push clears it with the rest.
|
||||
/// Written by the retention sweep before it deletes anything of a session, and deleted
|
||||
/// last (`remove_session`): what finds a removal the sweep started and could not finish,
|
||||
/// the markers being gone by then. Not an epoch, so nothing lists or pulls a session by it.
|
||||
fn sweep_key(&self, sid: &str) -> ObjectPath {
|
||||
ObjectPath::from(format!("{}/index/{sid}/{SWEEP_RECORD}", self.prefix))
|
||||
}
|
||||
|
||||
/// The token of the push split over parts in progress, next to the markers so a removal
|
||||
/// or the next whole push clears it with them, and the retention sweep, which walks the
|
||||
/// markers, finds one a browser abandoned.
|
||||
fn push_key(&self, sid: &str) -> ObjectPath {
|
||||
ObjectPath::from(format!("{}/sessions/{sid}/push", self.prefix))
|
||||
ObjectPath::from(format!("{}/index/{sid}/{PUSH_TOKEN}", self.prefix))
|
||||
}
|
||||
|
||||
fn session_prefix(&self, sid: &str) -> ObjectPath {
|
||||
@@ -307,7 +368,12 @@ impl Backend {
|
||||
let mut stream = self.store.list(Some(&prefix));
|
||||
while let Some(meta) = stream.next().await {
|
||||
let meta = meta.map_err(object_store_error_to_error)?;
|
||||
listed |= marker;
|
||||
// The sweep's record is not a marker: a session it started removing is absent.
|
||||
listed |= marker
|
||||
&& meta
|
||||
.location
|
||||
.filename()
|
||||
.is_some_and(|name| name.parse::<u32>().is_ok());
|
||||
acc = fold(
|
||||
acc,
|
||||
meta.location.as_ref(),
|
||||
@@ -408,34 +474,61 @@ fn require_json_object(kind: &str, raw: &RawValue, max_bytes: usize) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` when the workspace has nowhere to keep backups: no primary storage configured, or
|
||||
/// the admin switched them off. Both read as `enabled: false` so the browser stops trying.
|
||||
/// `None` when the workspace has nowhere to keep backups: no primary storage configured and
|
||||
/// no instance store to stand in, or the admin switched them off. Both read as
|
||||
/// `enabled: false` so the browser stops trying.
|
||||
async fn backend(authed: &ApiAuthed, db: &DB, w_id: &str) -> Result<Option<Backend>> {
|
||||
let (disabled, generation) = sqlx::query_as::<_, (Option<bool>, i64)>(
|
||||
"SELECT (ai_config->>'sessions_storage_disabled')::bool, ai_sessions_backup_generation \
|
||||
FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or((None, 0));
|
||||
let (disabled, retention, generation, has_storage) =
|
||||
sqlx::query_as::<_, (Option<bool>, Option<serde_json::Value>, i64, bool)>(
|
||||
"SELECT (ai_config->>'sessions_storage_disabled')::bool, \
|
||||
ai_config->'sessions_retention_days', ai_sessions_backup_generation, \
|
||||
large_file_storage IS NOT NULL \
|
||||
FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or((None, None, 0, false));
|
||||
if disabled.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (_, resource) =
|
||||
crate::job_helpers_oss::get_workspace_s3_resource(authed, db, None, w_id, None).await?;
|
||||
let Some(resource) = resource else {
|
||||
return Ok(None);
|
||||
let retention_days = sessions_retention_days(retention.as_ref());
|
||||
// Decided from the row the generation came from: the instance store is written only
|
||||
// under a generation read while the workspace had no storage of its own, which
|
||||
// configuring one moves past (`ai_session_backups`).
|
||||
let (store, storage_id, fallback) = if has_storage {
|
||||
let (_, resource) =
|
||||
crate::job_helpers_oss::get_workspace_s3_resource(authed, db, None, w_id, None).await?;
|
||||
let Some(resource) = resource else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
build_object_store_client(&resource).await?,
|
||||
storage_id(&resource),
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
// The instance store stands in, under the same layout and the same key.
|
||||
match fallback_store(db).await? {
|
||||
Some(f) => (f.store, f.storage_id, true),
|
||||
None => return Ok(None),
|
||||
}
|
||||
};
|
||||
let store = build_object_store_client(&resource).await?;
|
||||
let user = calculate_hash(&authed.email);
|
||||
// Keyed per user, not per workspace: anyone who can write the bucket could otherwise copy
|
||||
// another member's ciphertext under their own prefix and have `pull` decrypt it for them.
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mc = crypt_from_key_with_suffix(&key, &user);
|
||||
let storage_id = storage_id(&resource);
|
||||
let prefix = format!("{}/{user}", generation_prefix(w_id, generation));
|
||||
Ok(Some(Backend { store, mc, prefix, storage_id, generation }))
|
||||
Ok(Some(Backend {
|
||||
store,
|
||||
mc,
|
||||
prefix,
|
||||
storage_id,
|
||||
generation,
|
||||
retention_days,
|
||||
fallback,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -455,6 +548,12 @@ struct ListResponse {
|
||||
storage_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
backup_generation: Option<i64>,
|
||||
/// The storage is the instance store standing in for a workspace without one of its
|
||||
/// own; a removal owed to it is retired by any answer from the workspace's own storage
|
||||
/// once it has one (configuring it moved the generation past everything the workspace
|
||||
/// left in any instance store).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
fallback: bool,
|
||||
sessions: Vec<SessionListing>,
|
||||
/// The user has more sessions than the answer names.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -462,7 +561,9 @@ struct ListResponse {
|
||||
}
|
||||
|
||||
/// A session is listed once a push entry of it landed whole (its marker is written last);
|
||||
/// a push that failed before that left objects the listing does not name.
|
||||
/// a push that failed before that left objects the listing does not name. One past the
|
||||
/// workspace's retention is not listed either, whether or not the sweep has reached it, so a
|
||||
/// browser never restores it.
|
||||
async fn list(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -474,11 +575,13 @@ async fn list(
|
||||
enabled: false,
|
||||
storage_id: None,
|
||||
backup_generation: None,
|
||||
fallback: false,
|
||||
sessions: vec![],
|
||||
truncated: false,
|
||||
}));
|
||||
};
|
||||
let prefix = backend.index_prefix();
|
||||
let cutoff = backend.retention_cutoff();
|
||||
let mut stream = backend.store.list(Some(&prefix));
|
||||
// One marker per session, whatever the session holds: the newest LIST_MAX are kept as
|
||||
// the scan goes (a min-heap drops the oldest), and the scan itself is bounded.
|
||||
@@ -494,19 +597,12 @@ async fn list(
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
// `Path` drops the trailing delimiter, so the remainder starts with one.
|
||||
let Some(rel) = meta.location.as_ref().strip_prefix(prefix.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let Some((sid, epoch)) = rel.trim_start_matches('/').split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(epoch) = epoch.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
if sid.is_empty() || sid.contains('/') {
|
||||
if cutoff.is_some_and(|cutoff| meta.last_modified < cutoff) {
|
||||
continue;
|
||||
}
|
||||
let Some((sid, IndexEntry::Marker(epoch))) = index_entry(&prefix, &meta.location) else {
|
||||
continue;
|
||||
};
|
||||
newest.push(std::cmp::Reverse((
|
||||
meta.last_modified,
|
||||
epoch,
|
||||
@@ -526,6 +622,7 @@ async fn list(
|
||||
enabled: true,
|
||||
storage_id: Some(backend.storage_id.clone()),
|
||||
backup_generation: Some(backend.generation),
|
||||
fallback: backend.fallback,
|
||||
sessions,
|
||||
truncated,
|
||||
}))
|
||||
@@ -594,6 +691,8 @@ struct PullResponse {
|
||||
storage_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
backup_generation: Option<i64>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
fallback: bool,
|
||||
sessions: Vec<PulledSession>,
|
||||
/// Ids that did not fit the response budget; ask for them again.
|
||||
deferred: Vec<String>,
|
||||
@@ -839,6 +938,7 @@ async fn pull(
|
||||
enabled: false,
|
||||
storage_id: None,
|
||||
backup_generation: None,
|
||||
fallback: false,
|
||||
sessions: vec![],
|
||||
deferred: vec![],
|
||||
}));
|
||||
@@ -861,6 +961,7 @@ async fn pull(
|
||||
enabled: true,
|
||||
storage_id: Some(backend.storage_id),
|
||||
backup_generation: Some(backend.generation),
|
||||
fallback: backend.fallback,
|
||||
sessions,
|
||||
deferred,
|
||||
}))
|
||||
@@ -952,6 +1053,8 @@ struct PushResponse {
|
||||
storage_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
backup_generation: Option<i64>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
fallback: bool,
|
||||
results: Vec<PushResult>,
|
||||
}
|
||||
|
||||
@@ -1167,15 +1270,29 @@ async fn push_session(backend: &Backend, s: &PushedSession) -> Result<(usize, bo
|
||||
Ok((written, false))
|
||||
}
|
||||
|
||||
/// The marker goes first so a removal cut short leaves nothing listed, then the head so
|
||||
/// The markers go first so a removal cut short leaves nothing listed, then the head so
|
||||
/// nothing pulls either, and no push takes it for a session still there (see `push_session`).
|
||||
/// The retention sweep's record goes last (see `Backend::sweep_key`).
|
||||
async fn remove_session(backend: &Backend, sid: &str) -> Result<()> {
|
||||
let sweep = backend.sweep_key(sid);
|
||||
backend
|
||||
.delete_prefix(&backend.index_session_prefix(sid))
|
||||
.store
|
||||
.list(Some(&backend.index_session_prefix(sid)))
|
||||
.map_err(object_store_error_to_error)
|
||||
.try_for_each_concurrent(IO_CONCURRENCY, |meta| {
|
||||
let sweep = &sweep;
|
||||
async move {
|
||||
if meta.location == *sweep {
|
||||
return Ok(());
|
||||
}
|
||||
backend.delete(&meta.location).await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
backend.delete(&backend.head_key(sid)).await?;
|
||||
backend.delete_prefix(&backend.session_prefix(sid)).await?;
|
||||
backend.delete_prefix(&backend.images_prefix(sid)).await
|
||||
backend.delete_prefix(&backend.images_prefix(sid)).await?;
|
||||
backend.delete(&sweep).await
|
||||
}
|
||||
|
||||
/// One writer per session at a time, across servers: a push and a removal of the same
|
||||
@@ -1222,6 +1339,205 @@ async fn remove_session_locked(db: &DB, backend: &Backend, sid: &str) -> Result<
|
||||
result
|
||||
}
|
||||
|
||||
/// Deletes, in every workspace with `ai_config.sessions_retention_days`, the backups of the
|
||||
/// sessions whose marker is older than that: the marker is rewritten by every push that
|
||||
/// completes, so its modification time is the session's last activity as the storage clocks
|
||||
/// it. For the monitor, on every server: a session-level advisory lock keeps one pass at a
|
||||
/// time across them. The walk reads markers only, one object per session and nothing of what
|
||||
/// the sessions hold, under each user's prefix in turn (`list_with_delimiter` names the
|
||||
/// users), and deletes at most `SWEEP_MAX_PER_WORKSPACE` sessions per workspace and pass. A
|
||||
/// session goes under its lock (`lock_session`), once its markers are listed again there and
|
||||
/// still all older (see `sweep_session`). A removal cut short leaves the sweep's record next
|
||||
/// to the markers, which the walk also collects, so the next pass finishes it.
|
||||
pub async fn sweep_expired_ai_session_backups(db: &DB) {
|
||||
let mut lock_conn = match db.acquire().await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
tracing::error!("AI session retention: could not acquire a connection: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let locked: bool = match sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(SWEEP_LOCK_ID)
|
||||
.fetch_one(&mut *lock_conn)
|
||||
.await
|
||||
{
|
||||
Ok(locked) => locked,
|
||||
Err(e) => {
|
||||
tracing::error!("AI session retention: advisory lock failed: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !locked {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sweep_workspaces(db).await {
|
||||
tracing::error!("AI session retention sweep failed: {e:#}");
|
||||
}
|
||||
if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
.bind(SWEEP_LOCK_ID)
|
||||
.execute(&mut *lock_conn)
|
||||
.await
|
||||
{
|
||||
tracing::error!("AI session retention: advisory unlock failed: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sweep_workspaces(db: &DB) -> Result<()> {
|
||||
let workspaces = sqlx::query_as::<_, (String, Option<serde_json::Value>, i64, bool)>(
|
||||
"SELECT workspace_id, ai_config->'sessions_retention_days', ai_sessions_backup_generation, \
|
||||
large_file_storage IS NOT NULL \
|
||||
FROM workspace_settings \
|
||||
WHERE ai_config->'sessions_retention_days' IS NOT NULL",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for (w_id, retention, generation, has_storage) in workspaces {
|
||||
let Some(days) = sessions_retention_days(retention.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
match sweep_workspace(db, &w_id, days, generation, has_storage).await {
|
||||
Ok(0) => {}
|
||||
Ok(deleted) => tracing::info!(
|
||||
"AI session retention deleted {deleted} session backups of {w_id} older than {days} days"
|
||||
),
|
||||
Err(e) => tracing::warn!("AI session retention sweep of {w_id}: {e:#}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `has_storage` comes from the row `generation` was read from, as in `backend`: the instance
|
||||
/// store is swept only under a generation read while the workspace had no storage of its own.
|
||||
async fn sweep_workspace(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
days: u32,
|
||||
generation: i64,
|
||||
has_storage: bool,
|
||||
) -> Result<usize> {
|
||||
let resolved = if has_storage {
|
||||
primary_store(db, w_id).await?
|
||||
} else {
|
||||
fallback_store(db).await?
|
||||
};
|
||||
let Some(resolved) = resolved else {
|
||||
return Ok(0);
|
||||
};
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let (store, storage_id) = (resolved.store, resolved.storage_id);
|
||||
let cutoff = retention_cutoff(days);
|
||||
let root = ObjectPath::from(generation_prefix(w_id, generation));
|
||||
let users = store
|
||||
.list_with_delimiter(Some(&root))
|
||||
.await
|
||||
.map_err(object_store_error_to_error)?
|
||||
.common_prefixes;
|
||||
let mut deleted = 0;
|
||||
for user_prefix in users {
|
||||
let Some(user) = user_prefix.filename() else {
|
||||
continue;
|
||||
};
|
||||
// The sweep decrypts nothing; the cipher is only what a `Backend` is made of.
|
||||
let backend = Backend {
|
||||
store: store.clone(),
|
||||
mc: crypt_from_key_with_suffix(&key, user),
|
||||
prefix: user_prefix.to_string(),
|
||||
storage_id: storage_id.clone(),
|
||||
generation,
|
||||
retention_days: Some(days),
|
||||
fallback: resolved.fallback,
|
||||
};
|
||||
let index = backend.index_prefix();
|
||||
let mut markers = backend.store.list(Some(&index));
|
||||
let mut expired = std::collections::BTreeSet::new();
|
||||
while let Some(meta) = markers.next().await {
|
||||
let meta = meta.map_err(object_store_error_to_error)?;
|
||||
let sid = match index_entry(&index, &meta.location) {
|
||||
Some((sid, IndexEntry::Sweep)) => sid,
|
||||
Some((sid, IndexEntry::Marker(_) | IndexEntry::Push))
|
||||
if meta.last_modified < cutoff =>
|
||||
{
|
||||
sid
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
expired.insert(sid.to_string());
|
||||
if deleted + expired.len() >= SWEEP_MAX_PER_WORKSPACE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for sid in expired {
|
||||
match sweep_session(db, &backend, &sid, cutoff).await {
|
||||
Ok(true) => deleted += 1,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
"AI session retention left the backup of {sid} in {w_id} for the next pass: {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
if deleted >= SWEEP_MAX_PER_WORKSPACE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// True when the session was deleted. Under the session's lock its markers are listed again:
|
||||
/// one a push renewed since the walk keeps the session. A session with none is left alone
|
||||
/// while a push split over parts is between two of them (its token younger than the
|
||||
/// retention) or it is gone, unless the sweep's record says a removal was started; an older
|
||||
/// token is a split push a browser abandoned, whose landed parts nothing lists. The record is
|
||||
/// written before anything is deleted and removed last, so a removal cut short is found again
|
||||
/// by the next pass.
|
||||
async fn sweep_session(
|
||||
db: &DB,
|
||||
backend: &Backend,
|
||||
sid: &str,
|
||||
cutoff: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<bool> {
|
||||
let tx = lock_session(db, backend, sid).await?;
|
||||
let result = async {
|
||||
let (sweep, push) = (backend.sweep_key(sid), backend.push_key(sid));
|
||||
let mut entries = backend.store.list(Some(&backend.index_session_prefix(sid)));
|
||||
let (mut listed, mut renewed, mut started, mut abandoned) = (false, false, false, false);
|
||||
while let Some(meta) = entries.next().await {
|
||||
let meta = meta.map_err(object_store_error_to_error)?;
|
||||
if meta.location == sweep {
|
||||
started = true;
|
||||
} else if meta.location == push {
|
||||
abandoned = meta.last_modified < cutoff;
|
||||
} else {
|
||||
listed = true;
|
||||
renewed |= meta.last_modified >= cutoff;
|
||||
}
|
||||
}
|
||||
if renewed {
|
||||
// A push listed the session again over a removal cut short before its markers
|
||||
// went, which had deleted nothing else.
|
||||
if started {
|
||||
backend.delete(&sweep).await?;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if !listed && !started && !abandoned {
|
||||
return Ok(false);
|
||||
}
|
||||
if !started {
|
||||
backend
|
||||
.store
|
||||
.put(&sweep, PutPayload::new())
|
||||
.await
|
||||
.map_err(object_store_error_to_error)?;
|
||||
}
|
||||
remove_session(backend, sid).await?;
|
||||
Ok(true)
|
||||
}
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
result
|
||||
}
|
||||
|
||||
async fn push(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1241,6 +1557,7 @@ async fn push(
|
||||
enabled: false,
|
||||
storage_id: None,
|
||||
backup_generation: None,
|
||||
fallback: false,
|
||||
results: vec![],
|
||||
}));
|
||||
};
|
||||
@@ -1294,16 +1611,16 @@ async fn push(
|
||||
results.push(PushResult { id: sid.clone(), error, needs_whole: false });
|
||||
}
|
||||
// Overwrites and deletes make this an over-count; the periodic recount the quota check
|
||||
// schedules once usage is stale settles it.
|
||||
// schedules once usage is stale settles it. Bytes in the instance store count under a
|
||||
// name of their own, which the recount lists there.
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if written > 0 {
|
||||
crate::job_helpers_oss::bump_storage_usage(
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_object_store::DEFAULT_STORAGE,
|
||||
written as i64,
|
||||
)
|
||||
.await;
|
||||
let storage = if backend.fallback {
|
||||
windmill_api_workspaces::ai_session_backups::FALLBACK_STORAGE
|
||||
} else {
|
||||
windmill_object_store::DEFAULT_STORAGE
|
||||
};
|
||||
crate::job_helpers_oss::bump_storage_usage(&db, &w_id, storage, written as i64).await;
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
let _ = written;
|
||||
@@ -1311,6 +1628,7 @@ async fn push(
|
||||
enabled: true,
|
||||
storage_id: Some(backend.storage_id),
|
||||
backup_generation: Some(backend.generation),
|
||||
fallback: backend.fallback,
|
||||
results,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
|
||||
auth::TOKEN_PREFIX_LEN,
|
||||
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
|
||||
cache::{self, future::FutureCachedExt},
|
||||
db::{DbWithOptAuthed, UserDB},
|
||||
error::{to_anyhow, Error, JsonResult, Result},
|
||||
@@ -1522,7 +1522,10 @@ async fn mint_raw_app_sdk_token(
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("sdk_app:{app_path}"), requested_exp),
|
||||
None => (
|
||||
format!("{RAW_APP_SDK_TOKEN_LABEL_PREFIX}{app_path}"),
|
||||
requested_exp,
|
||||
),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(label),
|
||||
@@ -1804,7 +1807,10 @@ pub async fn mint_app_embed_token(
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("embed_app:{app_path}"), requested_exp),
|
||||
None => (
|
||||
format!("{APP_EMBED_TOKEN_LABEL_PREFIX}{app_path}"),
|
||||
requested_exp,
|
||||
),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(label),
|
||||
|
||||
@@ -71,6 +71,8 @@ mod ai_free_tier_ee;
|
||||
mod ai_free_tier_oss;
|
||||
#[cfg(feature = "parquet")]
|
||||
mod ai_sessions;
|
||||
#[cfg(feature = "parquet")]
|
||||
pub use ai_sessions::sweep_expired_ai_session_backups;
|
||||
mod ai_shared_artifacts;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
|
||||
@@ -116,6 +116,10 @@ struct RunnableItem {
|
||||
use_codebase: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
has_deploy_errors: Option<bool>,
|
||||
// flow-only: projected from the value so the home list can badge flows that
|
||||
// open as a chat without fetching every flow's value.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chat_input_enabled: Option<bool>,
|
||||
// app-only
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_app: Option<bool>,
|
||||
@@ -274,7 +278,7 @@ fn branch_sqls() -> Branches {
|
||||
o.ws_error_handler_muted, o.created_at as edited_at, \
|
||||
o.hash, o.language::text as language, o.kind::text as script_kind, o.auto_kind, \
|
||||
o.codebase IS NOT NULL as use_codebase, \
|
||||
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, \
|
||||
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
|
||||
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
|
||||
FROM script o \
|
||||
@@ -291,6 +295,7 @@ fn branch_sqls() -> Branches {
|
||||
o.ws_error_handler_muted, o.edited_at, \
|
||||
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
|
||||
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
|
||||
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM flow o \
|
||||
@@ -306,7 +311,7 @@ fn branch_sqls() -> Branches {
|
||||
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
|
||||
NULL::bool as ws_error_handler_muted, av.created_at as edited_at, \
|
||||
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
|
||||
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
|
||||
o.versions[array_upper(o.versions, 1)] as version, \
|
||||
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
@@ -344,11 +349,21 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
let kind_cols = match kind {
|
||||
"script" => {
|
||||
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
|
||||
d.value->>'auto_kind' as auto_kind, false as raw_app"
|
||||
d.value->>'auto_kind' as auto_kind, false as raw_app, \
|
||||
NULL::bool as chat_input_enabled"
|
||||
}
|
||||
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
|
||||
// unvalidated, so a malformed value must yield NULL, not abort the list,
|
||||
// and the string "true" must not count as enabled.
|
||||
"flow" => {
|
||||
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
false as raw_app, \
|
||||
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
|
||||
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
|
||||
}
|
||||
_ => {
|
||||
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
(d.typ = 'raw_app') as raw_app"
|
||||
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
|
||||
}
|
||||
};
|
||||
format!(
|
||||
@@ -359,7 +374,7 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
NULL::text[] as labels, NULL::text[] as inherited_labels, \
|
||||
NULL::bool as ws_error_handler_muted, o.created_at as edited_at, \
|
||||
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
|
||||
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM ( \
|
||||
|
||||
@@ -108,6 +108,7 @@ async fn edit_copilot_config(
|
||||
}
|
||||
|
||||
ai_config.validate_model_pricing()?;
|
||||
ai_config.validate_sessions_retention()?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -148,6 +149,7 @@ async fn edit_copilot_config(
|
||||
let workspace_has_config = ai_config.has_providers();
|
||||
let copilot_disabled = ai_config.copilot_disabled;
|
||||
let sessions_storage_disabled = ai_config.sessions_storage_disabled;
|
||||
let sessions_retention_days = ai_config.sessions_retention_days;
|
||||
let instance_ai_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
@@ -176,6 +178,7 @@ async fn edit_copilot_config(
|
||||
};
|
||||
effective_ai_config.copilot_disabled = copilot_disabled;
|
||||
effective_ai_config.sessions_storage_disabled = sessions_storage_disabled;
|
||||
effective_ai_config.sessions_retention_days = sessions_retention_days;
|
||||
|
||||
Ok(Json(EditCopilotConfigResponse {
|
||||
effective_ai_config,
|
||||
@@ -217,6 +220,9 @@ async fn get_copilot_info(
|
||||
let sessions_storage_disabled = workspace_ai_config
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.0.sessions_storage_disabled);
|
||||
let sessions_retention_days = workspace_ai_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.0.sessions_retention_days);
|
||||
let instance_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
@@ -242,6 +248,7 @@ async fn get_copilot_info(
|
||||
};
|
||||
effective.copilot_disabled = copilot_disabled;
|
||||
effective.sessions_storage_disabled = sessions_storage_disabled;
|
||||
effective.sessions_retention_days = sessions_retention_days;
|
||||
Ok(Json(effective))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
|
||||
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
||||
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
||||
/// a user token.
|
||||
///
|
||||
/// This is the canonical copy. When updating it, also update its mirrors:
|
||||
/// - the `update_token_label` editability guard (SQL `WHERE`) in
|
||||
@@ -40,15 +41,30 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
// Short-lived tokens the server mints per app open or per service-account
|
||||
// impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning
|
||||
// for one is noise.
|
||||
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with("impersonation:")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
|
||||
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
|
||||
|
||||
/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the
|
||||
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
||||
|
||||
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
||||
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`
|
||||
/// and `debugger-token` are minted by the editor and the debugger through that same handler,
|
||||
/// so reserving them would break those features.
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
||||
/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger
|
||||
/// and object-storage connection tests through that same handler, so reserving them would
|
||||
/// break those features.
|
||||
///
|
||||
/// `username_override_from_label` trusts a label to name the entity acting only if it is in
|
||||
/// here, so anything added must be unmintable by a member.
|
||||
@@ -961,6 +977,9 @@ mod tests {
|
||||
assert!(!is_user_token(Some("Ephemeral lsp token")));
|
||||
assert!(!is_user_token(Some("debugger-token")));
|
||||
assert!(!is_user_token(Some("mcp-oauth-client")));
|
||||
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
|
||||
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
||||
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,11 @@ pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth";
|
||||
// windmill-worker/src/ssh_executor_ee.rs.
|
||||
pub const SSH_EXECUTION_SETTING: &str = "ssh_execution_enabled";
|
||||
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
|
||||
/// Whether the instance object store stands in for a workspace without storage of its own
|
||||
/// as the place its members' AI sessions are backed up to. On unless the row says `false`;
|
||||
/// inert without an instance object store.
|
||||
pub const AI_SESSIONS_INSTANCE_STORAGE_FALLBACK_SETTING: &str =
|
||||
"ai_sessions_instance_storage_fallback";
|
||||
/// Compile a newly deployed script's binary right after its dependency job and push it
|
||||
/// to the instance object store, so the first run does not pay the compile. Inert unless
|
||||
/// instance object storage is configured — without it the binary would only ever land in
|
||||
|
||||
@@ -264,6 +264,8 @@ pub struct GlobalSettings {
|
||||
pub disable_hub: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_build_binary_on_deploy: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ai_sessions_instance_storage_fallback: Option<bool>,
|
||||
|
||||
// String settings
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -1788,6 +1788,48 @@ pub async fn workspace_with_fork_ancestors(db: &crate::DB, w_id: &str) -> Result
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// The nearest fork ancestor of `w_id` holding a row at `path` in `table`, or `None` when no
|
||||
/// ancestor does (or `w_id` is not a fork). Fork creation clones trigger and schedule rows down
|
||||
/// the whole chain, so a row shares its upstream identifier (Kafka group, PG slot, cron) with
|
||||
/// every ancestor that still has one, not just the direct parent, which may have deleted its
|
||||
/// copy since.
|
||||
///
|
||||
/// Runs on the caller's connection so it sees the caller's transaction, and uncached: the
|
||||
/// answer depends on the target table, not only on lineage.
|
||||
///
|
||||
/// `table` is interpolated into SQL, hence `'static`: a trigger's `TABLE_NAME` or a literal,
|
||||
/// never caller input. Reads lineage for any `w_id` with no authorization check, like
|
||||
/// [`fork_ancestor_chain`], so the caller must already be authorized for `w_id`.
|
||||
pub async fn nearest_fork_ancestor_having(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
table: &'static str,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar(&format!(
|
||||
r#"
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT id, parent_workspace_id, 0 AS depth
|
||||
FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_workspace_id, chain.depth + 1
|
||||
FROM workspace w
|
||||
JOIN chain ON w.id = chain.parent_workspace_id
|
||||
WHERE chain.depth < 20
|
||||
)
|
||||
SELECT chain.id FROM chain
|
||||
JOIN {table} t ON t.workspace_id = chain.id AND t.path = $2
|
||||
WHERE chain.depth > 0
|
||||
ORDER BY chain.depth LIMIT 1
|
||||
"#
|
||||
))
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("resolving fork ancestors of {w_id}: {e:#}")))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// workspace id -> (root workspace id, expiry ts). Read once per job start, so correctness
|
||||
/// rests on the invalidation rather than on the TTL: every mutation that can change the answer
|
||||
|
||||
@@ -118,6 +118,10 @@ pub fn object_store_error_to_error(err: object_store::Error) -> error::Error {
|
||||
pub struct ExpirableObjectStore {
|
||||
pub store: Arc<dyn ObjectStore>,
|
||||
pub refresh: Option<ObjectStoreRefresh>,
|
||||
/// What locates the store's objects ([`object_store_location`]), for a store built from
|
||||
/// settings. Kept with the store rather than read off the settings again, so a server
|
||||
/// whose reload is still pending never names one store by another's location.
|
||||
pub location: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -155,7 +159,7 @@ impl ObjectStoreRefresh {
|
||||
#[cfg(feature = "parquet")]
|
||||
impl From<Arc<dyn ObjectStore>> for ExpirableObjectStore {
|
||||
fn from(store: Arc<dyn ObjectStore>) -> Self {
|
||||
Self { store, refresh: None }
|
||||
Self { store, refresh: None, location: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +201,15 @@ static CACHE_OVERRIDE_GENERATION: std::sync::atomic::AtomicU64 =
|
||||
async fn resolve_object_store(
|
||||
settings_lock: &RwLock<Option<ExpirableObjectStore>>,
|
||||
) -> Option<Arc<dyn ObjectStore>> {
|
||||
resolve_object_store_with_location(settings_lock)
|
||||
.await
|
||||
.map(|(store, _)| store)
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn resolve_object_store_with_location(
|
||||
settings_lock: &RwLock<Option<ExpirableObjectStore>>,
|
||||
) -> Option<(Arc<dyn ObjectStore>, Option<String>)> {
|
||||
let settings = settings_lock.read().await;
|
||||
let Some(s) = settings.as_ref() else {
|
||||
return None;
|
||||
@@ -212,18 +225,18 @@ async fn resolve_object_store(
|
||||
// A reload may have installed a different store while the credentials were
|
||||
// being minted; that one reflects newer config, so the refresh is stale.
|
||||
Some(current) if !Arc::ptr_eq(¤t.store, &refreshed_from) => {
|
||||
Some(current.store.clone())
|
||||
Some((current.store.clone(), current.location.clone()))
|
||||
}
|
||||
Some(_) => {
|
||||
let arc = new_store.store.clone();
|
||||
let found = (new_store.store.clone(), new_store.location.clone());
|
||||
*settings = Some(new_store);
|
||||
Some(arc)
|
||||
Some(found)
|
||||
}
|
||||
// Cleared while refreshing.
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
_ => Some(s.store.clone()),
|
||||
_ => Some((s.store.clone(), s.location.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +245,13 @@ pub async fn get_object_store() -> Option<Arc<dyn ObjectStore>> {
|
||||
resolve_object_store(&OBJECT_STORE_SETTINGS).await
|
||||
}
|
||||
|
||||
/// The instance object store with what locates its objects ([`object_store_location`]),
|
||||
/// read together; the location is `None` for a store installed without settings.
|
||||
#[cfg(feature = "parquet")]
|
||||
pub async fn get_object_store_with_location() -> Option<(Arc<dyn ObjectStore>, Option<String>)> {
|
||||
resolve_object_store_with_location(&OBJECT_STORE_SETTINGS).await
|
||||
}
|
||||
|
||||
/// The store the dependency cache reads and writes: the worker group's override when it has one,
|
||||
/// the instance object store otherwise. Anything the server must also reach goes through
|
||||
/// [`get_object_store`] instead.
|
||||
@@ -422,20 +442,22 @@ pub async fn reload_object_store_setting(db: &windmill_common::DB) -> ObjectStor
|
||||
tracing::error!("S3 cache is not available for pro plan");
|
||||
return ObjectStoreReload::Never;
|
||||
}
|
||||
*s3_cache_settings = build_s3_client_from_settings(S3Settings {
|
||||
bucket: None,
|
||||
region: None,
|
||||
access_key: None,
|
||||
secret_key: None,
|
||||
endpoint: None,
|
||||
store_logs: None,
|
||||
path_style: None,
|
||||
allow_http: None,
|
||||
port: None,
|
||||
})
|
||||
*s3_cache_settings = build_object_store_from_settings(
|
||||
ObjectSettings::S3(S3Settings {
|
||||
bucket: None,
|
||||
region: None,
|
||||
access_key: None,
|
||||
secret_key: None,
|
||||
endpoint: None,
|
||||
store_logs: None,
|
||||
path_style: None,
|
||||
allow_http: None,
|
||||
port: None,
|
||||
}),
|
||||
Some(db),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|x| ExpirableObjectStore::from(x))
|
||||
} else {
|
||||
*s3_cache_settings = None;
|
||||
}
|
||||
@@ -887,19 +909,49 @@ impl ObjectStore for FilesystemStoreIgnoringAttributes {
|
||||
}
|
||||
}
|
||||
|
||||
/// What locates a store's objects: endpoint, port, region and bucket (or account and
|
||||
/// container, or root), never the credentials, which rotate. Two stores with the same
|
||||
/// location hold the same objects.
|
||||
pub fn object_store_location(resource: &ObjectStoreResource) -> String {
|
||||
match resource {
|
||||
ObjectStoreResource::S3(s) => format!(
|
||||
"s3:{}:{}:{}:{}",
|
||||
s.endpoint,
|
||||
s.port.unwrap_or_default(),
|
||||
s.region,
|
||||
s.bucket
|
||||
),
|
||||
ObjectStoreResource::Azure(a) => format!(
|
||||
"azure:{}:{}:{}",
|
||||
a.endpoint.as_deref().unwrap_or_default(),
|
||||
a.account_name,
|
||||
a.container_name
|
||||
),
|
||||
ObjectStoreResource::Gcs(g) => format!("gcs:{}", g.bucket),
|
||||
ObjectStoreResource::Filesystem(f) => format!("fs:{}", f.root_path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
pub async fn build_object_store_from_settings(
|
||||
settings: ObjectSettings,
|
||||
init_private_key: Option<&windmill_common::DB>,
|
||||
) -> error::Result<ExpirableObjectStore> {
|
||||
let located =
|
||||
|store: Arc<dyn ObjectStore>, resource: ObjectStoreResource| ExpirableObjectStore {
|
||||
store,
|
||||
refresh: None,
|
||||
location: Some(object_store_location(&resource)),
|
||||
};
|
||||
match settings {
|
||||
ObjectSettings::S3(s3_settings) => build_s3_client_from_settings(s3_settings)
|
||||
.await
|
||||
.map(|x| ExpirableObjectStore::from(x)),
|
||||
ObjectSettings::Azure(azure_settings) => {
|
||||
let azure_blob_resource = azure_settings;
|
||||
build_azure_blob_client(&azure_blob_resource).map(|x| ExpirableObjectStore::from(x))
|
||||
ObjectSettings::S3(s3_settings) => {
|
||||
let s3_resource = s3_resource_from_settings(s3_settings);
|
||||
build_s3_client(&s3_resource)
|
||||
.await
|
||||
.map(|x| located(x, ObjectStoreResource::S3(s3_resource)))
|
||||
}
|
||||
ObjectSettings::Azure(azure_settings) => build_azure_blob_client(&azure_settings)
|
||||
.map(|x| located(x, ObjectStoreResource::Azure(azure_settings))),
|
||||
ObjectSettings::AwsOidc(ref s3_aws_oidc_settings) => {
|
||||
let token_generator = crate::job_s3_helpers_oss::TokenGenerator::AsServerInstance();
|
||||
let res = crate::job_s3_helpers_oss::generate_s3_aws_oidc_resource(
|
||||
@@ -914,17 +966,14 @@ pub async fn build_object_store_from_settings(
|
||||
.map(|x| ExpirableObjectStore {
|
||||
store: x,
|
||||
refresh: Some(ObjectStoreRefresh::new(settings.clone(), res.expiration())),
|
||||
location: Some(object_store_location(&res)),
|
||||
})
|
||||
}
|
||||
ObjectSettings::Gcs(gcs_settings) => {
|
||||
let gcs_resource = gcs_settings;
|
||||
build_gcs_client(&gcs_resource)
|
||||
.await
|
||||
.map(|x| ExpirableObjectStore::from(x))
|
||||
}
|
||||
ObjectSettings::Filesystem(fs) => {
|
||||
build_filesystem_client(&fs.root_path).map(|x| ExpirableObjectStore::from(x))
|
||||
}
|
||||
ObjectSettings::Gcs(gcs_settings) => build_gcs_client(&gcs_settings)
|
||||
.await
|
||||
.map(|x| located(x, ObjectStoreResource::Gcs(gcs_settings))),
|
||||
ObjectSettings::Filesystem(fs) => build_filesystem_client(&fs.root_path)
|
||||
.map(|x| located(x, ObjectStoreResource::Filesystem(fs))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,14 +986,14 @@ fn none_if_empty(s: Option<String>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The S3 resource instance settings resolve to, the environment filling in what they
|
||||
/// leave out.
|
||||
#[cfg(feature = "parquet")]
|
||||
pub async fn build_s3_client_from_settings(
|
||||
settings: S3Settings,
|
||||
) -> error::Result<Arc<dyn ObjectStore>> {
|
||||
fn s3_resource_from_settings(settings: S3Settings) -> S3Resource {
|
||||
let region = none_if_empty(settings.region)
|
||||
.unwrap_or_else(|| std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()));
|
||||
|
||||
let s3_resource = S3Resource {
|
||||
S3Resource {
|
||||
endpoint: none_if_empty(settings.endpoint).unwrap_or_else(|| {
|
||||
std::env::var("S3_ENDPOINT").unwrap_or_else(|_| format!("s3.{region}.amazonaws.com"))
|
||||
}),
|
||||
@@ -959,9 +1008,7 @@ pub async fn build_s3_client_from_settings(
|
||||
port: settings.port,
|
||||
token: None,
|
||||
expiration: None,
|
||||
};
|
||||
|
||||
build_s3_client(&s3_resource).await
|
||||
}
|
||||
}
|
||||
|
||||
// Resolving the default chain goes over the network (ECS/IMDS) on instances relying on an
|
||||
@@ -2624,6 +2671,36 @@ mod tests {
|
||||
reload_cache_object_store_override(&db, None).await;
|
||||
}
|
||||
|
||||
/// A store built from settings is located by where its objects are, not by how the
|
||||
/// client describes itself: an S3 client prints only its bucket, so the same bucket name
|
||||
/// on another endpoint would otherwise pass for the same store.
|
||||
#[cfg(feature = "parquet")]
|
||||
#[tokio::test]
|
||||
async fn test_settings_store_location_tells_endpoints_apart() {
|
||||
let s3 = |endpoint: &str| {
|
||||
ObjectSettings::S3(S3Settings {
|
||||
bucket: Some("windmill".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
access_key: Some("key".to_string()),
|
||||
secret_key: Some("secret".to_string()),
|
||||
endpoint: Some(endpoint.to_string()),
|
||||
allow_http: Some(true),
|
||||
path_style: Some(true),
|
||||
store_logs: None,
|
||||
port: None,
|
||||
})
|
||||
};
|
||||
let a = build_object_store_from_settings(s3("minio.internal:9000"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = build_object_store_from_settings(s3("s3.us-east-1.amazonaws.com"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a.store.to_string(), b.store.to_string());
|
||||
assert!(a.location.is_some());
|
||||
assert_ne!(a.location, b.location);
|
||||
}
|
||||
|
||||
// --- get_logs_from_store test ---
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
|
||||
@@ -654,6 +654,16 @@ pub struct ResultMetadata {
|
||||
pub wm_failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses a marker struct out of a job result, which only an object can carry.
|
||||
/// A derived `Deserialize` also accepts an array, filling fields by position, so
|
||||
/// without the check a result like `[[], "boom"]` reads as `wm_failure: "boom"`.
|
||||
pub fn parse_result_object<T: serde::de::DeserializeOwned>(result: &str) -> Option<T> {
|
||||
if !result.trim_start().starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(result).ok()
|
||||
}
|
||||
|
||||
/// Sentinel `error.name` we inject into a result when retagging a successful
|
||||
/// run as a failure due to `wm_failure`. Used downstream to detect that
|
||||
/// the result is already in the standard `{ error: { name, message }, ... }`
|
||||
@@ -674,8 +684,7 @@ pub fn is_pre_shaped_wm_failure_result(result: &str) -> bool {
|
||||
struct NameOnly {
|
||||
name: String,
|
||||
}
|
||||
serde_json::from_str::<Marker>(result)
|
||||
.ok()
|
||||
parse_result_object::<Marker>(result)
|
||||
.and_then(|m| m.error)
|
||||
.map(|e| e.name == MANUAL_FAILURE_ERROR_NAME)
|
||||
.unwrap_or(false)
|
||||
@@ -721,7 +730,7 @@ impl ValidableJson for Box<RawValue> {
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
serde_json::from_str::<ResultMetadata>(self.get()).unwrap_or_default()
|
||||
parse_result_object::<ResultMetadata>(self.get()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
@@ -774,6 +783,10 @@ impl ValidableJson for serde_json::Value {
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
// An array would decode positionally, see `parse_result_object`.
|
||||
if !self.is_object() {
|
||||
return ResultMetadata::default();
|
||||
}
|
||||
serde_json::from_value::<ResultMetadata>(self.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -7876,3 +7889,36 @@ mod git_sync_concurrency_key_tests {
|
||||
assert!(a.len() <= 255 && b.len() <= 255);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod result_metadata_tests {
|
||||
use super::{ResultMetadata, ValidableJson};
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
fn from_raw(json: &str) -> ResultMetadata {
|
||||
RawValue::from_string(json.to_string())
|
||||
.unwrap()
|
||||
.result_metadata()
|
||||
}
|
||||
|
||||
fn from_value(json: &str) -> ResultMetadata {
|
||||
serde_json::from_str::<serde_json::Value>(json)
|
||||
.unwrap()
|
||||
.result_metadata()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_result_carries_no_markers() {
|
||||
for json in [r#"[["label"], "boom"]"#, r#"[null, "boom"]"#] {
|
||||
for meta in [from_raw(json), from_value(json)] {
|
||||
assert!(
|
||||
meta.wm_labels.is_none() && meta.wm_failure.is_none(),
|
||||
"{json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let meta = from_raw(r#"{"wm_labels": ["label"], "wm_failure": "boom"}"#);
|
||||
assert_eq!(meta.wm_labels, Some(vec!["label".to_string()]));
|
||||
assert_eq!(meta.wm_failure.as_deref(), Some("boom"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +97,14 @@ pub trait TriggerCrud: Send + Sync + 'static {
|
||||
const DEPLOYMENT_NAME: &'static str;
|
||||
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[];
|
||||
const IS_ALLOWED_ON_CLOUD: bool;
|
||||
/// Whether enabling this trigger in a fork while the parent has the same
|
||||
/// path enabled is a real conflict (shared upstream resource). True for
|
||||
/// Whether enabling this trigger in a fork while an ancestor workspace has
|
||||
/// the same path is a real conflict (shared upstream resource). True for
|
||||
/// listener-based kinds where two consumers compete (Kafka group, PG slot,
|
||||
/// SQS queue, etc.) and for Websocket where both subscribers fire on every
|
||||
/// broadcast. False for kinds whose upstream identifier is implicitly
|
||||
/// workspace-scoped at runtime (HTTP routes, Email local_part — clones for
|
||||
/// the non-workspaced sub-case are filtered out, so any cloned row is
|
||||
/// already collision-free vs. the parent).
|
||||
/// already collision-free vs. its ancestors).
|
||||
const FORK_CONFLICT_ON_ENABLE: bool = true;
|
||||
|
||||
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject;
|
||||
@@ -1095,54 +1095,14 @@ async fn exists_trigger<T: TriggerCrud>(
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetTriggerModePayload {
|
||||
mode: TriggerMode,
|
||||
/// When true, bypass the parent-state warning that would otherwise reject
|
||||
/// enabling a trigger that's already enabled in the parent workspace.
|
||||
/// The frontend sets this after the user confirms the duplicate-execution
|
||||
/// dialog. See windmill-trigger/src/handler.rs::set_trigger_mode for the
|
||||
/// full check.
|
||||
/// When true, bypass the fork-conflict warning that would otherwise reject
|
||||
/// enabling a trigger an ancestor workspace also has at this path. The
|
||||
/// frontend sets this after the user confirms the duplicate-execution
|
||||
/// dialog. See `set_trigger_mode` for the full check.
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
/// Returns the parent workspace id when this workspace is a fork *and* the
|
||||
/// parent has a row at the same trigger path. Used to gate enabling a trigger
|
||||
/// in a fork behind an explicit `force=true` confirmation: the fork's row was
|
||||
/// cloned from the parent, so its upstream identifier (Kafka group, PG slot,
|
||||
/// SQS queue URL, etc.) is shared by construction. The risk is independent of
|
||||
/// the parent's current `mode`: if the parent is enabled, the two listeners
|
||||
/// compete; if it's disabled, the fork can destructively take over shared
|
||||
/// state (e.g. advance the PG WAL, claim an MQTT client_id) before the parent
|
||||
/// re-enables. Either way, the user should be asked to confirm.
|
||||
async fn parent_has_trigger(
|
||||
tx: &mut PgConnection,
|
||||
table_name: &str,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let parent: Option<String> =
|
||||
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
let Some(parent_id) = parent else {
|
||||
return Ok(None);
|
||||
};
|
||||
let exists: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)",
|
||||
table_name
|
||||
))
|
||||
.bind(&parent_id)
|
||||
.bind(path)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
Ok(if exists == Some(true) {
|
||||
Some(parent_id)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_trigger_mode<T: TriggerCrud>(
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
authed: ApiAuthed,
|
||||
@@ -1157,22 +1117,28 @@ async fn set_trigger_mode<T: TriggerCrud>(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Block transitioning a trigger in a fork to any mode that attaches a
|
||||
// listener (Enabled or Suspended) when the parent has the same path,
|
||||
// listener (Enabled or Suspended) when an ancestor has the same path,
|
||||
// unless the caller passes force=true. Suspended still keeps the
|
||||
// listener attached — it just stops auto-running queued jobs — so a
|
||||
// suspended fork would still split Kafka events / share a PG slot
|
||||
// with the parent. The cloned upstream identifier is shared by
|
||||
// construction; the risk is independent of the parent's current mode.
|
||||
// Skipped for kinds where the upstream identifier is already
|
||||
// workspace-scoped at runtime (HTTP, Email).
|
||||
// with the ancestor. The risk is independent of the ancestor's current
|
||||
// mode: enabled, the two listeners compete; disabled, the fork can
|
||||
// destructively take over shared state (advance the PG WAL, claim an
|
||||
// MQTT client_id) before it re-enables. Skipped for kinds where the
|
||||
// upstream identifier is already workspace-scoped at runtime (HTTP, Email).
|
||||
if T::FORK_CONFLICT_ON_ENABLE && payload.mode != TriggerMode::Disabled && !payload.force {
|
||||
if let Some(parent_id) =
|
||||
parent_has_trigger(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?
|
||||
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
|
||||
&mut *tx,
|
||||
T::TABLE_NAME,
|
||||
&workspace_id,
|
||||
path,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"fork-conflict:{}:{}",
|
||||
T::TRIGGER_TYPE,
|
||||
parent_id
|
||||
ancestor_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@ pub struct ListableFlow {
|
||||
pub deployment_msg: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Projected from the flow value so a list can mark a flow that opens as a
|
||||
/// chat without fetching every flow's value.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chat_input_enabled: Option<bool>,
|
||||
/// True when the authed user has a draft for this flow (draft-only or layered
|
||||
/// over the deployed row). See ListableScript in scripts.rs.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -26,6 +26,10 @@ use windmill_ai::{
|
||||
image_handler::upload_image_to_s3,
|
||||
providers::{
|
||||
create_chat_completions_query_builder, create_query_builder, is_chat_completions_only,
|
||||
openai::{
|
||||
is_reasoning_summary_unavailable, rejects_reasoning_summary,
|
||||
remember_reasoning_summary_unavailable,
|
||||
},
|
||||
remember_chat_completions_only,
|
||||
},
|
||||
proxy::{
|
||||
@@ -1300,6 +1304,10 @@ pub async fn run_agent(
|
||||
attachments: args.user_attachments.as_deref(),
|
||||
has_websearch,
|
||||
prompt_cache_key: include_prompt_cache_key.then_some(prompt_cache_key.as_str()),
|
||||
reasoning_summary: !is_reasoning_summary_unavailable(
|
||||
&credentials,
|
||||
args.provider.get_model(),
|
||||
),
|
||||
};
|
||||
|
||||
// A worker cannot run the client credentials exchange, so an OAuth resource
|
||||
@@ -1350,7 +1358,8 @@ pub async fn run_agent(
|
||||
|
||||
// An endpoint can reject the request shape rather than the model:
|
||||
// `stream_options` and `prompt_cache_key`, which not every OpenAI-compatible
|
||||
// gateway accepts, and the route itself, when an Azure resource is outside
|
||||
// gateway accepts, a reasoning summary, which OpenAI refuses to unverified
|
||||
// organizations, and the route itself, when an Azure resource is outside
|
||||
// the Responses API's model/region matrix. Each is retried once with that
|
||||
// part dropped.
|
||||
// Set where the route is found to be absent, and read once the fallback has
|
||||
@@ -1407,6 +1416,9 @@ pub async fn run_agent(
|
||||
&& status.as_u16() == 400
|
||||
&& text.contains("prompt_cache_key");
|
||||
|
||||
let summary_refused = build_args.reasoning_summary
|
||||
&& rejects_reasoning_summary(status.as_u16(), &text);
|
||||
|
||||
// Only the first call of the step may re-route: an endpoint that
|
||||
// does not serve this API rejects that one already, whereas a
|
||||
// rejection once the conversation is under way is about the
|
||||
@@ -1430,6 +1442,15 @@ pub async fn run_agent(
|
||||
);
|
||||
include_prompt_cache_key = false;
|
||||
build_args.prompt_cache_key = None;
|
||||
} else if summary_refused {
|
||||
tracing::info!(
|
||||
"Retrying request without the reasoning summary the endpoint refused"
|
||||
);
|
||||
remember_reasoning_summary_unavailable(
|
||||
&credentials,
|
||||
args.provider.get_model(),
|
||||
);
|
||||
build_args.reasoning_summary = false;
|
||||
} else if route_unserved {
|
||||
tracing::info!(
|
||||
"Endpoint rejected the request ({}), falling back to chat/completions",
|
||||
|
||||
@@ -2874,15 +2874,17 @@ pub async fn handle_wac_v2_output(
|
||||
.collect();
|
||||
|
||||
// Resolve job_payload once (same for all children since they re-run
|
||||
// the parent script)
|
||||
// the parent script). The step's cache setting is for the workflow's
|
||||
// result; a task is cached only through its own `cache_ttl` option,
|
||||
// under a key of its own (see `cached_result_path`).
|
||||
let job_payload_template = match job.kind {
|
||||
JobKind::Script => {
|
||||
if let Some(hash) = job.runnable_id {
|
||||
Ok(JobPayload::ScriptHash {
|
||||
hash,
|
||||
path: job.runnable_path.clone().unwrap_or_default(),
|
||||
cache_ttl: job.cache_ttl,
|
||||
cache_ignore_s3_path: job.cache_ignore_s3_path,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
language: job.script_lang.unwrap_or(ScriptLang::Bun),
|
||||
priority: job.priority,
|
||||
@@ -2897,6 +2899,27 @@ pub async fn handle_wac_v2_output(
|
||||
))
|
||||
}
|
||||
}
|
||||
// A deployed flow runs an inline step as the `flow_node` its deploy
|
||||
// rewrote it into; the child re-runs that node the way a `Script`
|
||||
// child re-runs its hash, so `runnable_id` (the checkpoint's source
|
||||
// hash) stays the same across parent and children.
|
||||
JobKind::FlowScript => {
|
||||
if let Some(id) = job.runnable_id {
|
||||
Ok(JobPayload::FlowScript {
|
||||
id: windmill_common::flows::FlowNodeId(id.0),
|
||||
path: job.runnable_path.clone().unwrap_or_default(),
|
||||
language: job.script_lang.unwrap_or(ScriptLang::Bun),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
})
|
||||
} else {
|
||||
Err(error::Error::internal_err(
|
||||
"WAC v2 FlowScript job missing runnable_id".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
JobKind::Preview => {
|
||||
let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT raw_code, raw_lock FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
@@ -2912,8 +2935,8 @@ pub async fn handle_wac_v2_output(
|
||||
hash: None,
|
||||
language: job.script_lang.unwrap_or(ScriptLang::Bun),
|
||||
lock: lock,
|
||||
cache_ttl: job.cache_ttl,
|
||||
cache_ignore_s3_path: job.cache_ignore_s3_path,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: ConcurrencySettingsWithCustom::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
@@ -3012,6 +3035,12 @@ pub async fn handle_wac_v2_output(
|
||||
let mut pushed_ids: Vec<Uuid> = Vec::with_capacity(num_steps);
|
||||
let push_result: error::Result<()> = async {
|
||||
for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) {
|
||||
// A task with a runnable of its own (a deployed script or flow) queues
|
||||
// at that runnable's priority; any other task is the parent's code and
|
||||
// queues at the parent's.
|
||||
let own_runnable = matches!(step.dispatch_type.as_str(), "script" | "flow")
|
||||
&& !step.script.starts_with("./");
|
||||
|
||||
// Resolve job payload based on dispatch_type
|
||||
let (job_payload, child_args, is_external, on_behalf_of) =
|
||||
match step.dispatch_type.as_str() {
|
||||
@@ -3025,8 +3054,8 @@ pub async fn handle_wac_v2_output(
|
||||
hash: None,
|
||||
language: module.language,
|
||||
lock: module.lock,
|
||||
cache_ttl: job.cache_ttl,
|
||||
cache_ignore_s3_path: job.cache_ignore_s3_path,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: ConcurrencySettingsWithCustom::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
@@ -3110,7 +3139,8 @@ pub async fn handle_wac_v2_output(
|
||||
let mut job_payload = job_payload;
|
||||
if let Some(cache_ttl) = step.cache_ttl {
|
||||
match &mut job_payload {
|
||||
JobPayload::ScriptHash { cache_ttl: ref mut ct, .. } => {
|
||||
JobPayload::ScriptHash { cache_ttl: ref mut ct, .. }
|
||||
| JobPayload::FlowScript { cache_ttl: ref mut ct, .. } => {
|
||||
*ct = Some(cache_ttl)
|
||||
}
|
||||
JobPayload::Code(ref mut code) => code.cache_ttl = Some(cache_ttl),
|
||||
@@ -3122,7 +3152,8 @@ pub async fn handle_wac_v2_output(
|
||||
|| step.concurrency_time_window_s.is_some()
|
||||
{
|
||||
match &mut job_payload {
|
||||
JobPayload::ScriptHash { concurrency_settings: ref mut cs, .. } => {
|
||||
JobPayload::ScriptHash { concurrency_settings: ref mut cs, .. }
|
||||
| JobPayload::FlowScript { concurrency_settings: ref mut cs, .. } => {
|
||||
if let Some(limit) = step.concurrent_limit {
|
||||
cs.concurrent_limit = Some(limit);
|
||||
}
|
||||
@@ -3188,13 +3219,14 @@ pub async fn handle_wac_v2_output(
|
||||
job.visible_to_owner,
|
||||
step.tag.clone().or_else(|| Some(job.tag.clone())),
|
||||
step.timeout.or(job.timeout),
|
||||
None, // flow_step_id
|
||||
step.priority, // priority_override
|
||||
None, // authed
|
||||
false, // running
|
||||
None, // end_user_email
|
||||
None, // trigger
|
||||
None, // suspended_mode
|
||||
None, // flow_step_id
|
||||
step.priority
|
||||
.or(if own_runnable { None } else { job.priority }),
|
||||
None, // authed
|
||||
false, // running
|
||||
None, // end_user_email
|
||||
None, // trigger
|
||||
None, // suspended_mode
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1559,7 +1559,7 @@ pub async fn cached_result_path(
|
||||
client: &AuthedClient,
|
||||
job: &MiniPulledJob,
|
||||
raw_data: Option<&RawData>,
|
||||
) -> String {
|
||||
) -> windmill_common::error::Result<String> {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(&[job.kind as u8]);
|
||||
if let Some(ScriptHash(hash)) = job.runnable_id {
|
||||
@@ -1574,6 +1574,13 @@ pub async fn cached_result_path(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A workflow-as-code task child runs its parent's code with the parent's
|
||||
// arguments; the step it executes is what tells its result from the parent's
|
||||
// and from its siblings'.
|
||||
if let Some(step_key) = wac_executing_key(db, job).await? {
|
||||
hasher.update(b"wac_step:");
|
||||
hasher.update(step_key.as_bytes());
|
||||
}
|
||||
hash_args(
|
||||
db,
|
||||
client,
|
||||
@@ -1584,7 +1591,26 @@ pub async fn cached_result_path(
|
||||
job.cache_ignore_s3_path.unwrap_or(false),
|
||||
)
|
||||
.await;
|
||||
format!("g/results/{:064x}", hasher.finalize())
|
||||
Ok(format!("g/results/{:064x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// The checkpoint step key a workflow-as-code parent seeded for this child at push
|
||||
/// time; `None` for any job that is not such a child.
|
||||
async fn wac_executing_key(
|
||||
db: &DB,
|
||||
job: &MiniPulledJob,
|
||||
) -> windmill_common::error::Result<Option<String>> {
|
||||
if job.parent_job.is_none() || job.flow_step_id.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let key: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT workflow_as_code_status->'_checkpoint'->>'_executing_key' \
|
||||
FROM v2_job_status WHERE id = $1",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(key.flatten())
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
|
||||
@@ -32,8 +32,8 @@ use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
|
||||
|
||||
use windmill_queue::{
|
||||
append_logs, asset_dispatch, get_mini_completed_job, is_pre_shaped_wm_failure_result,
|
||||
CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, MiniPulledJob, ValidableJson,
|
||||
WrappedError, INIT_SCRIPT_TAG, MANUAL_FAILURE_ERROR_NAME,
|
||||
parse_result_object, CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, MiniPulledJob,
|
||||
ValidableJson, WrappedError, INIT_SCRIPT_TAG, MANUAL_FAILURE_ERROR_NAME,
|
||||
};
|
||||
|
||||
use serde_json::{json, value::RawValue, Value};
|
||||
@@ -72,13 +72,11 @@ struct NestedErrorMessage {
|
||||
/// named `name`/`message`), and we want OTel to record the ManualFailure
|
||||
/// rather than the user's sibling fields.
|
||||
fn extract_error_message(raw: &str) -> Option<ErrorMessage> {
|
||||
let nested = serde_json::from_str::<NestedErrorMessage>(raw)
|
||||
.ok()
|
||||
.map(|n| n.error);
|
||||
let nested = parse_result_object::<NestedErrorMessage>(raw).map(|n| n.error);
|
||||
if matches!(&nested, Some(em) if em.name == MANUAL_FAILURE_ERROR_NAME) {
|
||||
return nested;
|
||||
}
|
||||
if let Ok(em) = serde_json::from_str::<ErrorMessage>(raw) {
|
||||
if let Some(em) = parse_result_object::<ErrorMessage>(raw) {
|
||||
return Some(em);
|
||||
}
|
||||
nested
|
||||
@@ -1256,18 +1254,6 @@ async fn maybe_open_git_sync_deploy_pr(
|
||||
if row.marker.is_none() {
|
||||
return;
|
||||
}
|
||||
// Runtime Enterprise gate, like the poller: the toggles may have been set
|
||||
// while a license was active (or written directly), and this hook drives
|
||||
// GitHub API calls with the installation token.
|
||||
if !matches!(
|
||||
windmill_common::ee_oss::get_license_plan().await,
|
||||
windmill_common::ee_oss::LicensePlan::Enterprise
|
||||
) {
|
||||
tracing::warn!(
|
||||
"git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(repo_path) = row.repo_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -4645,7 +4645,7 @@ pub async fn handle_queued_job(
|
||||
let cached_res_path = if job.cache_ttl.is_some() {
|
||||
match conn {
|
||||
Connection::Sql(db) => {
|
||||
Some(cached_result_path(db, &client, &job, preview_data.as_ref()).await)
|
||||
Some(cached_result_path(db, &client, &job, preview_data.as_ref()).await?)
|
||||
}
|
||||
Connection::Http(_) => None,
|
||||
}
|
||||
|
||||
@@ -2005,7 +2005,8 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
|
||||
if flow_job.cache_ttl.is_some() && success {
|
||||
let flow = RawData::Flow(flow_data.clone());
|
||||
let cached_res_path = cached_result_path(db, client, &flow_job, Some(&flow)).await;
|
||||
let cached_res_path =
|
||||
cached_result_path(db, client, &flow_job, Some(&flow)).await?;
|
||||
|
||||
save_in_cache(
|
||||
db,
|
||||
|
||||
@@ -1795,6 +1795,17 @@ async fn lock_modules(
|
||||
agent,
|
||||
tool_inputs,
|
||||
} => {
|
||||
if let Some(agent_path) = agent.as_deref().filter(|_| !skip_flow_update) {
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
|
||||
job_path,
|
||||
agent_path,
|
||||
job.workspace_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Extract FlowModules from tools and track their original indices
|
||||
// MCP tools don't need locking, so we filter them out
|
||||
let mut flow_modules = Vec::new();
|
||||
|
||||
+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.812.0";
|
||||
export const VERSION = "v1.813.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -128,6 +128,8 @@ import {
|
||||
} from "../../utils/metadata.ts";
|
||||
import {
|
||||
DoubleLinkedDependencyTree,
|
||||
LocalScripts,
|
||||
resolvePlaceholdersFromLocal,
|
||||
uploadScripts,
|
||||
} from "../../utils/dependency_tree.ts";
|
||||
import {
|
||||
@@ -176,6 +178,7 @@ import {
|
||||
isDbtModulePath,
|
||||
isDbtGeneratedPath,
|
||||
isModuleEntryPoint,
|
||||
scriptPathToRemotePath,
|
||||
getScriptBasePathFromModulePath,
|
||||
hasWrongFormatSuffix,
|
||||
DBT_DESCRIPTOR_NAME,
|
||||
@@ -3385,6 +3388,37 @@ async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index the checkout's standalone scripts by the remote path a relative import
|
||||
* resolves to, reusing the content the local/remote diff already read.
|
||||
*
|
||||
* Same classification as `addToChangedIfNotExists`: a flow or app inline script
|
||||
* is not addressable as an import target, and a module bundle is addressed by
|
||||
* its entry point.
|
||||
*/
|
||||
function localScriptsByRemotePath(
|
||||
localMap: Record<string, string>,
|
||||
): LocalScripts {
|
||||
const byRemotePath: LocalScripts = new Map();
|
||||
for (const [p, content] of Object.entries(localMap)) {
|
||||
if (isScriptModulePath(p)) {
|
||||
if (!isModuleEntryPoint(p)) continue;
|
||||
} else if (
|
||||
!hasScriptExt(p) ||
|
||||
isDatatableMigrationPath(p) ||
|
||||
isFileResource(p) ||
|
||||
isFilesetResource(p) ||
|
||||
isFlowPath(p) ||
|
||||
isAppPath(p) ||
|
||||
isRawAppPath(p)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
byRemotePath.set(scriptPathToRemotePath(p), { localPath: p, content });
|
||||
}
|
||||
return byRemotePath;
|
||||
}
|
||||
|
||||
export async function buildTracker(changes: Change[]) {
|
||||
const tracker: ChangeTracker = {
|
||||
scripts: [],
|
||||
@@ -5074,6 +5108,14 @@ export async function push(
|
||||
}
|
||||
|
||||
if (autoRegenerate && tree) {
|
||||
// Pass 1 only ever walks the change set, so anything imported through a
|
||||
// module the push leaves alone is still a dead end here.
|
||||
await resolvePlaceholdersFromLocal(
|
||||
tree,
|
||||
localScriptsByRemotePath(localMap),
|
||||
opts.defaultTs,
|
||||
);
|
||||
|
||||
// Propagate staleness through imports + upload script content to
|
||||
// raw_script_temp so the dep job can resolve cross-folder relative imports
|
||||
// via temp_script_refs (instead of hitting 404s for not-yet-deployed
|
||||
|
||||
@@ -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.812.0";
|
||||
export const VERSION = "1.813.0";
|
||||
|
||||
Generated
+35
-11
@@ -4608,6 +4608,14 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
|
||||
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
|
||||
# no \`\`delay\`\` all go out in a single round.
|
||||
#
|
||||
# \`\`cache_ttl\`\` serves a previous result of the task for that many seconds
|
||||
# instead of running it again. A task is keyed on its step key (its name and
|
||||
# call order) and the workflow's input, not on the arguments it is called
|
||||
# with, so cache one only when whether it runs, and what it receives, follow
|
||||
# from the workflow's input alone. A \`\`task_script\`\` target is keyed on the
|
||||
# arguments it is called with. It has no effect on a \`\`task_flow\`\` target,
|
||||
# which keeps its flow's own cache policy.
|
||||
#
|
||||
# Usage::
|
||||
#
|
||||
# @task
|
||||
@@ -6012,8 +6020,8 @@ export async function main(user_id: string) {
|
||||
const users = await sql\`SELECT * FROM users WHERE active = \${true}\`.fetch();
|
||||
|
||||
// Insert/Update
|
||||
await sql\`INSERT INTO users (name, email) VALUES (\${name}, \${email})\`;
|
||||
await sql\`UPDATE users SET name = \${newName} WHERE id = \${user_id}\`;
|
||||
await sql\`INSERT INTO users (name, email) VALUES (\${name}, \${email})\`.execute();
|
||||
await sql\`UPDATE users SET name = \${newName} WHERE id = \${user_id}\`.execute();
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -6032,8 +6040,8 @@ def main(user_id: str):
|
||||
users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()
|
||||
|
||||
# Insert/Update
|
||||
db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email)
|
||||
db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id)
|
||||
db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()
|
||||
db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()
|
||||
|
||||
return user
|
||||
\`\`\`
|
||||
@@ -6042,13 +6050,14 @@ def main(user_id: str):
|
||||
|
||||
1. **Check existing tables** before creating new ones — reuse beats schema growth.
|
||||
2. **Use parameterized queries** — never concatenate user input into SQL.
|
||||
3. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
4. **Use descriptive keys** — \`get_user\`, not \`a\`.
|
||||
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
|
||||
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
7. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
|
||||
8. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
9. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
3. **Terminate every datatable statement** — the tagged template and \`db.query(...)\` only build a statement. It runs when you call \`fetch\` / \`fetchOne\` / \`fetchOneScalar\` / \`execute\` (\`fetch\` / \`fetch_one\` / \`fetch_one_scalar\` / \`execute\` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.
|
||||
4. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
5. **Use descriptive keys** — \`get_user\`, not \`a\`.
|
||||
6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
|
||||
7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable.
|
||||
9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.
|
||||
10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.
|
||||
`,
|
||||
"triggers": `---
|
||||
name: triggers
|
||||
@@ -6741,6 +6750,13 @@ export interface TaskRetry {
|
||||
export interface TaskOptions {
|
||||
timeout?: number;
|
||||
tag?: string;
|
||||
/** Seconds during which a previous result of this task is served instead of
|
||||
* running it again. A task written inline in the workflow is keyed on its
|
||||
* step key (its name and call order) and the workflow's input, not on the
|
||||
* arguments it is called with, so cache one only when whether it runs, and
|
||||
* what it receives, follow from the workflow's input alone. A \`taskScript\`
|
||||
* target is keyed on the arguments it is called with. It has no effect on a
|
||||
* \`taskFlow\` target, which keeps its flow's own cache policy. */
|
||||
cache_ttl?: number;
|
||||
priority?: number;
|
||||
concurrency_limit?: number;
|
||||
@@ -6932,6 +6948,14 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
|
||||
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
|
||||
# no \`\`delay\`\` all go out in a single round.
|
||||
#
|
||||
# \`\`cache_ttl\`\` serves a previous result of the task for that many seconds
|
||||
# instead of running it again. A task is keyed on its step key (its name and
|
||||
# call order) and the workflow's input, not on the arguments it is called
|
||||
# with, so cache one only when whether it runs, and what it receives, follow
|
||||
# from the workflow's input alone. A \`\`task_script\`\` target is keyed on the
|
||||
# arguments it is called with. It has no effect on a \`\`task_flow\`\` target,
|
||||
# which keeps its flow's own cache policy.
|
||||
#
|
||||
# Usage::
|
||||
#
|
||||
# @task
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Workspace } from "../commands/workspace/workspace.ts";
|
||||
import * as wmill from "../../gen/services.gen.ts";
|
||||
import type { ScriptLang } from "../../gen/types.gen.ts";
|
||||
import { ScriptLanguage } from "./script_common.ts";
|
||||
import { ScriptLanguage, inferContentTypeFromFilePath } from "./script_common.ts";
|
||||
import {
|
||||
filterWorkspaceDependencies,
|
||||
generateScriptHash,
|
||||
@@ -14,6 +14,65 @@ import {
|
||||
updateMetadataGlobalLock,
|
||||
} from "./metadata.ts";
|
||||
import { generateHash } from "./utils.ts";
|
||||
import { extractRelativeImports } from "./relative_imports.ts";
|
||||
|
||||
/** A local script file, keyed in `LocalScripts` by its Windmill remote path. */
|
||||
export interface LocalScriptSource {
|
||||
localPath: string;
|
||||
content: string;
|
||||
}
|
||||
export type LocalScripts = Map<string, LocalScriptSource>;
|
||||
|
||||
/**
|
||||
* Give every import target that is only a placeholder its local content and its
|
||||
* own imports, so the graph continues through it. A tree seeded from a subset of
|
||||
* the checkout otherwise dead-ends at any module outside that subset — a
|
||||
* re-export barrel needing no edit, typically — hiding what it re-exports from
|
||||
* `getTempScriptRefs`, which then resolves it against the deployed copy.
|
||||
*/
|
||||
export async function resolvePlaceholdersFromLocal(
|
||||
tree: DoubleLinkedDependencyTree,
|
||||
localScripts: LocalScripts,
|
||||
defaultTs: "bun" | "deno" | undefined
|
||||
): Promise<void> {
|
||||
// A module resolved here can expose placeholders of its own (a barrel behind
|
||||
// a barrel), so keep going until a round resolves nothing.
|
||||
for (;;) {
|
||||
let resolved = false;
|
||||
for (const remotePath of tree.placeholderPaths()) {
|
||||
const local = localScripts.get(remotePath);
|
||||
if (!local) continue;
|
||||
let language: ScriptLanguage;
|
||||
try {
|
||||
language = inferContentTypeFromFilePath(local.localPath, defaultTs);
|
||||
} catch {
|
||||
// A bare `.sql` names no dialect, so its imports cannot be read here.
|
||||
continue;
|
||||
}
|
||||
const imports = await extractRelativeImports(
|
||||
local.content,
|
||||
remotePath,
|
||||
language
|
||||
);
|
||||
// Never directly stale: it is outside the change set, so nothing relocks
|
||||
// it. It is here to carry edges, and to be uploaded if it differs from
|
||||
// what is deployed.
|
||||
await tree.addNode(
|
||||
remotePath,
|
||||
local.content,
|
||||
language,
|
||||
"",
|
||||
imports,
|
||||
"script",
|
||||
remotePath,
|
||||
local.localPath,
|
||||
false
|
||||
);
|
||||
resolved = true;
|
||||
}
|
||||
if (!resolved) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff local scripts against deployed versions, upload only those that differ.
|
||||
@@ -97,6 +156,9 @@ interface DependencyNode {
|
||||
originalPath: string; // Original path passed to handler (with extension for scripts)
|
||||
isRawApp?: boolean; // Only set for apps
|
||||
isDirectlyStale: boolean; // True if this item's content changed (vs transitively stale)
|
||||
// True while the node exists only because something imports it, so it carries
|
||||
// no content and no imports of its own.
|
||||
isPlaceholder: boolean;
|
||||
}
|
||||
|
||||
export class DoubleLinkedDependencyTree {
|
||||
@@ -130,9 +192,11 @@ export class DoubleLinkedDependencyTree {
|
||||
content: "", stalenessHash: "", language: "deno", metadata: "",
|
||||
imports: new Set(), importedBy: new Set(),
|
||||
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
|
||||
isPlaceholder: true,
|
||||
});
|
||||
}
|
||||
const node = this.nodes.get(path)!;
|
||||
node.isPlaceholder = false;
|
||||
node.content = content;
|
||||
node.stalenessHash = stalenessHash;
|
||||
node.language = language;
|
||||
@@ -155,7 +219,7 @@ export class DoubleLinkedDependencyTree {
|
||||
stalenessHash: "", language: depsInfo?.language ?? "deno", metadata: "",
|
||||
imports: new Set(), importedBy: new Set(),
|
||||
itemType: "dependencies", folder: "", originalPath: depsPath,
|
||||
isDirectlyStale: !isUpToDate,
|
||||
isDirectlyStale: !isUpToDate, isPlaceholder: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,6 +233,7 @@ export class DoubleLinkedDependencyTree {
|
||||
content: "", stalenessHash: "", language: "deno", metadata: "",
|
||||
imports: new Set(), importedBy: new Set(),
|
||||
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
|
||||
isPlaceholder: true,
|
||||
});
|
||||
}
|
||||
this.nodes.get(importPath)!.importedBy.add(path);
|
||||
@@ -309,6 +374,18 @@ export class DoubleLinkedDependencyTree {
|
||||
return this.nodes.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that exist only as somebody's import target, so the traversal stops
|
||||
* at them instead of continuing into what they themselves import.
|
||||
*/
|
||||
placeholderPaths(): string[] {
|
||||
const result: string[] = [];
|
||||
for (const [path, node] of this.nodes.entries()) {
|
||||
if (node.isPlaceholder) result.push(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns paths of all stale nodes (those with a staleReason).
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,10 @@ import { expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { DoubleLinkedDependencyTree } from "../src/utils/dependency_tree.ts";
|
||||
import {
|
||||
DoubleLinkedDependencyTree,
|
||||
resolvePlaceholdersFromLocal,
|
||||
} from "../src/utils/dependency_tree.ts";
|
||||
|
||||
// addNode consults wmill-lock.yaml from cwd for workspace deps; run inside a
|
||||
// temp dir so the test never reads/writes the repo's own lock file.
|
||||
@@ -104,3 +107,62 @@ test("getAllTempScriptRefs is a superset of getTempScriptRefs for any node", asy
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Two barrels deep so the fixpoint matters: resolving the first one is what
|
||||
// puts the second in the tree, and only a further round reaches the leaf.
|
||||
test("resolvePlaceholdersFromLocal walks the graph through unresolved barrels", async () => {
|
||||
await withTempDir(async () => {
|
||||
const tree = new DoubleLinkedDependencyTree();
|
||||
await tree.addNode(
|
||||
"f/app/consumer",
|
||||
`import { subtract } from "../barrel/index.ts"`,
|
||||
"bun",
|
||||
"",
|
||||
["f/barrel/index"],
|
||||
"script",
|
||||
"f/app/consumer",
|
||||
"f/app/consumer.ts",
|
||||
true,
|
||||
);
|
||||
await tree.addNode(
|
||||
"f/barrel/helper",
|
||||
"export function subtract(a: number, b: number) { return a - b }",
|
||||
"bun",
|
||||
"",
|
||||
[],
|
||||
"script",
|
||||
"f/barrel/helper",
|
||||
"f/barrel/helper.ts",
|
||||
true,
|
||||
);
|
||||
// Neither barrel is in the change set, so both are bare import targets.
|
||||
expect(tree.getTempScriptRefs("f/app/consumer")).toEqual({});
|
||||
|
||||
await resolvePlaceholdersFromLocal(
|
||||
tree,
|
||||
new Map([
|
||||
[
|
||||
"f/barrel/index",
|
||||
{
|
||||
localPath: "f/barrel/index.ts",
|
||||
content: `export * from "./mid.ts"`,
|
||||
},
|
||||
],
|
||||
[
|
||||
"f/barrel/mid",
|
||||
{
|
||||
localPath: "f/barrel/mid.ts",
|
||||
content: `export * from "./helper.ts"`,
|
||||
},
|
||||
],
|
||||
]),
|
||||
"bun",
|
||||
);
|
||||
|
||||
// Only the leaf diverged from deployed, so only it was uploaded.
|
||||
tree.setContentHash("f/barrel/helper", "hash_helper");
|
||||
expect(tree.getTempScriptRefs("f/app/consumer")).toEqual({
|
||||
"f/barrel/helper": "hash_helper",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,3 +268,78 @@ test(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// The importer and the leaf change; the barrel between them does not, so it is
|
||||
// absent from the push's change set. See `resolvePlaceholdersFromLocal`.
|
||||
test(
|
||||
"sync push --auto-metadata succeeds when a changed leaf sits behind an unchanged barrel",
|
||||
{ timeout: 180000 },
|
||||
async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
|
||||
|
||||
await createLocalScript(
|
||||
tempDir,
|
||||
"f/barrel",
|
||||
"helper",
|
||||
"bun",
|
||||
`export function add(a: number, b: number) { return a + b; }\n`,
|
||||
);
|
||||
await createLocalScript(
|
||||
tempDir,
|
||||
"f/barrel",
|
||||
"index",
|
||||
"bun",
|
||||
`export * from "./helper.ts";\n`,
|
||||
);
|
||||
await createLocalScript(
|
||||
tempDir,
|
||||
"f/app",
|
||||
"consumer",
|
||||
"bun",
|
||||
`import { add } from "../barrel/index.ts";
|
||||
export async function main() { return add(1, 2); }
|
||||
`,
|
||||
);
|
||||
|
||||
const deploy = await backend.runCLICommand(
|
||||
["sync", "push", "--yes", "--auto-metadata"],
|
||||
tempDir,
|
||||
);
|
||||
if (deploy.code !== 0) {
|
||||
console.log("STDOUT:", deploy.stdout);
|
||||
console.log("STDERR:", deploy.stderr);
|
||||
}
|
||||
expect(deploy.code).toBe(0);
|
||||
|
||||
// Add an export to the leaf and use it from the importer. The barrel
|
||||
// re-exports it already, so it stays byte-identical and out of the push.
|
||||
await writeFile(
|
||||
`${tempDir}/f/barrel/helper.ts`,
|
||||
`export function add(a: number, b: number) { return a + b; }
|
||||
export function subtract(a: number, b: number) { return a - b; }
|
||||
`,
|
||||
);
|
||||
await writeFile(
|
||||
`${tempDir}/f/app/consumer.ts`,
|
||||
`import { subtract } from "../barrel/index.ts";
|
||||
export async function main() { return subtract(3, 1); }
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["sync", "push", "--yes", "--auto-metadata"],
|
||||
tempDir,
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
console.log("STDOUT:", result.stdout);
|
||||
console.log("STDERR:", result.stderr);
|
||||
}
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined).not.toContain("No matching export");
|
||||
expect(combined).not.toContain("Failed to generate lockfile");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+137
-3
@@ -129,11 +129,58 @@ against it for the same reason.
|
||||
The feature is on wherever the workspace has primary storage, and off with
|
||||
`ai_config.sessions_storage_disabled` (the `copilot_disabled` pattern: no migration, carried by
|
||||
settings export and the CLI). A build without `parquet` has no routes (404), a workspace without
|
||||
storage answers `enabled: false`; either turns the backup off for ten minutes, after which the
|
||||
storage and nothing to stand in for it answers `enabled: false`; either turns the backup off
|
||||
for ten minutes, after which the
|
||||
page asks again on its own (a flush for whatever is pending, and a restore), and the AI
|
||||
settings page tells the mirror at once when the switch is saved there (the off state is
|
||||
forgotten, the rows that went stale are marked again, a restore runs).
|
||||
|
||||
## The instance store standing in
|
||||
|
||||
A workspace without storage of its own keeps its backups in the instance object store
|
||||
(`object_store_cache_config`, loaded the way every other use of it is, so never with
|
||||
`DISABLE_S3_STORE`; the plan is checked on every request and Pro never falls back, since a
|
||||
store loaded before a switch to Pro stays loaded), under the same layout and the same
|
||||
per-user key,
|
||||
while the instance setting `ai_sessions_instance_storage_fallback` allows it (on unless set
|
||||
to false; the instance settings page shows it under Object Storage). A build without
|
||||
`private` has neither workspace storage nor the quota below, and never falls back. Every
|
||||
answer says which kind of store it came from (`fallback`), and the instance store is named
|
||||
(`storage_id`) by what locates its objects, the endpoint, region and bucket its settings
|
||||
resolve to, in a namespace of its own: moving the instance store to another endpoint under
|
||||
the same bucket name is a storage switch for the browsers, and a workspace bucket is never
|
||||
taken for it. The location is kept with the loaded store, so a server whose reload is still
|
||||
pending names the store it writes to. A route decides between the workspace's storage and
|
||||
the instance store from the row it reads the generation from, so a push lands in the
|
||||
instance store only under a generation read while the workspace had no storage. The store a
|
||||
workspace's backups live in is resolved in one place
|
||||
(`ai_session_backups::workspace_store`), for the routes and for the rotation's deletion of
|
||||
older generations alike.
|
||||
|
||||
Configuring a storage for a workspace that had none (`edit_large_file_storage_config`) bumps
|
||||
the backup generation in the transaction that sets it, so everything the workspace left in
|
||||
any instance store sits under a generation the routes never read again: a later return to
|
||||
the instance store, whichever it is by then, starts from a newer one. Every storage settings
|
||||
change then deletes from the instance store, off the request, the workspace's generations
|
||||
older than the one it committed, whether the setting is on or off (copies from when it was
|
||||
on may be there). Nothing live is older, whatever happens next: a deletion that is slow, cut
|
||||
short, or overtaken by the storage being dropped or pointed at the instance store's own
|
||||
bucket touches only generations nothing reads. Dropping the storage bumps nothing and is a
|
||||
switch like any other: the rows go stale, the sessions are pushed whole into the instance
|
||||
store, and the old bucket keeps its copy. On the browser side a removal owed to an instance
|
||||
store (the row names it apart, `storageName` in `sessionMirrorPlan.ts`) is retired by any
|
||||
answer from the workspace's own storage, since that storage being there means the
|
||||
generation moved past the copy; one owed to a workspace storage still waits for that
|
||||
storage, whatever the instance store answered. Copies a deletion missed stay in the
|
||||
operator's bucket unread, as a deleted workspace's copies do.
|
||||
|
||||
On CE the bytes in the instance store count toward the workspace's storage quota under a
|
||||
storage name of their own (`_ai_sessions_fallback_`, listed by the periodic recount while
|
||||
the workspace has no storage of its own, and left out when there are none), so a member
|
||||
cannot fill the operator's bucket past what the workspace may use; on EE, where workspace
|
||||
storage has no quota either, nothing bounds them but the per-push caps and the instance
|
||||
setting.
|
||||
|
||||
## Conflicts and deletion
|
||||
|
||||
Last write wins across devices. The head carries no manifest; `pull` lists the session's prefix
|
||||
@@ -154,7 +201,8 @@ its pulls are done, and just before they do the whole family is listed again (me
|
||||
backups were off included, since a move from another device can land in a workspace between
|
||||
the first listings and the pulls; a family of one, with nowhere else for a copy to show up, is
|
||||
not): a session a later copy of which showed up elsewhere is left, with the family, for the
|
||||
next time. Only a user-initiated `deleteSession` removes the backup; the next push from
|
||||
next time. Only a user-initiated `deleteSession`, and the retention sweep below, remove the
|
||||
backup; the next push from
|
||||
another device that still has the session is refused with `needs_whole` (nothing of it is
|
||||
written), its row goes stale without a backoff, and that device's next flush sends the session
|
||||
whole; the workspace-lifecycle
|
||||
@@ -172,7 +220,8 @@ session whose pieces could not be written: recording it would let the next flush
|
||||
half-empty local state over the backup.
|
||||
|
||||
Every answer names the storage it came from (`storage_id`, a hash of what locates the objects,
|
||||
endpoint, region and bucket, not the credentials, which rotate) and the backup generation a
|
||||
endpoint, region and bucket, not the credentials, which rotate; the instance store standing
|
||||
in for a workspace without one is named apart, see above) and the backup generation a
|
||||
key rotation bumps (`backup_generation`). A sync row records both, and a row naming another
|
||||
storage or generation goes stale and its session is marked again: a workspace pointed at a
|
||||
new bucket, or whose key was rotated, holds nothing, and the server looks nowhere else, so
|
||||
@@ -187,6 +236,91 @@ old one; a session whose own parts were answered from different storages is not
|
||||
all. The listing a restore starts with runs the same check, so a storage switch is noticed at
|
||||
the first push after it or on the next page load, whichever comes first.
|
||||
|
||||
## Retention
|
||||
|
||||
`ai_config.sessions_retention_days` (per workspace, in the AI settings; unset by default;
|
||||
the `sessions_storage_disabled` pattern: no migration, carried by settings export and the
|
||||
CLI; 1 to 3650) puts an age on sessions, counted from their last activity. Each side applies
|
||||
it with its own clock against its own timestamps, so no clock is compared with another
|
||||
machine's, and the two do not time the same event: the server counts the last push that
|
||||
completed, a browser its last local activity, which includes reading new messages and is not
|
||||
pushed. A backup swept while a browser still reads its copy comes back once that browser
|
||||
writes to the session again (its incremental push is refused and goes whole):
|
||||
|
||||
- The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor
|
||||
about every 40 minutes on each server, one pass at a time under a session-level advisory
|
||||
lock). For every workspace with a retention it takes the store its backups live in, its
|
||||
own storage or the instance store standing in, decided from the row it reads the
|
||||
generation from as the routes do, names the users under the generation prefix
|
||||
(`list_with_delimiter`) and lists each user's `index/` once: one object
|
||||
per session, nothing of what the sessions hold. A session whose marker is older than the
|
||||
retention is removed under its lock (`lock_session`), once its markers, listed again
|
||||
there, are still all older: a push that renewed the session between the walk and the lock
|
||||
keeps it, and one split over parts either holds the lock or has the session unlisted with
|
||||
its token next to the markers (`index/{sid}/push`), which the sweep leaves alone while the
|
||||
token is younger than the retention: an older one is a push a browser abandoned, whose
|
||||
landed parts nothing lists, and it goes the same way. Before deleting anything the sweep
|
||||
writes a record next to
|
||||
the markers (`index/{sid}/sweep`, not an epoch, so neither `list` nor `pull` counts it),
|
||||
and `remove_session` deletes it last: a removal cut short, its markers already gone, is
|
||||
found by the next pass and finished, unless a push listed the session again first. At
|
||||
most 1000 sessions per workspace and pass; the rest wait for the next. `list` leaves an
|
||||
expired marker out of its answer meanwhile, so a browser never restores a session the
|
||||
sweep has not reached. The marker's modification time is the storage's clock and the
|
||||
cutoff the server's. The sweep reaches only the backups the routes would: a deleted
|
||||
workspace's stay in its storage, and so do those a workspace keeps in the instance store
|
||||
once `ai_sessions_instance_storage_fallback` is set to false.
|
||||
- The browser sweeps its own stores when a tab resolves the logged-in user
|
||||
(`sweepExpiredSessions`, from the one `onUserChange` in `sessionState.svelte.ts`), before
|
||||
that tab reads a single session. A session whose last activity is older than the retention
|
||||
by the browser's clock is deleted locally, record, chats, images, attached files and
|
||||
artifacts. A restored session carries the backup's time as its last activity, the storage's
|
||||
clock, so it counts from the later of that and the moment it was restored here
|
||||
(`restoredAt`): a browser clock ahead of the storage's never deletes a session it just
|
||||
brought back. Archived sessions count like any other, and persisted unsent drafts by their
|
||||
pending workspace.
|
||||
|
||||
The stores are shared by the user's tabs, and each keeps copies of the sessions in memory,
|
||||
so every tab holds a shared Web Lock from before it reads them until it stops using them,
|
||||
and the sweep deletes only while holding that lock exclusively, requested if available:
|
||||
granted exactly when no tab of the user has the sessions loaded, which is why the sweep
|
||||
runs where it does and nowhere else. Nothing holds a copy of what it deletes and nothing
|
||||
writes the stores meanwhile, so it deletes one record at a time and without re-reading. It
|
||||
also takes the tab lock the flush and the restore take, again only if available, so neither
|
||||
plans nor stages a session half deleted; like the restore, it does not run where Web Locks
|
||||
do not exist. With several tabs open nothing is swept, until one of them reloads alone.
|
||||
|
||||
The hold is only as good as the tabs that take it, so a tab still running a build from before
|
||||
it has the sessions loaded and holds nothing. A tab loaded after that one, across a deploy,
|
||||
can sweep a session the older tab has in memory, and a write there afterwards brings the
|
||||
record back without its chats, which the next flush pushes. It needs a tab left open across a
|
||||
deploy, a session untouched for the whole retention, and the user going back to that session
|
||||
in the older tab; the next sweep deletes it again. The same window is open to the
|
||||
workspace-lifecycle delete in `reconcileSessionsLifecycle`, which no lock guards at all.
|
||||
|
||||
What deletes is the retention the server gives as the sweep runs, asked for under both locks
|
||||
(`POST /workspaces/session_workspace_retention`, its own route rather than a field on the
|
||||
lifecycle status, whose answer a tab loaded before this version still reads). Never a
|
||||
remembered one: a retention raised or cleared since would otherwise delete a session that is
|
||||
now within it, and a persisted unsent draft has no backup to come back from. What the sweep
|
||||
keeps in localStorage decides only whether to ask again — it asks when it has asked nothing
|
||||
yet, when the answer it has is a day old, or when that answer marks a session expired — so
|
||||
an ordinary load costs no request at all. An answer that does not arrive within five seconds
|
||||
leaves the sessions for the next load rather than delete on what this browser guessed. That
|
||||
route answers for a workspace the caller can be authed into, unlike the status: a status is
|
||||
what to do with the caller's own sessions, a setting is the workspace's to tell, so a
|
||||
disabled membership is told nothing though its sessions still reconcile.
|
||||
|
||||
Each session's record goes before its pieces, so nothing plans a push for it afterwards,
|
||||
and a localStorage key written before the record and removed once every piece is gone makes
|
||||
a later sweep finish a deletion that failed, unless a restore brought the session back
|
||||
since. The record is deleted without the tombstone a user delete leaves, which is what lets
|
||||
a restore bring it back. The session's dirty mark and sync row go with it (`sessionSwept`),
|
||||
unless the row still carries a removal or a restore's staging. Nothing is sent to the storage: the local
|
||||
copy's age says nothing about another device's, which may have pushed the session since,
|
||||
and the server applies the rule to the backup on its own. A session swept here that the
|
||||
storage still lists comes back on the next restore.
|
||||
|
||||
## Limits
|
||||
|
||||
Push bodies are packed to about 8 MB (UTF-8 bytes as sent), at most 100 entries, 200 removals and
|
||||
|
||||
@@ -12,6 +12,14 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
|
||||
mints one for any non-job token but returns plain text, no redirect.
|
||||
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
|
||||
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items`
|
||||
removes an expired `token` row, the monitor emails the owner and raises a critical alert (if
|
||||
enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon"
|
||||
warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves
|
||||
the label, so a token the system mints for itself, whether from the backend or from the frontend
|
||||
through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change
|
||||
(keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix
|
||||
also goes into the SQL and Svelte mirrors that function's doc lists.
|
||||
- **Every superadmin route refuses a job token**: `require_super_admin`
|
||||
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
|
||||
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.812.0",
|
||||
"version": "1.813.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",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import { autoPlacement } from '@floating-ui/core'
|
||||
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
@@ -24,9 +25,22 @@
|
||||
closeButton
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button color={isConflict ? 'red' : 'light'} size="xs" spacingSize="xs2" nonCaptureEvent={true}
|
||||
>{isConflict ? 'Fix username conflict' : 'Change username'}</Button
|
||||
>
|
||||
{#if isConflict}
|
||||
<!-- An icon rather than a labelled button: the username column truncates text but
|
||||
cannot truncate a button, so a wide trigger here forced the whole table to scroll. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: AlertTriangle }}
|
||||
btnClasses="text-yellow-600 dark:text-yellow-400"
|
||||
title="No instance username. Click to fix the conflict."
|
||||
aria-label="Fix username conflict"
|
||||
nonCaptureEvent={true}
|
||||
/>
|
||||
{:else}
|
||||
<Button variant="default" unifiedSize="xs" nonCaptureEvent={true}>Change username</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<ChangeInstanceUsernameInner
|
||||
|
||||
@@ -53,12 +53,17 @@
|
||||
{#if item.icon}
|
||||
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
|
||||
{/if}
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate grow min-w-0 whitespace-nowrap text-left"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
<div class="grow min-w-0 text-left">
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate whitespace-nowrap"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
{#if item.description}
|
||||
<p class="text-2xs text-secondary">{item.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{@render item.extra?.()}
|
||||
{#if item.shortcut || item.selected || item.toggle !== undefined}
|
||||
<!-- Single trailing group so `shortcut` and `selected` can coexist:
|
||||
|
||||
@@ -510,8 +510,8 @@
|
||||
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)])
|
||||
)
|
||||
// The path is passed, so a draft that renames the agent is refused here too: a rename is
|
||||
// the resource editor's to deploy, and this dialog lists the agent under the path the
|
||||
// flow links.
|
||||
// the agent editor's to deploy, and this dialog lists the agent under the path the flow
|
||||
// links.
|
||||
agentRefusal = Object.fromEntries(
|
||||
draftAgents.map((a) => [a.path, agentDraftDeployRefusal(a.state, a.path)])
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Copy, Expand } from 'lucide-svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
|
||||
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
|
||||
|
||||
interface Props {
|
||||
schema?: any | undefined
|
||||
@@ -28,6 +29,8 @@
|
||||
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
|
||||
// editors); used to qualify resource links.
|
||||
workspace?: string
|
||||
/** Given, the step header starts with a back control that calls it. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +38,8 @@
|
||||
stepDetail = undefined,
|
||||
jobScriptHash = undefined,
|
||||
hideDefaultInputs = false,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
onBack = undefined
|
||||
}: Props = $props()
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
let codeViewer: Drawer | undefined = $state()
|
||||
@@ -104,57 +108,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if stepDetail == 'Input'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
{#if schema}
|
||||
<SchemaViewer {schema} />
|
||||
{:else}
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
|
||||
{/if}
|
||||
{:else if stepDetail == 'Result'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
|
||||
{:else if typeof stepDetail != 'string' && stepDetail.value}
|
||||
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
|
||||
box, so wrapped with the path link below it would scroll away with that wrapper. -->
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<div class="">
|
||||
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
|
||||
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
|
||||
<Badge color="indigo">
|
||||
{stepDetail.id}
|
||||
</Badge>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-semibold text-emphasis text-sm',
|
||||
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
|
||||
)}
|
||||
>
|
||||
{#if stepDetail.summary}
|
||||
{stepDetail.summary}
|
||||
{:else if stepDetail.value.type == 'identity'}
|
||||
Identity
|
||||
{:else if stepDetail.value.type == 'forloopflow'}
|
||||
For loop {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.value.type == 'branchall'}
|
||||
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{:else if stepDetail.value.type == 'branchone'}
|
||||
Run one branch
|
||||
{:else if stepDetail.value.type == 'flow'}
|
||||
Inner flow
|
||||
{:else if stepDetail.value.type == 'whileloopflow'}
|
||||
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.id === 'failure'}
|
||||
Error handler
|
||||
{:else if stepDetail.id === 'preprocessor'}
|
||||
Preprocessor
|
||||
{:else if stepDetail.value.type == 'rawscript'}
|
||||
Inline {stepDetail.value.language} script
|
||||
{:else if stepDetail.value.type == 'script'}
|
||||
Workspace script
|
||||
{:else if stepDetail.value.type == 'aiagent'}
|
||||
AI Agent
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if stepDetail.value.type == 'script'}
|
||||
<div class="pb-2">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Badge, Button } from './common'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
|
||||
stepDetail: FlowModule | string
|
||||
/** Given, the row starts with a back control; the caller decides where back leads. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { stepDetail, onBack = undefined }: Props = $props()
|
||||
|
||||
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
|
||||
// The error handler and the preprocessor are named by their role, not by an id badge.
|
||||
const showId = $derived(
|
||||
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
|
||||
)
|
||||
|
||||
const title = $derived.by((): string => {
|
||||
if (typeof stepDetail === 'string') {
|
||||
if (stepDetail === 'Input') return 'Flow inputs'
|
||||
if (stepDetail === 'Result') return 'Result'
|
||||
return stepDetail
|
||||
}
|
||||
if (stepDetail.summary) return stepDetail.summary
|
||||
if (stepDetail.id === 'failure') return 'Error handler'
|
||||
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
|
||||
const v = stepDetail.value
|
||||
switch (v?.type) {
|
||||
case 'identity':
|
||||
return 'Identity'
|
||||
case 'forloopflow':
|
||||
return (
|
||||
'For loop' +
|
||||
(v.parallel ? ' (parallel)' : '') +
|
||||
(v.skip_failures ? ' (skip failures)' : '') +
|
||||
(v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'whileloopflow':
|
||||
return (
|
||||
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'branchall':
|
||||
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
|
||||
case 'branchone':
|
||||
return 'Run one branch'
|
||||
case 'flow':
|
||||
return 'Inner flow'
|
||||
case 'rawscript':
|
||||
return `Inline ${v.language} script`
|
||||
case 'script':
|
||||
return 'Workspace script'
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
default:
|
||||
return stepDetail.id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
|
||||
its root by that much, so at top-0 the body would show through the padding above the row. -->
|
||||
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
|
||||
{#if onBack}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
title="Back to the flow graph"
|
||||
onclick={onBack}
|
||||
/>
|
||||
{/if}
|
||||
{#if showId && module}
|
||||
<Badge color="indigo">{module.id}</Badge>
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
|
||||
</div>
|
||||
@@ -32,29 +32,29 @@
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!state}
|
||||
title="Enable in fork conflicts with parent"
|
||||
title="Enable in fork conflicts with upstream workspace"
|
||||
confirmationText="Enable anyway"
|
||||
onConfirmed={() => close(true)}
|
||||
onCanceled={() => close(false)}
|
||||
>
|
||||
{#if state}
|
||||
<p>
|
||||
The parent workspace (<span class="font-mono">{state.parentWorkspaceId}</span>) has the same
|
||||
{state.kindLabel} configured at this path. Because this fork's row was cloned from it, the upstream
|
||||
identifier is shared.
|
||||
The upstream workspace (<span class="font-mono">{state.upstreamWorkspaceId}</span>) this fork
|
||||
descends from has the same {state.kindLabel} configured at this path. Because this fork's row was
|
||||
cloned from it, the upstream identifier is shared.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
{#if family === 'split'}
|
||||
If both are enabled, the two listeners will compete on the same upstream and each side will
|
||||
receive only a fraction of its events.
|
||||
{:else if family === 'duplicate'}
|
||||
If both are enabled, every event will fire the script twice — once in the fork and once in
|
||||
the parent.
|
||||
If both are enabled, every event will fire the script twice: once in the fork and once in
|
||||
the upstream workspace.
|
||||
{:else if family === 'slot'}
|
||||
The cloned <span class="font-mono">replication_slot_name</span> points at the same Postgres slot,
|
||||
which only allows one consumer at a time. Enabling here will either fail with "slot already active"
|
||||
if the parent is enabled, or hijack the slot's WAL position if it isn't — causing the parent
|
||||
to lose events when re-enabled.
|
||||
if the upstream workspace is enabled, or hijack the slot's WAL position if it isn't, causing
|
||||
it to lose events when re-enabled.
|
||||
{:else}
|
||||
Enabling it here may compete for the same upstream events or duplicate side effects.
|
||||
{/if}
|
||||
|
||||
@@ -134,11 +134,15 @@
|
||||
}
|
||||
applyFormDefaults(nvalues)
|
||||
|
||||
// Apply select/select_python defaults so initialValues matches what InstanceSetting's $effect does
|
||||
// Apply declared defaults before snapshotting initialValues, so a default shows without
|
||||
// marking the form dirty: a select's mirrors InstanceSetting's $effect, a boolean's is
|
||||
// what its toggle shows while the key is unset.
|
||||
for (const category of settingsKeys) {
|
||||
for (const s of settings[category]) {
|
||||
if (
|
||||
(s.fieldType === 'select' || s.fieldType === 'select_python') &&
|
||||
(s.fieldType === 'select' ||
|
||||
s.fieldType === 'select_python' ||
|
||||
s.fieldType === 'boolean') &&
|
||||
nvalues[s.key] == undefined &&
|
||||
s.defaultValue
|
||||
) {
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
popup?: boolean
|
||||
firstTime?: boolean
|
||||
autoRedirect?: boolean
|
||||
/** Supplying this replaces the post-login redirect: the card hands back instead of
|
||||
* navigating, and the host is expected to re-check access in place. For a gate on a
|
||||
* page that stays mounted, `rd` is the URL already shown, so navigating there would
|
||||
* re-run nothing. */
|
||||
onLoginSuccess?: () => void
|
||||
/** A refusal the popup relayed back, in the server's words. */
|
||||
onLoginError?: (message: string) => void
|
||||
@@ -290,6 +294,11 @@
|
||||
|
||||
// Finally, we check whether the user is a superadmin
|
||||
refreshSuperadmin()
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess()
|
||||
return
|
||||
}
|
||||
redirectUser()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert, Button } from './common'
|
||||
import { overlayStack, type OverlayStack } from './common/overlayHost.svelte'
|
||||
import { random_adj } from './random_positive_adjetive'
|
||||
import { ChevronDown, Copy, SearchCode } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
@@ -405,9 +406,11 @@
|
||||
}
|
||||
})
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
const openSearchWithPrefilledText: (t?: string, stack?: OverlayStack) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
// Handed to the search so it stacks above the modal or drawer this field sits in.
|
||||
const searchStack = overlayStack()
|
||||
|
||||
$effect.pre(() => {
|
||||
;[meta?.name, meta?.owner, meta?.ownerKind]
|
||||
@@ -451,14 +454,18 @@
|
||||
initialPath !== path
|
||||
)
|
||||
let pathUsageInFlowsPromise = $derived(
|
||||
(kind == 'script' || kind == 'flow') &&
|
||||
ws &&
|
||||
initialPath &&
|
||||
FlowService.listFlowPathsFromWorkspaceRunnable({
|
||||
workspace: ws,
|
||||
path: initialPath,
|
||||
runnableKind: kind
|
||||
})
|
||||
ws && initialPath
|
||||
? kind == 'script' || kind == 'flow'
|
||||
? FlowService.listFlowPathsFromWorkspaceRunnable({
|
||||
workspace: ws,
|
||||
path: initialPath,
|
||||
runnableKind: kind
|
||||
})
|
||||
: kind == 'resource'
|
||||
? // Only steps linked to a saved agent are tracked; other `$res:` references are not.
|
||||
FlowService.listFlowPathsLinkingAgent({ workspace: ws, path: initialPath })
|
||||
: undefined
|
||||
: undefined
|
||||
)
|
||||
let pathUsageInAppsPromise = $derived(
|
||||
(kind == 'script' || kind == 'flow') &&
|
||||
@@ -647,24 +654,36 @@
|
||||
</ul>
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else if displayPathChangedWarning && kind == 'resource'}
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
{:catch}
|
||||
<!-- A resource's references beyond linked agents are never looked up, so a failed lookup
|
||||
still leaves it with the generic warning. -->
|
||||
{#if displayPathChangedWarning && kind == 'resource'}
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
{/await}
|
||||
{:else if displayPathChangedWarning}
|
||||
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
|
||||
You are renaming an item that may be depended upon by other items. This may break apps, flows
|
||||
or resources. Find if it used elsewhere using the content search. Note that linked variables
|
||||
and resources (having the same path) are automatically moved together.
|
||||
<div class="flex pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
openSearchWithPrefilledText('#')
|
||||
}}
|
||||
startIcon={{ icon: SearchCode }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet renameMayBreakWarning()}
|
||||
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
|
||||
You are renaming an item that may be depended upon by other items. This may break apps, flows or
|
||||
resources. Find if it used elsewhere using the content search. Note that linked variables and
|
||||
resources (having the same path) are automatically moved together.
|
||||
<div class="flex pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
openSearchWithPrefilledText('#', searchStack)
|
||||
}}
|
||||
startIcon={{ icon: SearchCode }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/snippet}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="1200px" {disableChatOffset} on:close={handleDrawerClose}>
|
||||
<Drawer bind:this={drawer} size="1300px" {disableChatOffset} on:close={handleDrawerClose}>
|
||||
<DrawerContent noPadding overflow_y={false} title="Instance settings" on:close={handleClose}>
|
||||
{#snippet titleExtra()}
|
||||
<MeltTooltip disablePopup={!uptodateVersion}>
|
||||
|
||||
@@ -26,11 +26,10 @@
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Settings,
|
||||
UserMinus,
|
||||
UserPlus
|
||||
} from 'lucide-svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
@@ -155,6 +154,11 @@
|
||||
loadExtJwtPage(1)
|
||||
|
||||
let tab: string = $state('users')
|
||||
let usersListShown = $derived(
|
||||
tab === 'users' &&
|
||||
!yamlMode &&
|
||||
(usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0))
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
tab = $instanceSettingsSelectedTab
|
||||
@@ -320,11 +324,14 @@
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="h-full overflow-auto bg-surface">
|
||||
<div class="h-fit px-8 py-4">
|
||||
<!-- The users list scrolls inside a bounded table, so its tab fills the pane instead of
|
||||
growing with it: that is what lets the header pin and keeps the horizontal
|
||||
scrollbar in view rather than under hundreds of rows. -->
|
||||
<div class={usersListShown ? 'h-full flex flex-col px-8 py-4' : 'h-fit px-8 py-4'}>
|
||||
{#if tab === 'ai' && !yamlMode}
|
||||
<InstanceAISettings {disableChatOffset} />
|
||||
{:else if tab === 'users' && !yamlMode}
|
||||
<div class="h-full">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{#if !automateUsernameCreation && !isCloudHosted()}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2"> Automatic username creation </h3>
|
||||
@@ -373,7 +380,7 @@
|
||||
<Tab value="guests" label="Guests" />
|
||||
</Tabs>
|
||||
|
||||
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
|
||||
{#if usersListShown}
|
||||
<SettingsPageHeader
|
||||
title="Instance users ({users.length})"
|
||||
description="Manage all users across your Windmill instance."
|
||||
@@ -414,14 +421,10 @@
|
||||
<p class="text-hint text-2xs mt-2">
|
||||
{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<DataTable
|
||||
shouldLoadMore={(filteredUsers?.length ?? 0) > 50}
|
||||
loadMore={50}
|
||||
on:loadMore={() => {
|
||||
nbDisplayed += 50
|
||||
}}
|
||||
>
|
||||
<!-- Shrinks but never grows: a short list keeps its box hugging the rows, a long one
|
||||
is capped by the pane and scrolls inside, with a floor of a few rows. -->
|
||||
<div class="mt-1 min-h-48">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Email</Cell>
|
||||
@@ -434,7 +437,7 @@
|
||||
<Cell head>Kind</Cell>
|
||||
{/if}
|
||||
<Cell head>Role</Cell>
|
||||
<Cell head last>
|
||||
<Cell head last actions>
|
||||
<span class="sr-only">Actions</span>
|
||||
</Cell>
|
||||
</tr>
|
||||
@@ -443,12 +446,22 @@
|
||||
{#if filteredUsers && users}
|
||||
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
|
||||
{@const isServiceAccount = login_type === 'service_account'}
|
||||
{@const groupRole =
|
||||
role_source === 'instance_group' && (super_admin || devops)}
|
||||
<!-- Any elevated role picked here is stored as manual and wins over the group on later
|
||||
syncs; only a demotion to User is re-applied from the group. So only User locks. -->
|
||||
{@const groupRoleTooltip =
|
||||
'Role is set by an instance group. Superadmin and Devops can be set here, but demoting to User requires removing the user from the group.'}
|
||||
{@const serviceAccountTooltip =
|
||||
'Service accounts are always users in the instance. Their workspace role is managed in the workspace user settings.'}
|
||||
<!-- Dimmed per cell content, not on the row: opacity on the row would make the pinned
|
||||
actions cell translucent and let the columns scrolling under it show through. -->
|
||||
<tr
|
||||
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
|
||||
? 'opacity-60'
|
||||
? '[&>td>*]:opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<Cell first class="max-w-[250px]">
|
||||
<Cell first class="max-w-[240px]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if isServiceAccount}
|
||||
<Bot size={16} class="text-blue-500 shrink-0" />
|
||||
@@ -458,14 +471,6 @@
|
||||
>{email}</a
|
||||
>
|
||||
{/if}
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/?workspace={workspace_id}"
|
||||
title="Workspace: {workspace_id}"
|
||||
>
|
||||
<Badge color="blue">{truncate(workspace_id, 20)}</Badge>
|
||||
</a>
|
||||
{/if}
|
||||
{#if disabled}
|
||||
<span
|
||||
class="text-2xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 whitespace-nowrap"
|
||||
@@ -475,7 +480,7 @@
|
||||
</div>
|
||||
</Cell>
|
||||
{#if automateUsernameCreation}
|
||||
<Cell class="max-w-[150px]">
|
||||
<Cell class="max-w-[140px]">
|
||||
{#if username}
|
||||
<span title={username} class="truncate block">{username}</span>
|
||||
{:else}
|
||||
@@ -503,133 +508,157 @@
|
||||
>
|
||||
{#if activeOnly}
|
||||
<Cell>
|
||||
{#if is_workspace_admin}
|
||||
Admin
|
||||
{:else if operator_only}
|
||||
Operator only
|
||||
{:else}
|
||||
Developer
|
||||
{/if}
|
||||
<span>
|
||||
{#if is_workspace_admin}
|
||||
Admin
|
||||
{:else if operator_only}
|
||||
Operator only
|
||||
{:else}
|
||||
Developer
|
||||
{/if}
|
||||
</span>
|
||||
</Cell>
|
||||
{/if}
|
||||
<Cell>
|
||||
{#if isServiceAccount}
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- A service account has no `password` row, so it can never hold an
|
||||
instance role: the group renders locked on "User" rather than hidden. -->
|
||||
<div class="flex flex-col items-start">
|
||||
{#key `${super_admin}_${devops}_${role_source}`}
|
||||
<ToggleButtonGroup
|
||||
disabled={isServiceAccount}
|
||||
selected={super_admin
|
||||
? 'super_admin'
|
||||
: devops
|
||||
? 'devops'
|
||||
: 'user'}
|
||||
on:selected={async (e) => {
|
||||
if (email == $userStore?.email) {
|
||||
sendUserToast('You cannot demote yourself', true)
|
||||
listUsers(activeOnly)
|
||||
return
|
||||
}
|
||||
|
||||
let role = e.detail
|
||||
|
||||
if (role === 'super_admin') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: true,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'devops') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'user') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUserToast('User updated')
|
||||
listUsers(activeOnly)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value={'user'}
|
||||
small
|
||||
label="User"
|
||||
shortLabel="User"
|
||||
disabled={isServiceAccount || groupRole}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: groupRole
|
||||
? groupRoleTooltip
|
||||
: undefined}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'devops'}
|
||||
small
|
||||
label="Devops"
|
||||
shortLabel="Dev"
|
||||
disabled={isServiceAccount}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: "Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'super_admin'}
|
||||
small
|
||||
label="Superadmin"
|
||||
shortLabel="Admin"
|
||||
disabled={isServiceAccount}
|
||||
tooltip={isServiceAccount
|
||||
? serviceAccountTooltip
|
||||
: undefined}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/key}
|
||||
{#if isServiceAccount}
|
||||
<span
|
||||
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 whitespace-nowrap"
|
||||
title={serviceAccountTooltip}
|
||||
>
|
||||
{is_workspace_admin
|
||||
? 'Admin'
|
||||
: operator_only
|
||||
? 'Operator'
|
||||
: 'Developer'}
|
||||
in
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
|
||||
class="hover:underline"
|
||||
title={workspace_id}
|
||||
onclick={() => closeDrawer?.()}
|
||||
>{truncate(workspace_id, 20)}</a
|
||||
>
|
||||
{:else}
|
||||
its workspace
|
||||
{/if}
|
||||
</span>
|
||||
<Tooltip>
|
||||
Service-account role is managed in the workspace user settings.
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-start">
|
||||
{#key `${super_admin}_${devops}_${role_source}`}
|
||||
<ToggleButtonGroup
|
||||
selected={super_admin
|
||||
? 'super_admin'
|
||||
: devops
|
||||
? 'devops'
|
||||
: 'user'}
|
||||
on:selected={async (e) => {
|
||||
if (email == $userStore?.email) {
|
||||
sendUserToast('You cannot demote yourself', true)
|
||||
listUsers(activeOnly)
|
||||
return
|
||||
}
|
||||
|
||||
let role = e.detail
|
||||
|
||||
if (role === 'super_admin') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: true,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'devops') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (role === 'user') {
|
||||
await UserService.globalUserUpdate({
|
||||
email,
|
||||
requestBody: {
|
||||
is_super_admin: false,
|
||||
is_devops: false
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUserToast('User updated')
|
||||
listUsers(activeOnly)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value={'user'}
|
||||
small
|
||||
label="User"
|
||||
disabled={role_source === 'instance_group' &&
|
||||
(super_admin || devops)}
|
||||
tooltip={role_source === 'instance_group' &&
|
||||
(super_admin || devops)
|
||||
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
|
||||
: undefined}
|
||||
showTooltipIcon={role_source === 'instance_group' &&
|
||||
(super_admin || devops)}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'devops'}
|
||||
small
|
||||
label="Devops"
|
||||
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'super_admin'}
|
||||
small
|
||||
label="Superadmin"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/key}
|
||||
{#if role_source === 'instance_group' && (super_admin || devops)}
|
||||
<a
|
||||
href="{base}/groups"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
|
||||
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to "User" requires removing them from the group."
|
||||
onclick={() => closeDrawer?.()}
|
||||
>
|
||||
Set by instance group
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if groupRole}
|
||||
<a
|
||||
href="{base}/groups"
|
||||
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
|
||||
title={groupRoleTooltip}
|
||||
onclick={() => closeDrawer?.()}
|
||||
>
|
||||
Set by instance group
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<Cell last actions class={i % 2 === 0 ? 'bg-surface-tertiary' : ''}>
|
||||
<div class="flex items-center justify-end">
|
||||
{#if isServiceAccount}
|
||||
{#if workspace_id}
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
|
||||
class="text-xs text-secondary hover:text-primary hover:underline"
|
||||
title="Manage in workspace settings">Manage in workspace</a
|
||||
>
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: 'Manage in workspace',
|
||||
icon: Settings,
|
||||
action: () => closeDrawer?.(),
|
||||
href: `${base}/workspace_settings?tab=users&workspace=${workspace_id}`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
@@ -722,6 +751,29 @@
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filteredUsers.length > nbDisplayed}
|
||||
{@const remaining = Math.min(50, filteredUsers.length - nbDisplayed)}
|
||||
<!-- Last row rather than a footer under the scroller, the way the runs
|
||||
list pages: the control scrolls with the rows it extends. -->
|
||||
<tr>
|
||||
<Cell
|
||||
colspan={5 +
|
||||
(automateUsernameCreation ? 1 : 0) +
|
||||
(activeOnly ? 1 : 0)}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
wrapperClasses="w-full justify-center"
|
||||
onClick={() => {
|
||||
nbDisplayed += 50
|
||||
}}
|
||||
>
|
||||
Load next {remaining} user{remaining !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</Cell>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
@@ -174,7 +174,7 @@ export async function main(bucket: any, api_token: string) {
|
||||
async function mintApiToken(): Promise<string> {
|
||||
return await UserService.createToken({
|
||||
requestBody: {
|
||||
label: `test connection: ${resourceType}`,
|
||||
label: `ephemeral-test-connection: ${resourceType}`,
|
||||
expiration: new Date(Date.now() + API_TOKEN_TTL_MS).toISOString(),
|
||||
scopes: ['settings:write']
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
type MenuItem = {
|
||||
label: string
|
||||
description?: string
|
||||
onClick?: (e?: Event) => void
|
||||
href?: string
|
||||
icon?: any
|
||||
@@ -146,6 +147,7 @@
|
||||
const items = typeof menuItems === 'function' ? menuItems() : menuItems
|
||||
return items.map((item) => ({
|
||||
displayName: item.label,
|
||||
description: item.description,
|
||||
action: item.onClick ? (e) => item.onClick?.(e) : undefined,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled ?? false,
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
HistoryIcon
|
||||
} from 'lucide-svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
@@ -48,6 +49,9 @@
|
||||
draft_path?: string
|
||||
draft_users?: { username?: string | null }[]
|
||||
canWrite: boolean
|
||||
/** Projected from the flow value by the listing; a chat-input flow opens
|
||||
* as a conversation and is badged as such. */
|
||||
chat_input_enabled?: boolean
|
||||
}
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
@@ -124,6 +128,10 @@
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} />
|
||||
{/if}
|
||||
|
||||
{#snippet chatBadge()}
|
||||
<ChatFlowBadge />
|
||||
{/snippet}
|
||||
|
||||
<Row
|
||||
aiId={`flow-row-${flow.path}`}
|
||||
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
|
||||
@@ -140,6 +148,7 @@
|
||||
canFavorite={!flow.draft_only}
|
||||
{depth}
|
||||
{rowSelection}
|
||||
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
|
||||
>
|
||||
{#snippet badges()}
|
||||
{#if flow.archived}
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
badges?: import('svelte').Snippet
|
||||
actions?: import('svelte').Snippet
|
||||
customSummary?: import('svelte').Snippet
|
||||
/** Rendered inline right after the title, unlike `badges`, which sit in
|
||||
* their own column and are hidden below `lg`. */
|
||||
titleBadge?: import('svelte').Snippet
|
||||
/** Overrides the secondary path line (e.g. to strike a renamed path).
|
||||
* Falls back to the plain `path` string when not provided. */
|
||||
pathDisplay?: import('svelte').Snippet
|
||||
@@ -101,6 +104,7 @@
|
||||
badges,
|
||||
actions,
|
||||
customSummary,
|
||||
titleBadge,
|
||||
pathDisplay,
|
||||
onSelect = () => {}
|
||||
}: Props = $props()
|
||||
@@ -275,7 +279,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grow min-w-0">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
||||
<div
|
||||
class={twMerge(
|
||||
'text-emphasis flex-wrap text-left text-xs font-semibold',
|
||||
titleBadge ? 'inline-flex items-center gap-2' : ''
|
||||
)}
|
||||
>
|
||||
{#if customSummary}
|
||||
{@render customSummary?.()}
|
||||
{:else if marked}
|
||||
@@ -283,6 +292,7 @@
|
||||
{:else}
|
||||
{!summary || summary.length == 0 ? displayPath : summary}
|
||||
{/if}
|
||||
{@render titleBadge?.()}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal" title={path}>
|
||||
{#if pathDisplay}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
interface Props {
|
||||
label?: string | undefined
|
||||
/** Shown instead of `label` below the `xl` breakpoint, for groups that must keep
|
||||
* their width inside a narrow table cell. The full label stays the accessible name. */
|
||||
shortLabel?: string | undefined
|
||||
iconOnly?: boolean
|
||||
tooltip?: string | undefined
|
||||
icon?: any | undefined
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
let {
|
||||
label = undefined,
|
||||
shortLabel = undefined,
|
||||
iconOnly = false,
|
||||
tooltip = undefined,
|
||||
icon = undefined,
|
||||
@@ -68,6 +72,7 @@
|
||||
<button
|
||||
{id}
|
||||
{disabled}
|
||||
aria-label={shortLabel ? label : undefined}
|
||||
class={twMerge(
|
||||
'group rounded-md transition-all font-normal flex gap-1 flex-row items-center justify-center border text-xs',
|
||||
horizontalPadding,
|
||||
@@ -75,7 +80,7 @@
|
||||
'text-primary data-[state=on]:text-primary',
|
||||
'data-[state=on]:bg-surface-tertiary data-[state=off]:border-transparent data-[state=on]:border-border-normal/30',
|
||||
'bg-surface-transparent hover:bg-surface-hover',
|
||||
disabled ? '!shadow-none' : '',
|
||||
disabled ? '!shadow-none !text-disabled' : '',
|
||||
className
|
||||
)}
|
||||
use:melt={$item(value)}
|
||||
@@ -102,7 +107,12 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if label && !iconOnly}
|
||||
{label}
|
||||
{#if shortLabel}
|
||||
<span class="hidden xl:inline">{label}</span>
|
||||
<span class="xl:hidden" aria-hidden="true">{shortLabel}</span>
|
||||
{:else}
|
||||
{label}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showTooltipIcon}
|
||||
<Info size={iconSize} class="text-gray-400" />
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
@@ -68,14 +69,19 @@
|
||||
import { base } from '$lib/base'
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const chatHost = getChatViewHost()
|
||||
// The skill and MCP menus take an AIChatManager itself, which the seam deliberately
|
||||
// doesn't carry. They render only under GLOBAL, which a non-copilot host never sets.
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// The free grant pays for the copilot's own model, so its banners belong only to a host
|
||||
// that sends to that model. A flow chat's turn runs on the flow's provider.
|
||||
const freeTier = $derived(chatHost.supportsModelSettings ? $copilotInfo.freeTier : undefined)
|
||||
// The user spent their one-time free Windmill AI grant: there is no model left to send
|
||||
// to, so say so in the thread itself rather than only failing on send.
|
||||
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
let freeTierExhausted = $derived(freeTier?.exhausted === true)
|
||||
// Still on the free grant: keep how much is left in view right above the composer, so
|
||||
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
|
||||
let freeTier = $derived($copilotInfo.freeTier)
|
||||
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
|
||||
|
||||
@@ -174,8 +180,12 @@
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
inputPreface,
|
||||
footerSettings,
|
||||
initialInstructions = undefined,
|
||||
onDraftChange = undefined
|
||||
onDraftChange = undefined,
|
||||
placeholder = undefined,
|
||||
scrollElement = $bindable(),
|
||||
onTranscriptScroll = undefined
|
||||
}: {
|
||||
messages: DisplayMessage[]
|
||||
pastChats: { id: string; title: string }[]
|
||||
@@ -202,9 +212,18 @@
|
||||
wideLayout?: boolean
|
||||
emptyHint?: Snippet
|
||||
inputPreface?: Snippet
|
||||
/** The settings control at the footer's right edge, where the copilot puts its
|
||||
* model picker. A host that configures its turn elsewhere replaces it here. */
|
||||
footerSettings?: Snippet
|
||||
// Seed / observe the main composer's draft text (see AIChatInput).
|
||||
initialInstructions?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
/** Composer placeholder. Falls back to the per-AI-mode wording. */
|
||||
placeholder?: string
|
||||
/** The transcript's scroll container. A host that paginates older messages
|
||||
* needs it to measure and restore the scroll position. */
|
||||
scrollElement?: HTMLDivElement | undefined
|
||||
onTranscriptScroll?: () => void
|
||||
} = $props()
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
@@ -223,7 +242,7 @@
|
||||
let panelEl: HTMLDivElement | undefined = $state()
|
||||
$effect(() => {
|
||||
function onWindowKeydownCapture(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !aiChatManager.loading) return
|
||||
if (e.key !== 'Escape' || !chatHost.loading) return
|
||||
const active = document.activeElement
|
||||
const focusOnChat =
|
||||
!active || active === document.body || (panelEl?.contains(active) ?? false)
|
||||
@@ -231,22 +250,21 @@
|
||||
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
|
||||
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
|
||||
// row must not answer for the other.
|
||||
if (aiChatManager.hasPendingRunForm) {
|
||||
if (chatHost.hasPendingRunForm) {
|
||||
const row = active?.closest('[data-run-form-actions]')
|
||||
const toolCallId = row?.getAttribute('data-run-form-actions')
|
||||
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
|
||||
if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return
|
||||
} else if (!focusOnChat) return
|
||||
e.preventDefault()
|
||||
// Immediate form: other chat panels' identical listeners must not
|
||||
// also cancel on body focus, nor a drawer/modal close on this press.
|
||||
e.stopImmediatePropagation()
|
||||
aiChatManager.cancel()
|
||||
chatHost.cancel()
|
||||
}
|
||||
window.addEventListener('keydown', onWindowKeydownCapture, true)
|
||||
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
|
||||
})
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
|
||||
// event; if a token-append between the scrollTo and the dispatch makes
|
||||
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
|
||||
@@ -259,22 +277,23 @@
|
||||
// Instant scroll — smooth would animate every token append, racing with
|
||||
// the next scrollDown and confusing the onscroll bottom-detection below.
|
||||
function scrollDown() {
|
||||
if (!scrollEl) return
|
||||
if (!scrollElement) return
|
||||
programmaticScrollAt = Date.now()
|
||||
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
|
||||
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
|
||||
}
|
||||
|
||||
let height = $state(0)
|
||||
$effect(() => {
|
||||
if (aiChatManager.automaticScroll && height) {
|
||||
if (chatHost.automaticScroll && height) {
|
||||
scrollDown()
|
||||
}
|
||||
// Recompute the scroll-to-latest visibility on every content-height
|
||||
// change. `onScroll` only fires for actual scroll events, so without
|
||||
// this the arrow can go stale when content grows past the threshold
|
||||
// while auto-scroll is disabled (user scrolled up mid-stream).
|
||||
if (scrollEl && height) {
|
||||
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
|
||||
if (scrollElement && height) {
|
||||
const distance =
|
||||
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
|
||||
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
|
||||
}
|
||||
})
|
||||
@@ -289,8 +308,9 @@
|
||||
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
|
||||
let showScrollToLatest = $state(false)
|
||||
function onScroll() {
|
||||
if (!scrollEl) return
|
||||
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
|
||||
if (!scrollElement) return
|
||||
const distance =
|
||||
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
|
||||
// Always refresh the arrow visibility — even during the cooldown,
|
||||
// because clicking the arrow itself triggers a programmatic scroll
|
||||
// whose only event would otherwise be swallowed, leaving the arrow
|
||||
@@ -303,14 +323,15 @@
|
||||
return
|
||||
}
|
||||
if (distance <= STICK_TO_BOTTOM_PX) {
|
||||
aiChatManager.enableAutomaticScroll()
|
||||
chatHost.enableAutomaticScroll()
|
||||
} else {
|
||||
aiChatManager.disableAutomaticScroll()
|
||||
chatHost.disableAutomaticScroll()
|
||||
}
|
||||
onTranscriptScroll?.()
|
||||
}
|
||||
|
||||
function submitSuggestion(suggestion: string) {
|
||||
aiChatManager.sendRequest({ instructions: suggestion })
|
||||
chatHost.sendRequest({ instructions: suggestion })
|
||||
}
|
||||
|
||||
export function focusInput() {
|
||||
@@ -319,35 +340,31 @@
|
||||
|
||||
$effect(() => {
|
||||
if (aiChatInput) {
|
||||
aiChatManager.setAiChatInput(aiChatInput)
|
||||
chatHost.setAiChatInput(aiChatInput)
|
||||
}
|
||||
|
||||
return () => {
|
||||
aiChatManager.setAiChatInput(null)
|
||||
chatHost.setAiChatInput(null)
|
||||
}
|
||||
})
|
||||
|
||||
// Also shown for a run held by another tab, labeled with where it is: the
|
||||
// dots say a turn is in flight even before the reader reaches the footer
|
||||
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
|
||||
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
|
||||
const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere)
|
||||
|
||||
// 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.APP
|
||||
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
|
||||
)
|
||||
|
||||
// File attachment is GLOBAL-mode only.
|
||||
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
|
||||
// Steers the OS file picker toward text + image formats (soft hint; both attach
|
||||
// to the message — text files after a content sniff).
|
||||
const TEXT_FILE_ACCEPT =
|
||||
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
||||
const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled)
|
||||
// Folders are linked as session-wide assets, which only a host that reads files in
|
||||
// the browser can do — a host running the turn server-side takes attachments only.
|
||||
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
|
||||
let fileInputEl = $state<HTMLInputElement | null>(null)
|
||||
let folderInputEl = $state<HTMLInputElement | null>(null)
|
||||
let dragDepth = $state(0)
|
||||
@@ -373,12 +390,12 @@
|
||||
}
|
||||
|
||||
async function handleAddFiles(files: FileList | FileToAttach[]) {
|
||||
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
|
||||
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
|
||||
reportAddResult(added, rejected)
|
||||
}
|
||||
|
||||
async function addDirHandle(dir: FileSystemDirectoryHandle) {
|
||||
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
|
||||
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
|
||||
reportAddResult(added, rejected)
|
||||
}
|
||||
|
||||
@@ -471,8 +488,13 @@
|
||||
const textFiles = looseFiles.filter((f) => !isImageFile(f))
|
||||
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
|
||||
// Folders link as a live handle.
|
||||
for (const h of handles.filter(isDirectoryHandle)) {
|
||||
await addDirHandle(h)
|
||||
const dirs = handles.filter(isDirectoryHandle)
|
||||
if (dirs.length > 0 && !canLinkFolders) {
|
||||
sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
|
||||
} else {
|
||||
for (const h of dirs) {
|
||||
await addDirHandle(h)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback (no File System Access API): snapshot dropped files AND folders by walking
|
||||
@@ -497,7 +519,10 @@
|
||||
topLevelText.push(file)
|
||||
}
|
||||
}
|
||||
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
|
||||
if (folderEntries.length > 0) {
|
||||
if (canLinkFolders) await handleAddFiles(folderEntries)
|
||||
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
|
||||
}
|
||||
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
|
||||
}
|
||||
}
|
||||
@@ -524,9 +549,9 @@
|
||||
input.value = ''
|
||||
}
|
||||
const autonomyAvailability = $derived({
|
||||
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
|
||||
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
|
||||
planModeAvailable: aiChatManager.planModeAvailable
|
||||
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
|
||||
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
|
||||
planModeAvailable: chatHost.planModeAvailable
|
||||
})
|
||||
const availableAutonomyModeOptions = $derived(
|
||||
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
|
||||
@@ -534,8 +559,8 @@
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
const effectiveAutonomyMode = $derived(
|
||||
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
|
||||
? chatHost.autonomyMode
|
||||
: AIAutonomyMode.DEFAULT
|
||||
)
|
||||
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
|
||||
@@ -544,13 +569,13 @@
|
||||
// The typing-dots indicator implies the AI is busy, which is misleading while
|
||||
// the loop is parked on the user; surface a text pill instead so users know to
|
||||
// act on the tool above.
|
||||
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
|
||||
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
|
||||
|
||||
// Gated on `loading` because a card restored from history still looks parked:
|
||||
// its resolver left with the old page, so the composer must not advertise an
|
||||
// answer it cannot deliver.
|
||||
const pendingQuestionToolCallId = $derived.by(() => {
|
||||
if (!aiChatManager.loading) {
|
||||
if (!chatHost.loading) {
|
||||
return undefined
|
||||
}
|
||||
const pending = pendingUserActionDetail(messages)
|
||||
@@ -559,14 +584,14 @@
|
||||
|
||||
// Get app context for display when in APP mode
|
||||
const appContext = $derived.by((): SelectedContext | undefined => {
|
||||
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
|
||||
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
|
||||
return undefined
|
||||
}
|
||||
return aiChatManager.appAiChatHelpers.getSelectedContext()
|
||||
return chatHost.appAiChatHelpers.getSelectedContext()
|
||||
})
|
||||
|
||||
const yoloBypassedTools = $derived.by(() => {
|
||||
return aiChatManager.tools
|
||||
return chatHost.tools
|
||||
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
|
||||
.map((tool) => ({
|
||||
name: tool.def.function.name,
|
||||
@@ -583,8 +608,7 @@
|
||||
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
|
||||
)
|
||||
const showFlowPendingActionControls = $derived(
|
||||
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
|
||||
!aiChatManager.autoAcceptEditsActive
|
||||
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
|
||||
)
|
||||
// A disabled state with no message (a remote hold, a spent free grant) keeps
|
||||
// the footer toolbar in place — swapping it for an empty strip would make
|
||||
@@ -592,11 +616,15 @@
|
||||
// a real message (archived, AI off) still shows it, hold or not, matching
|
||||
// the precedence disabledMessage itself encodes.
|
||||
const footerMessageShown = $derived(disabled && disabledMessage !== '')
|
||||
// `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the
|
||||
// context picker or the autonomy selector beside it, but a host with attachments and
|
||||
// nothing else would lose the group and the `+` with it.
|
||||
const showFooterLeftControls = $derived(
|
||||
!footerMessageShown &&
|
||||
(showContextPicker ||
|
||||
(canAttachFiles ||
|
||||
showContextPicker ||
|
||||
showAutonomyModeSelector ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
|
||||
(chatHost.mode === AIMode.SCRIPT && hasDiff))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -694,12 +722,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#each pastChats as chat (chat.id)}
|
||||
<button
|
||||
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
|
||||
disabled={aiChatManager.loading ||
|
||||
aiChatManager.sendInFlight ||
|
||||
aiChatManager.runHeldElsewhere}
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
disabled={chatHost.loading ||
|
||||
chatHost.sendInFlight ||
|
||||
chatHost.runHeldElsewhere}
|
||||
title={chatHost.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to switch conversation'
|
||||
: aiChatManager.loading || aiChatManager.sendInFlight
|
||||
: chatHost.loading || chatHost.sendInFlight
|
||||
? 'Stop the current answer to switch conversation'
|
||||
: undefined}
|
||||
onclick={() => {
|
||||
@@ -731,10 +759,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
title={chatHost.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to start a new chat'
|
||||
: 'New chat'}
|
||||
disabled={aiChatManager.runHeldElsewhere}
|
||||
disabled={chatHost.runHeldElsewhere}
|
||||
on:click={() => {
|
||||
saveAndClear()
|
||||
}}
|
||||
@@ -769,7 +797,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div
|
||||
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
|
||||
bind:this={scrollEl}
|
||||
bind:this={scrollElement}
|
||||
onscroll={onScroll}
|
||||
>
|
||||
<div
|
||||
@@ -800,16 +828,16 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<ChatTypingIndicator
|
||||
loading={showTypingIndicator}
|
||||
paused={waitingForUserAction}
|
||||
label={aiChatManager.runHeldElsewhere
|
||||
label={chatHost.runHeldElsewhere
|
||||
? 'Running in another tab'
|
||||
: aiChatManager.loadingLabel
|
||||
? aiChatManager.loadingLabel
|
||||
: aiChatManager.compacting
|
||||
: chatHost.loadingLabel
|
||||
? chatHost.loadingLabel
|
||||
: chatHost.compacting
|
||||
? 'Compacting conversation'
|
||||
: aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: chatHost.currentReasoningActive &&
|
||||
!chatHost.currentReply &&
|
||||
!chatHost.currentReasoning
|
||||
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -832,7 +860,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
aria-label="Scroll to latest message"
|
||||
startIcon={{ icon: ArrowDown }}
|
||||
on:click={() => {
|
||||
aiChatManager.enableAutomaticScroll()
|
||||
chatHost.enableAutomaticScroll()
|
||||
scrollDown()
|
||||
}}
|
||||
/>
|
||||
@@ -854,7 +882,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
variant="default"
|
||||
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
|
||||
onclick={() => {
|
||||
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
|
||||
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
|
||||
}}
|
||||
>
|
||||
Accept all
|
||||
@@ -866,7 +894,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
variant="default"
|
||||
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
|
||||
onclick={() => {
|
||||
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
|
||||
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
|
||||
}}
|
||||
>
|
||||
Reject all
|
||||
@@ -876,7 +904,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
<div>
|
||||
<QueuedMessageChip />
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
|
||||
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
|
||||
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
|
||||
Jobs segment lives inside the session bar (SessionChangesBar). -->
|
||||
<div class="mb-1">
|
||||
@@ -898,9 +926,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
{availableContext}
|
||||
{placeholder}
|
||||
{initialInstructions}
|
||||
{onDraftChange}
|
||||
showContext={aiChatManager.mode !== AIMode.GLOBAL}
|
||||
showContext={chatHost.mode !== AIMode.GLOBAL}
|
||||
{disabled}
|
||||
{pendingQuestionToolCallId}
|
||||
isFirstMessage={messages.length === 0}
|
||||
@@ -925,7 +954,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
{#if chatHost.mode === AIMode.APP}
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
@@ -969,7 +998,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
// together: awaited inline they queue, and the whole menu —
|
||||
// attachments included — waits out two round trips.
|
||||
const closeMenu = () => (plusMenuOpen = false)
|
||||
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
|
||||
const inGlobal = chatHost.mode === AIMode.GLOBAL
|
||||
const [skillItems, mcpItems] = await Promise.all([
|
||||
inGlobal ? skillsMenu.items(closeMenu) : undefined,
|
||||
inGlobal ? mcpMenu.items(closeMenu) : undefined
|
||||
@@ -983,19 +1012,23 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
linkFiles()
|
||||
}
|
||||
},
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
}
|
||||
},
|
||||
...(canLinkFolders
|
||||
? [
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(skillItems
|
||||
? [
|
||||
{
|
||||
@@ -1054,7 +1087,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
multiple
|
||||
accept={TEXT_FILE_ACCEPT}
|
||||
accept={chatHost.attachmentAccept}
|
||||
class="hidden no-default-style"
|
||||
onchange={onFileInputChange}
|
||||
/>
|
||||
@@ -1075,7 +1108,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
availableAutonomyModeOptions.map((option) => ({
|
||||
displayName: option.label,
|
||||
selected: effectiveAutonomyMode === option.mode,
|
||||
action: () => aiChatManager.setAutonomyMode(option.mode)
|
||||
action: () => chatHost.setAutonomyMode(option.mode)
|
||||
}))}
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
@@ -1102,18 +1135,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
|
||||
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
|
||||
<Tooltip small placement="top">
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
{#snippet text()}
|
||||
<div class="max-w-64 text-xs">
|
||||
<p class="font-semibold">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
{chatHost.autoAcceptEditsAvailable
|
||||
? 'Bypass permissions auto-accepts edits and tool usage.'
|
||||
: 'Bypass permissions auto-accepts tool usage.'}
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
{chatHost.autoAcceptEditsAvailable
|
||||
? 'This can result in edits being applied or tools being called without user confirmation.'
|
||||
: 'This can result in tools being called without user confirmation.'}
|
||||
</p>
|
||||
@@ -1134,7 +1167,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
|
||||
{#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1145,25 +1178,27 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
{#if chatHost.mode === AIMode.GLOBAL}
|
||||
<AttachedFilesBar />
|
||||
{/if}
|
||||
{#if !hideModeSelector}
|
||||
<ChatMode />
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
{#if chatHost.mode === AIMode.APP}
|
||||
<DatatableCreationPolicy />
|
||||
{/if}
|
||||
<ContextUsageIndicator />
|
||||
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
|
||||
and `sessionRuntime` locks a session to GLOBAL, where the settings
|
||||
modal's Instructions section owns the prompt entries. -->
|
||||
<AIChatModelSettings promptSettings={false} />
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
{#if chatHost.supportsModelSettings}
|
||||
<!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's
|
||||
Instructions section owns the prompt entries. -->
|
||||
<AIChatModelSettings promptSettings={false} />
|
||||
{/if}
|
||||
{@render footerSettings?.()}
|
||||
{#if chatHost.mode === AIMode.GLOBAL}
|
||||
<AssistantSettingsModal bind:this={assistantSettings} />
|
||||
{/if}
|
||||
|
||||
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if appContext.inspectorElement}
|
||||
<div
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
|
||||
@@ -1210,7 +1245,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
<div class="px-2 mt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each suggestions as suggestion (suggestion)}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
} from './context'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
|
||||
import { formatMention } from './mention'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { tick, untrack, type Snippet } from 'svelte'
|
||||
@@ -45,7 +47,10 @@
|
||||
isImageViewerOpen
|
||||
} from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
// Resolved here, not where it is used: getContext is only legal during component
|
||||
// initialisation, and the mention consumer below runs inside the send gesture.
|
||||
const chatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
@@ -65,15 +70,15 @@
|
||||
showContext?: boolean
|
||||
bottomRightSnippet?: Snippet
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
// When provided, overrides `aiChatManager.loading` for the send/stop
|
||||
// When provided, overrides `chatHost.loading` for the send/stop
|
||||
// button — useful for callers driving their own request lifecycle
|
||||
// (e.g. the inline ⌘K widget runs requests outside the global
|
||||
// `aiChatManager.loading` flag).
|
||||
// `chatHost.loading` flag).
|
||||
loading?: boolean
|
||||
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
|
||||
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
|
||||
onCancel?: () => void
|
||||
// Observe the composer draft as it changes (the text is local state —
|
||||
// `aiChatManager.instructions` only carries programmatic prompts). Used by
|
||||
// `chatHost.instructions` only carries programmatic prompts). Used by
|
||||
// sessions to persist the typed-but-unsent prompt with the session draft.
|
||||
onDraftChange?: (text: string) => void
|
||||
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
|
||||
@@ -132,7 +137,7 @@
|
||||
// The composer unlocks by itself when the other tab's turn ends, so the
|
||||
// placeholder names what it is waiting on (the typing indicator says
|
||||
// where the run is).
|
||||
if (aiChatManager.runHeldElsewhere) {
|
||||
if (chatHost.runHeldElsewhere) {
|
||||
return 'Waiting for the turn in the other tab to finish'
|
||||
}
|
||||
if (pendingQuestionToolCallId !== undefined) {
|
||||
@@ -147,7 +152,7 @@
|
||||
return placeholder
|
||||
}
|
||||
|
||||
switch (aiChatManager.mode) {
|
||||
switch (chatHost.mode) {
|
||||
case AIMode.SCRIPT:
|
||||
return 'Modify this script...'
|
||||
case AIMode.FLOW:
|
||||
@@ -213,19 +218,24 @@
|
||||
// against a concurrent drop.
|
||||
let pendingImages = $state(0)
|
||||
|
||||
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
|
||||
/** Attach dropped/pasted image files (downscaled + bounded). */
|
||||
export async function addImages(files: (File | Blob)[]) {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
if (!chatHost.supportsMessageAttachments) return
|
||||
const imageFiles = files.filter(isImageFile)
|
||||
if (imageFiles.length === 0) return
|
||||
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
|
||||
// drop/paste handler that can't surface a rejection.
|
||||
const model = tryGetCurrentModel()
|
||||
// Only known text-only models fail this, so attaching would certainly 400 the
|
||||
// next turn — refuse rather than warn and send it anyway.
|
||||
if (model && !modelSupportsVision(model.provider, model.model)) {
|
||||
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
|
||||
return
|
||||
// The vision check is about the model this composer's own turn will hit, so it
|
||||
// only applies to a host that picks that model. Elsewhere the model is chosen
|
||||
// in the flow and tryGetCurrentModel would answer for the wrong one.
|
||||
if (chatHost.supportsModelSettings) {
|
||||
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
|
||||
// drop/paste handler that can't surface a rejection.
|
||||
const model = tryGetCurrentModel()
|
||||
// Only known text-only models fail this, so attaching would certainly 400 the
|
||||
// next turn — refuse rather than warn and send it anyway.
|
||||
if (model && !modelSupportsVision(model.provider, model.model)) {
|
||||
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Count decodes already in flight: two drops that both read the image count
|
||||
// before either resolves would each claim the same free slots and overshoot
|
||||
@@ -308,13 +318,13 @@
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
|
||||
)
|
||||
$effect(() => {
|
||||
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
|
||||
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
|
||||
})
|
||||
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
|
||||
$effect(() => () => chatHost.clearComposerStaged(composerKey))
|
||||
|
||||
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
|
||||
/** Attach dropped/picked text files (sniffed + bounded). */
|
||||
export async function addTextFiles(candidates: File[]) {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
if (!chatHost.supportsMessageAttachments) return
|
||||
if (candidates.length === 0) return
|
||||
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
|
||||
if (remaining <= 0) {
|
||||
@@ -346,7 +356,7 @@
|
||||
// stage stands in for it, so counting both would charge those bytes twice.
|
||||
let budget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
chatHost.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
pendingFileBytes
|
||||
const withinBudget: File[] = []
|
||||
@@ -388,7 +398,7 @@
|
||||
// from the budget — the decoded sizes replace it.
|
||||
const liveBudget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
chatHost.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
(pendingFileBytes - reservedBytes)
|
||||
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
|
||||
@@ -420,9 +430,9 @@
|
||||
// Modes that show the rich textarea with @-context support (workspace
|
||||
// scripts, workspace flows, code blocks, DBs, etc.).
|
||||
const isContextEnabledMode = $derived(
|
||||
aiChatManager.mode === AIMode.SCRIPT ||
|
||||
aiChatManager.mode === AIMode.FLOW ||
|
||||
aiChatManager.mode === AIMode.GLOBAL
|
||||
chatHost.mode === AIMode.SCRIPT ||
|
||||
chatHost.mode === AIMode.FLOW ||
|
||||
chatHost.mode === AIMode.GLOBAL
|
||||
)
|
||||
|
||||
const domSelectorChips = $derived(
|
||||
@@ -551,14 +561,14 @@
|
||||
* the composer. The conversation is left untouched — resending creates a new
|
||||
* message, unlike the bubble's edit pencil which rewinds the conversation. */
|
||||
function recallLastSentMessage(): boolean {
|
||||
const messages = aiChatManager.displayMessages
|
||||
const messages = chatHost.displayMessages
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (message.role !== 'user' || message.synthetic) continue
|
||||
// Images come from the stored turn, never the bubble: a provider
|
||||
// rejection strips them from history while the bubble keeps its copy,
|
||||
// and recalling that copy would re-attach the refused image.
|
||||
const images = aiChatManager.storedImages(i) ?? []
|
||||
const images = chatHost.storedImages(i) ?? []
|
||||
// Eligibility looks at the bubble, though: the last thing the user
|
||||
// actually sent is the recall boundary, so a context-only turn (GLOBAL
|
||||
// allows text-free sends with chips) recalls its chips, and a turn
|
||||
@@ -582,8 +592,7 @@
|
||||
// count against the conversation budget — re-admit them instead of
|
||||
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
|
||||
if (message.files?.length) {
|
||||
const budget =
|
||||
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
|
||||
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
|
||||
const { droppedAtBudget } = draft.addFiles(message.files, budget)
|
||||
if (droppedAtBudget > 0) {
|
||||
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
|
||||
@@ -654,10 +663,10 @@
|
||||
|
||||
if (
|
||||
contextElement.type === 'app_datatable' &&
|
||||
aiChatManager.mode === AIMode.APP &&
|
||||
aiChatManager.appAiChatHelpers
|
||||
chatHost.mode === AIMode.APP &&
|
||||
chatHost.appAiChatHelpers
|
||||
) {
|
||||
const appAiChatHelpers = aiChatManager.appAiChatHelpers
|
||||
const appAiChatHelpers = chatHost.appAiChatHelpers
|
||||
appAiChatHelpers.addTableToWhitelist(
|
||||
contextElement.datatableName,
|
||||
contextElement.schemaName,
|
||||
@@ -699,8 +708,10 @@
|
||||
* consuming past them would hand this message a mention the user picked for
|
||||
* the next one. */
|
||||
function consumeMentionsIfGlobal() {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
aiChatManager.contextManager?.consumeMentionContext()
|
||||
if (chatHost.mode !== AIMode.GLOBAL) return
|
||||
// The mention context belongs to the copilot's own ContextManager, which only
|
||||
// the manager has — the GLOBAL guard above means this host is always it.
|
||||
chatManager.contextManager?.consumeMentionContext()
|
||||
}
|
||||
|
||||
function sendRequest() {
|
||||
@@ -709,13 +720,18 @@
|
||||
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
|
||||
return
|
||||
}
|
||||
// A host whose consumer needs a message of its own refuses an attachment-only
|
||||
// turn. Returning before `take()` keeps the chips where the user put them.
|
||||
if (chatHost.requiresMessageText && draft.text.trim() === '') {
|
||||
return
|
||||
}
|
||||
// Read before `take()` empties the draft the id derives from, and only take
|
||||
// once the answer is delivered — an undelivered one would leave the user
|
||||
// with neither their text nor a resumed turn.
|
||||
const answeredQuestionId = questionAnsweredBySend
|
||||
if (
|
||||
answeredQuestionId &&
|
||||
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
|
||||
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
|
||||
expanded(chatDraft(draft.text.trim(), draft.pastes))
|
||||
])
|
||||
) {
|
||||
@@ -727,7 +743,7 @@
|
||||
contextTextareaComponent?.clearForSend()
|
||||
return
|
||||
}
|
||||
if (aiChatManager.loading) {
|
||||
if (chatHost.loading) {
|
||||
// Queue the message instead of silently discarding it — it is
|
||||
// auto-sent when the streaming turn completes successfully.
|
||||
// Editing-while-loading keeps the old discard behavior. Paste
|
||||
@@ -738,10 +754,10 @@
|
||||
// chips picked at press time.
|
||||
if (
|
||||
editingMessageIndex === null &&
|
||||
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
) {
|
||||
const sent = draft.take()
|
||||
aiChatManager.queueMessage(
|
||||
chatHost.queueMessage(
|
||||
expanded(chatDraft(sent.text, sent.pastes)),
|
||||
sent.images,
|
||||
[...selectedContext],
|
||||
@@ -758,7 +774,7 @@
|
||||
// message's original chips), so send exactly what's shown — the user may
|
||||
// have added or removed chips.
|
||||
const sent = draft.take()
|
||||
aiChatManager.restartGeneration(
|
||||
chatHost.restartGeneration(
|
||||
editingMessageIndex,
|
||||
sent.text,
|
||||
sent.pastes,
|
||||
@@ -771,9 +787,9 @@
|
||||
const sent = draft.take()
|
||||
// Pin before consuming: the manager falls back to the live selection only
|
||||
// when given no override, and the consume below empties it.
|
||||
const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
|
||||
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
|
||||
consumeMentionsIfGlobal()
|
||||
aiChatManager.sendRequest({
|
||||
chatHost.sendRequest({
|
||||
instructions: sent.text,
|
||||
pastes: sent.pastes,
|
||||
images: sent.images,
|
||||
@@ -1005,31 +1021,40 @@
|
||||
<!-- The turn stays `loading` while parked on a question, but a drafted answer
|
||||
is what the button should ship then — otherwise the only pointer action on
|
||||
a typed answer would be Stop. Anything else keeps Stop. -->
|
||||
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
|
||||
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
|
||||
{@const emptyDraft = draft.isEmpty}
|
||||
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
|
||||
already sends it), so the button stays enabled there for pointer/touch
|
||||
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
|
||||
(inline ⌘K) and editor copilots need content. -->
|
||||
{@const needsText = chatHost.requiresMessageText && draft.text.trim() === ''}
|
||||
<!-- The wording is about the attachment, so it earns its place only once there is one:
|
||||
an empty composer is the idle state, not a refusal. -->
|
||||
{@const needsTextForAttachment = needsText && !emptyDraft}
|
||||
{@const sendDisabled =
|
||||
disabled ||
|
||||
pendingImages > 0 ||
|
||||
pendingFiles > 0 ||
|
||||
ingestionHolds > 0 ||
|
||||
needsText ||
|
||||
(emptyDraft &&
|
||||
(onSendRequest !== undefined ||
|
||||
aiChatManager.mode !== AIMode.GLOBAL ||
|
||||
chatHost.mode !== AIMode.GLOBAL ||
|
||||
selectedContext.length === 0))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
iconOnly
|
||||
title={isLoading ? 'Stop' : 'Send'}
|
||||
title={isLoading
|
||||
? 'Stop'
|
||||
: needsTextForAttachment
|
||||
? 'Write a message to send with the attachment'
|
||||
: 'Send'}
|
||||
startIcon={{ icon: isLoading ? Square : ArrowUp }}
|
||||
disabled={!isLoading && sendDisabled}
|
||||
on:click={() => {
|
||||
if (isLoading) {
|
||||
onCancel ? onCancel() : aiChatManager.cancel()
|
||||
onCancel ? onCancel() : chatHost.cancel()
|
||||
} else if (!sendDisabled) {
|
||||
submitRequest()
|
||||
}
|
||||
@@ -1115,9 +1140,9 @@
|
||||
class="relative mt-1"
|
||||
role="presentation"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && aiChatManager.loading) {
|
||||
if (e.key === 'Escape' && chatHost.loading) {
|
||||
e.preventDefault()
|
||||
aiChatManager.cancel()
|
||||
chatHost.cancel()
|
||||
} else if (
|
||||
e.key === 'ArrowUp' &&
|
||||
!e.defaultPrevented &&
|
||||
@@ -1139,14 +1164,14 @@
|
||||
// custom-send consumers (inline widget) have their own history
|
||||
// semantics.
|
||||
if (
|
||||
aiChatManager.queuedMessage ||
|
||||
aiChatManager.queuedImages.length > 0 ||
|
||||
aiChatManager.queuedFiles.length > 0 ||
|
||||
(aiChatManager.queuedContext?.length ?? 0) > 0
|
||||
chatHost.queuedMessage ||
|
||||
chatHost.queuedImages.length > 0 ||
|
||||
chatHost.queuedFiles.length > 0 ||
|
||||
(chatHost.queuedContext?.length ?? 0) > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
aiChatManager.dequeueMessage()
|
||||
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
|
||||
chatHost.dequeueMessage()
|
||||
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
|
||||
// History recall waits for the in-flight turn: from the moment the
|
||||
// composer clears, the turn's bubble, stored images and context land
|
||||
// across several awaits, so recalling now would return an incomplete
|
||||
@@ -1163,10 +1188,10 @@
|
||||
bind:this={contextTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
bind:pastes={draft.pastes}
|
||||
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
onImageFiles={chatHost.supportsMessageAttachments
|
||||
? (pasted) => void addImages(pasted)
|
||||
: undefined}
|
||||
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
onTextFiles={chatHost.supportsMessageAttachments
|
||||
? (pasted) => void addTextFiles(pasted)
|
||||
: undefined}
|
||||
{availableContext}
|
||||
@@ -1196,7 +1221,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if aiChatManager.mode === AIMode.APP}
|
||||
{:else if chatHost.mode === AIMode.APP}
|
||||
{#if showContext}
|
||||
{@render badgeRow()}
|
||||
{/if}
|
||||
@@ -1264,30 +1289,38 @@
|
||||
</Portal>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={modePlaceholder}
|
||||
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
|
||||
{disabled}
|
||||
></textarea>
|
||||
{#if !bottomRightSnippet}
|
||||
<div class="absolute bottom-1 right-1">
|
||||
{@render sendStopButton()}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Same box as the rich composer above, so a host on the plain textarea shows
|
||||
the identical chip rows inside the identical field. -->
|
||||
<div class={composerBoxClass(disabled)}>
|
||||
{@render badgeRow()}
|
||||
{@render imageChipsRow()}
|
||||
<div class={twMerge('relative w-full', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
// An Enter that confirms an IME composition (Japanese, Chinese) is not a
|
||||
// send; it would ship the unfinished text.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={modePlaceholder}
|
||||
class={twMerge('resize-none', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING)}
|
||||
{disabled}
|
||||
></textarea>
|
||||
{#if !bottomRightSnippet}
|
||||
<div class="absolute bottom-1 right-1">
|
||||
{@render sendStopButton()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bottomRightSnippet}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChatViewHost } from './chatViewHost'
|
||||
import type { ScriptLang } from '$lib/gen/types.gen'
|
||||
import { JobService, type CompletedJob } from '$lib/gen'
|
||||
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
|
||||
@@ -445,9 +446,27 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
|
||||
}
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
export class AIChatManager implements ChatViewHost {
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
// The copilot owns its model choice and its own transcript, so both chat
|
||||
// affordances apply here. See ChatViewHost for hosts where they don't.
|
||||
supportsModelSettings = true
|
||||
supportsMessageEditing = true
|
||||
// The copilot turn is the attachments themselves when there is no text.
|
||||
requiresMessageText = false
|
||||
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
|
||||
// getters because `mode` changes under a mounted composer.
|
||||
get supportsMessageAttachments() {
|
||||
return this.mode === AIMode.GLOBAL
|
||||
}
|
||||
get supportsLinkedFolders() {
|
||||
return this.mode === AIMode.GLOBAL
|
||||
}
|
||||
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
|
||||
// the message — text files after a content sniff).
|
||||
attachmentAccept =
|
||||
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
||||
/** Files the user attached to the current GLOBAL-mode conversation. */
|
||||
attachedFiles = new AttachedFilesStore()
|
||||
/** Markdown artifacts the copilot created for the current session. */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { DisplayMessage, ToolDisplayMessage } from './shared'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import AssistantMessage from './AssistantMessage.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
@@ -15,7 +15,7 @@
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
// Paths in a message name items the chat's tools reach, so they resolve against the
|
||||
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
|
||||
@@ -25,7 +25,7 @@
|
||||
// Registers the dependency that `operatingWorkspace`'s own untracked
|
||||
// `get(workspaceStore)` cannot.
|
||||
void $workspaceStore
|
||||
return aiChatManager.operatingWorkspace
|
||||
return chatHost.operatingWorkspace
|
||||
})
|
||||
|
||||
// Per-message expand/collapse state for paste chips shown in the bubble.
|
||||
@@ -62,7 +62,12 @@
|
||||
let editContext = $state<ContextElement[]>([])
|
||||
|
||||
function editMessage() {
|
||||
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
|
||||
if (
|
||||
!chatHost.supportsMessageEditing ||
|
||||
message.role !== 'user' ||
|
||||
editingMessageIndex !== null ||
|
||||
chatHost.loading
|
||||
) {
|
||||
return
|
||||
}
|
||||
editContext = [...(message.contextElements ?? [])]
|
||||
@@ -79,7 +84,9 @@
|
||||
message.role === 'tool' && 'mb-1',
|
||||
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
|
||||
isLast && '!mb-12',
|
||||
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
|
||||
message.role !== 'user' || !chatHost.supportsMessageEditing
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -116,7 +123,7 @@
|
||||
bind:selectedContext={editContext}
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
initialImages={aiChatManager.storedImages(messageIndex)}
|
||||
initialImages={chatHost.storedImages(messageIndex)}
|
||||
initialFiles={message.files}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
@@ -185,9 +192,9 @@
|
||||
on:click={() => {
|
||||
if (message.snapshot) {
|
||||
if (message.snapshot.type === 'flow') {
|
||||
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
} else if (message.snapshot.type === 'app') {
|
||||
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -206,7 +213,7 @@
|
||||
variant="default"
|
||||
title="Retry generation"
|
||||
startIcon={{ icon: RefreshCwIcon }}
|
||||
onclick={() => aiChatManager.retryRequest(messageIndex)}
|
||||
onclick={() => chatHost.retryRequest(messageIndex)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import type { UserQuestionDisplay } from './shared'
|
||||
|
||||
// Sessions inject a per-pane `AIChatManager` via context; outside of
|
||||
@@ -12,7 +12,7 @@
|
||||
// this, answers clicked inside a session would dispatch to the singleton's
|
||||
// pending callbacks map (which doesn't have the session manager's question
|
||||
// callback), and the AI loop would stall.
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
interface Props {
|
||||
toolCallId: string
|
||||
@@ -93,14 +93,14 @@
|
||||
}
|
||||
return
|
||||
}
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
|
||||
}
|
||||
|
||||
function submitPicked() {
|
||||
if (!multiSelect || picked.size === 0) {
|
||||
return
|
||||
}
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
|
||||
}
|
||||
|
||||
function submitCustomAnswer() {
|
||||
@@ -119,7 +119,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
|
||||
}
|
||||
|
||||
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
workspaceItemRegistry
|
||||
} from './workspaceItems.svelte'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
|
||||
interface Props {
|
||||
message: DisplayMessage
|
||||
@@ -60,6 +61,20 @@
|
||||
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
|
||||
}
|
||||
|
||||
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
|
||||
|
||||
// A flow step can return a file rather than text; the raw JSON would be
|
||||
// unreadable, so hand it to the result viewer instead of the markdown renderer.
|
||||
const s3Object = $derived.by(() => {
|
||||
if (!message.content.startsWith('{')) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(message.content)
|
||||
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const candidatePaths = $derived(extractCandidatePaths(message.content))
|
||||
const rendererPlugin = {
|
||||
renderer: {
|
||||
@@ -98,6 +113,12 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if stepName}
|
||||
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
|
||||
{stepName}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if reasoning}
|
||||
<ChatCollapsibleCard
|
||||
label={reasoningLabel}
|
||||
@@ -112,7 +133,9 @@
|
||||
</ChatCollapsibleCard>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
{#if s3Object}
|
||||
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
|
||||
{:else if message.content}
|
||||
<div class="w-full space-y-2 {markdownProse.sm}">
|
||||
<Markdown md={message.content} {plugins} />
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user