Merge remote-tracking branch 'origin/main' into fix/ghsa-hfh4-on-behalf-superadmin

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
hugocasa
2026-08-07 10:50:37 +02:00
179 changed files with 8116 additions and 4413 deletions
+1 -6
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.780.0"
".": "1.782.0"
}
+69
View File
@@ -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)
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+172 -198
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -1 +1 @@
7622c1df38a1f858bd3f893537da0e2cca6c2d54
2656a901804c0ae791ab8e7e6714edc900d6c62a
+24 -24
View File
@@ -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
View File
@@ -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,
+15
View File
@@ -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');
+213
View File
@@ -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(
+62
View File
@@ -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(())
}
+82
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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 }
+352 -3
View File
@@ -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/")
);
}
}
@@ -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(())
}
+529 -67
View File
@@ -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!(
+1 -1
View File
@@ -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"]
+36 -7
View File
@@ -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
View File
@@ -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)));
+21 -13
View File
@@ -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(
+15 -4
View File
@@ -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()))
}
+17 -7
View File
@@ -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 \
+11 -1
View File
@@ -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
}
+32 -7
View File
@@ -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();
+2
View File
@@ -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;
+259 -20
View File
@@ -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(&registry.npmrc);
let npm_config_registry = configured(&registry.npm_config_registry);
let bunfig_install_scopes = configured(&registry.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(&registry.pip_index_url).or_else(|| PY_INDEX_URL.clone());
let extra_index_url =
configured(&registry.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(&registry.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
+67 -11
View File
@@ -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,
+35 -3
View File
@@ -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?;
+49 -45
View File
@@ -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
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.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
View File
@@ -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
-1492
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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>",
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.780.0";
export const VERSION = "1.782.0";
+2
View File
@@ -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
View File
@@ -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
+98 -73
View File
@@ -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
+158 -29
View File
@@ -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)
}
}
}
})
+43
View File
@@ -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
}
+9
View File
@@ -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"
+84
View File
@@ -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}` }
}
}
+4
View File
@@ -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:
+2
View File
@@ -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
+52
View File
@@ -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"
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+3 -2
View File
@@ -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 &amp; 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
+1 -7
View File
@@ -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}
+56 -23
View File
@@ -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 0100% reading. With an unknown
window there's no max to measure against, so it's a plain labeled indicator
(the bar is decorative/full and the token count lives in the tooltip). -->
<!-- Only a meter when a model is configured: it's a 0100% reading against the
window compaction enforces (known or assumed). With no model there's no max
to measure against, so it's a plain labeled indicator (the bar is
decorative/full and the token count lives in the tooltip). -->
<div
class="flex items-center h-5"
aria-label="Context window usage"
@@ -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 8K1M)
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