Merge remote-tracking branch 'origin/main' into gitlab-app-integration-exploration

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
hugocasa
2026-09-02 12:34:39 +02:00
94 changed files with 3961 additions and 1312 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.800.1"
".": "1.801.0"
}
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## [1.801.0](https://github.com/windmill-labs/windmill/compare/v1.800.1...v1.801.0) (2026-09-01)
### Features
* **ai-chat:** make reusable skills ai_skill resources you select per workspace ([#10914](https://github.com/windmill-labs/windmill/issues/10914)) ([cfcfe29](https://github.com/windmill-labs/windmill/commit/cfcfe298dd9ab50196bd64926ef78c4563f58c2c))
* **ai-sessions:** show a running session across tabs and reload finished turns ([#10916](https://github.com/windmill-labs/windmill/issues/10916)) ([816dc9d](https://github.com/windmill-labs/windmill/commit/816dc9dcd2c310e499d2d210a0abcd403469f29c))
* edit folders and groups in a drawer that saves once ([#10873](https://github.com/windmill-labs/windmill/issues/10873)) ([5d5ad4e](https://github.com/windmill-labs/windmill/commit/5d5ad4e8974e076ef53a26a5584e4209255a2248))
* make the home Build with AI composer dismissible, quiet the rest of the home page ([#10930](https://github.com/windmill-labs/windmill/issues/10930)) ([772fafe](https://github.com/windmill-labs/windmill/commit/772fafec8316a1e0c0e76b9a0737cc41d40a9a8c))
### Bug Fixes
* let a principal without a login account own a draft ([#10925](https://github.com/windmill-labs/windmill/issues/10925)) ([94af8d0](https://github.com/windmill-labs/windmill/commit/94af8d0fb5aceebe83936fd6761c6c1c02c75323))
* resolve chat path links against the session's operating workspace ([#10924](https://github.com/windmill-labs/windmill/issues/10924)) ([9074de2](https://github.com/windmill-labs/windmill/commit/9074de25ea730ca02653c9a2e2b8b99eda6f3137))
* tolerate string app_id in GHES app config deserialization ([#10923](https://github.com/windmill-labs/windmill/issues/10923)) ([af8ff38](https://github.com/windmill-labs/windmill/commit/af8ff3868748412cb658c803ebc8a71edc3cd8fb))
## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01)
+19
View File
@@ -36,3 +36,22 @@ _Avoid_: argument field, param
**Expression input**:
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
### Permissions
**Member**:
A user or group granted a role on a folder, a group, or an item's extra ACL. The list of them is
"Members (n)" everywhere it is shown, and one is added with "Add member".
_Avoid_: participant, collaborator, owner, ACL entry, permission (that names the concept, not the people)
**Role**:
The access level a member holds: viewer, writer or admin on a folder; member or admin on a group.
Viewers read, writers also edit, admins also manage the members. A group role of **manager**
manages the group without belonging to it — is a legacy state the UI shows and can leave, but
offers no way to enter.
_Avoid_: permission level, access level, rank
**Owner**:
Reserved for the path prefix that says where an item lives — `u/alice` or `f/team`. A folder's
`owners` column in the database is its admin members; call those admins, never owners, in the UI.
_Avoid_: using "owner" for a folder admin
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n LEFT JOIN password p\n ON p.email = d.email\n AND p.super_admin = true\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)\n ORDER BY d.email NULLS LAST",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username?",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "draft_saved_at!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text"
]
},
"nullable": [
null,
false
]
},
"hash": "032b51ce97c2f31dc2aea8ddf64e6971818ea3b19b4d4866d16e1bf9f7f2ec6f"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "0a02678e3f062c8854226d6d5eb7e493c229d205048eeac78a7cbe328c689b88"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.path = dest.path\n AND src.typ = dest.typ)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "188d024a53b2ef37442412824f73ab5cd81242501d9e7a476698ea7acccd4aef"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "43ea7d0cf7f3c50ec1e79b4d2384d49b6c65bc442263f228912a24c1c5740cc8"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4bc43a5e1c95cb8989962aeb1393a50df05437a2f1909ad5d303e2c2b89a0668"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "97691b88d43442e1f1984562372590428895b2bab67687c12efefe0b0d48881e"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
null
]
},
"hash": "9e503b65fe8bb1509f0d486829ce13ec9d93bb94192ddd85c3dacb9ff16cd032"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM draft WHERE path = 'u/two/s'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "e3c4922e315b75951b5ea07cdfca4cfb32747b52dc62c9e3eccacf9c69e29b3a"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM draft ORDER BY path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "f496f06f117f5c91104fc759df35b883070da938dea7c7975332afb876cc4691"
}
+90 -89
View File
@@ -970,9 +970,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -981,9 +981,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -7262,9 +7262,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.21"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
dependencies = [
"bitflags 2.13.1",
"libc",
@@ -7870,10 +7870,11 @@ dependencies = [
[[package]]
name = "mysql_async"
version = "0.37.0"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2"
checksum = "40d11da0e2d9fad4640c9f9198ee431c6d68444568f83ef1f10f3367270071e4"
dependencies = [
"arc-swap",
"bytes",
"crossbeam-queue",
"crossbeam-utils",
@@ -11753,9 +11754,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
dependencies = [
"serde",
]
@@ -14746,7 +14747,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14831,7 +14832,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14864,7 +14865,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14877,7 +14878,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"argon2",
@@ -15017,7 +15018,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15040,7 +15041,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15057,7 +15058,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15083,7 +15084,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15093,7 +15094,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15110,7 +15111,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15132,7 +15133,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15155,7 +15156,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15171,7 +15172,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15193,7 +15194,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15214,7 +15215,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15228,7 +15229,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15263,7 +15264,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15288,7 +15289,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15316,7 +15317,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15338,7 +15339,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15358,7 +15359,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15396,7 +15397,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15424,7 +15425,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"lazy_static",
"serde",
@@ -15436,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15460,7 +15461,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15474,7 +15475,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15509,7 +15510,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15523,7 +15524,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15542,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15646,7 +15647,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15665,7 +15666,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"regex",
"serde",
@@ -15680,7 +15681,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15707,7 +15708,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"futures",
@@ -15724,7 +15725,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15740,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15761,7 +15762,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15792,7 +15793,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15817,7 +15818,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15851,7 +15852,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"futures",
@@ -15869,7 +15870,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15878,7 +15879,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15890,7 +15891,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15902,7 +15903,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15914,7 +15915,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15926,7 +15927,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15938,7 +15939,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15949,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15960,7 +15961,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15972,7 +15973,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15983,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16005,7 +16006,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16017,7 +16018,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16031,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16048,7 +16049,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16061,7 +16062,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde",
@@ -16073,7 +16074,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16092,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16107,7 +16108,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16123,7 +16124,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16137,7 +16138,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16176,7 +16177,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"const_format",
@@ -16216,7 +16217,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16227,7 +16228,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16262,7 +16263,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16286,7 +16287,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16319,7 +16320,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16347,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16380,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16399,7 +16400,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16433,7 +16434,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16469,7 +16470,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16492,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16516,7 +16517,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16540,7 +16541,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16575,7 +16576,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16603,7 +16604,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16628,7 +16629,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16647,7 +16648,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16764,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.800.1"
version = "1.801.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.800.1"
version = "1.801.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
97ccaf599b709902fdf7526db538a25e2460b869
401a9b3a27935fc5d6d81e829f6aa765f4ed8e24
@@ -0,0 +1,12 @@
-- Drafts owned by a principal with no login account cannot exist under the constraint; drop them
-- before restoring it.
DELETE FROM draft
WHERE email IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM password WHERE password.email = draft.email);
ALTER TABLE draft
ADD CONSTRAINT draft_password_fkey
FOREIGN KEY (email)
REFERENCES password(email)
ON DELETE CASCADE
ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- The delete and rename this cascaded are now explicit, at the sites that remove or rename an
-- account; `windmill_common::user_drafts::delete_drafts_of_email` carries the reasoning.
ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.800.1"
version = "1.801.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.800.1"
version = "1.801.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2 -1
View File
@@ -157,7 +157,8 @@ async fn list_flows(
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users",
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow' \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(o.workspace_id, o.path) as inherited_labels"
])
.left()
@@ -183,9 +183,9 @@ async fn add_granular_acl(
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
} else {
"grant_read"
};
crate::folders::log_folder_permission_change(
&mut *tx,
@@ -917,3 +917,79 @@ async fn test_change_user_email_leaves_group_identities(db: Pool<Postgres>) -> a
Ok(())
}
/// An address with no `password` row can own a draft, and the account paths carry the delete and
/// rename that no foreign key does any more.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let global_base = format!("http://localhost:{port}/api/users");
// The destination of the rename below already holds a draft of the same item — it belongs to
// an accountless principal, so `change_email`'s "address is free" check does not see it.
sqlx::query!(
"INSERT INTO draft(workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),
('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')"
)
.execute(&db)
.await?;
// A null username is how the legacy workspace-level row is encoded, so an owner nobody can
// name must be absent from the owner circles rather than pose as one.
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list?all_users=true"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let listed = resp.json::<serde_json::Value>().await?;
let ext = listed
.as_array()
.unwrap()
.iter()
.find(|d| d["path"] == "u/ext/s")
.expect("the accountless owner's draft is listed");
assert_eq!(ext.get("draft_users"), None);
let resp = authed(client().post(format!("{global_base}/change_email/test2@windmill.dev")))
.json(&json!({ "new_email": "renamed@windmill.dev" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "change_email: {}", resp.text().await?);
let moved = sqlx::query!(
"SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'"
)
.fetch_all(&db)
.await?;
assert_eq!(
moved
.iter()
.map(|r| (r.email.as_deref(), r.summary.as_deref()))
.collect::<Vec<_>>(),
vec![(Some("renamed@windmill.dev"), Some("moving"))],
"the moving account's draft wins the unique index it now collides on"
);
let resp = authed(client().delete(format!("{global_base}/delete/test3@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete_user: {}", resp.text().await?);
let remaining = sqlx::query_scalar!("SELECT path FROM draft ORDER BY path")
.fetch_all(&db)
.await?;
assert_eq!(
remaining,
vec!["u/ext/s".to_string(), "u/two/s".to_string()],
"the deleted account's draft goes, the accountless owner's stays"
);
Ok(())
}
+5 -2
View File
@@ -216,12 +216,15 @@ async fn list_scripts(
// a member of has no `usr` row, so fall back to their instance-derived username
// (`password.username`), or their email when derivation is disabled — this keeps the
// raw email out of the payload whenever a derived username exists. The genuine
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL).
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL),
// which is why an owner that resolves to no name at all — an external JWT's subject
// has neither row — is dropped: None is read as "legacy" downstream.
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users",
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script' \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(o.workspace_id, o.path) as inherited_labels"
])
.left()
+7 -1
View File
@@ -1239,6 +1239,7 @@ async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Resu
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &authed.email).await?;
audit_log(
&mut *tx,
@@ -1661,6 +1662,7 @@ async fn delete_user(
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
let usernames = sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 RETURNING username",
@@ -1869,7 +1871,7 @@ async fn change_user_email(
.execute(&mut *tx)
.await?;
// ---- account ---- (draft.email follows through its ON UPDATE CASCADE fkey)
// ---- account ----
sqlx::query!(
"UPDATE password SET email = $1 WHERE email = $2",
&new_email,
@@ -1883,6 +1885,7 @@ async fn change_user_email(
}
_ => e.into(),
})?;
windmill_common::user_drafts::rename_drafts_of_email(&mut *tx, &old_email, &new_email).await?;
sqlx::query!(
"UPDATE usr SET email = $1 WHERE email = $2",
@@ -3539,6 +3542,9 @@ async fn overwrite_global_users(
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
// Replaces the account table, so — unlike the paths that remove one account — it deliberately
// does not call `delete_drafts_of_email`: the addresses are about to be reinstated, and
// dropping every draft on the instance to restore accounts would be pure collateral.
sqlx::query!("DELETE FROM password")
.execute(&mut *tx)
.await?;
@@ -5824,9 +5824,8 @@ async fn clone_workspace_data(
// Clone the forker's own per-user drafts (plus the legacy NULL-email
// workspace draft, if any) so they keep their pending edits in the
// fork. Other users' drafts are intentionally NOT cloned — they don't
// own a `usr` row in the fork (see `clone_workspace_full`) so their
// drafts would dangle and the home-page `draft_users` aggregate would
// surface them as duplicate legacy entries.
// own a `usr` row in the fork (see `clone_workspace_full`), so those
// drafts would belong to someone the fork holds no membership for.
clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?;
// Clone workspace runnable dependencies and dependency map
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.800.1
version: 1.801.0
title: Windmill API
contact:
+2 -1
View File
@@ -491,7 +491,8 @@ async fn list_apps(
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app')) as draft_users",
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app') \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(app.workspace_id, app.path) as inherited_labels",
])
.left()
+4
View File
@@ -214,6 +214,9 @@ fn list_drafts_query(all_users: bool) -> String {
// row: fall back to their instance-derived username (`password.username`), or
// their email when derivation is disabled (`password.username` is NULL). This
// keeps the raw email out of the payload whenever a derived username exists.
// A null username means the legacy row downstream, so an owner that resolves to
// no name at all — an external JWT's subject has neither row — is dropped rather
// than surfaced as a second legacy entry.
let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN (
SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END))
ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END) NULLS LAST)
@@ -221,6 +224,7 @@ fn list_drafts_query(all_users: bool) -> String {
LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email
LEFT JOIN password p ON p.email = du.email AND p.super_admin = true
WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ
AND (du.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
) ELSE NULL END"#;
// Default lists the user's own drafts AND the legacy NULL-email rows; with
// `all_users` the filter is dropped to list every workspace draft.
+1
View File
@@ -618,6 +618,7 @@ pub(crate) async fn offboard_global_user(
sqlx::query!("DELETE FROM password WHERE email = $1", &email)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email).await?;
sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email)
.execute(&mut *tx)
.await?;
+2 -1
View File
@@ -261,7 +261,8 @@ fn branch_sqls() -> Branches {
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred}) as draft_users"
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred} \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users"
)
};
+65 -1
View File
@@ -245,7 +245,9 @@ async fn fetch_other_drafts_users(
// row: fall back to their instance-derived username (`password.username`), or
// their email when derivation is disabled. Else a real teammate's draft renders
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
// `username = None` (no `usr`/`password` match and `d.email` is NULL).
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
// why an owner that resolves to no name at all — an external JWT's subject has
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
@@ -261,6 +263,7 @@ async fn fetch_other_drafts_users(
AND d.path = $2
AND d.typ = $3
AND (d.email IS NULL OR d.email <> $4)
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
ORDER BY d.email NULLS LAST"#,
w_id,
path,
@@ -426,6 +429,67 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
}
}
/// Delete the drafts an address owns, across every workspace.
///
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
/// the account removal.
///
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
/// caller must already have authorized removing that account (superadmin, the account's own
/// holder, or SCIM).
pub async fn delete_drafts_of_email<'c>(
executor: impl sqlx::PgExecutor<'c>,
email: &str,
) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.execute(executor)
.await?;
Ok(())
}
/// Move the drafts an address owns onto its new address, for the same reason
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
///
/// The two addresses may each already hold a draft of the same item, since the destination can
/// belong to a principal with no account and so is not covered by the caller's "address is free"
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
/// a rename onto the same address returns early: every row would collide with itself and be
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
pub async fn rename_drafts_of_email(
conn: &mut sqlx::PgConnection,
old_email: &str,
new_email: &str,
) -> Result<()> {
if old_email == new_email {
return Ok(());
}
sqlx::query!(
"DELETE FROM draft dest
WHERE dest.email = $1
AND EXISTS (SELECT 1 FROM draft src
WHERE src.email = $2
AND src.workspace_id = dest.workspace_id
AND src.path = dest.path
AND src.typ = dest.typ)",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"UPDATE draft SET email = $1 WHERE email = $2",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
@@ -0,0 +1,28 @@
use sqlx::{Pool, Postgres};
use windmill_common::user_drafts::rename_drafts_of_email;
/// A rename onto the same address has to be a no-op: the helper clears a draft the destination
/// already holds at the same item, and every row would be its own destination. SCIM PATCH sends
/// `userName` unconditionally, so an IdP re-sending an unchanged one reaches this.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn renaming_onto_the_same_address_keeps_the_drafts(db: Pool<Postgres>) {
sqlx::query(
"INSERT INTO draft(workspace_id, path, typ, value, email) \
VALUES ('test-workspace', 'u/test-user/s', 'script', '{}'::json, 'test@windmill.dev')",
)
.execute(&db)
.await
.expect("failed to seed draft");
let mut conn = db.acquire().await.unwrap();
rename_drafts_of_email(&mut conn, "test@windmill.dev", "test@windmill.dev")
.await
.unwrap();
let kept: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM draft WHERE email = 'test@windmill.dev'")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(kept, 1);
}
+57
View File
@@ -38,6 +38,25 @@ pub fn is_default<T: Default + std::cmp::PartialEq>(t: &T) -> bool {
&T::default() == t
}
pub fn maybe_number<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr + serde::Deserialize<'de>,
<T as FromStr>::Err: Display,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumericOrString<T> {
String(String),
RawT(T),
}
match NumericOrString::<T>::deserialize(deserializer)? {
NumericOrString::String(s) => T::from_str(&s).map_err(serde::de::Error::custom),
NumericOrString::RawT(i) => Ok(i),
}
}
pub fn maybe_number_opt<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
@@ -85,3 +104,41 @@ where
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
#[derive(Deserialize)]
struct WithMaybeNumber {
#[serde(deserialize_with = "super::maybe_number")]
n: i64,
}
#[test]
fn maybe_number_accepts_number() {
let v: WithMaybeNumber = serde_json::from_value(serde_json::json!({ "n": 12345 })).unwrap();
assert_eq!(v.n, 12345);
}
#[test]
fn maybe_number_accepts_string() {
let v: WithMaybeNumber =
serde_json::from_value(serde_json::json!({ "n": "12345" })).unwrap();
assert_eq!(v.n, 12345);
}
#[test]
fn maybe_number_rejects_non_numeric_string() {
assert!(
serde_json::from_value::<WithMaybeNumber>(serde_json::json!({ "n": "abc" })).is_err()
);
}
#[test]
fn maybe_number_rejects_null() {
assert!(
serde_json::from_value::<WithMaybeNumber>(serde_json::json!({ "n": null })).is_err()
);
}
}
+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.800.1";
export const VERSION = "v1.801.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -603,7 +603,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
build.onLoad(
{ filter: /.*/, namespace: "wmill-virtual" },
(args: any) => {
const contents = wmillTs(port);
const contents = wmillTs();
log.info(
colors.yellow(
`[wmill-virtual] Loading virtual module: ${args.path}`,
+3 -3
View File
@@ -1,5 +1,5 @@
//comment this line and last to dev
export function wmillTsDev(port: number) { return `
export function wmillTsDev() { return `
let reqs: Record<string, any> = {}
let ws: WebSocket | null = null
let wsReady: Promise<void>
@@ -10,7 +10,7 @@ function initWebSocket() {
wsReadyResolve = resolve
})
ws = new WebSocket('ws://localhost:${port}')
ws = new WebSocket((window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host)
ws.onopen = () => {
console.log('[wmill] WebSocket connected')
@@ -157,4 +157,4 @@ export function streamJob(
ws?.send(JSON.stringify({ jobId, type: 'streamJob', reqId }))
})
}
`}
`}
+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.800.1";
export const VERSION = "1.801.0";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.800.1",
"version": "1.801.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.800.1",
"version": "1.801.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.800.1",
"version": "1.801.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",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from './common'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import FolderEditor from './FolderEditor.svelte'
import { Save } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
let {
offset = 0,
disableChatOffset = false,
onSaved = undefined,
workspace = undefined
}: {
offset?: number
disableChatOffset?: boolean
onSaved?: (name: string, created: boolean) => void | Promise<void>
/** Edit a folder of this workspace rather than the active one. */
workspace?: string
} = $props()
let drawer: Drawer | undefined = $state()
let mode: 'edit' | 'new' = $state('edit')
let name: string = $state('')
let canSave = $state(false)
let unsaved = $state(false)
// An `edit` drawer whose folder turns out not to exist saves by creating it. Calling that
// Save would promise an update the click does not perform.
let exists = $state(false)
let saving = $state(false)
let confirmDiscardOpen = $state(false)
let discarding = $state(false)
let editor: { save: () => Promise<{ name: string; created: boolean } | undefined> } | undefined =
$state()
// Bumped per open so the editor reloads its draft from the folder it is now
// pointed at. Keying on `name` instead would remount on every keystroke of the
// name field in `new` mode.
let instance = $state(0)
function open(nextMode: 'edit' | 'new', folderName: string): void {
mode = nextMode
name = folderName
discarding = false
confirmDiscardOpen = false
exists = nextMode === 'edit'
// The remounted editor reports these on its first effect, which is a tick away. Until
// then the header would carry the last folder's answers.
canSave = false
unsaved = false
instance++
drawer?.openDrawer()
}
export function initEdit(folderName: string): void {
open('edit', folderName)
}
export function initNew(initialName: string = ''): void {
open('new', initialName)
}
/** The editor keeps its draft in memory only, so closing throws it away. */
function requestClose() {
// A save is already writing. `unsaved` only clears once it reloads, so closing here
// would offer to discard changes the in-flight requests are busy persisting — and
// confirming would close on that lie. Saving is the shorter wait; ignore the close.
if (saving) return
if (discarding || !unsaved) {
drawer?.closeDrawer()
return
}
confirmDiscardOpen = true
}
async function save() {
saving = true
try {
// `created` comes from the editor, not from `mode`: a folder whose row turned out
// not to exist is created from an `initEdit` drawer.
const saved = await editor?.save()
if (saved) {
// Callers reload a list here. Called from inside the chain, not before it, so a
// synchronous throw is caught too — thrown out of `save()` it would skip the
// close below and strand the drawer open on a folder that did save.
void Promise.resolve()
.then(() => onSaved?.(saved.name, saved.created))
.catch((e) => sendUserToast(e?.body ?? String(e), true))
// The editor reloads its baseline after saving, but that lands a tick
// later; close on our own authority rather than racing it.
discarding = true
// Belt and braces with the `saving` guard on the close paths: nothing that
// asked to discard may outlive a save that then succeeded.
confirmDiscardOpen = false
drawer?.closeDrawer()
}
} finally {
saving = false
}
}
</script>
<Drawer
bind:this={drawer}
{offset}
{disableChatOffset}
on:close={() => {
// Escape and click-away close the drawer before asking. Reopening in the same
// tick is how the flow's script editor drawer handles this too: the close
// transition has not started, so nothing flickers.
if (saving) {
drawer?.openDrawer()
return
}
if (!discarding && unsaved) {
drawer?.openDrawer()
confirmDiscardOpen = true
}
}}
>
<DrawerContent title={exists ? `Folder ${name}` : 'Create folder'} on:close={requestClose}>
<!-- `save()` snapshots the draft and then awaits several requests. An edit landing in
that window would not be in the snapshot, and the drawer closes on success — so
it would be lost without ever being offered as unsaved. `inert` keeps the form
from taking one. -->
<div inert={saving} class={saving ? 'opacity-60 transition-opacity' : 'transition-opacity'}>
{#key instance}
<FolderEditor
bind:this={editor}
bind:name
{mode}
{workspace}
onCanSaveChange={(v) => (canSave = v)}
onUnsavedChange={(v) => (unsaved = v)}
onExistsChange={(v) => (exists = v)}
/>
{/key}
</div>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
disabled={!canSave}
loading={saving}
on:click={save}
>
{exists ? 'Save' : 'Create'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- `alwaysPortal`: this drawer is opened from inside other drawers (the folder picker of a
resource or variable form), and that outer drawer is a stacking context this dialog
cannot climb out of on z-index alone. Left in place it paints under the drawer whose
unsaved changes it is asking about, which leaves that drawer impossible to close. -->
<ConfirmationModal
alwaysPortal
open={confirmDiscardOpen}
title="Unsaved changes detected"
confirmationText="Discard changes"
onCanceled={() => (confirmDiscardOpen = false)}
onConfirmed={() => {
confirmDiscardOpen = false
discarding = true
drawer?.closeDrawer()
}}
>
<span> Are you sure you want to discard the changes you have made to this folder? </span>
</ConfirmationModal>
+18 -103
View File
@@ -3,28 +3,16 @@
import { workspaceStore, userStore } from '$lib/stores'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import FolderEditor from './FolderEditor.svelte'
import { Button } from './common'
import FolderEditorDrawer from './FolderEditorDrawer.svelte'
import Select from './select/Select.svelte'
import TextInput from './text_input/TextInput.svelte'
import Label from './Label.svelte'
import InputError from './InputError.svelte'
import { tick } from 'svelte'
import { sendUserToast } from '$lib/toast'
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
let folders: { name: string; write: boolean }[] = $state([])
let filterText: string = $state('')
let selectOpen: boolean = $state(false)
let nameInput: TextInput | undefined = $state()
let newFolder: Drawer | null = $state(null)
let viewFolder: Drawer | null = $state(null)
let newFolderName: string = $state('')
let folderCreated: string | undefined = $state(undefined)
let creating: boolean = $state(false)
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let loadingFolders: boolean = $state(true)
let editingFolder: string = $state('')
type Props = {
folderName: string
@@ -99,45 +87,24 @@
}
}
async function openCreateFolder() {
newFolderName = filterText
folderCreated = undefined
newFolder?.openDrawer()
await tick()
nameInput?.focus()
}
async function addFolder() {
if (nameError || !newFolderName || creating) return
creating = true
try {
await FolderService.createFolder({
workspace: targetWorkspace,
requestBody: { name: newFolderName }
})
folderCreated = newFolderName
async function onFolderSaved(saved: string, created: boolean) {
if (created) {
// The creator owns what they just created. Recorded on whichever membership
// this picker is reading, and *before* reloading, so the new folder comes
// back selectable rather than `(read-only)` — `loadFolders` derives `write`
// from exactly this.
if (aimedElsewhere) {
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), newFolderName]
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), saved]
} else if ($userStore) {
// Writing $userStore.folders = [...] would call userStore.set(),
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
// switching the owner toggle from "Folder" back to "User".
if (!$userStore.folders) $userStore.folders = []
$userStore.folders.push(newFolderName)
$userStore.folders.push(saved)
}
await loadFolders()
folderName = newFolderName
} catch (e) {
sendUserToast(`Could not create folder: ${e}`, true)
} finally {
creating = false
}
await loadFolders()
if (created) folderName = saved
}
let selectItems = $derived(
@@ -148,16 +115,6 @@
}))
)
let nameError = $derived(
!newFolderName
? ''
: !VALID_FOLDER_NAME.test(newFolderName)
? 'Folder name can only contain alphanumeric characters, underscores, and hyphens'
: folders.some((f) => f.name === newFolderName)
? 'A folder with this name already exists'
: ''
)
let noMatchingItems = $derived(
filterText &&
!selectItems.some((item) => item.label.toLowerCase().includes(filterText.toLowerCase()))
@@ -167,7 +124,7 @@
if (e.key === 'Enter' && selectOpen && noMatchingItems && !restricted) {
e.preventDefault()
selectOpen = false
openCreateFolder()
folderEditorDrawer?.initNew(filterText)
}
}
@@ -185,53 +142,12 @@
loadTargetUser().then(loadFolders)
</script>
<Drawer bind:this={newFolder} name="newFolder" offset={drawerOffset}>
<DrawerContent
title={folderCreated ? `Folder ${folderCreated}` : 'Create folder'}
on:close={() => {
newFolder?.closeDrawer()
folderCreated = undefined
}}
>
{#if folderCreated}
<FolderEditor name={folderCreated} />
{:else}
<div class="flex flex-col gap-4">
<Label label="Folder name">
<TextInput
bind:this={nameInput}
bind:value={newFolderName}
error={!!nameError}
inputProps={{
placeholder: 'folder_name',
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' && newFolderName) {
e.preventDefault()
addFolder()
}
}
}}
/>
<InputError error={nameError} />
</Label>
<Button
variant="accent"
disabled={!newFolderName || !!nameError || creating}
loading={creating}
onClick={addFolder}
>
Create
</Button>
</div>
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={viewFolder} offset={drawerOffset}>
<DrawerContent title="Folder {editingFolder}" on:close={viewFolder.closeDrawer}>
<FolderEditor name={editingFolder} />
</DrawerContent>
</Drawer>
<FolderEditorDrawer
bind:this={folderEditorDrawer}
offset={drawerOffset}
workspace={targetWorkspace}
onSaved={onFolderSaved}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -262,8 +178,7 @@
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
onClick={() => {
editingFolder = item.value ?? ''
viewFolder?.openDrawer()
folderEditorDrawer?.initEdit(item.value ?? '')
close()
}}
startIcon={{ icon: Pen }}
@@ -278,7 +193,7 @@
: ''}"
onclick={() => {
close()
openCreateFolder()
folderEditorDrawer?.initNew(filterText)
}}
>
<PlusIcon class="inline" size={16} />
+470 -223
View File
@@ -7,10 +7,13 @@
type InstanceGroup
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher, untrack } from 'svelte'
import { onMount, tick, untrack } from 'svelte'
import { Button } from './common'
import Skeleton from './common/skeleton/Skeleton.svelte'
import TableCustom from './TableCustom.svelte'
import DataTable from './table/DataTable.svelte'
import Head from './table/Head.svelte'
import Row from './table/Row.svelte'
import Cell from './table/Cell.svelte'
import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -19,42 +22,112 @@
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Trash } from 'lucide-svelte'
import { Plus, Trash } from 'lucide-svelte'
import PermissionHistory from './PermissionHistory.svelte'
import Alert from './common/alert/Alert.svelte'
import InputError from './InputError.svelte'
import Popover from './meltComponents/Popover.svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
import {
groupMemberDiff,
isGroupDraftDirty,
type GroupDraft,
type GroupRole
} from '$lib/groupDraft'
interface Props {
name: string
const ROLE_TOOLTIPS = {
member:
'A Member of a group can see everything the group can see, write to everything the group can write, and generally act on behalf of the group',
manager:
'A manager of a group can manage the group, adding and removing users and change their roles. Being a manager does not make you a member',
admin:
'An admin of a group is a member of a group that can also add and remove members to the group, or make them admin.'
}
let { name }: Props = $props()
let can_write = $state(false)
const MEMBERS_EXPLAINER =
'A member is a user with a role on this group. Members act on behalf of the group and see everything it can see; admins can additionally add and remove members.'
type Role = 'member' | 'manager' | 'admin'
// Edits mutate `draft` only; `save()` is the sole writer to the backend, and `baseline` is
// what the group held when it was loaded, so comparing the two gives both the dirty state
// and the member calls to replay. Both live in `groupDraft.ts`, with tests.
interface Props {
/** In `new` mode this is the name being typed, hence bindable. */
name: string
mode?: 'edit' | 'new'
/** Drives the parent drawer's Save button, which lives above this component. */
onCanSaveChange?: (canSave: boolean) => void
/** Drives the parent drawer's discard confirmation on close. Unlike `canSave` this
* stays true for edits that cannot be saved yet (a name already taken) — closing
* would still throw them away. */
onUnsavedChange?: (unsaved: boolean) => void
/** Turns true once the group exists on the server, which a `new` drawer reaches
* mid-save. The drawer stops calling itself Create from that point. */
onExistsChange?: (exists: boolean) => void
}
let {
name = $bindable(),
mode = 'edit',
onCanSaveChange,
onUnsavedChange,
onExistsChange
}: Props = $props()
const restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let can_write = $state(false)
let group: Group | undefined
let instance_group: InstanceGroup | undefined = $state()
let members: { member_name: string; role: Role }[] | undefined = $state(undefined)
let usernames: string[] | undefined = $state([])
let username: string = $state('')
let summary = $state('')
let usernames: string[] = $state([])
let groupNames: string[] = $state([])
let loaded = $state(false)
let reloadHistory = $state(0)
let nameInput: TextInput | undefined = $state(undefined)
const dispatch = createEventDispatcher()
let baseline: GroupDraft | undefined = $state(undefined)
// Empty, not `emptyDraft()`: that one seeds the caller as an admin, which is true of a
// group being created and a lie about one whose read failed. Every path that wants the
// seeded row calls `emptyDraft()` itself.
let draft: GroupDraft = $state({ summary: '', members: [] })
let memberToAdd: string = $state('')
let newMemberRole: GroupRole = $state('member')
// `create_group` puts the caller in the group and gives them the write entry, so a save
// on this branch has already happened once the request lands: a retry after a later
// member call fails must take the edit path or it recreates a group that now exists.
let alreadyCreated = $state(false)
const isNew = $derived(mode === 'new' && !alreadyCreated)
function emptyDraft(): GroupDraft {
return {
summary: '',
// The backend makes the creator an admin whatever we send, so the table shows that
// from the start rather than after the first reload.
members: $userStore ? [{ member_name: $userStore.username, role: 'admin' as GroupRole }] : []
}
}
function setDraft(value: GroupDraft) {
baseline = structuredClone(value)
draft = structuredClone(value)
}
/** Fills a picker or a validation list. The editor is usable before these land, so they
* run alongside the group read — but a rejection has to be reported: unhandled, it
* leaves the list silently empty and duplicate names stop being caught. */
function loadAside(load: () => Promise<void>): void {
load().catch((e) => sendUserToast(e?.body ?? String(e), true))
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
}
async function load() {
return Promise.all([loadGroup(), loadInstanceGroup(), loadUsernames()])
}
async function addToGroup() {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { username }
})
loadGroup()
async function loadGroupNames(): Promise<void> {
groupNames = (await GroupService.listGroupNames({ workspace: $workspaceStore! })) ?? []
}
async function loadInstanceGroup(): Promise<void> {
@@ -65,55 +138,218 @@
}
}
async function loadGroup(): Promise<void> {
try {
group = await GroupService.getGroup({ workspace: $workspaceStore!, name })
can_write = canWrite(name!, group.extra_perms ?? {}, $userStore)
members = Array.from(
new Set(
Object.entries(group?.extra_perms ?? {})
.filter(([k, v]) => k.startsWith('u/') && v)
.map(([k, _]) => k.split('/')[1])
.concat(group?.members ?? [])
)
).map((x) => {
return {
member_name: x,
role: getRole(x)
}
})
summary = group.summary ?? ''
reloadHistory++
} catch (e) {
can_write = false
members = []
summary = ''
group = {
name
}
async function load() {
loadAside(loadUsernames)
if (isNew) {
loadAside(loadGroupNames)
can_write = true
setDraft(emptyDraft())
loaded = true
} else {
loadAside(loadInstanceGroup)
await loadGroup()
}
}
function getRole(x: string): Role {
const writer = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x]
const member = group?.members?.includes(x)
/** `baselineOnly` re-reads the group without touching the draft: after a save that
* committed some of its calls and then failed, the baseline must become what the server
* actually holds while the draft stays the user's intent — the applied changes then stop
* counting as dirty, and the ones still missing stay dirty and retryable. */
async function loadGroup(opts?: { baselineOnly?: boolean }): Promise<void> {
const apply = (value: GroupDraft) =>
opts?.baselineOnly ? (baseline = structuredClone(value)) : setDraft(value)
try {
group = await GroupService.getGroup({ workspace: $workspaceStore!, name })
can_write = canWrite(name, group.extra_perms ?? {}, $userStore)
apply({
summary: group.summary ?? '',
members: Array.from(
new Set(
Object.entries(group?.extra_perms ?? {})
.filter(([k, v]) => k.startsWith('u/') && v)
.map(([k, _]) => k.split('/')[1])
.concat(group?.members ?? [])
)
).map((x) => ({ member_name: x, role: getRole(x) }))
})
reloadHistory++
} catch (e) {
// The draft must survive a failed read: overwriting it here would discard the
// user's edits and clear `unsaved` with them.
sendUserToast(e?.body ?? String(e), true)
// Only the opening read decides this. Revoking it on a failed reconcile would
// disable Save against a draft that is still dirty, with nothing left to reload.
if (!opts?.baselineOnly) can_write = false
} finally {
loaded = true
}
}
if (writer && member) {
function getRole(x: string): GroupRole {
const manages = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x]
const belongs = group?.members?.includes(x)
if (manages && belongs) {
return 'admin'
} else if (writer) {
} else if (manages) {
return 'manager'
} else {
return 'member'
}
}
// Guarded on `isNew`, not `mode`: once the group exists the name is frozen, so there is
// nothing left to validate.
const nameError = $derived(
!isNew
? ''
: !name
? ''
: groupNames.includes(name)
? 'A group with this name already exists'
: ''
)
const dirty = $derived(isGroupDraftDirty(draft, baseline))
// A typed name is progress too, even before any other field is touched.
const unsaved = $derived(dirty || (mode === 'new' && !!name))
$effect(() => {
onCanSaveChange?.(isNew ? loaded && !!name && !nameError && !restricted : can_write && dirty)
})
$effect(() => {
onUnsavedChange?.(unsaved)
})
$effect(() => {
onExistsChange?.(!isNew)
})
// `create_group` folds the caller into the group as an admin whatever the payload says, so
// on create their own row is fixed: offering to demote or remove it would be a change the
// backend silently discards.
function isFixedCreatorRow(member: string): boolean {
return isNew && member === $userStore?.username
}
function addMember(close: () => void) {
if (!draft.members.some((m) => m.member_name === memberToAdd)) {
draft.members.push({ member_name: memberToAdd, role: newMemberRole })
}
memberToAdd = ''
close()
}
/** Replays the member rows the user changed. `updateGroup` writes the summary only, so
* membership goes through the endpoints that name who was added or promoted — which is
* what the permission history reads back. The diff itself is in `groupDraft.ts`. */
async function applyMemberChanges(next: GroupDraft['members'], prev: GroupDraft['members']) {
const workspace = $workspaceStore ?? ''
for (const call of groupMemberDiff(prev, next, $userStore?.username)) {
switch (call.kind) {
case 'addUser':
await GroupService.addUserToGroup({
workspace,
name,
requestBody: { username: call.username }
})
break
case 'removeUser':
await GroupService.removeUserToGroup({
workspace,
name,
requestBody: { username: call.username }
})
break
case 'setAcl':
await GranularAclService.addGranularAcls({
workspace,
path: name,
kind: 'group_',
requestBody: { owner: 'u/' + call.username, write: true }
})
break
case 'removeAcl':
await GranularAclService.removeGranularAcls({
workspace,
path: name,
kind: 'group_',
requestBody: { owner: 'u/' + call.username }
})
break
}
}
}
export async function save(): Promise<{ name: string; created: boolean } | undefined> {
const next = $state.snapshot(draft) as GroupDraft
const prev = baseline as GroupDraft
const created = isNew
try {
if (created) {
await GroupService.createGroup({
workspace: $workspaceStore ?? '',
requestBody: { name, summary: next.summary }
})
alreadyCreated = true
// The members the caller added, on top of the admin row `create_group` wrote.
await applyMemberChanges(next.members, emptyDraft().members)
sendUserToast(`Group ${name} created`)
} else {
if (next.summary !== prev.summary) {
await GroupService.updateGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { summary: next.summary }
})
}
await applyMemberChanges(next.members, prev.members)
await loadGroup()
sendUserToast('Group updated')
}
return { name, created }
} catch (e) {
sendUserToast(e.body ?? String(e), true)
// A failed create is not proof the group is absent: `create_group` commits before a
// git-sync step that can still fail the request, including with a 4xx. Only the name
// conflict says it was never written. Report rather than resolve — a group found by
// name may be someone else's, and adopting it would send this draft's writes there.
const nameTaken = String(e?.body ?? '').includes('already exists')
if (created && !alreadyCreated && !nameTaken) {
sendUserToast(`Group ${name} may have been created anyway — reopen it to check`, true)
}
// Reconcile after any edit-path failure: the post-commit window means a rejection is
// not proof nothing was written. The baseline moves to server truth and the draft
// stays, so a retry re-sends only what is missing. `isNew` is read after the create,
// so a group that now exists reconciles too.
if (!isNew) await loadGroup({ baselineOnly: true })
return undefined
}
}
// The stores are read only to wait until they are populated, and the load runs once: this
// editor holds an unsaved draft, and the layout re-`set`s `$userStore` periodically — a
// second `load()` would overwrite the draft with the server's state and lose the edits
// silently, `unsaved` included. The drawer remounts this component per opening.
let loadStarted = false
$effect.pre(() => {
if (loadStarted) return
if ($workspaceStore && $userStore) {
loadStarted = true
untrack(() => {
load()
})
}
})
let reloadHistory = $state(0)
onMount(async () => {
if (mode !== 'new') return
// The editor is remounted per drawer opening, so mount is the moment the create form
// appears; the input only exists after the first render.
await tick()
nameInput?.focus()
})
</script>
<div class="flex flex-col gap-6">
@@ -124,207 +360,218 @@
permission, deployed items will be reassigned to the deploying user.
</Alert>
{/if}
<Label label="Summary" for="summary">
<div class="flex flex-row gap-2">
{#if mode === 'new'}
<Label label="Group name">
<!-- Frozen once the group exists: `createGroup` lands before the member calls, so a
save can fail with the group already created under this name. Retyping it would
point the remaining member calls at a different group — one that may not exist,
or worse, one that does. -->
<TextInput
inputProps={{ placeholder: 'Short summary to be displayed when listed', id: 'summary' }}
bind:value={summary}
bind:this={nameInput}
bind:value={name}
error={!!nameError}
size="md"
inputProps={{ placeholder: 'group_name', disabled: !isNew }}
/>
<Button
unifiedSize="md"
variant="accent"
on:click={async () => {
await GroupService.updateGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { summary }
})
dispatch('update')
sendUserToast('Group summary updated')
loadGroup()
}}>Save</Button
>
</div>
<InputError error={nameError} />
</Label>
{/if}
<Label label="Summary" for="summary">
<TextInput
inputProps={{
placeholder: 'Short summary to be displayed when listed',
id: 'summary',
disabled: !can_write
}}
bind:value={draft.summary}
size="md"
/>
</Label>
<Label label={`Members (${members?.length ?? 0})`}>
{#if can_write}
<div class="flex items-start gap-1">
<Select items={safeSelectItems(usernames)} bind:value={username} size="md" class="grow" />
<Button variant="accent" color="blue" unifiedSize="md" on:click={addToGroup}>
Add member
</Button>
</div>
{/if}
{#if members}
<TableCustom>
{#snippet headerRow()}
<tr>
<th>user</th>
<th></th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each members ?? [] as { member_name, role }}<tr>
<td>{member_name}</td>
<td>
{#if can_write}
<Label label={`Members (${draft.members.length})`} tooltip={MEMBERS_EXPLAINER}>
{#snippet action()}
{#if can_write && !restricted}
<Popover
placement="bottom-end"
onClose={() => {
memberToAdd = ''
newMemberRole = 'member'
}}
>
{#snippet trigger()}
<Button
variant="default"
unifiedSize="sm"
nonCaptureEvent={true}
startIcon={{ icon: Plus }}
>
Add member
</Button>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col w-72 p-4 gap-4">
<span class="text-sm leading-6 font-semibold">Add a member</span>
<Label label="User">
<Select
items={safeSelectItems(
usernames.filter((x) => !draft.members.some((m) => m.member_name === x))
)}
bind:value={memberToAdd}
size="sm"
class="grow min-w-0"
/>
</Label>
<Label label="Role">
<ToggleButtonGroup bind:selected={newMemberRole}>
{#snippet children({ item })}
<ToggleButton
value="member"
label="Member"
tooltip={ROLE_TOOLTIPS.member}
{item}
size="sm"
/>
<ToggleButton
value="admin"
label="Admin"
tooltip={ROLE_TOOLTIPS.admin}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Button
variant="accent"
unifiedSize="sm"
disabled={memberToAdd == ''}
onClick={() => addMember(close)}
>
Add
</Button>
</div>
{/snippet}
</Popover>
{/if}
{/snippet}
<div class="flex flex-col gap-2">
{#if can_write && restricted}
<Alert type="info" title="Sharing disabled">{DEMO_RESTRICTION_HINT}</Alert>
{/if}
{#if loaded}
<DataTable size="sm">
<Head>
<tr>
<Cell head first class="text-secondary">Name</Cell>
<Cell head class="text-secondary">Role</Cell>
<Cell head last actions class="text-secondary">Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each draft.members as member, idx (member.member_name)}
<Row>
<Cell first>
<span class="text-emphasis font-medium">{member.member_name}</span>
</Cell>
<Cell>
{#if can_write && !restricted}
<div>
<ToggleButtonGroup
selected={role}
on:selected={async (e) => {
const role = e.detail
// const wasInGroup = (group?.members ?? []).includes(group)
// const inAcl = (
// group?.extra_perms ? Object.keys(group?.extra_perms) : []
// ).includes(group)
if (role == 'member') {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name
}
})
} else if (role == 'manager') {
await GroupService.removeUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.addGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name,
write: true
}
})
} else if (role == 'admin') {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.addGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name,
write: true
}
})
}
loadGroup()
disabled={isFixedCreatorRow(member.member_name)}
selected={member.role}
on:selected={(e) => {
draft.members[idx].role = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="member"
small
label="Member"
tooltip="A Member of a group can see everything the group can see, write to everything the group can write, and generally act on behalf of the group"
tooltip={ROLE_TOOLTIPS.member}
{item}
size="sm"
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin of a group is a member of a group that can also add and remove members to the group, or make them admin."
tooltip={ROLE_TOOLTIPS.admin}
{item}
size="sm"
/>
{#if role === 'manager'}
<!-- Manager is a state the UI can leave but not enter: it is a
write entry without membership, which only older grants hold. -->
{#if member.role === 'manager'}
<ToggleButton
value="manager"
small
label="Manager"
tooltip="A manager of a group can manage the group, adding and removing users and
change their roles. Being a manager does not make you a member"
tooltip={ROLE_TOOLTIPS.manager}
{item}
size="sm"
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
{:else}
{role}
{/if}</td
>
<td class="flex justify-end">
{#if can_write}
<Button
variant="subtle"
destructive
unifiedSize="md"
startIcon={{ icon: Trash }}
iconOnly
onclick={async () => {
await GroupService.removeUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { username: member_name }
})
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name
}
})
loadGroup()
}}
/>
{/if}</td
>
</tr>{/each}
{member.role}
{/if}
</Cell>
<Cell last actions>
<div class="flex items-center justify-end">
{#if can_write && !isFixedCreatorRow(member.member_name)}
<Button
variant="subtle"
destructive
unifiedSize="sm"
startIcon={{ icon: Trash }}
iconOnly
onclick={() => {
draft.members = draft.members.filter(
(m) => m.member_name !== member.member_name
)
}}
/>
{:else if isFixedCreatorRow(member.member_name)}
<span class="text-2xs text-hint">admin as the creator</span>
{/if}
</div>
</Cell>
</Row>
{/each}
</tbody>
{/snippet}
</TableCustom>
{#if instance_group?.emails}
<h2 class="mt-6 text-emphasis text-xs font-semibold">Members from the instance group</h2>
<TableCustom>
{#snippet headerRow()}
<tr>
<th>user</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each instance_group?.emails ?? [] as email}<tr>
<td>{email}</td>
</tr>{/each}
</tbody>
{/snippet}
</TableCustom>
</DataTable>
{:else}
<div class="flex flex-col">
{#each new Array(6) as _}
<Skeleton layout={[[2], 0.7]} />
{/each}
</div>
{/if}
{:else}
<div class="flex flex-col">
{#each new Array(6) as _}
<Skeleton layout={[[2], 0.7]} />
{/each}
</div>
{/if}
</div>
</Label>
{#if instance_group?.emails}
<Label label="Members from the instance group">
<DataTable size="sm">
<Head>
<tr>
<Cell head first last class="text-secondary">Email</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each instance_group?.emails ?? [] as email}
<Row>
<Cell first last>{email}</Cell>
</Row>
{/each}
</tbody>
</DataTable>
</Label>
{/if}
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
@@ -0,0 +1,162 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from './common'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import GroupEditor from './GroupEditor.svelte'
import { Save } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
let {
offset = 0,
disableChatOffset = false,
onSaved = undefined
}: {
offset?: number
disableChatOffset?: boolean
onSaved?: (name: string, created: boolean) => void | Promise<void>
} = $props()
let drawer: Drawer | undefined = $state()
let mode: 'edit' | 'new' = $state('edit')
let name: string = $state('')
let canSave = $state(false)
let unsaved = $state(false)
// A `new` drawer whose group has been created but whose member calls then failed stays
// open on the edit path. Calling it Create there would offer to create what exists.
let exists = $state(false)
let saving = $state(false)
let confirmDiscardOpen = $state(false)
let discarding = $state(false)
let editor: { save: () => Promise<{ name: string; created: boolean } | undefined> } | undefined =
$state()
// Bumped per open so the editor reloads its draft from the group it is now pointed at.
// Keying on `name` instead would remount on every keystroke of the name field in `new` mode.
let instance = $state(0)
function open(nextMode: 'edit' | 'new', groupName: string): void {
mode = nextMode
name = groupName
discarding = false
confirmDiscardOpen = false
exists = nextMode === 'edit'
// The remounted editor reports these on its first effect, which is a tick away. Until
// then the header would carry the last group's answers.
canSave = false
unsaved = false
instance++
drawer?.openDrawer()
}
export function initEdit(groupName: string): void {
open('edit', groupName)
}
export function initNew(initialName: string = ''): void {
open('new', initialName)
}
/** The editor keeps its draft in memory only, so closing throws it away. */
function requestClose() {
// A save is already writing. `unsaved` only clears once it reloads, so closing here
// would offer to discard changes the in-flight requests are busy persisting — and
// confirming would close on that lie. Saving is the shorter wait; ignore the close.
if (saving) return
if (discarding || !unsaved) {
drawer?.closeDrawer()
return
}
confirmDiscardOpen = true
}
async function save() {
saving = true
try {
const saved = await editor?.save()
if (saved) {
// Callers reload a list here. Called from inside the chain, not before it, so a
// synchronous throw is caught too — thrown out of `save()` it would skip the
// close below and strand the drawer open on a group that did save.
void Promise.resolve()
.then(() => onSaved?.(saved.name, saved.created))
.catch((e) => sendUserToast(e?.body ?? String(e), true))
// The editor reloads its baseline after saving, but that lands a tick later;
// close on our own authority rather than racing it.
discarding = true
// Belt and braces with the `saving` guard on the close paths: nothing that
// asked to discard may outlive a save that then succeeded.
confirmDiscardOpen = false
drawer?.closeDrawer()
}
} finally {
saving = false
}
}
</script>
<Drawer
bind:this={drawer}
{offset}
{disableChatOffset}
on:close={() => {
// Escape and click-away close the drawer before asking. Reopening in the same tick is
// how the flow's script editor drawer handles this too: the close transition has not
// started, so nothing flickers.
if (saving) {
drawer?.openDrawer()
return
}
if (!discarding && unsaved) {
drawer?.openDrawer()
confirmDiscardOpen = true
}
}}
>
<DrawerContent title={exists ? `Group ${name}` : 'Create group'} on:close={requestClose}>
<!-- `save()` snapshots the draft and then awaits several requests. An edit landing in
that window would not be in the snapshot, and the drawer closes on success — so it
would be lost without ever being offered as unsaved. `inert` keeps the form from
taking one. -->
<div inert={saving} class={saving ? 'opacity-60 transition-opacity' : 'transition-opacity'}>
{#key instance}
<GroupEditor
bind:this={editor}
bind:name
{mode}
onCanSaveChange={(v) => (canSave = v)}
onUnsavedChange={(v) => (unsaved = v)}
onExistsChange={(v) => (exists = v)}
/>
{/key}
</div>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
disabled={!canSave}
loading={saving}
on:click={save}
>
{exists ? 'Save' : 'Create'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- `alwaysPortal`: this drawer is opened from inside another drawer (the folder editor, itself
reachable from a resource or variable form), and that outer drawer is a stacking context
this dialog cannot climb out of on z-index alone. Left in place it paints under the drawer
whose unsaved changes it is asking about, which leaves that drawer impossible to close. -->
<ConfirmationModal
alwaysPortal
open={confirmDiscardOpen}
title="Unsaved changes detected"
confirmationText="Discard changes"
onCanceled={() => (confirmDiscardOpen = false)}
onConfirmed={() => {
confirmDiscardOpen = false
discarding = true
drawer?.closeDrawer()
}}
>
<span> Are you sure you want to discard the changes you have made to this group? </span>
</ConfirmationModal>
+34 -2
View File
@@ -8,9 +8,22 @@
labels: string[] | undefined
onchange?: () => void
class?: string
/** Suggest the labels of this workspace rather than the active one, for an editor
* aimed elsewhere (the folder drawer opened from a cross-workspace picker). */
workspace?: string
/** Text typed into the input but not yet added to `labels`. An editor with a Save
* button needs it: without it that text is invisible to the editor's dirty state,
* so it is silently dropped on close and cannot even enable Save on its own. */
onPendingChange?: (pending: string) => void
}
let { labels = $bindable(), onchange, class: clazz = '' }: Props = $props()
let {
labels = $bindable(),
onchange,
class: clazz = '',
workspace,
onPendingChange
}: Props = $props()
let adding = $state(false)
let inputValue = $state('')
@@ -34,9 +47,13 @@
!(labels ?? []).includes(trimmedInput)
)
$effect(() => {
onPendingChange?.(adding ? trimmedInput : '')
})
async function loadExistingLabels() {
try {
const resp = await fetch(`/api/w/${$workspaceStore}/labels/list`)
const resp = await fetch(`/api/w/${workspace ?? $workspaceStore}/labels/list`)
if (resp.ok) existingLabels = await resp.json()
} catch {}
}
@@ -82,8 +99,15 @@
addLabel() // either "Create new" selected or free text
}
} else if (e.key === 'Escape') {
// Escape cancels the label, and nothing else. Left to bubble it also reaches
// whatever encloses us — a drawer or dialog closes on it, and one guarding on
// unsaved changes reads `pending` before this clears it, so it prompts to
// discard work this key just discarded.
e.preventDefault()
e.stopPropagation()
inputValue = ''
adding = false
onPendingChange?.('')
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const maxIdx = suggestions.length + (showCreateNew ? 1 : 0) - 1
@@ -100,6 +124,14 @@
if (adding) addLabel()
}, 150)
}
/** Add whatever is typed but not yet committed, right now. Blur commits on a 150ms
* grace period, so a caller that reads `labels` in the same tick as the blur — a Save
* button, which blurs this input by being clicked — would miss the last label.
* `adding` is cleared here, so the pending timer then finds nothing to do. */
export function flushPendingLabel(): void {
if (adding) addLabel()
}
</script>
<div class="inline-flex items-center gap-1 ml-0.5 h-5 {clazz}">
@@ -252,7 +252,7 @@
{/if}
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-emphasis"
>Extra permissions ({acls?.length ?? 0})</span
>Extra members ({acls?.length ?? 0})</span
>
{#if linkedVarPaths.length > 0}
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
@@ -299,7 +299,7 @@
size="lg"
variant="accent"
disabled={!newOwner}
on:click={() => addAcl(newOwner, write)}>Add permission</Button
on:click={() => addAcl(newOwner, write)}>Add member</Button
>
</div>
{/if}
@@ -307,7 +307,7 @@
<TableCustom>
{#snippet headerRow()}
<tr>
<th>owner</th>
<th>member</th>
<th></th>
<th></th>
</tr>
@@ -10,10 +10,12 @@
Icon?: any | undefined
class?: string
id?: string | undefined
/** Names the button: it has no text, so without this it reads as "button" to a screen reader. */
title?: string | undefined
onClick?: () => void | undefined | any
}
let { noBg = false, small = false, Icon, class: className, id, onClick }: Props = $props()
let { noBg = false, small = false, Icon, class: className, id, title, onClick }: Props = $props()
const dispatch = createEventDispatcher()
</script>
@@ -22,6 +24,7 @@
on:click={() => (dispatch('close'), onClick?.())}
on:pointerdown={(e) => e.stopPropagation()}
{id}
{title}
startIcon={{ icon: Icon ?? X }}
iconOnly
unifiedSize="sm"
@@ -23,6 +23,12 @@
/** Tailwind z-index class for the modal root. Override to stack this modal
* above another modal that's already open (both default to `z-[9999]`). */
zIndexClass?: string
/** Render into `body` instead of where this component sits. Needed when an ancestor
* creates a stacking context the dialog has to escape — a drawer paints over the page
* whatever the dialog's z-index, and a `transform`, `filter` or `overflow` on the way
* up confines it. Off by default: it moves the dialog out of its DOM position, so opt
* in per call site rather than assuming every caller wants it. */
alwaysPortal?: boolean
children?: Snippet
onConfirmed?: () => void | Promise<void>
onCanceled?: () => void
@@ -40,6 +46,7 @@
id,
trashbin = false,
zIndexClass = 'z-[9999]',
alwaysPortal = false,
children,
onConfirmed,
onCanceled
@@ -141,7 +148,11 @@
<svelte:window onkeydowncapture={onKeyDown} />
<ConditionalPortal condition={!!hostEl} target={hostEl} class="contents">
<ConditionalPortal
condition={alwaysPortal || !!hostEl}
target={hostEl}
class={hostEl ? 'contents' : undefined}
>
{#if open}
<div
transition:fadeFast|local
@@ -44,8 +44,12 @@
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
// Another tab is running a turn on this session: transcript stays readable,
// composer locks, and the chat re-reads the shared record when the turn ends.
const runHeldElsewhere = $derived(aiChatManager.runHeldElsewhere)
const disabled = $derived(
forceDisabled ||
runHeldElsewhere ||
!hasCopilot ||
(aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
@@ -58,19 +62,23 @@
const disabledMessage = $derived(
forceDisabled
? forceDisabledMessage
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
: runHeldElsewhere
? // The typing indicator and the composer placeholder already carry
// this state; a footer note would say it a third time.
''
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -315,7 +315,10 @@
}
})
const showTypingIndicator = $derived(aiChatManager.loading)
// Also shown for a run held by another tab, labeled with where it is: the
// dots say a turn is in flight even before the reader reaches the footer
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
@@ -571,8 +574,14 @@
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
)
// A disabled state with no message (a remote hold, a spent free grant) keeps
// the footer toolbar in place — swapping it for an empty strip would make
// the model/mode row flash out and back on every remote turn. A state with
// a real message (archived, AI off) still shows it, hold or not, matching
// the precedence disabledMessage itself encodes.
const footerMessageShown = $derived(disabled && disabledMessage !== '')
const showFooterLeftControls = $derived(
!disabled &&
!footerMessageShown &&
(showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
@@ -673,10 +682,14 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading || aiChatManager.sendInFlight}
title={aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
disabled={aiChatManager.loading ||
aiChatManager.sendInFlight ||
aiChatManager.runHeldElsewhere}
title={aiChatManager.runHeldElsewhere
? 'Wait for the turn in the other tab to switch conversation'
: aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
loadPastChat(chat.id)
close()
@@ -706,7 +719,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title="New chat"
title={aiChatManager.runHeldElsewhere
? 'Wait for the turn in the other tab to start a new chat'
: 'New chat'}
disabled={aiChatManager.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -770,17 +786,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
)}
>
<ChatTypingIndicator
loading={aiChatManager.loading}
loading={showTypingIndicator}
paused={waitingForUserAction}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
label={aiChatManager.runHeldElsewhere
? 'Running in another tab'
: aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
{/if}
@@ -1104,12 +1122,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{/if}
{#if disabled}
{#if footerMessageShown}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
</div>
@@ -129,6 +129,12 @@
// Generate mode-specific placeholder
const modePlaceholder = $derived.by(() => {
// The composer unlocks by itself when the other tab's turn ends, so the
// placeholder names what it is waiting on (the typing indicator says
// where the run is).
if (aiChatManager.runHeldElsewhere) {
return 'Waiting for the turn in the other tab to finish'
}
if (pendingQuestionToolCallId !== undefined) {
return 'Answer the question above'
}
@@ -501,7 +501,24 @@ export class AIChatManager {
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
closeArtifact?: (artifactId: string) => void
loading = $state<boolean>(false)
#loading = $state<boolean>(false)
get loading(): boolean {
return this.#loading
}
// An accessor so every run bracket — the send turn, manual compaction, a
// rollback — reports its transitions through one place, synchronously: the
// rising edge posts the cross-tab "running here" signal the moment the
// bracket opens (after the send's preflight awaits; the post-preflight
// guard covers that gap), and `loading` falls only after the turn's last
// saveChat, making the falling edge the "safe to re-read the record" signal.
set loading(v: boolean) {
if (v === this.#loading) return
this.#loading = v
this.onRunningChanged?.(v)
}
/** Sessions wiring (see sessionRuntime); undefined for the global
* side-panel chat, whose transcript no other tab renders. */
onRunningChanged: ((running: boolean) => void) | undefined = undefined
currentReply = $state<string>('')
currentReasoning = $state<string>('')
currentReasoningActive = $state<boolean>(false)
@@ -677,6 +694,14 @@ export class AIChatManager {
// sessions modules — and re-read on every system-message rebuild; the send
// path rebuilds after beforeSend, so a fork committed there is picked up.
sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined
// Whether another tab is running a turn on this session right now (sessions
// wiring, same seam as above). The composer locks on it, and sendRequest
// refuses on it — the refusal covers the send already in flight when the
// other tab's run signal arrives, which no disabled input can stop.
runHeldElsewhereResolver: (() => boolean) | undefined = undefined
get runHeldElsewhere(): boolean {
return this.runHeldElsewhereResolver?.() ?? false
}
// The page the side panel shows, stamped on each user message. Same seam as above:
// a page tab is an iframe in its own realm, so the tab model is the only place the
// chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those.
@@ -688,8 +713,8 @@ export class AIChatManager {
workspaceResolver: (() => string | undefined) | undefined = undefined
// The workspace every workspace-scoped chat action targets — skills, tool
// loop, logging, user-message context, and commit. Session-resolved when a
// resolver is set, else the globally-active workspace.
// loop, logging, user-message context, message rendering, and commit.
// Session-resolved when a resolver is set, else the globally-active workspace.
get operatingWorkspace(): string | undefined {
return this.workspaceResolver?.() ?? get(workspaceStore)
}
@@ -1056,6 +1081,16 @@ export class AIChatManager {
* doesn't spawn a new job leaves nothing to re-trigger on. A turn that DOES
* spawn another job resumes again when that one finishes, which is the point.
*/
#autoResumeRetry: ReturnType<typeof setTimeout> | undefined
#scheduleAutoResumeRetry() {
clearTimeout(this.#autoResumeRetry)
this.#autoResumeRetry = setTimeout(() => {
this.#autoResumeRetry = undefined
void this.#maybeAutoResumeFromJobs()
}, 5_000)
}
async #maybeAutoResumeFromJobs() {
if (this.#autoResuming) return
// Global/sessions chat only (the only mode with a jobs tray + preamble).
@@ -1066,6 +1101,17 @@ export class AIChatManager {
// Nothing to continue (empty chat), or the user is mid-compose — don't
// clobber their draft or auto-send it. Their eventual send carries the notes.
if (this.messages.length === 0 || this.instructions.trim()) return
// Another tab is driving: the synthetic send would only be refused, and
// the instructions staged below would then block every later auto-resume
// in this tab. The notes stay pending; re-checked shortly, because the
// hold can clear silently (staleness after a driver crash) with nothing
// else to fire this. When the driver instead ends its turn normally, its
// own resume carries the notes and this tab's catch-up clears the local
// copy — the re-check then finds nothing and stands down.
if (this.runHeldElsewhere) {
this.#scheduleAutoResumeRetry()
return
}
this.#autoResuming = true
try {
const count = this.pendingJobNotes.length
@@ -1104,6 +1150,8 @@ export class AIChatManager {
// Invalidate any in-flight poll so its post-await continuation can't write
// into the conversation we're switching to.
this.#jobPollGeneration++
clearTimeout(this.#autoResumeRetry)
this.#autoResumeRetry = undefined
this.backgroundJobs = []
this.pendingJobNotes = []
}
@@ -2832,6 +2880,35 @@ export class AIChatManager {
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
return
}
// Refused before anything mutates, so there is nothing to unwind: the
// draft (already taken by the composer) goes back where the user can see
// it, and the turn never starts. Only the message's own send restores it
// — a refused queued flush is re-queued by its caller (`accepted ===
// false`), and a copy here would double it. Paste tokens are expanded
// into the text, as the queue does, because the restore lanes carry no
// pastes.
if (this.runHeldElsewhere) {
if (options.synthetic) {
// Client-authored prompt (a job auto-resume), not user input: nothing
// to hand back and no toast. Releasing the staged text un-blocks the
// next auto-resume attempt, scheduled for when the hold clears.
this.instructions = ''
this.#scheduleAutoResumeRetry()
} else {
if (!options.queued) {
// Programmatic prompts (askAi, fix) stage their text in
// `this.instructions` and pass no option — fall back to it so
// they are handed back too.
this.restoreToInput(
expanded(chatDraft(options.instructions ?? this.instructions, options.pastes ?? [])),
options.images,
options.files
)
}
sendUserToast('This session is running in another tab. Your message was kept.', true)
}
return false
}
this.#sendsInFlight++
try {
return await this.sendRequestImpl(options)
@@ -3051,6 +3128,28 @@ export class AIChatManager {
)
}
const images = modelIsBlind ? [] : requestedImages
// Re-checks the wrapper's remote-run guard: a run announced by another tab
// during the upkeep awaits above would otherwise interleave two turns into
// one chat id. Resends are exempt — restartGeneration already truncated
// the transcript, so they run as the documented advisory race instead.
if (this.runHeldElsewhere && !options.resendReservationKey) {
this.#releaseOutgoingReservation(reservationKey)
if (options.synthetic) {
// Same as the wrapper guard: an internal prompt is released, not
// restored as a draft the user never wrote.
this.instructions = ''
this.#scheduleAutoResumeRetry()
} else {
// restoreToInput, not restoreInstructions: a draft typed during the
// awaits above occupies the composer, and this restore must merge
// into it (or queue), never be refused by it.
if (!options.queued) {
this.restoreToInput(expanded(chatDraft(this.instructions, pastes)), images, files)
}
sendUserToast('This session is running in another tab. Your message was kept.', true)
}
return false
}
const optimisticIndex = this.displayMessages.length
this.loading = true
// Create the abort controller before the (possibly slow) beforeSend pre-flight,
@@ -3892,6 +3991,29 @@ export class AIChatManager {
throw new Error('No user message found at the specified index')
}
// Refused before anything mutates: past this point the transcript is
// sliced and resend bytes are reserved, and the sendRequest guard could
// only refuse AFTER that damage — restoring nothing, since this path
// carries its text in `this.instructions`, not the options. The retry and
// edit controls check only local `loading`, so a remote run reaches here.
// An edit (newContent defined, even '': attachment-only edits exist) is
// restored with its pastes expanded into the text; a bare retry mutates
// nothing yet, so there is nothing to restore. Un-submitted context-chip
// edits are the one loss — the chips re-seed from the untouched message
// on the next edit.
if (this.runHeldElsewhere) {
if (newContent !== undefined) {
this.restoreToInput(
expanded(chatDraft(newContent, pastes ?? [])),
images ?? [],
files ?? []
)
}
// "Text", not "message": chip edits are the part that does not survive.
sendUserToast('This session is running in another tab. Your text was kept.', true)
return
}
// Resolve the API restart point BEFORE reserving bytes or truncating: a
// stale index must fail while nothing has been mutated, or the transcript
// would be left truncated with the reservation leaked. A negative index
@@ -4015,7 +4137,7 @@ export class AIChatManager {
this.onChatRotated?.(this.historyManager.getCurrentChatId())
}
loadPastChat = async (id: string) => {
loadPastChat = async (id: string, { preserveQueue = false } = {}) => {
// A turn commits into whatever transcript it finds when it ends, so swapping
// one in underneath it misfiles the turn — or duplicates it, when the loaded
// chat already carries the turn's own checkpoint. Gated on `sendInFlight`
@@ -4025,7 +4147,10 @@ export class AIChatManager {
if (chat) {
// Drop any message queued in the current conversation so it doesn't
// auto-send into the loaded one or linger as a card across the switch.
this.#clearQueue()
// `preserveQueue` is for reloads that are NOT a switch — a cross-tab
// catch-up re-reading the conversation on screen — where the queued
// draft is unsent user input the reload must not destroy.
if (!preserveQueue) this.#clearQueue()
// Stop the poller for the conversation being left before swapping in the
// loaded chat's jobs below.
this.clearBackgroundJobs()
@@ -7,6 +7,7 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completio
import type { DisplayMessage } from './shared'
import type { AttachedImage } from './imageUtils'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { makePasteToken } from './pasteTokens'
import { chatState } from './sharedChatState.svelte'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { runChatLoop } from './chatLoop'
@@ -3997,3 +3998,135 @@ describe('AIChatManager reasoning duration', () => {
expect(assistantDurations(manager)).toEqual([3_000, 7_000])
})
})
describe('AIChatManager cross-tab run seams', () => {
// The whole cross-tab feature hangs off these two seams: `loading`'s edges
// are the "running here" / "safe to re-read" signals, and the resolver is
// the advisory lock. Reverting `loading` to a plain $state field would
// silently disconnect every tab.
it('reports loading transitions, and only transitions, through onRunningChanged', () => {
const manager = new AIChatManager()
const seen: boolean[] = []
manager.onRunningChanged = (running) => seen.push(running)
manager.loading = true
manager.loading = true
manager.loading = false
manager.loading = false
expect(seen).toEqual([true, false])
})
it('refuses a send while another tab holds the run, keeping the draft', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
const accepted = await manager.sendRequest({ instructions: 'race loser' })
expect(accepted).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
// restoreToInput falls back to the queued draft when no composer is
// mounted, so the refused text must surface there rather than vanish.
expect(manager.queuedMessage).toBe('race loser')
})
// A synthetic (auto-resume) prompt is client-authored: a refusal must
// release it rather than hand it back as a draft the user never wrote —
// staged instructions would otherwise block every later auto-resume.
it('releases a refused synthetic send instead of restoring it as a draft', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
manager.instructions = 'A background job just finished.'
const accepted = await manager.sendRequest({ synthetic: true })
expect(accepted).toBe(false)
expect(manager.instructions).toBe('')
expect(manager.queuedMessage).toBe('')
})
// The restore lanes carry no pastes, so a refusal must expand the tokens
// into the text — dangling markers with the content gone otherwise.
it('expands paste tokens into the text a refusal hands back', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
const paste = { id: 1, lines: 1, content: 'the pasted block' }
await manager.sendRequest({
instructions: `see ${makePasteToken(paste)}`,
pastes: [paste]
})
expect(manager.queuedMessage).toBe('see the pasted block')
})
// The wrapper's check runs before the attachment upkeep awaits; a run
// announced by another tab during that upkeep must still be refused before
// the turn takes visible effect.
it('refuses a run announced by another tab during the preflight awaits', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
let held = false
manager.runHeldElsewhereResolver = () => held
let releaseUpkeep: (() => void) | undefined
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(
() => new Promise<void>((resolve) => (releaseUpkeep = resolve))
)
const sending = manager.sendRequest({ instructions: 'racing turn' })
await vi.waitFor(() => expect(manager.sendInFlight).toBe(true))
held = true
releaseUpkeep?.()
expect(await sending).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
})
it('refuses a retry/edit while another tab holds the run, before mutating the transcript', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.displayMessages = [
{ role: 'user', content: 'original prompt', index: 0 },
{ role: 'assistant', content: 'original reply' }
] as DisplayMessage[]
manager.messages = [
{ role: 'user', content: 'original prompt' }
] as ChatCompletionMessageParam[]
manager.runHeldElsewhereResolver = () => true
await manager.restartGeneration(0, 'edited prompt')
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.displayMessages).toHaveLength(2)
expect(manager.messages).toHaveLength(1)
// The edited text survives the refusal via restoreToInput's queued-draft
// fallback.
expect(manager.queuedMessage).toBe('edited prompt')
})
// A cross-tab catch-up re-reads the conversation on screen; the queued
// draft is unsent user input (possibly the refusal's kept message) that
// this non-switch reload must not destroy — while a real conversation
// switch still drops it.
it('keeps the queued draft when a catch-up reload preserves it', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
vi.spyOn(manager.historyManager, 'loadPastChat').mockResolvedValue({
id: 'c1',
actualMessages: [],
displayMessages: [],
title: '',
lastModified: 1
} as never)
manager.queueMessage('kept across catch-up')
await manager.loadPastChat('c1', { preserveQueue: true })
expect(manager.queuedMessage).toBe('kept across catch-up')
await manager.loadPastChat('c1')
expect(manager.queuedMessage).toBe('')
})
})
@@ -13,9 +13,21 @@
import { messageDraft, segments } from './chatDraft'
import { lineCountLabel } from './pasteTokens'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import { workspaceStore } from '$lib/stores'
const aiChatManager = getAiChatManager()
// Paths in a message name items the chat's tools reach, so they resolve against the
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
// navigated workspace, where a fork-only item resolves to nothing and the rest resolve
// to a different copy.
const messageWorkspace = $derived.by(() => {
// Registers the dependency that `operatingWorkspace`'s own untracked
// `get(workspaceStore)` cannot.
void $workspaceStore
return aiChatManager.operatingWorkspace
})
// Per-message expand/collapse state for paste chips shown in the bubble.
let expandedPastes = $state<Set<number>>(new Set())
@@ -119,7 +131,7 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} /></div>
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -6,7 +6,6 @@
import { thinkingPreferences } from './thinkingPreferences.svelte'
import CodeDisplay from './script/CodeDisplay.svelte'
import LinkRenderer from './LinkRenderer.svelte'
import { workspaceStore } from '$lib/stores'
import {
extractCandidatePaths,
remarkWindmillPaths,
@@ -16,9 +15,12 @@
interface Props {
message: DisplayMessage
// Workspace the message's paths are resolved against: the one the chat
// operates on, which is not always the one being navigated.
workspace: string | undefined
}
let { message }: Props = $props()
let { message, workspace }: Props = $props()
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
@@ -69,12 +71,11 @@
// Only populate the registry for messages that contain path-shaped tokens. The
// registry still dedups concurrent calls across messages and workspaces.
$effect(() => {
const ws = $workspaceStore
if (ws && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(ws)
if (workspace && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(workspace)
})
const plugins = $derived.by(() => {
const ws = $workspaceStore ?? ''
const ws = workspace ?? ''
if (!ws || candidatePaths.length === 0) {
return [gfmPlugin(), rendererPlugin]
}
@@ -770,8 +770,17 @@
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
textarea, so context chips can sit INSIDE the box, above the text. The
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
<!-- The disabled treatment lives on the wrapper for the same reason the box
does: `disabled` on the textarea alone leaves the field looking exactly
like a usable one, so the only cue that typing is refused is placeholder
text the eye reads as an invitation. -->
<div
class="w-full scroll-pb-2 bg-surface-input rounded-md border border-border-light focus-within:border-border-selected transition-colors"
class={twMerge(
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
disabled
? 'bg-surface-disabled cursor-not-allowed'
: 'bg-surface-input focus-within:border-border-selected'
)}
>
<!-- Context chips live inside the input box, above the textarea. The snippet
self-guards (renders nothing when empty) so no blank row appears. -->
@@ -825,6 +834,7 @@
// @tailwindcss/forms border, focus ring, and background so only the
// wrapper reads as the field.
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
CHAT_INPUT_PADDING,
className
)}
@@ -599,6 +599,35 @@ export default class HistoryManager {
}).catch((err) => console.error('Could not delete chat', err))
}
/** Re-read one chat from the store into the in-memory mirror, for a record
* another tab wrote after this manager last read it. `init()` is the wrong
* tool: it re-reads the user's entire history to pick up a single chat.
*
* 'missing' is a fact about the conversation (the store holds nothing under
* this id); 'unavailable' is a fact about this browser. Callers act on the
* first and must not act on the second treating a closed database as an
* empty chat would throw away a transcript that is merely unreadable. */
async reloadChat(id: string): Promise<'loaded' | 'missing' | 'unavailable'> {
const db = await this.dbh.whenReady()
if (!db) return 'unavailable'
try {
const chat = await db.get('chats', id)
if (!chat) {
// Drop the mirror too. `loadPastChat` reads from it and never from the
// store, so a copy left behind here is a deleted chat that comes back
// on the next rotation onto this id.
const { [id]: _gone, ...rest } = this.savedChats
this.savedChats = rest
return 'missing'
}
this.savedChats = { ...this.savedChats, [id]: chat }
return 'loaded'
} catch (err) {
console.error('Could not reload chat', err)
return 'unavailable'
}
}
async loadPastChat(id: string) {
const chat = this.savedChats[id]
if (!chat) return
@@ -757,3 +757,62 @@ describe('HistoryManager modified-items mask persistence', () => {
expect(hm.getModifiedItems(id)).toBeUndefined()
})
})
describe('HistoryManager.reloadChat', () => {
it('picks up another tabs write, and tells an empty chat from an unreadable store', async () => {
const hm = new HistoryManager()
await hm.init()
const chatId = hm.getCurrentChatId()
await hm.saveChat(
[{ role: 'user', content: 'before the other tab ran' }] as DisplayMessage[],
[] as ChatCompletionMessageParam[]
)
// The other tab's turn, written straight to the store this one shares.
const db = await openDB('copilot-chat-history::admin@test')
const row = (await db.get('chats' as never, chatId)) as any
row.displayMessages = [{ role: 'user', content: 'written by the driving tab' }]
await db.put('chats' as never, row)
db.close()
expect(await hm.reloadChat(chatId)).toBe('loaded')
const chat = await hm.loadPastChat(chatId)
expect((chat?.displayMessages[0] as any).content).toBe('written by the driving tab')
// A chat the store does not hold — distinct from 'unavailable' below:
// 'missing' evicts the in-memory mirror, so conflating the two would let
// a store that merely failed to open erase transcripts this tab holds.
expect(await hm.reloadChat('no-such-chat')).toBe('missing')
})
it('evicts the mirrored copy of a chat the driver deleted', async () => {
const hm = new HistoryManager()
await hm.init()
const chatId = hm.getCurrentChatId()
await hm.saveChat(
[{ role: 'user', content: 'deleted by the driving tab' }] as DisplayMessage[],
[] as ChatCompletionMessageParam[]
)
const db = await openDB('copilot-chat-history::admin@test')
await db.delete('chats' as never, chatId)
db.close()
expect(await hm.reloadChat(chatId)).toBe('missing')
// loadPastChat serves the mirror, so a copy left behind would resurrect the
// deleted transcript the next time this id came round again.
expect(await hm.loadPastChat(chatId)).toBeUndefined()
})
it('reports a store it cannot open as unavailable, never as missing', async () => {
;(globalThis as any).indexedDB = {
open: () => {
throw new Error('blocked')
}
}
const hm = new HistoryManager()
await hm.init()
expect(await hm.reloadChat(hm.getCurrentChatId())).toBe('unavailable')
})
})
@@ -3,7 +3,10 @@
import { ExternalLink, PanelRight } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { runToolDisplayAction } from './createdResourceActions.svelte'
import {
hasToolDisplayActionHandler,
runToolDisplayAction
} from './createdResourceActions.svelte'
import {
workspaceItemAction,
type WindmillItemKind,
@@ -27,7 +30,12 @@
title
}: Props = $props()
const drawerAction = $derived(workspaceItemAction(wmKind, wmPath, wmTargetKind))
// The drawers ride with the docked chat, so a surface can render this pill with nothing
// able to open one.
const drawerAction = $derived.by(() => {
const action = workspaceItemAction(wmKind, wmPath, wmTargetKind)
return action && hasToolDisplayActionHandler(action.type) ? action : undefined
})
async function openDrawer(event?: Event) {
event?.preventDefault()
@@ -28,7 +28,10 @@
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import { runToolDisplayAction } from './createdResourceActions.svelte'
import {
hasToolDisplayActionHandler,
runToolDisplayAction
} from './createdResourceActions.svelte'
import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared'
interface Props {
@@ -122,17 +125,21 @@
<div class="truncate text-xs font-semibold text-primary">{card.title}</div>
<div class="truncate text-2xs text-secondary">{card.subtitle}</div>
</div>
<Button
size="xs"
variant="default"
title={action.label}
loading={runningActionId === action.id}
disabled={runningActionId !== undefined && runningActionId !== action.id}
startIcon={{ icon: card.buttonIcon }}
onClick={() => handleAction(action)}
>
Open
</Button>
<!-- open_created_resource is serviced solely by the docked chat's drawers, so
elsewhere the card stands alone as a record of what the tool created. -->
{#if hasToolDisplayActionHandler(action.type)}
<Button
unifiedSize="sm"
variant="default"
title={action.label}
loading={runningActionId === action.id}
disabled={runningActionId !== undefined && runningActionId !== action.id}
startIcon={{ icon: card.buttonIcon }}
onClick={() => handleAction(action)}
>
Open
</Button>
{/if}
</div>
{/each}
</div>
@@ -90,6 +90,15 @@ export class SessionArtifactsStore {
await this.#load()
}
/** Re-read the loaded session's artifacts from the store, for records another
* tab wrote after this one loaded. Forces the read setSession skips: that
* skip protects local edits whose best-effort persist failed, while a tab
* catching up on another tab's finished turn wants the store's truth. */
async resyncFromStore(): Promise<void> {
if (this.#sessionId === undefined) return
await this.#load()
}
async #load(): Promise<void> {
const token = ++this.#seq
const id = this.#sessionId
@@ -25,6 +25,15 @@ export function registerToolDisplayActionHandler(
}
}
/**
* Reactive: reads the `$state` registry, so a component re-renders when a page mounts or
* unmounts its handler. Offering an action without checking this yields an affordance whose
* only outcome is the unavailable-action toast.
*/
export function hasToolDisplayActionHandler(type: ToolDisplayAction['type']): boolean {
return toolDisplayActionHandlers[type] !== undefined
}
export async function runToolDisplayAction(action: ToolDisplayAction): Promise<void> {
const handler = toolDisplayActionHandlers[action.type]
if (!handler) {
@@ -20,24 +20,46 @@
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
import {
ArrowUp,
ExternalLink,
Globe2,
KeyRound,
PlugZap,
Settings,
WandSparkles
} from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { Badge } from '../common'
import CloseButton from '../common/CloseButton.svelte'
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { HOME_SHOW_HUB } from '$lib/consts'
import { base } from '$lib/base'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { BROWSER } from 'esm-env'
import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte'
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import { USER_SETTINGS_HASH } from '../sidebar/settings'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
let value = $state('')
let placeholder = $state('')
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// How much of the home page this reader wants the composer to take, so it lives per browser
// rather than per workspace or account.
let collapsed = $state(BROWSER && getLocalSetting(COLLAPSED_SETTING) === 'true')
function setCollapsed(next: boolean) {
collapsed = next
storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined)
}
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
// config, so the home chat loads it for the current workspace itself.
$effect(() => {
@@ -48,10 +70,9 @@
// Whether the copilot config has actually loaded for the current workspace.
let configLoaded = $derived($copilotWorkspace === $workspaceStore)
// No usable model (no provider configured, or AI disabled): the composer is blurred and an
// overlay explains why and links to the fix. Static, not hover-gated, so keyboard and touch
// users see it too. Gate on `configLoaded` so the initial (unloaded) state doesn't flash the
// overlay while a provider is in fact configured.
// No usable model (no provider configured, or AI disabled): the input is blurred and an overlay
// explains why and links to the fix. Gate on `configLoaded` so the initial (unloaded) state
// doesn't flash the overlay while a provider is in fact configured.
let disabled = $derived(configLoaded && !$copilotInfo.enabled)
// Submission is stricter than the overlay: block it until the config is loaded AND
// enabled. Submitting during the unknown-config window hands the prompt to a session
@@ -59,17 +80,27 @@
// workspace that never happens and the prompt is silently lost.
let canSend = $derived(configLoaded && $copilotInfo.enabled)
// Applied to the AI-specific parts only (title, input, example tags) when disabled. The
// CLI/MCP and Hub buttons are unrelated to AI and stay sharp and clickable.
// The input alone: what the overlay covers and the one part a missing provider makes unusable.
let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '')
// Disabled because the user spent their free Windmill AI grant, not because AI was never
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
// A workspace locked against direct deployment is run, not authored in, so its home page drops
// the composer and the button to reopen it. This is about the workspace, not the caller:
// `createSession` would steer a prompt into the paired dev workspace and an admin bypasses the
// lock outright, yet neither makes prod the place to start one. Unresolved rules read as
// unlocked, so the far commoner unlocked workspace never pops the composer in mid-load.
let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment'))
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
let showComposer = $derived(prefersSessionHandoff($userStore?.operator))
let showComposer = $derived(prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace)
// The hero's margins are for the full block; around the lone button row left by a collapsed,
// operator or run-only view they would just be empty page.
let outerSpacing = $derived(showComposer && !collapsed ? 'mt-20 mb-16' : 'mt-8 mb-2')
let starting = $state(false)
async function start() {
@@ -100,7 +131,7 @@
// Typewriter effect: type a prompt out, hold, delete, then advance to the next. Only while the
// composer is shown — otherwise (operators) it would loop forever driving an unrendered input.
$effect(() => {
if (!showComposer) return
if (!showComposer || collapsed) return
let promptIndex = 0
let charIndex = 0
let deleting = false
@@ -135,17 +166,26 @@
})
</script>
<div class="w-full flex justify-center">
<div class="w-full flex justify-center {outerSpacing}">
<div class="max-w-[40rem] grow relative group">
{#if showComposer}
<div class={blurClass} inert={disabled}>
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">Build with AI</p>
<Badge color="blue" small>Beta</Badge>
{#if showComposer && !collapsed}
{#if !disabled}
<!-- The one dismiss control while the composer is usable; the overlay below carries its
own once it takes over, so the two never show at the same time. -->
<div class="absolute right-0 top-0 z-20">
<CloseButton small noBg title="Hide Build with AI" onClick={() => setCollapsed(true)} />
</div>
<!-- anchors the send button / model settings to the input, not to the whole
block — the row below would otherwise push them down -->
<div class="relative">
{/if}
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">Build with AI</p>
<Badge color="blue" small>Beta</Badge>
</div>
<!-- Anchors the send button / model settings to the input, not to the whole block — the row
below would otherwise push them down. The inner wrapper stays `relative` in both
states: `blur-sm` is a filter, which makes an element the containing block for its
absolutely positioned children, so those two would shift when the blur turns on. -->
<div class="relative">
<div class="relative {blurClass}" inert={disabled}>
<TextInput
bind:value
class="resize-none px-4 py-3 pb-9 shadow-sm border-accent"
@@ -165,12 +205,56 @@
<AIChatModelSettings />
</div>
</div>
{#if disabled}
<!-- Covers the input alone: the title, the example prompts and the CLI/MCP row are all
still legible and usable without a provider. Static, not hover-gated, so keyboard
and touch users see it too. -->
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
>
<p class="text-sm text-secondary">
{#if $aiUserDisabled}
Windmill AI is disabled in your account settings
{:else if freeTierExhausted}
You have used all of your free Windmill AI tokens
{:else}
No AI provider is configured
{/if}
</p>
<div class="flex items-center gap-2">
{#if $aiUserDisabled}
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Settings }}
href={USER_SETTINGS_HASH}
>
Open account settings
</Button>
{:else}
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
href="{base}/workspace_settings?tab=ai"
>
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
</Button>
{/if}
<Button unifiedSize="sm" variant="default" onClick={() => setCollapsed(true)}>
Hide
</Button>
</div>
</div>
{/if}
</div>
{/if}
<div class="flex items-center justify-between gap-2">
{#if showComposer}
<div class="flex flex-row flex-wrap items-center gap-1.5 {blurClass}" inert={disabled}>
{#if showComposer && !collapsed}
<div class="flex flex-row flex-wrap items-center gap-1.5">
{#each homeAIExamples as example (example.label)}
<Button
variant="default"
@@ -182,13 +266,24 @@
</Button>
{/each}
</div>
{:else if showComposer}
<!-- All that is left of the composer once dismissed: sits with the CLI/MCP row so the
collapsed home page is one quiet line. -->
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: WandSparkles }}
onClick={() => setCollapsed(false)}
>
Build with AI
</Button>
{:else}
<div></div>
{/if}
<!-- Not AI-related, so shown even to operators / when the composer is hidden: kept out of
the blurred subtree and above the disabled overlay so it stays sharp and clickable. -->
<div class="relative z-20 flex flex-row items-center gap-1">
<!-- Not AI-related, so shown even to operators / when the composer is hidden. -->
<div class="flex flex-row items-center gap-1">
<Button
variant="subtle"
unifiedSize="xs"
@@ -213,42 +308,6 @@
{/if}
</div>
</div>
{#if showComposer && disabled}
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
>
<p class="text-sm text-secondary">
{#if $aiUserDisabled}
Windmill AI is disabled in your account settings
{:else if freeTierExhausted}
You have used all of your free Windmill AI tokens
{:else}
No AI provider is configured
{/if}
</p>
{#if $aiUserDisabled}
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Settings }}
href={USER_SETTINGS_HASH}
>
Open account settings
</Button>
{:else}
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
href="{base}/workspace_settings?tab=ai"
>
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
</Button>
{/if}
</div>
{/if}
</div>
</div>
@@ -30,7 +30,7 @@
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-xs font-normal text-hint">
<div class="text-lg font-normal text-hint">
Get started by creating your first script, flow, or app
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { GraduationCap, X } from 'lucide-svelte'
import CloseButton from '$lib/components/common/CloseButton.svelte'
import { GraduationCap } from 'lucide-svelte'
import { base } from '$lib/base'
import { goto } from '$app/navigation'
import { sendUserToast, type ToastAction } from '$lib/toast'
@@ -153,44 +154,25 @@
</script>
{#if !isDismissed}
<div
class="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-light bg-surface-tertiary mb-4"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<GraduationCap size={20} class="text-accent-primary flex-shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if hasCompletedAny}
New tutorial available!
{:else}
Learn with interactive tutorials
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{#if hasCompletedAny}
Continue your learning journey and master new Windmill skills.
{:else}
Get started quickly with step-by-step guides on building flows, scripts, and more.
{/if}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Button
size="xs"
variant="accent"
onclick={goToTutorials}
startIcon={{ icon: GraduationCap }}
>
View tutorials
</Button>
<button
onclick={dismissBanner}
class="p-1.5 rounded hover:bg-surface-hover text-secondary hover:text-primary transition-colors"
aria-label="Dismiss tutorial banner"
>
<X size={16} />
</button>
</div>
<!-- A standing invitation, not an announcement: it sits inline at the start of the row rather
than filling the page, so it reads as one more control and not as a card the user has to
dispatch before getting to their work. -->
<div class="flex items-center gap-2 mt-4 mb-4">
<span class="text-hint text-xs truncate min-w-0">
{#if hasCompletedAny}
New tutorial available!
{:else}
First time?
{/if}
</span>
<Button
unifiedSize="sm"
variant="default"
onclick={goToTutorials}
startIcon={{ icon: GraduationCap }}
>
Tutorials
</Button>
<CloseButton small noBg title="Dismiss tutorial banner" onClick={dismissBanner} />
</div>
{/if}
@@ -283,7 +283,17 @@
placeholder: '12345',
disabled: fieldsDisabled
}}
bind:value={$values['github_enterprise_app'].app_id}
bind:value={
() => $values['github_enterprise_app'].app_id,
(v) => {
// The backend expects app_id as a positive integer (i64). Reject
// fractional/out-of-range values instead of truncating them, and store
// undefined (never a string or 0) so the config omits the key when unset.
const n = typeof v === 'string' ? Number(v.trim() || NaN) : (v ?? NaN)
$values['github_enterprise_app'].app_id =
Number.isSafeInteger(n) && n > 0 ? n : undefined
}
}
/>
</div>
<div class="flex flex-col gap-1">
@@ -29,6 +29,12 @@ import { userWorkspaces, workspaceStore } from '$lib/stores'
import { copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { emptySchema, type StateStore } from '$lib/utils'
import {
localRunEnded,
localRunStarted,
onRemoteTurnEnd,
runHeldElsewhere
} from './sessionSync.svelte'
import {
commitSessionWorkspace,
deleteSession as deleteSessionState,
@@ -338,6 +344,15 @@ function createRuntime(session: Session): SessionRuntime {
// Carried into the tool helpers so this session's preview/deploy tool calls
// dispatch to THIS session even when another session is the UI-active one.
manager.sessionId = session.id
// Cross-tab awareness: heartbeat while this tab runs a turn, composer lock
// (and send refusal) while another tab does. The chat id is read at turn
// end, not captured at start — the turn may have rotated it, and the other
// tabs re-read whichever record it ended on.
manager.runHeldElsewhereResolver = () => runHeldElsewhere(session.id)
manager.onRunningChanged = (running) => {
if (running) localRunStarted(session.id, manager.historyManager.getCurrentChatId())
else localRunEnded(session.id, manager.historyManager.getCurrentChatId())
}
// The chat targets the session's OWN (possibly forked) workspace without
// switching the global workspaceStore. Resolved live from the session record
// so it tracks the pending → committed (and staged-fork) transitions.
@@ -958,6 +973,65 @@ export function getRuntime(sessionId: string): SessionRuntime | undefined {
return runtimes.get(sessionId)
}
// ---------------------------------------------------------------------------
// Cross-tab catch-up
// ---------------------------------------------------------------------------
// Chained per session so two turn-ends close together (a turn plus its queued
// follow-up) re-read sequentially: the later read starts after the earlier
// one's loadPastChat, so the newest record is what ends up on screen.
const catchUps = new Map<string, Promise<void>>()
onRemoteTurnEnd((sessionId, chatId) => {
const next = (catchUps.get(sessionId) ?? Promise.resolve())
.then(() => applyRemoteTurnEnd(sessionId, chatId))
.catch((e) => console.error('Failed to catch up on a turn from another tab', e))
catchUps.set(sessionId, next)
void next.finally(() => {
if (catchUps.get(sessionId) === next) catchUps.delete(sessionId)
})
// Awaited by the caller: the composer unlock rides on this settling.
return next
})
async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise<void> {
const runtime = runtimes.get(sessionId)
if (!runtime) return
const m = runtime.manager
// Two transient states get a short retry rather than a skip, because the
// composer unlocks when this promise settles and a skip would unlock it on
// stale history: a send of this tab's own still in preflight (it may yet be
// refused, leaving no turn to converge on), and a store that failed to
// open. A turn actually running here owns the transcript instead — its own
// end converges — and the pruner caps the whole hold at STALE_MS anyway.
for (let attempt = 0; ; attempt++) {
if (m.loading) return
if (!m.sendInFlight) {
const res = await m.historyManager.reloadChat(chatId)
if (res === 'missing') return
if (res === 'loaded') break
}
if (attempt >= 7) return
await new Promise((r) => setTimeout(r, 500))
if (runtimes.get(sessionId) !== runtime) return
}
// Disposed (session deleted, teardown) while the read was in flight.
if (runtimes.get(sessionId) !== runtime) return
// Adopts the driver's chat unconditionally, current view included: watching
// a session means following where its activity is, and it is also how tabs
// converge after an unsynced /clear rotation. A watcher browsing an older
// conversation is pulled along — deliberate, and the price of not syncing
// rotation as its own message.
//
// preserveQueue: this reload is a catch-up, not a conversation switch — a
// draft queued here (a refused send's kept message, a failed turn's card)
// is unsent user input the re-read must not destroy.
await m.loadPastChat(chatId, { preserveQueue: true })
// loadPastChat's own artifact sync no-ops for an unchanged session id, so
// artifacts the driver wrote during the turn need this forced re-read.
await m.artifacts.resyncFromStore()
}
// Point a session's preview at a single seed tab. For re-pointing an existing
// draft session at a new destination ("Open in AI session" / new-session-from-
// page on a reused transient): its previous tabs — persisted with the draft
@@ -405,8 +405,9 @@ export function takeSessionAutoSend(sessionId: string): boolean {
// Persist a session on a genuine user edit, promoting an in-memory-only
// (transient) pending session to a durable IndexedDB record on first touch.
// Non-touch writers (runtime chatId seeding, unread watermark) call putSession
// directly, so an untouched draft stays in memory and vanishes on reload.
// Non-touch writers (runtime chatId seeding via patchStoredSessionChatId, the
// unread watermark via putSession) persist directly, so an untouched draft
// stays in memory and vanishes on reload.
function persistTouched(s: Session): void {
if (s.transient) delete s.transient
s.lastActivityAt = Date.now()
@@ -437,10 +438,10 @@ async function deleteSessionRow(db: IDBPDatabase<SessionSchema>, id: string): Pr
await db.delete('sessions', id)
}
// The one way to write a session's record, and the other half of the invariant above:
// every caller reaches its write across an await — putSession on the DB handle, the
// reconcile and hydrate passes on a getAll() snapshot that an interleaved delete
// invalidates — so the tombstone has to be consulted here, not only at the entry points.
// The way a session record is written (patchStoredSessionChatId is the one
// exception: it re-checks the tombstone inline to stay inside its own
// transaction). Every caller reaches its write across an await, so the
// tombstone has to be consulted here, not only at the entry points.
async function putSessionRow(db: IDBPDatabase<SessionSchema>, s: Session): Promise<void> {
if (deletedSessionIds.has(s.id)) return
await db.put('sessions', s)
@@ -1151,7 +1152,36 @@ export function setSessionChatId(sessionId: string, chatId: string) {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (s && s.chatId !== chatId) {
s.chatId = chatId
void putSession(s)
void patchStoredSessionChatId(s, chatId)
}
}
// Persists the pointer through the STORED row, not this tab's copy: another
// tab may have written newer fields (summary, tabs, archive state) since this
// tab last read the record, and a whole-object put would roll them back — a
// watcher adopting the driver's rotation reaches here with exactly that copy.
async function patchStoredSessionChatId(s: Session, chatId: string): Promise<void> {
if (!BROWSER || s.transient || deletedSessionIds.has(s.id)) return
const db = await sessionsDb.whenReady()
if (!db) return
try {
const tx = db.transaction('sessions', 'readwrite')
const stored = await tx.store.get(s.id)
// Inline tombstone re-check in place of putSessionRow's: routing through
// it would put outside this transaction and lose the read's atomicity.
if (stored && !deletedSessionIds.has(s.id)) {
stored.chatId = chatId
await tx.store.put(stored)
await tx.done
return
}
await tx.done
// No stored row: either the record is not yet persisted — its own
// materialization writes it later with the chatId already set in memory —
// or another tab deleted it, and an upsert here would resurrect it. No
// write either way.
} catch (e) {
console.error('Failed to persist session chat id', e)
}
}
@@ -50,6 +50,7 @@ import {
getSessionDraftPrompt,
setSessionDraftPrompt,
setSessionTabs,
setSessionChatId,
reconcileSessionsLifecycle,
__resetDeletedSessionIdsForTesting,
setSessionArchived,
@@ -117,6 +118,30 @@ describe('sessionState IndexedDB persistence', () => {
await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1']))
})
// A watcher adopting the driver's chat rotation holds a stale in-memory
// record; persisting the pointer must not roll back fields another tab
// wrote to the store since.
it('setSessionChatId patches the stored row instead of writing back a stale copy', async () => {
const user = freshUser()
await login(user)
const stale = session({ id: 's1', createdAt: 100, summary: 'old summary' })
await putSession(stale)
// A newer write from another tab, landing directly in the store.
await putSession(session({ id: 's1', createdAt: 100, summary: 'newer summary' }))
sessionState.sessions = [stale]
setSessionChatId('s1', 'chat-2')
await flush()
await rehydrate(user)
await vi.waitFor(() => {
const s = sessionState.sessions.find((x) => x.id === 's1')
expect(s?.chatId).toBe('chat-2')
expect(s?.summary).toBe('newer summary')
})
})
it('does not persist a transient (untouched) session — it is in-memory only', async () => {
const user = freshUser()
await login(user)
@@ -0,0 +1,206 @@
import { BROWSER } from 'esm-env'
import { SvelteMap } from 'svelte/reactivity'
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
import { randomUUID } from '$lib/utils/uuid'
// Cross-tab awareness for AI sessions. Invariant: no message carries state —
// a heartbeat is presence, turn-end triggers an idempotent re-read of the
// shared IndexedDB record — so tabs converge on the store, never on delivery
// order. The lock is advisory (a broadcast-latency race stays last-writer-
// wins, as with no channel), and the channel is per-user like the stores.
const CHANNEL_BASE = 'windmill-sessions-sync'
/** Silence past STALE_MS unlocks watchers a dead driver would strand. The
* window sits above the 1/min floor browsers throttle a hidden tab's timers
* to and a hidden driver is the normal case here. Only an uncleanly killed
* tab waits it out; a closed one says goodbye via the pagehide farewell. */
const HEARTBEAT_MS = 3_000
const STALE_MS = 90_000
const PRUNE_MS = 2_000
// `from` identifies the driving tab: two drivers racing on one session (the
// documented advisory race) hold separate slots, so one's turn-end can never
// unlock a watcher the other still holds.
export type SyncMsg =
| { kind: 'run-heartbeat'; sessionId: string; from: string }
| { kind: 'turn-end'; sessionId: string; chatId: string; from: string }
/** This tab's identity on the channel (a tab never receives its own posts). */
const TAB_ID = randomUUID()
// One slot per (session, driving tab), keyed with a separator no UUID contains.
// The value is a fresh object per message: turn-end's deferred cleanup asks
// "is this slot still mine?" by identity — a timestamp can't, since a same-
// millisecond follow-up heartbeat would compare equal and be deleted.
const remoteRuns = new SvelteMap<string, { at: number }>()
function runKey(sessionId: string, from: string): string {
return sessionId + ':' + from
}
export function runHeldElsewhere(sessionId: string): boolean {
const prefix = sessionId + ':'
for (const key of remoteRuns.keys()) {
if (key.startsWith(prefix)) return true
}
return false
}
let remoteTurnEnd: ((sessionId: string, chatId: string) => void | Promise<void>) | undefined
/** Registered by sessionRuntime, which already imports this module a
* callback rather than an import keeps that edge one-way. The returned
* promise is when the catch-up has been applied; the composer stays locked
* until it settles. */
export function onRemoteTurnEnd(
fn: (sessionId: string, chatId: string) => void | Promise<void>
): void {
remoteTurnEnd = fn
}
let channel: BroadcastChannel | undefined
let channelName: string | undefined
function openChannel(): void {
const name = scopedKey(CHANNEL_BASE)
if (name === channelName) return
channel?.close()
channel = undefined
channelName = name
if (!name) return
try {
const ch = new BroadcastChannel(name)
ch.onmessage = (ev: MessageEvent<SyncMsg>) => receive(ev.data)
channel = ch
} catch (e) {
// No BroadcastChannel (or blocked): every tab simply stays independent,
// which is the pre-sync behavior rather than a broken one.
console.error('sessionSync: could not open channel', e)
}
}
if (BROWSER) {
// A user switch rescopes the channel name, so the previous identity's
// channel is closed before the next one opens.
onUserChange(() => openChannel())
}
function receive(msg: SyncMsg): void {
switch (msg.kind) {
case 'run-heartbeat':
remoteRuns.set(runKey(msg.sessionId, msg.from), { at: Date.now() })
ensurePruner()
break
case 'turn-end': {
// Unlocking on receipt would let a send here start from history missing
// the turn that just ended, so the slot holds until the catch-up
// settles — unless the driver's next turn replaced it meanwhile (object
// identity, see remoteRuns). The pruner caps a wedged reload at STALE_MS.
const key = runKey(msg.sessionId, msg.from)
const hold = { at: Date.now() }
remoteRuns.set(key, hold)
ensurePruner()
Promise.resolve()
.then(() => remoteTurnEnd?.(msg.sessionId, msg.chatId))
.catch((e) => console.error('sessionSync: turn-end handler failed', e))
.finally(() => {
if (remoteRuns.get(key) === hold) remoteRuns.delete(key)
})
break
}
}
}
function post(msg: SyncMsg): void {
if (!channel) return
try {
channel.postMessage(msg)
} catch (e) {
// A failed post must never take the turn down with it.
console.error('sessionSync: could not post message', e)
}
}
let pruneTimer: ReturnType<typeof setInterval> | undefined
function ensurePruner(): void {
if (pruneTimer) return
pruneTimer = setInterval(() => {
const cutoff = Date.now() - STALE_MS
for (const [id, entry] of remoteRuns) {
if (entry.at < cutoff) remoteRuns.delete(id)
}
if (remoteRuns.size === 0) {
clearInterval(pruneTimer)
pruneTimer = undefined
}
}, PRUNE_MS)
}
// ---------------------------------------------------------------------------
// Driving side
// ---------------------------------------------------------------------------
// The chat id rides along for the pagehide farewell below, which cannot ask
// the manager for it. Taken at run start; only a mid-turn rotation could make
// it stale, and a farewell pointing at the pre-rotation record still converges
// (the re-read is idempotent and the next turn-end names the right one).
const heartbeats = new Map<string, { timer: ReturnType<typeof setInterval>; chatId: string }>()
/** Posted when the run's loading bracket opens after the send's attachment
* upkeep awaits, so a competing send can start during them; the sender's own
* post-preflight re-check is what refuses one that did. */
export function localRunStarted(sessionId: string, chatId: string): void {
if (heartbeats.has(sessionId)) return
post({ kind: 'run-heartbeat', sessionId, from: TAB_ID })
heartbeats.set(sessionId, {
timer: setInterval(
() => post({ kind: 'run-heartbeat', sessionId, from: TAB_ID }),
HEARTBEAT_MS
),
chatId
})
}
/** `chatId` is read at turn end, not reused from the start: a rotation
* mid-turn means the transcript now lives under a different record, and the
* watchers' re-read must follow it there. */
export function localRunEnded(sessionId: string, chatId: string): void {
const entry = heartbeats.get(sessionId)
if (entry !== undefined) {
clearInterval(entry.timer)
heartbeats.delete(sessionId)
}
post({ kind: 'turn-end', sessionId, chatId, from: TAB_ID })
}
if (BROWSER) {
// The run dies with the page: a turn-end farewell (which also has watchers
// re-read the last checkpoint) beats making them wait out STALE_MS. Not on
// a bfcache freeze (persisted) — that turn resumes with the page, and
// nothing would re-arm a farewelled heartbeat.
window.addEventListener('pagehide', (ev) => {
if (ev.persisted) return
for (const [sessionId, entry] of [...heartbeats]) {
localRunEnded(sessionId, entry.chatId)
}
})
}
/** Test seam: deliver a message as if it arrived on the channel. */
export function __receiveForTest(msg: SyncMsg): void {
receive(msg)
}
/** Test seam: clear the module's state between tests. */
export function __resetForTest(): void {
remoteRuns.clear()
if (pruneTimer) {
clearInterval(pruneTimer)
pruneTimer = undefined
}
for (const entry of heartbeats.values()) clearInterval(entry.timer)
heartbeats.clear()
remoteTurnEnd = undefined
}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__receiveForTest,
__resetForTest,
onRemoteTurnEnd,
runHeldElsewhere
} from './sessionSync.svelte'
// Exercises the receive-side state machine directly (the module opens no
// BroadcastChannel outside the browser). The channel itself is glue that only
// a real browser can prove; what these pin are the invariants a refactor could
// silently break: the identity-token cleanup, the staleness prune, and the
// turn-end hold.
beforeEach(() => {
__resetForTest()
vi.useFakeTimers()
})
afterEach(() => {
__resetForTest()
vi.useRealTimers()
})
describe('sessionSync receive-side state', () => {
it('locks on a heartbeat and unlocks by staleness when the driver dies silently', async () => {
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
expect(runHeldElsewhere('s1')).toBe(true)
// Refreshed heartbeats keep the lock past the original entry's window.
await vi.advanceTimersByTimeAsync(60_000)
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(60_000)
expect(runHeldElsewhere('s1')).toBe(true)
// Silence past STALE_MS (90s) prunes the entry.
await vi.advanceTimersByTimeAsync(40_000)
expect(runHeldElsewhere('s1')).toBe(false)
})
it('holds the lock through the turn-end catch-up and releases when it settles', async () => {
let releaseCatchUp: (() => void) | undefined
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(0)
// Unlocking on receipt would let a send here start from history missing
// the turn that just ended; the lock must outlive the re-read.
expect(runHeldElsewhere('s1')).toBe(true)
releaseCatchUp?.()
await vi.advanceTimersByTimeAsync(0)
expect(runHeldElsewhere('s1')).toBe(false)
})
it("keeps the lock when the driver's next turn arrives during the catch-up", async () => {
let releaseCatchUp: (() => void) | undefined
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
// Flush so the catch-up handler has started (releaseCatchUp is assigned)
// before the follow-up arrives — otherwise the release below no-ops and
// the lock would survive for the wrong reason (a catch-up that never
// settled), passing even with the identity comparison broken.
await vi.advanceTimersByTimeAsync(0)
// The queued-follow-up sequence: the next turn's first heartbeat lands
// while this tab's catch-up is still reading — in the same millisecond,
// which is why the cleanup must compare identity, not timestamps.
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
releaseCatchUp?.()
await vi.advanceTimersByTimeAsync(0)
expect(runHeldElsewhere('s1')).toBe(true)
})
// Two drivers on one session is the documented advisory race; a watcher
// must not compound it by unlocking when only one of them finishes.
it('stays locked when one of two drivers ends its turn', async () => {
onRemoteTurnEnd(() => Promise.resolve())
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-b' })
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(0)
// Driver A's slot released with its catch-up; driver B still holds its own.
expect(runHeldElsewhere('s1')).toBe(true)
})
})
@@ -102,7 +102,7 @@
<SettingsPageHeader
title="Members {members != undefined ? `(${members.length})` : ''}"
description="Add collaborators to the fork you created."
description="Add members to the fork you created."
link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
/>
@@ -124,12 +124,12 @@
nonCaptureEvent={true}
startIcon={{ icon: UserPlus }}
>
Add collaborator
Add member
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col w-[28rem] p-4 gap-2">
<span class="text-sm leading-6 font-semibold">Add a collaborator</span>
<span class="text-sm leading-6 font-semibold">Add a member</span>
<span class="text-xs text-secondary">
They join as a developer of this fork. Only members of
<b>{parentWorkspaceId}</b> who are developers or admins there can be added.
+38 -7
View File
@@ -11,7 +11,9 @@
shouldStopPropagation?: boolean
selected?: boolean
sticky?: boolean
stickyEnd?: boolean
/** The column holding a row's action buttons. It hugs its content at the table's
* right edge instead of absorbing the width the other columns leave over. */
actions?: boolean
wrap?: boolean
children?: import('svelte').Snippet
[key: string]: any
@@ -25,7 +27,7 @@
shouldStopPropagation = false,
selected = false,
sticky = false,
stickyEnd = false,
actions = false,
wrap = false,
children,
...rest
@@ -55,11 +57,11 @@
last && size === 'xs' ? 'sm:pr-3' : '',
numeric ? 'text-right' : '',
// Pin an actions column to the right so it stays visible when a wide table
// scrolls horizontally. The background must be opaque so cells sliding under it
// are occluded — the row's hover tint is translucent and would bleed through.
stickyEnd ? 'sticky right-0 border-l' : '',
stickyEnd ? (head ? 'bg-surface-secondary' : 'bg-surface') : '',
// `w-0` shrinks the column to its buttons instead of taking the leftover width, and
// the pin keeps them reachable while a wide table scrolls. The background must stay
// opaque for the cells passing under it to be occluded — see `wm-cell-pinned` below.
actions ? 'w-0 text-right [&>*]:ml-auto sticky right-0 wm-cell-pinned' : '',
actions ? (head ? 'bg-surface-secondary' : 'bg-surface') : '',
sticky ? `!p-0 sticky ${first ? 'left-0' : 'right-0'}` : 'px-2 py-2',
size === 'sm' ? 'px-1.5 py-2.5' : '',
size === 'lg' ? 'px-3 py-4' : '',
@@ -77,3 +79,32 @@
{@render children?.()}
{/if}
</svelte:element>
<style>
/* A sticky cell paints over its row's hover tint rather than inheriting it, and the tint
token carries alpha — adopting it would make the cell translucent and stop it
occluding. So it is layered over the opaque colour. A pseudo-element, not a
`background-image`: that is not animatable, and the row fades its tint on this curve. */
.wm-cell-pinned::after {
content: '';
position: absolute;
inset: 0;
/* Above the cell's own background, below its buttons. */
z-index: -1;
pointer-events: none;
background-color: rgb(var(--color-surface-hover));
opacity: 0;
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
:global(tr.wm-row-hoverable:hover) > .wm-cell-pinned::after {
opacity: 1;
}
/* Drawn only while the table overflows (DataTable measures it), since the seam marks that
content is passing under. A shadow, not a border: under `border-collapse: collapse` a
cell's borders belong to the table and scroll away with it. */
:global(.wm-table-x-overflow) .wm-cell-pinned {
box-shadow: -1px 0 0 0 rgb(var(--color-border-light));
}
</style>
@@ -15,6 +15,24 @@
let tableHeight: number = $state(0)
const dispatch = createEventDispatcher()
let tableContainer: HTMLDivElement | undefined = $state()
let tableEl: HTMLTableElement | undefined = $state()
// A pinned actions column only earns a seam once something can actually pass under it,
// so the overflow is measured rather than assumed: on a table that fits, the column
// should be indistinguishable from an ordinary one.
let xOverflowing = $state(false)
$effect(() => {
const container = tableContainer
const table = tableEl
if (!container || !table) return
// Sub-pixel widths make an exactly-fitting table read as 0.5px over.
const measure = () => (xOverflowing = container.scrollWidth > container.clientWidth + 1)
measure()
const observer = new ResizeObserver(measure)
observer.observe(container)
observer.observe(table)
return () => observer.disconnect()
})
interface Props {
paginated?: boolean
currentPage?: number
@@ -130,11 +148,15 @@
>
<List justify="between" gap="none" hFull={true}>
<div
class={twMerge('w-full overflow-auto h-fit', preventXOverflow ? 'overflow-x-hidden' : '')}
class={twMerge(
'w-full overflow-auto h-fit',
preventXOverflow ? 'overflow-x-hidden' : '',
xOverflowing ? 'wm-table-x-overflow' : ''
)}
bind:this={tableContainer}
onscroll={handleScroll}
>
<table class={tableFixed ? 'table-fixed w-full' : 'min-w-full'}>
<table bind:this={tableEl} class={tableFixed ? 'table-fixed w-full' : 'min-w-full'}>
{@render children?.()}
</table>
{@render emptyMessage?.()}
+3 -1
View File
@@ -28,7 +28,9 @@
<tr
class={twMerge(
hoverable ? 'hover:bg-surface-hover cursor-pointer' : '',
// `wm-row-hoverable` lets a pinned cell re-create this tint on top of its own opaque
// background — it cannot simply adopt it, since the hover token carries alpha.
hoverable ? 'wm-row-hoverable hover:bg-surface-hover cursor-pointer' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all',
dividable ? 'divide-x' : '',
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest'
import {
folderPermissionDiff,
isFolderDraftDirty,
type FolderDraft,
type FolderMember,
type FolderRole
} from './folderDraft'
function member(role: FolderRole): FolderMember {
return { owner_name: 'u/alice', role }
}
function baseline(): FolderDraft {
return {
summary: 'Reporting jobs',
labels: ['prod'],
defaultPermissionedAs: [{ path_glob: '**', permissioned_as: 'u/admin' }],
perms: [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'viewer' }
]
}
}
describe('folderPermissionDiff', () => {
// The whole transition matrix: which endpoint each role change maps to. `admin` lives in
// `owners` and the other two in `extra_perms`, so leaving admin is the one transition that
// cannot go through the ACL endpoint.
const transitions: Array<[from: FolderRole | 'absent', to: FolderRole, expected: unknown]> = [
['absent', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
['absent', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
['absent', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['viewer', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
['viewer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['writer', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
['writer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['admin', 'viewer', { kind: 'demoteAdmin', owner: 'u/alice', write: false }],
['admin', 'writer', { kind: 'demoteAdmin', owner: 'u/alice', write: true }]
]
it.each(transitions)('%s → %s', (from, to, expected) => {
const prev = from === 'absent' ? [] : [member(from)]
expect(folderPermissionDiff(prev, [member(to)])).toEqual([expected])
})
it.each(['viewer', 'writer', 'admin'] as const)('%s → removed drops owner and acl', (role) => {
expect(folderPermissionDiff([member(role)], [])).toEqual([{ kind: 'remove', owner: 'u/alice' }])
})
it.each(['viewer', 'writer', 'admin'] as const)('%s unchanged calls nothing', (role) => {
expect(folderPermissionDiff([member(role)], [member(role)])).toEqual([])
})
// The caller is a folder admin only through `g/ops`, so that demotion is the one the write
// policy refuses. Sent first it takes the rest of the save down with it.
it('gives up the caller own admin last', () => {
const prev: FolderMember[] = [
{ owner_name: 'g/ops', role: 'admin' },
{ owner_name: 'u/bob', role: 'viewer' }
]
const next: FolderMember[] = [
{ owner_name: 'g/ops', role: 'viewer' },
{ owner_name: 'u/bob', role: 'admin' }
]
expect(folderPermissionDiff(prev, next, ['u/alice', 'g/ops'])).toEqual([
{ kind: 'grantAdmin', owner: 'u/bob' },
{ kind: 'demoteAdmin', owner: 'g/ops', write: false }
])
})
// `g/z` is a group the caller belongs to but holds no admin through, so removing it is an
// ordinary call — queued behind the refused one it would never run.
it('defers only the rows the caller is an admin through', () => {
const prev: FolderMember[] = [
{ owner_name: 'g/a', role: 'admin' },
{ owner_name: 'g/z', role: 'viewer' }
]
expect(folderPermissionDiff(prev, [], ['u/alice', 'g/a', 'g/z'])).toEqual([
{ kind: 'remove', owner: 'g/z' },
{ kind: 'remove', owner: 'g/a' }
])
})
it('touches only the members that changed', () => {
const prev: FolderMember[] = [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'viewer' },
{ owner_name: 'g/ops', role: 'writer' }
]
const next: FolderMember[] = [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'writer' }
]
expect(folderPermissionDiff(prev, next)).toEqual([
{ kind: 'setAcl', owner: 'g/all', write: true },
{ kind: 'remove', owner: 'g/ops' }
])
})
})
describe('isFolderDraftDirty', () => {
it('is clean against its own baseline', () => {
expect(isFolderDraftDirty(baseline(), baseline())).toBe(false)
})
it('is clean before anything has loaded', () => {
expect(isFolderDraftDirty(baseline(), undefined)).toBe(false)
})
// A reload rebuilds the members in the server's order, which is not the order they were
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
it('ignores the order the members are held in', () => {
const reordered = baseline()
reordered.perms = [...reordered.perms].reverse()
expect(isFolderDraftDirty(reordered, baseline())).toBe(false)
})
// Enumerated from the value itself rather than a hand-written list: a field added to
// `FolderDraft` and to `baseline()` is covered here without anyone remembering to add a
// case. An edit this misses is one the drawer discards without asking.
it.each(Object.keys(baseline()) as Array<keyof FolderDraft>)('notices a change to %s', (key) => {
const edited = baseline()
if (key === 'summary') edited.summary = 'Something else'
else if (key === 'labels') edited.labels = [...edited.labels, 'staging']
else if (key === 'defaultPermissionedAs') edited.defaultPermissionedAs = []
else if (key === 'perms') edited.perms[1].role = 'writer'
else throw new Error(`no edit defined for ${key} — add one so the field stays covered`)
expect(isFolderDraftDirty(edited, baseline())).toBe(true)
})
it('notices a member added and a member removed', () => {
const added = baseline()
added.perms.push({ owner_name: 'g/ops', role: 'writer' })
expect(isFolderDraftDirty(added, baseline())).toBe(true)
const removed = baseline()
removed.perms.pop()
expect(isFolderDraftDirty(removed, baseline())).toBe(true)
})
it('is clean again once the baseline catches up', () => {
const saved = baseline()
saved.summary = 'Renamed'
expect(isFolderDraftDirty(saved, structuredClone(saved))).toBe(false)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { deepEqual } from 'fast-equals'
import type { FolderDefaultPermissionedAs } from '$lib/gen'
/** What a member may hold on a folder. `admin` is the `owners` array server-side; `writer`
* and `viewer` are the `true`/`false` entries of `extra_perms`. */
export type FolderRole = 'viewer' | 'writer' | 'admin'
export type FolderMember = { owner_name: string; role: FolderRole }
/** Everything the folder editor can change, held as one value so the whole edit is one
* comparison against the loaded folder and one Save. */
export type FolderDraft = {
summary: string
labels: string[]
defaultPermissionedAs: FolderDefaultPermissionedAs
perms: FolderMember[]
}
/** Whether the draft still matches the folder it was loaded from. Every field of
* `FolderDraft` participates, so a field added to the type is covered by construction
* which is what the discard guard depends on: an edit this misses is an edit the drawer
* throws away without asking. No baseline means nothing has loaded yet, so nothing to lose. */
export function isFolderDraftDirty(draft: FolderDraft, baseline: FolderDraft | undefined): boolean {
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
}
/** Members are a set, but a reload rebuilds them in the server's `extra_perms` key order while
* the draft keeps the order they were added in. Compared as-is, a change that has already been
* applied still reads as dirty. Labels and rules keep their order, which is meaningful. */
function sortedMembers(value: FolderDraft): FolderDraft {
return {
...value,
perms: [...value.perms].sort((a, b) => a.owner_name.localeCompare(b.owner_name))
}
}
/** One backend call the folder's members need. Kept as data so the mapping from role
* transitions to endpoints can be read and tested without a server. */
export type FolderPermissionCall =
/** `addowner`: appends to `owners` and sets `extra_perms[owner] = true`. */
| { kind: 'grantAdmin'; owner: string }
/** `removeowner` with a write flag: takes the member out of `owners` and sets their
* level. The only way down from admin. */
| { kind: 'demoteAdmin'; owner: string; write: boolean }
/** `acls/add`: sets `extra_perms[owner]`, for a member who is not an admin. */
| { kind: 'setAcl'; owner: string; write: boolean }
/** Both removals. `removeowner` without a write only drops the member from `owners`,
* leaving their `extra_perms` entry alone it demotes an admin rather than removing
* them, so the ACL delete is not optional. */
| { kind: 'remove'; owner: string }
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none.
*
* `callerOwners` is the caller's own `u/name` plus every group they belong to. Giving up the
* last of those that is in `owners` goes last: the write policy checks the row the update
* would produce, so that call is refused for anyone but a workspace admin, and sent early it
* takes the rest of the save with it. */
export function folderPermissionDiff(
prev: FolderMember[],
next: FolderMember[],
callerOwners?: string[]
): FolderPermissionCall[] {
const previousRole = new Map(prev.map((p) => [p.owner_name, p.role]))
const calls: FolderPermissionCall[] = []
for (const member of next) {
const before = previousRole.get(member.owner_name)
if (before === member.role) continue
if (member.role === 'admin') {
calls.push({ kind: 'grantAdmin', owner: member.owner_name })
} else if (before === 'admin') {
calls.push({
kind: 'demoteAdmin',
owner: member.owner_name,
write: member.role === 'writer'
})
} else {
calls.push({ kind: 'setAcl', owner: member.owner_name, write: member.role === 'writer' })
}
}
const kept = new Set(next.map((n) => n.owner_name))
for (const member of prev) {
if (kept.has(member.owner_name)) continue
calls.push({ kind: 'remove', owner: member.owner_name })
}
// `previousRole === 'admin'` is what makes it a handle: `callerOwners` lists every group
// the caller belongs to, and one holding only a viewer or writer row is not in `owners`,
// so removing it is an ordinary call that should not queue behind the fatal one.
const revokesCaller = (call: FolderPermissionCall) =>
(call.kind === 'demoteAdmin' || call.kind === 'remove') &&
(callerOwners?.includes(call.owner) ?? false) &&
previousRole.get(call.owner) === 'admin'
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { groupMemberDiff, isGroupDraftDirty, type GroupDraft, type GroupRole } from './groupDraft'
function baseline(): GroupDraft {
return {
summary: 'On-call engineers',
members: [
{ member_name: 'admin', role: 'admin' },
{ member_name: 'alice', role: 'member' }
]
}
}
describe('groupMemberDiff', () => {
// The whole transition matrix: which endpoints each role change maps to. A role is a
// membership row plus an ACL entry, so only the halves that actually change are sent —
// an extra call would log a permission-history row for something that did not move.
const transitions: Array<
[from: GroupRole | 'absent', to: GroupRole | 'absent', expected: unknown[]]
> = [
['absent', 'member', [{ kind: 'addUser', username: 'bob' }]],
['absent', 'manager', [{ kind: 'setAcl', username: 'bob' }]],
[
'absent',
'admin',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['member', 'admin', [{ kind: 'setAcl', username: 'bob' }]],
[
'member',
'manager',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['manager', 'admin', [{ kind: 'addUser', username: 'bob' }]],
[
'manager',
'member',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
],
['admin', 'member', [{ kind: 'removeAcl', username: 'bob' }]],
['admin', 'manager', [{ kind: 'removeUser', username: 'bob' }]],
['member', 'absent', [{ kind: 'removeUser', username: 'bob' }]],
['manager', 'absent', [{ kind: 'removeAcl', username: 'bob' }]],
[
'admin',
'absent',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
]
]
for (const [from, to, expected] of transitions) {
it(`${from} to ${to}`, () => {
const prev = from === 'absent' ? [] : [{ member_name: 'bob', role: from }]
const next = to === 'absent' ? [] : [{ member_name: 'bob', role: to }]
expect(groupMemberDiff(prev, next)).toEqual(expected)
})
}
it('sends nothing for an unchanged member', () => {
expect(groupMemberDiff(baseline().members, baseline().members)).toEqual([])
})
it('revokes the caller last so the rest of the save stays authorized', () => {
const prev = [{ member_name: 'admin', role: 'admin' as GroupRole }]
const next = [
{ member_name: 'admin', role: 'member' as GroupRole },
{ member_name: 'bob', role: 'admin' as GroupRole }
]
expect(groupMemberDiff(prev, next, 'admin')).toEqual([
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' },
{ kind: 'removeAcl', username: 'admin' }
])
})
})
describe('isGroupDraftDirty', () => {
it('is clean against an equal baseline and dirty on any field', () => {
expect(isGroupDraftDirty(baseline(), baseline())).toBe(false)
expect(isGroupDraftDirty({ ...baseline(), summary: 'Other' }, baseline())).toBe(true)
expect(
isGroupDraftDirty(
{ ...baseline(), members: [{ member_name: 'admin', role: 'member' }] },
baseline()
)
).toBe(true)
})
it('is clean while nothing has loaded', () => {
expect(isGroupDraftDirty(baseline(), undefined)).toBe(false)
})
// A reload rebuilds the members in the server's order, which is not the order they were
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
it('ignores the order the members are held in', () => {
const reordered = baseline()
reordered.members = [...reordered.members].reverse()
expect(isGroupDraftDirty(reordered, baseline())).toBe(false)
})
})
+99
View File
@@ -0,0 +1,99 @@
import { deepEqual } from 'fast-equals'
/** What a member may hold on a group. `member` is the `usr_to_group` row server-side and
* `manager` is the `true` entry in `extra_perms`; `admin` is both at once. */
export type GroupRole = 'member' | 'manager' | 'admin'
export type GroupMember = { member_name: string; role: GroupRole }
/** Everything the group editor can change, held as one value so the whole edit is one
* comparison against the loaded group and one Save. */
export type GroupDraft = {
summary: string
members: GroupMember[]
}
/** Whether the draft still matches the group it was loaded from. Every field of `GroupDraft`
* participates, so a field added to the type is covered by construction which is what the
* discard guard depends on: an edit this misses is an edit the drawer throws away without
* asking. No baseline means nothing has loaded yet, so nothing to lose. */
export function isGroupDraftDirty(draft: GroupDraft, baseline: GroupDraft | undefined): boolean {
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
}
/** Members are a set, but a reload rebuilds them in the server's order while the draft keeps
* the order they were added in. Compared as-is, a change that has already been applied still
* reads as dirty. */
function sortedMembers(value: GroupDraft): GroupDraft {
return {
...value,
members: [...value.members].sort((a, b) => a.member_name.localeCompare(b.member_name))
}
}
/** One backend call a group's members need. Kept as data so the mapping from role
* transitions to endpoints can be read and tested without a server. */
export type GroupMemberCall =
/** `addUserToGroup` / `removeUserToGroup`: the `usr_to_group` row. */
| { kind: 'addUser'; username: string }
| { kind: 'removeUser'; username: string }
/** `acls/add` / `acls/remove` on kind `group_`: the write entry that lets someone
* manage the group. */
| { kind: 'setAcl'; username: string }
| { kind: 'removeAcl'; username: string }
/** The two independent things a role is made of: belonging to the group, and holding the
* write entry that lets you manage it. Every role is one combination of the two, which is
* why a transition needs at most one call per flag. */
function flagsOf(role: GroupRole | undefined): { belongs: boolean; manages: boolean } {
return {
belongs: role === 'member' || role === 'admin',
manages: role === 'manager' || role === 'admin'
}
}
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none, and
* a member dropped from `next` is treated as holding neither flag which is what removing
* one means.
*
* `require_is_owner` authorizes each of these against `extra_perms['u/<caller>']`, so the
* caller's own revocation goes last: in row order it lands first and the rest 403s. */
export function groupMemberDiff(
prev: GroupMember[],
next: GroupMember[],
caller?: string
): GroupMemberCall[] {
const previousRole = new Map(prev.map((p) => [p.member_name, p.role]))
const calls: GroupMemberCall[] = []
const transition = (
username: string,
before: GroupRole | undefined,
after: GroupRole | undefined
) => {
const from = flagsOf(before)
const to = flagsOf(after)
if (to.belongs !== from.belongs) {
calls.push({ kind: to.belongs ? 'addUser' : 'removeUser', username })
}
if (to.manages !== from.manages) {
calls.push({ kind: to.manages ? 'setAcl' : 'removeAcl', username })
}
}
for (const member of next) {
const before = previousRole.get(member.member_name)
if (before === member.role) continue
transition(member.member_name, before, member.role)
}
const kept = new Set(next.map((n) => n.member_name))
for (const member of prev) {
if (kept.has(member.member_name)) continue
transition(member.member_name, member.role, undefined)
}
const revokesCaller = (call: GroupMemberCall) =>
call.kind === 'removeAcl' && call.username === caller
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
}
@@ -263,13 +263,14 @@
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
<TutorialBanner />
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
so it shows whenever the sessions beta is on; the composer itself is gated on operator
status inside the component (operators are refused by /sessions). -->
status and on the workspace inside the component, which owns its own vertical spacing
because the hero and the bare connect row want different amounts of it. -->
{#if isGlobalAiEnabled()}
<div class="w-full mb-16 mt-20">
<HomeAIChat />
</div>
<HomeAIChat />
{/if}
{#if $workspaceStore == 'admins'}
@@ -280,8 +281,6 @@
<div class="my-4"></div>
{/if}
<TutorialBanner />
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if tab == 'hub'}
@@ -4,11 +4,10 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FolderEditor from '$lib/components/FolderEditor.svelte'
import FolderEditorDrawer from '$lib/components/FolderEditorDrawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Button, Drawer, DrawerContent, EmptyState, Skeleton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import FolderInfo from '$lib/components/FolderInfo.svelte'
import FolderUsageInfo from '$lib/components/FolderUsageInfo.svelte'
import { sendUserToast } from '$lib/utils'
@@ -28,9 +27,8 @@
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let newFolderName: string = $state('')
let folders: FolderW[] | undefined = $state(undefined)
let folderDrawer: Drawer | undefined = $state()
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let hubDrawer: Drawer | undefined = $state()
let publishFolderName: string = $state('')
@@ -47,23 +45,11 @@
})
}
function handleKeyUp(event: KeyboardEvent, close: () => void) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
addFolder()
close()
}
}
async function addFolder() {
await FolderService.createFolder({
workspace: $workspaceStore ?? '',
requestBody: { name: newFolderName }
})
$userStore?.folders.push(newFolderName)
loadFolders()
editFolderName = newFolderName
folderDrawer?.openDrawer()
function onFolderSaved(name: string, created: boolean) {
if (created) $userStore?.folders.push(name)
// Returned, not fired: the drawer reports a failed reload, and it can only see one
// through the promise this hands back.
return loadFolders()
}
$effect(() => {
@@ -74,8 +60,6 @@
}
})
let editFolderName: string = $state('')
function computeMembers(owners: string[], extra_perms: Record<string, any>) {
const members = new Set(owners)
for (const [user, _] of Object.entries(extra_perms)) {
@@ -85,48 +69,18 @@
}
</script>
{#snippet newFolderPopover(
label: string,
placement: 'bottom' | 'bottom-end',
variant: 'accent' | 'default'
)}
<Popover
floatingConfig={{ strategy: 'absolute', placement }}
contentClasses="flex flex-col gap-2 p-4"
{#snippet newFolderButton(label: string, variant: 'accent' | 'default')}
<Button
{variant}
unifiedSize="md"
startIcon={{ icon: Plus }}
on:click={() => folderEditorDrawer?.initNew()}
>
{#snippet trigger()}
<Button {variant} unifiedSize="md" startIcon={{ icon: Plus }} nonCaptureEvent>{label}</Button>
{/snippet}
{#snippet content({ close })}
<input
class="mr-2"
onkeyup={(e) => handleKeyUp(e, () => close())}
placeholder="New folder name"
bind:value={newFolderName}
/>
<div>
<Button
variant="accent"
startIcon={{ icon: Plus }}
disabled={!newFolderName}
on:click={() => {
addFolder()
close()
}}
>
Create
</Button>
</div>
{/snippet}
</Popover>
{label}
</Button>
{/snippet}
<Drawer bind:this={folderDrawer}>
<DrawerContent title="Folder {editFolderName}" on:close={folderDrawer.closeDrawer}>
<FolderEditor on:update={loadFolders} name={editFolderName} />
</DrawerContent>
</Drawer>
<FolderEditorDrawer bind:this={folderEditorDrawer} onSaved={onFolderSaved} />
<Drawer bind:this={hubDrawer} size="1100px">
<DrawerContent
@@ -168,7 +122,7 @@
New folder
</Button>
{:else}
{@render newFolderPopover('New folder', 'bottom-end', 'accent')}
{@render newFolderButton('New folder', 'accent')}
{/if}
</div>
</PageHeader>
@@ -181,7 +135,7 @@
description="Folders are how you grant permissions: make a user or group viewer, writer or admin on a folder and that access applies to every script, flow, app, resource and schedule inside it."
>
{#if !restricted}
{@render newFolderPopover('Add a folder', 'bottom', 'default')}
{@render newFolderButton('Add a folder', 'default')}
{/if}
</EmptyState>
{:else}
@@ -196,8 +150,8 @@
<Cell head class="w-20">Schedules</Cell>
<Cell head class="w-20">Variables</Cell>
<Cell head class="w-20">Resources</Cell>
<Cell head class="w-20">Participants</Cell>
<Cell head last stickyEnd />
<Cell head class="w-20">Members</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -211,13 +165,7 @@
{/each}
{:else}
{#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)}
<Row
hoverable
on:click={() => {
editFolderName = name
folderDrawer?.openDrawer()
}}
>
<Row hoverable on:click={() => folderEditorDrawer?.initEdit(name)}>
<Cell first>
<span class="text-emphasis text-xs font-semibold">{name}</span>
{#if summary}
@@ -250,17 +198,14 @@
<FolderUsageInfo {name} tabular />
<Cell><FolderInfo members={computeMembers(owners, extra_perms)} /></Cell>
<Cell last stickyEnd shouldStopPropagation>
<Cell last actions shouldStopPropagation>
<Dropdown
items={[
{
displayName: 'Manage folder',
icon: Pen,
disabled: !canWrite,
action: () => {
editFolderName = name
folderDrawer?.openDrawer()
}
action: () => folderEditorDrawer?.initEdit(name)
},
{
displayName: 'Publish to Hub',
@@ -5,9 +5,8 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import GroupEditor from '$lib/components/GroupEditor.svelte'
import GroupEditorDrawer from '$lib/components/GroupEditorDrawer.svelte'
import InstanceGroupEditor from '$lib/components/InstanceGroupEditor.svelte'
import GroupInfo from '$lib/components/GroupInfo.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
@@ -20,7 +19,6 @@
import Cell from '$lib/components/table/Cell.svelte'
import Row from '$lib/components/table/Row.svelte'
import { untrack } from 'svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
@@ -30,10 +28,9 @@
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let newGroupName: string = $state('')
let groups: GroupW[] | undefined = $state(undefined)
let instanceGroups: InstanceGroupWithWorkspaces[] | undefined = $state(undefined)
let groupDrawer: Drawer | undefined = $state()
let groupEditorDrawer: GroupEditorDrawer | undefined = $state()
async function loadGroups(): Promise<void> {
groups = (await GroupService.listGroups({ workspace: $workspaceStore! })).map((x) => {
@@ -49,24 +46,6 @@
}
}
function handleKeyUp(event: KeyboardEvent, close: () => void) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
addGroup()
close()
}
}
async function addGroup() {
await GroupService.createGroup({
workspace: $workspaceStore ?? '',
requestBody: { name: newGroupName }
})
loadGroups()
editGroupName = newGroupName
groupDrawer?.openDrawer()
}
$effect(() => {
untrack(() => loadInstanceGroups())
if ($workspaceStore && $userStore) {
@@ -74,16 +53,11 @@
}
})
let editGroupName: string = $state('')
let instanceGroupDrawer: Drawer | undefined = $state()
let editInstanceGroupName: string = $state('')
</script>
<Drawer bind:this={groupDrawer}>
<DrawerContent title="Group {editGroupName}" on:close={groupDrawer.closeDrawer}>
<GroupEditor on:update={loadGroups} name={editGroupName} />
</DrawerContent>
</Drawer>
<GroupEditorDrawer bind:this={groupEditorDrawer} onSaved={loadGroups} />
<Drawer bind:this={instanceGroupDrawer}>
<DrawerContent
@@ -119,37 +93,14 @@
New&nbsp;group
</Button>
{:else}
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
{#snippet trigger()}
<Button unifiedSize="md" variant="accent" startIcon={{ icon: Plus }} nonCaptureEvent
>New&nbsp;group</Button
>
{/snippet}
{#snippet content({ close })}
<div class="flex-col flex gap-2 p-4">
<TextInput
size="md"
inputProps={{
placeholder: 'New group name',
onkeyup: (e) => handleKeyUp(e, close)
}}
bind:value={newGroupName}
/>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
disabled={!newGroupName}
on:click={() => {
addGroup()
close()
}}
>
Create
</Button>
</div>
{/snippet}
</Popover>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => groupEditorDrawer?.initNew()}
>
New&nbsp;group
</Button>
{/if}
</div>
</div>
@@ -161,7 +112,7 @@
<tr>
<Cell head first>Name</Cell>
<Cell head>Members</Cell>
<Cell head last />
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -175,13 +126,7 @@
{/each}
{:else}
{#each groups as { name, summary, extra_perms, canWrite } (name)}
<Row
hoverable
on:click={() => {
editGroupName = name
groupDrawer?.openDrawer()
}}
>
<Row hoverable on:click={() => groupEditorDrawer?.initEdit(name)}>
<Cell first>
<div class="flex flex-row gap-2 justify-between">
<div>
@@ -197,7 +142,7 @@
<Cell>
<GroupInfo {name} />
</Cell>
<Cell>
<Cell last actions shouldStopPropagation>
<Dropdown
items={[
{
@@ -206,8 +151,7 @@
disabled: !canWrite,
action: (e) => {
e?.stopPropagation()
editGroupName = name
groupDrawer?.openDrawer()
groupEditorDrawer?.initEdit(name)
}
},
{
@@ -1086,8 +1086,8 @@
<Cell head>Path</Cell>
<Cell head>Resource type</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last stickyEnd />
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</Row>
</Head>
<tbody class="divide-y bg-surface">
@@ -1254,8 +1254,8 @@
{/if}
</div>
</Cell>
<Cell last stickyEnd>
<div class="flex justify-end">
<Cell last actions>
<div class="flex justify-end items-center gap-2">
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator}
<ExploreAssetButton
asset={{ kind: 'resource', path }}
@@ -1374,7 +1374,7 @@
<Row>
<Cell head first>Name</Cell>
<Cell head>Description</Cell>
<Cell head last stickyEnd />
<Cell head last actions>Actions</Cell>
</Row>
</Head>
<tbody class="divide-y bg-surface">
@@ -1418,7 +1418,7 @@
</span>
</div>
</Cell>
<Cell last stickyEnd class="border-l-0 text-right">
<Cell last actions>
{#if !canWrite}
<!-- Badge is inline-flex, so it needs a right-aligning wrapper to sit
flush with the action buttons on the rows that have them. -->
@@ -391,8 +391,8 @@
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last stickyEnd />
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -494,7 +494,7 @@
{#if refresh_error}
<Popover notClickable>
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
so it can't paint over a sticky-pinned actions column scrolling past it -->
so it can't paint over anything that scrolls past it -->
<div
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
>
@@ -546,7 +546,7 @@
{/if}
</div>
</Cell>
<Cell last stickyEnd shouldStopPropagation>
<Cell last actions shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
@@ -9,6 +9,7 @@
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
import { Globe } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
let tab = $state('button')
@@ -195,7 +196,7 @@ That's the full round-trip.`
<code>CodeDisplay</code><code>HighlightCode</code>), constrained to the chat panel width.
</div>
<div class="border border-border-light rounded-lg p-3 bg-surface" style="max-width: 420px;">
<AssistantMessage message={chatMessage} />
<AssistantMessage message={chatMessage} workspace={$workspaceStore} />
</div>
</TabContent>
<TabContent value="scrollbar" class="p-4">
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.800.1"
wmill = ">=1.801.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.800.1
version: 1.801.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.800.1'
ModuleVersion = '1.801.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.800.1"
version = "1.801.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.800.1",
"version": "1.801.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.800.1",
"version": "1.801.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.800.1
1.801.0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.800.1",
"version": "1.801.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.800.1",
"version": "1.801.0",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-yaml-validator",
"version": "1.800.1",
"version": "1.801.0",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",