mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
Merge remote-tracking branch 'origin/main' into fix/ghsa-hfh4-on-behalf-superadmin
# Conflicts: # backend/ee-repo-ref.txt
This commit is contained in:
@@ -18,12 +18,7 @@ const RETRIES = 2
|
||||
// Links whose target page is written but not yet deployed on windmill.dev: the app
|
||||
// link is already the final slug, so a 404 is expected until the docs side ships.
|
||||
// The value is why the entry exists, for whoever has to judge whether it still should.
|
||||
const PENDING_DEPLOY = new Map([
|
||||
[
|
||||
'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt',
|
||||
'windmilldocs#1625 (dbt runtime quickstart)'
|
||||
]
|
||||
])
|
||||
const PENDING_DEPLOY = new Map()
|
||||
|
||||
async function walk(dir) {
|
||||
const out = []
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.780.0"
|
||||
".": "1.782.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## [1.782.0](https://github.com/windmill-labs/windmill/compare/v1.781.3...v1.782.0) (2026-08-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve duckdb isolation ([#10565](https://github.com/windmill-labs/windmill/issues/10565)) ([956210e](https://github.com/windmill-labs/windmill/commit/956210ea06224f7c6cd3e5082a96d3f80039404b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* compact ai chat context for models with unknown context windows ([#10564](https://github.com/windmill-labs/windmill/issues/10564)) ([fc1e11c](https://github.com/windmill-labs/windmill/commit/fc1e11cb3d056af6a3779cf17b23f9bf7152d355))
|
||||
* keep the mermaid fullscreen dialog in its pane and its emoji vector ([#10541](https://github.com/windmill-labs/windmill/issues/10541)) ([c2a6936](https://github.com/windmill-labs/windmill/commit/c2a6936e7d3fe724f968b10dd689abb6ff8f75ad))
|
||||
|
||||
## [1.781.3](https://github.com/windmill-labs/windmill/compare/v1.781.2...v1.781.3) (2026-08-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** take the editor's post-edit content, not setCode's argument ([#10562](https://github.com/windmill-labs/windmill/issues/10562)) ([1846bd5](https://github.com/windmill-labs/windmill/commit/1846bd5ce59ee71a4262297513673d662f6a38b6))
|
||||
|
||||
## [1.781.2](https://github.com/windmill-labs/windmill/compare/v1.781.1...v1.781.2) (2026-08-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** reset the editor content when the script language changes ([#10560](https://github.com/windmill-labs/windmill/issues/10560)) ([56ea133](https://github.com/windmill-labs/windmill/commit/56ea133366875d8bd13347b5002f4ef351a8e4e6))
|
||||
|
||||
## [1.781.1](https://github.com/windmill-labs/windmill/compare/v1.781.0...v1.781.1) (2026-08-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* open the expression property column on demand, not from focus ([#10558](https://github.com/windmill-labs/windmill/issues/10558)) ([386c66b](https://github.com/windmill-labs/windmill/commit/386c66bef0534d4a63b3220050e98b9654ef8f34))
|
||||
* restore the flow expression editor's property side panel ([#10555](https://github.com/windmill-labs/windmill/issues/10555)) ([e4e7782](https://github.com/windmill-labs/windmill/commit/e4e7782517a9fd0fbbec020694b263a948ed1c0c))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* keep run status out of flow graph node and edge data ([#10554](https://github.com/windmill-labs/windmill/issues/10554)) ([b2d38e0](https://github.com/windmill-labs/windmill/commit/b2d38e0391f26d92e8f9245a3db099129b6c3977))
|
||||
|
||||
## [1.781.0](https://github.com/windmill-labs/windmill/compare/v1.780.0...v1.781.0) (2026-08-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add a load all to the tree view's per-folder pager ([#10548](https://github.com/windmill-labs/windmill/issues/10548)) ([a3b79d7](https://github.com/windmill-labs/windmill/commit/a3b79d7732deba54fd6344229d5b91a80a3e0dd6))
|
||||
* allow a dev workspace to have its own dev workspace ([#10534](https://github.com/windmill-labs/windmill/issues/10534)) ([e203ab0](https://github.com/windmill-labs/windmill/commit/e203ab087a4ce885a1d424267f96db861207a0e6))
|
||||
* **debugger:** install debug session deps from the instance registry settings ([#10550](https://github.com/windmill-labs/windmill/issues/10550)) ([154f8f4](https://github.com/windmill-labs/windmill/commit/154f8f461ef01d60da73f9633d76bed45381a035))
|
||||
* **mcp:** serve the 2026-07-28 spec alongside the legacy protocol ([#10535](https://github.com/windmill-labs/windmill/issues/10535)) ([4fe4fac](https://github.com/windmill-labs/windmill/commit/4fe4fac358e67ee6e20dbff8bfa1de0e92e5f3c7))
|
||||
* redesign flow step, loop and branch settings panels ([#10026](https://github.com/windmill-labs/windmill/issues/10026)) ([09c8f3b](https://github.com/windmill-labs/windmill/commit/09c8f3b1f3eef2409e212cdb26c7bd2f830dae89))
|
||||
* register mounted CA certificates in windmill_extra at startup ([#10545](https://github.com/windmill-labs/windmill/issues/10545)) ([d9d6ec8](https://github.com/windmill-labs/windmill/commit/d9d6ec82ab7ad5279ba8ebc059f58bd663bad87f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ai:** collapse thinking to a status row with a thought-for duration ([#10515](https://github.com/windmill-labs/windmill/issues/10515)) ([d9b10e7](https://github.com/windmill-labs/windmill/commit/d9b10e7b0a1ba6702ecf05f64b80ac8bb7c79326))
|
||||
* **debugger:** confine prepare-deps under nsjail in both language paths ([#10546](https://github.com/windmill-labs/windmill/issues/10546)) ([a5423a8](https://github.com/windmill-labs/windmill/commit/a5423a81ca5d7c1e88ebed4573952e858ef1546c))
|
||||
* **debugger:** pass python index settings to prepare-deps and report failures ([#10533](https://github.com/windmill-labs/windmill/issues/10533)) ([7d153d5](https://github.com/windmill-labs/windmill/commit/7d153d5750db0ea17812d6b1f9a63ceaefddc2b9))
|
||||
* **debugger:** report python debugger dependency install failures instead of timing out ([#10531](https://github.com/windmill-labs/windmill/issues/10531)) ([29e179f](https://github.com/windmill-labs/windmill/commit/29e179f7879845f01c6813ad5dfbb19d08dc030e))
|
||||
* edit-in-dev-workspace dead-ends, wraps, and misses the tree view ([#10354](https://github.com/windmill-labs/windmill/issues/10354)) ([616d4fe](https://github.com/windmill-labs/windmill/commit/616d4fe167e1c99f8f5104c7b66dbc5fd502e0be))
|
||||
* flow step picker layout and single hover/keyboard highlight ([#10488](https://github.com/windmill-labs/windmill/issues/10488)) ([aa91619](https://github.com/windmill-labs/windmill/commit/aa91619bb67b7ae0cbd3a3848b6171d36967bd1f))
|
||||
* forward TLS trust roots to debug sessions and honor INIT_SCRIPT on windmill_extra ([#10532](https://github.com/windmill-labs/windmill/issues/10532)) ([74c4185](https://github.com/windmill-labs/windmill/commit/74c418570bb5bf1af54f367cfecae3e8b4ede764))
|
||||
* **frontend:** draw the tab strip's scroll bar instead of the native one ([#10547](https://github.com/windmill-labs/windmill/issues/10547)) ([2c189fe](https://github.com/windmill-labs/windmill/commit/2c189fea14749b8bb4604b1363eef907abc3b129))
|
||||
* **frontend:** filter the AI Sandbox entry by the flow insert search ([#10529](https://github.com/windmill-labs/windmill/issues/10529)) ([1dcb6bb](https://github.com/windmill-labs/windmill/commit/1dcb6bb9005c1e97f1fca6470eb2d2105a973918))
|
||||
* keep the same_worker pin across a flow module that spawns no job ([#10551](https://github.com/windmill-labs/windmill/issues/10551)) ([1aee222](https://github.com/windmill-labs/windmill/commit/1aee22296e228c54d4ca7251e9ae7e04229adb34))
|
||||
* keep the same_worker pin when a suspend ends without approval ([#10552](https://github.com/windmill-labs/windmill/issues/10552)) ([c59b60c](https://github.com/windmill-labs/windmill/commit/c59b60c729b03c5be66738b1f4cbf0d989a7b438))
|
||||
* point re-opened previews at the tab already showing them ([#10538](https://github.com/windmill-labs/windmill/issues/10538)) ([f11e883](https://github.com/windmill-labs/windmill/commit/f11e8835fdc762fccb521de48049ea43838d7f3c))
|
||||
* **triggers:** stop one failing trigger count from zeroing the rest ([#10549](https://github.com/windmill-labs/windmill/issues/10549)) ([0e42381](https://github.com/windmill-labs/windmill/commit/0e42381df086d4463e5914d8a194fe5141793bd1))
|
||||
|
||||
## [1.780.0](https://github.com/windmill-labs/windmill/compare/v1.779.0...v1.780.0) (2026-08-04)
|
||||
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE tree AS (\n SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,\n tree.depth + 1\n FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM tree\n WHERE depth > 0 AND is_dev_workspace AND NOT deleted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "dev_workspace_label",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN workspace.id IS NULL THEN 'deleted'\n WHEN usr.email IS NULL AND NOT $3 THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3200de51b6fedd925ab3e9e471a6719b7b6de68976e9f90d18c5b74f43763ece"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE ancestors AS (\n SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted,\n 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,\n w.deleted, ancestors.depth + 1\n FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id\n WHERE ancestors.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM ancestors\n WHERE is_dev_workspace AND NOT deleted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "dev_workspace_label",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS \"exists!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM workspace\n WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS \"cycle!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cycle!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (parent_workspace_id IS NOT NULL) AS \"is_fork!\", is_dev_workspace, deleted\n FROM workspace WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_fork!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_dev_workspace",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deleted",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id),\n up AS (\n SELECT w.id, w.parent_workspace_id, 0 AS depth\n FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, w.parent_workspace_id, up.depth + 1\n FROM workspace w JOIN up ON w.id = up.parent_workspace_id\n WHERE up.depth < 20\n ),\n down AS (\n SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, down.depth + 1\n FROM workspace w JOIN down ON w.parent_workspace_id = down.id\n WHERE down.depth < 20 AND w.is_dev_workspace\n )\n SELECT id AS \"id!\" FROM (\n SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down\n ) n ORDER BY id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN usr.email IS NULL THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441"
|
||||
}
|
||||
Generated
+172
-198
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.780.0"
|
||||
version = "1.782.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.780.0"
|
||||
version = "1.782.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
7622c1df38a1f858bd3f893537da0e2cca6c2d54
|
||||
2656a901804c0ae791ab8e7e6714edc900d6c62a
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.780.0"
|
||||
version = "1.782.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+15
-10
@@ -119,15 +119,15 @@ use windmill_queue::{
|
||||
};
|
||||
use windmill_worker::{
|
||||
result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel,
|
||||
OtelTracingProxySettings, SameWorkerSender, WorkspaceRegistryMap, BUNFIG_INSTALL_SCOPES,
|
||||
BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR,
|
||||
JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML,
|
||||
NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB,
|
||||
NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
|
||||
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY,
|
||||
SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY,
|
||||
UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
|
||||
OtelTracingProxySettings, SameWorkerSender, StepFailureKind, WorkspaceRegistryMap,
|
||||
BUNFIG_INSTALL_SCOPES, BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION,
|
||||
JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS,
|
||||
MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE,
|
||||
NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS,
|
||||
PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL,
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB,
|
||||
SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER,
|
||||
UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -4700,7 +4700,12 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
|
||||
memory_peak,
|
||||
None,
|
||||
error::Error::ExecutionErr(error_message.clone()),
|
||||
matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie
|
||||
// a same worker zombie means the worker itself is gone
|
||||
if matches!(error_kind, ErrorMessage::SameWorker) {
|
||||
StepFailureKind::Unrecoverable
|
||||
} else {
|
||||
StepFailureKind::Normal
|
||||
},
|
||||
Some(&same_worker_tx_never_used),
|
||||
"",
|
||||
node_name,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Extends base.sql with two workspaces `test@windmill.dev` (the instance superadmin) has no
|
||||
-- `usr` row in: one live, one soft-deleted. The third no-membership case, `admins`, is
|
||||
-- created by migration with no `usr` rows at all and needs no fixture.
|
||||
|
||||
INSERT INTO workspace (id, name, owner, deleted) VALUES
|
||||
('foreign-workspace', 'foreign-workspace', 'someone-else', false),
|
||||
('archived-workspace', 'archived-workspace', 'someone-else', true);
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('foreign-workspace', 'cloud', 'test-key'),
|
||||
('archived-workspace', 'cloud', 'test-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('foreign-workspace'),
|
||||
('archived-workspace');
|
||||
@@ -83,6 +83,32 @@ async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCo
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
url: &str,
|
||||
token: Option<&str>,
|
||||
body: serde_json::Value,
|
||||
) -> (reqwest::StatusCode, String) {
|
||||
let mut req = client().post(url).json(&body);
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let resp = req.send().await.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn delete(url: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
|
||||
let mut req = client().delete(url);
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let resp = req.send().await.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
|
||||
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -92,6 +118,8 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
|
||||
// result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`.
|
||||
let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
let rules_url =
|
||||
format!("http://localhost:{port}/api/w/test-workspace/workspaces/protection_rules");
|
||||
|
||||
// The endpoints that return the victim job's sensitive data by UUID.
|
||||
let endpoints = [
|
||||
@@ -507,6 +535,161 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
"a non-reader must not be able to mint a share token (got {status})"
|
||||
);
|
||||
|
||||
// ---- PUBLIC SHARE READ LINK (public view_token) ----
|
||||
// A member-audience token must never turn into a public one: links already handed
|
||||
// out stay confined to logged-in members.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}?view_token={token}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"a member view_token must not grant unauthenticated read (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(RESULT_SECRET),
|
||||
"unauth body must not leak with a member token: {body}"
|
||||
);
|
||||
|
||||
// The owner mints the public flavor for the top flow.
|
||||
let (status, mint_body) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner must be able to mint a public share token (got {status}): {mint_body}"
|
||||
);
|
||||
let public_token = mint_body.trim().trim_matches('"').to_string();
|
||||
|
||||
// It grants a logged-out visitor the shared job and its whole flow subtree.
|
||||
for path in [
|
||||
format!("get/{TOP_SECRET_FLOW}?view_token={public_token}"),
|
||||
format!("get_args/{TOP_SECRET_FLOW}?view_token={public_token}"),
|
||||
format!("getupdate/{TOP_SECRET_FLOW}?view_token={public_token}"),
|
||||
format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"),
|
||||
format!("get_logs/{DEEP_LEAF_JOB}?view_token={public_token}"),
|
||||
] {
|
||||
let (status, body) = get(&base, &path, None).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a public view_token must grant unauthenticated read of {path} (got {status}): {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// Same scoping as the member flavor: another job, and a tampered signature, are refused.
|
||||
for path in [
|
||||
format!("get/{VICTIM}?view_token={public_token}"),
|
||||
format!("get/{TOP_SECRET_FLOW}?view_token={TOP_SECRET_FLOW}.deadbeef"),
|
||||
] {
|
||||
let (status, body) = get(&base, &path, None).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"an out-of-scope or forged public token must not grant read of {path} (got {status}): {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// The public audience is a superset of the member one: it must also satisfy the
|
||||
// authenticated ACL check, so a viewer handed a public link is not worse off.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a public view_token must also grant an authenticated member read (got {status}): {body}"
|
||||
);
|
||||
|
||||
// Tag-scoped caller, job inside its scope: the member flavor is allowed (asserted
|
||||
// further down), the public one must not be — i.e. strictly stricter.
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{VICTIM}"),
|
||||
Some("SCOPED_DENO_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"a tag-scoped token must NOT mint a public link, even for an in-scope job (got {status}): {body}"
|
||||
);
|
||||
|
||||
// Minting the public flavor takes the same read access as the member one.
|
||||
let (status, _) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"a non-reader must not be able to mint a public share token (got {status})"
|
||||
);
|
||||
|
||||
// Publishing a run is gated by `RestrictPublicRunSharing`, so a wrong bitflag or
|
||||
// rule-kind arm would silently let a restricted member expose one. Admins bypass, and
|
||||
// the member flavor is untouched by the rule.
|
||||
let (status, body) = post_json(
|
||||
&rules_url,
|
||||
Some("SECRET_TOKEN"),
|
||||
serde_json::json!({
|
||||
"name": "no-public-runs",
|
||||
"rules": ["RestrictPublicRunSharing"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"admin should create the protection rule (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"the rule must block a non-admin owner from minting a public link (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"the rule must NOT affect the member flavor (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"an admin must bypass the rule (got {status}): {body}"
|
||||
);
|
||||
|
||||
// Later assertions in this test mint public tokens; drop the rule so they see the
|
||||
// same workspace state as before.
|
||||
let (status, _) = delete(&format!("{rules_url}/no-public-runs"), Some("SECRET_TOKEN")).await;
|
||||
assert!(status.is_success(), "admin should delete the rule");
|
||||
|
||||
// ---- TAG-SCOPED token must not mint a token outside its allowed tags ----
|
||||
// SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both
|
||||
// VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the
|
||||
@@ -618,6 +801,36 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
"viewer must not cancel another user's job ({path}, got {status}): {body}"
|
||||
);
|
||||
}
|
||||
// A public share link grants read, never the right to kill the run: cancelling stays
|
||||
// confined to anonymously-created jobs for a logged-out caller.
|
||||
let (status, mint_body) = get(
|
||||
&authed_base,
|
||||
&format!("job_public_view_token/{RUNNING_JOB}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(status.is_success(), "owner mints for the running job");
|
||||
let running_public = mint_body.trim().trim_matches('"').to_string();
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("get/{RUNNING_JOB}?view_token={running_public}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"the public token must grant the anonymous read it is for (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = post(
|
||||
&base,
|
||||
&format!("queue/cancel/{RUNNING_JOB}?view_token={running_public}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!status.is_success(),
|
||||
"a public token must not let an anonymous caller cancel the run (got {status}): {body}"
|
||||
);
|
||||
// The owner still cancels their own job (no over-blocking). Keep this last: it
|
||||
// takes RUNNING_JOB out of the queue.
|
||||
let (status, body) = post(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! `POST /workspaces/session_workspace_status` is what the client uses to decide whether to
|
||||
//! keep or destroy an AI session, so its notion of "reachable" must match what the authed
|
||||
//! 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.
|
||||
|
||||
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>> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/workspaces/session_workspace_status"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&json!({ "workspace_ids": ids }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "session_workspace_status"))]
|
||||
async fn test_superadmin_reaches_workspaces_without_a_usr_row(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let ids = [
|
||||
"admins",
|
||||
"foreign-workspace",
|
||||
"archived-workspace",
|
||||
"test-workspace",
|
||||
"no-such-workspace",
|
||||
];
|
||||
|
||||
// test@windmill.dev is an instance superadmin, and a member of test-workspace only.
|
||||
let sa = status(port, "SECRET_TOKEN", &ids).await?;
|
||||
assert_eq!(sa["admins"], "active");
|
||||
assert_eq!(sa["foreign-workspace"], "active");
|
||||
// Reachable, but soft-deleted: superadmins must not bypass the archived state.
|
||||
assert_eq!(sa["archived-workspace"], "archived");
|
||||
assert_eq!(sa["test-workspace"], "active");
|
||||
// A workspace that never existed stays unresolvable — the superadmin arm must not
|
||||
// swallow the hard-deleted case, or those sessions would linger forever.
|
||||
assert_eq!(sa["no-such-workspace"], "deleted");
|
||||
|
||||
// test2@windmill.dev is not a superadmin, and a member of test-workspace only.
|
||||
let usr = status(port, "SECRET_TOKEN_2", &ids).await?;
|
||||
assert_eq!(usr["admins"], "deleted");
|
||||
assert_eq!(usr["foreign-workspace"], "deleted");
|
||||
assert_eq!(usr["archived-workspace"], "deleted");
|
||||
assert_eq!(usr["test-workspace"], "active");
|
||||
assert_eq!(usr["no-such-workspace"], "deleted");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,6 +235,88 @@ mod suspend_resume {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A suspend gate that ends without approval leaves the worker that ran the approval step
|
||||
/// alive, so the error handler it routes to must stay pinned to that worker rather than
|
||||
/// being unpinned and routed by tag — which would break the `./shared` contract of a
|
||||
/// `same_worker` flow.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn disapproved_suspend_keeps_same_worker_pin(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let value: FlowValue = serde_json::from_value(json!({
|
||||
"same_worker": true,
|
||||
"modules": [{
|
||||
"id": "a",
|
||||
"value": {
|
||||
"input_transforms": {
|
||||
"port": { "type": "javascript", "expr": "flow_input.port" },
|
||||
},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "\
|
||||
export async function main(port) {\
|
||||
const job = Deno.env.get('WM_JOB_ID');\
|
||||
const token = Deno.env.get('WM_TOKEN');\
|
||||
const secret = await (await fetch(\
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }\
|
||||
)).text();\
|
||||
await fetch(\
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs_u/cancel/${job}/0/${secret}?approver=ruben`,\
|
||||
{ method: 'POST', body: JSON.stringify('from job'), headers: { 'content-type': 'application/json' } }\
|
||||
);\
|
||||
return 'a ran';\
|
||||
}",
|
||||
},
|
||||
"suspend": { "required_events": 1 },
|
||||
}, {
|
||||
"id": "b",
|
||||
"value": {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return 'b ran' }",
|
||||
},
|
||||
// The gate holds `b` back, so `b` never runs and its error policy describes
|
||||
// nothing: honouring it here would skip `b` instead of reaching the handler.
|
||||
"continue_on_error": true,
|
||||
}],
|
||||
"failure_module": {
|
||||
"id": "failure",
|
||||
"value": {
|
||||
"input_transforms": {},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return 'handled' }",
|
||||
},
|
||||
},
|
||||
}))?;
|
||||
|
||||
let completed =
|
||||
RunJob::from(JobPayload::RawFlow { value, path: None, restarted_from: None })
|
||||
.arg("port", json!(port))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
server.close().await.unwrap();
|
||||
|
||||
assert_eq!(json!("handled"), completed.json_result().unwrap());
|
||||
|
||||
let same_worker: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'failure'",
|
||||
)
|
||||
.bind(completed.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(Some(true), same_worker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that self-approval is blocked when self_approval_disabled is true.
|
||||
///
|
||||
/// This test verifies that when a flow has an approval step with self_approval_disabled=true,
|
||||
|
||||
+66
-3
@@ -587,6 +587,69 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_same_worker_survives_empty_branch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server: ApiServer = ApiServer::start(db.clone()).await?;
|
||||
|
||||
// No branch matches and the default is empty, so `a` completes without spawning a job and
|
||||
// hands the flow back over the UpdateFlow channel. `b` must still be pinned to the worker.
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"same_worker": true,
|
||||
"modules": [
|
||||
{
|
||||
"id": "a",
|
||||
"value": {
|
||||
"type": "branchone",
|
||||
"branches": [{
|
||||
"expr": "false",
|
||||
"modules": [{
|
||||
"id": "c",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(){ return 1 }",
|
||||
}
|
||||
}]
|
||||
}],
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(){ return 42 }",
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let job = run_job_in_new_worker_until_complete(
|
||||
&db,
|
||||
false,
|
||||
JobPayload::RawFlow { value: flow, path: None, restarted_from: None },
|
||||
server.addr.port(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(job.json_result().unwrap(), json!(42));
|
||||
|
||||
let same_worker: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(same_worker, Some(true));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_flow_result_by_id(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -3992,7 +4055,7 @@ async fn test_failure_module(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
/// Push `flow`, run it on a real worker until its first step is running, then simulate
|
||||
/// `monitor::handle_zombie_jobs` reaping that step unrecoverably (its worker crashed/OOM'd)
|
||||
/// by calling `handle_job_error(..., unrecoverable = true, ...)` exactly as the monitor does.
|
||||
/// by calling `handle_job_error(..., StepFailureKind::Unrecoverable, ...)` exactly as the monitor does.
|
||||
/// Returns the flow's completed result.
|
||||
#[cfg(feature = "deno_core")]
|
||||
async fn run_flow_until_step_running_then_fail_unrecoverably(
|
||||
@@ -4007,7 +4070,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably(
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::KillpillSender;
|
||||
use windmill_queue::{get_queued_job_v2, MiniCompletedJob, SameWorkerPayload};
|
||||
use windmill_worker::{JobCompletedSender, SameWorkerSender};
|
||||
use windmill_worker::{JobCompletedSender, SameWorkerSender, StepFailureKind};
|
||||
|
||||
let flow_id =
|
||||
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
@@ -4072,7 +4135,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably(
|
||||
windmill_common::error::Error::ExecutionErr(
|
||||
"simulated worker OOM crash".to_string(),
|
||||
),
|
||||
true, // unrecoverable
|
||||
StepFailureKind::Unrecoverable,
|
||||
Some(&sw_tx),
|
||||
"",
|
||||
"test-monitor",
|
||||
|
||||
@@ -8,6 +8,10 @@ edition.workspace = true
|
||||
name = "windmill_api_debug"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
enterprise = []
|
||||
|
||||
[dependencies]
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
|
||||
@@ -20,15 +20,21 @@
|
||||
//! - A job entry in v2_job (kind=preview) for traceability
|
||||
//! - A completed job entry in v2_job_completed
|
||||
//! - An audit log entry identical to script preview runs
|
||||
//!
|
||||
//! The same signature is what authorizes the debugger's requests back to the API:
|
||||
//! /api/debug/registry_config serves the instance's dependency-registry settings, which the
|
||||
//! debugger cannot read for itself, to sessions whose token carries the `registry_config`
|
||||
//! claim.
|
||||
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::Utc;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::types::Json as SqlxJson;
|
||||
@@ -37,8 +43,18 @@ use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB, error::JsonResult, jobs::JobKind, jwt::JWT_SECRET, scripts::ScriptLang,
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult},
|
||||
global_settings::{
|
||||
BUNFIG_INSTALL_SCOPES_SETTING, EXTRA_PIP_INDEX_URL_SETTING, NPMRC_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, PIP_INDEX_URL_SETTING, UV_INDEX_STRATEGY_SETTING,
|
||||
WORKSPACE_REGISTRIES_SETTING,
|
||||
},
|
||||
jobs::JobKind,
|
||||
jwt::JWT_SECRET,
|
||||
scripts::ScriptLang,
|
||||
users::username_to_permissioned_as,
|
||||
DB,
|
||||
};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
@@ -112,7 +128,9 @@ pub async fn reload_debug_signing_key() {
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new().route("/jwks", get(get_jwks))
|
||||
Router::new()
|
||||
.route("/jwks", get(get_jwks))
|
||||
.route("/registry_config", get(get_registry_config))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -168,6 +186,220 @@ async fn get_jwks() -> JsonResult<DebugJwks> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The instance's dependency-registry configuration, as `windmill prepare-deps` consumes it.
|
||||
/// Field names are the `global_settings` keys, so the debug service forwards this object to
|
||||
/// the CLI as-is.
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct DebugRegistryConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub npm_config_registry: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub npmrc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bunfig_install_scopes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_index_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_extra_index_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uv_index_strategy: Option<String>,
|
||||
/// Why configured settings were withheld, for the debug service to show the user.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Placeholder an EE instance puts in an index URL for a token minted per install by
|
||||
/// `EPHEMERAL_TOKEN_CMD` (`windmill-worker`'s `handle_ephemeral_token`).
|
||||
const EPHEMERAL_TOKEN_MARKER: &str = "EPHEMERAL_TOKEN";
|
||||
|
||||
/// Every `global_settings` key [`get_registry_config`] reads, in one query. A setting
|
||||
/// resolved there but missing here reads as unset, whatever the instance has stored.
|
||||
const REGISTRY_SETTINGS: [&str; 7] = [
|
||||
NPM_CONFIG_REGISTRY_SETTING,
|
||||
NPMRC_SETTING,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING,
|
||||
PIP_INDEX_URL_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING,
|
||||
WORKSPACE_REGISTRIES_SETTING,
|
||||
];
|
||||
|
||||
/// Which half of the settings a session installs with, from the language its token was signed
|
||||
/// for: a session is served only what its own installer runs on, so a token minted for one
|
||||
/// language cannot be replayed to read the other's credentials. Every language the debugger
|
||||
/// accepts (`isDebuggableLanguage` in the frontend) has to appear here, or its sessions
|
||||
/// silently install from the public registries.
|
||||
fn registry_settings_for_language(language: &str) -> (bool, bool) {
|
||||
match language {
|
||||
"bun" | "typescript" | "deno" | "nativets" => (true, false),
|
||||
"python3" | "python" => (false, true),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one setting the way a worker resolves it: a workspace override wins over the
|
||||
/// instance value, which is `FORCE_<env>` > `global_settings` > `<env>` (the server's
|
||||
/// `load_option_setting_value`). A blank value means unset from either source, so a
|
||||
/// workspace can blank one out.
|
||||
fn resolve_registry_setting(
|
||||
stored: &std::collections::HashMap<String, serde_json::Value>,
|
||||
workspace_overrides: Option<&serde_json::Value>,
|
||||
key: &str,
|
||||
env_var: &str,
|
||||
) -> Option<String> {
|
||||
let as_str = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let instance_value = std::env::var(format!("FORCE_{env_var}"))
|
||||
.ok()
|
||||
.or_else(|| as_str(stored.get(key)))
|
||||
.or_else(|| std::env::var(env_var).ok());
|
||||
as_str(workspace_overrides.and_then(|w| w.get(key)))
|
||||
.or(instance_value)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Serve the dependency-registry settings to the debug service.
|
||||
///
|
||||
/// `windmill prepare-deps` installs a debug session's imports without a database
|
||||
/// connection, so the service fetches the settings here and passes them down over the
|
||||
/// CLI's stdin request. They stop there: a private index URL embeds credentials and the
|
||||
/// debugged script can read its own process, so nothing served here reaches the session's
|
||||
/// environment (see `debugger/README.md`).
|
||||
///
|
||||
/// Authorized by the launch token the service verified for that session, and only when the
|
||||
/// token carries the `registry_config` claim (see [`sign_debug_request`] for what it means).
|
||||
async fn get_registry_config(
|
||||
Extension(db): Extension<DB>,
|
||||
headers: HeaderMap,
|
||||
) -> JsonResult<DebugRegistryConfig> {
|
||||
let token = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| Error::NotAuthorized("Missing debug token".to_string()))?;
|
||||
|
||||
let claims = verify_debug_token(token).await?;
|
||||
if !claims.registry_config {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This debug session is not allowed to read the registry configuration".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let names = REGISTRY_SETTINGS.map(String::from);
|
||||
let stored = sqlx::query!(
|
||||
"SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
&names[..]
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.name, r.value))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let workspace_overrides = stored
|
||||
.get(WORKSPACE_REGISTRIES_SETTING)
|
||||
.and_then(|v| v.get(&claims.workspace_id));
|
||||
let (npm, python) = registry_settings_for_language(&claims.language);
|
||||
let resolve = |serve: bool, key: &str, env_var: &str| {
|
||||
serve
|
||||
.then(|| resolve_registry_setting(&stored, workspace_overrides, key, env_var))
|
||||
.flatten()
|
||||
};
|
||||
|
||||
let mut config = DebugRegistryConfig {
|
||||
npm_config_registry: resolve(npm, NPM_CONFIG_REGISTRY_SETTING, "NPM_CONFIG_REGISTRY"),
|
||||
npmrc: resolve(npm, NPMRC_SETTING, "NPMRC"),
|
||||
bunfig_install_scopes: resolve(
|
||||
npm,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING,
|
||||
"BUNFIG_INSTALL_SCOPES",
|
||||
),
|
||||
pip_index_url: resolve(python, PIP_INDEX_URL_SETTING, "PIP_INDEX_URL"),
|
||||
pip_extra_index_url: resolve(python, EXTRA_PIP_INDEX_URL_SETTING, "PIP_EXTRA_INDEX_URL"),
|
||||
// Not a private-registry setting: a worker reads it on any edition, so it is
|
||||
// served below the Enterprise gate too.
|
||||
uv_index_strategy: resolve(python, UV_INDEX_STRATEGY_SETTING, "UV_INDEX_STRATEGY"),
|
||||
message: None,
|
||||
};
|
||||
|
||||
if cfg!(feature = "enterprise") {
|
||||
// A worker substitutes this marker with the output of `EPHEMERAL_TOKEN_CMD`
|
||||
// (`handle_ephemeral_token`), a command the debug service has no way to run. Serving
|
||||
// the placeholder would install with a literal token, so the value is withheld and
|
||||
// the service falls back to the index URL in its own environment.
|
||||
let ephemeral = |url: &Option<String>| {
|
||||
url.as_ref()
|
||||
.is_some_and(|u| u.contains(EPHEMERAL_TOKEN_MARKER))
|
||||
};
|
||||
if ephemeral(&config.pip_index_url) || ephemeral(&config.pip_extra_index_url) {
|
||||
config.pip_index_url = None;
|
||||
config.pip_extra_index_url = None;
|
||||
config.message = Some(format!(
|
||||
"Python index configuration ignored: an {EPHEMERAL_TOKEN_MARKER} index URL can only be resolved on a worker"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// A private registry is an Enterprise feature, and `read_ee_registry` drops these
|
||||
// same settings on a CE worker, so a CE debug session installs from the public registries
|
||||
// and says why, instead of gaining a capability jobs on that instance don't have.
|
||||
let configured = config.npm_config_registry.is_some()
|
||||
|| config.npmrc.is_some()
|
||||
|| config.bunfig_install_scopes.is_some()
|
||||
|| config.pip_index_url.is_some()
|
||||
|| config.pip_extra_index_url.is_some();
|
||||
config.npm_config_registry = None;
|
||||
config.npmrc = None;
|
||||
config.bunfig_install_scopes = None;
|
||||
config.pip_index_url = None;
|
||||
config.pip_extra_index_url = None;
|
||||
if configured {
|
||||
config.message = Some(
|
||||
"Private registry configuration ignored: this feature requires Windmill Enterprise Edition"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// Verify a token minted by [`sign_debug_request`] and return its claims.
|
||||
///
|
||||
/// The debug service verifies the same token itself against the JWKS public key; this is
|
||||
/// the server-side half, for the requests the service makes back on a session's behalf.
|
||||
async fn verify_debug_token(token: &str) -> Result<DebugTokenClaims, Error> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InternalErr("Debug signing key not initialized".to_string()))?;
|
||||
|
||||
let invalid = || Error::NotAuthorized("Invalid debug token".to_string());
|
||||
let mut parts = token.split('.');
|
||||
let (header_b64, claims_b64, signature_b64) =
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(header), Some(claims), Some(signature), None) => (header, claims, signature),
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
|
||||
let signature = Signature::from_slice(
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?;
|
||||
signing_key
|
||||
.verifying_key()
|
||||
.verify_strict(format!("{header_b64}.{claims_b64}").as_bytes(), &signature)
|
||||
.map_err(|_| invalid())?;
|
||||
|
||||
let claims: DebugTokenClaims =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
if Utc::now().timestamp() > claims.exp {
|
||||
return Err(Error::NotAuthorized("Debug token expired".to_string()));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignDebugRequest {
|
||||
/// The code to be debugged
|
||||
@@ -193,6 +425,11 @@ pub struct DebugTokenClaims {
|
||||
pub exp: i64,
|
||||
/// Job ID for traceability
|
||||
pub job_id: String,
|
||||
/// Whether this session may be served the instance's dependency-registry settings
|
||||
/// (see [`get_registry_config`]). Defaults to `false` so a token that predates the
|
||||
/// claim is refused rather than silently trusted.
|
||||
#[serde(default)]
|
||||
pub registry_config: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -248,6 +485,14 @@ async fn sign_debug_request(
|
||||
iat: now_ts,
|
||||
exp,
|
||||
job_id: job_id.to_string(),
|
||||
// The registry settings embed credentials, and the token reaches the browser, so they
|
||||
// are only served for a session whose author can already install with them: someone
|
||||
// who can run a preview job. For npm that discloses nothing new, since a worker leaves
|
||||
// the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in; the
|
||||
// Python index URL only ever appears as uv's argv, so serving it here does widen what
|
||||
// a member of the workspace can read. Operators cannot run previews at all, so their
|
||||
// sessions install from the public registries.
|
||||
registry_config: !authed.is_operator,
|
||||
};
|
||||
|
||||
// Create JWT manually with Ed25519 signature
|
||||
@@ -504,3 +749,107 @@ async fn sign_multiplayer(
|
||||
|
||||
Ok(Json(SignedMultiplayerPayload { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Sign `claims` the way [`sign_debug_request`] does, with a key only this test knows.
|
||||
async fn signed(claims: &DebugTokenClaims) -> String {
|
||||
let key = derive_signing_key_from_jwt_secret("test-secret");
|
||||
*DEBUG_SIGNING_KEY.write().await = Some(key.clone());
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
let message = format!("{header}.{payload}");
|
||||
let signature = URL_SAFE_NO_PAD.encode(key.sign(message.as_bytes()).to_bytes());
|
||||
format!("{message}.{signature}")
|
||||
}
|
||||
|
||||
fn claims(exp_in: i64) -> DebugTokenClaims {
|
||||
DebugTokenClaims {
|
||||
code_hash: "0".repeat(32),
|
||||
language: "bun".to_string(),
|
||||
workspace_id: "test".to_string(),
|
||||
email: "user@windmill.dev".to_string(),
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: Utc::now().timestamp() + exp_in,
|
||||
job_id: Uuid::nil().to_string(),
|
||||
registry_config: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry settings are credentials, and this signature is the only thing standing
|
||||
/// between them and any caller of `/api/debug/registry_config`. Each rejection here is a
|
||||
/// way in if it stops being checked: an edited claim, a session whose author may not read
|
||||
/// them, or a token replayed long after its session.
|
||||
#[tokio::test]
|
||||
async fn only_an_unexpired_token_with_the_claim_verifies() {
|
||||
let token = signed(&claims(60)).await;
|
||||
assert!(verify_debug_token(&token).await.is_ok());
|
||||
|
||||
let no_claim = signed(&DebugTokenClaims { registry_config: false, ..claims(60) }).await;
|
||||
assert!(!verify_debug_token(&no_claim).await.unwrap().registry_config);
|
||||
|
||||
let expired = signed(&claims(-1)).await;
|
||||
assert!(verify_debug_token(&expired).await.is_err());
|
||||
|
||||
// Re-signing is the only way to change a claim: swapping the payload of a valid token
|
||||
// for one that grants itself the claim must not verify.
|
||||
let (header, rest) = token.split_once('.').unwrap();
|
||||
let (_, signature) = rest.split_once('.').unwrap();
|
||||
let forged = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_string(&DebugTokenClaims { exp: i64::MAX, ..claims(60) }).unwrap(),
|
||||
);
|
||||
assert!(
|
||||
verify_debug_token(&format!("{header}.{forged}.{signature}"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Every language the debugger can start a session for installs dependencies, so each one
|
||||
/// has to name the settings its installer reads. A language missing here is not a refusal:
|
||||
/// its sessions quietly install from the public registries instead.
|
||||
#[test]
|
||||
fn every_debuggable_language_is_served_its_own_settings() {
|
||||
// Mirrors `isDebuggableLanguage` in frontend/src/lib/components/debug/debugUtils.ts.
|
||||
for language in ["bun", "typescript", "deno", "nativets"] {
|
||||
assert_eq!(registry_settings_for_language(language), (true, false));
|
||||
}
|
||||
assert_eq!(registry_settings_for_language("python3"), (false, true));
|
||||
assert_eq!(registry_settings_for_language("go"), (false, false));
|
||||
}
|
||||
|
||||
/// A debug session must resolve a registry setting to what a job in the same workspace
|
||||
/// resolves it to (`read_ee_registry_with_workspace_override`): the workspace override
|
||||
/// replaces the instance value, and a blank value from either source means unset, which
|
||||
/// is how a workspace opts out of an instance-wide registry.
|
||||
#[test]
|
||||
fn workspace_override_replaces_the_instance_value() {
|
||||
let stored = [(
|
||||
NPM_CONFIG_REGISTRY_SETTING.to_string(),
|
||||
serde_json::json!("https://instance.example/"),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
// Never set, so the environment fallback stays out of the comparison.
|
||||
let env_var = "WM_TEST_DEBUG_REGISTRY_UNSET";
|
||||
let resolve = |overrides: Option<&serde_json::Value>| {
|
||||
resolve_registry_setting(&stored, overrides, NPM_CONFIG_REGISTRY_SETTING, env_var)
|
||||
};
|
||||
|
||||
assert_eq!(resolve(None).as_deref(), Some("https://instance.example/"));
|
||||
let workspace = serde_json::json!({ "npm_config_registry": "https://workspace.example/" });
|
||||
assert_eq!(
|
||||
resolve(Some(&workspace)).as_deref(),
|
||||
Some("https://workspace.example/")
|
||||
);
|
||||
let blanked = serde_json::json!({ "npm_config_registry": " " });
|
||||
assert_eq!(resolve(Some(&blanked)), None);
|
||||
let unrelated = serde_json::json!({ "npmrc": "//other/:_authToken=x" });
|
||||
assert_eq!(
|
||||
resolve(Some(&unrelated)).as_deref(),
|
||||
Some("https://instance.example/")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
-- Seven families for the nested dev-workspace (dev of a dev) guards.
|
||||
--
|
||||
-- Family A is rooted at `test-workspace` (base fixture) and is the one a nested dev is attached to:
|
||||
-- test-workspace -> tw-dev ('dev')
|
||||
-- plus two standalone attach candidates, one of which already owns a 'dev'-labelled dev workspace.
|
||||
--
|
||||
-- Family B carries a `wm-fork-` workspace re-designated as a dev workspace, which is the shape that
|
||||
-- returns to being a throwaway fork on detach:
|
||||
-- prod-b -> wm-fork-redev ('dev') -> redev-dev ('staging')
|
||||
--
|
||||
-- Family C is the ordinary prefix-less nesting. Detaching its middle workspace is fine (it returns
|
||||
-- to standalone and goes on hosting `c-dev-dev`), but archiving it is not:
|
||||
-- prod-c -> c-dev ('dev') -> c-dev-dev ('staging')
|
||||
--
|
||||
-- Family E has no nested dev yet, so both "give `wm-fork-edev` a dev" and "detach `wm-fork-edev`"
|
||||
-- pass their own checks — the pair that must not both commit:
|
||||
-- prod-e -> wm-fork-edev ('dev'), plus the standalone candidate `e-cand`
|
||||
--
|
||||
-- Family F is three standalone workspaces, so "attach f-mid under prod-f" and "attach f-leaf under
|
||||
-- f-mid" both pass on their own — adjacent attaches whose labels only collide once both land:
|
||||
-- prod-f, f-mid, f-leaf
|
||||
--
|
||||
-- Family G already nests, so two attaches at opposite ends of it touch no workspace in common —
|
||||
-- their labels only collide once both land, three dev workspaces deep:
|
||||
-- prod-g (root), g-mid -> g-sub ('dev'), and the standalone `g-leaf`
|
||||
--
|
||||
-- Family H is a standalone that already owns a dev: archiving it resolves as "no pairing involved"
|
||||
-- while still being an operation the pairing lock has to cover:
|
||||
-- h-cand -> h-sub ('dev')
|
||||
|
||||
|
||||
INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) VALUES
|
||||
('tw-dev', 'dev of test-workspace', 'test@windmill.dev', 'test-workspace', true, 'dev'),
|
||||
('standalone', 'standalone', 'test@windmill.dev', NULL, false, NULL),
|
||||
('standalone-dev', 'dev of standalone', 'test@windmill.dev', 'standalone', true, 'dev'),
|
||||
('spare', 'spare standalone', 'test@windmill.dev', NULL, false, NULL),
|
||||
('prod-b', 'prod b', 'test@windmill.dev', NULL, false, NULL),
|
||||
('wm-fork-redev', 'redesignated fork', 'test@windmill.dev', 'prod-b', true, 'dev'),
|
||||
('redev-dev', 'dev of the redesignated fork', 'test@windmill.dev', 'wm-fork-redev', true, 'staging'),
|
||||
('prod-c', 'prod c', 'test@windmill.dev', NULL, false, NULL),
|
||||
('c-dev', 'dev of prod-c', 'test@windmill.dev', 'prod-c', true, 'dev'),
|
||||
('c-dev-dev', 'dev of c-dev', 'test@windmill.dev', 'c-dev', true, 'staging'),
|
||||
('prod-e', 'prod e', 'test@windmill.dev', NULL, false, NULL),
|
||||
('wm-fork-edev', 'redesignated fork with no dev yet', 'test@windmill.dev', 'prod-e', true, 'dev'),
|
||||
('e-cand', 'attach candidate', 'test@windmill.dev', NULL, false, NULL),
|
||||
('prod-f', 'prod f', 'test@windmill.dev', NULL, false, NULL),
|
||||
('f-mid', 'middle attach candidate', 'test@windmill.dev', NULL, false, NULL),
|
||||
('f-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL),
|
||||
('prod-g', 'prod g', 'test@windmill.dev', NULL, false, NULL),
|
||||
('g-mid', 'standalone with a dev of its own', 'test@windmill.dev', NULL, false, NULL),
|
||||
('g-sub', 'dev of g-mid', 'test@windmill.dev', 'g-mid', true, 'dev'),
|
||||
('g-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL),
|
||||
('h-cand', 'standalone owning a dev', 'test@windmill.dev', NULL, false, NULL),
|
||||
('h-sub', 'dev of h-cand', 'test@windmill.dev', 'h-cand', true, 'dev');
|
||||
|
||||
CREATE TEMP VIEW new_workspaces AS SELECT unnest(ARRAY[
|
||||
'tw-dev', 'standalone', 'standalone-dev', 'spare', 'prod-b', 'wm-fork-redev', 'redev-dev',
|
||||
'prod-c', 'c-dev', 'c-dev-dev', 'prod-e', 'wm-fork-edev', 'e-cand',
|
||||
'prod-f', 'f-mid', 'f-leaf', 'prod-g', 'g-mid', 'g-sub', 'g-leaf',
|
||||
'h-cand', 'h-sub'
|
||||
]) AS id;
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id)
|
||||
SELECT id FROM new_workspaces;
|
||||
|
||||
INSERT INTO workspace_key (workspace_id, kind, key)
|
||||
SELECT id, 'cloud', 'test-key' FROM new_workspaces;
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
|
||||
SELECT id, 'all', 'All users', '{}' FROM new_workspaces;
|
||||
|
||||
INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
SELECT id, 'test@windmill.dev', 'test-user', true, 'Admin'
|
||||
FROM new_workspaces;
|
||||
|
||||
DROP VIEW new_workspaces;
|
||||
@@ -0,0 +1,444 @@
|
||||
//! A dev workspace may itself be paired with one (a "dev of a dev"). The guards that keep that
|
||||
//! shape well-formed are what these tests pin: a chain must stay acyclic, and no two dev workspaces
|
||||
//! in it may carry the same environment label — they inherit the same git-sync repositories, so an
|
||||
//! equal label means both deploy to one branch.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
|
||||
async fn attach(port: u16, prod: &str, body: serde_json::Value) -> (reqwest::StatusCode, String) {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{prod}/workspaces/attach_dev_workspace"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {ADMIN_TOKEN}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
(status, resp.text().await.unwrap())
|
||||
}
|
||||
|
||||
async fn detach(port: u16, prod: &str, dev: &str) -> (reqwest::StatusCode, String) {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{prod}/workspaces/detach_dev_workspace"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {ADMIN_TOKEN}"))
|
||||
.json(&json!({ "dev_workspace_id": dev }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
(status, resp.text().await.unwrap())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_nested_dev_workspace_attach_guards(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The family root is an ancestor of the dev workspace: reparenting it below would close a
|
||||
// parent<->child cycle and hang every hierarchy walk.
|
||||
let (status, body) = attach(
|
||||
port,
|
||||
"tw-dev",
|
||||
json!({ "dev_workspace_id": "test-workspace", "dev_workspace_label": "staging" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"cycle attach returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("ancestor"), "unexpected error: {body}");
|
||||
|
||||
// `tw-dev` is itself the 'dev' workspace of the root, so a dev nested under it cannot be one too.
|
||||
let (status, body) = attach(
|
||||
port,
|
||||
"tw-dev",
|
||||
json!({ "dev_workspace_id": "spare", "dev_workspace_label": "dev" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"label reuse returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("tw-dev"), "unexpected error: {body}");
|
||||
|
||||
// `standalone` brings its own 'dev'-labelled dev workspace into the chain, which collides with
|
||||
// `tw-dev` whatever label the candidate itself is given.
|
||||
let (status, body) = attach(
|
||||
port,
|
||||
"tw-dev",
|
||||
json!({ "dev_workspace_id": "standalone", "dev_workspace_label": "staging" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"subtree label reuse returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("standalone-dev"), "unexpected error: {body}");
|
||||
|
||||
// With a free label and nothing conflicting underneath, the nested pairing goes through.
|
||||
let (status, body) = attach(
|
||||
port,
|
||||
"tw-dev",
|
||||
json!({ "dev_workspace_id": "spare", "dev_workspace_label": "staging" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"nested attach returned {status}: {body}"
|
||||
);
|
||||
// Runtime-checked (not `query!`): a macro here would need its own `.sqlx` entry, which
|
||||
// `cargo sqlx prepare --workspace` does not produce for test targets.
|
||||
let (parent, is_dev, label): (Option<String>, bool, Option<String>) = sqlx::query_as(
|
||||
"SELECT parent_workspace_id, is_dev_workspace, dev_workspace_label FROM workspace WHERE id = 'spare'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(parent.as_deref(), Some("tw-dev"));
|
||||
assert!(is_dev);
|
||||
assert_eq!(label.as_deref(), Some("staging"));
|
||||
|
||||
// A chain runs as deep as there are distinct labels to give it, not two:
|
||||
// `test-workspace` -> `tw-dev` ('dev') -> `spare` ('staging') -> `e-cand` ('uat').
|
||||
let (status, body) = attach(
|
||||
port,
|
||||
"spare",
|
||||
json!({ "dev_workspace_id": "e-cand", "dev_workspace_label": "uat" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"third-label attach returned {status}: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn archive(port: u16, w_id: &str) -> (reqwest::StatusCode, String) {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{w_id}/workspaces/archive"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {ADMIN_TOKEN}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
(status, resp.text().await.unwrap())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_teardown_refuses_to_strand_a_nested_dev(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// A `wm-fork-` workspace keeps its parent when it stops being a dev workspace, so it returns to
|
||||
// being a throwaway fork — which hosts no pairing, leaving `redev-dev` attached with no way to
|
||||
// reach it. Detach and archive both clear the flag, so both have to refuse.
|
||||
let (status, body) = detach(port, "prod-b", "wm-fork-redev").await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"stranding detach returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("redev-dev"), "unexpected error: {body}");
|
||||
|
||||
let (status, body) = archive(port, "wm-fork-redev").await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"stranding archive returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("redev-dev"), "unexpected error: {body}");
|
||||
|
||||
// Archive soft-deletes whatever the id looks like, so a prefix-less dev workspace strands its
|
||||
// own dev too — even though detaching that same workspace is fine (it returns to standalone).
|
||||
let (status, body) = archive(port, "c-dev").await;
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"prefix-less stranding archive returned {status}: {body}"
|
||||
);
|
||||
assert!(body.contains("c-dev-dev"), "unexpected error: {body}");
|
||||
let (status, body) = detach(port, "prod-c", "c-dev").await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"prefix-less detach returned {status}: {body}"
|
||||
);
|
||||
|
||||
// Bottom-up is the supported order.
|
||||
let (status, body) = detach(port, "wm-fork-redev", "redev-dev").await;
|
||||
assert!(status.is_success(), "leaf detach returned {status}: {body}");
|
||||
let (status, body) = detach(port, "prod-b", "wm-fork-redev").await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"detach after cleanup returned {status}: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Giving a workspace a dev and clearing its own dev flag each decide on state the other mutates, so
|
||||
/// checked outside a common lock both commit and leave `e-cand` under a throwaway fork. Fired
|
||||
/// together: whichever lands second must see the first and be rejected.
|
||||
///
|
||||
/// Repeated, because how far each handler gets before the other's mutation lands is timing-dependent
|
||||
/// — one pass caught an unlocked build only about a fifth of the time, and the runs are cheap.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_nested_attach_and_detach_cannot_both_commit(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for round in 0..12 {
|
||||
// Back to `prod-e -> wm-fork-edev ('dev')` with `e-cand` standalone, the state in which both
|
||||
// requests pass their own checks.
|
||||
sqlx::query(
|
||||
"UPDATE workspace SET parent_workspace_id = 'prod-e', is_dev_workspace = true,
|
||||
dev_workspace_label = 'dev' WHERE id = 'wm-fork-edev'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false,
|
||||
dev_workspace_label = NULL WHERE id = 'e-cand'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let (attached, detached) = tokio::join!(
|
||||
attach(
|
||||
port,
|
||||
"wm-fork-edev",
|
||||
json!({ "dev_workspace_id": "e-cand", "dev_workspace_label": "staging" }),
|
||||
),
|
||||
detach(port, "prod-e", "wm-fork-edev"),
|
||||
);
|
||||
assert!(
|
||||
attached.0.is_success() != detached.0.is_success(),
|
||||
"round {round}: exactly one must win, got attach={} detach={}\n{}\n{}",
|
||||
attached.0,
|
||||
detached.0,
|
||||
attached.1,
|
||||
detached.1
|
||||
);
|
||||
|
||||
// Whichever won, `wm-fork-edev` is never left a throwaway fork with a dev workspace beneath it.
|
||||
let (is_dev, cand_parent): (bool, Option<String>) = sqlx::query_as(
|
||||
"SELECT (SELECT is_dev_workspace FROM workspace WHERE id = 'wm-fork-edev'),
|
||||
(SELECT parent_workspace_id FROM workspace WHERE id = 'e-cand')",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
is_dev || cand_parent.is_none(),
|
||||
"round {round}: stranded — wm-fork-edev is_dev={is_dev}, e-cand parent={cand_parent:?}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two adjacent attaches — `f-mid` under `prod-f` and `f-leaf` under `f-mid` — each see a chain that
|
||||
/// does not yet contain the other's dev workspace, so both pass their label check. Committing both
|
||||
/// puts two `dev` workspaces in one chain, deploying to the same branch. Repeated for the same
|
||||
/// reason as the detach race above.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_adjacent_attaches_cannot_both_claim_a_label(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for round in 0..12 {
|
||||
sqlx::query(
|
||||
"UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false,
|
||||
dev_workspace_label = NULL WHERE id IN ('f-mid', 'f-leaf')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let (upper, lower) = tokio::join!(
|
||||
attach(
|
||||
port,
|
||||
"prod-f",
|
||||
json!({ "dev_workspace_id": "f-mid", "dev_workspace_label": "dev" }),
|
||||
),
|
||||
attach(
|
||||
port,
|
||||
"f-mid",
|
||||
json!({ "dev_workspace_id": "f-leaf", "dev_workspace_label": "dev" }),
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
upper.0.is_success() != lower.0.is_success(),
|
||||
"round {round}: exactly one must win, got upper={} lower={}\n{}\n{}",
|
||||
upper.0,
|
||||
lower.0,
|
||||
upper.1,
|
||||
lower.1
|
||||
);
|
||||
|
||||
// Never both: that is the chain prod-f -> f-mid('dev') -> f-leaf('dev').
|
||||
let chained: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM workspace mid
|
||||
JOIN workspace leaf ON leaf.parent_workspace_id = mid.id
|
||||
WHERE mid.id = 'f-mid' AND leaf.id = 'f-leaf'
|
||||
AND mid.is_dev_workspace AND leaf.is_dev_workspace
|
||||
AND mid.parent_workspace_id = 'prod-f'
|
||||
)",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(!chained, "round {round}: both attaches committed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attaching `g-mid` under `prod-g` and attaching `g-leaf` under `g-sub` touch no workspace in
|
||||
/// common — `g-sub` already sits under `g-mid`, so the two operations are two hops apart. Each sees
|
||||
/// a two-workspace chain with a free label; together they make a four-deep one that repeats
|
||||
/// `staging`. Locking the endpoints alone leaves them free to both commit, which is why the pairing
|
||||
/// lock covers every workspace its checks read.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_attaches_two_hops_apart_cannot_both_claim_a_label(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for round in 0..12 {
|
||||
sqlx::query(
|
||||
"UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false,
|
||||
dev_workspace_label = NULL WHERE id IN ('g-mid', 'g-leaf')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let (upper, lower) = tokio::join!(
|
||||
attach(
|
||||
port,
|
||||
"prod-g",
|
||||
json!({ "dev_workspace_id": "g-mid", "dev_workspace_label": "staging" }),
|
||||
),
|
||||
attach(
|
||||
port,
|
||||
"g-sub",
|
||||
json!({ "dev_workspace_id": "g-leaf", "dev_workspace_label": "staging" }),
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
upper.0.is_success() != lower.0.is_success(),
|
||||
"round {round}: exactly one must win, got upper={} lower={}\n{}\n{}",
|
||||
upper.0,
|
||||
lower.0,
|
||||
upper.1,
|
||||
lower.1
|
||||
);
|
||||
|
||||
// No chain may carry one label twice. Walk every dev workspace up to its root and count.
|
||||
let duplicated: Option<String> = sqlx::query_scalar(
|
||||
"WITH RECURSIVE chain AS (
|
||||
SELECT id AS leaf, id, parent_workspace_id, is_dev_workspace,
|
||||
COALESCE(dev_workspace_label, 'dev') AS label, 0 AS depth
|
||||
FROM workspace WHERE is_dev_workspace AND NOT deleted
|
||||
UNION ALL
|
||||
SELECT c.leaf, w.id, w.parent_workspace_id, w.is_dev_workspace,
|
||||
COALESCE(w.dev_workspace_label, 'dev'), c.depth + 1
|
||||
FROM workspace w JOIN chain c ON w.id = c.parent_workspace_id
|
||||
WHERE c.depth < 20
|
||||
)
|
||||
SELECT leaf FROM chain WHERE is_dev_workspace
|
||||
GROUP BY leaf, label HAVING count(*) > 1 LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
duplicated.is_none(),
|
||||
"round {round}: a chain repeats a label, below {duplicated:?}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pairing lock covers the chains an operation touches, not every pairing on the instance: a
|
||||
/// transaction holding one family's nodes must not hold up another family's. Pinned because the
|
||||
/// obvious way to make the races above safe — one key for the whole operation class — would serialize
|
||||
/// dev-workspace creation database-wide, and creation holds its transaction across a full clone.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_pairing_lock_does_not_span_unrelated_families(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Hold family F's nodes the way an in-flight attach on it would, then act on family E.
|
||||
let mut held = db.begin().await?;
|
||||
for node in ["prod-f", "f-mid", "f-leaf"] {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))")
|
||||
.bind(node)
|
||||
.execute(&mut *held)
|
||||
.await?;
|
||||
}
|
||||
// Also the un-suffixed key. Nothing takes it today, so holding it costs the passing case
|
||||
// nothing — but a lock narrowed back to one key for every family would take it, and without
|
||||
// this the test would sail through that exact regression.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing'))")
|
||||
.execute(&mut *held)
|
||||
.await?;
|
||||
|
||||
let (status, body) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
detach(port, "prod-e", "wm-fork-edev"),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("an unrelated family's pairing blocked on family F's locks"))?;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"unrelated detach returned {status}: {body}"
|
||||
);
|
||||
|
||||
held.rollback().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Archive resolves the workspace's pairing state before its transaction, so it takes the pairing
|
||||
/// lock before reading that state again rather than on the strength of it — an attach can be turning
|
||||
/// the workspace into a dev in the meantime. `h-cand` is standalone, the shape whose resolved state
|
||||
/// says no pairing is involved: its archive must wait on the lock all the same.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))]
|
||||
async fn test_archive_takes_the_pairing_lock_for_a_standalone_workspace(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let mut held = db.begin().await?;
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))")
|
||||
.bind("h-cand")
|
||||
.execute(&mut *held)
|
||||
.await?;
|
||||
|
||||
let finished =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), archive(port, "h-cand")).await;
|
||||
assert!(
|
||||
finished.is_err(),
|
||||
"archive of a standalone workspace completed while its pairing lock was held: {finished:?}"
|
||||
);
|
||||
|
||||
held.rollback().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -490,8 +490,8 @@ struct CreateWorkspaceFork {
|
||||
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
|
||||
#[serde(default)]
|
||||
copy_members: bool,
|
||||
/// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text +
|
||||
/// wording); ignored for non-dev forks. None defaults to 'dev'.
|
||||
/// Environment label for the dev workspace, e.g. 'dev' or 'staging': its badge text and the
|
||||
/// branch it deploys to. Ignored for non-dev forks. None defaults to 'dev'.
|
||||
#[serde(default)]
|
||||
dev_workspace_label: Option<String>,
|
||||
}
|
||||
@@ -712,16 +712,84 @@ struct DevWorkspaceInfo {
|
||||
dev_workspace_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev";
|
||||
/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it
|
||||
/// round-trips, but a NULL column is treated as 'dev' on the read side too.
|
||||
/// The environment labels a dev workspace may carry, ordered dev -> prod. Each names the git branch
|
||||
/// that workspace deploys to (`dev_workspace_branch`), and every dev workspace in a chain must
|
||||
/// carry a distinct one (`reject_dev_label_taken_in_chain`) — so the length of this list is also
|
||||
/// the deepest promotion chain. A fixed list rather than free text: the label has to be a usable
|
||||
/// single-segment branch name, must not collide with the `wm-fork/**` and `wm_deploy/**` namespaces
|
||||
/// git-sync already writes, and must not be a repository's default branch (`main`, `master`).
|
||||
pub const DEV_WORKSPACE_LABELS: [&str; 8] = [
|
||||
"dev", "qa", "test", "uat", "staging", "demo", "sandbox", "preprod",
|
||||
];
|
||||
|
||||
/// Normalize/validate the dev-workspace environment label. Unset defaults to 'dev', which is also
|
||||
/// what a NULL column reads as; any supplied value must be one of `DEV_WORKSPACE_LABELS` exactly,
|
||||
/// so the accepted set is what the OpenAPI enum advertises — no trimming, no empty-string alias.
|
||||
fn normalize_dev_workspace_label(label: Option<String>) -> Result<Option<String>> {
|
||||
match label.as_deref() {
|
||||
None | Some("dev") => Ok(Some("dev".to_string())),
|
||||
Some("staging") => Ok(Some("staging".to_string())),
|
||||
Some(other) => Err(Error::BadRequest(format!(
|
||||
"invalid dev workspace label '{other}' (expected 'dev' or 'staging')"
|
||||
))),
|
||||
let Some(label) = label else {
|
||||
return Ok(Some("dev".to_string()));
|
||||
};
|
||||
if !DEV_WORKSPACE_LABELS.contains(&label.as_str()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"invalid dev workspace label '{label}' (expected one of: {})",
|
||||
DEV_WORKSPACE_LABELS.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(Some(label))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dev_workspace_label_tests {
|
||||
use super::{normalize_dev_workspace_label, tracked_branch_blocks_dev_label};
|
||||
|
||||
#[test]
|
||||
fn tracked_branch_blocks_its_own_name_and_its_namespace() {
|
||||
assert!(tracked_branch_blocks_dev_label("uat", "uat"));
|
||||
// The label would have to be a ref and a ref directory at once.
|
||||
assert!(tracked_branch_blocks_dev_label("release", "release/main"));
|
||||
assert!(!tracked_branch_blocks_dev_label("release", "release-main"));
|
||||
assert!(!tracked_branch_blocks_dev_label("release", "main"));
|
||||
assert!(!tracked_branch_blocks_dev_label("uat", "pre/uat"));
|
||||
}
|
||||
|
||||
fn norm(label: &str) -> Option<String> {
|
||||
normalize_dev_workspace_label(Some(label.to_string()))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_defaults_to_dev() {
|
||||
assert_eq!(
|
||||
normalize_dev_workspace_label(None).unwrap().as_deref(),
|
||||
Some("dev")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_every_offered_label_and_nothing_else() {
|
||||
for label in super::DEV_WORKSPACE_LABELS {
|
||||
assert_eq!(norm(label).as_deref(), Some(label), "rejected '{label}'");
|
||||
}
|
||||
// Off-list names are refused whether or not they would make a usable branch: the list is
|
||||
// what keeps a label off `main`/`master` and out of the `wm-fork/**` and `wm_deploy/**`
|
||||
// namespaces git-sync writes. Padded and empty values are refused too, so the accepted set
|
||||
// is exactly the OpenAPI enum rather than a superset a validating client would reject.
|
||||
for label in [
|
||||
" uat ",
|
||||
"",
|
||||
"main",
|
||||
"master",
|
||||
"wm-fork",
|
||||
"wm_deploy",
|
||||
"UAT",
|
||||
"feature/uat",
|
||||
] {
|
||||
assert!(
|
||||
normalize_dev_workspace_label(Some(label.to_string())).is_err(),
|
||||
"accepted '{label}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,11 +863,23 @@ fn clear_client_supplied_auto_pull_state(
|
||||
auto_pull.last_pull_status = None;
|
||||
}
|
||||
|
||||
/// A dev workspace deploys to a branch named after its environment label. If a
|
||||
/// git-sync repository's tracked branch carries that same name, dev deploys
|
||||
/// would write straight into the branch the workspace (or its prod) syncs
|
||||
/// from — the CLI refuses that push, so every deploy job would fail. Reject
|
||||
/// the label up front instead.
|
||||
/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's
|
||||
/// deploy branch. Two ways it can:
|
||||
///
|
||||
/// - the same name: dev deploys would write straight into the branch the workspace (or its prod)
|
||||
/// syncs from, and the CLI refuses that push;
|
||||
/// - `label_branch` is the namespace `tracked` sits under (label `release`, tracked
|
||||
/// `release/main`): git stores refs hierarchically, so `refs/heads/release` cannot exist
|
||||
/// alongside `refs/heads/release/main`.
|
||||
///
|
||||
/// Either way every deploy job from that workspace would fail.
|
||||
fn tracked_branch_blocks_dev_label(label_branch: &str, tracked: &str) -> bool {
|
||||
tracked == label_branch || tracked.starts_with(&format!("{label_branch}/"))
|
||||
}
|
||||
|
||||
/// Reject a label whose branch clashes with a tracked branch of any git-sync repository on
|
||||
/// `workspace_ids`, before the pairing is created. (`wm-fork` and `wm_deploy`, whose namespaces
|
||||
/// exist whatever a repo tracks, are reserved unconditionally in `normalize_dev_workspace_label`.)
|
||||
async fn reject_dev_label_matching_tracked_branch(
|
||||
db: &DB,
|
||||
label: Option<&str>,
|
||||
@@ -827,14 +907,31 @@ async fn reject_dev_label_matching_tracked_branch(
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
if branch.as_deref() == Some(label_branch.as_str()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
// A repository that pins no branch tracks the remote's default, which cannot be
|
||||
// resolved here without a network call — but no offered label is a plausible default
|
||||
// (`main`/`master` are off the list), so there is nothing to compare against.
|
||||
let Some(tracked) = branch.as_deref().filter(|b| !b.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if !tracked_branch_blocks_dev_label(&label_branch, tracked) {
|
||||
continue;
|
||||
}
|
||||
return Err(Error::BadRequest(if tracked == label_branch {
|
||||
format!(
|
||||
"The environment label '{label_branch}' matches the tracked branch of git-sync \
|
||||
repository '{path}' in workspace '{w_id}': deploys from the dev workspace go \
|
||||
to the '{label_branch}' branch and would overwrite the branch that repository \
|
||||
syncs from. Use the other label or change the repository's tracked branch."
|
||||
)));
|
||||
}
|
||||
syncs from. Use a different label or change the repository's branch."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"The environment label '{label_branch}' is the namespace of the tracked branch \
|
||||
'{tracked}' of git-sync repository '{path}' in workspace '{w_id}': git cannot \
|
||||
hold a branch named '{label_branch}' alongside '{tracked}', so every deploy \
|
||||
from the dev workspace would fail. Use a different label or change the \
|
||||
repository's branch."
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -4910,11 +5007,17 @@ struct SessionWorkspaceStatusRequest {
|
||||
|
||||
/// Reconciliation support for client-side AI sessions, which the backend cannot touch
|
||||
/// directly. The client posts the workspace ids its sessions reference and uses the
|
||||
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row /
|
||||
/// no access → unresolvable) drops the sessions, `archived` (soft-deleted, still a
|
||||
/// member) archives them, `active` restores ones previously archived-by-workspace.
|
||||
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row, or
|
||||
/// no way for this caller to reach it) drops the sessions, `archived` (soft-deleted, still
|
||||
/// reachable) archives them, `active` restores ones previously archived-by-workspace.
|
||||
/// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the
|
||||
/// only way the client learns about a change made while it was away or on another device.
|
||||
///
|
||||
/// Membership alone under-reports reachability: a superadmin is authed into any existing
|
||||
/// workspace without a `usr` row, and `admins` has no `usr` rows at all, so answering from
|
||||
/// `usr` destroys sessions that still work. It over-reports in one direction — a `usr` row
|
||||
/// with `disabled` counts here but not in the extractor — which only leaves a session
|
||||
/// lingering, so it is deliberately not treated as unreachable.
|
||||
async fn session_workspace_status(
|
||||
Extension(db): Extension<DB>,
|
||||
ApiAuthed { email, .. }: ApiAuthed,
|
||||
@@ -4925,10 +5028,15 @@ async fn session_workspace_status(
|
||||
"Too many workspace ids (max 1000)".to_string(),
|
||||
));
|
||||
}
|
||||
let is_superadmin = windmill_common::auth::is_super_admin_email(&db, &email).await?;
|
||||
let rows = sqlx::query!(
|
||||
// A missing workspace row must be caught before the membership arm: for a
|
||||
// superadmin the two arms below both fall through, and a hard-deleted workspace
|
||||
// would report `active` forever.
|
||||
"SELECT req.id AS \"id!\",
|
||||
(CASE
|
||||
WHEN usr.email IS NULL THEN 'deleted'
|
||||
WHEN workspace.id IS NULL THEN 'deleted'
|
||||
WHEN usr.email IS NULL AND NOT $3 THEN 'deleted'
|
||||
WHEN workspace.deleted THEN 'archived'
|
||||
ELSE 'active'
|
||||
END) AS \"status!\"
|
||||
@@ -4937,6 +5045,7 @@ async fn session_workspace_status(
|
||||
LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||||
&req.workspace_ids[..],
|
||||
email,
|
||||
is_superadmin,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
@@ -6687,10 +6796,18 @@ async fn create_workspace_fork_branch(
|
||||
// that second call. Validating early lets a bad request fail before any branch is created.
|
||||
if nw.is_dev_workspace {
|
||||
validate_dev_workspace_id(&nw.id)?;
|
||||
// Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork).
|
||||
// Reject a bad label before any git branch is created (acted on in create_workspace_fork).
|
||||
let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
|
||||
reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&w_id]).await?;
|
||||
ensure_dev_parent_is_root(&db, &w_id).await?;
|
||||
ensure_dev_parent_can_host_dev(&db, &w_id).await?;
|
||||
reject_dev_label_taken_in_chain(
|
||||
&mut *db.acquire().await?,
|
||||
&w_id,
|
||||
&nw.id,
|
||||
false,
|
||||
label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
// Reject before creating any git branch if the parent already has a dev workspace,
|
||||
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
|
||||
ensure_no_existing_dev_workspace(&db, &w_id).await?;
|
||||
@@ -7006,11 +7123,19 @@ async fn create_workspace_fork(
|
||||
validate_fork_workspace_id(&nw.id)?;
|
||||
}
|
||||
validate_workspace_name(&nw.name)?;
|
||||
// Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL.
|
||||
// The environment label only applies to dev workspaces; a non-dev fork stores NULL.
|
||||
let dev_workspace_label = if nw.is_dev_workspace {
|
||||
let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
|
||||
reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&parent_workspace_id])
|
||||
.await?;
|
||||
reject_dev_label_taken_in_chain(
|
||||
&mut *db.acquire().await?,
|
||||
&parent_workspace_id,
|
||||
&nw.id,
|
||||
false,
|
||||
label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
label
|
||||
} else {
|
||||
None
|
||||
@@ -7077,7 +7202,7 @@ async fn create_workspace_fork(
|
||||
}
|
||||
|
||||
if nw.is_dev_workspace {
|
||||
ensure_dev_parent_is_root(&db, &parent_workspace_id).await?;
|
||||
ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?;
|
||||
// Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod
|
||||
// mutates its protection rules), so require admin of the parent regardless of the lock flags —
|
||||
// mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could
|
||||
@@ -7088,6 +7213,22 @@ async fn create_workspace_fork(
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
if nw.is_dev_workspace {
|
||||
// The checks above ran outside a transaction, so the parent's eligibility and the chain's
|
||||
// labels could have changed under us: re-decide both here, under the pairing lock.
|
||||
lock_dev_pairing(&mut tx, &[&parent_workspace_id]).await?;
|
||||
ensure_dev_parent_can_host_dev(&mut *tx, &parent_workspace_id).await?;
|
||||
ensure_no_existing_dev_workspace(&mut *tx, &parent_workspace_id).await?;
|
||||
reject_dev_label_taken_in_chain(
|
||||
&mut *tx,
|
||||
&parent_workspace_id,
|
||||
&nw.id,
|
||||
false,
|
||||
dev_workspace_label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let forked_id = nw.id;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -7264,7 +7405,7 @@ struct AttachDevWorkspace {
|
||||
lock_prod_deploy: bool,
|
||||
#[serde(default)]
|
||||
lock_prod_forking: bool,
|
||||
/// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'.
|
||||
/// Environment label for the attached dev workspace, e.g. 'dev' or 'staging'. None defaults to 'dev'.
|
||||
#[serde(default)]
|
||||
dev_workspace_label: Option<String>,
|
||||
}
|
||||
@@ -7321,15 +7462,6 @@ async fn attach_dev_workspace(
|
||||
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
|
||||
validate_dev_workspace_id(&dev_w_id)?;
|
||||
let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?;
|
||||
// The attached workspace keeps its own sync repos and prod keeps its config;
|
||||
// the label branch must not collide with either side's tracked branch.
|
||||
reject_dev_label_matching_tracked_branch(
|
||||
&db,
|
||||
dev_workspace_label.as_deref(),
|
||||
&[&prod_w_id, &dev_w_id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let dev = sqlx::query!(
|
||||
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
|
||||
&dev_w_id
|
||||
@@ -7358,24 +7490,41 @@ async fn attach_dev_workspace(
|
||||
dev_w_id
|
||||
)));
|
||||
}
|
||||
// The candidate can't itself be a prod with its own dev workspace (no nested dev chains).
|
||||
ensure_no_existing_dev_workspace(&db, &dev_w_id).await?;
|
||||
|
||||
// Prod must be a root workspace, otherwise attaching could form a parent<->child cycle (e.g.
|
||||
// attaching A as the dev of B when B is already the dev of A), which breaks hierarchy traversal.
|
||||
let prod_has_parent = sqlx::query_scalar!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#,
|
||||
let prod_exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS "exists!""#,
|
||||
&prod_w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", prod_w_id)))?;
|
||||
if prod_has_parent {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is itself a fork or dev workspace and cannot be a prod workspace",
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
if !prod_exists {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Workspace {} not found",
|
||||
prod_w_id
|
||||
)));
|
||||
}
|
||||
// Prod may be a root workspace or another dev workspace (a dev of a dev); a throwaway fork
|
||||
// can't host one.
|
||||
ensure_dev_parent_can_host_dev(&db, &prod_w_id).await?;
|
||||
reject_attach_cycle(&db, &prod_w_id, &dev_w_id).await?;
|
||||
|
||||
// The attached workspace keeps its own sync repos and prod keeps its config; the label branch
|
||||
// must not collide with either side's tracked branch.
|
||||
reject_dev_label_matching_tracked_branch(
|
||||
&db,
|
||||
dev_workspace_label.as_deref(),
|
||||
&[&prod_w_id, &dev_w_id],
|
||||
)
|
||||
.await?;
|
||||
// The candidate keeps its own subtree, so its dev descendants keep their labels and join the
|
||||
// chain alongside it.
|
||||
reject_dev_label_taken_in_chain(
|
||||
&mut *db.acquire().await?,
|
||||
&prod_w_id,
|
||||
&dev_w_id,
|
||||
true,
|
||||
dev_workspace_label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The caller must be admin of the dev workspace too (or a superadmin).
|
||||
let is_admin_of_dev = sqlx::query_scalar!(
|
||||
@@ -7392,9 +7541,47 @@ async fn attach_dev_workspace(
|
||||
)));
|
||||
}
|
||||
|
||||
ensure_no_existing_dev_workspace(&db, &prod_w_id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
// Everything above ran outside a transaction, so prod's eligibility and the chain's labels could
|
||||
// have changed under us: re-decide both here, under the pairing lock.
|
||||
lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?;
|
||||
// The candidate was read before the lock, and archiving it is one of the operations the lock
|
||||
// serializes: re-read it, or the pairing lands on a workspace that is gone or has since been
|
||||
// taken by another prod.
|
||||
let dev = sqlx::query!(
|
||||
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
|
||||
&dev_w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?;
|
||||
if dev.deleted {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is archived",
|
||||
dev_w_id
|
||||
)));
|
||||
}
|
||||
if dev
|
||||
.parent_workspace_id
|
||||
.as_deref()
|
||||
.is_some_and(|p| p != prod_w_id)
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is already a fork or dev workspace of another workspace",
|
||||
dev_w_id
|
||||
)));
|
||||
}
|
||||
ensure_dev_parent_can_host_dev(&mut *tx, &prod_w_id).await?;
|
||||
reject_attach_cycle(&mut *tx, &prod_w_id, &dev_w_id).await?;
|
||||
ensure_no_existing_dev_workspace(&mut *tx, &prod_w_id).await?;
|
||||
reject_dev_label_taken_in_chain(
|
||||
&mut *tx,
|
||||
&prod_w_id,
|
||||
&dev_w_id,
|
||||
true,
|
||||
dev_workspace_label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
|
||||
&prod_w_id,
|
||||
@@ -7566,6 +7753,11 @@ async fn detach_dev_workspace(
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let dev_w_id = req.dev_workspace_id;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
// Under the pairing lock, so a dev workspace cannot appear beneath this one between the check
|
||||
// below and the update.
|
||||
lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?;
|
||||
let is_dev_of_prod = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM workspace
|
||||
@@ -7574,7 +7766,7 @@ async fn detach_dev_workspace(
|
||||
&dev_w_id,
|
||||
&prod_w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_dev_of_prod {
|
||||
@@ -7583,8 +7775,8 @@ async fn detach_dev_workspace(
|
||||
dev_w_id, prod_w_id
|
||||
)));
|
||||
}
|
||||
reject_stranding_nested_dev(&mut *tx, &dev_w_id, DevTeardown::Detach).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
// A wm-fork- workspace re-designated as dev returns to being a plain fork
|
||||
// (keeps its parent); a standalone workspace that was attached returns to
|
||||
// being standalone — with the parent kept it would still classify as a
|
||||
@@ -7692,6 +7884,30 @@ pub(crate) async fn archive_workspace_impl(
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
// Step 1: Disable all schedules and clear their queued jobs
|
||||
let mut tx = db.begin().await?;
|
||||
// Unconditionally, before reading any pairing state: whether this workspace is a dev, and whether
|
||||
// it has one, is exactly what a concurrent attach changes, so gating the lock on the caller's
|
||||
// `dev_lock_parent` would skip it on the strength of the value the race invalidates.
|
||||
lock_dev_pairing(&mut tx, &[w_id]).await?;
|
||||
let dev_parent = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
// The caller resolved this before the lock and authorized against it — its prod admin check, and
|
||||
// the pairing teardown below, are both answers to that value. Refuse rather than act on a pairing
|
||||
// nobody checked.
|
||||
if dev_parent.as_deref() != dev_lock_parent {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"The dev pairing of {w_id} changed while it was being archived. Retry."
|
||||
)));
|
||||
}
|
||||
if dev_parent.is_some() {
|
||||
// Archiving a dev workspace clears its dev flag, so it must not strand a dev workspace of
|
||||
// its own.
|
||||
reject_stranding_nested_dev(&mut *tx, w_id, DevTeardown::Archive).await?;
|
||||
}
|
||||
let disabled_schedules = sqlx::query_scalar!(
|
||||
"UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path",
|
||||
w_id
|
||||
@@ -7830,7 +8046,9 @@ async fn archive_workspace(
|
||||
}
|
||||
|
||||
// The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's
|
||||
// transaction, atomically with `deleted = true`.
|
||||
// transaction, atomically with `deleted = true` — including the guard that it strands no nested
|
||||
// dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a
|
||||
// root archived out from under its dev is the pre-existing shape and not this pairing's to police.
|
||||
let (schedules_count, canceled_count, deleted_tokens_count) =
|
||||
archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?;
|
||||
|
||||
@@ -8885,7 +9103,10 @@ async fn lock_prod_workspace(
|
||||
|
||||
/// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the
|
||||
/// partial unique index `workspace_canonical_dev_idx` with a friendly message.
|
||||
async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<()> {
|
||||
async fn ensure_no_existing_dev_workspace<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||||
db: E,
|
||||
parent_w_id: &str,
|
||||
) -> Result<()> {
|
||||
let existing = sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||||
parent_w_id
|
||||
@@ -8901,22 +9122,263 @@ async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dev workspace pairs with a root prod workspace; nesting dev workspaces (a dev of a dev) isn't
|
||||
/// supported and would muddle the prod<->dev relationship.
|
||||
async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> {
|
||||
let parent_is_fork = sqlx::query_scalar!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#,
|
||||
/// A dev workspace pairs with a root workspace or — supported, though not the recommended shape —
|
||||
/// with another dev workspace, giving a promotion chain (dev of dev -> dev -> prod). A throwaway
|
||||
/// fork is never a valid prod: its deploys go to its own `wm-fork/**` branch and it is discarded
|
||||
/// with its subtree, so a dev pinned under it has nowhere to promote to. Nor is an archived one,
|
||||
/// which hosts nothing at all.
|
||||
async fn ensure_dev_parent_can_host_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||||
db: E,
|
||||
parent_w_id: &str,
|
||||
) -> Result<()> {
|
||||
let parent = sqlx::query!(
|
||||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!", is_dev_workspace, deleted
|
||||
FROM workspace WHERE id = $1"#,
|
||||
parent_w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if parent_is_fork {
|
||||
.await?;
|
||||
let Some(parent) = parent else {
|
||||
return Ok(());
|
||||
};
|
||||
if parent.deleted {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Cannot create a dev workspace of '{}' because it is itself a fork or dev workspace.",
|
||||
"Cannot create a dev workspace of '{}' because it is archived.",
|
||||
parent_w_id
|
||||
)));
|
||||
}
|
||||
if parent.is_fork && !parent.is_dev_workspace {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Cannot create a dev workspace of '{}' because it is a throwaway fork.",
|
||||
parent_w_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prod may be a dev workspace, so the candidate can sit ABOVE it in the tree — reparenting it below
|
||||
/// prod would close a parent<->child cycle and hang every hierarchy walk. Prod itself is at depth 0
|
||||
/// of the chain, so callers must have rejected `dev_w_id == prod_w_id` first.
|
||||
///
|
||||
/// `reject_dev_label_taken_in_chain` would also reject a cyclic pairing, since a cycle puts one
|
||||
/// workspace in the chain twice and so always repeats a label. It reports it as a workspace clashing
|
||||
/// with itself, which describes nothing the caller can act on — hence this, first.
|
||||
async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||||
db: E,
|
||||
prod_w_id: &str,
|
||||
dev_w_id: &str,
|
||||
) -> Result<()> {
|
||||
let would_cycle = sqlx::query_scalar!(
|
||||
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 EXISTS(SELECT 1 FROM chain WHERE id = $2) AS "cycle!""#,
|
||||
prod_w_id,
|
||||
dev_w_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if would_cycle {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {} is an ancestor of {} and cannot become its dev workspace",
|
||||
dev_w_id, prod_w_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize everything that makes or breaks a dev pairing: giving a workspace a dev workspace
|
||||
/// (create, attach) and clearing one's dev flag (detach, archive). Each decides on state the others
|
||||
/// mutate — whether a workspace already has a dev, still has one, or leaves a label free — so
|
||||
/// unserialized they all pass their checks and commit a shape those checks exist to reject. Take it
|
||||
/// before re-running them inside the mutating transaction; it releases on commit or rollback.
|
||||
///
|
||||
/// Locks every workspace the operation's own checks read: each seed, its ancestors, and the dev
|
||||
/// workspaces beneath it. Locking just the endpoints is not enough — the label rule spans a whole
|
||||
/// chain, so two operations a couple of hops apart would hold disjoint keys and both commit. Reading
|
||||
/// the same set that is checked is what closes that: an attach splices two chains together and so
|
||||
/// holds nodes from both, and any operation that could collide with it necessarily touches the
|
||||
/// joined chain, hence shares a node. Acquired in id order, the only ordering rule that keeps two
|
||||
/// overlapping sets from deadlocking.
|
||||
///
|
||||
/// Recomputed inside the transaction, but from a set that may already be stale — harmless, because
|
||||
/// whoever made it stale is the operation holding the node this one is missing.
|
||||
pub(crate) async fn lock_dev_pairing(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
seeds: &[&str],
|
||||
) -> Result<()> {
|
||||
let seeds: Vec<String> = seeds.iter().map(|s| s.to_string()).collect();
|
||||
// Depth bounds are the cycle-safety backstop used by every other hierarchy walk.
|
||||
let nodes = sqlx::query_scalar!(
|
||||
r#"WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id),
|
||||
up AS (
|
||||
SELECT w.id, w.parent_workspace_id, 0 AS depth
|
||||
FROM workspace w JOIN seeded s ON w.id = s.id
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_workspace_id, up.depth + 1
|
||||
FROM workspace w JOIN up ON w.id = up.parent_workspace_id
|
||||
WHERE up.depth < 20
|
||||
),
|
||||
down AS (
|
||||
SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id
|
||||
UNION ALL
|
||||
SELECT w.id, down.depth + 1
|
||||
FROM workspace w JOIN down ON w.parent_workspace_id = down.id
|
||||
WHERE down.depth < 20 AND w.is_dev_workspace
|
||||
)
|
||||
SELECT id AS "id!" FROM (
|
||||
SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down
|
||||
) n ORDER BY id"#,
|
||||
&seeds[..]
|
||||
)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
// One statement per node rather than a set-returning call: only a client-side loop actually
|
||||
// guarantees the acquisition order the deadlock argument above rests on.
|
||||
for node in nodes {
|
||||
sqlx::query!(
|
||||
"SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))",
|
||||
node
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What is about to clear `is_dev_workspace` on a workspace, which decides whether the workspace can
|
||||
/// go on hosting a dev workspace of its own afterwards.
|
||||
enum DevTeardown {
|
||||
/// Keeps `parent_workspace_id` only for a `wm-fork-` workspace, which then reads as a throwaway
|
||||
/// fork. A prefix-less workspace returns to standalone and hosts its dev exactly as before.
|
||||
Detach,
|
||||
/// Soft-deletes the workspace whatever its id looks like, so it hosts nothing afterwards.
|
||||
Archive,
|
||||
}
|
||||
|
||||
/// A nested dev workspace outlives whatever clears its parent's dev flag, and the parent is then a
|
||||
/// shape that hosts no pairing: its settings tab offers no detach control, and `delete_workspace`
|
||||
/// refuses a workspace that still has a dev child, so the pairing could never be undone. Reject the
|
||||
/// teardown so it is done bottom-up instead.
|
||||
async fn reject_stranding_nested_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||||
db: E,
|
||||
w_id: &str,
|
||||
teardown: DevTeardown,
|
||||
) -> Result<()> {
|
||||
let action = match teardown {
|
||||
DevTeardown::Detach => {
|
||||
if !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
"Detaching"
|
||||
}
|
||||
DevTeardown::Archive => "Archiving",
|
||||
};
|
||||
let nested = sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace
|
||||
WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if let Some(nested) = nested {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{action} {w_id} would leave it unable to host a pairing, but it is the prod workspace \
|
||||
of '{nested}'. Detach '{nested}' first."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A dev workspace deploys to the branch named by its environment label, and every dev workspace in
|
||||
/// a chain inherits the same git-sync repositories, so two of them sharing a label push to one
|
||||
/// branch: each deploy clobbers the other environment, and the root's auto-pull routes that branch
|
||||
/// to whichever dev it matches first. Require every dev workspace in the resulting chain to carry a
|
||||
/// distinct label — the dev ancestors `new_dev_id` lands under, `new_dev_id` with `label`, and (when
|
||||
/// it already exists and so keeps its own subtree) the dev workspaces it brings with it. Dev
|
||||
/// workspaces only ever hang off a root or another dev (`ensure_dev_parent_can_host_dev`), so that
|
||||
/// chain is linear and this is the whole of it.
|
||||
async fn reject_dev_label_taken_in_chain(
|
||||
db: &mut sqlx::PgConnection,
|
||||
parent_w_id: &str,
|
||||
new_dev_id: &str,
|
||||
keeps_own_subtree: bool,
|
||||
label: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Depth bounds are the cycle-safety backstop used by every other hierarchy walk.
|
||||
let mut chain = sqlx::query!(
|
||||
r#"WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted,
|
||||
0 AS depth
|
||||
FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,
|
||||
w.deleted, ancestors.depth + 1
|
||||
FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id
|
||||
WHERE ancestors.depth < 20
|
||||
)
|
||||
SELECT id AS "id!", dev_workspace_label FROM ancestors
|
||||
WHERE is_dev_workspace AND NOT deleted"#,
|
||||
parent_w_id
|
||||
)
|
||||
.fetch_all(&mut *db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.id,
|
||||
windmill_common::workspaces::dev_workspace_branch(r.dev_workspace_label.as_deref()),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
chain.push((
|
||||
new_dev_id.to_string(),
|
||||
windmill_common::workspaces::dev_workspace_branch(label),
|
||||
));
|
||||
if keeps_own_subtree {
|
||||
// `depth > 0`: the candidate itself is already in the list above, carrying its new label.
|
||||
chain.extend(
|
||||
sqlx::query!(
|
||||
r#"WITH RECURSIVE tree AS (
|
||||
SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth
|
||||
FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,
|
||||
tree.depth + 1
|
||||
FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id
|
||||
WHERE tree.depth < 20
|
||||
)
|
||||
SELECT id AS "id!", dev_workspace_label FROM tree
|
||||
WHERE depth > 0 AND is_dev_workspace AND NOT deleted"#,
|
||||
new_dev_id
|
||||
)
|
||||
.fetch_all(&mut *db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.id,
|
||||
windmill_common::workspaces::dev_workspace_branch(
|
||||
r.dev_workspace_label.as_deref(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let mut by_branch: HashMap<String, String> = HashMap::new();
|
||||
for (id, branch) in chain {
|
||||
if let Some(other) = by_branch.insert(branch.clone(), id.clone()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{other}' and '{id}' would both be '{branch}' workspaces in the same chain: dev \
|
||||
workspaces in a chain share their git-sync repositories, so both would deploy to \
|
||||
the '{branch}' branch. Use a different environment label."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ pub(crate) async fn change_workspace_id(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// A rename rewrites the workspace's dev flag and reparents its children, so it decides on the
|
||||
// same state the pairing handlers do: without this lock a concurrent create/attach could commit
|
||||
// an active dev workspace under the shell this rename is about to archive. Both ids, since the
|
||||
// rename moves the chain from one to the other.
|
||||
crate::workspaces::lock_dev_pairing(&mut tx, &[&old_id, &rw.new_id]).await?;
|
||||
|
||||
check_w_id_conflict(&mut tx, &rw.new_id).await?;
|
||||
|
||||
info!(
|
||||
|
||||
@@ -11,7 +11,7 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-amqp?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private", "windmill-object-store/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-debug/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"]
|
||||
stripe = []
|
||||
run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"]
|
||||
agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.780.0
|
||||
version: 1.782.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1239,7 +1239,8 @@ paths:
|
||||
type: boolean
|
||||
dev_workspace_label:
|
||||
type: string
|
||||
enum: [dev, staging]
|
||||
enum: [dev, qa, test, uat, staging, demo, sandbox, preprod]
|
||||
description: "Environment label; also names the branch the dev workspace deploys to. Omitted defaults to 'dev'"
|
||||
required:
|
||||
- dev_workspace_id
|
||||
responses:
|
||||
@@ -1301,7 +1302,7 @@ paths:
|
||||
dev_workspace_label:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'"
|
||||
description: "Environment label, e.g. 'dev' or 'staging'; null defaults to 'dev'"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -10698,6 +10699,33 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/job_public_view_token/{id}:
|
||||
get:
|
||||
summary: mint a public read-only share token for a job
|
||||
description: >
|
||||
Returns a stateless `{job_id}.{hmac}` token that grants anyone holding it —
|
||||
including logged-out visitors, who land on the minimal public run page — read
|
||||
access to this job (and its flow subtree) via a `view_token` query param or
|
||||
`X-View-Token` header. Only callable by a user who can already read the job.
|
||||
operationId: getJobPublicViewToken
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: the public share read token
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/list_paths:
|
||||
get:
|
||||
summary: list all flow paths
|
||||
@@ -31115,7 +31143,7 @@ components:
|
||||
dev_workspace_label:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
|
||||
description: "Environment label of the dev workspace, e.g. 'dev' or 'staging'; null defaults to 'dev'"
|
||||
created_by:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -31191,8 +31219,8 @@ components:
|
||||
description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it"
|
||||
dev_workspace_label:
|
||||
type: string
|
||||
enum: [dev, staging]
|
||||
description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks"
|
||||
enum: [dev, qa, test, uat, staging, demo, sandbox, preprod]
|
||||
description: "Environment label for the dev workspace: its badge text and the branch it deploys to. Ignored for non-dev forks. Omitted defaults to 'dev'"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -31222,7 +31250,7 @@ components:
|
||||
dev_workspace_label:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
|
||||
description: "Environment label of the dev workspace, e.g. 'dev' or 'staging'; null defaults to 'dev'"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -33408,6 +33436,7 @@ components:
|
||||
- DisableWorkspaceForking
|
||||
- RestrictDeployToDeployers
|
||||
- RestrictAnonymousAppDeployment
|
||||
- RestrictPublicRunSharing
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
|
||||
+366
-263
@@ -51,6 +51,7 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
|
||||
use windmill_common::workspace_dependencies::{
|
||||
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
|
||||
};
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::DYNAMIC_INPUT_CACHE;
|
||||
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
|
||||
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
|
||||
@@ -367,6 +368,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/job_view_token/{id}",
|
||||
get(get_job_view_token).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/job_public_view_token/{id}",
|
||||
get(get_job_public_view_token).layer(cors.clone()),
|
||||
)
|
||||
.route("/run/dependencies", post(run_dependencies_job))
|
||||
.route("/run/dependencies_async", post(run_dependencies_job_async))
|
||||
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
|
||||
@@ -500,7 +505,63 @@ async fn get_job_view_token(
|
||||
// enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't
|
||||
// mint a transferable link for a job outside its allowed tags.
|
||||
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
|
||||
let hmac = generate_view_token(&w_id, id, &db).await?;
|
||||
let hmac = generate_view_token(&w_id, id, VIEW_TOKEN_DOMAIN, &db).await?;
|
||||
Ok(format!("{id}.{hmac}"))
|
||||
}
|
||||
|
||||
/// Public flavor of [`get_job_view_token`]: the resulting link additionally grants read of
|
||||
/// the job (and its flow subtree) to logged-out visitors, who land on the minimal public
|
||||
/// run page. Read access is necessary but not sufficient — exposing workspace data to the
|
||||
/// anonymous internet is a privileged action, gated like an app's anonymous execution mode.
|
||||
async fn get_job_public_view_token(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::Result<String> {
|
||||
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
|
||||
|
||||
// Anonymous readers of a public link carry no scope to confine, so the link would
|
||||
// reach out-of-scope descendants of an in-scope job (mixed-tag flow trees). A tag
|
||||
// scope is a hard restriction: refuse rather than silently narrow the link.
|
||||
if get_scope_tags(&authed).is_some() {
|
||||
return Err(Error::PermissionDenied(
|
||||
"A tag-scoped token cannot share a run publicly: the resulting link is read \
|
||||
anonymously and could not carry the tag restriction to the run's steps."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::RestrictPublicRunSharing,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let hmac = generate_view_token(&w_id, id, PUBLIC_VIEW_TOKEN_DOMAIN, &db).await?;
|
||||
|
||||
// The link is stateless and permanent, so the mint is the only moment this is
|
||||
// observable: audit it unconditionally rather than through the opt-in job-view log.
|
||||
let mut tx = db.begin().await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&AuditAuthor::from(&authed),
|
||||
"jobs.share_publicly",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("{id}.{hmac}"))
|
||||
}
|
||||
|
||||
@@ -1318,7 +1379,7 @@ struct GetJobQuery {
|
||||
/// job UUID, even though the same job is hidden from them in `jobs/list`
|
||||
/// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read)
|
||||
///
|
||||
/// Unauthenticated callers are still handled by each handler's anonymous-job check;
|
||||
/// Unauthenticated callers go through [`require_unauthed_job_read_access`] instead;
|
||||
/// this gate applies only when a user is authenticated. Access is granted when:
|
||||
/// - the caller created the job (`created_by`) — covers app components, webhooks and
|
||||
/// the caller's own runs, whose `permissioned_as` is the policy identity rather
|
||||
@@ -1445,9 +1506,13 @@ async fn require_job_read_access(
|
||||
}
|
||||
|
||||
// Share read link: a valid view token minted by someone with read access grants
|
||||
// this authenticated member read of the shared job and its flow subtree.
|
||||
// this authenticated member read of the shared job and its flow subtree. Either
|
||||
// audience does — the public one is a superset of the member one.
|
||||
if let Some(token) = view_token {
|
||||
if validate_view_token(db, w_id, job_id, token).await? {
|
||||
if validate_view_token(db, w_id, job_id, token)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -1540,43 +1605,109 @@ async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Re
|
||||
})
|
||||
}
|
||||
|
||||
/// Who a share read link was minted for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum ViewTokenAudience {
|
||||
/// Read of the job subtree for an authenticated workspace member.
|
||||
Member,
|
||||
/// The same read, additionally granted to logged-out visitors.
|
||||
Public,
|
||||
}
|
||||
|
||||
/// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is
|
||||
/// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants
|
||||
/// read of that job and its whole flow subtree, so the run page can present a single
|
||||
/// link that also renders the flow's steps. Returns true iff the signature is valid
|
||||
/// AND `accessed_job_id` is the shared job or one of its descendants.
|
||||
/// link that also renders the flow's steps. Returns the token's audience iff the
|
||||
/// signature is valid AND `accessed_job_id` is the shared job or one of its descendants.
|
||||
async fn validate_view_token(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
accessed_job_id: &Uuid,
|
||||
token: &str,
|
||||
) -> error::Result<bool> {
|
||||
) -> error::Result<Option<ViewTokenAudience>> {
|
||||
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
};
|
||||
// Constant-time verification (same domain as `generate_view_token`, mirroring
|
||||
// Constant-time verification (same domains as `generate_view_token`, mirroring
|
||||
// `verify_suspended_secret`); avoids the timing side-channel of comparing the
|
||||
// hex strings with `!=`.
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
||||
mac.update(shared_id.as_bytes());
|
||||
mac.update(b"view_token");
|
||||
if mac.verify_slice(&provided_bytes).is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
let verify = |domain: &[u8]| -> error::Result<bool> {
|
||||
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
||||
mac.update(shared_id.as_bytes());
|
||||
mac.update(domain);
|
||||
Ok(mac.verify_slice(&provided_bytes).is_ok())
|
||||
};
|
||||
let audience = if verify(VIEW_TOKEN_DOMAIN)? {
|
||||
ViewTokenAudience::Member
|
||||
} else if verify(PUBLIC_VIEW_TOKEN_DOMAIN)? {
|
||||
ViewTokenAudience::Public
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
if accessed_job_id == &shared_id {
|
||||
return Ok(true);
|
||||
return Ok(Some(audience));
|
||||
}
|
||||
// The token authorizes the shared job's subtree: accessed must descend from it,
|
||||
// i.e. the shared job is among accessed's ancestors.
|
||||
let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?;
|
||||
Ok(chain.contains(&shared_id))
|
||||
Ok(chain.contains(&shared_id).then_some(audience))
|
||||
}
|
||||
|
||||
/// Whether `token` is a *public* share link covering `job_id`. This is the only thing
|
||||
/// that lets a logged-out caller read a job that was not itself run anonymously.
|
||||
async fn public_view_token_grants(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: &Uuid,
|
||||
token: Option<&str>,
|
||||
) -> error::Result<bool> {
|
||||
let Some(token) = token else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(validate_view_token(db, w_id, job_id, token).await? == Some(ViewTokenAudience::Public))
|
||||
}
|
||||
|
||||
/// Read gate for logged-out callers, the counterpart of [`require_job_read_access`]: a job
|
||||
/// run anonymously is public by construction, and a public share link publishes any other.
|
||||
async fn require_unauthed_job_read_access(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: &Uuid,
|
||||
created_by: &str,
|
||||
view_token: Option<&str>,
|
||||
) -> error::Result<()> {
|
||||
if created_by == "anonymous" || public_view_token_grants(db, w_id, job_id, view_token).await? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The read gate of every handler that serves both audiences (the `jobs_u` router):
|
||||
/// [`require_job_read_access`] when logged in, [`require_unauthed_job_read_access`] when not.
|
||||
async fn require_opt_authed_job_read_access(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
w_id: &str,
|
||||
job_id: &Uuid,
|
||||
created_by: &str,
|
||||
view_token: Option<&str>,
|
||||
) -> error::Result<()> {
|
||||
match opt_authed {
|
||||
Some(authed) => {
|
||||
require_job_read_access(db, user_db, authed, w_id, job_id, created_by, view_token).await
|
||||
}
|
||||
None => require_unauthed_job_read_access(db, w_id, job_id, created_by, view_token).await,
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1745,7 +1876,12 @@ async fn get_job(
|
||||
false
|
||||
};
|
||||
|
||||
let mut get = GetQuery::new().with_in_tags(tags.as_ref());
|
||||
let public_view_grant = opt_authed.is_none()
|
||||
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
|
||||
|
||||
let mut get = GetQuery::new()
|
||||
.with_in_tags(tags.as_ref())
|
||||
.with_public_view_grant(public_view_grant);
|
||||
if !has_valid_approval_token {
|
||||
get = get.with_auth(&opt_authed);
|
||||
}
|
||||
@@ -1760,21 +1896,21 @@ async fn get_job(
|
||||
let mut job = get.fetch(&db, &id, &w_id).await?;
|
||||
job.fetch_outstanding_wait_time(&db).await?;
|
||||
|
||||
// A valid approval token is itself the capability; otherwise an authenticated
|
||||
// caller must pass the same visibility as `jobs/list` (see `require_job_read_access`).
|
||||
if !has_valid_approval_token {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
job.created_by(),
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// A valid approval token is itself the capability; otherwise the caller must pass the
|
||||
// same visibility as `jobs/list` (see `require_job_read_access`), or hold a public
|
||||
// share link when logged out — which `public_view_grant` already established above,
|
||||
// so skip re-deriving it here: this handler is what the public run page polls.
|
||||
if !has_valid_approval_token && !public_view_grant {
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
job.created_by(),
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
log_job_view(
|
||||
@@ -1871,6 +2007,10 @@ struct GetQuery<'a> {
|
||||
with_flow: bool,
|
||||
with_auth: Option<&'a Option<ApiAuthed>>,
|
||||
with_in_tags: Option<&'a Vec<&'a str>>,
|
||||
/// A public share link covering this job was presented: lifts the logged-out
|
||||
/// restriction in [`Self::check_auth`], the way a view token lifts the ACL check
|
||||
/// in `require_job_read_access` for a member.
|
||||
with_public_view_grant: bool,
|
||||
}
|
||||
|
||||
impl<'a> GetQuery<'a> {
|
||||
@@ -1881,6 +2021,7 @@ impl<'a> GetQuery<'a> {
|
||||
with_flow: true,
|
||||
with_auth: None,
|
||||
with_in_tags: None,
|
||||
with_public_view_grant: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1905,9 +2046,16 @@ impl<'a> GetQuery<'a> {
|
||||
Self { with_in_tags: in_tags, ..self }
|
||||
}
|
||||
|
||||
fn with_public_view_grant(self, granted: bool) -> Self {
|
||||
Self { with_public_view_grant: granted, ..self }
|
||||
}
|
||||
|
||||
fn check_auth(&self, email: Option<&str>) -> error::Result<()> {
|
||||
if let Some(email) = email {
|
||||
if self.with_auth.is_some_and(|x| x.is_none()) && email != "anonymous" {
|
||||
if self.with_auth.is_some_and(|x| x.is_none())
|
||||
&& email != "anonymous"
|
||||
&& !self.with_public_view_grant
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
@@ -2389,22 +2537,16 @@ async fn get_completed_job_logs_tail(
|
||||
.await?;
|
||||
|
||||
if let Some(record) = record {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let logs = record.logs.unwrap_or_default();
|
||||
Ok(Json(logs))
|
||||
@@ -2453,22 +2595,16 @@ async fn get_job_logs(
|
||||
.await?;
|
||||
|
||||
if let Some(record) = record {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let logs = record.logs.unwrap_or_default();
|
||||
|
||||
log_job_view(
|
||||
@@ -2522,10 +2658,15 @@ async fn get_job_logs(
|
||||
.await?;
|
||||
let text = not_found_if_none(text, "Job Logs", id.to_string())?;
|
||||
|
||||
if opt_authed.is_none() && text.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
if opt_authed.is_none() {
|
||||
require_unauthed_job_read_access(
|
||||
&db,
|
||||
&w_id,
|
||||
&id,
|
||||
&text.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let logs = text.logs.unwrap_or_default();
|
||||
|
||||
@@ -2629,22 +2770,16 @@ async fn authorize_flow_tree_read(
|
||||
|
||||
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
|
||||
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
db,
|
||||
user_db,
|
||||
authed,
|
||||
w_id,
|
||||
&id,
|
||||
&root_job.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if root_job.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
db,
|
||||
user_db,
|
||||
&opt_authed,
|
||||
w_id,
|
||||
&id,
|
||||
&root_job.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
log_job_view(
|
||||
db,
|
||||
@@ -3625,22 +3760,16 @@ async fn get_args(
|
||||
.await?;
|
||||
|
||||
if let Some(record) = record {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
log_job_view(
|
||||
&db,
|
||||
@@ -3664,22 +3793,16 @@ async fn get_args(
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let record = not_found_if_none(record, "Job Args", id.to_string())?;
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&record.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
log_job_view(
|
||||
&db,
|
||||
@@ -4233,7 +4356,9 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
|
||||
// --- New approval system endpoints ---
|
||||
|
||||
use windmill_common::variables::{generate_approval_token, generate_view_token};
|
||||
use windmill_common::variables::{
|
||||
generate_approval_token, generate_view_token, PUBLIC_VIEW_TOKEN_DOMAIN, VIEW_TOKEN_DOMAIN,
|
||||
};
|
||||
|
||||
/// Verify an approval token against the workspace key + job_id.
|
||||
async fn validate_approval_token(
|
||||
@@ -4698,7 +4823,7 @@ async fn get_approval_info(
|
||||
// Possession of view rights over this approval is sufficient to mint a
|
||||
// share-read-link token for the flow: it only grants read (no resume), and only to
|
||||
// an authenticated workspace member, so it never widens what the approver can do.
|
||||
let hmac = generate_view_token(&w_id, row.id, &db).await?;
|
||||
let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?;
|
||||
let view_token = Some(format!("{}.{hmac}", row.id));
|
||||
|
||||
Ok(Json(ApprovalInfo {
|
||||
@@ -5211,7 +5336,7 @@ pub async fn get_suspended_job_flow(
|
||||
// Possession of a valid approval secret is sufficient to mint a share-read-link
|
||||
// token for the parent flow: it only grants read (no resume), and only to an
|
||||
// authenticated workspace member, so it never widens what the approver can do.
|
||||
let hmac = generate_view_token(&w_id, flow_id, &db).await?;
|
||||
let hmac = generate_view_token(&w_id, flow_id, VIEW_TOKEN_DOMAIN, &db).await?;
|
||||
let view_token = Some(format!("{flow_id}.{hmac}"));
|
||||
|
||||
Ok(Json(SuspendedJobFlow { job: flow, approvers, view_token }).into_response())
|
||||
@@ -7630,6 +7755,7 @@ pub async fn stream_job(
|
||||
poll_delay_ms,
|
||||
early_return,
|
||||
has_failure_module,
|
||||
false,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
|
||||
@@ -9329,8 +9455,7 @@ async fn get_log_file(
|
||||
}
|
||||
|
||||
// Authorization: the log file directory is the job id, so gate access the same
|
||||
// way as get_job_logs — the caller must be able to read the job. Non-logged-in
|
||||
// callers may only read logs of jobs created by the anonymous user.
|
||||
// way as get_job_logs — the caller must be able to read the job.
|
||||
let tags = opt_authed
|
||||
.as_ref()
|
||||
.map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec()))
|
||||
@@ -9344,22 +9469,16 @@ async fn get_log_file(
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&job_id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if created_by != "anonymous" {
|
||||
return Err(error::Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&job_id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
|
||||
// SECURITY (defense in depth): refuse to read through a symlink so a planted
|
||||
@@ -9453,6 +9572,10 @@ async fn get_job_update(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// A public share link authorizes a logged-out read of this job, seeding the latch
|
||||
// that would otherwise confine it to anonymously-run jobs.
|
||||
let mut unauthed_read_authorized = opt_authed.is_none()
|
||||
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
|
||||
Ok(Json(
|
||||
get_job_update_data(
|
||||
&opt_authed,
|
||||
@@ -9473,7 +9596,7 @@ async fn get_job_update(
|
||||
None,
|
||||
false,
|
||||
&mut false,
|
||||
&mut false,
|
||||
&mut unauthed_read_authorized,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
@@ -9499,7 +9622,7 @@ async fn get_job_update_sse(
|
||||
}): Query<JobUpdateQuery>,
|
||||
) -> error::Result<Response> {
|
||||
// Authorize once at connection time; `created_by` cannot change for a given job,
|
||||
// mirroring the per-stream `anonymous_verified` latch in the streaming loop.
|
||||
// mirroring the per-stream `unauthed_read_authorized` latch in the streaming loop.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_update_read_access(
|
||||
&db,
|
||||
@@ -9511,6 +9634,8 @@ async fn get_job_update_sse(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let unauthed_read_authorized = opt_authed.is_none()
|
||||
&& public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
@@ -9532,6 +9657,7 @@ async fn get_job_update_sse(
|
||||
poll_delay_ms,
|
||||
None,
|
||||
false,
|
||||
unauthed_read_authorized,
|
||||
);
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
|
||||
@@ -9571,6 +9697,9 @@ pub fn start_job_update_sse_stream(
|
||||
poll_delay_ms: Option<u64>,
|
||||
early_return: Option<String>,
|
||||
has_failure_module: bool,
|
||||
// Seeds the per-stream latch below: set when the caller is logged out but presented
|
||||
// a public share link for this job, which authorizes the whole stream up front.
|
||||
unauthed_read_authorized: bool,
|
||||
) -> () {
|
||||
tokio::spawn(async move {
|
||||
let mut log_offset = initial_log_offset;
|
||||
@@ -9580,10 +9709,10 @@ pub fn start_job_update_sse_stream(
|
||||
// Latched once the early_return node's failure is observed alongside a
|
||||
// failure_module — subsequent polls then skip the redundant per-node lookup.
|
||||
let mut early_return_suppressed = false;
|
||||
// Latched once we've verified the job was created by "anonymous" — for
|
||||
// Latched once a logged-out caller's read of this job has been authorized — for
|
||||
// unauthenticated SSE streams, this gates access and is checked once per
|
||||
// stream rather than once per poll (created_by cannot change).
|
||||
let mut anonymous_verified = false;
|
||||
let mut unauthed_read_authorized = unauthed_read_authorized;
|
||||
|
||||
// Send initial update immediately
|
||||
let mut running = running;
|
||||
@@ -9609,7 +9738,7 @@ pub fn start_job_update_sse_stream(
|
||||
early_return.as_deref(),
|
||||
has_failure_module,
|
||||
&mut early_return_suppressed,
|
||||
&mut anonymous_verified,
|
||||
&mut unauthed_read_authorized,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -9732,7 +9861,7 @@ pub fn start_job_update_sse_stream(
|
||||
early_return.as_deref(),
|
||||
has_failure_module,
|
||||
&mut early_return_suppressed,
|
||||
&mut anonymous_verified,
|
||||
&mut unauthed_read_authorized,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -9886,7 +10015,9 @@ async fn get_job_update_data(
|
||||
early_return: Option<&str>,
|
||||
has_failure_module: bool,
|
||||
early_return_suppressed: &mut bool,
|
||||
anonymous_verified: &mut bool,
|
||||
// Latched gate for logged-out callers: once true, this job's updates are readable
|
||||
// without a session (job run anonymously, or a public share link presented).
|
||||
unauthed_read_authorized: &mut bool,
|
||||
) -> error::Result<JobUpdate> {
|
||||
let tags = if log_view {
|
||||
log_job_view(
|
||||
@@ -9908,14 +10039,13 @@ async fn get_job_update_data(
|
||||
let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some();
|
||||
|
||||
if only_result.unwrap_or(false) {
|
||||
// Unauthenticated callers may only read jobs whose creator is "anonymous".
|
||||
// Unauthenticated callers are confined to jobs they may read logged out.
|
||||
// The non-only_result branch enforces this via `record.created_by` from its
|
||||
// main query, but the only_result branch below fetches solely the result by
|
||||
// (workspace_id, job_id), so we guard here to close the gap. The
|
||||
// `anonymous_verified` flag is preserved across SSE poll iterations so the
|
||||
// lookup only happens once per stream — `created_by` cannot change for a
|
||||
// given job once it has been created.
|
||||
if opt_authed.is_none() && !*anonymous_verified {
|
||||
// (workspace_id, job_id), so we guard here to close the gap. The latch is
|
||||
// preserved across SSE poll iterations so the lookup only happens once per
|
||||
// stream — `created_by` cannot change for a given job once it has been created.
|
||||
if opt_authed.is_none() && !*unauthed_read_authorized {
|
||||
let created_by = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
@@ -9931,7 +10061,7 @@ async fn get_job_update_data(
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
*anonymous_verified = true;
|
||||
*unauthed_read_authorized = true;
|
||||
}
|
||||
|
||||
let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) =
|
||||
@@ -10172,15 +10302,22 @@ async fn get_job_update_data(
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
|
||||
|
||||
if opt_authed.is_none() && record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
if opt_authed.is_none() && !*unauthed_read_authorized {
|
||||
if record.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
*unauthed_read_authorized = true;
|
||||
}
|
||||
|
||||
let job = if record.completed.unwrap_or(false) && get_full_job_on_completion {
|
||||
let get = GetQuery::new()
|
||||
.with_auth(&opt_authed)
|
||||
// Already authorized above, latch included — don't re-derive it from
|
||||
// `created_by` here or a public share link would lose the full job.
|
||||
.with_public_view_grant(*unauthed_read_authorized)
|
||||
.without_logs()
|
||||
.without_code();
|
||||
Some(get.fetch(&db, job_id, &w_id).await?)
|
||||
@@ -10306,19 +10443,24 @@ async fn get_completed_job<'a>(
|
||||
.map(|authed| get_scope_tags(authed))
|
||||
.flatten();
|
||||
|
||||
let public_view_grant = opt_authed.is_none()
|
||||
&& public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?;
|
||||
|
||||
let job_o = GetQuery::new()
|
||||
.with_auth(&opt_authed)
|
||||
.with_in_tags(tags.as_ref())
|
||||
.with_public_view_grant(public_view_grant)
|
||||
.fetch_completed(&db, &id, &w_id)
|
||||
.await?;
|
||||
|
||||
let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?;
|
||||
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
// `public_view_grant` already settled the logged-out case above — don't re-derive it.
|
||||
if !public_view_grant {
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&cj.created_by,
|
||||
@@ -10454,22 +10596,16 @@ async fn get_completed_job_result(
|
||||
};
|
||||
|
||||
if !approval_secret_ok {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format_result(
|
||||
@@ -10571,22 +10707,16 @@ async fn get_completed_job_result_maybe(
|
||||
|
||||
if let Some(mut res) = result_o {
|
||||
format_result(res.result_columns.as_ref(), res.result.as_mut());
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&res.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if res.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&res.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
log_job_view(
|
||||
&db,
|
||||
@@ -10617,23 +10747,16 @@ async fn get_completed_job_result_maybe(
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if let Some(created_by) = created_by {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let started = sqlx::query_scalar!(
|
||||
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
|
||||
@@ -10707,9 +10830,8 @@ async fn get_dispatch_events(
|
||||
|
||||
// Gate on the producer job's visibility, exactly like
|
||||
// get_completed_job_timing on the same unauthed router: scope tags
|
||||
// first, then per-job read access for authed users, anonymous-only
|
||||
// jobs otherwise. The dispatch_event FK to v2_job(id) guarantees the
|
||||
// producer row exists for any extant event.
|
||||
// first, then the shared per-audience read gate. The dispatch_event FK
|
||||
// to v2_job(id) guarantees the producer row exists for any extant event.
|
||||
let producer = sqlx::query!(
|
||||
r#"SELECT created_by AS "created_by!"
|
||||
FROM v2_job
|
||||
@@ -10722,22 +10844,16 @@ async fn get_dispatch_events(
|
||||
.await?;
|
||||
let producer = not_found_if_none(producer, "Job", id.to_string())?;
|
||||
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&producer.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if producer.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&producer.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
@@ -10890,22 +11006,16 @@ async fn get_completed_job_timing(
|
||||
|
||||
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
|
||||
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&result.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if result.created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&result.created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(JobTiming {
|
||||
created_at: result.created_at,
|
||||
@@ -11200,23 +11310,16 @@ async fn get_otel_traces(
|
||||
|
||||
match job {
|
||||
Some(created_by) => {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
require_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
} else if created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
require_opt_authed_job_read_access(
|
||||
&db,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
&w_id,
|
||||
&id,
|
||||
&created_by,
|
||||
view_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
return Err(Error::NotFound(format!("Job {} not found", id)));
|
||||
|
||||
@@ -187,14 +187,18 @@ pub async fn get_triggers_count_internal(
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
// These counts are independent reads: they share a connection to avoid one acquire
|
||||
// per trigger kind, but must not share a transaction. A single failing count would
|
||||
// abort it and, since `trigger_count` falls back to 0 on error, silently zero every
|
||||
// count after it.
|
||||
#[allow(unused)]
|
||||
let mut tx = db.begin().await?;
|
||||
let mut conn = db.acquire().await?;
|
||||
|
||||
#[cfg(feature = "http_trigger")]
|
||||
let http_routes_count = {
|
||||
use crate::triggers::http::HttpTrigger;
|
||||
let count = HttpTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -205,7 +209,7 @@ pub async fn get_triggers_count_internal(
|
||||
let websocket_count = {
|
||||
use crate::triggers::websocket::WebsocketTrigger;
|
||||
let count = WebsocketTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -216,7 +220,7 @@ pub async fn get_triggers_count_internal(
|
||||
let kafka_count = {
|
||||
use crate::triggers::kafka::KafkaTrigger;
|
||||
let count = KafkaTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -227,7 +231,7 @@ pub async fn get_triggers_count_internal(
|
||||
let nats_count = {
|
||||
use crate::triggers::nats::NatsTrigger;
|
||||
let count = NatsTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -238,7 +242,7 @@ pub async fn get_triggers_count_internal(
|
||||
let postgres_count = {
|
||||
use crate::triggers::postgres::PostgresTrigger;
|
||||
let count = PostgresTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -249,7 +253,7 @@ pub async fn get_triggers_count_internal(
|
||||
let mqtt_count = {
|
||||
use crate::triggers::mqtt::MqttTrigger;
|
||||
let count = MqttTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -260,7 +264,7 @@ pub async fn get_triggers_count_internal(
|
||||
let amqp_count = {
|
||||
use crate::triggers::amqp::AmqpTrigger;
|
||||
let count = AmqpTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -270,7 +274,9 @@ pub async fn get_triggers_count_internal(
|
||||
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
|
||||
let sqs_count = {
|
||||
use crate::triggers::sqs::SqsTrigger;
|
||||
let count = SqsTrigger.trigger_count(&mut tx, w_id, is_flow, path).await;
|
||||
let count = SqsTrigger
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
#[cfg(not(all(feature = "sqs_trigger", feature = "enterprise", feature = "private")))]
|
||||
@@ -279,7 +285,9 @@ pub async fn get_triggers_count_internal(
|
||||
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
|
||||
let gcp_count = {
|
||||
use crate::triggers::gcp::GcpTrigger;
|
||||
let count = GcpTrigger.trigger_count(&mut tx, w_id, is_flow, path).await;
|
||||
let count = GcpTrigger
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
#[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))]
|
||||
@@ -289,7 +297,7 @@ pub async fn get_triggers_count_internal(
|
||||
let azure_count = {
|
||||
use crate::triggers::azure::AzureTrigger;
|
||||
let count = AzureTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
@@ -300,14 +308,14 @@ pub async fn get_triggers_count_internal(
|
||||
let email_count = {
|
||||
use crate::triggers::email::EmailTrigger;
|
||||
let count = EmailTrigger
|
||||
.trigger_count(&mut tx, w_id, is_flow, path)
|
||||
.trigger_count(&mut conn, w_id, is_flow, path)
|
||||
.await;
|
||||
count
|
||||
};
|
||||
#[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))]
|
||||
let email_count = 0;
|
||||
|
||||
tx.commit().await?;
|
||||
drop(conn);
|
||||
|
||||
let webhook_count = (if is_flow {
|
||||
sqlx::query_scalar!(
|
||||
|
||||
@@ -579,6 +579,7 @@ async fn route_job(
|
||||
None,
|
||||
early_return,
|
||||
has_failure_module,
|
||||
false,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(
|
||||
|
||||
@@ -192,14 +192,25 @@ pub async fn generate_approval_token(
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`.
|
||||
/// Domain separator of the member-audience view token (see [`generate_view_token`]).
|
||||
pub const VIEW_TOKEN_DOMAIN: &[u8] = b"view_token";
|
||||
|
||||
/// Domain separator of the public-audience view token (see [`generate_view_token`]).
|
||||
/// Distinct from [`VIEW_TOKEN_DOMAIN`] so that already-shared member links keep granting
|
||||
/// only what they were minted for: going public is always an explicit new mint.
|
||||
pub const PUBLIC_VIEW_TOKEN_DOMAIN: &[u8] = b"public_view_token";
|
||||
|
||||
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || domain)`.
|
||||
/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can
|
||||
/// never be used as a view token (or vice-versa). Used to build a "share read link" that
|
||||
/// grants an authenticated workspace member read access to a job (and its flow subtree)
|
||||
/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token.
|
||||
/// grants read access to a job (and its flow subtree) to someone who otherwise lacks ACL
|
||||
/// on it: an authenticated workspace member under [`VIEW_TOKEN_DOMAIN`], anyone holding
|
||||
/// the link (logged in or not) under [`PUBLIC_VIEW_TOKEN_DOMAIN`]. No expiry/revocation
|
||||
/// (stateless), like the approval token.
|
||||
pub async fn generate_view_token(
|
||||
w_id: &str,
|
||||
job_id: uuid::Uuid,
|
||||
domain: &[u8],
|
||||
db: &DB,
|
||||
) -> crate::error::Result<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
@@ -208,7 +219,7 @@ pub async fn generate_view_token(
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job_id.as_bytes());
|
||||
mac.update(b"view_token");
|
||||
mac.update(domain);
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ bitflags::bitflags! {
|
||||
const DISABLE_WORKSPACE_FORKING = 1 << 1;
|
||||
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
|
||||
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
|
||||
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ pub enum ProtectionRuleKind {
|
||||
DisableWorkspaceForking,
|
||||
RestrictDeployToDeployers,
|
||||
RestrictAnonymousAppDeployment,
|
||||
RestrictPublicRunSharing,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
@@ -98,6 +100,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictAnonymousAppDeployment => {
|
||||
ProtectionRules::RESTRICT_ANONYMOUS_APP_DEPLOYMENT
|
||||
}
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +118,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictAnonymousAppDeployment => {
|
||||
"Making an app publicly accessible without login (anonymous execution mode) is restricted in this workspace"
|
||||
}
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
"Sharing a run publicly (readable without login) is restricted in this workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +183,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28871/sync-script-to-git-repo
|
||||
/// ignores the slug, so the slug is kept free of characters that would be
|
||||
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
|
||||
/// reverse proxies reject as double-encoding when the client re-encodes it).
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28870/git-sync-init-repository-windmill";
|
||||
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28890/git-sync-init-repository-windmill";
|
||||
|
||||
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
|
||||
/// fork of another workspace.
|
||||
@@ -310,7 +318,7 @@ fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GitRepositorySettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exclude_types_override: Option<Vec<ObjectType>>,
|
||||
@@ -508,7 +516,7 @@ impl AutoPullSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GitSyncSettings {
|
||||
pub include_path: Vec<String>,
|
||||
pub include_type: Vec<ObjectType>,
|
||||
@@ -1525,10 +1533,12 @@ pub async fn resolve_fork_branch_target(
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
} else if branch != expected_base {
|
||||
// Environment-label branch (`dev`/`staging`) of a dev-workspace child.
|
||||
// Dev workspaces only exist directly under a root, so no recursion here.
|
||||
// The tracked-branch guard keeps a label that collides with the tracked
|
||||
// branch from double-routing (the parent's own pull already covers it).
|
||||
// Environment-label branch (`dev`, `staging`, ...) of a dev-workspace child.
|
||||
// Direct children only: a dev nested under another dev is parent-managed
|
||||
// by that dev, which holds no auto-pull config of its own, so no branch
|
||||
// pushed to this repo routes to it. The tracked-branch guard keeps a
|
||||
// label that collides with the tracked branch from double-routing (the
|
||||
// parent's own pull already covers it).
|
||||
sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace \
|
||||
WHERE parent_workspace_id = $1 AND NOT deleted AND is_dev_workspace \
|
||||
|
||||
@@ -361,7 +361,17 @@ pub trait TriggerCrud: Send + Sync + 'static {
|
||||
.bind(script_path)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
// Falling back to 0 keeps one unreadable table from failing the whole count
|
||||
// endpoint, but the cause must still reach the logs: a silent 0 is
|
||||
// indistinguishable from "no triggers" in the UI.
|
||||
.unwrap_or_else(|err| {
|
||||
tracing::error!(
|
||||
"failed to count {} triggers of {} {script_path} in {workspace_id}: {err:#}",
|
||||
Self::TABLE_NAME,
|
||||
if is_flow { "flow" } else { "script" }
|
||||
);
|
||||
0
|
||||
});
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
@@ -571,12 +571,8 @@ async fn gen_bunfig(
|
||||
NPMRC.read().await.clone()
|
||||
};
|
||||
|
||||
if let Some(ref npmrc_content) = npmrc {
|
||||
if !npmrc_content.trim().is_empty() {
|
||||
tracing::debug!("Writing .npmrc for bun from npmrc setting");
|
||||
write_file(job_dir, ".npmrc", npmrc_content)?;
|
||||
return Ok(());
|
||||
}
|
||||
if npmrc.as_ref().is_some_and(|c| !c.trim().is_empty()) {
|
||||
return write_bun_registry_config(job_dir, npmrc, None, None);
|
||||
}
|
||||
|
||||
let (registry, bunfig_install_scopes) = if let Some(conn) = db {
|
||||
@@ -606,6 +602,35 @@ async fn gen_bunfig(
|
||||
BUNFIG_INSTALL_SCOPES.read().await.clone(),
|
||||
)
|
||||
};
|
||||
write_bun_registry_config(job_dir, None, registry, bunfig_install_scopes)
|
||||
}
|
||||
|
||||
/// The files [`write_bun_registry_config`] may create in the directory bun installs from.
|
||||
/// Both can hold a registry auth token, so `prepare-deps` deletes them by these names once
|
||||
/// the install is over.
|
||||
pub(crate) const BUN_NPMRC_FILE: &str = ".npmrc";
|
||||
pub(crate) const BUN_CONFIG_FILE: &str = "bunfig.toml";
|
||||
|
||||
/// Write the registry configuration `bun install` picks up from its working directory: the
|
||||
/// `npmrc` setting verbatim as `.npmrc` when set, otherwise a `bunfig.toml` holding the
|
||||
/// registry URL, its auth token and the install scopes.
|
||||
///
|
||||
/// Shared with the debugger's `prepare-deps`, which resolves the same settings without a
|
||||
/// database (see `prepare_deps.rs`), so the two install paths configure bun identically.
|
||||
pub(crate) fn write_bun_registry_config(
|
||||
job_dir: &str,
|
||||
npmrc: Option<String>,
|
||||
registry: Option<String>,
|
||||
bunfig_install_scopes: Option<String>,
|
||||
) -> Result<()> {
|
||||
if let Some(ref npmrc_content) = npmrc {
|
||||
if !npmrc_content.trim().is_empty() {
|
||||
tracing::debug!("Writing .npmrc for bun from npmrc setting");
|
||||
write_file(job_dir, BUN_NPMRC_FILE, npmrc_content)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if registry.is_some() || bunfig_install_scopes.is_some() {
|
||||
let (url, token_opt) = if let Some(ref s) = registry {
|
||||
let url = s.trim();
|
||||
@@ -635,7 +660,7 @@ registry = {}
|
||||
.unwrap_or("".to_string())
|
||||
);
|
||||
tracing::debug!("Writing following bunfig.toml: {bunfig_toml}");
|
||||
let _ = write_file(&job_dir, "bunfig.toml", &bunfig_toml)?;
|
||||
let _ = write_file(&job_dir, BUN_CONFIG_FILE, &bunfig_toml)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1597,6 +1597,10 @@ pub async fn do_duckdb(
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
let query_block_list =
|
||||
crate::duckdb_isolation_ee::apply_duckdb_isolation(query_block_list)?;
|
||||
|
||||
let base_internal_url = client.base_internal_url.clone();
|
||||
let w_id = job.workspace_id.clone();
|
||||
let job_dir = job_dir.to_string();
|
||||
|
||||
@@ -43,6 +43,8 @@ mod deno_executor;
|
||||
mod docker_v2;
|
||||
#[cfg(feature = "duckdb")]
|
||||
mod duckdb_executor;
|
||||
#[cfg(all(feature = "duckdb", feature = "private"))]
|
||||
mod duckdb_isolation_ee;
|
||||
mod global_cache;
|
||||
mod go_executor;
|
||||
mod graphql_executor;
|
||||
|
||||
@@ -12,6 +12,7 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::bun_executor::{write_bun_registry_config, BUN_CONFIG_FILE, BUN_NPMRC_FILE};
|
||||
use crate::worker::non_empty_env;
|
||||
use crate::{
|
||||
BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST,
|
||||
@@ -92,9 +93,10 @@ lazy_static::lazy_static! {
|
||||
/// UV binary path
|
||||
static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string());
|
||||
|
||||
/// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance
|
||||
/// settings the job path resolves are unreachable here: their env-var equivalents are the
|
||||
/// only registry configuration the debugger can see.
|
||||
/// Fallbacks for the registry settings, used when the caller sends none: this process has
|
||||
/// no database, so the instance settings only reach it through the request (see
|
||||
/// [`RegistryConfig`]), and a debug service that cannot fetch them still configures the
|
||||
/// installer from its own environment.
|
||||
static ref PY_INDEX_URL: Option<String> = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL"));
|
||||
static ref PY_EXTRA_INDEX_URL: Option<String> = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL"));
|
||||
/// uv defaults to `first-index`; the job path overrides it so a package missing from the
|
||||
@@ -112,10 +114,153 @@ const p = {
|
||||
};
|
||||
"#;
|
||||
|
||||
/// The registry settings resolved from the instance settings by the caller.
|
||||
///
|
||||
/// This process runs without a database, so `GET /api/debug/registry_config` is where the
|
||||
/// debug service reads them and this request field is how they get here. They are consumed
|
||||
/// to configure the installer and never handed to the debug session itself: an index URL
|
||||
/// embeds credentials and the session executes user-supplied code.
|
||||
///
|
||||
/// Field names are the `global_settings` keys, so the service forwards the endpoint's
|
||||
/// response verbatim.
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct RegistryConfig {
|
||||
pub npm_config_registry: Option<String>,
|
||||
pub npmrc: Option<String>,
|
||||
pub bunfig_install_scopes: Option<String>,
|
||||
pub pip_index_url: Option<String>,
|
||||
pub pip_extra_index_url: Option<String>,
|
||||
pub uv_index_strategy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PrepareRequest {
|
||||
pub code: String,
|
||||
pub language: String,
|
||||
/// Interpreter the caller will run the script with. The venv must be built against it:
|
||||
/// site-packages is put on that interpreter's sys.path, and a wheel with a compiled
|
||||
/// extension built for another version is simply invisible there.
|
||||
#[serde(default)]
|
||||
pub python_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub registry: RegistryConfig,
|
||||
}
|
||||
|
||||
/// A blank value means unset, as it does for the same setting on a worker.
|
||||
fn configured(value: &Option<String>) -> Option<String> {
|
||||
value.clone().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Where the registry configuration for one `bun install` is written.
|
||||
///
|
||||
/// Deliberately not the install directory: the debug session resolves its `node_modules`
|
||||
/// symlink into that directory, and the sandbox bind-mounts all of `/tmp` into every session,
|
||||
/// so a concurrent session could read the credentials of an install in flight. `/var/tmp` is a
|
||||
/// tmpfs private to each jail (`debugger/nsjail.debug.config.proto`), which also takes the
|
||||
/// credentials with it when a jailed install is killed. `bun install` reads them from here
|
||||
/// through `--config` and `HOME`.
|
||||
const REGISTRY_CONFIG_ROOT: &str = "/var/tmp/windmill-debug-registry";
|
||||
|
||||
/// How long a configuration directory may survive before the next install treats it as debris.
|
||||
/// The caller kills an install with SIGKILL, leaving nothing able to clean up after it, and an
|
||||
/// unjailed install is the only one that writes somewhere outliving the process at all. Well
|
||||
/// past any install: the caller's own timeout is two minutes by default.
|
||||
const REGISTRY_CONFIG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
|
||||
/// Removes the registry configuration when the install ends, whichever way it ends.
|
||||
struct RegistryConfigDir(Option<String>);
|
||||
|
||||
impl Drop for RegistryConfigDir {
|
||||
fn drop(&mut self) {
|
||||
if let Some(dir) = self.0.as_deref() {
|
||||
remove_registry_config_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the registry configuration for one install and return the directory holding it, empty
|
||||
/// when there is nothing to write.
|
||||
///
|
||||
/// Falls back to the install directory if the private root is not writable: an install that
|
||||
/// reaches its registry matters more than the isolation above, which only holds for sessions
|
||||
/// that run under nsjail in the first place.
|
||||
fn write_registry_config_dir(
|
||||
job_id: &uuid::Uuid,
|
||||
job_dir: &str,
|
||||
registry: &RegistryConfig,
|
||||
) -> anyhow::Result<RegistryConfigDir> {
|
||||
let npmrc = configured(®istry.npmrc);
|
||||
let npm_config_registry = configured(®istry.npm_config_registry);
|
||||
let bunfig_install_scopes = configured(®istry.bunfig_install_scopes);
|
||||
if npmrc.is_none() && npm_config_registry.is_none() && bunfig_install_scopes.is_none() {
|
||||
return Ok(RegistryConfigDir(None));
|
||||
}
|
||||
|
||||
let dir = format!("{}/{}", REGISTRY_CONFIG_ROOT, job_id);
|
||||
let dir = match create_private_dir(&dir) {
|
||||
Ok(()) => dir,
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not create {dir} ({e}), keeping the registry configuration in the install directory");
|
||||
job_dir.to_string()
|
||||
}
|
||||
};
|
||||
// Claimed before anything is written to it, so a failure below still takes it down.
|
||||
let held = RegistryConfigDir(Some(dir.clone()));
|
||||
write_bun_registry_config(&dir, npmrc, npm_config_registry, bunfig_install_scopes)?;
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
fn create_private_dir(dir: &str) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
std::fs::create_dir_all(REGISTRY_CONFIG_ROOT)?;
|
||||
sweep_stale_registry_config();
|
||||
std::fs::DirBuilder::new().mode(0o700).create(dir)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
sweep_stale_registry_config();
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop what an install that was killed could not clean up itself.
|
||||
fn sweep_stale_registry_config() {
|
||||
let Ok(entries) = std::fs::read_dir(REGISTRY_CONFIG_ROOT) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let stale = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.is_ok_and(|m| m.elapsed().is_ok_and(|age| age > REGISTRY_CONFIG_MAX_AGE));
|
||||
if stale {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the registry configuration once the install that needed it is over. A failure to
|
||||
/// remove credentials has to be visible.
|
||||
fn remove_registry_config_dir(dir: &str) {
|
||||
if dir.starts_with(REGISTRY_CONFIG_ROOT) {
|
||||
if let Err(e) = std::fs::remove_dir_all(dir) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::error!("Failed to remove registry configuration {dir}: {e}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The fallback path above put them in the install directory, which has to survive.
|
||||
for file in [BUN_NPMRC_FILE, BUN_CONFIG_FILE] {
|
||||
let path = format!("{}/{}", dir, file);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::error!("Failed to remove registry configuration {path}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -178,15 +323,30 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap<String, String> {
|
||||
envs
|
||||
}
|
||||
|
||||
/// CA bundle for the package index, most specific spelling first. A host behind a TLS-intercepting
|
||||
/// proxy configures it under whichever name its other tooling uses, and the environment is cleared
|
||||
/// below, so falling back past `PY_INDEX_CERT` is what makes those hosts work at all.
|
||||
fn index_ca_bundle() -> Option<String> {
|
||||
INDEX_CERT
|
||||
.clone()
|
||||
.or_else(|| non_empty_env("SSL_CERT_FILE"))
|
||||
.or_else(|| non_empty_env("REQUESTS_CA_BUNDLE"))
|
||||
.or_else(|| non_empty_env("CURL_CA_BUNDLE"))
|
||||
}
|
||||
|
||||
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
|
||||
fn uv_registry_args() -> Vec<String> {
|
||||
fn uv_registry_args(registry: &RegistryConfig) -> Vec<String> {
|
||||
let index_url = configured(®istry.pip_index_url).or_else(|| PY_INDEX_URL.clone());
|
||||
let extra_index_url =
|
||||
configured(®istry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone());
|
||||
|
||||
let mut args: Vec<String> = vec![];
|
||||
if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() {
|
||||
if let Some(urls) = extra_index_url.as_ref() {
|
||||
for url in urls.split(',') {
|
||||
args.extend(["--extra-index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
}
|
||||
if let Some(url) = PY_INDEX_URL.as_ref() {
|
||||
if let Some(url) = index_url.as_ref() {
|
||||
args.extend(["--index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
if let Some(hosts) = TRUSTED_HOST.as_ref() {
|
||||
@@ -201,7 +361,11 @@ fn uv_registry_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Prepare Python dependencies using uv
|
||||
async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
async fn prepare_python_deps_standalone(
|
||||
code: &str,
|
||||
python_path: Option<&str>,
|
||||
registry: &RegistryConfig,
|
||||
) -> PrepareResponse {
|
||||
// Parse imports from the code
|
||||
let packages = parse_python_imports(code);
|
||||
|
||||
@@ -237,23 +401,36 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
|
||||
common_uv_envs.insert(
|
||||
"UV_INDEX_STRATEGY".to_string(),
|
||||
PY_INDEX_STRATEGY.to_string(),
|
||||
configured(®istry.uv_index_strategy).unwrap_or_else(|| PY_INDEX_STRATEGY.to_string()),
|
||||
);
|
||||
if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() {
|
||||
common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string());
|
||||
}
|
||||
if let Some(cert_path) = INDEX_CERT.as_ref() {
|
||||
if let Some(cert_path) = index_ca_bundle() {
|
||||
// uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle
|
||||
// reaches it through SSL_CERT_FILE, as in the job path.
|
||||
common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string());
|
||||
// reaches it through SSL_CERT_FILE, as in the job path. It replaces uv's own roots rather
|
||||
// than adding to them, so the file has to be a complete bundle.
|
||||
common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path);
|
||||
}
|
||||
// The other spelling uv accepts. Like the bundle above it replaces uv's own roots rather than
|
||||
// adding to them, so a directory holding only a private CA leaves public indexes untrusted.
|
||||
if let Some(cert_dir) = non_empty_env("SSL_CERT_DIR") {
|
||||
common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir);
|
||||
}
|
||||
|
||||
let registry_args = uv_registry_args();
|
||||
let registry_args = uv_registry_args(registry);
|
||||
|
||||
// Step 1: Create virtual environment using uv
|
||||
// `--seed` resolves pip/setuptools from the index, so the venv also needs the registry
|
||||
// arguments: on a network that only reaches a private mirror it fails without them.
|
||||
let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()];
|
||||
// Without this uv picks its own interpreter, and the caller then puts a site-packages built
|
||||
// for that version on a different interpreter's sys.path: pure-Python packages still import,
|
||||
// anything with a compiled extension does not, and the error names the missing extension
|
||||
// rather than the mismatch.
|
||||
if let Some(python_path) = python_path {
|
||||
venv_args.extend(["-p".to_string(), python_path.to_string()]);
|
||||
}
|
||||
venv_args.extend(registry_args.iter().cloned());
|
||||
|
||||
let output = Command::new(UV_PATH.as_str())
|
||||
@@ -375,16 +552,26 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
|
||||
/// Get common environment variables for Bun processes
|
||||
pub fn get_simple_bun_proc_envs() -> HashMap<String, String> {
|
||||
get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR)))
|
||||
let mut envs = get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR)));
|
||||
// Bun reads none of the spellings uv does, so a custom CA reaches `bun install` only here.
|
||||
if let Some(cert_path) = non_empty_env("NODE_EXTRA_CA_CERTS").or_else(index_ca_bundle) {
|
||||
envs.insert("NODE_EXTRA_CA_CERTS".to_string(), cert_path);
|
||||
}
|
||||
envs
|
||||
}
|
||||
|
||||
/// Prepare dependencies for a script without requiring database access.
|
||||
/// This is meant to be called from the CLI.
|
||||
pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareResponse {
|
||||
pub async fn prepare_deps_standalone(
|
||||
code: &str,
|
||||
language: &str,
|
||||
python_path: Option<&str>,
|
||||
registry: &RegistryConfig,
|
||||
) -> PrepareResponse {
|
||||
// Route to the appropriate handler based on language
|
||||
match language {
|
||||
"python3" | "python" => {
|
||||
return prepare_python_deps_standalone(code).await;
|
||||
return prepare_python_deps_standalone(code, python_path, registry).await;
|
||||
}
|
||||
"bun" | "typescript" | "deno" => {
|
||||
// Continue with JS/TS handling below
|
||||
@@ -451,7 +638,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
};
|
||||
}
|
||||
|
||||
let common_bun_proc_envs = get_simple_bun_proc_envs();
|
||||
let mut common_bun_proc_envs = get_simple_bun_proc_envs();
|
||||
|
||||
// Step 1: Run build.js to generate package.json
|
||||
let output = Command::new(&*BUN_PATH)
|
||||
@@ -544,17 +731,43 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Run bun install
|
||||
// Step 2: Run bun install, from the same registry configuration a job installs with.
|
||||
let mut args = vec!["install".to_string()];
|
||||
let registry_config_dir = match write_registry_config_dir(&job_id, &job_dir, registry) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write registry configuration: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(dir) = registry_config_dir.0.as_ref() {
|
||||
// Only one of the two files exists: `.npmrc` is read from the installer's home,
|
||||
// `bunfig.toml` only from the path named here.
|
||||
common_bun_proc_envs.insert("HOME".to_string(), dir.to_string());
|
||||
let bunfig = format!("{}/{}", dir, BUN_CONFIG_FILE);
|
||||
if std::path::Path::new(&bunfig).exists() {
|
||||
args.push(format!("--config={}", bunfig));
|
||||
}
|
||||
}
|
||||
|
||||
let output = Command::new(&*BUN_PATH)
|
||||
.current_dir(&job_dir)
|
||||
.env_clear()
|
||||
.envs(common_bun_proc_envs)
|
||||
.args(vec!["install"])
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
drop(registry_config_dir);
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
@@ -649,7 +862,13 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let response = prepare_deps_standalone(&request.code, &request.language).await;
|
||||
let response = prepare_deps_standalone(
|
||||
&request.code,
|
||||
&request.language,
|
||||
request.python_path.as_deref(),
|
||||
&request.registry,
|
||||
)
|
||||
.await;
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
|
||||
Ok(())
|
||||
@@ -657,7 +876,27 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PrepareResponse;
|
||||
use super::{PrepareRequest, PrepareResponse};
|
||||
|
||||
/// The debug service and this CLI are deployed as separate images, and the service
|
||||
/// forwards `GET /api/debug/registry_config` verbatim, `message` field included. So a
|
||||
/// request from an older service carries no `registry` at all, and one from a newer
|
||||
/// service carries fields this binary does not know.
|
||||
#[test]
|
||||
fn test_registry_is_optional_and_tolerates_unknown_fields() {
|
||||
let without: PrepareRequest =
|
||||
serde_json::from_str(r#"{"code": "import lodash", "language": "bun"}"#).unwrap();
|
||||
assert!(without.registry.npm_config_registry.is_none());
|
||||
|
||||
let with: PrepareRequest = serde_json::from_str(
|
||||
r#"{"code": "", "language": "bun", "registry": {"npm_config_registry": "https://npm.example", "message": "for the user"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with.registry.npm_config_registry.as_deref(),
|
||||
Some("https://npm.example")
|
||||
);
|
||||
}
|
||||
|
||||
/// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's
|
||||
/// stdout, so `install_stderr` has to stay additive: a response without an install failure
|
||||
|
||||
@@ -52,7 +52,7 @@ use crate::{
|
||||
otel_oss::add_root_flow_job_to_otlp,
|
||||
worker_flow::update_flow_status_after_job_completion,
|
||||
JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload,
|
||||
UpdateFlow, SAME_WORKER_REQUIREMENTS,
|
||||
StepFailureKind, UpdateFlow, SAME_WORKER_REQUIREMENTS,
|
||||
};
|
||||
use windmill_common::client::AuthedClient;
|
||||
|
||||
@@ -465,6 +465,7 @@ pub fn start_background_processor(
|
||||
worker_dir,
|
||||
stop_early_override,
|
||||
token,
|
||||
step_failure,
|
||||
}),
|
||||
time,
|
||||
}) => {
|
||||
@@ -485,7 +486,7 @@ pub fn start_background_processor(
|
||||
None,
|
||||
Arc::new(result),
|
||||
None,
|
||||
true,
|
||||
step_failure,
|
||||
&same_worker_tx,
|
||||
&worker_dir,
|
||||
stop_early_override,
|
||||
@@ -791,7 +792,7 @@ pub async fn handle_receive_completed_job(
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
err,
|
||||
false,
|
||||
StepFailureKind::Normal,
|
||||
same_worker_tx.clone(),
|
||||
&worker_dir,
|
||||
worker_name,
|
||||
@@ -866,6 +867,20 @@ fn parse_git_sync_changes(result_raw: &str) -> Option<(Vec<(String, String)>, bo
|
||||
))
|
||||
}
|
||||
|
||||
/// The pull script reports an unmergeable PR as a top-level `pr_check_error`
|
||||
/// sentinel field. Matched as an exact field, never a substring: a successful
|
||||
/// diff lists user-controlled repo paths that could embed the sentinel text.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
fn parse_pr_check_error(result_raw: &str) -> Option<String> {
|
||||
serde_json::from_str::<serde_json::Value>(result_raw)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("pr_check_error")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
fn format_change_list(changes: &[(String, String)]) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
@@ -880,7 +895,19 @@ fn format_change_list(changes: &[(String, String)]) -> Vec<String> {
|
||||
|
||||
#[cfg(all(test, feature = "enterprise", feature = "private"))]
|
||||
mod git_sync_check_tests {
|
||||
use super::{format_change_list, parse_git_sync_changes};
|
||||
use super::{format_change_list, parse_git_sync_changes, parse_pr_check_error};
|
||||
|
||||
#[test]
|
||||
fn pr_check_error_is_a_field_not_a_substring() {
|
||||
// A diff whose paths embed the sentinel text must not trip the verdict.
|
||||
let diff = r#"{"changes":[{"type":"edited","path":"f/team/PR_MERGE_CONFLICTS.ts"}]}"#;
|
||||
assert_eq!(parse_pr_check_error(diff), None);
|
||||
let sentinel = r#"{"pr_check_error":"PR_MERGE_CONFLICTS","message":"m"}"#;
|
||||
assert_eq!(
|
||||
parse_pr_check_error(sentinel).as_deref(),
|
||||
Some("PR_MERGE_CONFLICTS")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_changes_is_in_sync() {
|
||||
@@ -972,7 +999,7 @@ async fn maybe_reconcile_git_sync_auto_pull(
|
||||
|
||||
/// Branch a git-sync push job deployed to, mirroring the hub script's
|
||||
/// derivation: a dev workspace deploys to its environment-label branch
|
||||
/// (`dev`/`staging`), other fork workspaces to `wm-fork/<base>/<id-suffix>`,
|
||||
/// (`dev`, `staging`, ...), other fork workspaces to `wm-fork/<base>/<id-suffix>`,
|
||||
/// else the promotion `wm_deploy/**` formula (per-folder or per-item form).
|
||||
/// A dev workspace in promotion mode is the exception: it takes the promotion
|
||||
/// `wm_deploy/**` formula (per-item PRs into the parent) instead of its label
|
||||
@@ -1390,8 +1417,37 @@ async fn maybe_post_git_sync_check(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Phase 4: dry-run diff preview for a PR.
|
||||
if !success {
|
||||
// Phase 4: dry-run diff preview for a PR. An unmergeable PR has no
|
||||
// diff; the pull script reports which sentinel applies (returned as a
|
||||
// result — thrown bun errors reach the job result as truncated log tails).
|
||||
let pr_check_error = parse_pr_check_error(result_raw);
|
||||
if pr_check_error.as_deref() == Some("PR_MERGE_CONFLICTS") {
|
||||
(
|
||||
"failure",
|
||||
"Merge conflicts with the base branch".to_string(),
|
||||
"This PR cannot be merged cleanly, so there is no deploy diff to compute. Resolve the conflicts and push again to re-run this check."
|
||||
.to_string(),
|
||||
)
|
||||
} else if pr_check_error.as_deref() == Some("PR_HEAD_REF_UNAVAILABLE") {
|
||||
// Neutral, not failure: a transient fetch problem is Windmill-side
|
||||
// and, unlike conflicts, has no fixing push that would re-run the
|
||||
// check on its own — it must not hard-block the PR.
|
||||
(
|
||||
"neutral",
|
||||
"Could not compute the deploy diff".to_string(),
|
||||
"Windmill could not fetch this PR's head or enough history from GitHub to compute its merge with the base. Push again to re-run this check."
|
||||
.to_string(),
|
||||
)
|
||||
} else if pr_check_error.is_some() {
|
||||
// Unknown sentinel (script newer than this backend): an explicit
|
||||
// error signal must not degrade into a "diff computed" verdict.
|
||||
(
|
||||
"failure",
|
||||
"Windmill diff failed".to_string(),
|
||||
"The dry-run pull reported an unrecognized error. See the job in Windmill for details."
|
||||
.to_string(),
|
||||
)
|
||||
} else if !success {
|
||||
(
|
||||
"failure",
|
||||
"Windmill diff failed".to_string(),
|
||||
@@ -1593,7 +1649,7 @@ pub async fn process_completed_job(
|
||||
canceled_by,
|
||||
result,
|
||||
started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }),
|
||||
false,
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
None,
|
||||
@@ -1700,7 +1756,7 @@ pub async fn process_completed_job(
|
||||
duration_ms: d,
|
||||
})
|
||||
}),
|
||||
false,
|
||||
StepFailureKind::Normal,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(),
|
||||
&worker_dir,
|
||||
None,
|
||||
@@ -1978,7 +2034,7 @@ pub async fn handle_job_error(
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
err: Error,
|
||||
unrecoverable: bool,
|
||||
step_failure: StepFailureKind,
|
||||
same_worker_tx: Option<&SameWorkerSender>,
|
||||
worker_dir: &str,
|
||||
worker_name: &str,
|
||||
@@ -2028,7 +2084,7 @@ pub async fn handle_job_error(
|
||||
canceled_by.clone(),
|
||||
Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()),
|
||||
None,
|
||||
unrecoverable,
|
||||
step_failure,
|
||||
&same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(),
|
||||
worker_dir,
|
||||
None,
|
||||
|
||||
@@ -1747,7 +1747,7 @@ pub async fn handle_all_job_kind_error(
|
||||
0,
|
||||
None,
|
||||
err,
|
||||
false,
|
||||
StepFailureKind::Normal,
|
||||
same_worker_tx,
|
||||
&worker_dir,
|
||||
&worker_name,
|
||||
@@ -3639,6 +3639,38 @@ pub struct UpdateFlow {
|
||||
pub worker_dir: String,
|
||||
pub stop_early_override: Option<bool>,
|
||||
pub token: String,
|
||||
pub step_failure: StepFailureKind,
|
||||
}
|
||||
|
||||
/// Why the step a flow is being resumed from failed, which bounds what the engine may do next.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StepFailureKind {
|
||||
/// The step failed by running, or did not fail at all.
|
||||
Normal,
|
||||
/// A suspend gate was disapproved or timed out. The worker that ran the approval step is
|
||||
/// still alive, but the failure is recorded against the step the gate was holding back,
|
||||
/// which never ran — so that step's `retry` and `continue_on_error` describe nothing that
|
||||
/// happened, and honouring them would re-open the gate or skip the step outright.
|
||||
/// `suspend.continue_on_disapprove_timeout` is how a flow opts into continuing past a gate.
|
||||
SuspendNotApproved,
|
||||
/// The step's worker died (OOM/zombie), or the flow status update itself errored. Neither
|
||||
/// leaves state worth pinning to: in the first case that worker is gone, in the second the
|
||||
/// flow's own bookkeeping is what just broke.
|
||||
Unrecoverable,
|
||||
}
|
||||
|
||||
impl StepFailureKind {
|
||||
/// Whether the failed module's own `retry` / `continue_on_error` still describe the
|
||||
/// failure at hand. When they don't, the failure module is the only way forward.
|
||||
pub fn honors_step_error_policy(self) -> bool {
|
||||
matches!(self, Self::Normal)
|
||||
}
|
||||
|
||||
/// Whether follow-up work may still be pinned to the worker that ran the previous step,
|
||||
/// via `same_worker` or dedicated flow-module runners.
|
||||
pub fn keeps_worker_pin(self) -> bool {
|
||||
!matches!(self, Self::Unrecoverable)
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_nativets(
|
||||
@@ -3940,8 +3972,8 @@ pub async fn handle_queued_job(
|
||||
flow_runners,
|
||||
&killpill_rx,
|
||||
// A freshly pulled flow job is being executed by a live worker; the prior
|
||||
// step (if any) completed normally, so this is never unrecoverable here.
|
||||
false,
|
||||
// step (if any) completed normally.
|
||||
StepFailureKind::Normal,
|
||||
))
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transfor
|
||||
use crate::js_eval::{eval_timeout, IdContext};
|
||||
use crate::worker_utils::get_tag_and_concurrency;
|
||||
use crate::{
|
||||
JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, UpdateFlow,
|
||||
KEEP_JOB_DIR,
|
||||
JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, StepFailureKind,
|
||||
UpdateFlow, KEEP_JOB_DIR,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -169,7 +169,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
canceled_by: Option<CanceledBy>,
|
||||
result: Arc<Box<RawValue>>,
|
||||
flow_job_duration: Option<FlowJobDuration>,
|
||||
unrecoverable: bool,
|
||||
step_failure: StepFailureKind,
|
||||
same_worker_tx: &SameWorkerSender,
|
||||
worker_dir: &str,
|
||||
stop_early_override: Option<bool>,
|
||||
@@ -192,7 +192,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
stop_early_override,
|
||||
has_triggered_error_handler: false,
|
||||
};
|
||||
let mut unrecoverable = unrecoverable;
|
||||
let mut step_failure = step_failure;
|
||||
loop {
|
||||
potentially_crash_for_testing();
|
||||
let nrec = match Box::pin(update_flow_status_after_job_completion_internal(
|
||||
@@ -205,7 +205,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
rec.canceled_by,
|
||||
rec.flow_job_duration.clone(),
|
||||
rec.result,
|
||||
unrecoverable,
|
||||
step_failure,
|
||||
same_worker_tx,
|
||||
worker_dir,
|
||||
rec.stop_early_override,
|
||||
@@ -234,7 +234,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
Arc::new(to_raw_value(&Json(&WrappedError {
|
||||
error: json!(e.to_string()),
|
||||
}))),
|
||||
true,
|
||||
StepFailureKind::Unrecoverable,
|
||||
same_worker_tx,
|
||||
worker_dir,
|
||||
rec.stop_early_override,
|
||||
@@ -249,7 +249,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
.await?
|
||||
}
|
||||
};
|
||||
unrecoverable = false;
|
||||
step_failure = StepFailureKind::Normal;
|
||||
|
||||
match nrec {
|
||||
UpdateFlowStatusAfterJobCompletion::Done(job) => {
|
||||
@@ -421,7 +421,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
canceled_by: Option<CanceledBy>,
|
||||
mut flow_job_duration: Option<FlowJobDuration>,
|
||||
result: Arc<Box<RawValue>>,
|
||||
unrecoverable: bool,
|
||||
step_failure: StepFailureKind,
|
||||
same_worker_tx: &SameWorkerSender,
|
||||
worker_dir: &str,
|
||||
stop_early_override: Option<bool>,
|
||||
@@ -1298,11 +1298,11 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// An unrecoverable failure (worker crash/OOM) must reach the error handler
|
||||
// even on a continue_on_error step, so don't advance the step counter past
|
||||
// the failed module — otherwise the flow would silently continue to the next
|
||||
// step and hide the worker death.
|
||||
let inc = if !unrecoverable && continue_on_error {
|
||||
// A failure the module's error policy does not describe must reach the error
|
||||
// handler even on a continue_on_error step, so don't advance the step
|
||||
// counter past the failed module — otherwise the flow would silently
|
||||
// continue to the next step and hide it.
|
||||
let inc = if step_failure.honors_step_error_policy() && continue_on_error {
|
||||
let retry = current_module
|
||||
.as_ref()
|
||||
.and_then(|x| x.retry.clone())
|
||||
@@ -1741,19 +1741,20 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
// enclosing job/subflow. Detect that case and treat the flow as successful.
|
||||
let recoverable_failure_at_last_step = !success
|
||||
&& is_last_step
|
||||
&& !unrecoverable
|
||||
&& step_failure.honors_step_error_policy()
|
||||
&& (skip_seq_branch_failure || skip_loop_failures || continue_on_error);
|
||||
|
||||
let should_continue_flow = match success {
|
||||
_ if stop_early => stop_early_err_msg.is_some() && flow_value.failure_module.is_some(), // if stop_early_err_msg some, we want to trigger the error handler before stopping the flow, if any
|
||||
_ if flow_job.is_canceled() => false,
|
||||
true => !is_last_step,
|
||||
// An unrecoverable failure (a step killed by a worker crash/OOM and surfaced by
|
||||
// the zombie handler, or an error raised while updating the flow status itself)
|
||||
// must not be retried or silently skipped, but it should still trigger the flow's
|
||||
// error handler: an OOM/worker death is precisely when the error handler is expected
|
||||
// to run. Continue the flow only to reach the failure module, never to retry.
|
||||
false if unrecoverable => {
|
||||
// A failure the module's error policy does not describe (a worker crash/OOM
|
||||
// surfaced by the zombie handler, an error raised while updating the flow status,
|
||||
// a suspend gate that ended without approval) must not be retried or silently
|
||||
// skipped, but it should still trigger the flow's error handler — that is
|
||||
// precisely when the error handler is expected to run. Continue the flow only to
|
||||
// reach the failure module, never to retry.
|
||||
false if !step_failure.honors_step_error_policy() => {
|
||||
!is_failure_step
|
||||
&& !has_triggered_error_handler
|
||||
&& flow_value.failure_module.is_some()
|
||||
@@ -1776,7 +1777,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
success = true;
|
||||
}
|
||||
|
||||
tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, unrecoverable = %unrecoverable,
|
||||
tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, step_failure = ?step_failure,
|
||||
skip_seq_branch_failure = %skip_seq_branch_failure, skip_loop_failures = %skip_loop_failures,
|
||||
current_module_id = %current_module.map(|x| x.id.clone()).unwrap_or_default(),
|
||||
continue_on_error = %continue_on_error, should_continue_flow = %should_continue_flow, "computed if flow should continue");
|
||||
@@ -2108,7 +2109,7 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
worker_name,
|
||||
flow_runners,
|
||||
&killpill_rx,
|
||||
unrecoverable,
|
||||
step_failure,
|
||||
))
|
||||
.warn_after_seconds(10)
|
||||
.await
|
||||
@@ -2799,10 +2800,9 @@ pub async fn handle_flow(
|
||||
worker_name: &str,
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
// The previous step failed unrecoverably (e.g. a worker crash/OOM surfaced by the
|
||||
// zombie handler). The next pushed step can only be the error handler (failure
|
||||
// module), and it must not be pinned to the dead worker via same_worker.
|
||||
unrecoverable: bool,
|
||||
// How the step this flow is resuming from failed, which bounds what may be pushed next:
|
||||
// see [`StepFailureKind`].
|
||||
step_failure: StepFailureKind,
|
||||
) -> anyhow::Result<()> {
|
||||
let flow = flow_data.value();
|
||||
|
||||
@@ -2984,7 +2984,7 @@ pub async fn handle_flow(
|
||||
flow_runners.clone(),
|
||||
job_completed_tx.clone(),
|
||||
&killpill_rx,
|
||||
unrecoverable,
|
||||
step_failure,
|
||||
))
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
@@ -3167,10 +3167,9 @@ async fn push_next_flow_job(
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
job_completed_tx: JobCompletedSender,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
// The prior step failed unrecoverably (worker crash/OOM). The only step pushed
|
||||
// from here is the error handler, which must run on a live worker rather than
|
||||
// being pinned to the dead one via same_worker / dedicated runners.
|
||||
unrecoverable: bool,
|
||||
// How the prior step failed, which bounds what may be pushed next: see
|
||||
// [`StepFailureKind`].
|
||||
step_failure: StepFailureKind,
|
||||
) -> error::Result<PushNextFlowJob> {
|
||||
let job_root = flow_job
|
||||
.flow_innermost_root_job
|
||||
@@ -3231,6 +3230,7 @@ async fn push_next_flow_job(
|
||||
w_id: flow_job.workspace_id.clone(),
|
||||
worker_dir: worker_dir.to_string(),
|
||||
token: client.token.clone(),
|
||||
step_failure,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -3285,6 +3285,7 @@ async fn push_next_flow_job(
|
||||
w_id: flow_job.workspace_id.clone(),
|
||||
worker_dir: worker_dir.to_string(),
|
||||
token: client.token.clone(),
|
||||
step_failure,
|
||||
}
|
||||
)));
|
||||
}
|
||||
@@ -3329,6 +3330,7 @@ async fn push_next_flow_job(
|
||||
w_id: flow_job.workspace_id.clone(),
|
||||
worker_dir: worker_dir.to_string(),
|
||||
token: client.token.clone(),
|
||||
step_failure,
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -3661,6 +3663,7 @@ async fn push_next_flow_job(
|
||||
w_id: flow_job.workspace_id.clone(),
|
||||
worker_dir: worker_dir.to_string(),
|
||||
token: client.token.clone(),
|
||||
step_failure: StepFailureKind::SuspendNotApproved,
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -3730,10 +3733,11 @@ async fn push_next_flow_job(
|
||||
}
|
||||
};
|
||||
|
||||
// An unrecoverable failure (worker crash/OOM) must not be retried — the original worker
|
||||
// and its state are gone — so skip retry evaluation and fall straight through to the
|
||||
// failure module below.
|
||||
let retry = if !unrecoverable && matches!(&status_module, FlowStatusModule::Failure { .. },) {
|
||||
// Retry is a policy on the step's own execution: skip it for a failure the step did not
|
||||
// produce by running, and fall straight through to the failure module below.
|
||||
let retry = if step_failure.honors_step_error_policy()
|
||||
&& matches!(&status_module, FlowStatusModule::Failure { .. },)
|
||||
{
|
||||
let retry = &module.retry.clone().unwrap_or_default();
|
||||
evaluate_retry(
|
||||
retry,
|
||||
@@ -3748,13 +3752,13 @@ async fn push_next_flow_job(
|
||||
None
|
||||
};
|
||||
let get_args_from_id = match &status_module {
|
||||
// `|| unrecoverable`: a worker crash/OOM routes to the failure module even on a
|
||||
// continue_on_error step (whose failures are normally tolerated), matching the
|
||||
// `unrecoverable` decision in update_flow_status_after_job_completion_internal.
|
||||
// `|| !honors_step_error_policy()`: such a failure routes to the failure module even
|
||||
// on a continue_on_error step (whose failures are normally tolerated), matching the
|
||||
// decision in update_flow_status_after_job_completion_internal.
|
||||
FlowStatusModule::Failure { job, .. }
|
||||
if retry.as_ref().is_some()
|
||||
|| !module.continue_on_error.is_some_and(|x| x)
|
||||
|| unrecoverable =>
|
||||
|| !step_failure.honors_step_error_policy() =>
|
||||
{
|
||||
if let Some((fail_count, retry_in)) = retry {
|
||||
tracing::debug!(
|
||||
@@ -4075,7 +4079,7 @@ async fn push_next_flow_job(
|
||||
None,
|
||||
result,
|
||||
None,
|
||||
false,
|
||||
StepFailureKind::Normal,
|
||||
same_worker_tx,
|
||||
worker_dir,
|
||||
None,
|
||||
@@ -4103,7 +4107,7 @@ async fn push_next_flow_job(
|
||||
.as_ref()
|
||||
.is_some_and(|fr| fr.job_id == flow_job.id);
|
||||
|
||||
let continue_with_runners = !unrecoverable
|
||||
let continue_with_runners = step_failure.keeps_worker_pin()
|
||||
&& (start_runners || (flow_runners.is_some() && !do_not_pass_runners))
|
||||
&& module.suspend.is_none()
|
||||
&& module.sleep.is_none();
|
||||
@@ -4113,10 +4117,10 @@ async fn push_next_flow_job(
|
||||
let job_same_worker = flow_job.same_worker
|
||||
&& matches!(flow_job.kind, JobKind::Flow)
|
||||
&& flow_job.runnable_id.is_some();
|
||||
// After an unrecoverable failure the original worker is gone, so the error handler
|
||||
// step is pushed as a regular queued job (any live worker can pick it up) instead of
|
||||
// being signaled to the dead worker via same_worker — which would strand it forever.
|
||||
let continue_on_same_worker = !unrecoverable
|
||||
// Without a worker worth pinning to, the error handler step is pushed as a regular queued
|
||||
// job (any live worker can pick it up) instead of being signaled via same_worker to a
|
||||
// worker that may be dead — which would strand it forever.
|
||||
let continue_on_same_worker = step_failure.keeps_worker_pin()
|
||||
&& (flow.same_worker || job_same_worker)
|
||||
&& module.suspend.is_none()
|
||||
&& module.sleep.is_none();
|
||||
|
||||
+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.780.0";
|
||||
export const VERSION = "v1.782.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
npm/
|
||||
gen/
|
||||
node_modules/
|
||||
windmill-utils-internal/.npmrc
|
||||
windmill-utils-internal/.npmrc
|
||||
|
||||
# bun.lock is the CLI's lockfile: every install path here (CI, build.sh, install_dev.sh)
|
||||
# runs `bun install`. A committed npm lockfile is read by nothing and goes stale unnoticed.
|
||||
package-lock.json
|
||||
|
||||
Generated
-1492
File diff suppressed because it is too large
Load Diff
@@ -6071,7 +6071,7 @@ const command = new Command()
|
||||
)
|
||||
.option(
|
||||
"--dev-workspace-label <label:string>",
|
||||
"Environment label of a dev workspace (dev/staging); its deploys go to that branch",
|
||||
"Environment label of a dev workspace (dev, staging, uat, ...); its deploys go to that branch",
|
||||
)
|
||||
.option(
|
||||
"--parent-dev-workspace-label <label:string>",
|
||||
|
||||
@@ -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.780.0";
|
||||
export const VERSION = "1.782.0";
|
||||
|
||||
@@ -46,7 +46,9 @@ WORKDIR /app
|
||||
# Copy the debug service files
|
||||
COPY dap_debug_service.ts .
|
||||
COPY dap_websocket_server_bun.ts .
|
||||
COPY env_passthrough.ts .
|
||||
COPY dap_websocket_server.py .
|
||||
COPY registry_config.ts .
|
||||
|
||||
# Expose the default port
|
||||
EXPOSE 5679
|
||||
|
||||
+94
-20
@@ -75,41 +75,107 @@ Options:
|
||||
| `DAP_NSJAIL_PATH` | nsjail binary path | nsjail |
|
||||
| `DAP_NSJAIL_CONFIG` | nsjail config file path | - |
|
||||
|
||||
### Python dependency preparation
|
||||
### Dependency preparation
|
||||
|
||||
Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which
|
||||
runs `uv` without a database connection. It cannot read the instance settings, so it takes its
|
||||
registry configuration from the environment of the debug service instead, and the Python server is
|
||||
handed the resulting venv with `--venv-path`. The install runs in the service rather than in the
|
||||
session because a private index URL usually embeds credentials and the Python server executes the
|
||||
debugged script inside its own interpreter, where anything it holds is readable by that script.
|
||||
Before debugging a script, its imports are installed through `windmill prepare-deps`, which runs
|
||||
`uv` (Python) or `bun install` (TypeScript) without a database connection. The install runs in the
|
||||
service rather than in the session because the registry configuration usually embeds credentials
|
||||
and a debug server executes the submitted script inside a process the script can read; the Python
|
||||
server is handed only the resulting venv, with `--venv-path`, and a Bun session only the resulting
|
||||
`node_modules`.
|
||||
|
||||
Set these on the debug service. Where two names are listed the first wins; a worker reads the
|
||||
`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are
|
||||
only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry
|
||||
with the other settings):
|
||||
`DAP_PREPARE_DEPS_TIMEOUT_MS` bounds the install (default 120000); past it the session starts
|
||||
without its dependencies. When the install fails, the CLI answers `success: false` and carries the
|
||||
installer's stderr in both `error` and `install_stderr`; the service reports it to the client as an
|
||||
`output` event, so the reason (unreachable mirror, untrusted certificate, unknown package) reaches
|
||||
the user instead of a bare `ModuleNotFoundError` at the first import.
|
||||
|
||||
### Registry configuration
|
||||
|
||||
Because `prepare-deps` has no database, the service reads the instance settings for it from
|
||||
`GET /api/debug/registry_config` on `WINDMILL_BASE_URL` and passes them down over the CLI's stdin
|
||||
request. It is authorized by the launch token of the session being started, and serves only the
|
||||
settings that session's own installer runs on, so a TypeScript session's token cannot be used to
|
||||
read the Python index credentials.
|
||||
|
||||
The token also reaches the browser, so what it can fetch is what a workspace member can fetch.
|
||||
Sessions started by an operator are refused outright, since an operator cannot run a preview job
|
||||
either; for a member who can, the npm settings are already exposed by a preview (a worker leaves
|
||||
the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in), while the Python
|
||||
index URL, which otherwise only appears as uv's argv, becomes readable where it was not before.
|
||||
|
||||
These settings are Enterprise-only, exactly as they are for jobs, and a CE instance reports that in
|
||||
the session's output rather than applying them:
|
||||
|
||||
| Setting | Applies to |
|
||||
|---------|------------|
|
||||
| `npm_config_registry` | `bun install` registry and its `:_authToken=` |
|
||||
| `npmrc` | written verbatim as `.npmrc`, taking precedence over `npm_config_registry` |
|
||||
| `bunfig_install_scopes` | `[install.scopes]` in the generated `bunfig.toml` |
|
||||
| `pip_index_url` | `uv --index-url` |
|
||||
| `pip_extra_index_url` | `uv --extra-index-url`, comma-separated |
|
||||
|
||||
`uv_index_strategy` is served on any edition, like it is to a worker. An index URL holding the
|
||||
`EPHEMERAL_TOKEN` placeholder is not served at all: only a worker can run the command that
|
||||
substitutes it.
|
||||
|
||||
The credential-bearing files (`.npmrc`, `bunfig.toml`) are written under
|
||||
`/var/tmp/windmill-debug-registry`, not into the directory the install runs in, and are deleted
|
||||
when the install ends. That directory is not private to the install: a session resolves its
|
||||
`node_modules` symlink back into it, and `nsjail.debug.config.proto` bind-mounts the whole of
|
||||
`/tmp` into every session, so credentials left there would be readable by a concurrent session.
|
||||
`/var/tmp` is a tmpfs in that same config, one instance per jail, so a session sees an empty one
|
||||
and a jailed install's credentials go away with the jail even when it is killed (the service kills
|
||||
an install with SIGKILL, which no cleanup in the installer can survive). An install running
|
||||
unjailed writes to the host's `/var/tmp` instead, where a directory a kill left behind is removed
|
||||
by the next install; a session running unjailed is unconfined anyway and sees the whole filesystem,
|
||||
as it already does the rest of the service's state.
|
||||
|
||||
The rest of the registry configuration has no instance setting and is read from the environment of
|
||||
the debug service. Where two names are listed the first wins; a worker reads the same names:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI |
|
||||
| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - |
|
||||
| `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - |
|
||||
| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - |
|
||||
| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - |
|
||||
| `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - |
|
||||
| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false |
|
||||
| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match |
|
||||
| `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default |
|
||||
| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 |
|
||||
| `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 |
|
||||
|
||||
When the install fails, the CLI answers `success: false` and carries the installer's stderr in both
|
||||
`error` and `install_stderr`; the service reports it to the client as an `output` event, so the
|
||||
reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a
|
||||
bare `ModuleNotFoundError` at the first import.
|
||||
`PY_INDEX_URL` / `PIP_INDEX_URL` and `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL`, along with
|
||||
`UV_INDEX_STRATEGY`, are still read from the same environment whenever the fetch yields no index:
|
||||
because the instance has none set, because this is a CE instance, or because the session was not
|
||||
allowed the settings. A Python debug service configured that way therefore keeps working, but setting
|
||||
them is an instance-wide decision to install Python dependencies from that index, independent of who
|
||||
opened the session; leave them unset to let the instance settings alone decide. The npm settings have
|
||||
no such fallback: the instance settings are the only source.
|
||||
|
||||
Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the
|
||||
service into each session, since the debugged script needs them for its own outbound calls, exactly
|
||||
as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults
|
||||
to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied.
|
||||
|
||||
Trust roots are forwarded alongside them: `SSL_CERT_FILE`, `SSL_CERT_DIR`, `REQUESTS_CA_BUNDLE`,
|
||||
`CURL_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`. Behind a TLS-intercepting proxy these are what let the
|
||||
debugged script's own HTTPS calls verify, and installing the CA in the container's system store is
|
||||
not enough on its own, since `requests` carries its own bundle and Node reads only
|
||||
`NODE_EXTRA_CA_CERTS`. Registry settings are deliberately not forwarded: they carry credentials and
|
||||
only the service needs them.
|
||||
|
||||
Registering that CA in the container's system store happens on its own: mount it into
|
||||
`/usr/local/share/ca-certificates/` **named `*.crt`**, the only extension `update-ca-certificates`
|
||||
reads, and `windmill_extra` runs it before starting any service. `RUN_UPDATE_CA_CERTIFICATE_AT_START=true` forces the same thing whether or not
|
||||
certificates are mounted there, and `RUN_UPDATE_CA_CERTIFICATE_PATH` overrides the tool, matching
|
||||
the server and worker. Both are best-effort: a UID that cannot write `/etc/ssl/certs` logs a warning
|
||||
and the container still boots. `INIT_SCRIPT` remains the hook for anything more involved, and unlike
|
||||
the CA update it aborts startup when it fails.
|
||||
|
||||
Note what the system store does *not* cover, which is most of what a debug session installs with:
|
||||
uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read
|
||||
only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib
|
||||
`ssl`, `curl` and `git`; the rest still needs the variables above.
|
||||
|
||||
Keeping the settings out of the session's environment only bounds what the debugged script can read
|
||||
from itself. An unsandboxed session runs under the same user as the service and can still read the
|
||||
service's environment through `/proc`, the same way a job can read a worker's when the worker runs
|
||||
@@ -117,6 +183,14 @@ unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config
|
||||
nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service
|
||||
out of reach, not the flag on its own.
|
||||
|
||||
The installer is jailed on the same terms, in both languages: `uv pip install` builds source
|
||||
distributions and `bun install` runs postinstall scripts, so a package's own code executes there
|
||||
too. It keeps the service's environment across that boundary — the config sets `keep_env`, which is
|
||||
how the settings above reach it — so replacing that with an allowlist would have to carry the
|
||||
registry and CA variables in explicitly. It also runs in its own process group, because `uv` and
|
||||
`bun` are grandchildren: signalling only the installer reparents them to init and they keep
|
||||
downloading, which would make the timeout and the cancel-on-disconnect half-measures.
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
```svelte
|
||||
|
||||
@@ -40,7 +40,14 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Import the working Bun debug session from the standalone server
|
||||
import { DebugSession as BunDebugSessionWorking, type NsjailConfig } from './dap_websocket_server_bun'
|
||||
import {
|
||||
DebugSession as BunDebugSessionWorking,
|
||||
killProcessTree,
|
||||
nsjailWrap,
|
||||
type NsjailConfig
|
||||
} from './dap_websocket_server_bun'
|
||||
import { sessionEnv } from './env_passthrough'
|
||||
import { fetchRegistryConfig, type RegistryConfig } from './registry_config'
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
@@ -348,78 +355,35 @@ interface SpawnOptions {
|
||||
cmd: string[]
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
stdin?: Blob
|
||||
/**
|
||||
* Hand the child this process's whole environment rather than the minimal set below. Only
|
||||
* the dependency installer wants it: the registry credentials and CA settings it reads are
|
||||
* precisely what the minimal set exists to keep away from a debugged script.
|
||||
*/
|
||||
inheritEnv?: boolean
|
||||
/**
|
||||
* Start the child in its own process group, so killProcessTree can signal what it spawns.
|
||||
*/
|
||||
detached?: boolean
|
||||
stdout?: 'pipe' | 'inherit'
|
||||
stderr?: 'pipe' | 'inherit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy settings forwarded to a debug session, matching what a worker gives a job's script.
|
||||
* spawnProcess intentionally does not inherit this process's environment, so an outbound proxy
|
||||
* is unreachable from a session unless these are passed explicitly. Registry settings are
|
||||
* deliberately absent: they carry credentials and are consumed by the service itself (see
|
||||
* PythonDebugSession.prepareDependencies).
|
||||
*/
|
||||
const SESSION_PROXY_ENV_VARS = [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
// The lowercase spellings take precedence in the worker, so forward both.
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy'
|
||||
]
|
||||
|
||||
/**
|
||||
* How long `windmill prepare-deps` may take before the session gives up on it and starts without
|
||||
* the dependencies. Raise it for slow private mirrors, where a large install can outlast the default.
|
||||
*/
|
||||
const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000
|
||||
|
||||
function sessionProxyEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const key of SESSION_PROXY_ENV_VARS) {
|
||||
const value = process.env[key]
|
||||
if (value) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
// A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it;
|
||||
// the worker defaults the same way (PROXY_ENVS in windmill-worker).
|
||||
if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) {
|
||||
env.NO_PROXY = 'localhost,127.0.0.1'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a process, optionally wrapped with nsjail.
|
||||
* This is the key function for sandboxed execution.
|
||||
*/
|
||||
function spawnProcess(options: SpawnOptions): Subprocess {
|
||||
let cmd = options.cmd
|
||||
const cmd = nsjailWrap(options.cmd, config.nsjail, options.cwd)
|
||||
|
||||
if (config.nsjail.enabled) {
|
||||
// Build nsjail command
|
||||
const nsjailCmd = [config.nsjail.binaryPath]
|
||||
|
||||
// Add config file if specified
|
||||
if (config.nsjail.configPath) {
|
||||
nsjailCmd.push('--config', config.nsjail.configPath)
|
||||
}
|
||||
|
||||
// Add any extra nsjail arguments
|
||||
nsjailCmd.push(...config.nsjail.extraArgs)
|
||||
|
||||
// Add working directory if specified
|
||||
if (options.cwd) {
|
||||
nsjailCmd.push('--cwd', options.cwd)
|
||||
}
|
||||
|
||||
// Separator and actual command
|
||||
nsjailCmd.push('--')
|
||||
nsjailCmd.push(...cmd)
|
||||
|
||||
cmd = nsjailCmd
|
||||
logger.info(`Spawning with nsjail: ${cmd.join(' ')}`)
|
||||
} else {
|
||||
logger.info(`Spawning: ${cmd.join(' ')}`)
|
||||
@@ -430,9 +394,12 @@ function spawnProcess(options: SpawnOptions): Subprocess {
|
||||
return spawn({
|
||||
cmd,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
...(options.stdin ? { stdin: options.stdin } : {}),
|
||||
...(options.detached ? { detached: true } : {}),
|
||||
stdout: options.stdout || 'pipe',
|
||||
stderr: options.stderr || 'pipe',
|
||||
env: {
|
||||
...(options.inheritEnv ? process.env : {}),
|
||||
// Essential system vars
|
||||
PATH: process.env.PATH || '/usr/bin:/bin',
|
||||
HOME: process.env.HOME,
|
||||
@@ -551,6 +518,8 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
private envVars: Record<string, string> = {}
|
||||
private windmillPath?: string
|
||||
private venvPath?: string
|
||||
private prepareDepsProcess: Subprocess | null = null
|
||||
private disposed = false
|
||||
private debugMode: boolean
|
||||
|
||||
constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) {
|
||||
@@ -682,21 +651,29 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
* Install the script's imports through `windmill prepare-deps` and return the venv to add to
|
||||
* the debugged script's sys.path.
|
||||
*
|
||||
* This runs here rather than in the Python server because the registry settings the CLI reads
|
||||
* (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python
|
||||
* server executes the submitted script inside its own interpreter: anything in that process is
|
||||
* recoverable by the script. The service never executes user code, so the credentials stop here.
|
||||
* This runs here rather than in the Python server because the registry settings the CLI is
|
||||
* given routinely embed private-registry credentials, and the Python server executes the
|
||||
* submitted script inside its own interpreter: anything in that process is recoverable by the
|
||||
* script. The service never executes user code, so the credentials stop here.
|
||||
*
|
||||
* The trade-off is that the install itself is not jailed, so a source distribution's build
|
||||
* backend runs outside nsjail, as it already does for Bun sessions.
|
||||
* It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee:
|
||||
* `uv pip install` builds source distributions, which executes their build backend's arbitrary
|
||||
* Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the
|
||||
* environment across the boundary, so nothing else has to carry them in.
|
||||
*/
|
||||
private async prepareDependencies(code: string): Promise<string | null> {
|
||||
private async prepareDependencies(code: string, registry: RegistryConfig): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
}
|
||||
|
||||
const warn = (reason: string): null => {
|
||||
// cleanup() kills the installer, which ends the read with nothing to parse. Reporting
|
||||
// that as an install failure blames the user for their own disconnect, on a websocket
|
||||
// that is being torn down anyway.
|
||||
if (this.disposed) {
|
||||
return null
|
||||
}
|
||||
logger.error(`prepare-deps failed: ${reason}`)
|
||||
this.sendEvent('output', {
|
||||
category: 'stderr',
|
||||
@@ -706,21 +683,33 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn({
|
||||
const proc = spawnProcess({
|
||||
cmd: [this.windmillPath, 'prepare-deps'],
|
||||
stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
// The venv has to be built against the interpreter that will run the script: its
|
||||
// site-packages goes on that interpreter's sys.path, and uv otherwise picks its
|
||||
// own, which silently leaves compiled extensions unimportable.
|
||||
stdin: new Blob([
|
||||
JSON.stringify({
|
||||
code,
|
||||
language: 'python3',
|
||||
python_path: config.pythonPath,
|
||||
registry
|
||||
}) + '\n'
|
||||
]),
|
||||
inheritEnv: true,
|
||||
detached: true
|
||||
})
|
||||
this.prepareDepsProcess = proc
|
||||
|
||||
// The launch response is already sent, so an install that never returns would leave the
|
||||
// client waiting on a session that never starts, with nothing on screen. The deadline
|
||||
// races the read rather than only killing the child: a grandchild holding the pipe open
|
||||
// keeps the read pending long after the child itself is gone.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
// spawnProcess's return type does not carry the piped stdio through
|
||||
const read = (async () => ({
|
||||
output: await new Response(proc.stdout).text(),
|
||||
stderr: await new Response(proc.stderr).text()
|
||||
output: await new Response(proc.stdout as ReadableStream).text(),
|
||||
stderr: await new Response(proc.stderr as ReadableStream).text()
|
||||
}))()
|
||||
const result = await Promise.race([
|
||||
read,
|
||||
@@ -729,9 +718,10 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
})
|
||||
])
|
||||
clearTimeout(timer)
|
||||
this.prepareDepsProcess = null
|
||||
|
||||
if (!result) {
|
||||
proc.kill()
|
||||
killProcessTree(proc)
|
||||
return warn(
|
||||
`dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
@@ -797,7 +787,7 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.process = spawnProcess({
|
||||
cmd,
|
||||
cwd,
|
||||
env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars }
|
||||
env: { PYTHONUNBUFFERED: '1', ...sessionEnv(), ...this.envVars }
|
||||
})
|
||||
|
||||
// Read stderr to capture startup messages
|
||||
@@ -1047,6 +1037,10 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
}
|
||||
|
||||
private async handleLaunch(request: DAPMessage): Promise<void> {
|
||||
// Per launch, not per session: cleanup() also runs when a program finishes normally, and
|
||||
// the flag must only mean "torn down while this launch was still preparing".
|
||||
this.disposed = false
|
||||
|
||||
const args = request.arguments || {}
|
||||
let code = args.code as string | undefined
|
||||
this.scriptPath = args.program as string | undefined
|
||||
@@ -1054,6 +1048,8 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.callMain = (args.callMain as boolean) || false
|
||||
this.mainArgs = (args.args as Record<string, unknown>) || {}
|
||||
this.envVars = (args.env as Record<string, string>) || {}
|
||||
// Also what authorizes the registry configuration fetch below.
|
||||
const token = args.token as string | undefined
|
||||
|
||||
// Enforce signing on every launch. The token is passed in the launch
|
||||
// arguments and is verified against the inline `code` (see windmill-api-debug).
|
||||
@@ -1067,7 +1063,6 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
return
|
||||
}
|
||||
|
||||
const token = args.token as string | undefined
|
||||
if (!token) {
|
||||
logger.error('No debug token provided but signed requests are required')
|
||||
this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.')
|
||||
@@ -1130,7 +1125,29 @@ sys.stdout.flush()
|
||||
|
||||
try {
|
||||
if (code) {
|
||||
this.venvPath = (await this.prepareDependencies(code)) ?? undefined
|
||||
const registry = await fetchRegistryConfig(token, logger)
|
||||
// A round trip of its own, during which the client can give up: the installer runs
|
||||
// a source distribution's build backend, so starting one for a session that is
|
||||
// already gone executes package code nobody is waiting for.
|
||||
if (this.disposed) {
|
||||
logger.info('Session torn down during the registry configuration fetch, not installing')
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
if (registry.message) {
|
||||
this.sendEvent('output', { category: 'console', output: `${registry.message}\n` })
|
||||
}
|
||||
this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined
|
||||
}
|
||||
|
||||
// Installing takes long enough for the client to give up meanwhile, and cleanup() has
|
||||
// then already run: starting the debuggee now would leave a process nothing owns
|
||||
// executing the script for a session that is gone. Clean up again on the way out,
|
||||
// since a teardown that landed before the script was written left it behind.
|
||||
if (this.disposed) {
|
||||
logger.info('Session torn down during dependency preparation, not starting Python')
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
await this.startPythonProcess(cwd)
|
||||
@@ -1188,6 +1205,14 @@ sys.stdout.flush()
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.disposed = true
|
||||
|
||||
// A client that gives up mid-install must not leave the package manager running
|
||||
if (this.prepareDepsProcess) {
|
||||
killProcessTree(this.prepareDepsProcess)
|
||||
this.prepareDepsProcess = null
|
||||
}
|
||||
|
||||
if (this.debugpyWs) {
|
||||
this.debugpyWs.close()
|
||||
this.debugpyWs = null
|
||||
|
||||
@@ -22,9 +22,12 @@
|
||||
*/
|
||||
|
||||
import { spawn, type Subprocess } from 'bun'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { sessionEnv } from './env_passthrough'
|
||||
import { fetchRegistryConfig, type RegistryConfig } from './registry_config'
|
||||
|
||||
// Types for V8 Inspector Protocol
|
||||
interface V8Message {
|
||||
@@ -556,6 +559,63 @@ export interface NsjailConfig {
|
||||
extraArgs?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a command so nsjail runs it, or return it unchanged when sandboxing is off.
|
||||
* The environment is not filtered here: the config sets `keep_env`, so the jailed process
|
||||
* receives whatever the spawning call gives it.
|
||||
*/
|
||||
export function nsjailWrap(cmd: string[], nsjail: NsjailConfig | undefined, cwd?: string): string[] {
|
||||
if (!nsjail?.enabled) {
|
||||
return cmd
|
||||
}
|
||||
const wrapped = [nsjail.binaryPath]
|
||||
if (nsjail.configPath) {
|
||||
wrapped.push('--config', nsjail.configPath)
|
||||
}
|
||||
if (nsjail.extraArgs) {
|
||||
wrapped.push(...nsjail.extraArgs)
|
||||
}
|
||||
if (cwd) {
|
||||
wrapped.push('--cwd', cwd)
|
||||
}
|
||||
wrapped.push('--', ...cmd)
|
||||
return wrapped
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGKILL a subprocess along with everything it spawned.
|
||||
*
|
||||
* SIGKILL because `windmill prepare-deps` does not act on SIGTERM while uv is running. The
|
||||
* whole group because uv is a grandchild: signalling the child alone reparents uv to init and
|
||||
* it keeps downloading. The group id is read back from /proc instead of assumed, since a group
|
||||
* kill aimed at this service's own group would take down every service in the container; a
|
||||
* child spawned without `detached` therefore only gets the plain kill.
|
||||
*/
|
||||
export function killProcessTree(proc: Subprocess): void {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
// Nothing left to signal, and the pid may already have been handed to someone else
|
||||
return
|
||||
}
|
||||
|
||||
let ownsGroup = false
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${proc.pid}/stat`, 'utf8')
|
||||
// The comm field can hold spaces and parentheses, so read the fields after its closing one
|
||||
ownsGroup = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]) === proc.pid
|
||||
} catch {
|
||||
// Already reaped, or not Linux: fall back to killing the process alone
|
||||
}
|
||||
try {
|
||||
if (ownsGroup) {
|
||||
process.kill(-proc.pid, 'SIGKILL')
|
||||
} else {
|
||||
proc.kill('SIGKILL')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill process:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VLQ (Variable-Length Quantity) decoder for source maps.
|
||||
* Returns array of decoded integers from VLQ string.
|
||||
@@ -753,6 +813,11 @@ export class DebugSession {
|
||||
// Path to installed node_modules (set after prepare-deps runs)
|
||||
private nodeModulesPath?: string
|
||||
|
||||
// Running dependency installer, so a teardown mid-install can stop it
|
||||
private prepareDepsProcess: Subprocess | null = null
|
||||
|
||||
private disposed = false
|
||||
|
||||
constructor(ws: WebSocket, options?: { nsjailConfig?: NsjailConfig; bunPath?: string; windmillPath?: string }) {
|
||||
this.ws = ws
|
||||
this.nsjailConfig = options?.nsjailConfig
|
||||
@@ -1437,6 +1502,10 @@ export class DebugSession {
|
||||
* Handle the 'launch' request.
|
||||
*/
|
||||
async handleLaunch(request: DAPMessage): Promise<void> {
|
||||
// Per launch, not per session: cleanup() also runs when a program finishes normally, and
|
||||
// the flag must only mean "torn down while this launch was still preparing".
|
||||
this.disposed = false
|
||||
|
||||
const args = request.arguments || {}
|
||||
let code = args.code as string | undefined
|
||||
this.scriptPath = args.program as string | undefined
|
||||
@@ -1444,6 +1513,8 @@ export class DebugSession {
|
||||
this.callMain = (args.callMain as boolean) || false
|
||||
this.mainArgs = (args.args as Record<string, unknown>) || {}
|
||||
this.envVars = (args.env as Record<string, string>) || {}
|
||||
// Also what authorizes the registry configuration fetch below.
|
||||
const token = args.token as string | undefined
|
||||
|
||||
// Enforce signing on every launch. The token is passed in the launch
|
||||
// arguments and is verified against the inline `code` (see windmill-api-debug).
|
||||
@@ -1457,7 +1528,6 @@ export class DebugSession {
|
||||
return
|
||||
}
|
||||
|
||||
const token = args.token as string | undefined
|
||||
if (!token) {
|
||||
logger.error('No debug token provided but signed requests are required')
|
||||
this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.')
|
||||
@@ -1489,7 +1559,29 @@ export class DebugSession {
|
||||
// Prepare dependencies using the original code (before any modifications)
|
||||
// This analyzes imports and installs required npm packages
|
||||
if (code) {
|
||||
this.nodeModulesPath = await this.prepareDependencies(code) || undefined
|
||||
const registry = await fetchRegistryConfig(token, logger)
|
||||
// A round trip of its own, during which the client can give up: the installer runs the
|
||||
// packages' postinstall scripts, so starting one for a session that is already gone
|
||||
// executes package code nobody is waiting for.
|
||||
if (this.disposed) {
|
||||
logger.info('Session was torn down during the registry configuration fetch, not installing')
|
||||
this.sendResponse(request, false, {}, 'Session terminated during dependency preparation')
|
||||
return
|
||||
}
|
||||
if (registry.message) {
|
||||
this.sendEvent('output', { category: 'console', output: `${registry.message}\n` })
|
||||
}
|
||||
this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined
|
||||
|
||||
// Installing takes long enough for the client to give up meanwhile, and cleanup() has
|
||||
// then already run: starting the debuggee now would leak a process nothing owns.
|
||||
// The response still goes out, since a client that terminated without closing the
|
||||
// socket is otherwise left waiting out its own launch timeout.
|
||||
if (this.disposed) {
|
||||
logger.info('Session was torn down during dependency preparation, not starting Bun')
|
||||
this.sendResponse(request, false, {}, 'Session terminated during dependency preparation')
|
||||
return
|
||||
}
|
||||
|
||||
// Remove version specifiers from imports (e.g., "lodash@4" -> "lodash")
|
||||
// This must happen AFTER prepareDependencies (which needs the versions)
|
||||
@@ -1572,8 +1664,21 @@ export class DebugSession {
|
||||
* Prepare dependencies by calling the windmill CLI's prepare-deps command.
|
||||
* This analyzes imports in the code and installs required npm packages.
|
||||
* Returns the path to node_modules if any were installed.
|
||||
*
|
||||
* Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall
|
||||
* scripts, which is user-supplied code executing next to the other services in the container.
|
||||
* Its environment is inherited rather than filtered, which is what carries the CA settings
|
||||
* into the installer (the jail keeps the environment across the boundary).
|
||||
*
|
||||
* The CLI has no database, so `registry` carries the instance's registry settings down to it
|
||||
* instead. They configure `bun install` and nothing else: the debugged script never gets
|
||||
* them, since it could read them back out of the process it runs in.
|
||||
*/
|
||||
private async prepareDependencies(code: string, language: string = 'bun'): Promise<string | null> {
|
||||
private async prepareDependencies(
|
||||
code: string,
|
||||
registry: RegistryConfig,
|
||||
language: string = 'bun'
|
||||
): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
@@ -1596,29 +1701,43 @@ export class DebugSession {
|
||||
let timedOut = false
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ code, language }) + '\n'
|
||||
const input = JSON.stringify({ code, language, registry }) + '\n'
|
||||
logger.info(`prepare-deps input length: ${input.length}`)
|
||||
|
||||
// Spawn the windmill binary with prepare-deps command
|
||||
// Spawn the windmill binary with prepare-deps command. Its environment is inherited
|
||||
// rather than filtered, which is what gives prepare-deps the container's index and
|
||||
// certificate settings; the allowlist above is what keeps them from the debugged
|
||||
// script, and the jail keeps them across its own boundary.
|
||||
const cmd = nsjailWrap([this.windmillPath, 'prepare-deps'], this.nsjailConfig)
|
||||
logger.info(`Spawning${this.nsjailConfig?.enabled ? ' with nsjail' : ''}: ${cmd.join(' ')}`)
|
||||
const proc = spawn({
|
||||
cmd: [this.windmillPath, 'prepare-deps'],
|
||||
cmd,
|
||||
stdin: new Blob([input]), // Use Blob for complete stdin data
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
stderr: 'pipe',
|
||||
// So the installer and the bun it spawns can be killed as one group
|
||||
detached: true
|
||||
})
|
||||
this.prepareDepsProcess = proc
|
||||
|
||||
// Bound the wait: the only other ceiling is the DAP client's launch timeout,
|
||||
// which is minutes, so a wedged installer would hang the session that long.
|
||||
killTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`)
|
||||
proc.kill()
|
||||
killProcessTree(proc)
|
||||
}, PREPARE_DEPS_TIMEOUT_MS)
|
||||
|
||||
// Wait for completion
|
||||
const output = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
// The read also ends when cleanup() kills the installer, which leaves no output to
|
||||
// parse. Reporting that as an install failure blames the user for their own Stop.
|
||||
if (this.disposed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s`
|
||||
this.sendEvent('output', {
|
||||
@@ -1676,6 +1795,9 @@ export class DebugSession {
|
||||
logger.info('No external dependencies to install')
|
||||
return null
|
||||
} catch (error) {
|
||||
if (this.disposed) {
|
||||
return null
|
||||
}
|
||||
logger.error(`Failed to prepare dependencies: ${error}`)
|
||||
this.sendEvent('output', {
|
||||
category: 'console',
|
||||
@@ -1685,6 +1807,7 @@ export class DebugSession {
|
||||
} finally {
|
||||
clearInterval(progress)
|
||||
clearTimeout(killTimer)
|
||||
this.prepareDepsProcess = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,24 +1823,13 @@ export class DebugSession {
|
||||
const inspectUrl = `127.0.0.1:${inspectPort}`
|
||||
|
||||
// Build the command - optionally wrapped with nsjail
|
||||
let cmd: string[] = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath]
|
||||
const cmd = nsjailWrap(
|
||||
[this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath],
|
||||
this.nsjailConfig,
|
||||
cwd
|
||||
)
|
||||
|
||||
if (this.nsjailConfig?.enabled) {
|
||||
const nsjailCmd = [this.nsjailConfig.binaryPath]
|
||||
|
||||
if (this.nsjailConfig.configPath) {
|
||||
nsjailCmd.push('--config', this.nsjailConfig.configPath)
|
||||
}
|
||||
|
||||
if (this.nsjailConfig.extraArgs) {
|
||||
nsjailCmd.push(...this.nsjailConfig.extraArgs)
|
||||
}
|
||||
|
||||
nsjailCmd.push('--cwd', cwd)
|
||||
nsjailCmd.push('--')
|
||||
nsjailCmd.push(...cmd)
|
||||
|
||||
cmd = nsjailCmd
|
||||
logger.info(`Starting Bun with nsjail: ${cmd.join(' ')}`)
|
||||
} else {
|
||||
logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`)
|
||||
@@ -1735,12 +1847,16 @@ export class DebugSession {
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
// Only include essential env vars + client-provided ones
|
||||
// Only include essential env vars + the network-config allowlist + client-provided ones.
|
||||
// Don't inherit all of process.env to keep debugger environment clean
|
||||
const envVars: Record<string, string | undefined> = {
|
||||
// Essential system vars
|
||||
PATH: process.env.PATH || '/usr/bin:/bin',
|
||||
HOME: process.env.HOME,
|
||||
// Proxy / TLS settings inherited from the container, before the client's env so an
|
||||
// explicit override still wins. Package-index settings are deliberately absent: this
|
||||
// runs user-supplied code and index URLs carry registry credentials.
|
||||
...sessionEnv(),
|
||||
// Client-provided env vars (WM_WORKSPACE, WM_TOKEN, etc.)
|
||||
// Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set
|
||||
...this.envVars
|
||||
@@ -2490,15 +2606,23 @@ export class DebugSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Clean up resources. Public because both servers call it when a client goes away.
|
||||
*/
|
||||
private async cleanup(): Promise<void> {
|
||||
async cleanup(): Promise<void> {
|
||||
this.disposed = true
|
||||
|
||||
// Close inspector connection
|
||||
if (this.inspectorWs) {
|
||||
this.inspectorWs.close()
|
||||
this.inspectorWs = null
|
||||
}
|
||||
|
||||
// A disconnect during dependency installation must not leave bun install running
|
||||
if (this.prepareDepsProcess) {
|
||||
killProcessTree(this.prepareDepsProcess)
|
||||
this.prepareDepsProcess = null
|
||||
}
|
||||
|
||||
// Kill process
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
@@ -2638,9 +2762,14 @@ if (import.meta.main) {
|
||||
logger.error('Error handling message:', error)
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
async close(ws) {
|
||||
logger.info('Client disconnected')
|
||||
sessions.delete(ws)
|
||||
const session = sessions.get(ws)
|
||||
if (session) {
|
||||
// Dropping the session without this leaves its installer and debuggee running
|
||||
await session.cleanup()
|
||||
sessions.delete(ws)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Container network configuration forwarded to a debug session, matching what a worker gives a
|
||||
* job's script. The session environment is built from an allowlist rather than inherited, so an
|
||||
* outbound proxy or a private CA is unreachable from a session unless these are passed
|
||||
* explicitly. Registry settings are deliberately absent: they carry credentials and are consumed
|
||||
* by the service itself (see PythonDebugSession.prepareDependencies).
|
||||
*
|
||||
* Lives in its own module because both session kinds build their own environment, and
|
||||
* dap_debug_service.ts already imports from dap_websocket_server_bun.ts.
|
||||
*/
|
||||
export const SESSION_ENV_VARS = [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
// The lowercase spellings take precedence in the worker, so forward both.
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy',
|
||||
// Trust roots for a TLS-intercepting proxy. Installing the CA in the container's system
|
||||
// store is not enough on its own: requests carries its own bundle and Node reads only
|
||||
// NODE_EXTRA_CA_CERTS, so a debugged script's own HTTPS calls fail without these.
|
||||
'SSL_CERT_FILE',
|
||||
'SSL_CERT_DIR',
|
||||
'REQUESTS_CA_BUNDLE',
|
||||
'CURL_CA_BUNDLE',
|
||||
'NODE_EXTRA_CA_CERTS'
|
||||
]
|
||||
|
||||
export function sessionEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const key of SESSION_ENV_VARS) {
|
||||
const value = process.env[key]
|
||||
if (value) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
// A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it;
|
||||
// the worker defaults the same way (PROXY_ENVS in windmill-worker).
|
||||
if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) {
|
||||
env.NO_PROXY = 'localhost,127.0.0.1'
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -63,6 +63,15 @@ mount {
|
||||
rw: true
|
||||
}
|
||||
|
||||
# Private scratch, one instance per jail. `windmill prepare-deps` writes the registry
|
||||
# credentials here rather than into its install directory under the shared /tmp above, so no
|
||||
# other session can read them, and they go away with the jail even when it is killed.
|
||||
mount {
|
||||
dst: "/var/tmp"
|
||||
fstype: "tmpfs"
|
||||
rw: true
|
||||
}
|
||||
|
||||
# Debugger scripts directory (for Python debugger server)
|
||||
mount {
|
||||
src: "/debugger"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Dependency-registry settings for a debug session's install.
|
||||
*
|
||||
* `windmill prepare-deps` installs a session's imports with no database connection, so the
|
||||
* instance settings that point at a private npm or pip registry cannot be read there. They
|
||||
* are fetched here instead, from the backend that signed the session's launch token, and
|
||||
* passed down to the CLI over its stdin request.
|
||||
*
|
||||
* They stop at the installer. A registry URL usually embeds credentials and a debugged
|
||||
* script can read whatever the process running it holds, so none of these values are ever
|
||||
* put in a session's environment (see README.md, "Registry configuration").
|
||||
*/
|
||||
|
||||
export interface RegistryConfig {
|
||||
npm_config_registry?: string
|
||||
npmrc?: string
|
||||
bunfig_install_scopes?: string
|
||||
pip_index_url?: string
|
||||
pip_extra_index_url?: string
|
||||
uv_index_strategy?: string
|
||||
/** Why the instance's settings are not in this response, for the user to see. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL
|
||||
|
||||
/**
|
||||
* Bounds how long a launch waits on the backend. The session can still start without the
|
||||
* settings, it just installs from the public registries, so an unreachable backend must
|
||||
* not hold it up for longer than the install itself would take.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS = Number(process.env.DAP_REGISTRY_CONFIG_TIMEOUT_MS) || 10_000
|
||||
|
||||
/**
|
||||
* Fetch the registry settings for a session, authorized by its launch token.
|
||||
*
|
||||
* Never throws and never blocks a launch: on any failure it returns a config carrying only
|
||||
* a `message`, so the session starts against the public registries and the user is told why
|
||||
* instead of being left with an unexplained "package not found".
|
||||
*/
|
||||
export async function fetchRegistryConfig(
|
||||
token: string | undefined,
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }
|
||||
): Promise<RegistryConfig> {
|
||||
if (!token || !WINDMILL_BASE_URL) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const url = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/registry_config`
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
||||
// Expected answers, not something the user can act on: a session that may not read
|
||||
// the settings (an operator's) is refused, and a backend older than this image has
|
||||
// no such route at all. Both install from the public registries.
|
||||
logger.info(`Registry configuration not served for this session (${response.status})`)
|
||||
return {}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text().catch(() => '')).trim()
|
||||
return {
|
||||
message: `Could not read the registry configuration (${response.status}): ${detail || response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
const config: RegistryConfig = await response.json()
|
||||
// The values carry registry credentials, so only their names are logged.
|
||||
const configured = Object.entries(config)
|
||||
.filter(([key, value]) => key !== 'message' && value)
|
||||
.map(([key]) => key)
|
||||
logger.info(
|
||||
configured.length > 0
|
||||
? `Registry configuration from instance settings: ${configured.join(', ')}`
|
||||
: 'No registry configuration set on the instance'
|
||||
)
|
||||
return config
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch registry configuration: ${error}`)
|
||||
return { message: `Could not read the registry configuration: ${error}` }
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,10 @@ services:
|
||||
# - DEBUG_ALLOWED_ORIGINS=https://your-windmill-host # Optional CSWSH hardening: comma-separated allowlist of browser Origins permitted to open debug WebSockets
|
||||
volumes:
|
||||
- lsp_cache:/pyls/.cache
|
||||
# Behind a TLS-intercepting proxy, mount its CA here (as .crt) and it is registered in the
|
||||
# system trust store before any service starts. That alone does not cover dependency
|
||||
# installation — see debugger/README.md for the variables it also needs
|
||||
# - ./corp-ca.crt:/usr/local/share/ca-certificates/corp-ca.crt:ro
|
||||
logging: *default-logging
|
||||
|
||||
caddy:
|
||||
|
||||
@@ -94,7 +94,9 @@ WORKDIR /debugger
|
||||
# Copy debugger files
|
||||
COPY debugger/dap_debug_service.ts .
|
||||
COPY debugger/dap_websocket_server_bun.ts .
|
||||
COPY debugger/env_passthrough.ts .
|
||||
COPY debugger/dap_websocket_server.py .
|
||||
COPY debugger/registry_config.ts .
|
||||
COPY debugger/nsjail.debug.config.proto .
|
||||
|
||||
# Install Python debugger dependencies using uv
|
||||
|
||||
@@ -33,6 +33,58 @@ if [ ! -w "$HOME" ]; then
|
||||
fi
|
||||
export HOME
|
||||
|
||||
# Register CA certificates mounted into the image before anything opens a TLS connection.
|
||||
# Best-effort on purpose, unlike INIT_SCRIPT below: a non-root UID cannot write /etc/ssl/certs, and
|
||||
# a deployment that never needed a custom CA must still boot. Env var names and the default-off
|
||||
# behavior match the server/worker binary, so one setting covers every container. What the system
|
||||
# trust store does and does not reach is documented in debugger/README.md.
|
||||
CA_CERT_DIR=/usr/local/share/ca-certificates
|
||||
|
||||
update_ca_certificates() {
|
||||
local reason="$1"
|
||||
local tool="${RUN_UPDATE_CA_CERTIFICATE_PATH:-/usr/sbin/update-ca-certificates}"
|
||||
local output
|
||||
if [ ! -x "$tool" ]; then
|
||||
echo "[entrypoint] $reason but $tool is not executable, skipping CA update"
|
||||
return
|
||||
fi
|
||||
echo "[entrypoint] $reason, running $tool"
|
||||
if output=$("$tool" 2>&1); then
|
||||
echo "[entrypoint] CA certificates updated"
|
||||
else
|
||||
# Carry the tool's own message: the usual cause is an unwritable /etc/ssl/certs under a
|
||||
# non-root UID, but guessing that in place of the real error hides everything else.
|
||||
echo "[entrypoint] WARNING: $tool failed (UID $(id -u)): ${output:-no output}; continuing" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$(echo "${RUN_UPDATE_CA_CERTIFICATE_AT_START:-false}" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
|
||||
update_ca_certificates "RUN_UPDATE_CA_CERTIFICATE_AT_START=true"
|
||||
elif [ -n "$(find -L "$CA_CERT_DIR" -type f -name '*.crt' -print -quit 2>/dev/null)" ]; then
|
||||
# Certificates mounted there are unambiguous intent, and they do nothing until registered, so
|
||||
# take the same action without making the operator also find the env var.
|
||||
update_ca_certificates "Found certificates in $CA_CERT_DIR"
|
||||
elif [ -n "$(ls -A "$CA_CERT_DIR" 2>/dev/null)" ]; then
|
||||
# Reporting success over a mount update-ca-certificates ignores would be worse than saying
|
||||
# nothing: .pem is the spelling people reach for, and only .crt is read.
|
||||
echo "[entrypoint] WARNING: $CA_CERT_DIR has files but none named *.crt, the only extension" \
|
||||
"update-ca-certificates reads; they will be ignored" >&2
|
||||
fi
|
||||
|
||||
# INIT_SCRIPT is the documented hook for preparing the host before anything reaches the network
|
||||
# (CA certificates, proxies, mounts), matching the worker's INIT_SCRIPT. It must therefore complete
|
||||
# before any service starts, and a failure has to abort: services that come up with an unprepared
|
||||
# trust store fail every TLS handshake instead, which is far harder to diagnose.
|
||||
if [ -n "$INIT_SCRIPT" ]; then
|
||||
echo "[entrypoint] Running INIT_SCRIPT..."
|
||||
bash -c "$INIT_SCRIPT" || {
|
||||
code=$?
|
||||
echo "[entrypoint] ERROR: INIT_SCRIPT failed with exit code $code, aborting" >&2
|
||||
exit "$code"
|
||||
}
|
||||
echo "[entrypoint] INIT_SCRIPT completed"
|
||||
fi
|
||||
|
||||
# Setup NETRC if provided (for LSP)
|
||||
if [ -n "$NETRC" ]; then
|
||||
echo "$NETRC" > "$HOME/.netrc"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.780.0",
|
||||
"version": "1.782.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.780.0",
|
||||
"version": "1.782.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.780.0",
|
||||
"version": "1.782.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",
|
||||
|
||||
@@ -280,8 +280,9 @@
|
||||
}
|
||||
|
||||
/* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both
|
||||
axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size
|
||||
via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the
|
||||
axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size
|
||||
via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes
|
||||
`thin` itself and spends ~11px of the box on it. Higher specificity than the
|
||||
app-wide `*::-webkit-scrollbar`, so it overrides it. */
|
||||
.scrollbar-subtle {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
|
||||
import { maskHasDraftRow } from './sessions/modifiedItemsMask'
|
||||
import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
|
||||
import DraftBadge from './DraftBadge.svelte'
|
||||
@@ -578,15 +579,9 @@
|
||||
trigger_azure: '/azure_triggers',
|
||||
trigger_email: '/email_triggers'
|
||||
}
|
||||
// A data-pipeline bundle is keyed at `f/<folder>/data_pipeline`; its editor
|
||||
// is the pipeline view of that folder.
|
||||
function pipelineFolderFromPath(path: string): string | undefined {
|
||||
const segs = path.split('/')
|
||||
return segs[0] === 'f' && segs.length >= 2 ? segs[1] : undefined
|
||||
}
|
||||
function draftEditUrl(d: Row): string | undefined {
|
||||
if (d.draftKind === 'data_pipeline') {
|
||||
const folder = pipelineFolderFromPath(d.path)
|
||||
const folder = pipelineFolderFromBundlePath(d.path)
|
||||
return folder
|
||||
? `/pipeline/${encodeURIComponent(folder)}?workspace=${encodeURIComponent(currentWorkspaceId)}`
|
||||
: undefined
|
||||
@@ -616,7 +611,7 @@
|
||||
// The pipeline bundle's storage path (`f/<folder>/data_pipeline`) is an
|
||||
// implementation detail — show the folder it belongs to.
|
||||
if (d.draftKind === 'data_pipeline') {
|
||||
const folder = pipelineFolderFromPath(d.path)
|
||||
const folder = pipelineFolderFromBundlePath(d.path)
|
||||
return folder ? `f/${folder}` : d.path
|
||||
}
|
||||
const path = d.draft_path ?? d.path
|
||||
|
||||
@@ -8,9 +8,21 @@
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import {
|
||||
findCanonicalDevWorkspace,
|
||||
findWorkspaceAncestors,
|
||||
findWorkspaceDescendants,
|
||||
devWorkspacesInChainAbove
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import {
|
||||
DEV_WORKSPACE_LABELS,
|
||||
devBadgeText,
|
||||
devLabelKey,
|
||||
devLabelNoun,
|
||||
type DevWorkspaceLabelKey
|
||||
} from '$lib/utils/devWorkspaceLabel'
|
||||
import DevWorkspaceLabelPicker from '$lib/components/workspaceSettings/DevWorkspaceLabelPicker.svelte'
|
||||
import {
|
||||
loadProtectionRules,
|
||||
isRuleActiveInRulesets,
|
||||
@@ -19,18 +31,31 @@
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { GitFork, ExternalLink, Check, Minus, Pen } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
// What this workspace promotes into its parent (deploy target + item filters). Rendered with
|
||||
// the pairing statement it restates, ahead of the parent's protections and of the nested
|
||||
// pairing below — hence handed over rather than placed by the page, which cannot see where
|
||||
// that boundary falls.
|
||||
deployTarget
|
||||
}: { deployTarget?: Snippet } = $props()
|
||||
|
||||
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
|
||||
let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label))
|
||||
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
|
||||
let parentWs = $derived(parentId ? $userWorkspaces.find((w) => w.id === parentId) : undefined)
|
||||
// A throwaway fork sits on neither side of a pairing: it is not its parent's dev workspace, and a
|
||||
// dev of its own would be discarded along with it.
|
||||
let isThrowawayFork = $derived(!!parentId && !isDev)
|
||||
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
|
||||
// A prod admin who isn't a member of the dev can't see it in their workspace list, so ask the
|
||||
// server (only when the client list doesn't already have it) — otherwise the tab would show the
|
||||
// attach form instead of the existing pairing and detach control.
|
||||
const devWorkspaceResource = resource(
|
||||
() => (!isDev && !parentId && !canonicalDev ? $workspaceStore : undefined),
|
||||
() => (!isThrowawayFork && !canonicalDev ? $workspaceStore : undefined),
|
||||
async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined)
|
||||
)
|
||||
// The paired dev to display: the client entry when we're a member, else the server result (pairing
|
||||
@@ -57,23 +82,40 @@
|
||||
let selectedDevId = $state<string | undefined>(undefined)
|
||||
let lockProdDeploy = $state(true)
|
||||
let lockProdForking = $state(true)
|
||||
// Cosmetic display label chosen when attaching an existing workspace as dev.
|
||||
let attachLabel = $state<'dev' | 'staging'>('dev')
|
||||
// The label names the deploy branch, and dev workspaces in a chain share their git-sync
|
||||
// repositories: two carrying the same label deploy to the same branch. So the picker steers away
|
||||
// from a label the dev workspaces above already hold. Computed without the selected candidate, so
|
||||
// picking one can never take the form away mid-selection.
|
||||
let chainTakenLabels = $derived(
|
||||
new Set(
|
||||
devWorkspacesInChainAbove($workspaceStore, $userWorkspaces).map((w) =>
|
||||
devLabelKey(w.dev_workspace_label)
|
||||
)
|
||||
)
|
||||
)
|
||||
let availableAttachLabels = $derived(DEV_WORKSPACE_LABELS.filter((l) => !chainTakenLabels.has(l)))
|
||||
let attachLabel = $state<DevWorkspaceLabelKey>('dev')
|
||||
// A candidate keeps its own dev workspaces through the attach, labels included: the first one
|
||||
// whose label is already spoken for further up the resulting chain blocks the pairing, whatever
|
||||
// label the candidate itself is given.
|
||||
let candidateClash = $derived.by(() => {
|
||||
if (!selectedDevId) return undefined
|
||||
const taken = new Set<string>([...chainTakenLabels, attachLabel])
|
||||
for (const w of findWorkspaceDescendants(selectedDevId, $userWorkspaces)) {
|
||||
if (!w.is_dev_workspace) continue
|
||||
const label = devLabelKey(w.dev_workspace_label)
|
||||
if (taken.has(label)) return w.id
|
||||
taken.add(label)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
let busy = $state(false)
|
||||
|
||||
// The pairing's locks always sit on the root (prod) workspace: this one when viewed from prod, the
|
||||
// parent's when viewed from inside the dev workspace. A plain fork has no pairing of its own, so it
|
||||
// reads nothing.
|
||||
let lockWorkspace = $derived(parentId ? (isDev ? parentId : undefined) : $workspaceStore)
|
||||
|
||||
// If this workspace already blocks direct deploy / forking through an existing protection rule, keep
|
||||
// the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule,
|
||||
// so turning it "off" here couldn't lift a separately-defined block. A failed fetch falls back to the
|
||||
// editable default-on toggle (real rules still enforce). Once paired, the same rules report which
|
||||
// locks are actually in force.
|
||||
const rootProtectionRules = resource(
|
||||
() => lockWorkspace,
|
||||
async (ws, _prev, { signal }) => {
|
||||
// Protection-rule state for one workspace. Instantiated twice because a dev workspace shows two
|
||||
// panels at once: the locks its own prod carries, and the locks it would carry as the prod of a
|
||||
// nested dev.
|
||||
function useProtectionRules(getWs: () => string | undefined) {
|
||||
const rules = resource(getWs, async (ws, _prev, { signal }) => {
|
||||
if (!ws) return undefined
|
||||
// `fetchProtectionRulesForWorkspace` fails open with an empty list, which the toggles below
|
||||
// want but the status panel must not read as "nothing is enforced" — so keep the failure.
|
||||
@@ -83,69 +125,103 @@
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch protection rules for workspace ${ws}:`, e)
|
||||
}
|
||||
// The generated client can't take an abort signal, so drop a superseded response here: a late
|
||||
// result for a previously selected workspace must not overwrite the current one's rules.
|
||||
// The generated client can't take an abort signal, so drop a superseded response here: a
|
||||
// late result for a previously selected workspace must not overwrite the current one's.
|
||||
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
|
||||
return { ws, rules: rules ?? [], failed: rules === undefined }
|
||||
})
|
||||
// Only trust a result that belongs to the workspace we're currently reading rules for (guards
|
||||
// the in-flight window and any out-of-order response); undefined means "not known yet" and is
|
||||
// treated as locked below.
|
||||
const result = $derived.by(() => {
|
||||
const current = rules.current
|
||||
return current && current.ws === getWs() ? current : undefined
|
||||
})
|
||||
const list = $derived(result?.rules)
|
||||
// Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as
|
||||
// engaged during that window so the toggle is locked on and the effective value stays true:
|
||||
// otherwise a user could turn a lock off and attach before an existing rule is detected,
|
||||
// sending false and omitting the reserved rule — leaving prod unprotected if that rule is
|
||||
// later removed.
|
||||
const unknown = $derived(rules.loading || list === undefined)
|
||||
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would
|
||||
// create; a bypassable rule stays editable, otherwise forcing the lock on would revoke the
|
||||
// bypassed users' direct-deploy / forking access.
|
||||
const alreadyBlocksDeploy = $derived(
|
||||
isRuleUnconditionallyActiveInRulesets(list ?? [], 'DisableDirectDeployment')
|
||||
)
|
||||
const alreadyBlocksForking = $derived(
|
||||
isRuleUnconditionallyActiveInRulesets(list ?? [], 'DisableWorkspaceForking')
|
||||
)
|
||||
// What the paired view reports. Unlike the toggles above, this asks whether the rule is
|
||||
// enforced at all: a ruleset with bypass users still blocks everyone outside that list, so it
|
||||
// is in force here even though the attach form leaves its toggle editable.
|
||||
const enforcesDeployBlock = $derived(
|
||||
isRuleActiveInRulesets(list ?? [], 'DisableDirectDeployment')
|
||||
)
|
||||
const enforcesForkingBlock = $derived(
|
||||
isRuleActiveInRulesets(list ?? [], 'DisableWorkspaceForking')
|
||||
)
|
||||
// The named rulesets actually carrying either lock. Reporting only "blocked / allowed" left no
|
||||
// way to reach the rule that decides it, which is the one thing a reader here wants to change.
|
||||
const enforcingRulesets = $derived(
|
||||
(list ?? []).filter(
|
||||
(r) =>
|
||||
r.rules.includes('DisableDirectDeployment') || r.rules.includes('DisableWorkspaceForking')
|
||||
)
|
||||
)
|
||||
// A failed read is not "nothing is enforced": report it as unknown rather than claiming allowed.
|
||||
const readFailed = $derived(result?.failed ?? false)
|
||||
return {
|
||||
get deployLocked() {
|
||||
return alreadyBlocksDeploy || unknown
|
||||
},
|
||||
get forkingLocked() {
|
||||
return alreadyBlocksForking || unknown
|
||||
},
|
||||
get alreadyBlocksDeploy() {
|
||||
return alreadyBlocksDeploy
|
||||
},
|
||||
get alreadyBlocksForking() {
|
||||
return alreadyBlocksForking
|
||||
},
|
||||
get enforcesDeployBlock() {
|
||||
return enforcesDeployBlock
|
||||
},
|
||||
get enforcesForkingBlock() {
|
||||
return enforcesForkingBlock
|
||||
},
|
||||
get enforcingRulesets() {
|
||||
return enforcingRulesets
|
||||
},
|
||||
get readFailed() {
|
||||
return readFailed
|
||||
},
|
||||
get enforcementUnknown() {
|
||||
return unknown || readFailed
|
||||
},
|
||||
refetch: () => rules.refetch()
|
||||
}
|
||||
)
|
||||
// Only trust a result that belongs to the workspace we're currently reading rules for (guards the
|
||||
// in-flight window and any out-of-order response); undefined means "not known yet" and is treated as
|
||||
// locked below.
|
||||
let rootResult = $derived.by(() => {
|
||||
const current = rootProtectionRules.current
|
||||
return current && current.ws === lockWorkspace ? current : undefined
|
||||
})
|
||||
let rootRules = $derived(rootResult?.rules)
|
||||
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
|
||||
// bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users'
|
||||
// direct-deploy / forking access.
|
||||
let alreadyBlocksDeploy = $derived(
|
||||
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
|
||||
)
|
||||
let alreadyBlocksForking = $derived(
|
||||
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
|
||||
)
|
||||
// Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as
|
||||
// engaged during that window so the toggle is locked on and the effective value stays true:
|
||||
// otherwise a user could turn a lock off and attach before an existing rule is detected, sending
|
||||
// false and omitting the reserved rule — leaving prod unprotected if that rule is later removed.
|
||||
let rulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined)
|
||||
let deployLocked = $derived(alreadyBlocksDeploy || rulesUnknown)
|
||||
let forkingLocked = $derived(alreadyBlocksForking || rulesUnknown)
|
||||
}
|
||||
|
||||
// The locks a pairing applies always sit on the prod side, so each panel reads its own workspace:
|
||||
// the parent's rules describe what this dev workspace is promoted into, this workspace's own
|
||||
// describe what its (possibly nested) dev workspace is promoted into.
|
||||
const parentRules = useProtectionRules(() => (isDev ? parentId : undefined))
|
||||
const ownRules = useProtectionRules(() => (isThrowawayFork ? undefined : $workspaceStore))
|
||||
|
||||
// Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the
|
||||
// toggle's raw state, keeping the request consistent with what the locked toggle shows.
|
||||
let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy)
|
||||
let effectiveLockProdForking = $derived(forkingLocked || lockProdForking)
|
||||
let effectiveLockProdDeploy = $derived(ownRules.deployLocked || lockProdDeploy)
|
||||
let effectiveLockProdForking = $derived(ownRules.forkingLocked || lockProdForking)
|
||||
|
||||
// What the paired view reports. Unlike the toggles above, this asks whether the rule is enforced at
|
||||
// all: a ruleset with bypass users still blocks everyone outside that list, so it is in force here
|
||||
// even though the attach form leaves its toggle editable.
|
||||
let enforcesDeployBlock = $derived(
|
||||
isRuleActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
|
||||
)
|
||||
let enforcesForkingBlock = $derived(
|
||||
isRuleActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
|
||||
)
|
||||
// A failed read is not "nothing is enforced": report it as unknown rather than claiming allowed.
|
||||
let enforcementReadFailed = $derived(rootResult?.failed ?? false)
|
||||
let enforcementUnknown = $derived(rulesUnknown || enforcementReadFailed)
|
||||
|
||||
// The named rulesets actually carrying either lock. Reporting only "blocked / allowed" left no way
|
||||
// to reach the rule that decides it, which is the one thing a reader here wants to change.
|
||||
let enforcingRulesets = $derived(
|
||||
(rootRules ?? []).filter(
|
||||
(r) =>
|
||||
r.rules.includes('DisableDirectDeployment') || r.rules.includes('DisableWorkspaceForking')
|
||||
)
|
||||
)
|
||||
// Editing prod's rules from the dev side needs admin IN PROD, which membership does not imply and
|
||||
// this workspace's own admin rights say nothing about: the rulesets tab is admin-only, so a link
|
||||
// offered to anyone else lands them on a tab they cannot open. Asked of the parent directly, as
|
||||
// `is_admin` is per-workspace. A superadmin is admin everywhere and has no `usr` row to find.
|
||||
// Tagged with its workspace and guarded against a superseded response, like the rules resource
|
||||
// above: runed keeps the previous `current` while a new source loads, so switching between dev
|
||||
// workspaces would otherwise offer Edit based on the previous parent's role.
|
||||
// Editing the parent's rules from the dev side needs admin IN THE PARENT, which membership does
|
||||
// not imply and this workspace's own admin rights say nothing about: the rulesets tab is
|
||||
// admin-only, so a link offered to anyone else lands them on a tab they cannot open. Asked of the
|
||||
// parent directly, as `is_admin` is per-workspace. A superadmin is admin everywhere and has no
|
||||
// `usr` row to find. Tagged with its workspace and guarded against a superseded response, like the
|
||||
// rules resource above: runed keeps the previous `current` while a new source loads, so switching
|
||||
// between dev workspaces would otherwise offer Edit based on the previous parent's role.
|
||||
const parentUser = resource(
|
||||
() => (isDev && parentId ? parentId : undefined),
|
||||
async (ws, _prev, { signal }) => {
|
||||
@@ -176,15 +252,22 @@
|
||||
goto(rulesetsHref(name))
|
||||
}
|
||||
|
||||
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
|
||||
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
|
||||
// prod), so it's excluded here.
|
||||
// Reparenting an ancestor below this workspace would close a parent<->child cycle, which the
|
||||
// backend rejects: with a dev workspace allowed as prod, the family root is such an ancestor and
|
||||
// would otherwise show up here as a standalone candidate.
|
||||
let ancestorIds = $derived(
|
||||
new Set(findWorkspaceAncestors($workspaceStore, $userWorkspaces).map((w) => w.id))
|
||||
)
|
||||
// A standalone root workspace, or an existing fork of this workspace (same family), can be
|
||||
// attached. A fork parented to a different workspace can't (the backend rejects a parent that
|
||||
// isn't this one), so it's excluded here.
|
||||
let attachCandidates = $derived(
|
||||
$userWorkspaces
|
||||
.filter(
|
||||
(w) =>
|
||||
w.id !== $workspaceStore &&
|
||||
w.id !== 'admins' &&
|
||||
!ancestorIds.has(w.id) &&
|
||||
(!w.parent_workspace_id || w.parent_workspace_id === $workspaceStore)
|
||||
)
|
||||
.map((w) => ({
|
||||
@@ -203,8 +286,8 @@
|
||||
// member of the dev workspace (the one that reads the pairing from the server rather than
|
||||
// from the workspace list) keeps seeing the pre-attach/detach state until the tab remounts.
|
||||
devWorkspaceResource.refetch()
|
||||
// Attach/detach changes this (root) workspace's protection rules; reload them so the
|
||||
// direct-deploy / forking lock UI reflects the change without a workspace switch or reload.
|
||||
// Attach/detach changes this workspace's protection rules; reload them so the direct-deploy /
|
||||
// forking lock UI reflects the change without a workspace switch or reload.
|
||||
// Refetching duplicates the request `loadProtectionRules` just made, which is the price of it
|
||||
// being the only way to supersede whatever this resource already has in flight: `mutate` just
|
||||
// assigns, so an earlier fetch lands afterwards and puts the pre-attach rules back on screen.
|
||||
@@ -213,7 +296,7 @@
|
||||
// action is cheaper than a panel that misreports what is enforced.
|
||||
if ($workspaceStore) {
|
||||
await loadProtectionRules($workspaceStore)
|
||||
rootProtectionRules.refetch()
|
||||
ownRules.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,41 +348,41 @@
|
||||
so the button does not switch workspaces without saying so. -->
|
||||
{#snippet protectionsPanel(opts: {
|
||||
title: string
|
||||
rules: ReturnType<typeof useProtectionRules>
|
||||
onOpen?: (name?: string) => void
|
||||
editLabel: string
|
||||
manageLabel: string
|
||||
})}
|
||||
{@const onOpen = opts.onOpen}
|
||||
{@const rules = opts.rules}
|
||||
<div class="flex flex-col gap-1 rounded-md border bg-surface-secondary p-3">
|
||||
<span class="text-xs font-semibold text-emphasis">{opts.title}</span>
|
||||
{#if enforcementUnknown}
|
||||
{#if rules.enforcementUnknown}
|
||||
<span class="text-2xs text-secondary">
|
||||
{enforcementReadFailed
|
||||
? 'Could not read the protection rules'
|
||||
: 'Checking protection rules…'}
|
||||
{rules.readFailed ? 'Could not read the protection rules' : 'Checking protection rules…'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesDeployBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
{#if rules.enforcesDeployBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Direct edits {enforcesDeployBlock ? 'are blocked' : 'are allowed'}
|
||||
Direct edits {rules.enforcesDeployBlock ? 'are blocked' : 'are allowed'}
|
||||
</span>
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesForkingBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
{#if rules.enforcesForkingBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Forking {enforcesForkingBlock ? 'is blocked' : 'is allowed'}
|
||||
Forking {rules.enforcesForkingBlock ? 'is blocked' : 'is allowed'}
|
||||
</span>
|
||||
{#if enforcesDeployBlock || enforcesForkingBlock}
|
||||
{#if rules.enforcesDeployBlock || rules.enforcesForkingBlock}
|
||||
<!-- Only admins reach this tab, and `check_user_against_rule` lets an admin through
|
||||
every rule, so without this the reader would try what the panel calls blocked. -->
|
||||
<span class="text-2xs text-secondary">Workspace admins always bypass these rules.</span>
|
||||
{/if}
|
||||
{#if enforcingRulesets.length > 0}
|
||||
{#if rules.enforcingRulesets.length > 0}
|
||||
<div class="flex flex-col gap-1 mt-2 pt-2 border-t">
|
||||
<span class="text-2xs text-secondary">Enforced by</span>
|
||||
{#each enforcingRulesets as ruleset (ruleset.name)}
|
||||
{#each rules.enforcingRulesets as ruleset (ruleset.name)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-2xs font-mono text-emphasis truncate">{ruleset.name}</span>
|
||||
@@ -322,7 +405,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if onOpen && enforcingRulesets.length === 0}
|
||||
{#if onOpen && rules.enforcingRulesets.length === 0}
|
||||
<div class="self-start mt-1">
|
||||
<Button variant="subtle" unifiedSize="2xs" onclick={() => onOpen()}>
|
||||
{opts.manageLabel}
|
||||
@@ -332,10 +415,142 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if isDev && parentId}
|
||||
<!-- This workspace as the prod side of a pairing: the dev it already has, or the form to give it one.
|
||||
Rendered for a root workspace and, since a dev workspace can host its own dev, for a dev
|
||||
workspace too. -->
|
||||
{#snippet ownDevSection()}
|
||||
{#if pairedDev}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b>
|
||||
({pairedDev.id}). Edits to this workspace are redirected there.
|
||||
</p>
|
||||
{@render protectionsPanel({
|
||||
title: 'Protections in force on this workspace',
|
||||
rules: ownRules,
|
||||
onOpen: openRulesets,
|
||||
editLabel: 'Edit',
|
||||
manageLabel: 'Manage in Rulesets'
|
||||
})}
|
||||
<div class="flex gap-2">
|
||||
{#if pairedDev.isMember || $superadmin}
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => switchWorkspace(pairedDev.id)}
|
||||
>
|
||||
Go to {devLabelNoun(pairedDev.label)}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if availableAttachLabels.length === 0}
|
||||
<p class="text-sm text-secondary max-w-2xl">
|
||||
Every environment label is already taken by a dev workspace in this chain, and two carrying
|
||||
the same label would deploy to the same branch. Promote through the existing chain instead.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm text-secondary">
|
||||
Pair this workspace with a dev workspace: the same code with a different environment
|
||||
(resource and variable values). Edits are made in the dev workspace and promoted here.
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Attach an existing workspace as dev</span>
|
||||
<Select
|
||||
items={attachCandidates}
|
||||
bind:value={selectedDevId}
|
||||
placeholder="Select a workspace"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<DevWorkspaceLabelPicker bind:value={attachLabel} takenLabels={chainTakenLabels} />
|
||||
<!-- Left under the label row rather than replacing it: flipping the label is often the fix. -->
|
||||
{#if candidateClash}
|
||||
<p class="text-2xs text-secondary">
|
||||
<b>{candidateClash}</b> comes with the selected workspace and already deploys to a branch this
|
||||
chain would then use twice. Pick a different label, or another workspace.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis">Protect this workspace on attach</span>
|
||||
<span class="text-2xs text-secondary">
|
||||
Nothing is enforced until you attach: these add protection rules to this workspace so
|
||||
changes are made in the dev workspace and promoted here.
|
||||
</span>
|
||||
</div>
|
||||
{#if ownRules.deployLocked}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle
|
||||
checked
|
||||
disabled
|
||||
options={{
|
||||
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
|
||||
}}
|
||||
/>
|
||||
{#if ownRules.alreadyBlocksDeploy}
|
||||
<span class="text-2xs text-secondary ml-11"
|
||||
>Already enforced by an existing protection rule</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Toggle
|
||||
bind:checked={lockProdDeploy}
|
||||
options={{
|
||||
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if ownRules.forkingLocked}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle checked disabled options={{ right: 'Prevent forking this workspace' }} />
|
||||
{#if ownRules.alreadyBlocksForking}
|
||||
<span class="text-2xs text-secondary ml-11"
|
||||
>Already enforced by an existing protection rule</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Toggle
|
||||
bind:checked={lockProdForking}
|
||||
options={{ right: 'Prevent forking this workspace' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={busy || !selectedDevId || !!candidateClash}
|
||||
onclick={attach}
|
||||
>
|
||||
Attach dev workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => goto(`${base}/user/fork_workspace?dev=true`)}
|
||||
>
|
||||
Create a new dev workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if isThrowawayFork}
|
||||
<p class="text-sm text-secondary max-w-2xl">
|
||||
Dev workspace pairing is not available on a throwaway fork. This workspace is a fork of
|
||||
<b>{parentId}</b>.
|
||||
</p>
|
||||
{@render deployTarget?.()}
|
||||
{:else if isDev && parentId}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with root workspace
|
||||
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with
|
||||
{parentWs?.is_dev_workspace ? devLabelNoun(parentWs.dev_workspace_label) : 'root workspace'}
|
||||
<b>{parentId}</b>. Promote changes from the home page banner or the Compare & Deploy page.
|
||||
</p>
|
||||
<div class="text-2xs text-secondary">
|
||||
@@ -345,11 +560,15 @@
|
||||
<span class="font-mono">{currentLabel}</span> branch.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{@render deployTarget?.()}
|
||||
<div class="flex flex-col gap-3 max-w-2xl mt-6">
|
||||
<!-- A reader who is not a member of the parent gets a 403 listing its rules, which is expected
|
||||
here rather than an anomaly worth a permanent error box, so drop the panel instead. -->
|
||||
{#if !enforcementReadFailed}
|
||||
here rather than an anomaly worth a permanent error box, so drop the panel instead. -->
|
||||
{#if !parentRules.readFailed}
|
||||
{@render protectionsPanel({
|
||||
title: `Protections in force on ${parentId}`,
|
||||
rules: parentRules,
|
||||
onOpen: canEditParentRules ? openRulesetsInParent : undefined,
|
||||
editLabel: `Edit in ${parentId}`,
|
||||
manageLabel: `Manage in ${parentId}`
|
||||
@@ -361,123 +580,23 @@
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
onclick={() => switchWorkspace(parentId)}
|
||||
>
|
||||
Go to root workspace
|
||||
Go to {parentWs?.is_dev_workspace
|
||||
? devLabelNoun(parentWs.dev_workspace_label)
|
||||
: 'root workspace'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if pairedDev}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
|
||||
Edits to this workspace are redirected there.
|
||||
</p>
|
||||
{@render protectionsPanel({
|
||||
title: 'Protections in force on this workspace',
|
||||
onOpen: openRulesets,
|
||||
editLabel: 'Edit',
|
||||
manageLabel: 'Manage in Rulesets'
|
||||
})}
|
||||
<div class="flex gap-2">
|
||||
{#if pairedDev.isMember || $superadmin}
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => switchWorkspace(pairedDev.id)}
|
||||
>
|
||||
Go to {devLabelNoun(pairedDev.label)}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if parentId}
|
||||
<p class="text-sm text-secondary max-w-2xl">
|
||||
Dev workspace pairing is only available for root workspaces. This workspace is a fork of
|
||||
<b>{parentId}</b>.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm text-secondary">
|
||||
Pair this workspace with a dev workspace: the same code with a different environment (resource
|
||||
and variable values). Edits are made in the dev workspace and promoted here.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 max-w-2xl mt-8 pt-6 border-t">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Attach an existing workspace as dev</span>
|
||||
<Select
|
||||
items={attachCandidates}
|
||||
bind:value={selectedDevId}
|
||||
placeholder="Select a workspace"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="text-2xs text-secondary">
|
||||
Label: <Badge color="indigo" small>{devBadgeText(attachLabel)}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
class="text-secondary hover:text-primary hover:underline"
|
||||
onclick={() => (attachLabel = attachLabel === 'staging' ? 'dev' : 'staging')}
|
||||
>
|
||||
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis">Protect this workspace on attach</span>
|
||||
<span class="text-2xs text-secondary">
|
||||
Nothing is enforced until you attach: these add protection rules to this workspace so
|
||||
changes are made in the dev workspace and promoted here.
|
||||
</span>
|
||||
</div>
|
||||
{#if deployLocked}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle
|
||||
checked
|
||||
disabled
|
||||
options={{
|
||||
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
|
||||
}}
|
||||
/>
|
||||
{#if alreadyBlocksDeploy}
|
||||
<span class="text-2xs text-secondary ml-11"
|
||||
>Already enforced by an existing protection rule</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Toggle
|
||||
bind:checked={lockProdDeploy}
|
||||
options={{
|
||||
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if forkingLocked}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Toggle checked disabled options={{ right: 'Prevent forking this workspace' }} />
|
||||
{#if alreadyBlocksForking}
|
||||
<span class="text-2xs text-secondary ml-11"
|
||||
>Already enforced by an existing protection rule</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Toggle
|
||||
bind:checked={lockProdForking}
|
||||
options={{ right: 'Prevent forking this workspace' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="accent" disabled={busy || !selectedDevId} onclick={attach}>
|
||||
Attach dev workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => goto(`${base}/user/fork_workspace?dev=true`)}
|
||||
>
|
||||
Create a new dev workspace
|
||||
</Button>
|
||||
<span class="text-xs font-semibold text-emphasis">This workspace's own dev workspace</span>
|
||||
<p class="text-2xs text-secondary">
|
||||
A dev workspace can itself be paired with one, giving a longer promotion chain (for example
|
||||
dev into staging into prod). It is not the recommended shape — each extra level is another
|
||||
promotion to run — but nothing prevents it.
|
||||
</p>
|
||||
</div>
|
||||
{@render ownDevSection()}
|
||||
</div>
|
||||
{:else}
|
||||
{@render ownDevSection()}
|
||||
{/if}
|
||||
|
||||
@@ -2203,6 +2203,12 @@
|
||||
} else {
|
||||
ed.setValue(next)
|
||||
}
|
||||
// The write above went through `onDidChangeModelContent`, which arms the
|
||||
// keystroke debounce as if the user had typed. Monaco now holds exactly
|
||||
// `code`, so there is nothing to flush — and leaving the timer armed makes
|
||||
// every "is Monaco the newer side?" check (notably the unmount flush) answer
|
||||
// yes on the strength of our own write.
|
||||
cancelPendingChanges()
|
||||
}
|
||||
|
||||
// External `code` prop changes should flow into the Monaco editor. Skip
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import FlowLogRow from './FlowLogRow.svelte'
|
||||
import { Tooltip } from './meltComponents'
|
||||
import FlowTimelineBar from './FlowTimelineBar.svelte'
|
||||
import { getActiveReplay } from './recording/flowRecording.svelte'
|
||||
import { getActiveReplay } from './recording/replay.svelte'
|
||||
|
||||
type RootJobData = Partial<Job>
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import FlowRestartButton from './FlowRestartButton.svelte'
|
||||
import { useNestedRestartState } from './useNestedRestartState.svelte'
|
||||
import { createFlowRecording, setActiveRecording } from './recording/flowRecording.svelte'
|
||||
import type { FlowRecording } from './recording/types'
|
||||
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
|
||||
|
||||
interface Props {
|
||||
previewMode: 'upTo' | 'whole'
|
||||
@@ -146,9 +145,11 @@
|
||||
let stepHistoryLoader = getStepHistoryLoaderContext()
|
||||
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
|
||||
|
||||
// Recording & replay
|
||||
let flowRecording = createFlowRecording()
|
||||
let lastRecording: FlowRecording | undefined = $state(undefined)
|
||||
// Recording: nothing is captured live — once the run completes, its id is
|
||||
// remembered and the recording is built from the completed jobs on download.
|
||||
let lastRecordingJobId: string | undefined = $state(undefined)
|
||||
let recordedFlow: OpenFlow | undefined = undefined
|
||||
let downloadingRecording: boolean = $state(false)
|
||||
let recordingMode: boolean = $state(false)
|
||||
|
||||
export function setRecordingMode(on: boolean) {
|
||||
@@ -196,6 +197,13 @@
|
||||
lastPreviewFlow = JSON.stringify(flowStore.val)
|
||||
flowProgressBar?.reset()
|
||||
const newFlow = extractFlow(previewMode)
|
||||
if (recordingMode) {
|
||||
// Any completed run in recording mode becomes downloadable, so pair
|
||||
// the flow as it is for *this* run — not a stale earlier capture.
|
||||
// Deep-cloned: extractFlow('whole') returns the live store object,
|
||||
// and edits made before download must not rewrite the run's definition.
|
||||
recordedFlow = JSON.parse(JSON.stringify(newFlow))
|
||||
}
|
||||
args = await processSecretArgs(args, flowStore.val.schema as any, opWs)
|
||||
newJobId = await runFlowPreview(
|
||||
args,
|
||||
@@ -265,49 +273,27 @@
|
||||
}
|
||||
|
||||
async function recordAndTest() {
|
||||
lastRecording = undefined
|
||||
flowRecording.start($pathStore)
|
||||
flowRecording.setFlow(extractFlow(previewMode))
|
||||
setActiveRecording(flowRecording)
|
||||
lastRecordingJobId = undefined
|
||||
await runPreview(previewArgs.val, undefined)
|
||||
}
|
||||
|
||||
function collectSubJobIds(flowStatus: Job['flow_status']): string[] {
|
||||
if (!flowStatus) return []
|
||||
const ids: string[] = []
|
||||
for (const mod of flowStatus.modules ?? []) {
|
||||
if (mod.job) ids.push(mod.job)
|
||||
if (mod.flow_jobs) ids.push(...mod.flow_jobs)
|
||||
}
|
||||
if (flowStatus.failure_module?.job) ids.push(flowStatus.failure_module.job)
|
||||
if (flowStatus.preprocessor_module?.job) ids.push(flowStatus.preprocessor_module.job)
|
||||
return ids
|
||||
}
|
||||
|
||||
async function recordSubJobs(completedJob: Job) {
|
||||
const subJobIds = collectSubJobIds(completedJob.flow_status)
|
||||
await Promise.all(
|
||||
subJobIds.map(async (subId) => {
|
||||
try {
|
||||
const subJob = await JobService.getJob({
|
||||
workspace: opWs!,
|
||||
id: subId
|
||||
})
|
||||
flowRecording.addCompletedJob(subId, subJob)
|
||||
// Recurse into nested flows (flow-within-flow)
|
||||
if (subJob.flow_status) {
|
||||
await recordSubJobs(subJob)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[recording] failed to fetch sub-job', subId, e)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function downloadRecording() {
|
||||
if (lastRecording) {
|
||||
flowRecording.download(lastRecording)
|
||||
async function downloadRecording() {
|
||||
if (!lastRecordingJobId || downloadingRecording) return
|
||||
downloadingRecording = true
|
||||
try {
|
||||
// A run started before recording mode was enabled captured no flow —
|
||||
// fall back to the flow as it is now.
|
||||
const recording = await buildFlowRecording(
|
||||
opWs!,
|
||||
lastRecordingJobId,
|
||||
$pathStore,
|
||||
recordedFlow ?? extractFlow(previewMode)
|
||||
)
|
||||
downloadRecordingJson(recording, `flow-recording-${$pathStore.replace(/\//g, '-')}`)
|
||||
} catch (e: any) {
|
||||
sendUserToast('Could not build the recording', true, undefined, e?.toString())
|
||||
} finally {
|
||||
downloadingRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,24 +314,6 @@
|
||||
scrollableDiv && render && untrack(() => onScrollableDivChange())
|
||||
})
|
||||
|
||||
// During recording, watch sub-job SSE streams to capture incremental logs
|
||||
$effect(() => {
|
||||
// Only watch sub-jobs for the current job (jobId is set after runPreview starts)
|
||||
if (flowRecording.active && job?.flow_status && job?.id === jobId) {
|
||||
const modules = job.flow_status.modules ?? []
|
||||
untrack(() => {
|
||||
for (const mod of modules) {
|
||||
if (mod.job) {
|
||||
flowRecording.watchSubJob(mod.job, opWs!)
|
||||
}
|
||||
}
|
||||
if (job?.flow_status?.failure_module?.job) {
|
||||
flowRecording.watchSubJob(job.flow_status.failure_module.job, opWs!)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export async function cancelTest() {
|
||||
isRunning = false
|
||||
try {
|
||||
@@ -418,7 +386,7 @@
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{#if flowRecording.active}
|
||||
{#if recordingMode}
|
||||
<span class="text-xs text-red-500 font-medium flex items-center gap-1">
|
||||
<Circle size={8} fill="currentColor" /> Recording
|
||||
</span>
|
||||
@@ -473,12 +441,13 @@
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if lastRecording && recordingMode}
|
||||
{#if lastRecordingJobId && recordingMode}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
title="Download recording"
|
||||
on:click={downloadRecording}
|
||||
loading={downloadingRecording}
|
||||
startIcon={{ icon: Download }}
|
||||
>
|
||||
Download recording
|
||||
@@ -682,13 +651,8 @@
|
||||
stepHistoryLoader?.resetInitial(mod.id)
|
||||
}
|
||||
}
|
||||
if (flowRecording.active) {
|
||||
lastRecording = flowRecording.stop()
|
||||
setActiveRecording(undefined)
|
||||
// Fetch sub-job data and add to recording (they load after the root completes)
|
||||
await recordSubJobs(completedJob)
|
||||
// Trigger reactivity update for lastRecording
|
||||
lastRecording = lastRecording
|
||||
if (recordingMode && completedJob?.id) {
|
||||
lastRecordingJobId = completedJob.id
|
||||
}
|
||||
onJobDone?.()
|
||||
}}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
AI_TOOL_MESSAGE_PREFIX,
|
||||
AI_MCP_TOOL_CALL_PREFIX,
|
||||
AI_WEBSEARCH_PREFIX,
|
||||
getAgentActionStateId,
|
||||
getToolCallId
|
||||
} from './graph/renderers/nodes/AIToolNode.svelte'
|
||||
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
|
||||
@@ -60,7 +61,7 @@
|
||||
import { SelectionManager } from './graph/selectionUtils.svelte'
|
||||
import { useThrottle } from 'runed'
|
||||
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
||||
import { getActiveReplay } from './recording/flowRecording.svelte'
|
||||
import { getActiveReplay } from './recording/replay.svelte'
|
||||
import { publishLinkedAgentTools } from './flows/flowState'
|
||||
import {
|
||||
getLinkedAgentTools,
|
||||
@@ -761,34 +762,28 @@
|
||||
|
||||
if (mod.agent_actions && mod.id) {
|
||||
setModuleState(mod.id, {
|
||||
agent_actions: mod.agent_actions
|
||||
agent_actions: mod.agent_actions,
|
||||
agent_actions_success: mod.agent_actions_success
|
||||
})
|
||||
mod.agent_actions.forEach((action, idx) => {
|
||||
if (mod.id) {
|
||||
if (action.type == 'tool_call') {
|
||||
const toolCallId = getToolCallId(idx, mod.id, action.module_id)
|
||||
const success = mod.agent_actions_success?.[idx]
|
||||
setModuleState(toolCallId, {
|
||||
job_id: action.job_id,
|
||||
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
||||
})
|
||||
} else if (action.type == 'mcp_tool_call') {
|
||||
const mcpToolCallId = AI_MCP_TOOL_CALL_PREFIX + '-' + mod.id + '-' + idx
|
||||
const success = mod.agent_actions_success?.[idx]
|
||||
setModuleState(mcpToolCallId, {
|
||||
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
||||
})
|
||||
} else if (action.type == 'web_search') {
|
||||
const websearchId = AI_WEBSEARCH_PREFIX + '-' + mod.id + '-' + idx
|
||||
setModuleState(websearchId, {
|
||||
type: 'Success'
|
||||
})
|
||||
} else if (action.type == 'message') {
|
||||
const toolCallId = getToolCallId(idx, mod.id)
|
||||
setModuleState(toolCallId, {
|
||||
type: 'Success'
|
||||
})
|
||||
}
|
||||
if (!mod.id) {
|
||||
return
|
||||
}
|
||||
const stateId = getAgentActionStateId(idx, mod.id, action)
|
||||
const success = mod.agent_actions_success?.[idx]
|
||||
if (action.type == 'tool_call') {
|
||||
setModuleState(stateId, {
|
||||
job_id: action.job_id,
|
||||
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
||||
})
|
||||
} else if (action.type == 'mcp_tool_call') {
|
||||
setModuleState(stateId, {
|
||||
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
|
||||
})
|
||||
} else {
|
||||
setModuleState(stateId, {
|
||||
type: 'Success'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import type { WorkspaceComparison } from '$lib/gen'
|
||||
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
|
||||
@@ -26,12 +26,25 @@
|
||||
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
|
||||
let isFork = $derived(parentWorkspaceId != null)
|
||||
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
|
||||
// Operators run scripts and flows, they never deploy a fork, so the banner and
|
||||
// its CTA are noise for them. Gates the fetches too, not just the markup: the
|
||||
// fork/parent comparison is an expensive tally no operator can act on.
|
||||
//
|
||||
// Only a role fetched for the workspace we are on answers this. `$workspaceStore`
|
||||
// flips synchronously on a switch while `$userStore` still holds the workspace we
|
||||
// left, so trusting it unqualified would flash the banner at (and start the tally
|
||||
// for) an operator entering a fork from a workspace where they are not one.
|
||||
function isConfirmedNonOperator(user: UserExt | undefined, ws: string | undefined): boolean {
|
||||
return !!user && !!ws && user.workspace_id === ws && !user.operator
|
||||
}
|
||||
let isNotOperator = $derived(isConfirmedNonOperator($userStore, $workspaceStore))
|
||||
let showBanner = $derived(isFork && isNotOperator)
|
||||
|
||||
// Drafts in this fork. When the fork is otherwise in sync with its parent, a
|
||||
// user with only pending drafts should still get the draft CTA (mirrors the
|
||||
// non-fork WorkspaceDraftsBanner). Pass undefined when not a fork so it doesn't
|
||||
// fetch.
|
||||
const drafts = useWorkspaceDrafts(() => (isFork ? ($workspaceStore ?? undefined) : undefined))
|
||||
// non-fork WorkspaceDraftsBanner). Pass undefined when the banner is hidden so
|
||||
// it doesn't fetch.
|
||||
const drafts = useWorkspaceDrafts(() => (showBanner ? ($workspaceStore ?? undefined) : undefined))
|
||||
const draftCount = $derived(drafts.count)
|
||||
|
||||
// Every read of `comparison` that decides what the banner says or where its button
|
||||
@@ -63,10 +76,12 @@
|
||||
resetCiTestSummary()
|
||||
}
|
||||
|
||||
// `isNotOperator` is a dependency of its own: it only turns true once this
|
||||
// workspace's role has landed, which is after the switch that triggered it.
|
||||
$effect(() => {
|
||||
;[$workspaceStore, parentWorkspaceId]
|
||||
;[$workspaceStore, parentWorkspaceId, isNotOperator]
|
||||
untrack(() => {
|
||||
if (isFork && $workspaceStore) {
|
||||
if (showBanner && $workspaceStore) {
|
||||
checkForChanges()
|
||||
} else {
|
||||
dropComparison()
|
||||
@@ -75,7 +90,7 @@
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (isFork && $workspaceStore) {
|
||||
if (showBanner && $workspaceStore) {
|
||||
checkForChanges()
|
||||
} else {
|
||||
dropComparison()
|
||||
@@ -229,7 +244,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isFork}
|
||||
{#if showBanner}
|
||||
<!-- Side padding mirrors the page content container below, so the banner
|
||||
stays aligned with it instead of bleeding to the viewport edges. -->
|
||||
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import FlowPlugConnect from './FlowPlugConnect.svelte'
|
||||
import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import S3ArrayHelperButton from './S3ArrayHelperButton.svelte'
|
||||
import { inputBorderClass } from './text_input/TextInput.svelte'
|
||||
@@ -157,15 +156,12 @@
|
||||
const propPickerWrapperContext: PropPickerWrapperContext | undefined =
|
||||
getContext<PropPickerWrapperContext>('PropPickerWrapper')
|
||||
const pickerMode = $derived(propPickerWrapperContext?.pickerMode?.() ?? 'pane')
|
||||
// Settings rows hand their properties to the wrapper, not to this form.
|
||||
const connectableProperties = $derived(
|
||||
pickableProperties ?? propPickerWrapperContext?.pickableProperties?.()
|
||||
)
|
||||
const {
|
||||
inputMatches,
|
||||
connectProp: focusProp,
|
||||
propPickerConfig,
|
||||
clearConnect: clearFocus,
|
||||
openPicker,
|
||||
exprBeingEdited
|
||||
} = propPickerWrapperContext ?? {}
|
||||
|
||||
@@ -365,6 +361,18 @@
|
||||
})
|
||||
}
|
||||
|
||||
/** A predicate is usually half-written when you reach for a property, so insert at the
|
||||
* cursor and leave the rest of the expression alone. Only a field that isn't an
|
||||
* expression yet gets replaced outright. */
|
||||
function pickIntoArg(path: string) {
|
||||
if (propertyType === 'javascript' && monaco) {
|
||||
propPickerWrapperContext?.onPick?.(path)
|
||||
} else {
|
||||
connectProperty(path)
|
||||
}
|
||||
dispatch('change', { argName })
|
||||
}
|
||||
|
||||
function connectProperty(rawValue: string) {
|
||||
// Extract path from variable('x') or resource('x') format
|
||||
const varMatch = variableMatch(rawValue)
|
||||
@@ -465,8 +473,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The column beside a settings row delivers here rather than through the host's `select`
|
||||
// handler, which can only reach a mounted expression editor. A collapsed setting has no
|
||||
// field at all, so it gives the target up and the column closes with it.
|
||||
$effect(() => {
|
||||
if (pickerMode !== 'sidePane') return
|
||||
propPickerWrapperContext?.setPickTarget?.(
|
||||
collapsed ? undefined : { id: argName, onSelect: pickIntoArg }
|
||||
)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
updatePropsBeingEdited(false)
|
||||
if (pickerMode === 'sidePane') {
|
||||
propPickerWrapperContext?.setPickTarget?.(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
let prevArg: any = undefined
|
||||
@@ -555,8 +576,10 @@
|
||||
{#if (arg != undefined || collapsed) && !hidden}
|
||||
<div class={twMerge('relative group flex flex-col gap-1', className)}>
|
||||
<!-- `relative` so the absolute button cluster below anchors to this row rather than
|
||||
to the whole field, letting it share the label's baseline. -->
|
||||
<div class="relative flex flex-row flex-wrap justify-between gap-1">
|
||||
to the whole field, letting it share the label's baseline. `w-full` so an
|
||||
`align-items` on the caller's class can't shrink the row to its label and pull
|
||||
`right-0` onto it. -->
|
||||
<div class="relative w-full flex flex-row flex-wrap justify-between gap-1">
|
||||
<!-- min-h-7 reserves room for the button cluster beside a plain label; a custom
|
||||
header is a control of its own and sets the row's height itself. -->
|
||||
<div class="flex grow items-end {header ? '' : 'min-h-7'}">
|
||||
@@ -613,27 +636,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if propPickerWrapperContext && pickerMode === 'popover'}
|
||||
<!-- Settings rows have no picker pane, so the properties hang off the
|
||||
button itself, exactly as the other expression inputs do. -->
|
||||
<ExpressionPicker
|
||||
id={argName}
|
||||
pickableProperties={connectableProperties}
|
||||
result={propPickerWrapperContext.result?.()}
|
||||
extraResults={propPickerWrapperContext.extraResults?.()}
|
||||
onSelect={(path) => {
|
||||
// A predicate is usually half-written when you reach for a property, so
|
||||
// insert at the cursor and leave the rest of the expression alone. Only
|
||||
// a field that isn't an expression yet gets replaced outright.
|
||||
if (propertyType === 'javascript' && monaco) {
|
||||
propPickerWrapperContext.onPick?.(path)
|
||||
} else {
|
||||
connectProperty(path)
|
||||
}
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
/>
|
||||
{:else if propPickerWrapperContext}
|
||||
{#if propPickerWrapperContext}
|
||||
<FlowPlugConnect
|
||||
wrapperClasses={twMerge(
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
@@ -646,8 +649,12 @@
|
||||
clearFocus()
|
||||
} else {
|
||||
focusProp?.(argName, (path) => {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
if (pickerMode === 'sidePane') {
|
||||
pickIntoArg(path)
|
||||
} else {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -937,7 +944,12 @@
|
||||
{/snippet}
|
||||
</ArgInput>
|
||||
{:else if argKind === 'javascript' && arg.expr != undefined}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- Reaching for the editor reveals the properties beside it. On pointerdown,
|
||||
not focus: an editor that was never blurred emits no focus event, so a
|
||||
column dismissed while it kept the caret could not be brought back. -->
|
||||
<div
|
||||
onpointerdown={() => openPicker?.()}
|
||||
class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`}
|
||||
>
|
||||
<SimpleEditor
|
||||
@@ -952,6 +964,7 @@
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
updatePropsBeingEdited(true)
|
||||
openPicker?.()
|
||||
}}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
|
||||
@@ -21,11 +21,7 @@
|
||||
import type { SupportedLanguage } from '$lib/common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { DynamicInput, isScriptPreview } from '$lib/utils'
|
||||
import {
|
||||
getActiveRecording,
|
||||
getActiveReplay,
|
||||
getReplayStartTime
|
||||
} from './recording/flowRecording.svelte'
|
||||
import { getActiveReplay, getReplayStartTime } from './recording/replay.svelte'
|
||||
|
||||
// Will be set to number if job is not a flow
|
||||
|
||||
@@ -714,7 +710,6 @@
|
||||
)
|
||||
|
||||
callbacks?.change?.(job)
|
||||
getActiveRecording()?.recordInitialJob(id, job)
|
||||
}
|
||||
|
||||
if (!onlyResult) {
|
||||
@@ -824,7 +819,6 @@
|
||||
throw new Error('Not found')
|
||||
}
|
||||
jobUpdateLastFetch = new Date()
|
||||
getActiveRecording()?.recordEvent(id, previewJobUpdates)
|
||||
|
||||
if (job) {
|
||||
updateJobFromProgress(previewJobUpdates, job, callbacks)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Badge for a workspace locked against direct edits, with the rule names, the route into the dev
|
||||
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
|
||||
* affordance for it to explain.
|
||||
*/
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
canUserBypassRuleKind,
|
||||
@@ -6,11 +11,13 @@
|
||||
isRuleActive
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Alert, Button } from './common'
|
||||
import { GitFork } from 'lucide-svelte'
|
||||
import { Badge, Button } from './common'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
|
||||
|
||||
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
|
||||
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
|
||||
@@ -24,7 +31,10 @@
|
||||
: 'You will need to make your changes locally and submit a PR to an authorized user.'
|
||||
)
|
||||
let overrideChecked = $state(false)
|
||||
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || (canBypass && overrideChecked))
|
||||
// The toggle is only offered to a user who can bypass, but the answer can change under a
|
||||
// workspace switch, so the checked flag alone never grants the edit.
|
||||
let bypassActive = $derived(canBypass && overrideChecked)
|
||||
let canEdit = $derived(!isRuleActive('DisableDirectDeployment') || bypassActive)
|
||||
|
||||
let {
|
||||
onUpdateCanEditStatus = (value) => {}
|
||||
@@ -35,50 +45,76 @@
|
||||
$effect(() => {
|
||||
onUpdateCanEditStatus(canEdit)
|
||||
})
|
||||
|
||||
let badgeLabel = $derived(
|
||||
bypassActive
|
||||
? 'Protection bypassed'
|
||||
: canonicalDev
|
||||
? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}`
|
||||
: 'Edits restricted'
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
|
||||
<div class="my-2">
|
||||
<Alert
|
||||
type="info"
|
||||
title={canonicalDev
|
||||
? `Edits happen in the ${devLabelNoun(canonicalDev.dev_workspace_label)}`
|
||||
: 'Workspace protection active'}
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
class="inline-flex items-center"
|
||||
triggerAttrs={{ 'aria-label': badgeLabel }}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if canonicalDev}
|
||||
<p>
|
||||
Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)}
|
||||
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
btnClasses="w-auto"
|
||||
{#snippet trigger()}
|
||||
<!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the
|
||||
one Popover wraps its trigger in. -->
|
||||
<Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer">
|
||||
{#if bypassActive}
|
||||
<ShieldOff class="h-3 w-3" />
|
||||
{:else if canonicalDev}
|
||||
<GitFork class="h-3 w-3" />
|
||||
{:else}
|
||||
<Lock class="h-3 w-3" />
|
||||
{/if}
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 p-4 text-xs max-w-sm">
|
||||
{#if canonicalDev}
|
||||
<p class="text-primary">
|
||||
Edits to this workspace are made in its {devLabelNoun(
|
||||
canonicalDev.dev_workspace_label
|
||||
)}
|
||||
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
btnClasses="w-auto"
|
||||
size="xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => {
|
||||
if (canonicalDev) switchWorkspace(canonicalDev.id)
|
||||
}}
|
||||
>
|
||||
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-primary">
|
||||
The rule{activeDeployRulesets.length > 1 ? 's' : ''}
|
||||
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
|
||||
restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct edits to this workspace.
|
||||
{editAdvice}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canBypass}
|
||||
<Toggle
|
||||
size="xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: GitFork }}
|
||||
onclick={() => {
|
||||
if (canonicalDev) switchWorkspace(canonicalDev.id)
|
||||
}}
|
||||
>
|
||||
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
The rule{activeDeployRulesets.length > 1 ? 's' : ''}
|
||||
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
|
||||
restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct edits to this workspace.
|
||||
{editAdvice}
|
||||
</p>
|
||||
{/if}
|
||||
{#if canBypass}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input class="rounded max-w-4" type="checkbox" bind:checked={overrideChecked} />
|
||||
<span class="text-xs">Bypass restriction</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</Alert>
|
||||
bind:checked={overrideChecked}
|
||||
options={{ right: 'Bypass restriction' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -125,9 +125,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
|
||||
import { resource, watch } from 'runed'
|
||||
import { createScriptRecording } from './recording/scriptRecording.svelte'
|
||||
import { setActiveRecording } from './recording/flowRecording.svelte'
|
||||
import type { ScriptRecording } from './recording/types'
|
||||
import { buildScriptRecording, downloadRecordingJson } from './recording/runRecording'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -743,9 +741,21 @@
|
||||
let pastPreviewsRequest: ReturnType<typeof JobService.listCompletedJobs> | undefined
|
||||
let validCode = $state(true)
|
||||
|
||||
// Recording
|
||||
let scriptRecording = createScriptRecording()
|
||||
let lastRecording: ScriptRecording | undefined = $state(undefined)
|
||||
// Recording: nothing is captured live — a "record" run just remembers the
|
||||
// completed job id plus the code/args/schema as they were at run time, and
|
||||
// the recording is built from the completed job on download.
|
||||
let recordingArmed = false
|
||||
let lastRecordingJobId: string | undefined = $state(undefined)
|
||||
let recordingMeta:
|
||||
| {
|
||||
scriptPath: string
|
||||
code: string
|
||||
language: string
|
||||
args: Record<string, any>
|
||||
schema?: Record<string, any>
|
||||
}
|
||||
| undefined = undefined
|
||||
let downloadingRecording = $state(false)
|
||||
|
||||
let wsProvider: WebsocketProvider | undefined = $state(undefined)
|
||||
let yContent: Y.Text | undefined = $state(undefined)
|
||||
@@ -857,8 +867,8 @@
|
||||
// keep the latest choice as the active mode.
|
||||
if (opts?.cascade !== undefined) cascadeDownstream = opts.cascade
|
||||
// Discard any previous recording when running a normal test
|
||||
if (!scriptRecording.active) {
|
||||
lastRecording = undefined
|
||||
if (!recordingArmed) {
|
||||
lastRecordingJobId = undefined
|
||||
}
|
||||
// Not defined if JobProgressBar not loaded
|
||||
jobProgressBar?.reset()
|
||||
@@ -900,19 +910,20 @@
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
done(_x) {
|
||||
if (scriptRecording.active) {
|
||||
lastRecording = scriptRecording.stop()
|
||||
setActiveRecording(undefined)
|
||||
done(x) {
|
||||
if (recordingArmed) {
|
||||
recordingArmed = false
|
||||
lastRecordingJobId = x?.id
|
||||
}
|
||||
if (historyTabActive) {
|
||||
loadPastTests()
|
||||
}
|
||||
},
|
||||
doneError({ error }) {
|
||||
if (scriptRecording.active) {
|
||||
lastRecording = scriptRecording.stop()
|
||||
setActiveRecording(undefined)
|
||||
doneError({ id, error }) {
|
||||
// A failed run is still a completed job and records fine.
|
||||
if (recordingArmed) {
|
||||
recordingArmed = false
|
||||
lastRecordingJobId = id
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
@@ -931,15 +942,31 @@
|
||||
}
|
||||
|
||||
async function recordAndTest() {
|
||||
lastRecording = undefined
|
||||
scriptRecording.start(path ?? '', code, lang ?? '', args ?? {}, schema)
|
||||
setActiveRecording(scriptRecording)
|
||||
lastRecordingJobId = undefined
|
||||
recordingMeta = {
|
||||
scriptPath: path ?? '',
|
||||
code,
|
||||
language: lang ?? '',
|
||||
args: JSON.parse(JSON.stringify(args ?? {})),
|
||||
schema: schema ? JSON.parse(JSON.stringify(schema)) : undefined
|
||||
}
|
||||
recordingArmed = true
|
||||
await runTest()
|
||||
}
|
||||
|
||||
function downloadRecording() {
|
||||
if (lastRecording) {
|
||||
scriptRecording.download(lastRecording)
|
||||
async function downloadRecording() {
|
||||
if (!lastRecordingJobId || !recordingMeta || downloadingRecording) return
|
||||
downloadingRecording = true
|
||||
try {
|
||||
const recording = await buildScriptRecording(opWs!, lastRecordingJobId, recordingMeta)
|
||||
downloadRecordingJson(
|
||||
recording,
|
||||
`script-recording-${(recording.script_path || 'untitled').replace(/\//g, '-')}`
|
||||
)
|
||||
} catch (e: any) {
|
||||
sendUserToast('Could not build the recording', true, undefined, e?.toString())
|
||||
} finally {
|
||||
downloadingRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2014,12 +2041,13 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if lastRecording}
|
||||
{#if lastRecordingJobId}
|
||||
<Button
|
||||
on:click={downloadRecording}
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Download }}
|
||||
iconOnly
|
||||
loading={downloadingRecording}
|
||||
title="Download recording"
|
||||
/>
|
||||
{/if}
|
||||
@@ -2762,6 +2790,11 @@
|
||||
awareness={wsProvider?.awareness}
|
||||
on:change={(e) => {
|
||||
if (activeModuleTab === null) {
|
||||
// `editorCode`, not the payload: `setCode` dispatches the string it was
|
||||
// handed, but Monaco may have normalized it (EOL) while applying it, and
|
||||
// the re-entrant `updateCode` that runs inside `setCode` has already put
|
||||
// that normalized text here. Taking the payload would leave `code`
|
||||
// disagreeing with the buffer.
|
||||
code = editorCode
|
||||
lastSyncedCode = code
|
||||
inferSchema(e.detail)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack, type Snippet } from 'svelte'
|
||||
import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { DraftService } from '$lib/gen'
|
||||
@@ -264,8 +265,7 @@
|
||||
// the global drafts list. localStorage is a synchronous crash mirror, READ only
|
||||
// for the one-time migration below; the DB is the source of truth on load.
|
||||
// FlowBuilder's autosave analogue — gated by `persistDrafts`.
|
||||
const PIPELINE_DRAFT_KIND = 'data_pipeline' as const
|
||||
let pipelineDraftPath = $derived(`f/${folder}/data_pipeline`)
|
||||
let pipelineDraftPath = $derived(pipelineBundlePath(folder))
|
||||
let storageKey = $derived(`pipeline-${folder}`)
|
||||
type PipelineDraftBundle = { drafts: Array<[string, PipelineDraft]>; activeDraftPath?: string }
|
||||
// Hydration is tracked on the editor instance (`editor.hydratedFromDb`), not a
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
JOBS_FORCE_CANCEL: 'jobs.force_cancel',
|
||||
JOBS_DISAPPROVAL: 'jobs.disapproval',
|
||||
JOBS_DELETE: 'jobs.delete',
|
||||
JOBS_SHARE_PUBLICLY: 'jobs.share_publicly',
|
||||
ACCOUNT_DELETE: 'account.delete',
|
||||
AI_REQUEST: 'ai.request',
|
||||
RESOURCES_CREATE: 'resources.create',
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
// Subtle horizontal scroll: native overflow (so wheel/trackpad/drag always
|
||||
// work) with the shared `.scrollbar-subtle` thumb (thin, hover-revealed). Bar
|
||||
// thickness is tunable via the `--wm-scrollbar-size` CSS var (pass through
|
||||
// `style`), so denser callers (e.g. the tab strip) can shrink it.
|
||||
// `style`) — WebKit only, so Firefox spends its own ~11px of the box on the
|
||||
// bar whatever this says. Not for a height-constrained row: draw the thumb
|
||||
// yourself there, the way the tab strip does.
|
||||
let {
|
||||
class: c = '',
|
||||
style = '',
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
style?: string
|
||||
cancelText?: string | undefined
|
||||
kind?: 'button' | 'X'
|
||||
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
|
||||
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
|
||||
* the dialog otherwise hugs its content, and percentage heights inside it do not
|
||||
* resolve (the centering wrapper is `min-h-full`, i.e. height:auto). */
|
||||
fillHeight?: boolean
|
||||
/** Force a minimum z-index base. Defaults to elevating above the AI chat
|
||||
* side panel when it is open. Pass an explicit value to stack above other
|
||||
* surfaces (e.g. a modal opened over the /sessions preview-pane editor). */
|
||||
@@ -36,6 +41,7 @@
|
||||
style = '',
|
||||
cancelText = undefined,
|
||||
kind = 'button',
|
||||
fillHeight = false,
|
||||
minZIndex: minZIndexProp = undefined,
|
||||
settings,
|
||||
children: children_render,
|
||||
@@ -119,12 +125,17 @@
|
||||
></div>
|
||||
|
||||
<div class="{posClass} inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="flex {fillHeight ? 'h-full' : 'min-h-full'} items-center justify-center p-4"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={stopPropagation(bubble('click'))}
|
||||
class={twMerge(
|
||||
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
|
||||
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:w-full sm:max-w-lg sm:p-6',
|
||||
// The margins are what keeps a content-sized dialog off the viewport edges; a
|
||||
// filling one takes its inset from the wrapper's padding instead.
|
||||
fillHeight ? 'h-full flex flex-col' : 'sm:my-8',
|
||||
c,
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
@@ -137,16 +148,16 @@
|
||||
><CloseButton on:close={() => (open = false)} /></div
|
||||
>
|
||||
{/if}
|
||||
<div class="flex">
|
||||
<div class="flex {fillHeight ? 'flex-1 min-h-0' : ''}">
|
||||
<!-- min-w-0: without it this flex item takes its content's min-content width and
|
||||
stretches the modal past its max-width instead of letting content shrink. -->
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<div class="text-left flex-1 min-w-0 {fillHeight ? 'flex flex-col min-h-0' : ''}">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h3 class="text-emphasis text-lg font-semibold">{title}</h3>
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-primary">
|
||||
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
|
||||
{@render children_render?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
import { X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { untrack } from 'svelte'
|
||||
import ScrollableX from '../ScrollableX.svelte'
|
||||
|
||||
interface Props {
|
||||
tabs: TabItem[]
|
||||
@@ -41,7 +40,9 @@
|
||||
* activated via Enter/Space — lets the active tab host a secondary affordance
|
||||
* (e.g. toggling the breadcrumb picker rendered in `tabAccessory`). */
|
||||
onActiveClick?: (id: string) => void
|
||||
/** Extra classes for the outer tab strip. */
|
||||
/** Extra classes for the outer tab strip. It is 32px tall unless `trailing`
|
||||
* content is taller; a fixed height here overrides that, and going taller
|
||||
* pulls the tabs away from the scroll bar, which stays on the bottom edge. */
|
||||
class?: string
|
||||
/** Render inside the scroll row, right after the last tab (e.g. a "+" new-tab
|
||||
* button) — scrolls with the tabs, unlike `trailing`. */
|
||||
@@ -88,6 +89,85 @@
|
||||
if (!isDragging) dndMiddle = next
|
||||
})
|
||||
|
||||
// Scroll bar. The native one is hidden (`no-scrollbar`) and redrawn here: its
|
||||
// height is a WebKit-only setting, so Firefox spends 11px of the strip on a
|
||||
// bar there is no room for and clips the tabs. Ours is 4px in every engine and
|
||||
// costs no layout height at all.
|
||||
const MIN_THUMB = 24
|
||||
let scrollEl = $state<HTMLElement | undefined>(undefined)
|
||||
let scrollLeft = $state(0)
|
||||
let viewport = $state(0)
|
||||
let content = $state(0)
|
||||
const scrollable = $derived(Math.max(0, content - viewport))
|
||||
const overflowing = $derived(scrollable > 1)
|
||||
// Clamped to the viewport: a pane dragged shut leaves a few pixels of strip,
|
||||
// and an unclamped minimum-width thumb would hang out of it.
|
||||
const thumbWidth = $derived(
|
||||
overflowing ? Math.min(viewport, Math.max(MIN_THUMB, (viewport / content) * viewport)) : 0
|
||||
)
|
||||
// Clamped at both ends: `scrollLeft` is fractional on HiDPI while the widths
|
||||
// are rounded, so the ratio can tip past 1, and WebKit's elastic overscroll
|
||||
// drives it negative — either way the thumb would leave the track.
|
||||
const thumbLeft = $derived(
|
||||
scrollable > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(viewport - thumbWidth, (scrollLeft / scrollable) * (viewport - thumbWidth))
|
||||
)
|
||||
: 0
|
||||
)
|
||||
|
||||
function measure() {
|
||||
const el = scrollEl
|
||||
if (!el) return
|
||||
scrollLeft = el.scrollLeft
|
||||
viewport = el.clientWidth
|
||||
content = el.scrollWidth
|
||||
}
|
||||
|
||||
// Both ends move independently: the viewport on a pane resize, the content as
|
||||
// tabs open, close and get renamed.
|
||||
$effect(() => {
|
||||
const el = scrollEl
|
||||
if (!el) return
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
// Drag the thumb: pointer capture keeps the gesture alive past the strip's
|
||||
// edges, and the ratio maps thumb travel back onto scroll travel. Recomputing
|
||||
// from the anchor each move (rather than accumulating) means clamping at
|
||||
// either end doesn't drift, and reading the travel live keeps a tab opening
|
||||
// mid-drag from scaling every later move against a stale track.
|
||||
function handleThumbPointerDown(e: PointerEvent) {
|
||||
const el = scrollEl
|
||||
// Primary button only: a right-click would open the context menu without
|
||||
// delivering the pointerup that ends the drag.
|
||||
if (!el || e.button !== 0) return
|
||||
e.preventDefault()
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const startX = e.clientX
|
||||
const startScroll = el.scrollLeft
|
||||
target.setPointerCapture(e.pointerId)
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const travel = viewport - thumbWidth
|
||||
if (travel <= 0) return
|
||||
el.scrollLeft = startScroll + ((ev.clientX - startX) / travel) * scrollable
|
||||
}
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
target.releasePointerCapture(ev.pointerId)
|
||||
target.removeEventListener('pointermove', onMove)
|
||||
target.removeEventListener('pointerup', onUp)
|
||||
target.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
target.addEventListener('pointermove', onMove)
|
||||
target.addEventListener('pointerup', onUp)
|
||||
target.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
|
||||
isDragging = true
|
||||
dndMiddle = e.detail.items
|
||||
@@ -100,7 +180,7 @@
|
||||
|
||||
function tabClasses(isActive: boolean) {
|
||||
return twMerge(
|
||||
'group relative inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
|
||||
'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'bg-surface-tertiary text-emphasis'
|
||||
: 'bg-transparent text-hint hover:text-secondary'
|
||||
@@ -189,41 +269,69 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface', c)}>
|
||||
<!-- 4px bar to match the strip's `pb-1` reserve. -->
|
||||
<ScrollableX class="flex-1 min-w-0 pt-1 pl-1 pb-1" style="--wm-scrollbar-size: 4px;">
|
||||
<div class="flex items-center" role="tablist">
|
||||
{#each pinnedLeft as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="flex items-center"
|
||||
use:dndzone={{
|
||||
items: dndMiddle,
|
||||
flipDurationMs: 150,
|
||||
type: dndType,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each dndMiddle as tab (tab.id)}
|
||||
<div>
|
||||
{@render tabButton(tab)}
|
||||
</div>
|
||||
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface min-h-8', c)}>
|
||||
<!-- The tabs centre in the full strip and the bar overlays the air under them,
|
||||
flush with the strip's bottom edge — it takes no height of its own, so the
|
||||
strip never resizes and the tabs sit at the same place whether or not they
|
||||
overflow. -->
|
||||
<div class="group/scroll relative flex-1 min-w-0 self-stretch">
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
onscroll={() => scrollEl && (scrollLeft = scrollEl.scrollLeft)}
|
||||
class="h-full overflow-x-auto overflow-y-hidden no-scrollbar pl-1"
|
||||
>
|
||||
<!-- `w-max`: without it the row is pinned to the viewport width and the tabs
|
||||
overflow *out* of it, so the ResizeObserver below never sees a tab open
|
||||
or a label change and the scroll bar goes stale. -->
|
||||
<div class="flex items-center h-full w-max" role="tablist">
|
||||
{#each pinnedLeft as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="flex items-center"
|
||||
use:dndzone={{
|
||||
items: dndMiddle,
|
||||
flipDurationMs: 150,
|
||||
type: dndType,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each dndMiddle as tab (tab.id)}
|
||||
<!-- `flex`, not the default block: an inline-flex tab in a block wrapper
|
||||
sits on a text baseline and rides ~1.5px off the row's centre. -->
|
||||
<div class="flex">
|
||||
{@render tabButton(tab)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each pinnedRight as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
{#if afterTabs}
|
||||
{@render afterTabs()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each pinnedRight as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
{#if afterTabs}
|
||||
{@render afterTabs()}
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollableX>
|
||||
|
||||
{#if overflowing}
|
||||
<!-- Decorative: it mirrors the scroll position and can be dragged, but the
|
||||
strip is scrollable without it (wheel, trackpad, and the arrow keys that
|
||||
scroll the focused tab into view), so it stays out of the a11y tree
|
||||
rather than posing as a control at a 4px hit target. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute bottom-0 left-0 h-1 rounded-full touch-none bg-hint/0 group-hover/scroll:bg-hint/40 hover:!bg-secondary/60 transition-colors"
|
||||
style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"
|
||||
onpointerdown={handleThumbPointerDown}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if trailing}
|
||||
<div class="ml-1 pr-1 flex items-center shrink-0">
|
||||
|
||||
@@ -47,7 +47,7 @@ import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { modelSupportsVision } from '../modelConfig'
|
||||
import { getKnownModelContextWindow } from '../modelConfig'
|
||||
import { getModelContextWindow } from '../modelConfig'
|
||||
import {
|
||||
getCompactionSummaryPrompt,
|
||||
formatCompactSummary,
|
||||
@@ -2971,9 +2971,11 @@ export class AIChatManager {
|
||||
this.currentReasoningActive = false
|
||||
this.resetReasoningTiming()
|
||||
|
||||
// Compaction trigger. Without a known context window there is no limit
|
||||
// to enforce, so compaction stays off rather than guessing one.
|
||||
const contextWindow = model ? getKnownModelContextWindow(model.model) : undefined
|
||||
// Compaction trigger. An unrecognized model still gets the conservative
|
||||
// assumed window rather than no limit: without one the context grows
|
||||
// unbounded until the provider (or a proxy in front of it) times out.
|
||||
// Guessing low only compacts earlier, which is always recoverable.
|
||||
const contextWindow = model ? getModelContextWindow(model.model) : undefined
|
||||
if (
|
||||
contextWindow !== undefined &&
|
||||
projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO
|
||||
|
||||
@@ -1928,7 +1928,10 @@ describe('AIChatManager context compaction', () => {
|
||||
expect(manager.contextTokens).toBe(1_290)
|
||||
})
|
||||
|
||||
it('does not compact when the model context window is unknown', async () => {
|
||||
// An unrecognized model gets the conservative assumed 128K window instead of
|
||||
// no limit — otherwise the context grows unbounded until the provider (or a
|
||||
// proxy in front of it) times out the request.
|
||||
it('compacts against the assumed window when the model context window is unknown', async () => {
|
||||
mocks.getCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' })
|
||||
mocks.tryGetCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' })
|
||||
const manager = new AIChatManager()
|
||||
@@ -1941,7 +1944,11 @@ describe('AIChatManager context compaction', () => {
|
||||
|
||||
await manager.sendRequest()
|
||||
|
||||
expect(mocks.runChatLoop.mock.calls[0][0].messages.length).toBe(3)
|
||||
// ~10M projected against the 128K assumption: everything droppable goes,
|
||||
// leaving only the just-pushed user message
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
expect(sent.length).toBe(1)
|
||||
expect(sent[0].role).toBe('user')
|
||||
})
|
||||
|
||||
it('never drops the most recent message', () => {
|
||||
|
||||
@@ -41,8 +41,14 @@
|
||||
</script>
|
||||
|
||||
<div class={twMerge('font-mono text-xs', className)}>
|
||||
{#snippet labelText()}
|
||||
<span class={twMerge('text-secondary font-medium text-2xs', labelClass)}>
|
||||
{#snippet labelText(highlight: boolean)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'text-secondary font-medium text-2xs',
|
||||
labelClass,
|
||||
highlight && 'text-emphasis'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/snippet}
|
||||
@@ -58,13 +64,13 @@
|
||||
>
|
||||
{#if shimmer}
|
||||
<span class="shimmer inline-flex items-center min-w-0">
|
||||
{@render labelText()}
|
||||
{@render labelText(false)}
|
||||
<span class="shimmer-band inline-flex items-center min-w-0" aria-hidden="true">
|
||||
{@render labelText()}
|
||||
{@render labelText(true)}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
{@render labelText()}
|
||||
{@render labelText(false)}
|
||||
{/if}
|
||||
{#if toggleable}
|
||||
<ChevronRight
|
||||
@@ -99,9 +105,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A white copy of the label sits on top of the coloured one and is revealed
|
||||
through a travelling band, so the highlight is a colour change rather than
|
||||
an opacity change and the row keeps its own colour underneath. */
|
||||
/* An emphasis-coloured copy of the label sits on top of the muted one and is
|
||||
revealed through a travelling band, so the highlight is a colour change
|
||||
rather than an opacity change and the row keeps its own colour underneath. */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
}
|
||||
@@ -109,9 +115,6 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* Forces the copy white whatever colour the caller gives the label, without
|
||||
having to out-specify its utility classes. */
|
||||
filter: brightness(0) invert(1);
|
||||
--wm-shimmer-band: linear-gradient(
|
||||
100deg,
|
||||
rgba(0, 0, 0, 0.2) 40%,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
import { getKnownModelContextWindow } from '../modelConfig'
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -14,8 +14,14 @@
|
||||
let providerModel = $derived(
|
||||
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
|
||||
)
|
||||
// The same number the compaction trigger uses: the known window when the
|
||||
// model is listed, otherwise the conservative window the trigger assumes.
|
||||
// The tooltip marks the assumed case so the guess never reads as a spec.
|
||||
let contextWindow = $derived(
|
||||
providerModel ? getKnownModelContextWindow(providerModel.model) : undefined
|
||||
providerModel ? getModelContextWindow(providerModel.model) : undefined
|
||||
)
|
||||
let windowIsAssumed = $derived(
|
||||
providerModel !== undefined && getKnownModelContextWindow(providerModel.model) === undefined
|
||||
)
|
||||
// The same number the compaction trigger uses: the provider's report when
|
||||
// one describes the current history (one turn stale by nature), otherwise
|
||||
@@ -53,9 +59,10 @@
|
||||
|
||||
{#if visible}
|
||||
<Tooltip small placement="top">
|
||||
<!-- Only a meter when we know the window: it's a 0–100% reading. With an unknown
|
||||
window there's no max to measure against, so it's a plain labeled indicator
|
||||
(the bar is decorative/full and the token count lives in the tooltip). -->
|
||||
<!-- Only a meter when a model is configured: it's a 0–100% reading against the
|
||||
window compaction enforces (known or assumed). With no model there's no max
|
||||
to measure against, so it's a plain labeled indicator (the bar is
|
||||
decorative/full and the token count lives in the tooltip). -->
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label="Context window usage"
|
||||
@@ -74,7 +81,7 @@
|
||||
<p class="font-semibold">Context usage</p>
|
||||
<p class="mt-1 tabular-nums">
|
||||
~{formatTokenCount(usedTokens)}{contextWindow
|
||||
? ` / ${formatTokenCount(contextWindow)}`
|
||||
? ` / ${formatTokenCount(contextWindow)}${windowIsAssumed ? ' assumed' : ''}`
|
||||
: ''}{fillPct !== undefined ? ` (${fillPct}%)` : ''}
|
||||
</p>
|
||||
{#if ratio !== undefined && ratio >= COMPACTION_TRIGGER_RATIO}
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
startOnLoad: false,
|
||||
theme: dark ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
// Mermaid writes this stack into a <style> block inside the SVG, so the app's font
|
||||
// never reaches a diagram: the bundled vector emoji font has to be named here or
|
||||
// emoji fall back to the platform bitmap one, which ignores the zoom transform
|
||||
// (app.css). Inter sits last before it because the emoji font also covers digits,
|
||||
// # and *; on a box without the MS core fonts those would otherwise render as its
|
||||
// keycap glyphs. Keeping Inter behind them leaves diagram typography unchanged.
|
||||
fontFamily: '"trebuchet ms", verdana, arial, Inter, "Noto Color Emoji", sans-serif',
|
||||
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
|
||||
suppressErrorRendering: true
|
||||
})
|
||||
@@ -43,6 +50,17 @@
|
||||
if (seq !== renderSeq) return
|
||||
svg = result.svg
|
||||
renderedCode = source
|
||||
// Mermaid sizes each node by measuring its label, so a font still in flight yields
|
||||
// boxes cut to fallback metrics that the real glyphs then overflow. Drawing the
|
||||
// diagram is itself what asks for the fonts its glyphs need — including the emoji
|
||||
// subsets — so let them settle and lay it out again against the true metrics.
|
||||
if (document.fonts?.status === 'loading') {
|
||||
await document.fonts.ready
|
||||
if (seq !== renderSeq) return
|
||||
const settled = await mermaid.render(`mermaid-${randomUUID()}`, source)
|
||||
if (seq !== renderSeq) return
|
||||
svg = settled.svg
|
||||
}
|
||||
} catch {
|
||||
// Parse failure (often a partial block still streaming in): fall back to the
|
||||
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
|
||||
@@ -145,7 +163,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={expanded} title="Diagram" kind="X" class="sm:max-w-none w-[92vw]">
|
||||
<Modal bind:open={expanded} title="Diagram" kind="X" fillHeight class="sm:max-w-none w-[92vw]">
|
||||
{#snippet settings()}
|
||||
<div class="flex flex-row gap-1 mr-8">
|
||||
<Button
|
||||
@@ -183,7 +201,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
<div
|
||||
class="relative w-full h-[78vh] overflow-hidden rounded border cursor-grab bg-surface-secondary"
|
||||
class="relative w-full h-full overflow-hidden rounded border cursor-grab bg-surface-secondary"
|
||||
>
|
||||
{#if expanded}
|
||||
<div use:panzoomAction class="w-full h-full flex items-center justify-center">
|
||||
|
||||
@@ -267,6 +267,15 @@ describe('model context windows', () => {
|
||||
expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000)
|
||||
})
|
||||
|
||||
it('maps Qwen3-Max to 256K and leaves other Qwen ids to the assumed window', () => {
|
||||
expect(getKnownModelContextWindow('qwen3-max')).toBe(256000)
|
||||
expect(getKnownModelContextWindow('qwen3-max-2025-09-23')).toBe(256000)
|
||||
// a version between "qwen3" and "-max" must not claim the 256K entry, and
|
||||
// there is deliberately no qwen family entry (variant windows range 8K–1M)
|
||||
expect(getKnownModelContextWindow('qwen3.8-max')).toBeUndefined()
|
||||
expect(getModelContextWindow('qwen3.8-max')).toBe(128000)
|
||||
})
|
||||
|
||||
it('returns undefined for unrecognized models, 128K via the defaulting wrapper', () => {
|
||||
expect(getKnownModelContextWindow('some-custom-model')).toBeUndefined()
|
||||
expect(getModelContextWindow('some-custom-model')).toBe(128000)
|
||||
|
||||
@@ -65,8 +65,10 @@ export function requiresMaxCompletionTokens(model: string) {
|
||||
// name found in the bare model id wins, so vendor-namespaced and date-suffixed
|
||||
// ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve.
|
||||
// Conservative family fallbacks sit below the explicit entries; models not
|
||||
// listed at all resolve to undefined, which disables auto-trimming and the
|
||||
// indicator denominator.
|
||||
// listed at all resolve to undefined. Consumers that need a number regardless
|
||||
// (trim/compaction, the usage indicator) go through getModelContextWindow,
|
||||
// whose conservative 128K fallback keeps a limit enforced and is surfaced to
|
||||
// the user as an assumed window.
|
||||
const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [
|
||||
// Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA);
|
||||
// Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4
|
||||
@@ -98,6 +100,9 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [
|
||||
['deepseek-chat', 1_000_000],
|
||||
['deepseek-reasoner', 1_000_000],
|
||||
['deepseek', 128_000],
|
||||
// Alibaba — Qwen3-Max is 256K. No qwen family fallback: variant windows range
|
||||
// from 8K (character models) to 1M, too wide for even a conservative guess
|
||||
['qwen3-max', 256_000],
|
||||
// Others
|
||||
['llama', 128_000],
|
||||
['codestral', 32_000]
|
||||
|
||||
@@ -46,10 +46,9 @@
|
||||
</script>
|
||||
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
|
||||
@@ -234,10 +234,9 @@
|
||||
connectProp: () => {},
|
||||
propPickerConfig: writable(undefined),
|
||||
clearConnect: () => {},
|
||||
pickerMode: () => 'popover' as const,
|
||||
pickableProperties: () => undefined,
|
||||
result: () => undefined,
|
||||
extraResults: () => undefined,
|
||||
openPicker: () => {},
|
||||
pickerMode: () => 'pane' as const,
|
||||
setPickTarget: () => {},
|
||||
onPick: () => {},
|
||||
exprBeingEdited: writable([])
|
||||
})
|
||||
|
||||
@@ -226,10 +226,9 @@
|
||||
{#if selectedTab === 'loop'}
|
||||
<section>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
@@ -323,10 +322,9 @@
|
||||
{#if mod.value.parallel}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
parallelismEditor?.insertAtCursor(detail)
|
||||
|
||||
@@ -256,12 +256,11 @@
|
||||
{#if blocks !== 'all-iters' && !isBranchAll}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
result={earlyStopResult}
|
||||
extraResults={isLoop ? { all_iters: result } : undefined}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
stopAfterEditor?.insertAtCursor(detail)
|
||||
@@ -299,11 +298,10 @@
|
||||
{#if blocks !== 'stop-after' && (isLoop || isBranchAll)}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
stopAfterAllItersEditor?.insertAtCursor(detail)
|
||||
|
||||
@@ -71,11 +71,10 @@
|
||||
|
||||
<div class="w-full">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
|
||||
@@ -77,11 +77,10 @@
|
||||
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
|
||||
@@ -219,9 +219,9 @@
|
||||
for any)
|
||||
</span>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={undefined}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
{#if flowModule.timeout && schema.properties['timeout']}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
|
||||
@@ -265,11 +265,10 @@
|
||||
|
||||
{#if stepPropPicker}
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
sidePane
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
retryIfEditor?.insertAtCursor(detail)
|
||||
|
||||
@@ -107,6 +107,13 @@
|
||||
const s3Snippet = $derived(s3Language ? s3Scripts[s3Language][s3Kind] : undefined)
|
||||
const concurrencyOn = $derived(hasInlineConcurrency(flowModule))
|
||||
const concurrencyOff = $derived(!$enterpriseLicense || !concurrencyOn)
|
||||
// A resolved approval is recorded against the step the gate holds back, not this one, so
|
||||
// `continue_on_error` never sees it — the suspend option is the only way to continue past it.
|
||||
const suspendNeedsItsOwnContinueToggle = $derived(
|
||||
Boolean(flowModule.continue_on_error) &&
|
||||
Boolean(flowModule.suspend) &&
|
||||
!flowModule.suspend?.continue_on_disapprove_timeout
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet sectionHeader(title: string)}
|
||||
@@ -153,7 +160,7 @@
|
||||
<FlowRetries bind:flowModuleRetry={flowModule.retry} bind:flowModule {isAgentTool} />
|
||||
</div>
|
||||
|
||||
<div data-setting="error-handling">
|
||||
<div data-setting="error-handling" class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
@@ -164,6 +171,13 @@
|
||||
"The flow continues to the next step even if this step fails (after exhausting retries, if any). The step's error becomes its return, so a following branch can handle it."
|
||||
}}
|
||||
/>
|
||||
{#if suspendNeedsItsOwnContinueToggle}
|
||||
<Alert type="info" title="Does not cover the approval" size="xs">
|
||||
This only applies when the step's own code fails. A disapproval or an approval timeout
|
||||
is not a failure of this step, so it still stops the flow. To continue past those,
|
||||
turn on "Continue on disapproval/timeout" in the approval settings.
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- The error handler runs outside the flow's control graph; only its own
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
insertable: boolean
|
||||
moduleAction: ModuleActionInfo | undefined
|
||||
annotation?: string | undefined
|
||||
annotationTitle?: string | undefined
|
||||
sideAnnotation?: string | undefined
|
||||
sideAnnotationTitle?: string | undefined
|
||||
nodeState?: FlowNodeState
|
||||
duration_ms?: number | undefined
|
||||
retries?: number | undefined
|
||||
@@ -55,6 +58,9 @@
|
||||
insertable,
|
||||
moduleAction = undefined,
|
||||
annotation = undefined,
|
||||
annotationTitle = undefined,
|
||||
sideAnnotation = undefined,
|
||||
sideAnnotationTitle = undefined,
|
||||
nodeState,
|
||||
duration_ms = undefined,
|
||||
retries = undefined,
|
||||
@@ -134,8 +140,17 @@
|
||||
{msToSec(duration_ms)}s
|
||||
</div>
|
||||
{/if}
|
||||
{#if sideAnnotation && sideAnnotation != ''}
|
||||
<div
|
||||
title={sideAnnotationTitle}
|
||||
class="nodrag nopan absolute z-10 left-full ml-2 top-1/2 -translate-y-1/2 whitespace-nowrap text-2xs text-tertiary cursor-default"
|
||||
>
|
||||
{sideAnnotation}
|
||||
</div>
|
||||
{/if}
|
||||
{#if annotation && annotation != ''}
|
||||
<div
|
||||
title={annotationTitle}
|
||||
class={twMerge(
|
||||
'absolute z-10 left-0 -top-5 center-center text-primary',
|
||||
editMode ? '-top-4 text-gray-400 dark:text-gray-500 text-xs font-normal' : ''
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import PropPicker from '$lib/components/propertyPicker/PropPicker.svelte'
|
||||
import FlowPlugConnect from '$lib/components/FlowPlugConnect.svelte'
|
||||
import type { PropPickerContext } from '$lib/components/prop_picker'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import type { PropPickerWrapperContext } from './PropPickerWrapper.svelte'
|
||||
import { useConnect } from './useConnect.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
/** Identifies this input within the panel; arming another one disarms it. */
|
||||
id: string
|
||||
pickableProperties: PickableProperties | undefined
|
||||
result?: any
|
||||
extraResults?: any
|
||||
/** Receives the picked property, whether it came from the popover or the graph. */
|
||||
onSelect: (path: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let { id, pickableProperties, result, extraResults, onSelect, disabled = false }: Props = $props()
|
||||
|
||||
const propPickerContext = getContext<PropPickerContext | undefined>('PropPickerContext')
|
||||
|
||||
const inModalPanel = $derived(propPickerContext?.inModalPanel?.() ?? false)
|
||||
|
||||
const connect = useConnect({
|
||||
inModalPanel: () => inModalPanel,
|
||||
hasPickableProperties: () => pickableProperties != undefined,
|
||||
flowPropPickerConfig: propPickerContext?.flowPropPickerConfig ?? writable(undefined)
|
||||
})
|
||||
|
||||
// PropPicker reads these to filter and highlight against what is being typed. Only the
|
||||
// step input form produces that signal, so here they stay empty.
|
||||
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
|
||||
propPickerConfig: writable(undefined),
|
||||
inputMatches: writable(undefined),
|
||||
exprBeingEdited: writable([]),
|
||||
connectProp: () => {},
|
||||
clearConnect: () => connect.disarm(),
|
||||
pickerMode: () => 'popover' as const,
|
||||
pickableProperties: () => undefined,
|
||||
result: () => undefined,
|
||||
extraResults: () => undefined,
|
||||
onPick: () => {}
|
||||
})
|
||||
|
||||
let open = $state(false)
|
||||
|
||||
// A pick from the graph resolves without touching the popover, so follow the armed
|
||||
// slot rather than tracking open/closed separately.
|
||||
$effect(() => {
|
||||
if (!connect.isArmed(id)) {
|
||||
open = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- excludeSelectors: a step's output picker in the graph is the other half of connecting, not
|
||||
somewhere else to click. Reaching for it must not close this popover, which would disarm
|
||||
the connect and leave the pick that follows with nowhere to land. -->
|
||||
<Popover
|
||||
bind:isOpen={open}
|
||||
class="flex"
|
||||
placement="bottom-start"
|
||||
closeOnOutsideClick
|
||||
excludeSelectors="[data-prop-picker]"
|
||||
contentClasses="rounded-md border bg-surface shadow-lg overflow-hidden"
|
||||
on:openChange={({ detail }) => (detail ? connect.arm({ id, onSelect }) : connect.disarm())}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<FlowPlugConnect
|
||||
connecting={connect.isArmed(id)}
|
||||
{disabled}
|
||||
title="Connect a property from a previous step"
|
||||
wrapperClasses={twMerge(
|
||||
// Revealed by the row it sits in, like the pane-mode plug. Armed or open, it stays
|
||||
// put: the pointer has to be able to leave the row without the control vanishing.
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
connect.isArmed(id) || open ? '' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="max-h-80 w-72 overflow-auto p-2">
|
||||
{#if pickableProperties}
|
||||
<PropPicker
|
||||
{pickableProperties}
|
||||
previousId={pickableProperties?.previousId}
|
||||
{result}
|
||||
{extraResults}
|
||||
on:select={({ detail }) => {
|
||||
connect.resolve(detail)
|
||||
open = false
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="p-2 text-xs text-tertiary">Nothing to pick from yet.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -13,17 +13,21 @@
|
||||
inputMatches: Writable<{ word: string; value: string }[] | undefined>
|
||||
connectProp: (propName: string, onSelect: SelectCallback) => void
|
||||
clearConnect: () => void
|
||||
/** 'popover' hangs the picker off each input's own connect button instead of
|
||||
* taking a pane — for single-argument settings rows, which are not the step's
|
||||
* input form. */
|
||||
pickerMode: () => 'pane' | 'popover'
|
||||
/** The wrapper owns these; nested inputs receive none of their own. */
|
||||
pickableProperties: () => PickableProperties | undefined
|
||||
/** The step's own result, and anything extra worth offering beside it (a loop's
|
||||
* `all_iters`). Only the pane renders them directly — in popover mode the picker
|
||||
* hangs off each input, so it reads them from here instead. */
|
||||
result: () => any
|
||||
extraResults: () => any
|
||||
/** Reveal a `sidePane` column: its input is being written in. Reaching for the editor
|
||||
* again after dismissing the column has to say so, since an editor that kept focus
|
||||
* throughout emits no new focus event. */
|
||||
openPicker: () => void
|
||||
/** Where the properties are offered, and therefore what a pick does:
|
||||
* - 'pane': the step's input form — a pick replaces the argument outright.
|
||||
* - 'sidePane': a settings row — a pick lands at the expression's cursor, since a
|
||||
* half-written predicate must survive it. */
|
||||
pickerMode: () => 'pane' | 'sidePane'
|
||||
/** The single input a `sidePane` column belongs to. It stays a destination for as
|
||||
* long as its field is mounted, so a pick lands whether or not a connect is armed —
|
||||
* a static field has no editor for the host's own `select` handler to write into.
|
||||
* `undefined` (the setting was switched off) leaves the column with nowhere to
|
||||
* deliver, so it closes. */
|
||||
setPickTarget: (target: { id: string; onSelect: (path: string) => void } | undefined) => void
|
||||
/** Deliver a pick the way the pane does — as a `select` event, so each setting's own
|
||||
* handler inserts it at the cursor. Replacing the whole value is right for a step
|
||||
* input but destroys a half-written predicate. */
|
||||
@@ -37,6 +41,7 @@
|
||||
import PropPickerResult from '$lib/components/propertyPicker/PropPickerResult.svelte'
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { createEventDispatcher, getContext, setContext } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
@@ -55,8 +60,9 @@
|
||||
noPadding?: boolean
|
||||
paneClass?: string
|
||||
/** Settings rows reuse the step-input form for one argument but are not the input
|
||||
* form; their picker belongs in a popover. */
|
||||
popover?: boolean
|
||||
* form: their picker is a column beside the row, revealed while the expression is
|
||||
* being written, rather than a permanent split of the panel. */
|
||||
sidePane?: boolean
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@
|
||||
notSelectable = false,
|
||||
noPadding = false,
|
||||
paneClass = '',
|
||||
popover = false,
|
||||
sidePane = false,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -79,6 +85,7 @@
|
||||
>(undefined)
|
||||
|
||||
const inputMatches = writable<{ word: string; value: string }[] | undefined>(undefined)
|
||||
const exprBeingEdited = writable<string[]>([])
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
|
||||
@@ -99,14 +106,19 @@
|
||||
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
|
||||
propPickerConfig,
|
||||
inputMatches,
|
||||
connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }),
|
||||
clearConnect: connect.disarm,
|
||||
pickerMode: () => (popover ? 'popover' : 'pane'),
|
||||
pickableProperties: () => pickableProperties,
|
||||
result: () => result,
|
||||
extraResults: () => extraResults,
|
||||
connectProp: (propName, onSelect) => {
|
||||
connect.arm({ id: propName, onSelect })
|
||||
openPicker()
|
||||
},
|
||||
clearConnect: closePicker,
|
||||
openPicker,
|
||||
pickerMode: () => (sidePane ? 'sidePane' : 'pane'),
|
||||
setPickTarget: (target) => {
|
||||
pickTarget = target
|
||||
if (!target) closePicker()
|
||||
},
|
||||
onPick: (path) => dispatch('select', path),
|
||||
exprBeingEdited: writable<string[]>([])
|
||||
exprBeingEdited
|
||||
})
|
||||
|
||||
async function getPropPickerElements(): Promise<HTMLElement[]> {
|
||||
@@ -116,9 +128,33 @@
|
||||
}
|
||||
|
||||
let rightPaneHeight: number = $state(0)
|
||||
|
||||
let pickTarget: { id: string; onSelect: (path: string) => void } | undefined = $state(undefined)
|
||||
|
||||
// Opened and dismissed on demand rather than derived from focus: picking a property blurs
|
||||
// the editor, so a column that followed focus would vanish mid-click — and one that
|
||||
// latched onto focus would never let go of an editor unmounted by its own setting.
|
||||
let sidePaneOpen = $state(false)
|
||||
|
||||
function openPicker() {
|
||||
sidePaneOpen = true
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
connect.disarm()
|
||||
sidePaneOpen = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet pickerBody()}
|
||||
<!-- Exactly one destination per pick, or a cursor insertion lands twice: the armed input
|
||||
if there is one, else the column's own input, else the form's `select` handler. -->
|
||||
{@const deliver = (path: string) =>
|
||||
connect.armed
|
||||
? connect.resolve(path)
|
||||
: pickTarget
|
||||
? pickTarget.onSelect(path)
|
||||
: dispatch('select', path)}
|
||||
<div bind:clientHeight={rightPaneHeight} class="min-h-40 h-full !bg-surface">
|
||||
<AnimatedButton
|
||||
animate={$propPickerConfig != undefined}
|
||||
@@ -134,10 +170,7 @@
|
||||
{extraResults}
|
||||
{flow_input}
|
||||
allowCopy={!notSelectable && !$propPickerConfig}
|
||||
on:select={({ detail }) => {
|
||||
dispatch('select', detail)
|
||||
connect.resolve(detail)
|
||||
}}
|
||||
on:select={({ detail }) => deliver(detail)}
|
||||
/>
|
||||
{:else if pickableProperties}
|
||||
<PropPicker
|
||||
@@ -148,10 +181,7 @@
|
||||
previousId={pickableProperties?.previousId}
|
||||
{pickableProperties}
|
||||
allowCopy={!notSelectable && !$propPickerConfig}
|
||||
on:select={({ detail }) => {
|
||||
dispatch('select', detail)
|
||||
connect.resolve(detail)
|
||||
}}
|
||||
on:select={({ detail }) => deliver(detail)}
|
||||
/>
|
||||
{/if}
|
||||
</AnimatedButton>
|
||||
@@ -167,13 +197,24 @@
|
||||
// Through the controller, not the stores: it owns the armed target, and a
|
||||
// target left armed here would make the next click on that same input
|
||||
// read as a toggle-off.
|
||||
onClickOutside: connect.disarm
|
||||
onClickOutside: closePicker
|
||||
}}
|
||||
>
|
||||
{#if popover}
|
||||
<!-- The picker lives on each input's connect button here, so the row keeps its
|
||||
full width — and its own spacing, since the setting around it lays that out. -->
|
||||
{@render children?.()}
|
||||
{#if sidePane}
|
||||
<!-- The row keeps its own spacing, since the setting around it lays that out. -->
|
||||
<div class="flex w-full items-start gap-3">
|
||||
<div class="min-w-0 grow">{@render children?.()}</div>
|
||||
{#if sidePaneOpen && (pickableProperties != undefined || result != undefined)}
|
||||
<!-- A set height, not the row's: the picker scrolls its own categories, and an
|
||||
expression editor is one line tall next to them. -->
|
||||
<div
|
||||
class="shrink-0 w-[38%] min-w-52 max-w-xs h-72 overflow-auto border-l pl-2 {paneClass}"
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
{@render pickerBody()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Splitpanes class={$propPickerConfig ? 'splitpanes-remove-splitter' : ''}>
|
||||
<Pane minSize={20} size={60} class={'relative !transition-none'}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user