mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-15 08:02:27 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6060ac3adc | ||
|
|
d578e40101 | ||
|
|
db5e03610d | ||
|
|
a26a2e8092 | ||
|
|
81eb446eee | ||
|
|
8cfaa91d43 | ||
|
|
bdfd5d5726 | ||
|
|
2048a36376 | ||
|
|
3c34d19813 | ||
|
|
7f27d996ac | ||
|
|
6d63d9973d | ||
|
|
23df390b17 | ||
|
|
5089a45881 | ||
|
|
f035b538bb | ||
|
|
47c0c363f4 | ||
|
|
54f5a19377 |
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
|
||||
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
|
||||
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
|
||||
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
|
||||
|
||||
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add summary field for native triggers ([#8476](https://github.com/windmill-labs/windmill/issues/8476)) ([5089a45](https://github.com/windmill-labs/windmill/commit/5089a458819abbc6f241bc354bebb91520bd1a52))
|
||||
* add typed request body to OpenAPI spec generation ([#8481](https://github.com/windmill-labs/windmill/issues/8481)) ([37ebaf4](https://github.com/windmill-labs/windmill/commit/37ebaf4d0ac342703498733f97778a552f979f6a))
|
||||
* **cli:** better stale scripts detection [#3](https://github.com/windmill-labs/windmill/issues/3) ([#8480](https://github.com/windmill-labs/windmill/issues/8480)) ([9643006](https://github.com/windmill-labs/windmill/commit/9643006f1e90b991b334bb58caf62301bc26d09d))
|
||||
* Debounce node ([#8324](https://github.com/windmill-labs/windmill/issues/8324)) ([5d1c54d](https://github.com/windmill-labs/windmill/commit/5d1c54d9b33d6ff6f2c98481a2740d1e7629cdfa))
|
||||
* surface permissioned_as selector in trigger editor UI ([#8475](https://github.com/windmill-labs/windmill/issues/8475)) ([f035b53](https://github.com/windmill-labs/windmill/commit/f035b538bbd786445526339f88be8f33a3628105))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* clean up stale dependency map entries for renamed scripts ([#8492](https://github.com/windmill-labs/windmill/issues/8492)) ([47c0c36](https://github.com/windmill-labs/windmill/commit/47c0c363f4fc1d9af7efd07ea172e32989ce50d2))
|
||||
* **cli:** add Svelte 5 event delegation guidance and safe push to raw-app skill ([#8466](https://github.com/windmill-labs/windmill/issues/8466)) ([911df95](https://github.com/windmill-labs/windmill/commit/911df958e78d2dab9823dfa7d7e5c9824fc2d565))
|
||||
* Fix worker panic when job_isolation changed to unshare at runtime ([#8490](https://github.com/windmill-labs/windmill/issues/8490)) ([cbe47c0](https://github.com/windmill-labs/windmill/commit/cbe47c0b6c22f79452d020777e481ee26970f25b))
|
||||
* improve SQS retries ([3c8d351](https://github.com/windmill-labs/windmill/commit/3c8d351c9722a089133871019d27cf3bc3cdc159))
|
||||
* Move database manager SQL queries to backend ([#8306](https://github.com/windmill-labs/windmill/issues/8306)) ([aa30fd2](https://github.com/windmill-labs/windmill/commit/aa30fd252dcf40233d191c43a6293fb9feabf010))
|
||||
* prevent SQL injection in job query parameters ([#8494](https://github.com/windmill-labs/windmill/issues/8494)) ([54f5a19](https://github.com/windmill-labs/windmill/commit/54f5a19377e9df712e18f85f896e21b1776981ed))
|
||||
* respect NO_COLOR env variable for stdout log output ([#8483](https://github.com/windmill-labs/windmill/issues/8483)) ([f329ee7](https://github.com/windmill-labs/windmill/commit/f329ee7aaefbae0ad344743c40825440a936bd30))
|
||||
* show effective isolation level on workers page ([#8491](https://github.com/windmill-labs/windmill/issues/8491)) ([37886ed](https://github.com/windmill-labs/windmill/commit/37886edda1443293806a9b1b810196b72e076b12))
|
||||
* skip debounce arg accumulation when batch table is empty (CE) ([#8485](https://github.com/windmill-labs/windmill/issues/8485)) ([010753c](https://github.com/windmill-labs/windmill/commit/010753c73ac85237af50acadf9c08567b1bc993c))
|
||||
* stop_after_if with empty error_message prevents flow from stopping ([#8464](https://github.com/windmill-labs/windmill/issues/8464)) ([1503bf9](https://github.com/windmill-labs/windmill/commit/1503bf948e3340b8a6933d71885f8f2cb8dc1867))
|
||||
|
||||
## [1.662.0](https://github.com/windmill-labs/windmill/compare/v1.661.0...v1.662.0) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,11 @@
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7"
|
||||
"hash": "1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -91,8 +96,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb"
|
||||
"hash": "15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -92,8 +97,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce"
|
||||
"hash": "6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574"
|
||||
"hash": "a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -94,8 +99,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e"
|
||||
"hash": "b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
|
||||
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e"
|
||||
"hash": "bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708"
|
||||
}
|
||||
Generated
+134
-137
@@ -234,9 +234,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.8.2"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
|
||||
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -2536,12 +2536,6 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -4887,19 +4881,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case 0.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version 0.4.1",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "1.0.0"
|
||||
@@ -4927,6 +4908,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7419,14 +7401,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"widestring",
|
||||
"windows-sys 0.48.0",
|
||||
"winreg",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7446,9 +7429,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -7538,7 +7521,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -7547,9 +7530,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -8059,9 +8064,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"libc",
|
||||
@@ -8308,9 +8313,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "malachite"
|
||||
version = "0.4.18"
|
||||
version = "0.4.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6ecab92657eb234bfe98abd0b17920772c6b14ce69256950142e2eb36d000b"
|
||||
checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5"
|
||||
dependencies = [
|
||||
"malachite-base",
|
||||
"malachite-nz",
|
||||
@@ -8331,11 +8336,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "malachite-bigint"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17703a19c80bbdd0b7919f0f104f3b0597f7de4fc4e90a477c15366a5ba03faa"
|
||||
checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8"
|
||||
dependencies = [
|
||||
"derive_more 0.99.20",
|
||||
"derive_more 1.0.0",
|
||||
"malachite",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -8610,9 +8615,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.14"
|
||||
version = "0.12.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
|
||||
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"crossbeam-channel",
|
||||
@@ -8826,7 +8831,7 @@ version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11663,7 +11668,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki 0.103.10",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -11747,7 +11752,7 @@ dependencies = [
|
||||
"rustls 0.23.35",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki 0.103.10",
|
||||
"security-framework 3.6.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs 1.0.6",
|
||||
@@ -11795,9 +11800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring 0.17.14",
|
||||
@@ -13877,12 +13882,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14468,9 +14473,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.10+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||
dependencies = [
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
@@ -15040,9 +15045,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
@@ -15742,7 +15747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15818,7 +15823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15831,7 +15836,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15972,7 +15977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15995,7 +16000,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16008,7 +16013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16034,7 +16039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -16044,7 +16049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16061,7 +16066,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -16084,7 +16089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16107,7 +16112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16123,7 +16128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16143,7 +16148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16163,7 +16168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16177,7 +16182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16205,7 +16210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16230,7 +16235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"flate2",
|
||||
@@ -16248,7 +16253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16260,6 +16265,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
"sqlx",
|
||||
"tracing",
|
||||
"url",
|
||||
"windmill-api-auth",
|
||||
"windmill-common",
|
||||
@@ -16269,7 +16275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16289,7 +16295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16319,7 +16325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16346,7 +16352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16358,7 +16364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16381,7 +16387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16395,7 +16401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16426,7 +16432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16440,7 +16446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16459,7 +16465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16559,7 +16565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16578,7 +16584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16593,7 +16599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16617,7 +16623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16634,7 +16640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16650,7 +16656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16671,7 +16677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16702,7 +16708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16726,7 +16732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -16760,7 +16766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16778,7 +16784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16787,7 +16793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16799,7 +16805,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16811,7 +16817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16823,7 +16829,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16835,7 +16841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16847,7 +16853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16858,7 +16864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16869,7 +16875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16881,7 +16887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16892,7 +16898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16914,7 +16920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16928,7 +16934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16945,7 +16951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16958,7 +16964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16970,7 +16976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16988,7 +16994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -17004,7 +17010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -17020,7 +17026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -17031,7 +17037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17068,7 +17074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -17106,7 +17112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -17117,7 +17123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -17146,7 +17152,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -17169,7 +17175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17202,7 +17208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17222,7 +17228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17256,7 +17262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17291,7 +17297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17314,7 +17320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17338,7 +17344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -17362,7 +17368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17397,7 +17403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17425,7 +17431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -17448,7 +17454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.4",
|
||||
@@ -17467,7 +17473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -17495,6 +17501,7 @@ dependencies = [
|
||||
"gcp_auth",
|
||||
"git-version",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hudsucker",
|
||||
"hyper-http-proxy",
|
||||
"hyper-tls",
|
||||
@@ -17574,7 +17581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -18187,16 +18194,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -82,7 +82,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.662.0"
|
||||
version = "1.664.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
c04f3851c03758662e4936ff4b6e71bc56dbae7e
|
||||
faeaa43bbe2ba4804f80b828b85fd4d6daef096c
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE native_trigger DROP COLUMN IF EXISTS summary;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE native_trigger ADD COLUMN summary VARCHAR(1000);
|
||||
+7
-2
@@ -36,9 +36,10 @@ use windmill_common::ee_oss::{
|
||||
|
||||
use windmill_common::{
|
||||
agent_workers::AgentConfig,
|
||||
ai_cache::bump_instance_ai_config_revision,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
|
||||
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
@@ -1813,6 +1814,10 @@ async fn process_notify_event(
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
}
|
||||
AI_CONFIG_SETTING => {
|
||||
tracing::info!("AI config setting changed, bumping instance AI cache revision");
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(tx, 4, "OTEL setting change").await;
|
||||
|
||||
@@ -18,7 +18,6 @@ use regex::Regex;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -26,6 +25,11 @@ use windmill_common::{
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
@@ -716,13 +720,14 @@ async fn add_owner(
|
||||
.await?;
|
||||
|
||||
validate_owner(&owner)?;
|
||||
sqlx::query(&format!(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||
))
|
||||
sqlx::query(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
|
||||
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms",
|
||||
)
|
||||
.bind(true)
|
||||
.bind(&name)
|
||||
.bind(&w_id)
|
||||
.bind(&owner)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -787,14 +792,15 @@ async fn remove_owner(
|
||||
}
|
||||
|
||||
if let Some(write) = write {
|
||||
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
|
||||
let old_write = sqlx::query_scalar::<_, Option<bool>>(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
|
||||
true) FROM (SELECT (extra_perms->>$4)::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
|
||||
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
|
||||
))
|
||||
)
|
||||
.bind(write)
|
||||
.bind(&name)
|
||||
.bind(&w_id)
|
||||
.bind(&owner)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
@@ -422,6 +422,7 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -511,6 +512,7 @@ async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<(
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// --- allowed_domain_auto_invite ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{global_base}/allowed_domain_auto_invite"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
resp.json::<bool>().await?;
|
||||
|
||||
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
resp.json::<Vec<String>>().await?;
|
||||
|
||||
// --- get_dependents (empty, no dependencies exist) ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/get_dependents/u/test-user/nonexistent"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert!(dependents.is_empty());
|
||||
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- edit_large_file_storage_config ---
|
||||
let resp = authed(client().post(format!(
|
||||
"{base}/edit_large_file_storage_config"
|
||||
)))
|
||||
.json(&json!({"large_file_storage": null}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
|
||||
.json(&json!({"large_file_storage": null}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
let invites = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert!(
|
||||
invites
|
||||
.iter()
|
||||
.any(|i| i["email"] == "invited@example.com"),
|
||||
invites.iter().any(|i| i["email"] == "invited@example.com"),
|
||||
"invite not found: {:?}",
|
||||
invites
|
||||
);
|
||||
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"delete_invite: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
|
||||
|
||||
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
|
||||
|
||||
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"create_fork: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
|
||||
|
||||
// verify fork exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.json::<bool>().await?, false);
|
||||
|
||||
// --- create_workspace_require_superadmin ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{global_base}/create_workspace_require_superadmin"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
let instance_ai_config = json!({
|
||||
"providers": {
|
||||
"openai": {
|
||||
"resource_path": "u/test-user/openai_instance",
|
||||
"models": ["gpt-4o-mini"]
|
||||
}
|
||||
}
|
||||
});
|
||||
let workspace_ai_config = json!({
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"resource_path": "u/test-user/anthropic_workspace",
|
||||
"models": ["claude-3-5-haiku-latest"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind("ai_config")
|
||||
.bind(instance_ai_config)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(settings["has_instance_ai_config"], true);
|
||||
assert_eq!(settings["uses_instance_ai_config"], true);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["provider"],
|
||||
"openai"
|
||||
);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["models"][0],
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
|
||||
.bind(workspace_ai_config)
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(settings["has_instance_ai_config"], true);
|
||||
assert_eq!(settings["uses_instance_ai_config"], false);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["provider"],
|
||||
"openai"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind("ai_config")
|
||||
.bind(json!({}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_info")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert!(settings["providers"].is_null());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if w.negated {
|
||||
format!("v2_job_queue.worker NOT LIKE '{p}'")
|
||||
format!("v2_job_queue.worker NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job_queue.worker LIKE '{p}'")
|
||||
format!("v2_job_queue.worker LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -77,11 +77,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
let p = format!("{}%", v);
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
format!("runnable_path NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
format!("runnable_path LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -123,11 +123,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
format!("v2_job.tag NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
format!("v2_job.tag LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -287,14 +287,14 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if label.negated {
|
||||
format!(
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -308,14 +308,14 @@ pub fn filter_list_completed_query(
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v)))
|
||||
.collect();
|
||||
sqlb.and_where(format!("({})", clauses.join(" AND ")));
|
||||
} else {
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
|
||||
.map(|v| format!("result->'wm_labels' ? {}", quote(v)))
|
||||
.collect();
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(format!("({})", clauses.join(" OR ")));
|
||||
@@ -329,11 +329,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if worker.negated {
|
||||
format!("v2_job_completed.worker NOT LIKE '{p}'")
|
||||
format!("v2_job_completed.worker NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job_completed.worker LIKE '{p}'")
|
||||
format!("v2_job_completed.worker LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -366,11 +366,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
let p = format!("{}%", v);
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
format!("runnable_path NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
format!("runnable_path LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -400,11 +400,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
format!("v2_job.tag NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
format!("v2_job.tag LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -449,11 +449,7 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
if let Some(dt) = &lq.created_or_started_after {
|
||||
let ts = dt.to_rfc3339();
|
||||
sqlb.and_where(format!(
|
||||
"(created_at >= '{}' OR started_at >= '{}')",
|
||||
ts.replace("'", "''"),
|
||||
ts.replace("'", "''")
|
||||
));
|
||||
sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts));
|
||||
}
|
||||
|
||||
if let Some(dt) = &lq.created_before {
|
||||
|
||||
@@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
|
||||
use windmill_common::{
|
||||
ai_cache::bump_instance_ai_config_revision,
|
||||
email_oss::send_email_plain_text,
|
||||
error::{self, JsonResult, Result},
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
WS_BASE_URL_SETTING,
|
||||
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
) -> error::Result<()> {
|
||||
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
|
||||
let value = if key == "retention_period_secs" {
|
||||
instance_config::clamp_retention_period(value)
|
||||
} else {
|
||||
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
|
||||
}
|
||||
};
|
||||
|
||||
if should_bump_instance_ai_revision {
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -471,6 +477,10 @@ async fn set_instance_config(
|
||||
let current_map = current.global_settings.to_settings_map();
|
||||
let settings_diff =
|
||||
instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
||||
let ai_config_changed = settings_diff
|
||||
.upserts
|
||||
.iter()
|
||||
.any(|(key, _)| key == AI_CONFIG_SETTING);
|
||||
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
@@ -479,6 +489,10 @@ async fn set_instance_config(
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
|
||||
if ai_config_changed {
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
}
|
||||
|
||||
if !desired.worker_configs.is_empty() {
|
||||
|
||||
@@ -82,6 +82,10 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get_dependents/*imported_path", get(get_dependents))
|
||||
.route("/get_dependents_amounts", post(get_dependents_amounts))
|
||||
.route("/get_settings", get(get_settings))
|
||||
.route(
|
||||
"/get_copilot_settings_state",
|
||||
get(get_copilot_settings_state),
|
||||
)
|
||||
.route("/get_deploy_to", get(get_deploy_to))
|
||||
.route("/edit_slack_command", post(edit_slack_command))
|
||||
.route(
|
||||
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct CopilotSettingsState {
|
||||
pub has_instance_ai_config: bool,
|
||||
pub uses_instance_ai_config: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_ai_summary: Option<InstanceAISummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAIProviderSummary {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAIModelSummary {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAISummary {
|
||||
pub providers: Vec<InstanceAIProviderSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<InstanceAIModelSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_completion_model: Option<InstanceAIModelSummary>,
|
||||
}
|
||||
|
||||
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
|
||||
// pub enum WorkspaceKeyKind {
|
||||
@@ -608,15 +641,106 @@ async fn get_settings(
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
|
||||
|
||||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
tx.commit().await?;
|
||||
|
||||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
if !authed.is_admin {
|
||||
settings.slack_oauth_client_secret = None;
|
||||
}
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
async fn get_copilot_settings_state(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<CopilotSettingsState> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
|
||||
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
|
||||
let instance_ai_config: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(build_copilot_settings_state(
|
||||
has_ai_providers(workspace_ai_config.as_ref()),
|
||||
instance_ai_config.as_ref(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
|
||||
config
|
||||
.and_then(|value| value.get("providers"))
|
||||
.and_then(|providers| providers.as_object())
|
||||
.map(|providers| !providers.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn build_copilot_settings_state(
|
||||
has_workspace_ai_config: bool,
|
||||
instance_ai_config: Option<&serde_json::Value>,
|
||||
) -> CopilotSettingsState {
|
||||
let has_instance_ai_config = has_ai_providers(instance_ai_config);
|
||||
CopilotSettingsState {
|
||||
has_instance_ai_config,
|
||||
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
|
||||
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
|
||||
let config = config?;
|
||||
if !has_ai_providers(Some(config)) {
|
||||
return None;
|
||||
}
|
||||
let providers = config.get("providers")?.as_object()?;
|
||||
|
||||
let mut provider_summaries = providers
|
||||
.iter()
|
||||
.map(|(provider, provider_config)| InstanceAIProviderSummary {
|
||||
provider: provider.clone(),
|
||||
models: provider_config
|
||||
.get("models")
|
||||
.and_then(|models| models.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
|
||||
|
||||
Some(InstanceAISummary {
|
||||
providers: provider_summaries,
|
||||
default_model: extract_instance_ai_model_summary(config, "default_model"),
|
||||
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_instance_ai_model_summary(
|
||||
config: &serde_json::Value,
|
||||
key: &str,
|
||||
) -> Option<InstanceAIModelSummary> {
|
||||
let model_config = config.get(key)?.as_object()?;
|
||||
Some(InstanceAIModelSummary {
|
||||
provider: model_config.get("provider")?.as_str()?.to_owned(),
|
||||
model: model_config.get("model")?.as_str()?.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeployTo {
|
||||
deploy_to: Option<String>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.662.0
|
||||
version: 1.664.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -3191,9 +3191,49 @@ paths:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
effective_ai_config:
|
||||
$ref: "#/components/schemas/AIConfig"
|
||||
has_instance_ai_config:
|
||||
type: boolean
|
||||
uses_instance_ai_config:
|
||||
type: boolean
|
||||
instance_ai_summary:
|
||||
$ref: "#/components/schemas/InstanceAISummary"
|
||||
required:
|
||||
- effective_ai_config
|
||||
- has_instance_ai_config
|
||||
- uses_instance_ai_config
|
||||
|
||||
/w/{workspace}/workspaces/get_copilot_settings_state:
|
||||
get:
|
||||
summary: get copilot settings state
|
||||
operationId: getCopilotSettingsState
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
has_instance_ai_config:
|
||||
type: boolean
|
||||
uses_instance_ai_config:
|
||||
type: boolean
|
||||
instance_ai_summary:
|
||||
$ref: "#/components/schemas/InstanceAISummary"
|
||||
required:
|
||||
- has_instance_ai_config
|
||||
- uses_instance_ai_config
|
||||
|
||||
/w/{workspace}/workspaces/get_copilot_info:
|
||||
get:
|
||||
@@ -11048,6 +11088,129 @@ paths:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
|
||||
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
|
||||
post:
|
||||
summary: resume or cancel a suspended flow/WAC job
|
||||
description: >
|
||||
Resume or cancel a suspended flow/WAC job. Uses approval rules to
|
||||
determine authorization. Either a valid approval_token or an
|
||||
authenticated session is required.
|
||||
operationId: resumeSuspended
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
payload:
|
||||
description: payload to send to the resumed job
|
||||
approval_token:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
approved:
|
||||
type: boolean
|
||||
description: whether to approve (true) or cancel (false) the job
|
||||
default: true
|
||||
responses:
|
||||
"201":
|
||||
description: job resumed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
|
||||
get:
|
||||
summary: get approval info for a suspended flow/WAC job
|
||||
description: >
|
||||
Get approval info for a suspended flow/WAC job. Returns form schema,
|
||||
approval rules, and whether the current user can approve. Either a
|
||||
valid token query parameter or an authenticated session is required.
|
||||
operationId: getApprovalInfo
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: token
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
responses:
|
||||
"200":
|
||||
description: approval info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- flow_id
|
||||
- can_approve
|
||||
- user_auth_required
|
||||
- approvers
|
||||
properties:
|
||||
flow_id:
|
||||
type: string
|
||||
format: uuid
|
||||
form_schema:
|
||||
description: form schema for the approval step
|
||||
description:
|
||||
description: description of the approval step
|
||||
approval_conditions:
|
||||
type: object
|
||||
properties:
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
user_groups_required:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
self_approval_disabled:
|
||||
type: boolean
|
||||
required:
|
||||
- user_auth_required
|
||||
- user_groups_required
|
||||
- self_approval_disabled
|
||||
can_approve:
|
||||
type: boolean
|
||||
description: whether the current user/token holder can approve
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
description: whether user authentication is required to approve
|
||||
hide_cancel:
|
||||
type: boolean
|
||||
description: whether to hide the cancel button in the UI
|
||||
approvers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- resume_id
|
||||
- approver
|
||||
properties:
|
||||
resume_id:
|
||||
type: integer
|
||||
approver:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
@@ -18792,6 +18955,33 @@ components:
|
||||
minimum: 1
|
||||
maximum: 2000000
|
||||
|
||||
InstanceAIProviderSummary:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
$ref: "#/components/schemas/AIProvider"
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- provider
|
||||
- models
|
||||
|
||||
InstanceAISummary:
|
||||
type: object
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceAIProviderSummary"
|
||||
default_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
code_completion_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
required:
|
||||
- providers
|
||||
|
||||
Alert:
|
||||
type: object
|
||||
properties:
|
||||
@@ -24592,6 +24782,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Error message if the trigger is in an error state
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
required:
|
||||
- external_id
|
||||
- workspace_id
|
||||
@@ -24626,6 +24820,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Error message if the trigger is in an error state
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
external_data:
|
||||
type: object
|
||||
description: Configuration data from the external service
|
||||
@@ -24718,6 +24916,10 @@ components:
|
||||
type: object
|
||||
description: Service-specific configuration (e.g., event types, filters)
|
||||
additionalProperties: true
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
required:
|
||||
- script_path
|
||||
- is_flow
|
||||
|
||||
+169
-44
@@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::ai_cache::current_instance_ai_config_revision;
|
||||
use windmill_common::ai_providers::{
|
||||
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
|
||||
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct AIOAuthResource {
|
||||
client_id: String,
|
||||
@@ -373,8 +378,7 @@ impl AIRequestConfig {
|
||||
|
||||
let is_azure = provider.is_azure_openai(base_url);
|
||||
let is_anthropic = matches!(provider, AIProvider::Anthropic);
|
||||
let is_anthropic_vertex =
|
||||
is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
|
||||
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
|
||||
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
|
||||
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
|
||||
|
||||
@@ -483,18 +487,27 @@ impl AIRequestConfig {
|
||||
pub struct ExpiringAIRequestConfig {
|
||||
config: AIRequestConfig,
|
||||
expires_at: std::time::Instant,
|
||||
instance_ai_config_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl ExpiringAIRequestConfig {
|
||||
fn new(config: AIRequestConfig) -> Self {
|
||||
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
|
||||
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
|
||||
instance_ai_config_revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
self.expires_at < std::time::Instant::now()
|
||||
|| self
|
||||
.instance_ai_config_revision
|
||||
.is_some_and(|revision| revision != current_instance_ai_config_revision())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct AIConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
|
||||
@@ -508,6 +521,14 @@ pub struct AIConfig {
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
impl AIConfig {
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.providers
|
||||
.as_ref()
|
||||
.is_some_and(|providers| !providers.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic API version for Google Vertex AI
|
||||
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
|
||||
|
||||
@@ -762,47 +783,76 @@ async fn proxy(
|
||||
request_cache.config
|
||||
}
|
||||
_ => {
|
||||
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false)
|
||||
} else {
|
||||
let ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
|
||||
if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false, w_id.clone(), None)
|
||||
} else {
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
if ai_config.is_none() {
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
|
||||
let ws_has_config = workspace_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.is_some_and(|config| config.has_providers());
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
if ws_has_config {
|
||||
(workspace_ai_config.unwrap(), w_id.clone(), None)
|
||||
} else {
|
||||
let instance_config = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = 'ai_config'"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.map(|providers| providers.remove(&provider))
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
match instance_config {
|
||||
Some(config) => (
|
||||
config,
|
||||
"admins".to_string(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
),
|
||||
None => {
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
(provider_config.resource_path, true)
|
||||
};
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
let resource= sqlx::query_scalar!(
|
||||
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
&resource_path,
|
||||
&w_id
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
|
||||
(
|
||||
provider_config.resource_path,
|
||||
true,
|
||||
resource_workspace,
|
||||
instance_ai_config_revision,
|
||||
)
|
||||
};
|
||||
|
||||
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
|
||||
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&resource_path)
|
||||
.bind(&resource_workspace)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
|
||||
@@ -811,11 +861,15 @@ async fn proxy(
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
|
||||
let request_config =
|
||||
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringAIRequestConfig::new(request_config.clone()),
|
||||
ExpiringAIRequestConfig::new(
|
||||
request_config.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
request_config
|
||||
@@ -858,9 +912,7 @@ async fn proxy(
|
||||
"chat/completions" => {
|
||||
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
|
||||
}
|
||||
"models" => {
|
||||
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
|
||||
}
|
||||
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Unsupported Google AI path: {}",
|
||||
ai_path
|
||||
@@ -1005,3 +1057,76 @@ async fn proxy(
|
||||
};
|
||||
Ok((status_code, headers, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use windmill_common::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_common::ai_providers::AIPlatform;
|
||||
|
||||
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
fn sample_request_config() -> AIRequestConfig {
|
||||
AIRequestConfig {
|
||||
base_url: "https://example.com".to_string(),
|
||||
api_key: None,
|
||||
access_token: None,
|
||||
organization_id: None,
|
||||
user: None,
|
||||
region: None,
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
platform: AIPlatform::Standard,
|
||||
enable_1m_context: false,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidates_all_cached_providers_for_workspace() {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::Anthropic),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-b".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
|
||||
invalidate_ai_request_cache_for_workspace("workspace-a");
|
||||
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
|
||||
.is_none());
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
|
||||
.is_none());
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_backed_cache_entries_expire_when_revision_changes() {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
|
||||
let cached = ExpiringAIRequestConfig::new(
|
||||
sample_request_config(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
);
|
||||
assert!(!cached.is_expired());
|
||||
|
||||
bump_instance_ai_config_revision();
|
||||
|
||||
assert!(cached.is_expired());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ use windmill_common::{
|
||||
cache,
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, FlowStatus, FlowStatusModule},
|
||||
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
|
||||
post(cancel_persistent_script_api),
|
||||
)
|
||||
.route("/queue/force_cancel/:id", post(force_cancel))
|
||||
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
|
||||
.route("/flow/approval_info/:job_id", get(get_approval_info))
|
||||
}
|
||||
|
||||
pub fn global_root_service() -> Router {
|
||||
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
|
||||
Self { with_code: false, ..self }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn without_flow(self) -> Self {
|
||||
Self { with_flow: false, ..self }
|
||||
}
|
||||
@@ -2027,10 +2030,7 @@ async fn count_completed_jobs_detail(
|
||||
if let Some(tags) = query.tags {
|
||||
sqlb.and_where_in(
|
||||
"v2_job.tag",
|
||||
&tags
|
||||
.split(",")
|
||||
.map(|t| format!("'{}'", t))
|
||||
.collect::<Vec<_>>(),
|
||||
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2184,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
) -> error::Result<StatusCode> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
|
||||
require_owner_of_path(&authed, flow_path)?;
|
||||
@@ -2192,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
|
||||
// Check approval conditions (self-approval, required groups, etc.)
|
||||
if let Some(ref flow_status_value) = flow.flow_status {
|
||||
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
|
||||
}
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
|
||||
.ok()
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
.or_else(|| {
|
||||
// WAC flows store approval_conditions directly in flow_status JSONB
|
||||
flow_status_value
|
||||
.get("approval_conditions")
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
});
|
||||
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
|
||||
}
|
||||
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
@@ -2211,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
)
|
||||
.await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
if is_wac {
|
||||
// WAC: directly decrement suspend counter
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
// --- New approval system endpoints ---
|
||||
|
||||
use windmill_common::variables::generate_approval_token;
|
||||
|
||||
/// Verify an approval token against the workspace key + job_id.
|
||||
async fn validate_approval_token(
|
||||
db: &DB,
|
||||
token: &str,
|
||||
job_id: Uuid,
|
||||
workspace_id: &str,
|
||||
) -> error::Result<()> {
|
||||
let expected = generate_approval_token(workspace_id, job_id, db).await?;
|
||||
if token != expected {
|
||||
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResumeSuspendedBody {
|
||||
payload: Option<serde_json::Value>,
|
||||
approval_token: Option<String>,
|
||||
approved: Option<bool>,
|
||||
}
|
||||
|
||||
async fn resume_suspended(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Json(body): Json<ResumeSuspendedBody>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let approved = body.approved.unwrap_or(true);
|
||||
let value = body.payload.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// Determine if we have a valid authed user or token
|
||||
let has_token = if let Some(ref token) = body.approval_token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Resolve the suspended flow (works for both WAC and classic flows)
|
||||
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
|
||||
|
||||
// Verify the job belongs to this workspace
|
||||
let job_workspace: Option<String> =
|
||||
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if job_workspace.as_deref() != Some(w_id.as_str()) {
|
||||
return Err(Error::NotFound(
|
||||
"Job not found in this workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check approval conditions
|
||||
let approval_conditions = if is_wac {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
} else {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
};
|
||||
|
||||
if let Some(ref ac) = approval_conditions {
|
||||
if ac.user_auth_required && opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This approval requires a logged-in user. Please sign in.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// If logged in, check authorization rules
|
||||
if let Some(ref authed) = opt_authed {
|
||||
let is_admin = authed.is_admin;
|
||||
let is_owner = flow
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_admin && !is_owner {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
}
|
||||
} else if !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate a unique resume_id
|
||||
let resume_id: u32 = rand::random();
|
||||
|
||||
// Check for duplicate
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
|
||||
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest("Resume request already sent".to_string()));
|
||||
}
|
||||
|
||||
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
|
||||
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
resume_job_id,
|
||||
&flow,
|
||||
value,
|
||||
approver_value.clone(),
|
||||
approved,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !approved {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_wac {
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
|
||||
let audit_author = if let Some(ref authed) = opt_authed {
|
||||
AuditAuthor::from(authed)
|
||||
} else {
|
||||
AuditAuthor {
|
||||
email: approver.clone(),
|
||||
username: approver.clone(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
}
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&audit_author,
|
||||
"jobs.suspend_resume",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"approved": approved,
|
||||
"job_id": job_id,
|
||||
"details": if approved {
|
||||
format!("Approved by {}", &approver)
|
||||
} else {
|
||||
format!("Cancelled by {}", &approver)
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalInfoQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApprovalInfo {
|
||||
flow_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
form_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_conditions: Option<ApprovalConditions>,
|
||||
can_approve: bool,
|
||||
user_auth_required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hide_cancel: Option<bool>,
|
||||
approvers: Vec<Approval>,
|
||||
}
|
||||
|
||||
async fn get_approval_info(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Query(query): Query<ApprovalInfoQuery>,
|
||||
) -> error::Result<Json<ApprovalInfo>> {
|
||||
// Validate access: either logged in or valid token
|
||||
let has_token = if let Some(ref token) = query.token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch job info
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApprovalJobRow {
|
||||
id: Uuid,
|
||||
script_path: Option<String>,
|
||||
email: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
workflow_as_code_status: Option<serde_json::Value>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, ApprovalJobRow>(
|
||||
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
|
||||
s.flow_status, s.workflow_as_code_status
|
||||
FROM v2_job j
|
||||
LEFT JOIN v2_job_status s ON s.id = j.id
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, None, ac, None)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
|
||||
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
|
||||
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
|
||||
let raw_flow: Option<FlowValue> = {
|
||||
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(v) = from_job {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// Deployed flow: fetch from flow_version using runnable_id
|
||||
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
from_version.and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
};
|
||||
|
||||
let suspend_module = raw_flow
|
||||
.as_ref()
|
||||
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
|
||||
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
|
||||
|
||||
let form = suspend_settings
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
|
||||
// Fetch description and default_args from the step's completed job result
|
||||
let step_job_id = fs
|
||||
.as_ref()
|
||||
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
|
||||
.and_then(|m| m.job());
|
||||
let (desc, _default_args) = if let Some(sjid) = step_job_id {
|
||||
let result: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(sjid)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
|
||||
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
|
||||
(desc, da)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
(form, desc, ac, hc)
|
||||
};
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Determine if current user can approve
|
||||
let can_approve = if let Some(ref authed) = opt_authed {
|
||||
if authed.is_admin {
|
||||
true
|
||||
} else {
|
||||
let is_owner = row
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
if is_owner {
|
||||
true
|
||||
} else {
|
||||
let trigger_email = row.email.as_str();
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not logged in — can approve only if no auth required
|
||||
!user_auth_required
|
||||
};
|
||||
|
||||
// Get existing approvers
|
||||
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
|
||||
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(rid, approver)| Approval {
|
||||
resume_id: rid as u16,
|
||||
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApprovalInfo {
|
||||
flow_id: row.id,
|
||||
form_schema,
|
||||
description,
|
||||
approval_conditions,
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
hide_cancel,
|
||||
approvers,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- End new approval system endpoints ---
|
||||
|
||||
pub async fn resume_suspended_job(
|
||||
authed: Option<ApiAuthed>,
|
||||
opt_tokened: OptTokened,
|
||||
@@ -2258,26 +2679,8 @@ async fn resume_suspended_job_internal(
|
||||
// Get flow info - works for step-level, flow-level, and WAC approval
|
||||
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
|
||||
|
||||
// For step-level resumes, verify user auth and flow status
|
||||
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
|
||||
// For WAC approvals, skip flow status checks (there is no flow)
|
||||
if !is_flow_level && !is_wac {
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
}
|
||||
// HMAC secret = full capability. Skip approval_conditions checks.
|
||||
// Authorization rules are enforced by the new resume_suspended endpoint instead.
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2543,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<(FlowInfo, Uuid)> {
|
||||
) -> error::Result<(FlowInfo, Uuid, bool)> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
@@ -2556,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let job_id = flow
|
||||
|
||||
// Try to extract step job_id from FlowStatus modules (classic flow path)
|
||||
let step_job_id = flow
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
@@ -2565,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(job_id) = job_id {
|
||||
Ok((flow, job_id))
|
||||
if let Some(step_job_id) = step_job_id {
|
||||
// Classic flow
|
||||
Ok((flow, step_job_id, false))
|
||||
} else if flow.suspend > 0 {
|
||||
// WAC approval: no FlowStatus modules, but the job is suspended
|
||||
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
|
||||
// so for WAC it may contain approval_conditions from flow_status column
|
||||
// or the WAC checkpoint from workflow_as_code_status column.
|
||||
// We need the approval_conditions which are in flow_status column.
|
||||
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
|
||||
let flow_status_only: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
|
||||
.bind(&job_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let flow = FlowInfo {
|
||||
id: flow.id,
|
||||
flow_status: flow_status_only,
|
||||
suspend: flow.suspend,
|
||||
script_path: flow.script_path,
|
||||
email: flow.email,
|
||||
};
|
||||
Ok((flow, job_id, true))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
|
||||
}
|
||||
@@ -2643,7 +3071,11 @@ pub async fn get_suspended_job_flow(
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
|
||||
conditionally_require_authed_user(
|
||||
authed.clone(),
|
||||
flow_status.approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
|
||||
let approvers_from_status = match flow_module_status {
|
||||
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
|
||||
@@ -2684,16 +3116,25 @@ pub async fn get_suspended_job_flow(
|
||||
|
||||
fn conditionally_require_authed_user(
|
||||
_authed: Option<ApiAuthed>,
|
||||
flow_status: FlowStatus,
|
||||
approval_conditions_opt: Option<ApprovalConditions>,
|
||||
_trigger_email: &str,
|
||||
) -> error::Result<()> {
|
||||
let approval_conditions_opt = flow_status.approval_conditions;
|
||||
|
||||
if approval_conditions_opt.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let approval_conditions = approval_conditions_opt.unwrap();
|
||||
|
||||
// Check self-approval independently of user_auth_required
|
||||
if approval_conditions.self_approval_disabled {
|
||||
if let Some(ref authed) = _authed {
|
||||
if !authed.is_admin && authed.email.eq(_trigger_email) {
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if approval_conditions.user_auth_required {
|
||||
{
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
@@ -2711,13 +3152,6 @@ fn conditionally_require_authed_user(
|
||||
|
||||
let authed = _authed.unwrap();
|
||||
if !authed.is_admin {
|
||||
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !approval_conditions.user_groups_required.is_empty() {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
@@ -2863,11 +3297,18 @@ pub async fn get_resume_urls_internal(
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
// Generate approval token for the new approval page URL.
|
||||
// The token targets the parent flow/WAC job for proper resolution.
|
||||
let approval_target_id = get_flow_id_for_job(&db, job_id)
|
||||
.await
|
||||
.unwrap_or(target_job_id);
|
||||
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
|
||||
|
||||
let base_url_str = BASE_URL.read().await.clone();
|
||||
let base_url = base_url_str.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
|
||||
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
|
||||
),
|
||||
cancel: build_resume_url(
|
||||
"cancel",
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
// Re-export everything from windmill-api-workspaces
|
||||
pub use windmill_api_workspaces::workspaces::*;
|
||||
use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary};
|
||||
|
||||
use crate::ai::{AIConfig, AI_REQUEST_CACHE};
|
||||
use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig};
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::teams_oss::{
|
||||
connect_teams, edit_teams_command, run_teams_message_test_job,
|
||||
@@ -24,7 +25,7 @@ use axum::{
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
error::{Error, JsonResult},
|
||||
utils::require_admin,
|
||||
DB,
|
||||
};
|
||||
@@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
use axum::extract::Query;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::error::Result;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::utils::require_admin_or_devops;
|
||||
|
||||
@@ -83,7 +87,7 @@ async fn edit_copilot_config(
|
||||
Path(w_id): Path<String>,
|
||||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||||
Json(ai_config): Json<AIConfig>,
|
||||
) -> Result<String> {
|
||||
) -> JsonResult<EditCopilotConfigResponse> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
if let Some(ref custom_prompts) = ai_config.custom_prompts {
|
||||
@@ -109,11 +113,7 @@ async fn edit_copilot_config(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ref providers) = ai_config.providers {
|
||||
for provider in providers.keys() {
|
||||
AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone()));
|
||||
}
|
||||
}
|
||||
invalidate_ai_request_cache_for_workspace(&w_id);
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -139,37 +139,66 @@ async fn edit_copilot_config(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Edit copilot config for workspace {}", &w_id))
|
||||
let workspace_has_config = ai_config.has_providers();
|
||||
let instance_ai_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let settings_state =
|
||||
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
|
||||
let effective_ai_config = if workspace_has_config {
|
||||
ai_config
|
||||
} else if let Some(instance_ai_config) = instance_ai_config {
|
||||
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
|
||||
} else {
|
||||
AIConfig::default()
|
||||
};
|
||||
|
||||
Ok(Json(EditCopilotConfigResponse {
|
||||
effective_ai_config,
|
||||
has_instance_ai_config: settings_state.has_instance_ai_config,
|
||||
uses_instance_ai_config: settings_state.uses_instance_ai_config,
|
||||
instance_ai_summary: settings_state.instance_ai_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EditCopilotConfigResponse {
|
||||
effective_ai_config: AIConfig,
|
||||
has_instance_ai_config: bool,
|
||||
uses_instance_ai_config: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instance_ai_summary: Option<InstanceAISummary>,
|
||||
}
|
||||
|
||||
async fn get_copilot_info(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<AIConfig> {
|
||||
let mut tx = db.begin().await?;
|
||||
let copilot_info = sqlx::query_scalar!(
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"getting ai config: {e:#}"
|
||||
))
|
||||
})?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(sqlx::types::Json(copilot_info)) = copilot_info {
|
||||
Ok(Json(copilot_info))
|
||||
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
|
||||
Ok(Json(workspace_ai_config.0))
|
||||
} else if let Some(instance_config) =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
{
|
||||
Ok(Json(
|
||||
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
|
||||
))
|
||||
} else {
|
||||
Ok(Json(AIConfig {
|
||||
providers: None,
|
||||
default_model: None,
|
||||
code_completion_model: None,
|
||||
custom_prompts: None,
|
||||
max_tokens_per_model: None,
|
||||
}))
|
||||
Ok(Json(AIConfig::default()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn current_instance_ai_config_revision() -> u64 {
|
||||
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn bump_instance_ai_config_revision() -> u64 {
|
||||
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
@@ -286,16 +286,17 @@ pub struct FlowData {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct FlowNotes {
|
||||
pub struct FlowExtras {
|
||||
pub notes: Option<Box<RawValue>>,
|
||||
pub groups: Option<Box<RawValue>>,
|
||||
}
|
||||
|
||||
impl FlowData {
|
||||
pub fn notes(&self) -> Option<FlowNotes> {
|
||||
serde_json::from_str::<FlowNotes>(self.raw_flow.get())
|
||||
pub fn extras(&self) -> Option<FlowExtras> {
|
||||
serde_json::from_str::<FlowExtras>(self.raw_flow.get())
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to parse notes into FlowNotes: {}", e);
|
||||
error::Error::internal_err(format!("Failed to parse notes into FlowNotes: {}", e))
|
||||
tracing::error!("Failed to parse flow extras: {}", e);
|
||||
error::Error::internal_err(format!("Failed to parse flow extras: {}", e))
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
|
||||
pub const BASE_URL_SETTING: &str = "base_url";
|
||||
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
|
||||
pub const OAUTH_SETTING: &str = "oauths";
|
||||
pub const AI_CONFIG_SETTING: &str = "ai_config";
|
||||
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
|
||||
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
|
||||
pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3";
|
||||
|
||||
@@ -351,6 +351,13 @@ pub struct GlobalSettings {
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
|
||||
>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub ai_config: Option<serde_json::Value>,
|
||||
|
||||
/// Catch-all for settings not yet covered by typed fields.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
|
||||
@@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres};
|
||||
pub mod agent_workers;
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub mod ai_bedrock;
|
||||
pub mod ai_cache;
|
||||
pub mod ai_google;
|
||||
pub mod ai_providers;
|
||||
pub mod ai_types;
|
||||
|
||||
@@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a stateless approval token from workspace key + job_id.
|
||||
/// This token grants access to view approval info and attempt to resume,
|
||||
/// but cannot be reversed to obtain the HMAC resume secret.
|
||||
pub async fn generate_approval_token(
|
||||
w_id: &str,
|
||||
job_id: uuid::Uuid,
|
||||
db: &DB,
|
||||
) -> crate::error::Result<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job_id.as_bytes());
|
||||
mac.update(b"approval_token");
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
pub async fn get_secret_value_as_admin(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn trigger_dependents_to_recompute_dependencies(
|
||||
|
||||
let job_payload = match importer_kind.as_str() {
|
||||
"script" => match sqlx::query_scalar!(
|
||||
"SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
importer_path,
|
||||
w_id
|
||||
)
|
||||
|
||||
@@ -181,6 +181,7 @@ async fn create_native_trigger<T: External>(
|
||||
&external_id,
|
||||
&config,
|
||||
service_config,
|
||||
data.summary.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -304,6 +305,7 @@ async fn update_native_trigger_handler<T: External>(
|
||||
&external_id,
|
||||
&config,
|
||||
service_config,
|
||||
data.summary.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ pub struct NativeTrigger {
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -209,6 +210,7 @@ pub struct NativeTriggerData<C> {
|
||||
pub script_path: String,
|
||||
pub is_flow: bool,
|
||||
pub service_config: C,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||
@@ -821,6 +823,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
external_id: &str,
|
||||
config: &NativeTriggerConfig,
|
||||
service_config: C,
|
||||
summary: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use windmill_common::auth::hash_token;
|
||||
|
||||
@@ -835,12 +838,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
script_path,
|
||||
is_flow,
|
||||
webhook_token_hash,
|
||||
service_config
|
||||
service_config,
|
||||
summary
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
)
|
||||
ON CONFLICT (external_id, workspace_id, service_name)
|
||||
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()
|
||||
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()
|
||||
"#,
|
||||
external_id,
|
||||
workspace_id,
|
||||
@@ -849,6 +853,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
config.is_flow,
|
||||
webhook_token_hash,
|
||||
sqlx::types::Json(service_config) as _,
|
||||
summary,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@@ -863,6 +868,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
|
||||
external_id: &str,
|
||||
config: &NativeTriggerConfig,
|
||||
service_config: Option<&RawValue>,
|
||||
summary: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use windmill_common::auth::hash_token;
|
||||
|
||||
@@ -871,7 +877,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE native_trigger
|
||||
SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()
|
||||
SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()
|
||||
WHERE
|
||||
workspace_id = $5
|
||||
AND service_name = $6
|
||||
@@ -884,6 +890,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
external_id,
|
||||
summary,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@@ -934,7 +941,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
service_config,
|
||||
error,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
summary
|
||||
FROM
|
||||
native_trigger
|
||||
WHERE
|
||||
@@ -972,7 +980,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P
|
||||
service_config,
|
||||
error,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
summary
|
||||
FROM
|
||||
native_trigger
|
||||
WHERE
|
||||
@@ -1018,7 +1027,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
nt.service_config,
|
||||
nt.error,
|
||||
nt.created_at,
|
||||
nt.updated_at
|
||||
nt.updated_at,
|
||||
nt.summary
|
||||
FROM
|
||||
native_trigger nt
|
||||
WHERE
|
||||
|
||||
@@ -133,21 +133,12 @@ async fn list_variables(
|
||||
])
|
||||
.left()
|
||||
.join("account")
|
||||
.on(&format!(
|
||||
"variable.account = account.id AND account.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id))
|
||||
.left()
|
||||
.join("resource")
|
||||
.on(&format!(
|
||||
"resource.path = variable.path AND resource.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.on("resource.path = variable.path AND resource.workspace_id = ?".bind(&w_id))
|
||||
.and_where("variable.workspace_id = ?".bind(&w_id))
|
||||
.and_where(&format!(
|
||||
"variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'",
|
||||
authed.username
|
||||
))
|
||||
.and_where("variable.path NOT LIKE 'u/' || ? || '/secret_arg/%'".bind(&authed.username))
|
||||
.order_by("path", false)
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
|
||||
@@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true }
|
||||
rust_decimal.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
pem = { workspace = true, optional = true }
|
||||
urlencoding.workspace = true
|
||||
nix.workspace = true
|
||||
|
||||
@@ -1504,7 +1504,7 @@ async function run() {{
|
||||
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
|
||||
}}
|
||||
if (dispatch.mode === "approval") {{
|
||||
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }};
|
||||
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
|
||||
}}
|
||||
if (dispatch.mode === "sleep") {{
|
||||
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
|
||||
@@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output(
|
||||
job.id, num_steps
|
||||
)))
|
||||
}
|
||||
WacOutput::Approval { key, timeout, form } => {
|
||||
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
|
||||
let db = match conn {
|
||||
Connection::Sql(db) => db,
|
||||
_ => {
|
||||
@@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output(
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?;
|
||||
|
||||
// Store approval_conditions in flow_status for resume endpoint auth checks
|
||||
let sad = self_approval_disabled.unwrap_or(false);
|
||||
if sad {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
return Err(error::Error::ExecutionErr(
|
||||
"Disabling self-approval is an enterprise only feature".to_string(),
|
||||
));
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
use windmill_common::flow_status::ApprovalConditions;
|
||||
let approval_conditions = ApprovalConditions {
|
||||
user_auth_required: true,
|
||||
user_groups_required: vec![],
|
||||
self_approval_disabled: true,
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_status SET flow_status = JSONB_SET(
|
||||
COALESCE(flow_status, '{}'::jsonb),
|
||||
'{approval_conditions}',
|
||||
$2::jsonb
|
||||
) WHERE id = $1",
|
||||
)
|
||||
.bind(&job.id)
|
||||
.bind(&serde_json::json!(approval_conditions))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!(
|
||||
"Failed to save approval conditions: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate resume URLs for the inline approval buttons.
|
||||
// Use a hash of the step key as resume_id so each waitForApproval()
|
||||
// in the same workflow gets a unique resume_job record.
|
||||
let resume_id: u32 = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
(hasher.finish() & 0xFFFF_FFFF) as u32
|
||||
};
|
||||
// Generate stateless approval token using shared utility
|
||||
let approval_token =
|
||||
windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db)
|
||||
.await?;
|
||||
|
||||
let (resume_url, cancel_url, approval_page_url) = {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use windmill_common::variables::get_workspace_key;
|
||||
|
||||
let wkey = get_workspace_key(&job.workspace_id, db).await?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes())
|
||||
.map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job.id.as_bytes());
|
||||
mac.update(resume_id.to_be_bytes().as_ref());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let base_url = windmill_common::BASE_URL.read().await.clone();
|
||||
let w_id = &job.workspace_id;
|
||||
let job_id = &job.id;
|
||||
|
||||
let resume = format!(
|
||||
"{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}"
|
||||
);
|
||||
let cancel = format!(
|
||||
"{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}"
|
||||
);
|
||||
let approval_page =
|
||||
format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}");
|
||||
(resume, cancel, approval_page)
|
||||
};
|
||||
|
||||
// Store approval form metadata for the approval page endpoint
|
||||
let approval_meta = serde_json::json!({
|
||||
"key": key,
|
||||
"form": form,
|
||||
"timeout": timeout_secs as u32,
|
||||
"self_approval_disabled": sad,
|
||||
"resume": resume_url,
|
||||
"cancel": cancel_url,
|
||||
"approvalPage": approval_page_url,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
|
||||
@@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output(
|
||||
"started_at": &now_str,
|
||||
"name": key,
|
||||
"approval": true,
|
||||
"self_approval_disabled": sad,
|
||||
"form": form,
|
||||
"resume": &resume_url,
|
||||
"cancel": &cancel_url,
|
||||
"approvalPage": &approval_page_url,
|
||||
});
|
||||
let step_timeline_key = format!("_step/{}", key);
|
||||
sqlx::query(
|
||||
@@ -3008,7 +3093,10 @@ pub fn build_nativets_env_code(
|
||||
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
|
||||
reserved_variables
|
||||
.iter()
|
||||
.map(|(k, v)| format!("process.env['{}'] = '{}';", k, v))
|
||||
.map(|(k, v)| {
|
||||
let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r");
|
||||
format!("process.env['{}'] = '{}';", k, escaped)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
)
|
||||
|
||||
@@ -59,7 +59,13 @@ pub enum WacOutput {
|
||||
/// No child job is dispatched — the parent suspends directly and resumes
|
||||
/// when a user hits the resume/cancel endpoint.
|
||||
#[serde(rename = "approval")]
|
||||
Approval { key: String, timeout: Option<u32>, form: Option<Value> },
|
||||
Approval {
|
||||
key: String,
|
||||
timeout: Option<u32>,
|
||||
form: Option<Value>,
|
||||
#[serde(default)]
|
||||
self_approval_disabled: Option<bool>,
|
||||
},
|
||||
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
|
||||
#[serde(rename = "sleep")]
|
||||
Sleep { key: String, seconds: u32 },
|
||||
@@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume(
|
||||
}
|
||||
|
||||
/// Detect WAC v2 patterns in TypeScript/Bun code.
|
||||
/// Checks for `import ... from "windmill-client"` containing workflow/task,
|
||||
/// Checks for `import ... from "windmill-client"` containing workflow,
|
||||
/// skipping comment lines. Handles both single-line and multi-line imports.
|
||||
pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
let mut has_wac_import = false;
|
||||
let mut has_workflow = false;
|
||||
let mut has_task = false;
|
||||
let mut in_import_block = false;
|
||||
let mut import_block_has_workflow = false;
|
||||
let mut import_block_has_task = false;
|
||||
for line in code.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("//") {
|
||||
@@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
if trimmed.contains("workflow") {
|
||||
has_workflow = true;
|
||||
}
|
||||
if trimmed.contains("task") {
|
||||
has_task = true;
|
||||
}
|
||||
in_import_block = false;
|
||||
}
|
||||
// Start of multi-line import: import {
|
||||
else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") {
|
||||
in_import_block = true;
|
||||
import_block_has_workflow = trimmed.contains("workflow");
|
||||
import_block_has_task = trimmed.contains("task");
|
||||
}
|
||||
// Inside multi-line import block
|
||||
else if in_import_block {
|
||||
if trimmed.contains("workflow") {
|
||||
import_block_has_workflow = true;
|
||||
}
|
||||
if trimmed.contains("task") {
|
||||
import_block_has_task = true;
|
||||
}
|
||||
// End of multi-line import: } from "windmill-client"
|
||||
if trimmed.contains("windmill-client") {
|
||||
has_wac_import = true;
|
||||
if import_block_has_workflow {
|
||||
has_workflow = true;
|
||||
}
|
||||
if import_block_has_task {
|
||||
has_task = true;
|
||||
}
|
||||
in_import_block = false;
|
||||
}
|
||||
// End of import block but not windmill-client
|
||||
@@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
has_workflow = true;
|
||||
}
|
||||
}
|
||||
has_wac_import && has_workflow && has_task
|
||||
has_wac_import && has_workflow
|
||||
}
|
||||
|
||||
/// Detect WAC v2 patterns in Python code.
|
||||
|
||||
@@ -965,6 +965,7 @@ async fn get_otel_tracing_proxy_envs(
|
||||
TRACING_PROXY_CA_CERT_PATH.to_string(),
|
||||
),
|
||||
("CURL_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()),
|
||||
("GIT_SSL_CAINFO", TRACING_PROXY_CA_CERT_PATH.to_string()),
|
||||
("DENO_CERT", TRACING_PROXY_CA_CERT_PATH.to_string()),
|
||||
])
|
||||
}
|
||||
@@ -4493,7 +4494,10 @@ pub async fn run_language_executor(
|
||||
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
|
||||
reserved_variables
|
||||
.iter()
|
||||
.map(|(k, v)| format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, v, k, v))
|
||||
.map(|(k, v)| {
|
||||
let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r");
|
||||
format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, escaped, k, escaped)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n"));
|
||||
|
||||
|
||||
@@ -427,13 +427,13 @@ pub async fn handle_flow_dependency_job(
|
||||
// `JobKind::FlowDependencies` job store either:
|
||||
// - A saved flow version `id` in the `script_hash` column.
|
||||
// - Preview raw flow in the `queue` or `job` table.
|
||||
let (mut flow, notes) = match job.runnable_id {
|
||||
let (mut flow, extras) = match job.runnable_id {
|
||||
Some(ScriptHash(id)) => {
|
||||
let flow = cache::flow::fetch_version(db, id).await?;
|
||||
(flow.value().clone(), flow.notes())
|
||||
(flow.value().clone(), flow.extras())
|
||||
}
|
||||
_ => match preview_data {
|
||||
Some(RawData::Flow(data)) => (data.value().clone(), data.notes()),
|
||||
Some(RawData::Flow(data)) => (data.value().clone(), data.extras()),
|
||||
_ => return Err(Error::internal_err("expected script hash")),
|
||||
},
|
||||
};
|
||||
@@ -528,18 +528,22 @@ pub async fn handle_flow_dependency_job(
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FlowValueWithNotes<'a> {
|
||||
struct FlowValueWithExtras<'a> {
|
||||
#[serde(flatten)]
|
||||
value: &'a FlowValue,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
notes: Option<Box<RawValue>>, // TODO: Make this a Vec<FlowNote>
|
||||
notes: Option<Box<RawValue>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
groups: Option<Box<RawValue>>,
|
||||
}
|
||||
|
||||
let new_flow_value = Json(
|
||||
serde_json::value::to_raw_value(&FlowValueWithNotes {
|
||||
serde_json::value::to_raw_value(&FlowValueWithExtras {
|
||||
value: &flow,
|
||||
notes: notes.and_then(|n| n.notes).map(|n| n.into()),
|
||||
notes: extras.as_ref().and_then(|e| e.notes.clone()),
|
||||
groups: extras.as_ref().and_then(|e| e.groups.clone()),
|
||||
})
|
||||
.map_err(to_anyhow)?,
|
||||
);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.662.0";
|
||||
export const VERSION = "v1.664.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace, validatePath } from "../../core/context.ts";
|
||||
import { readFile, writeFile, stat } from "node:fs/promises";
|
||||
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
@@ -1069,16 +1069,22 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const languageAliases: Record<string, ScriptLanguage> = {
|
||||
python: "python3",
|
||||
};
|
||||
|
||||
async function bootstrap(
|
||||
opts: GlobalOptions & { summary: string; description: string },
|
||||
scriptPath: string,
|
||||
language: ScriptLanguage
|
||||
language: ScriptLanguage | string
|
||||
) {
|
||||
if (!validatePath(scriptPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptInitialCode = scriptBootstrapCode[language];
|
||||
const resolvedLanguage = (languageAliases[language] ?? language) as ScriptLanguage;
|
||||
|
||||
const scriptInitialCode = scriptBootstrapCode[resolvedLanguage];
|
||||
if (scriptInitialCode === undefined) {
|
||||
throw new Error("Language unknown");
|
||||
}
|
||||
@@ -1086,7 +1092,7 @@ async function bootstrap(
|
||||
const config = await readConfigFile();
|
||||
|
||||
const extension = filePathExtensionFromContentType(
|
||||
language,
|
||||
resolvedLanguage,
|
||||
config.defaultTs
|
||||
);
|
||||
const scriptCodeFileFullPath = scriptPath + extension;
|
||||
@@ -1118,6 +1124,9 @@ async function bootstrap(
|
||||
yamlOptions
|
||||
);
|
||||
|
||||
const parentDir = path.dirname(scriptCodeFileFullPath);
|
||||
await mkdir(parentDir, { recursive: true });
|
||||
|
||||
await writeFile(scriptCodeFileFullPath, scriptInitialCode, {
|
||||
flag: 'wx', encoding: 'utf-8',
|
||||
});
|
||||
|
||||
@@ -231,6 +231,7 @@ export async function pushNativeTrigger(
|
||||
is_flow: result.is_flow,
|
||||
service_config: result.service_config,
|
||||
error: result.error,
|
||||
summary: result.summary,
|
||||
};
|
||||
log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`);
|
||||
} catch {
|
||||
@@ -243,6 +244,7 @@ export async function pushNativeTrigger(
|
||||
script_path: localTrigger.script_path,
|
||||
is_flow: localTrigger.is_flow,
|
||||
service_config: localTrigger.service_config,
|
||||
summary: localTrigger.summary,
|
||||
};
|
||||
|
||||
if (remoteTrigger) {
|
||||
@@ -251,11 +253,13 @@ export async function pushNativeTrigger(
|
||||
script_path: localTrigger.script_path,
|
||||
is_flow: localTrigger.is_flow,
|
||||
service_config: localTrigger.service_config,
|
||||
summary: localTrigger.summary,
|
||||
};
|
||||
const remoteCompare = {
|
||||
script_path: remoteTrigger.script_path,
|
||||
is_flow: remoteTrigger.is_flow,
|
||||
service_config: remoteTrigger.service_config,
|
||||
summary: remoteTrigger.summary,
|
||||
};
|
||||
|
||||
if (isSuperset(localCompare, remoteCompare)) {
|
||||
|
||||
+14
-16
File diff suppressed because one or more lines are too long
+1
-1
@@ -68,7 +68,7 @@ export {
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.662.0";
|
||||
export const VERSION = "1.664.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -409,6 +409,63 @@ describe("script bootstrap command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts 'python' as alias for python3", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await writeFile(
|
||||
join(tempDir, "wmill.yaml"),
|
||||
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
await mkdir(join(tempDir, "f", "test"), { recursive: true });
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["script", "bootstrap", "f/test/py_alias_script", "python"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const codeStat = await stat(join(tempDir, "f/test/py_alias_script.py"));
|
||||
expect(codeStat.isFile()).toBe(true);
|
||||
|
||||
const metaStat = await stat(
|
||||
join(tempDir, "f/test/py_alias_script.script.yaml")
|
||||
);
|
||||
expect(metaStat.isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("creates parent directories automatically", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await writeFile(
|
||||
join(tempDir, "wmill.yaml"),
|
||||
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
// Do NOT pre-create f/test — bootstrap should create it
|
||||
const result = await backend.runCLICommand(
|
||||
["script", "bootstrap", "f/test/auto_dir_script", "bun"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
|
||||
const codeStat = await stat(join(tempDir, "f/test/auto_dir_script.ts"));
|
||||
expect(codeStat.isFile()).toBe(true);
|
||||
|
||||
const metaStat = await stat(
|
||||
join(tempDir, "f/test/auto_dir_script.script.yaml")
|
||||
);
|
||||
expect(metaStat.isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("creates Go script files", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.662.0",
|
||||
"version": "1.664.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.662.0",
|
||||
"version": "1.664.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.662.0",
|
||||
"version": "1.664.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
interface Props {
|
||||
expressOAuthSetup?: boolean
|
||||
workspace?: string
|
||||
disableChatOffset?: boolean
|
||||
}
|
||||
|
||||
let { expressOAuthSetup = false }: Props = $props()
|
||||
let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let resourceType = $state('')
|
||||
@@ -50,6 +52,7 @@
|
||||
dispatch('close')
|
||||
}}
|
||||
size="800px"
|
||||
{disableChatOffset}
|
||||
>
|
||||
<DrawerContent
|
||||
title="Add a resource"
|
||||
@@ -68,6 +71,7 @@
|
||||
on:close={drawer?.closeDrawer}
|
||||
on:refresh
|
||||
express={expressOAuthSetup}
|
||||
{workspace}
|
||||
/>
|
||||
{#snippet actions()}
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
disabled?: boolean
|
||||
manual?: boolean
|
||||
express?: boolean
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,9 +51,12 @@
|
||||
isGoogleSignin = $bindable(false),
|
||||
disabled = $bindable(false),
|
||||
manual = $bindable(true),
|
||||
express = false
|
||||
express = false,
|
||||
workspace = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
|
||||
let isValid = $state(true)
|
||||
|
||||
const nativeLanguagesCategory = [
|
||||
@@ -214,7 +218,7 @@
|
||||
return
|
||||
}
|
||||
const availableRts = await ResourceService.listResourceTypeNames({
|
||||
workspace: $workspaceStore!
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
|
||||
connectsManual = availableRts
|
||||
@@ -316,7 +320,7 @@
|
||||
|
||||
async function getResourceTypeInfo() {
|
||||
resourceTypeInfo = await ResourceService.getResourceType({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path: resourceType
|
||||
})
|
||||
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
|
||||
@@ -419,7 +423,7 @@
|
||||
// Check if variable paths already exist
|
||||
if (!manual || linkedSecrets.length <= 1) {
|
||||
const exists = await VariableService.existsVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path
|
||||
})
|
||||
if (exists) {
|
||||
@@ -429,7 +433,7 @@
|
||||
for (const secretField of linkedSecrets) {
|
||||
const varPath = `${path}_${secretField}`
|
||||
const exists = await VariableService.existsVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path: varPath
|
||||
})
|
||||
if (exists) {
|
||||
@@ -440,7 +444,7 @@
|
||||
}
|
||||
}
|
||||
let exists = await ResourceService.existsResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path
|
||||
})
|
||||
|
||||
@@ -478,7 +482,7 @@
|
||||
|
||||
account = Number(
|
||||
await OauthService.createAccount({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: accountData
|
||||
})
|
||||
)
|
||||
@@ -492,7 +496,7 @@
|
||||
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
|
||||
savedVariableCount++
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
path,
|
||||
value: value,
|
||||
@@ -513,7 +517,7 @@
|
||||
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
|
||||
savedVariableCount++
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
path,
|
||||
value: v,
|
||||
@@ -532,7 +536,7 @@
|
||||
const varPath = `${path}_${secretField}`
|
||||
savedVariableCount++
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
path: varPath,
|
||||
value: v,
|
||||
@@ -549,7 +553,7 @@
|
||||
}
|
||||
|
||||
await ResourceService.createResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: {
|
||||
resource_type: resourceType,
|
||||
path,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
|
||||
import { SelectionManager } from './graph/selectionUtils.svelte'
|
||||
import { NoteEditor, setNoteEditorContext } from './graph/noteEditor.svelte'
|
||||
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
||||
import { dfs } from './flows/dfs'
|
||||
import { loadSchemaFromModule } from './flows/flowInfers'
|
||||
import { CornerDownLeft, Play } from 'lucide-svelte'
|
||||
@@ -561,6 +562,11 @@
|
||||
})
|
||||
setNoteEditorContext(noteEditor)
|
||||
|
||||
// Set up GroupEditor context for group editing capabilities
|
||||
const groupEditor = new GroupEditor(flowStore)
|
||||
let canCreateGroup = $state({ val: false })
|
||||
setGroupEditorContext(groupEditor, canCreateGroup)
|
||||
|
||||
let lastSent: OpenFlow | undefined = undefined
|
||||
function updateFlow(flow: OpenFlow) {
|
||||
if (lockChanges) {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
>
|
||||
<FlowModuleSchemaItemViewer
|
||||
onclick={handleClick}
|
||||
deletable={false}
|
||||
id={mod.id}
|
||||
label={mod.summary ||
|
||||
(`path` in mod.value ? mod.value.path : undefined) ||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
import { SelectionManager } from './graph/selectionUtils.svelte'
|
||||
import { NoteEditor } from './graph/noteEditor.svelte'
|
||||
import { setNoteEditorContext } from './graph/noteEditor.svelte'
|
||||
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
||||
import { cleanFlow } from './flows/utils.svelte'
|
||||
import {
|
||||
Calendar,
|
||||
@@ -672,6 +673,11 @@
|
||||
)
|
||||
setNoteEditorContext(noteEditor)
|
||||
|
||||
// Set up GroupEditor context for group editing capabilities
|
||||
const groupEditor = new GroupEditor(flowStore)
|
||||
let canCreateGroup = $state({ val: false })
|
||||
setGroupEditorContext(groupEditor, canCreateGroup)
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
<FlowGraphV2
|
||||
bind:this={beforeGraph}
|
||||
modules={beforeFlow.value.modules}
|
||||
groups={beforeFlow.value.groups}
|
||||
failureModule={beforeFlow.value.failure_module}
|
||||
preprocessorModule={beforeFlow.value.preprocessor_module}
|
||||
earlyStop={beforeFlow.value.skip_expr !== undefined}
|
||||
@@ -171,6 +172,7 @@
|
||||
bind:this={afterGraph}
|
||||
diffBeforeFlow={beforeFlow}
|
||||
modules={afterFlow.value.modules}
|
||||
groups={afterFlow.value.groups}
|
||||
failureModule={afterFlow.value.failure_module}
|
||||
preprocessorModule={afterFlow.value.preprocessor_module}
|
||||
earlyStop={afterFlow.value.skip_expr !== undefined}
|
||||
@@ -201,6 +203,7 @@
|
||||
<FlowGraphV2
|
||||
diffBeforeFlow={beforeFlow}
|
||||
modules={afterFlow.value.modules}
|
||||
groups={afterFlow.value.groups}
|
||||
failureModule={afterFlow.value.failure_module}
|
||||
preprocessorModule={afterFlow.value.preprocessor_module}
|
||||
earlyStop={afterFlow.value.skip_expr !== undefined}
|
||||
|
||||
@@ -9,24 +9,23 @@
|
||||
import { dfs } from './flows/dfs'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
|
||||
interface Props {
|
||||
flow: {
|
||||
summary: string
|
||||
description?: string
|
||||
value: FlowValue
|
||||
schema?: any
|
||||
path?: string
|
||||
};
|
||||
overflowAuto?: boolean;
|
||||
noSide?: boolean;
|
||||
download?: boolean;
|
||||
noGraph?: boolean;
|
||||
triggerNode?: boolean;
|
||||
stepDetail?: FlowModule | string | undefined;
|
||||
workspace?: string | undefined;
|
||||
minHeight?: number;
|
||||
noBorder?: boolean;
|
||||
summary: string
|
||||
description?: string
|
||||
value: FlowValue
|
||||
schema?: any
|
||||
path?: string
|
||||
}
|
||||
overflowAuto?: boolean
|
||||
noSide?: boolean
|
||||
download?: boolean
|
||||
noGraph?: boolean
|
||||
triggerNode?: boolean
|
||||
stepDetail?: FlowModule | string | undefined
|
||||
workspace?: string | undefined
|
||||
minHeight?: number
|
||||
noBorder?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,7 +39,7 @@
|
||||
workspace = $workspaceStore,
|
||||
minHeight = 400,
|
||||
noBorder = false
|
||||
}: Props = $props();
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
@@ -64,6 +63,7 @@
|
||||
failureModule={flow?.value?.failure_module}
|
||||
preprocessorModule={flow?.value?.preprocessor_module}
|
||||
notes={flow?.value?.notes}
|
||||
groups={flow?.value?.groups}
|
||||
onSelect={(nodeId) => {
|
||||
if (nodeId === 'Trigger') {
|
||||
dispatch('triggerDetail')
|
||||
|
||||
@@ -236,7 +236,9 @@
|
||||
})
|
||||
|
||||
let jobResults: any[] = $state(
|
||||
untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? []
|
||||
untrack(() => flowJobIds)?.flowJobs?.map(
|
||||
(x, id) => `iter #${id + 1} not loaded by frontend yet`
|
||||
) ?? []
|
||||
)
|
||||
|
||||
function asWorkflowStatus(x: any): Record<string, WorkflowStatus> {
|
||||
@@ -255,7 +257,7 @@
|
||||
let retry_selected = $state('')
|
||||
let timeout: number | undefined = undefined
|
||||
|
||||
let expandedSubflows: Record<string, FlowModule[]> = $state({})
|
||||
let expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }> = $state({})
|
||||
|
||||
let selectionManager = new SelectionManager()
|
||||
|
||||
@@ -684,10 +686,7 @@
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
`Could not load inner module duration status for job ${mod.job}`,
|
||||
e
|
||||
)
|
||||
console.error(`Could not load inner module duration status for job ${mod.job}`, e)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -1154,7 +1153,7 @@
|
||||
|
||||
function allModulesForTimeline(
|
||||
modules: FlowModule[],
|
||||
expandedSubflows: Record<string, FlowModule[]>
|
||||
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
|
||||
): FlowModuleForTimeline[] {
|
||||
const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, {
|
||||
skipToolNodes: true
|
||||
@@ -1166,7 +1165,7 @@
|
||||
): FlowModuleForTimeline[] {
|
||||
return ids.concat(
|
||||
ids.flatMap(({ id }) => {
|
||||
let fms = expandedSubflows[id]
|
||||
let fms = expandedSubflows[id]?.modules
|
||||
let oid = id.split(':').pop()
|
||||
if (!oid) {
|
||||
return []
|
||||
@@ -1902,6 +1901,7 @@
|
||||
cache={job.raw_flow?.cache_ttl !== undefined}
|
||||
modules={job.raw_flow?.modules ?? []}
|
||||
notes={job.raw_flow?.notes ?? []}
|
||||
groups={job.raw_flow?.groups}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
preprocessorModule={job.raw_flow?.preprocessor_module}
|
||||
allowSimplifiedPoll={false}
|
||||
@@ -1994,7 +1994,9 @@
|
||||
{#if job.args}
|
||||
<JobArgs
|
||||
id={isReplay ? undefined : job.id}
|
||||
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
||||
workspace={isReplay
|
||||
? undefined
|
||||
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
||||
args={job.args}
|
||||
/>
|
||||
{:else}
|
||||
@@ -2064,20 +2066,25 @@
|
||||
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
|
||||
<JobArgs
|
||||
id={isReplay ? undefined : node.job_id}
|
||||
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
||||
workspace={isReplay
|
||||
? undefined
|
||||
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
|
||||
args={node.args}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if node.workflow_as_code_status}
|
||||
<div>
|
||||
<div class="text-xs text-emphasis font-semibold mb-1">Workflow timeline</div>
|
||||
<div class="text-xs text-emphasis font-semibold mb-1"
|
||||
>Workflow timeline</div
|
||||
>
|
||||
<WorkflowTimeline
|
||||
flow_status={asWorkflowStatus(node.workflow_as_code_status)}
|
||||
flowDone={node.type === 'Success' || node.type === 'Failure'}
|
||||
stepResults={getStepResults(node.workflow_as_code_status)}
|
||||
result={node.result}
|
||||
success={node.type === 'Success'}
|
||||
jobId={node.job_id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
light?: boolean
|
||||
}
|
||||
|
||||
let { isOwner, workspaceId, job, light = false }: Props = $props()
|
||||
let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props()
|
||||
|
||||
let default_payload: object = $state({})
|
||||
let resumeUrl: string | undefined = $state(undefined)
|
||||
let cancelUrl: string | undefined = $state(undefined)
|
||||
let description: any = $state(undefined)
|
||||
let hide_cancel = $state(false)
|
||||
|
||||
@@ -49,8 +47,6 @@
|
||||
defaultValues = JSON.parse(JSON.stringify(args))
|
||||
default_payload = args
|
||||
|
||||
resumeUrl = job_result?.['resume']
|
||||
cancelUrl = job_result?.['cancel']
|
||||
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
|
||||
schema = mergeSchema(
|
||||
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
|
||||
@@ -61,61 +57,19 @@
|
||||
let loading = $state(false)
|
||||
async function continu(approve: boolean) {
|
||||
loading = true
|
||||
if ((resumeUrl && approve) || (cancelUrl && !approve)) {
|
||||
let split = (approve ? resumeUrl : cancelUrl)!.split('/')
|
||||
let signatureUrl = split.pop() ?? ''
|
||||
const regex = /([^?]+)(?:\?[^=]+=(\w+))?/
|
||||
|
||||
const matches = signatureUrl.match(regex)
|
||||
|
||||
const signature = matches?.[1]
|
||||
if (!signature) {
|
||||
sendUserToast(`Could not parse signature: ${signatureUrl}`, true)
|
||||
return
|
||||
}
|
||||
const approver = matches?.[2] || undefined
|
||||
|
||||
let resumeId = -1
|
||||
let parsedResumeId = split.pop() ?? ''
|
||||
try {
|
||||
resumeId = new Number(parsedResumeId).valueOf()
|
||||
} catch (e) {
|
||||
console.error(`Could not parse resume id: ${parsedResumeId}`)
|
||||
}
|
||||
let jobId = split.pop() ?? ''
|
||||
if (approve) {
|
||||
await JobService.resumeSuspendedJobPost({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId,
|
||||
requestBody: default_payload as any,
|
||||
resumeId,
|
||||
signature,
|
||||
approver
|
||||
})
|
||||
} else {
|
||||
await JobService.cancelSuspendedJobPost({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId,
|
||||
resumeId,
|
||||
signature,
|
||||
approver,
|
||||
requestBody: {}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (approve) {
|
||||
await JobService.resumeSuspendedFlowAsOwner({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: job?.id ?? '',
|
||||
requestBody: default_payload as any
|
||||
})
|
||||
} else {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: job?.id ?? '',
|
||||
requestBody: {}
|
||||
})
|
||||
}
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
jobId: job?.id ?? '',
|
||||
requestBody: {
|
||||
payload: approve ? (default_payload as any) : undefined,
|
||||
approved: approve
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
|
||||
@@ -130,51 +84,41 @@
|
||||
<div class="mt-2"></div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if isOwner || resumeUrl}
|
||||
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
<div>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
{loading}
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
disabled={!cancelUrl}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
<div>
|
||||
<Button variant="accent" onClick={() => continu(true)} {loading} unifiedSize="md">
|
||||
Resume
|
||||
<Tooltip class="text-white">
|
||||
Since you are an owner of this flow, you can send resume events without necessarily
|
||||
knowing the resume id sent by the approval step
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
disabled={loading}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume`
|
||||
variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
<div>
|
||||
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
|
||||
Resume
|
||||
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
You cannot resume the flow yourself without receiving the resume secret since you are not an
|
||||
owner of {job.script_path} and the approval step did not contain the resume url at key `resume`
|
||||
{/if}
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume` variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
hidePath?: boolean
|
||||
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
|
||||
defaultValues?: Record<string, any> | undefined
|
||||
workspace?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -41,9 +42,12 @@
|
||||
path = $bindable(''),
|
||||
hidePath = false,
|
||||
onChange,
|
||||
defaultValues = undefined
|
||||
defaultValues = undefined,
|
||||
workspace = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
|
||||
let isValid = $state(true)
|
||||
let jsonError = $state('')
|
||||
let can_write = $state(true)
|
||||
@@ -68,13 +72,13 @@
|
||||
let rawCode: string | undefined = $state(undefined)
|
||||
|
||||
async function initEdit() {
|
||||
resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path })
|
||||
resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path })
|
||||
description = resourceToEdit!.description ?? ''
|
||||
resource_type = resourceToEdit!.resource_type
|
||||
args = resourceToEdit?.value ?? ({} as any)
|
||||
loadResourceType()
|
||||
can_write =
|
||||
resourceToEdit.workspace_id == $workspaceStore &&
|
||||
resourceToEdit.workspace_id == effectiveWorkspace &&
|
||||
canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore)
|
||||
linkedVars = Object.entries(args)
|
||||
.filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`)
|
||||
@@ -92,12 +96,12 @@
|
||||
export async function editResource(): Promise<void> {
|
||||
if (resourceToEdit) {
|
||||
await ResourceService.updateResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path: resourceToEdit.path,
|
||||
requestBody: { path, value: args, description }
|
||||
})
|
||||
if (resourceToEdit.resource_type === 'json_schema') {
|
||||
clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!)
|
||||
clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace)
|
||||
}
|
||||
sendUserToast(`Updated resource at ${path}`)
|
||||
dispatch('refresh', path)
|
||||
@@ -108,7 +112,7 @@
|
||||
|
||||
export async function createResource(): Promise<void> {
|
||||
await ResourceService.createResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: { path, value: args, description, resource_type: resource_type! }
|
||||
})
|
||||
sendUserToast(`Updated resource at ${path}`)
|
||||
@@ -119,7 +123,7 @@
|
||||
if (resource_type) {
|
||||
try {
|
||||
const resourceType = await ResourceService.getResourceType({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
path: resource_type
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
import { Loader2, Save } from 'lucide-svelte'
|
||||
|
||||
let {
|
||||
workspace = undefined,
|
||||
disableChatOffset = false
|
||||
}: { workspace?: string; disableChatOffset?: boolean } = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let canSave = $state(true)
|
||||
let resource_type: string | undefined = $state(undefined)
|
||||
@@ -34,7 +39,7 @@
|
||||
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<Drawer bind:this={drawer} size="800px" {disableChatOffset}>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
@@ -46,6 +51,7 @@
|
||||
{path}
|
||||
{resource_type}
|
||||
{defaultValues}
|
||||
{workspace}
|
||||
on:refresh
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
onClear?: () => void
|
||||
excludedValues?: string[]
|
||||
datatableAsPgResource?: boolean
|
||||
workspace?: string | undefined
|
||||
disableChatOffset?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,9 +49,13 @@
|
||||
class: className = '',
|
||||
onClear = undefined,
|
||||
excludedValues = undefined,
|
||||
datatableAsPgResource = false
|
||||
datatableAsPgResource = false,
|
||||
workspace = undefined,
|
||||
disableChatOffset = false
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
|
||||
if (initialValue && value == undefined) {
|
||||
value = initialValue
|
||||
}
|
||||
@@ -104,7 +110,7 @@
|
||||
const resources = await Promise.all(
|
||||
resourceTypesToQuery.map((rt) =>
|
||||
ResourceService.listResource({
|
||||
workspace: $workspaceStore!,
|
||||
workspace: effectiveWorkspace,
|
||||
resourceType: rt
|
||||
})
|
||||
)
|
||||
@@ -122,7 +128,7 @@
|
||||
if (datatableAsPgResource && resourceType === 'postgresql') {
|
||||
try {
|
||||
const datatables = await WorkspaceService.listDataTables({
|
||||
workspace: $workspaceStore!
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
for (const dt of datatables) {
|
||||
nc.push({
|
||||
@@ -155,7 +161,7 @@
|
||||
let previousResourceType = untrack(() => resourceType)
|
||||
|
||||
$effect(() => {
|
||||
$workspaceStore && resourceType
|
||||
effectiveWorkspace && resourceType
|
||||
untrack(() => {
|
||||
if (previousResourceType != resourceType) {
|
||||
previousResourceType = resourceType
|
||||
@@ -167,7 +173,7 @@
|
||||
|
||||
$effect(() => {
|
||||
excludedValues
|
||||
if ($workspaceStore && resourceType && !disabled) {
|
||||
if (effectiveWorkspace && resourceType && !disabled) {
|
||||
untrack(() => loadResources(resourceType))
|
||||
}
|
||||
})
|
||||
@@ -186,9 +192,13 @@
|
||||
}}
|
||||
bind:this={appConnect}
|
||||
{expressOAuthSetup}
|
||||
{workspace}
|
||||
{disableChatOffset}
|
||||
/>
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
{workspace}
|
||||
{disableChatOffset}
|
||||
on:refresh={async (e) => {
|
||||
await loadResources(resourceType)
|
||||
if (e.detail) {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
bind:this={innerComponent}
|
||||
closeDrawer={handleClose}
|
||||
showHeaderInfo={false}
|
||||
{disableChatOffset}
|
||||
bind:yamlMode
|
||||
bind:hasUnsavedChanges
|
||||
bind:hasAnyInvalid
|
||||
|
||||
@@ -39,12 +39,14 @@
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
|
||||
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
|
||||
import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte'
|
||||
|
||||
let filter = $state('')
|
||||
|
||||
let {
|
||||
closeDrawer,
|
||||
showHeaderInfo = true,
|
||||
disableChatOffset = false,
|
||||
yamlMode = $bindable(false),
|
||||
hasUnsavedChanges = $bindable(false),
|
||||
hasAnyInvalid = $bindable(false)
|
||||
@@ -234,7 +236,9 @@
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="h-full overflow-auto bg-surface">
|
||||
<div class="h-fit px-8 py-4">
|
||||
{#if tab === 'users' && !yamlMode}
|
||||
{#if tab === 'ai' && !yamlMode}
|
||||
<InstanceAISettings {disableChatOffset} />
|
||||
{:else if tab === 'users' && !yamlMode}
|
||||
<div class="h-full">
|
||||
{#if !automateUsernameCreation && !isCloudHosted()}
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { displayDate, msToSec } from '$lib/utils'
|
||||
import { displayDate, msToSec, emptyString } from '$lib/utils'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
|
||||
@@ -9,7 +9,11 @@
|
||||
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
||||
import { CheckCircle2, XCircle } from 'lucide-svelte'
|
||||
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
flow_status: Record<string, WorkflowStatus>
|
||||
@@ -18,6 +22,7 @@
|
||||
result?: any
|
||||
success?: boolean
|
||||
autoExpandResult?: boolean
|
||||
jobId?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +31,8 @@
|
||||
stepResults = {},
|
||||
result = undefined,
|
||||
success = true,
|
||||
autoExpandResult = false
|
||||
autoExpandResult = false,
|
||||
jobId = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let resultExpanded = $state(false)
|
||||
@@ -114,6 +120,49 @@
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Approval action state
|
||||
let approvalLoading: Record<string, boolean> = $state({})
|
||||
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
|
||||
|
||||
async function handleApprove(key: string, formSchema: any) {
|
||||
const ws = $workspaceStore
|
||||
if (!ws || !jobId) return
|
||||
approvalLoading[key] = true
|
||||
try {
|
||||
const payload =
|
||||
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
|
||||
await JobService.resumeSuspended({
|
||||
workspace: ws,
|
||||
jobId: jobId,
|
||||
requestBody: { payload, approved: true }
|
||||
})
|
||||
sendUserToast('Approval submitted')
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
|
||||
} finally {
|
||||
approvalLoading[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
let cancelLoading = $state(false)
|
||||
async function handleCancel() {
|
||||
const ws = $workspaceStore
|
||||
if (!ws || !jobId) return
|
||||
cancelLoading = true
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: ws,
|
||||
jobId: jobId,
|
||||
requestBody: { approved: false }
|
||||
})
|
||||
sendUserToast('Job cancelled')
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
|
||||
} finally {
|
||||
cancelLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if flow_status}
|
||||
@@ -167,22 +216,74 @@
|
||||
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
|
||||
</div>
|
||||
{:else if isApproval}
|
||||
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2">
|
||||
<div class="w-3 flex-shrink-0"></div>
|
||||
<ShieldCheck
|
||||
size={12}
|
||||
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
|
||||
/>
|
||||
<span class="italic text-secondary">
|
||||
{v.name ?? stepKey(k)}
|
||||
</span>
|
||||
{#if !isDone}
|
||||
<span class="text-tertiary flex items-center gap-1">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
waiting
|
||||
{@const selfApprovalDisabled = (v as any).self_approval_disabled === true}
|
||||
{@const formSchema = (v as any).form?.schema ?? (v as any).form}
|
||||
{@const hasForm =
|
||||
formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0}
|
||||
{@const canApprove = !isDone && jobId}
|
||||
<div class="w-full px-2 py-1.5 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 flex-shrink-0"></div>
|
||||
<ShieldCheck
|
||||
size={12}
|
||||
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
|
||||
/>
|
||||
<span class="italic text-secondary">
|
||||
{v.name ?? stepKey(k)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
|
||||
{#if !isDone}
|
||||
<span class="text-tertiary flex items-center gap-1">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
waiting
|
||||
</span>
|
||||
{#if canApprove}
|
||||
<div class="ml-auto flex gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={approvalLoading[k]}
|
||||
onclick={() => handleApprove(k, formSchema)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={approvalLoading[k] || cancelLoading}
|
||||
onclick={() => handleCancel()}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
|
||||
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
|
||||
Self-approval is disabled but allowed because you are an admin/owner
|
||||
</div>
|
||||
{/if}
|
||||
{#if canApprove && hasForm}
|
||||
<div class="mt-2 ml-5 max-w-md">
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Adding a form to the approval page is an EE feature"
|
||||
/>
|
||||
{:else}
|
||||
<SchemaForm
|
||||
onlyMaskPassword
|
||||
noVariablePicker
|
||||
schema={{
|
||||
properties: formSchema,
|
||||
order: Object.keys(formSchema)
|
||||
}}
|
||||
bind:args={approvalFormArgs[k]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -275,13 +376,13 @@
|
||||
{@const result = stepResults[stepKey(k)]}
|
||||
{#if isDone && result !== undefined}
|
||||
<div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
||||
<div class="max-h-40 overflow-auto">
|
||||
<ObjectViewer json={result} pureViewer />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-secondary py-1">Step completed (no result)</div>
|
||||
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
|
||||
{/if}
|
||||
{:else if loadingJobs[k] && !childJobs[k]}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary py-1">
|
||||
@@ -293,7 +394,7 @@
|
||||
<!-- Logs -->
|
||||
{#if job.logs || isRunning}
|
||||
<div class="mb-2">
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Logs</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
|
||||
<LogViewer
|
||||
content={job.logs ?? ''}
|
||||
jobId={k}
|
||||
@@ -309,7 +410,7 @@
|
||||
<!-- Result -->
|
||||
{#if isDone && job.result !== undefined}
|
||||
<div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
||||
<div class="max-h-40 overflow-auto">
|
||||
<ObjectViewer json={job.result} pureViewer />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
apiKey?: string | undefined
|
||||
workspace?: string | undefined
|
||||
resourcePath?: string | undefined
|
||||
aiProvider: AIProvider
|
||||
model: string
|
||||
@@ -15,6 +16,7 @@
|
||||
let {
|
||||
disabled = false,
|
||||
apiKey = undefined,
|
||||
workspace = undefined,
|
||||
resourcePath = undefined,
|
||||
aiProvider,
|
||||
model
|
||||
@@ -38,6 +40,7 @@
|
||||
|
||||
await testKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -5,10 +5,14 @@ import type {
|
||||
ChatCompletionCreateParams
|
||||
} from 'openai/resources/index.mjs'
|
||||
import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs'
|
||||
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
|
||||
import {
|
||||
createOpenAIProxyClient,
|
||||
getAiProxyBaseURL,
|
||||
getProviderAndCompletionConfig,
|
||||
workspaceAIClients
|
||||
} from '../lib'
|
||||
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
|
||||
import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs'
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import type { AIProviderModel } from '$lib/gen'
|
||||
|
||||
// Conversion utilities for Responses API
|
||||
@@ -354,6 +358,7 @@ export async function getNonStreamingOpenAIResponsesCompletion(
|
||||
abortController: AbortController,
|
||||
testOptions?: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
forceModelProvider: AIProviderModel
|
||||
}
|
||||
@@ -390,15 +395,10 @@ export async function getNonStreamingOpenAIResponsesCompletion(
|
||||
}
|
||||
|
||||
const openaiClient = testOptions?.apiKey
|
||||
? new OpenAI({
|
||||
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
: workspaceAIClients.getOpenaiClient()
|
||||
? createOpenAIProxyClient(getAiProxyBaseURL())
|
||||
: testOptions?.workspace
|
||||
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
|
||||
: workspaceAIClients.getOpenaiClient()
|
||||
|
||||
const response = await openaiClient.responses.create(
|
||||
{
|
||||
|
||||
@@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
|
||||
},
|
||||
googleai: {
|
||||
label: 'Google AI',
|
||||
defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite']
|
||||
defaultModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-3-flash',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3.1-flash-lite'
|
||||
]
|
||||
},
|
||||
groq: {
|
||||
label: 'Groq',
|
||||
@@ -364,38 +371,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCr
|
||||
aws_bedrock: DEFAULT_COMPLETION_CONFIG
|
||||
} as const
|
||||
|
||||
export function getAiProxyBaseURL(workspace?: string): string {
|
||||
return workspace
|
||||
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
|
||||
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
|
||||
}
|
||||
|
||||
export function createOpenAIProxyClient(baseURL: string): OpenAI {
|
||||
return new OpenAI({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
export function createAnthropicProxyClient(baseURL: string): Anthropic {
|
||||
return new Anthropic({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
class WorkspacedAIClients {
|
||||
private openaiClient: OpenAI | undefined
|
||||
private anthropicClient: Anthropic | undefined
|
||||
|
||||
init(workspace: string) {
|
||||
this.initOpenai(workspace)
|
||||
this.initAnthropic(workspace)
|
||||
this.openaiClient = this.createOpenaiClient(workspace)
|
||||
this.anthropicClient = this.createAnthropicClient(workspace)
|
||||
}
|
||||
|
||||
private getBaseURL(workspace: string) {
|
||||
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
|
||||
createOpenaiClient(workspace: string): OpenAI {
|
||||
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
|
||||
}
|
||||
|
||||
private initOpenai(workspace: string) {
|
||||
const baseURL = this.getBaseURL(workspace)
|
||||
this.openaiClient = new OpenAI({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
private initAnthropic(workspace: string) {
|
||||
const baseURL = this.getBaseURL(workspace)
|
||||
this.anthropicClient = new Anthropic({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
createAnthropicClient(workspace: string): Anthropic {
|
||||
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
|
||||
}
|
||||
|
||||
getOpenaiClient() {
|
||||
@@ -417,6 +432,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
|
||||
|
||||
export async function testKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model,
|
||||
abortController,
|
||||
@@ -424,6 +440,7 @@ export async function testKey({
|
||||
aiProvider
|
||||
}: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
model: string | undefined
|
||||
messages: ChatCompletionMessageParam[]
|
||||
@@ -443,6 +460,7 @@ export async function testKey({
|
||||
if (aiProvider === 'anthropic') {
|
||||
await testAnthropicKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model: modelToTest,
|
||||
abortController,
|
||||
@@ -453,6 +471,7 @@ export async function testKey({
|
||||
|
||||
await getNonStreamingCompletion(messages, abortController, {
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
forceModelProvider: {
|
||||
model: modelToTest,
|
||||
@@ -463,12 +482,14 @@ export async function testKey({
|
||||
|
||||
async function testAnthropicKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model,
|
||||
abortController,
|
||||
messages
|
||||
}: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
model: string
|
||||
abortController: AbortController
|
||||
@@ -489,12 +510,10 @@ async function testAnthropicKey({
|
||||
}
|
||||
|
||||
const anthropicClient = apiKey
|
||||
? new Anthropic({
|
||||
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
: workspaceAIClients.getAnthropicClient()
|
||||
? createAnthropicProxyClient(getAiProxyBaseURL())
|
||||
: workspace
|
||||
? workspaceAIClients.createAnthropicClient(workspace)
|
||||
: workspaceAIClients.getAnthropicClient()
|
||||
|
||||
await anthropicClient.messages.create(
|
||||
{
|
||||
@@ -719,6 +738,7 @@ export async function getNonStreamingCompletion(
|
||||
testOptions?: {
|
||||
apiKey?: string // testing API KEY using the global ai proxy
|
||||
resourcePath?: string // testing resource path passed as a header to the backend proxy
|
||||
workspace?: string // use a specific workspace proxy when testing a workspace resource
|
||||
forceModelProvider: AIProviderModel
|
||||
}
|
||||
) {
|
||||
@@ -768,15 +788,10 @@ export async function getNonStreamingCompletion(
|
||||
}
|
||||
}
|
||||
const openaiClient = testOptions?.apiKey
|
||||
? new OpenAI({
|
||||
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
: workspaceAIClients.getOpenaiClient()
|
||||
? createOpenAIProxyClient(getAiProxyBaseURL())
|
||||
: testOptions?.workspace
|
||||
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
|
||||
: workspaceAIClients.getOpenaiClient()
|
||||
|
||||
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
|
||||
response = completion.choices?.[0]?.message.content || ''
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
onDeleteSelected={() => flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)}
|
||||
onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)}
|
||||
onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)}
|
||||
onCreateGroup={() => flowModuleSchemaMap?.createGroup(selectionManager.selectedIds)}
|
||||
{canMoveSelected}
|
||||
resolvedCount={resolvedModuleIds.length}
|
||||
/>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
|
||||
import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte'
|
||||
import { getGroupEditorContext } from '$lib/components/graph/groupEditor.svelte'
|
||||
import { Group, Move, Copy, Trash2 } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +13,7 @@
|
||||
onDeleteSelected?: () => void
|
||||
onDuplicateSelected?: () => void
|
||||
onMoveSelected?: () => void
|
||||
onCreateGroup?: () => void
|
||||
canMoveSelected?: boolean
|
||||
resolvedCount?: number
|
||||
}
|
||||
@@ -22,18 +23,14 @@
|
||||
onDeleteSelected,
|
||||
onDuplicateSelected,
|
||||
onMoveSelected,
|
||||
onCreateGroup,
|
||||
canMoveSelected = false,
|
||||
resolvedCount = 0
|
||||
}: Props = $props()
|
||||
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
const groupEditorContext = getGroupEditorContext()
|
||||
|
||||
function addGroupNote() {
|
||||
if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) {
|
||||
// Create the group note
|
||||
noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds)
|
||||
}
|
||||
}
|
||||
let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false)
|
||||
|
||||
let menuItems: Item[] = $derived([
|
||||
{
|
||||
@@ -60,11 +57,11 @@
|
||||
{#snippet action()}
|
||||
<div class="flex gap-1 items-center">
|
||||
<Button
|
||||
onClick={addGroupNote}
|
||||
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
|
||||
startIcon={{ icon: StickyNote }}
|
||||
onClick={() => onCreateGroup?.()}
|
||||
disabled={!canCreateGroup}
|
||||
startIcon={{ icon: Group }}
|
||||
>
|
||||
Create group note
|
||||
Create group
|
||||
</Button>
|
||||
{#if resolvedCount > 0}
|
||||
<DropdownV2 items={menuItems} />
|
||||
|
||||
@@ -39,27 +39,9 @@
|
||||
render a cancel button, providing the operator with an option to cancel the step. e.g:
|
||||
<Tabs selected="bun" class="pt-4">
|
||||
<Tab value="bun" label="TypeScript (Bun)" />
|
||||
<Tab value="deno" label="TypeScript (Deno)" />
|
||||
<Tab value="python" label="Python" />
|
||||
|
||||
{#snippet content()}
|
||||
<TabContent value="deno" class="p-2">
|
||||
<HighlightCode
|
||||
language={'deno'}
|
||||
code={`import * as wmill from "npm:windmill-client@^1.158.2"
|
||||
|
||||
export async function main() {
|
||||
const urls = await wmill.getResumeUrls("approver1")
|
||||
|
||||
return {
|
||||
resume: urls['resume'],
|
||||
cancel: urls['cancel'],
|
||||
default_args: {}, // optional, see below
|
||||
enums: {} // optional, see below
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="bun" class="p-2">
|
||||
<HighlightCode
|
||||
language={'deno'}
|
||||
|
||||
@@ -31,9 +31,22 @@
|
||||
editor?.setCode(code)
|
||||
}
|
||||
|
||||
function validateGroups(groups: { start_id: string; end_id: string }[] | undefined) {
|
||||
if (!groups) return
|
||||
const seen = new Set<string>()
|
||||
for (const g of groups) {
|
||||
const key = `${g.start_id}:${g.end_id}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`Duplicate group: '${g.start_id}' → '${g.end_id}'`)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
try {
|
||||
const parsed = YAML.parse(code)
|
||||
validateGroups(parsed.value?.groups)
|
||||
if (parsed.summary && typeof parsed.summary === 'string') {
|
||||
flowStore.val.summary = parsed.summary
|
||||
}
|
||||
@@ -59,7 +72,7 @@
|
||||
initialCode = code
|
||||
sendUserToast('Changes applied')
|
||||
} catch (e) {
|
||||
;(sendUserToast('Error parsing yaml: ' + e), true)
|
||||
sendUserToast('Error parsing yaml: ' + e, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +82,12 @@
|
||||
<Drawer on:open={reload} bind:this={drawer} size="800px">
|
||||
<DrawerContent title="OpenFlow" on:close={() => drawer?.toggleDrawer()}>
|
||||
{#snippet actions()}
|
||||
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}>Reset code</Button>
|
||||
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}>Apply changes</Button>
|
||||
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}
|
||||
>Reset code</Button
|
||||
>
|
||||
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}
|
||||
>Apply changes</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#if flowStore.val}
|
||||
|
||||
@@ -192,10 +192,10 @@
|
||||
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
|
||||
)
|
||||
|
||||
let isDragging = $derived(!!moveManager?.dragging)
|
||||
let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId)
|
||||
|
||||
const outputPickerVisible = $derived(
|
||||
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging
|
||||
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isMoving
|
||||
)
|
||||
|
||||
const icon_render = $derived(icon)
|
||||
@@ -214,7 +214,7 @@
|
||||
flowStore?.val?.value.failure_module
|
||||
)}
|
||||
<Drawer bind:open={editId}>
|
||||
<DrawerContent title="Edit Step Id {id}" on:close={() => (editId = false)}>
|
||||
<DrawerContent title="Edit step id {id}" on:close={() => (editId = false)}>
|
||||
<div>
|
||||
<IdEditorInput
|
||||
buttonText="Edit Id "
|
||||
@@ -285,7 +285,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={classNames(
|
||||
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-base',
|
||||
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-sm',
|
||||
colorClasses.bg
|
||||
)}
|
||||
style="width: 275px; height: 34px;"
|
||||
@@ -434,11 +434,9 @@
|
||||
{label}
|
||||
{path}
|
||||
{id}
|
||||
{deletable}
|
||||
{bold}
|
||||
bind:editId
|
||||
disableEditId={isMultiSelected}
|
||||
{hover}
|
||||
{colorClasses}
|
||||
>
|
||||
{#snippet icon()}
|
||||
@@ -484,11 +482,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deletable && !isDragging}
|
||||
{#if deletable && !isMoving}
|
||||
{#if maximizeSubflow !== undefined}
|
||||
{@render buttonMaximizeSubflow?.()}
|
||||
{/if}
|
||||
|
||||
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
|
||||
<Popover
|
||||
style="will-change: transform;"
|
||||
@@ -569,7 +566,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging}
|
||||
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"
|
||||
@@ -577,7 +574,7 @@
|
||||
onmouseleave={() => (hover = false)}
|
||||
>
|
||||
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
|
||||
<div transition:fade={{ duration: 100 }}>
|
||||
<div class="bg-surface rounded-md" transition:fade={{ duration: 100 }}>
|
||||
{#if !testIsLoading}
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import type { FlowNodeColorClasses } from '$lib/components/graph'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let iconWidth: number = $state(0)
|
||||
@@ -12,11 +10,9 @@
|
||||
label?: string
|
||||
path?: string
|
||||
id?: string
|
||||
deletable?: boolean
|
||||
bold?: boolean
|
||||
editId?: boolean
|
||||
disableEditId?: boolean
|
||||
hover?: boolean
|
||||
colorClasses?: FlowNodeColorClasses
|
||||
icon?: import('svelte').Snippet
|
||||
onclick?: () => void
|
||||
@@ -26,11 +22,9 @@
|
||||
label = '',
|
||||
path = '',
|
||||
id = '',
|
||||
deletable = false,
|
||||
bold = false,
|
||||
editId = $bindable(false),
|
||||
disableEditId = false,
|
||||
hover = false,
|
||||
colorClasses,
|
||||
icon,
|
||||
onclick
|
||||
@@ -87,11 +81,6 @@
|
||||
}}
|
||||
>
|
||||
<span class="max-w-full text-2xs truncate flex items-center">
|
||||
{#if !disableEditId && (editId || (hover && deletable))}
|
||||
<span transition:slide={{ axis: 'x', duration: 100 }}>
|
||||
<Pencil size={10} class="mr-1" />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="max-w-12 truncate">
|
||||
{id}
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
|
||||
import { getDependentComponents } from '../flowExplorer'
|
||||
import { getAllModules, getDependentComponents } from '../flowExplorer'
|
||||
import { locateModules, groupByParent } from '../multiSelectUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
@@ -54,6 +54,18 @@
|
||||
} from '../agentToolUtils'
|
||||
import { loadFlowModuleState } from '../flowStateUtils.svelte'
|
||||
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
|
||||
import {
|
||||
GroupedModulesProxy,
|
||||
type ExtendedOpenFlow
|
||||
} from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import {
|
||||
type FlowStructureNode,
|
||||
matchStructureNode,
|
||||
dfsStructure,
|
||||
findInStructure,
|
||||
moduleToStructureNode
|
||||
} from '$lib/components/graph/flowStructure'
|
||||
|
||||
interface Props {
|
||||
sidebarSize?: number | undefined
|
||||
@@ -125,6 +137,8 @@
|
||||
|
||||
// Get NoteEditor context for note position updates
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
const proxy = new GroupedModulesProxy(flowStore as unknown as StateStore<ExtendedOpenFlow>)
|
||||
const groupDisplayState = new GroupDisplayState(() => flowStore.val.value?.groups ?? [])
|
||||
|
||||
$effect(() => {
|
||||
if (!moveManager.movingModuleId) return
|
||||
@@ -139,16 +153,13 @@
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
})
|
||||
|
||||
export async function insertNewModuleAtIndex(
|
||||
modules: FlowModule[] | AgentTool[],
|
||||
index: number,
|
||||
/** Create a new FlowModule without inserting it into any array */
|
||||
async function createNewModule(
|
||||
kind: InsertKind,
|
||||
wsScript?: { path: string; summary: string; hash: string | undefined },
|
||||
wsFlow?: { path: string; summary: string },
|
||||
inlineScript?: InlineScript,
|
||||
toolKind?: SpecialToolKind | 'flowmoduleTool'
|
||||
): Promise<FlowModule[] | AgentTool[]> {
|
||||
push(history, flowStore.val)
|
||||
inlineScript?: InlineScript
|
||||
): Promise<FlowModule> {
|
||||
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
|
||||
let state = emptyFlowModuleState()
|
||||
flowStateStore.val[module.id] = state
|
||||
@@ -196,6 +207,21 @@
|
||||
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
|
||||
export async function insertNewModuleAtIndex(
|
||||
modules: FlowModule[] | AgentTool[],
|
||||
index: number,
|
||||
kind: InsertKind,
|
||||
wsScript?: { path: string; summary: string; hash: string | undefined },
|
||||
wsFlow?: { path: string; summary: string },
|
||||
inlineScript?: InlineScript,
|
||||
toolKind?: SpecialToolKind | 'flowmoduleTool'
|
||||
): Promise<FlowModule[] | AgentTool[]> {
|
||||
push(history, flowStore.val)
|
||||
const module = await createNewModule(kind, wsScript, wsFlow, inlineScript)
|
||||
|
||||
if (!modules) return [module]
|
||||
|
||||
if (toolKind === 'mcpTool') {
|
||||
@@ -220,7 +246,7 @@
|
||||
;(modules as AgentTool[]).splice(index, 0, agentTool)
|
||||
return modules as AgentTool[]
|
||||
} else {
|
||||
// Standard FlowModule insertion (existing behavior)
|
||||
// Standard FlowModule insertion
|
||||
modules.splice(index, 0, module)
|
||||
return modules
|
||||
}
|
||||
@@ -330,6 +356,13 @@
|
||||
let deleteCallback: (() => void) | undefined = $state(undefined)
|
||||
let dependents: Record<string, string[]> = $state({})
|
||||
|
||||
/** Confirmation gate for actions that would empty or duplicate groups */
|
||||
let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] =
|
||||
$state([])
|
||||
let affectedGroupsAction: (() => void) | undefined = $state(undefined)
|
||||
let affectedGroupsCancel: (() => void) | undefined = $state(undefined)
|
||||
let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete')
|
||||
|
||||
let graph: FlowGraphV2 | undefined = $state(undefined)
|
||||
let noteMode = $state(false)
|
||||
let diffManager = $derived(getDiffManager())
|
||||
@@ -361,24 +394,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
const opts = { displayState: groupDisplayState }
|
||||
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
|
||||
for (const id of ids) {
|
||||
const found = findInStructure(tree, id)
|
||||
if (found) found.parentChildren.splice(found.index, 1)
|
||||
}
|
||||
}, opts)
|
||||
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
for (const id of ids) {
|
||||
removeAtId(flowStore.val.value.modules, id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
selectionManager.clearSelection()
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
if (Object.keys(allDeps).length > 0) {
|
||||
dependents = allDeps
|
||||
deleteCallback = cb
|
||||
const proceed = () => {
|
||||
if (Object.keys(allDeps).length > 0) {
|
||||
dependents = allDeps
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'delete'
|
||||
affectedGroupsAction = proceed
|
||||
} else {
|
||||
cb()
|
||||
proceed()
|
||||
}
|
||||
}
|
||||
|
||||
// Operates directly on the flat module array (not the structure tree).
|
||||
// Cloned modules are inserted after the originals, intentionally outside any group.
|
||||
export function duplicateMultiple(ids: string[]) {
|
||||
const locations = locateModules(ids, flowStore.val.value.modules)
|
||||
const groups = groupByParent(locations)
|
||||
@@ -423,6 +478,10 @@
|
||||
moveManager.toggleMovingMultiple(ids)
|
||||
}
|
||||
|
||||
export function createGroup(ids: string[]) {
|
||||
graph?.createGroupFromSelection(ids)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
|
||||
change: void
|
||||
@@ -506,6 +565,40 @@
|
||||
</div>
|
||||
{/each}
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
|
||||
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
|
||||
open={affectedGroupsPending.length > 0}
|
||||
on:confirmed={() => {
|
||||
affectedGroupsAction?.()
|
||||
affectedGroupsPending = []
|
||||
affectedGroupsAction = undefined
|
||||
affectedGroupsCancel = undefined
|
||||
}}
|
||||
on:canceled={() => {
|
||||
affectedGroupsCancel?.()
|
||||
affectedGroupsPending = []
|
||||
affectedGroupsAction = undefined
|
||||
affectedGroupsCancel = undefined
|
||||
}}
|
||||
>
|
||||
{#if affectedGroupsPending.length === 1}
|
||||
{@const group = affectedGroupsPending[0]}
|
||||
<p
|
||||
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
|
||||
Are you sure you want to {affectedGroupsActionLabel} the step?</p
|
||||
>
|
||||
{:else}
|
||||
<p>The following groups will be removed (empty or duplicate):</p>
|
||||
<ul class="list-disc pl-4 mt-1">
|
||||
{#each affectedGroupsPending as group}
|
||||
<li>{group.summary || `${group.start_id} → ${group.end_id}`}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
</Portal>
|
||||
<div class="flex flex-col h-full relative -pt-1" bind:clientWidth={flowPaneWidth}>
|
||||
<div
|
||||
@@ -542,8 +635,12 @@
|
||||
{moveManager}
|
||||
maxHeight={minHeight}
|
||||
modules={flowStore.val.value.modules}
|
||||
groupedModules={proxy.items}
|
||||
groupError={proxy.error}
|
||||
{groupDisplayState}
|
||||
{noteMode}
|
||||
notes={flowStore.val.value.notes}
|
||||
groups={flowStore.val.value.groups}
|
||||
preprocessorModule={flowStore.val.value?.preprocessor_module}
|
||||
failureModule={flowStore.val.value?.failure_module}
|
||||
currentInputSchema={flowStore.val.schema}
|
||||
@@ -563,159 +660,274 @@
|
||||
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
|
||||
onDelete={(id) => {
|
||||
dependents = getDependentComponents(id, flowStore.val)
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
if (id === 'preprocessor') {
|
||||
|
||||
if (id === 'preprocessor') {
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
selectionManager.selectId('Input')
|
||||
flowStore.val.value.preprocessor_module = undefined
|
||||
} else {
|
||||
selectNextId(id)
|
||||
removeAtId(flowStore.val.value.modules, id)
|
||||
refreshStateStore(flowStore)
|
||||
onDelete?.(id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dsOpts = { displayState: groupDisplayState }
|
||||
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
|
||||
const found = findInStructure(tree, id)
|
||||
if (found) found.parentChildren.splice(found.index, 1)
|
||||
}, dsOpts)
|
||||
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
selectNextId(id)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
refreshStateStore(flowStore)
|
||||
onDelete?.(id)
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
const proceed = () => {
|
||||
if (Object.keys(dependents).length > 0) {
|
||||
deleteCallback = cb
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'delete'
|
||||
affectedGroupsAction = proceed
|
||||
} else {
|
||||
cb()
|
||||
proceed()
|
||||
}
|
||||
}}
|
||||
onInsert={async (detail) => {
|
||||
{
|
||||
let originalModules
|
||||
let targetModules
|
||||
if (
|
||||
detail.sourceId == 'Input' ||
|
||||
detail.targetId == 'Result' ||
|
||||
detail.kind == 'trigger'
|
||||
) {
|
||||
targetModules = flowStore.val.value.modules
|
||||
if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return
|
||||
await tick()
|
||||
|
||||
// --- MOVE ---
|
||||
if (moveManager.movingModuleId) {
|
||||
const movedIds = moveManager.movingIds ?? [moveManager.movingModuleId]
|
||||
const movingId = moveManager.movingModuleId
|
||||
|
||||
let mutated = false
|
||||
const moveOpts = { displayState: groupDisplayState }
|
||||
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
|
||||
let originalModules: FlowStructureNode[] | undefined
|
||||
let targetModules: FlowStructureNode[] | undefined
|
||||
|
||||
if (detail.sourceId == 'Input' || detail.targetId == 'Result') {
|
||||
targetModules = tree
|
||||
}
|
||||
dfsStructure(tree, (node, parentArray) => {
|
||||
if (matchStructureNode(node, movingId)) originalModules = parentArray
|
||||
if (detail.branch && matchStructureNode(node, detail.branch.rootId)) {
|
||||
targetModules = node.branches[detail.branch.branch]?.children
|
||||
} else if (
|
||||
matchStructureNode(node, detail.sourceId ?? '') ||
|
||||
matchStructureNode(node, detail.targetId ?? '')
|
||||
) {
|
||||
targetModules = parentArray
|
||||
}
|
||||
})
|
||||
|
||||
if (!originalModules || !targetModules) return
|
||||
|
||||
if (movedIds.length > 1) {
|
||||
const firstIndex = originalModules.findIndex((m) =>
|
||||
matchStructureNode(m, movedIds[0])
|
||||
)
|
||||
if (firstIndex < 0) return
|
||||
const removedModules = originalModules.splice(firstIndex, movedIds.length)
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && firstIndex < detail.index) {
|
||||
insertIndex -= movedIds.length
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, ...removedModules)
|
||||
} else {
|
||||
const indexToRemove = originalModules.findIndex((m) =>
|
||||
matchStructureNode(m, movingId)
|
||||
)
|
||||
if (indexToRemove < 0) return
|
||||
const [removed] = originalModules.splice(indexToRemove, 1)
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && indexToRemove < detail.index)
|
||||
insertIndex -= 1
|
||||
targetModules.splice(insertIndex, 0, removed)
|
||||
}
|
||||
mutated = true
|
||||
}, moveOpts)
|
||||
|
||||
if (!mutated) {
|
||||
moveManager.clearMoving()
|
||||
return
|
||||
}
|
||||
|
||||
dfs(flowStore.val.value.modules, (mod, modules, branches) => {
|
||||
if (mod.id == moveManager.movingModuleId) {
|
||||
originalModules = modules
|
||||
}
|
||||
if (detail.branch) {
|
||||
if (mod.id == detail.branch.rootId) {
|
||||
targetModules = branches[detail.branch.branch]
|
||||
}
|
||||
} else if (mod.id == detail.sourceId || mod.id == detail.targetId) {
|
||||
targetModules = modules
|
||||
} else if (mod.id == detail.agentId && mod.value.type === 'aiagent') {
|
||||
targetModules = mod.value.tools
|
||||
}
|
||||
})
|
||||
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
|
||||
await tick()
|
||||
if (moveManager.movingModuleId) {
|
||||
push(history, flowStore.val)
|
||||
if (!originalModules || !targetModules) {
|
||||
moveManager.clearMoving()
|
||||
return
|
||||
}
|
||||
if (moveManager.movingIds && moveManager.movingIds.length > 1) {
|
||||
// Multi-move: splice out all moving modules from their parent, insert at target
|
||||
const firstIndex = originalModules.findIndex(
|
||||
(m) => m.id === moveManager.movingIds?.[0]
|
||||
)
|
||||
const removedModules = originalModules.splice(
|
||||
firstIndex,
|
||||
moveManager.movingIds.length
|
||||
)
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && firstIndex < detail.index) {
|
||||
insertIndex -= moveManager.movingIds.length
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, ...removedModules)
|
||||
selectionManager.selectByIds(removedModules.map((m) => m.id))
|
||||
} else {
|
||||
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
|
||||
let [removedModule] = originalModules.splice(indexToRemove, 1)
|
||||
// When moving within the same array, removal shifts subsequent indices down by 1
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && indexToRemove < detail.index) {
|
||||
insertIndex -= 1
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, removedModule)
|
||||
selectionManager.selectId(removedModule.id)
|
||||
}
|
||||
moveManager.clearMoving()
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
|
||||
const doMove = () => {
|
||||
push(history, flowStore.val)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
if (movedIds.length > 1) {
|
||||
selectionManager.selectByIds(movedIds)
|
||||
} else {
|
||||
if (detail.isPreprocessor) {
|
||||
await insertNewPreprocessorModule(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
detail.inlineScript,
|
||||
detail.script
|
||||
)
|
||||
selectionManager.selectId('preprocessor')
|
||||
|
||||
if (detail.inlineScript?.instructions) {
|
||||
dispatch('generateStep', {
|
||||
moduleId: 'preprocessor',
|
||||
lang: detail.inlineScript?.language,
|
||||
instructions: detail.inlineScript?.instructions
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0
|
||||
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = detail.agentId
|
||||
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
|
||||
? (detail.kind as SpecialToolKind)
|
||||
: 'flowmoduleTool'
|
||||
: undefined
|
||||
|
||||
await insertNewModuleAtIndex(
|
||||
targetModules,
|
||||
index,
|
||||
detail.kind,
|
||||
detail.script,
|
||||
detail.flow,
|
||||
detail.inlineScript,
|
||||
toolKind
|
||||
)
|
||||
const id = targetModules[index].id
|
||||
selectionManager.selectId(id)
|
||||
|
||||
if (detail.inlineScript?.instructions) {
|
||||
dispatch('generateStep', {
|
||||
moduleId: id,
|
||||
lang: detail.inlineScript?.language,
|
||||
instructions: detail.inlineScript?.instructions
|
||||
})
|
||||
}
|
||||
if (detail.kind == 'trigger') {
|
||||
await insertNewModuleAtIndex(
|
||||
targetModules,
|
||||
index + 1,
|
||||
'forloop',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
setExpr(targetModules[index + 1], `results.${id}`)
|
||||
setScheduledPollSchedule(triggersState, triggersCount)
|
||||
}
|
||||
|
||||
if (detail.flow?.path) {
|
||||
loadLastJob(detail.flow.path, id)
|
||||
} else if (detail.script?.path) {
|
||||
loadLastJob(detail.script?.path, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (['branchone', 'branchall'].includes(detail.kind)) {
|
||||
await addBranch(targetModules[detail.index ?? 0].id)
|
||||
selectionManager.selectId(movingId)
|
||||
}
|
||||
moveManager.clearMoving()
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'move'
|
||||
affectedGroupsAction = doMove
|
||||
affectedGroupsCancel = () => moveManager.clearMoving()
|
||||
} else {
|
||||
doMove()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- INSERT ---
|
||||
if (detail.isPreprocessor) {
|
||||
await insertNewPreprocessorModule(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
detail.inlineScript,
|
||||
detail.script
|
||||
)
|
||||
selectionManager.selectId('preprocessor')
|
||||
if (detail.inlineScript?.instructions) {
|
||||
dispatch('generateStep', {
|
||||
moduleId: 'preprocessor',
|
||||
lang: detail.inlineScript?.language,
|
||||
instructions: detail.inlineScript?.instructions
|
||||
})
|
||||
}
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
return
|
||||
}
|
||||
|
||||
push(history, flowStore.val)
|
||||
|
||||
const isAgentInsert = !!detail.agentId
|
||||
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert
|
||||
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
|
||||
? (detail.kind as SpecialToolKind)
|
||||
: 'flowmoduleTool'
|
||||
: undefined
|
||||
|
||||
// Agent tool inserts operate on the FlowModule's tools array directly
|
||||
if (isAgentInsert) {
|
||||
const agentMod = getAllModules(flowStore.val.value.modules).find(
|
||||
(m) => m.id === detail.agentId
|
||||
)
|
||||
if (agentMod && (agentMod.value as any).tools) {
|
||||
const tools = (agentMod.value as any).tools as AgentTool[]
|
||||
await insertNewModuleAtIndex(
|
||||
tools,
|
||||
tools.length,
|
||||
detail.kind as InsertKind,
|
||||
detail.script,
|
||||
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
|
||||
detail.inlineScript,
|
||||
toolKind
|
||||
)
|
||||
const id = tools[tools.length - 1].id
|
||||
selectionManager.selectId(id)
|
||||
}
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
return
|
||||
}
|
||||
|
||||
// Regular module insert: create the module, then insert a leaf node via tree mutation
|
||||
const module = await createNewModule(
|
||||
detail.kind as InsertKind,
|
||||
detail.script,
|
||||
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
|
||||
detail.inlineScript
|
||||
)
|
||||
const index = detail.index ?? 0
|
||||
const extraModules: FlowModule[] = [module]
|
||||
|
||||
// For trigger inserts, also create the forloop module
|
||||
let loopModule: FlowModule | undefined
|
||||
if (detail.kind == 'trigger') {
|
||||
loopModule = await createNewModule('forloop')
|
||||
setExpr(loopModule, `results.${module.id}`)
|
||||
extraModules.push(loopModule)
|
||||
}
|
||||
|
||||
proxy.applyTreeMutation(
|
||||
(tree) => {
|
||||
// Find target array in the snapshot
|
||||
let targetArray: FlowStructureNode[] | undefined
|
||||
if (
|
||||
detail.sourceId == 'Input' ||
|
||||
detail.targetId == 'Result' ||
|
||||
detail.kind == 'trigger'
|
||||
) {
|
||||
targetArray = tree
|
||||
}
|
||||
dfsStructure(tree, (node, parentArray) => {
|
||||
if (detail.branch && matchStructureNode(node, detail.branch.rootId)) {
|
||||
targetArray = node.branches[detail.branch.branch]?.children
|
||||
} else if (
|
||||
matchStructureNode(node, detail.sourceId ?? '') ||
|
||||
matchStructureNode(node, detail.targetId ?? '')
|
||||
) {
|
||||
targetArray = parentArray
|
||||
}
|
||||
})
|
||||
if (!targetArray) targetArray = tree
|
||||
|
||||
// Insert the structure node (correct kind for containers like branchone/branchall)
|
||||
targetArray.splice(index, 0, moduleToStructureNode(module))
|
||||
|
||||
// For trigger: also insert the forloop node after it
|
||||
if (loopModule) {
|
||||
targetArray.splice(index + 1, 0, moduleToStructureNode(loopModule))
|
||||
}
|
||||
},
|
||||
{ extraModules, displayState: groupDisplayState }
|
||||
)
|
||||
|
||||
selectionManager.selectId(module.id)
|
||||
|
||||
if (detail.inlineScript?.instructions) {
|
||||
dispatch('generateStep', {
|
||||
moduleId: module.id,
|
||||
lang: detail.inlineScript?.language,
|
||||
instructions: detail.inlineScript?.instructions
|
||||
})
|
||||
}
|
||||
if (detail.kind == 'trigger') {
|
||||
setScheduledPollSchedule(triggersState, triggersCount)
|
||||
}
|
||||
if (detail.flow?.path) {
|
||||
loadLastJob(detail.flow.path, module.id)
|
||||
} else if (detail.script?.path) {
|
||||
loadLastJob(detail.script?.path, module.id)
|
||||
}
|
||||
|
||||
if (['branchone', 'branchall'].includes(detail.kind)) {
|
||||
await addBranch(module.id)
|
||||
}
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
}}
|
||||
onNewBranch={async (id) => {
|
||||
if (id) {
|
||||
@@ -761,6 +973,17 @@
|
||||
mod.id = newId
|
||||
}
|
||||
})
|
||||
const groups = flowStore.val.value.groups
|
||||
if (groups) {
|
||||
for (const group of groups) {
|
||||
if (group.start_id === id) {
|
||||
group.start_id = newId
|
||||
}
|
||||
if (group.end_id === id) {
|
||||
group.end_id = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
flowStateStore.val[newId] = flowStateStore.val[id]
|
||||
delete flowStateStore.val[id]
|
||||
refreshStateStore(flowStore)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={classNames(
|
||||
'w-full flex relative rounded-md drop-shadow-base',
|
||||
'w-full flex relative rounded-md drop-shadow-sm',
|
||||
colorClasses.bg,
|
||||
onTop ? 'z-[901]' : '',
|
||||
className
|
||||
|
||||
@@ -170,7 +170,9 @@ export async function saveScheduleFromCfg(
|
||||
tag: scheduleCfg.tag,
|
||||
paused_until: scheduleCfg.paused_until,
|
||||
cron_version: scheduleCfg.cron_version,
|
||||
dynamic_skip: scheduleCfg.dynamic_skip
|
||||
dynamic_skip: scheduleCfg.dynamic_skip,
|
||||
permissioned_as: scheduleCfg.permissioned_as,
|
||||
preserve_permissioned_as: scheduleCfg.preserve_permissioned_as
|
||||
}
|
||||
try {
|
||||
if (edit) {
|
||||
|
||||
@@ -72,11 +72,17 @@ export function evalValue(
|
||||
return v
|
||||
}
|
||||
|
||||
/** Ensure modules comes first and groups last in the value object for readable YAML export. */
|
||||
function reorderFlowValue(value: ExtendedOpenFlow['value']): ExtendedOpenFlow['value'] {
|
||||
const { modules, groups, ...rest } = value
|
||||
return { modules, ...rest, ...(groups != null ? { groups } : {}) }
|
||||
}
|
||||
|
||||
export function filteredContentForExport(flow: ExtendedOpenFlow) {
|
||||
let o = {
|
||||
summary: flow.summary,
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
value: reorderFlowValue(flow.value),
|
||||
schema: flow.schema
|
||||
}
|
||||
if (flow.dedicated_worker) {
|
||||
|
||||
@@ -42,7 +42,12 @@
|
||||
return { x: n.position.x, y: n.position.y }
|
||||
}
|
||||
|
||||
function computeGhost(moduleId: string, draggedNodeIds: Set<string>, allNodes: Node[], allEdges: Edge[]) {
|
||||
function computeGhost(
|
||||
moduleId: string,
|
||||
draggedNodeIds: Set<string>,
|
||||
allNodes: Node[],
|
||||
allEdges: Edge[]
|
||||
) {
|
||||
// Use pre-computed draggedNodeIds when available (covers multi-select),
|
||||
// otherwise fall back to single-module subflow computation.
|
||||
let sfNodes: Node[]
|
||||
@@ -111,7 +116,15 @@
|
||||
zoom: scale
|
||||
}
|
||||
|
||||
return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport }
|
||||
return {
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
ghostNodes,
|
||||
ghostEdges,
|
||||
offsetX,
|
||||
offsetY,
|
||||
initialViewport
|
||||
}
|
||||
}
|
||||
|
||||
let isNearDrop = $derived(moveManager.nearestDropZone != null)
|
||||
@@ -128,7 +141,8 @@
|
||||
class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop
|
||||
? 'bg-surface-accent-primary text-white'
|
||||
: 'bg-surface text-secondary'}"
|
||||
style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
|
||||
style="left: {moveManager.ghostScreenX +
|
||||
CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
|
||||
>
|
||||
<Move size={12} />
|
||||
</div>
|
||||
|
||||
@@ -59,8 +59,22 @@
|
||||
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
|
||||
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
|
||||
import NoteNode from './renderers/nodes/NoteNode.svelte'
|
||||
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
|
||||
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
|
||||
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
|
||||
import NoteTool from './NoteTool.svelte'
|
||||
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
|
||||
import GroupOverlay from './GroupOverlay.svelte'
|
||||
import {
|
||||
GroupDisplayState,
|
||||
getGroupEditorContext,
|
||||
groupKey,
|
||||
type FlowGroup
|
||||
} from './groupEditor.svelte'
|
||||
import { buildStructureTree, computeGroupDepths, type FlowStructureNode } from './flowStructure'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { computeGroupModuleIds } from './groupDetectionUtils'
|
||||
import { getAllModules } from '../flows/flowExplorer'
|
||||
import SelectionTool from './SelectionTool.svelte'
|
||||
import PaneContextMenu from './PaneContextMenu.svelte'
|
||||
import { SelectionManager } from './selectionUtils.svelte'
|
||||
@@ -72,6 +86,7 @@
|
||||
import { compoundLayout } from './compoundLayout'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { AssetWithAltAccessType } from '../assets/lib'
|
||||
import { computeNodeExtraSpace } from './nodeExtraSpace'
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
import { setGraphContext } from './graphContext'
|
||||
import { computeNoteNodes } from './noteUtils.svelte'
|
||||
@@ -100,6 +115,8 @@
|
||||
interface Props {
|
||||
success?: boolean | undefined
|
||||
modules?: FlowModule[] | undefined
|
||||
groupedModules?: FlowStructureNode[]
|
||||
groupError?: unknown
|
||||
failureModule?: FlowModule | undefined
|
||||
preprocessorModule?: FlowModule | undefined
|
||||
minHeight?: number
|
||||
@@ -124,7 +141,7 @@
|
||||
workspace?: string
|
||||
editMode?: boolean
|
||||
allowSimplifiedPoll?: boolean
|
||||
expandedSubflows?: Record<string, FlowModule[]>
|
||||
expandedSubflows?: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>
|
||||
isOwner?: boolean
|
||||
isRunning?: boolean
|
||||
individualStepTests?: boolean
|
||||
@@ -133,6 +150,8 @@
|
||||
suspendStatus?: Record<string, { job: Job; nb: number }>
|
||||
noteMode?: boolean
|
||||
notes?: FlowNote[]
|
||||
groups?: FlowGroup[]
|
||||
groupDisplayState?: GroupDisplayState
|
||||
chatInputEnabled?: boolean
|
||||
multiSelectEnabled?: boolean
|
||||
onDeleteMultiple?: (ids: string[]) => void
|
||||
@@ -152,6 +171,7 @@
|
||||
script?: { path: string; summary: string; hash: string | undefined }
|
||||
flow?: { path: string; summary: string }
|
||||
kind: InsertKind
|
||||
expandGroup?: { groupId: string; position: 'top' | 'bottom' }
|
||||
}) => Promise<void>
|
||||
onNewBranch?: (id: string) => Promise<void>
|
||||
onSelect?: (id: string | FlowModule) => void
|
||||
@@ -193,6 +213,8 @@
|
||||
onSelectedIteration = undefined,
|
||||
success = undefined,
|
||||
modules = [],
|
||||
groupedModules: groupedModulesProp = undefined,
|
||||
groupError = undefined,
|
||||
failureModule = undefined,
|
||||
preprocessorModule = undefined,
|
||||
minHeight = 0,
|
||||
@@ -232,6 +254,8 @@
|
||||
flowHasChanged = false,
|
||||
noteMode = false,
|
||||
notes = undefined,
|
||||
groups = undefined,
|
||||
groupDisplayState: groupDisplayStateProp = undefined,
|
||||
exitNoteMode = undefined,
|
||||
onNotePositionUpdate = undefined,
|
||||
chatInputEnabled = false,
|
||||
@@ -257,6 +281,9 @@
|
||||
() => nodes
|
||||
)
|
||||
|
||||
const groupDisplayState =
|
||||
untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? [])
|
||||
|
||||
// Runtime text height tracking for notes (not stored in FlowNote)
|
||||
let noteTextHeights = $state<Record<string, number>>({})
|
||||
|
||||
@@ -264,6 +291,8 @@
|
||||
let paneContextMenu: PaneContextMenu | undefined = $state(undefined)
|
||||
let flowContainer: HTMLDivElement | undefined = $state(undefined)
|
||||
|
||||
// Hover tracking for group overlay
|
||||
|
||||
// Selection manager - create one if not provided
|
||||
let selectionManager = untrack(() => selectionManagerProp) || new SelectionManager()
|
||||
const selectedId = $derived(selectionManager.getSelectedId())
|
||||
@@ -298,7 +327,9 @@
|
||||
moveManager: untrack(() => moveManager),
|
||||
clearFlowSelection,
|
||||
yOffset,
|
||||
diffManager
|
||||
diffManager,
|
||||
getFlowNodes: () => currentGraphNodeDeps,
|
||||
groupDisplayState
|
||||
} as any)
|
||||
|
||||
if (triggerContext && untrack(() => allowSimplifiedPoll)) {
|
||||
@@ -332,14 +363,36 @@
|
||||
type NodeDep = {
|
||||
id: string
|
||||
parentIds?: string[]
|
||||
data?: { assets?: AssetWithAltAccessType[] }
|
||||
data?: { assets?: AssetWithAltAccessType[]; module?: any }
|
||||
}
|
||||
type NodePos = { position: { x: number; y: number } }
|
||||
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
|
||||
let lastNodes:
|
||||
| [NodeDep[], Map<string, { top: number; bottom: number }> | undefined, (NodeDep & NodePos)[]]
|
||||
| undefined = undefined
|
||||
let currentGraphNodeDeps: { id: string; parentIds?: string[] }[] = $state([])
|
||||
|
||||
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
|
||||
let lastResult = lastNodes?.[1]
|
||||
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
|
||||
// Keep canCreateGroup in sync for consumers (SelectionBoundingBox, FlowSelectionPanel, etc.)
|
||||
const groupEditorCtx = getGroupEditorContext()
|
||||
|
||||
$effect(() => {
|
||||
if (!groupEditorCtx) return
|
||||
const ids = selectionManager.selectedIds
|
||||
groupEditorCtx.canCreateGroup.val =
|
||||
ids.length >= 1 && groupEditorCtx.groupEditor.canCreateGroup(ids, currentGraphNodeDeps)
|
||||
})
|
||||
|
||||
let lastGroupDimensions: Map<string, { width: number; height: number }> | undefined = undefined
|
||||
|
||||
function layoutNodes(
|
||||
nodes: NodeDep[],
|
||||
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
|
||||
): (NodeDep & NodePos)[] {
|
||||
let lastResult = lastNodes?.[2]
|
||||
if (
|
||||
lastResult &&
|
||||
deepEqual(nodes, lastNodes?.[0]) &&
|
||||
deepEqual(nodeExtraSpace, lastNodes?.[1])
|
||||
) {
|
||||
console.debug('layoutNodes', 'same nodes')
|
||||
return lastResult
|
||||
}
|
||||
@@ -352,16 +405,23 @@
|
||||
seenId.push(n.id)
|
||||
}
|
||||
|
||||
// Run recursive compound layout
|
||||
const { positions, bbox } = compoundLayout(nodes, {
|
||||
nodeWidth: NODE.width,
|
||||
nodeHeight: NODE.height,
|
||||
gapH: NODE.gap.horizontal,
|
||||
gapV: NODE.gap.vertical
|
||||
})
|
||||
// Run recursive compound layout with pre-computed extra space
|
||||
const layoutResult = compoundLayout(
|
||||
nodes,
|
||||
{
|
||||
nodeWidth: NODE.width,
|
||||
nodeHeight: NODE.height,
|
||||
gapH: NODE.gap.horizontal,
|
||||
gapV: NODE.gap.vertical
|
||||
},
|
||||
nodeExtraSpace
|
||||
)
|
||||
const { positions, bbox } = layoutResult
|
||||
lastGroupDimensions = layoutResult.groupDimensions
|
||||
|
||||
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
|
||||
|
||||
// Center horizontally
|
||||
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
|
||||
const newNodes = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
position: {
|
||||
@@ -370,7 +430,7 @@
|
||||
}
|
||||
}))
|
||||
|
||||
lastNodes = [nodes, newNodes]
|
||||
lastNodes = [nodes, nodeExtraSpace, newNodes]
|
||||
return newNodes
|
||||
}
|
||||
|
||||
@@ -414,13 +474,16 @@
|
||||
},
|
||||
expandSubflow: async (id: string, path: string) => {
|
||||
const flow = await FlowService.getFlowByPath({ workspace: workspace, path })
|
||||
expandedSubflows[id] = flow.value.modules
|
||||
expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups }
|
||||
expandedSubflows = expandedSubflows
|
||||
},
|
||||
minimizeSubflow: (id: string) => {
|
||||
delete expandedSubflows[id]
|
||||
expandedSubflows = expandedSubflows
|
||||
},
|
||||
expandGroup: (groupId: string) => {
|
||||
groupDisplayState.expandGroup(groupId)
|
||||
},
|
||||
updateMock: (detail) => {
|
||||
onUpdateMock?.(detail)
|
||||
},
|
||||
@@ -585,17 +648,37 @@
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('compute')
|
||||
const graphNodeDeps = Object.values(graph.nodes).map((n) => ({
|
||||
id: n.id,
|
||||
parentIds: n.parentIds,
|
||||
data: { assets: (n.data as any).assets, module: (n.data as any).module }
|
||||
}))
|
||||
currentGraphNodeDeps = graphNodeDeps
|
||||
|
||||
let layoutedNodes = layoutNodes(
|
||||
Object.values(graph.nodes).map((n) => ({
|
||||
id: n.id,
|
||||
parentIds: n.parentIds,
|
||||
data: { assets: (n.data as any).assets }
|
||||
}))
|
||||
)
|
||||
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
|
||||
// Pre-compute extra space per node for assets, AI tools, group notes, group headers
|
||||
const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, {
|
||||
showAssets: $showAssets ?? true,
|
||||
showNotes,
|
||||
notes,
|
||||
noteTextHeights,
|
||||
groupDisplayState,
|
||||
insertable,
|
||||
flowModuleStates
|
||||
})
|
||||
|
||||
// Layout with extra space baked into sugiyama
|
||||
let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace)
|
||||
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => {
|
||||
const merged = { ...n, ...graph.nodes[n.id] }
|
||||
// Augment group head nodes with wrapper dimensions from compound layout
|
||||
if (graph.nodes[n.id]?.type === 'groupHead' && lastGroupDimensions?.has(n.id)) {
|
||||
const dims = lastGroupDimensions.get(n.id)!
|
||||
merged.data = { ...merged.data, wrapperWidth: dims.width, wrapperHeight: dims.height }
|
||||
}
|
||||
return merged
|
||||
})
|
||||
|
||||
// Compute asset visual nodes (no position remapping)
|
||||
let assetNodesResult = $showAssets
|
||||
? computeAssetNodes(
|
||||
newNodes.map((n) => ({
|
||||
@@ -605,25 +688,17 @@
|
||||
}))
|
||||
)
|
||||
: undefined
|
||||
if (assetNodesResult) {
|
||||
newNodes = newNodes.map((n) => ({
|
||||
...n,
|
||||
position: assetNodesResult.newNodePositions[n.id]
|
||||
}))
|
||||
}
|
||||
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
|
||||
let nodesAfterAITools = newNodes.map((n) => ({
|
||||
...n,
|
||||
position: aiToolNodesResult.newNodePositions[n.id]
|
||||
}))
|
||||
|
||||
let finalNodes = [
|
||||
...nodesAfterAITools,
|
||||
// Compute AI tool visual nodes (no position remapping)
|
||||
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
|
||||
|
||||
let finalNodes: (Node & NodeLayout)[] = [
|
||||
...newNodes,
|
||||
...(assetNodesResult?.newAssetNodes ?? []),
|
||||
...aiToolNodesResult.toolNodes
|
||||
]
|
||||
|
||||
// Compute note nodes and positions
|
||||
// Compute note nodes (no position remapping)
|
||||
let noteNodesResult = showNotes
|
||||
? computeNoteNodes(
|
||||
finalNodes.map((n) => ({
|
||||
@@ -644,14 +719,6 @@
|
||||
)
|
||||
: undefined
|
||||
|
||||
// Apply note positioning to nodes if notes are enabled
|
||||
if (noteNodesResult) {
|
||||
finalNodes = finalNodes.map((n) => ({
|
||||
...n,
|
||||
position: noteNodesResult.newNodePositions[n.id] || n.position
|
||||
}))
|
||||
}
|
||||
|
||||
// update nodes
|
||||
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
|
||||
|
||||
@@ -699,7 +766,10 @@
|
||||
assetsOverflowed: AssetsOverflowedNode,
|
||||
aiTool: AiToolNode,
|
||||
newAiTool: NewAiToolNode,
|
||||
note: NoteNode
|
||||
note: NoteNode,
|
||||
collapsedGroup: CollapsedGroupNode,
|
||||
groupHead: GroupHeadNode,
|
||||
groupEnd: GroupEndNode
|
||||
} as any
|
||||
|
||||
const edgeTypes = {
|
||||
@@ -735,7 +805,41 @@
|
||||
let graph = $derived.by(() => {
|
||||
moduleTracker.counter
|
||||
effectiveModuleActions
|
||||
return graphBuilder(
|
||||
currentGroups
|
||||
|
||||
const collapsedGroupIds = new Set(
|
||||
allGroups
|
||||
.filter((g) => groupDisplayState.isRuntimeCollapsed(groupKey(g)))
|
||||
.map((g) => groupKey(g))
|
||||
)
|
||||
|
||||
if (groupError) {
|
||||
return { nodes: {}, edges: [], error: groupError }
|
||||
}
|
||||
|
||||
// Use provided structure tree (from proxy) or build locally (diff mode / read-only)
|
||||
let gm: FlowStructureNode[] | undefined = groupedModulesProp
|
||||
if (!gm) {
|
||||
const allGroups = groups ?? []
|
||||
const graphGroups = allGroups.map((g) => ({
|
||||
...g,
|
||||
id: groupKey(g),
|
||||
moduleIds: untrack(() =>
|
||||
computeGroupModuleIds(g.start_id, g.end_id, getAllModules(effectiveModules ?? []))
|
||||
)
|
||||
}))
|
||||
try {
|
||||
gm = buildStructureTree(
|
||||
stateSnapshot(untrack(() => effectiveModules) ?? []) as FlowModule[],
|
||||
graphGroups
|
||||
)
|
||||
} catch (e) {
|
||||
return { nodes: {}, edges: [], error: e }
|
||||
}
|
||||
}
|
||||
|
||||
const result = graphBuilder(
|
||||
gm,
|
||||
untrack(() => effectiveModules),
|
||||
{
|
||||
disableAi,
|
||||
@@ -767,16 +871,43 @@
|
||||
untrack(() => selectedId),
|
||||
simplifiableFlow,
|
||||
triggerNode ? path : undefined,
|
||||
expandedSubflows
|
||||
expandedSubflows,
|
||||
showNotes,
|
||||
collapsedGroupIds
|
||||
)
|
||||
return { ...result, structureTree: gm }
|
||||
})
|
||||
let hideAssetsToggle = $derived(
|
||||
$showAssets && Object.values(nodes).every((n) => n.type !== 'asset')
|
||||
)
|
||||
let hideNotesToggle = $derived(!notes || notes.length === 0)
|
||||
let hideNotesToggle = $derived(
|
||||
(!notes || notes.length === 0) && !(groups ?? []).some((g) => g.note != null)
|
||||
)
|
||||
|
||||
let currentGroupDepths = $derived(
|
||||
'structureTree' in graph && graph.structureTree ? computeGroupDepths(graph.structureTree) : {}
|
||||
)
|
||||
|
||||
// All groups including those from expanded subflows (for overlay rendering)
|
||||
let allGroups = $derived.by(() => {
|
||||
const base = groups ?? []
|
||||
const subflowGroups = Object.values(expandedSubflows).flatMap((sf) => sf.groups ?? [])
|
||||
return subflowGroups.length > 0 ? [...base, ...subflowGroups] : base
|
||||
})
|
||||
|
||||
// Track groups for re-layout when groups change
|
||||
let currentGroups = $derived(groups ?? [])
|
||||
|
||||
$effect(() => {
|
||||
;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount]
|
||||
;[
|
||||
graph,
|
||||
allowSimplifiedPoll,
|
||||
$showAssets,
|
||||
showNotes,
|
||||
noteManager.renderCount,
|
||||
currentGroups,
|
||||
groupDisplayState.renderCount
|
||||
]
|
||||
untrack(async () => {
|
||||
await updateStores()
|
||||
})
|
||||
@@ -893,6 +1024,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
export function createGroupFromSelection(ids: string[]) {
|
||||
if (groupEditorCtx?.groupEditor) {
|
||||
groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps)
|
||||
tick().then(() => {
|
||||
clearFlowSelection()
|
||||
selectionManager.clearSelection()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const modifierKey = isMac() ? 'Meta' : 'Control'
|
||||
</script>
|
||||
|
||||
@@ -909,7 +1050,7 @@
|
||||
bind:this={flowContainer}
|
||||
>
|
||||
{#if graph?.error}
|
||||
<div class="center-center p-2">
|
||||
<div class="center-center p-2 mt-20">
|
||||
<Alert title="Error parsing the flow" type="error" class="max-w-1/2">
|
||||
{graph.error}
|
||||
|
||||
@@ -1008,6 +1149,12 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<GroupOverlay
|
||||
allNodes={nodesWithOffset as (Node & { type: string })[]}
|
||||
groups={allGroups}
|
||||
groupDepths={currentGroupDepths}
|
||||
/>
|
||||
|
||||
<!-- SelectionTool for handling selection changes and filtering -->
|
||||
<SelectionTool {selectionManager} clearGraphSelection={clearFlowSelection} />
|
||||
|
||||
@@ -1065,7 +1212,7 @@
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'svelvet',
|
||||
encodeState({ modules, failureModule, preprocessorModule, notes })
|
||||
encodeState({ modules, failureModule, preprocessorModule, notes, groups })
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault, stopPropagation } from 'svelte/legacy'
|
||||
import { EllipsisVertical, StickyNote, Ungroup } from 'lucide-svelte'
|
||||
import { NoteColor, NOTE_COLOR_SWATCHES } from './noteColors'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MoveHandleButton from './MoveHandleButton.svelte'
|
||||
import type { MoveManager } from './moveManager.svelte'
|
||||
|
||||
interface Props {
|
||||
note: string | undefined | null
|
||||
color: string | undefined
|
||||
autocollapse: boolean
|
||||
visible?: boolean
|
||||
menuOpen?: boolean
|
||||
moveManager?: MoveManager
|
||||
moveModuleId?: string
|
||||
onMenuOpenChange?: (open: boolean) => void
|
||||
onAddNote: () => void
|
||||
onRemoveNote: () => void
|
||||
onUpdateColor: (color: NoteColor) => void
|
||||
onUpdateAutocollapse: (value: boolean) => void
|
||||
onDeleteGroup?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
note,
|
||||
color,
|
||||
autocollapse,
|
||||
visible = true,
|
||||
menuOpen = $bindable(),
|
||||
moveManager,
|
||||
moveModuleId,
|
||||
onMenuOpenChange,
|
||||
onAddNote,
|
||||
onRemoveNote,
|
||||
onUpdateColor,
|
||||
onUpdateAutocollapse,
|
||||
onDeleteGroup = undefined
|
||||
}: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
onMenuOpenChange?.(menuOpen ?? false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute -translate-y-[100%] top-2 right-0 h-7 p-1 min-w-7 flex flex-row gap-2"
|
||||
style="will-change: transform;"
|
||||
>
|
||||
{#if moveManager && moveModuleId}
|
||||
<MoveHandleButton
|
||||
{moveManager}
|
||||
moduleId={moveModuleId}
|
||||
singleNode
|
||||
{visible}
|
||||
onClickMove={() => moveManager.toggleMoving(moveModuleId!)}
|
||||
/>
|
||||
{/if}
|
||||
{#if note == null}
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
visible ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
onclick={() => onAddNote()}
|
||||
title="Add note"
|
||||
>
|
||||
<StickyNote size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
placement="bottom-end"
|
||||
bind:open={menuOpen}
|
||||
fixedHeight={false}
|
||||
usePointerDownOutside
|
||||
customMenu
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
visible || menuOpen ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md'
|
||||
)}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title="Actions"
|
||||
>
|
||||
<EllipsisVertical size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none py-1"
|
||||
>
|
||||
<!-- Color picker -->
|
||||
<div class="px-4 py-2">
|
||||
<div class="grid grid-cols-5 gap-1">
|
||||
{#each Object.values(NoteColor) as c (c)}
|
||||
<button
|
||||
class="w-6 h-6 rounded-full hover:scale-110 transition-transform duration-100
|
||||
{NOTE_COLOR_SWATCHES[c]}
|
||||
{(color ?? NoteColor.BLUE) === c ? 'ring-2 ring-accent' : 'dark:border-gray-600'}"
|
||||
onclick={() => onUpdateColor(c)}
|
||||
title={c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Autocollapse toggle -->
|
||||
<div class="px-4 py-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={autocollapse}
|
||||
options={{ right: 'Autocollapse' }}
|
||||
on:change={(e) => onUpdateAutocollapse(e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
|
||||
<!-- Add / Remove note -->
|
||||
<button
|
||||
class="px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm"
|
||||
onclick={() => {
|
||||
note == null ? onAddNote() : onRemoveNote()
|
||||
menuOpen = false
|
||||
}}
|
||||
>
|
||||
<StickyNote size={14} class="shrink-0" />
|
||||
<p class="truncate grow min-w-0 whitespace-nowrap text-left"
|
||||
>{note == null ? 'Add note' : 'Remove note'}</p
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if onDeleteGroup}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
|
||||
<!-- Ungroup -->
|
||||
<button
|
||||
class="px-4 py-2 font-normal hover:bg-red-500/10 cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm text-red-600 dark:text-red-400"
|
||||
onclick={() => {
|
||||
onDeleteGroup?.()
|
||||
menuOpen = false
|
||||
}}
|
||||
>
|
||||
<Ungroup size={14} class="shrink-0" />
|
||||
<p class="truncate grow min-w-0 whitespace-nowrap text-left">Ungroup</p>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS, NoteColor } from './noteColors'
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
|
||||
interface Props {
|
||||
summary?: string
|
||||
color?: string
|
||||
collapsed: boolean
|
||||
editMode: boolean
|
||||
onToggleCollapse: () => void
|
||||
onSummaryUpdate?: (text: string) => void
|
||||
}
|
||||
|
||||
let { summary, color, collapsed, editMode, onToggleCollapse, onSummaryUpdate }: Props = $props()
|
||||
|
||||
let colorConfig = $derived(
|
||||
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
|
||||
)
|
||||
|
||||
const PLACEHOLDER = 'Group'
|
||||
|
||||
// Inline summary editing
|
||||
let editingSummary = $state(false)
|
||||
let summaryInput = $state('')
|
||||
let textInputComponent: TextInput | undefined = $state(undefined)
|
||||
|
||||
function startEditingSummary() {
|
||||
if (!editMode) return
|
||||
editingSummary = true
|
||||
summaryInput = summary ?? ''
|
||||
requestAnimationFrame(() => {
|
||||
textInputComponent?.focus()
|
||||
textInputComponent?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveSummary() {
|
||||
editingSummary = false
|
||||
const trimmed = summaryInput.trim()
|
||||
if (trimmed !== (summary ?? '')) {
|
||||
onSummaryUpdate?.(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSummaryKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
saveSummary()
|
||||
} else if (event.key === 'Escape') {
|
||||
editingSummary = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="flex items-center h-[22px] w-full px-2 relative cursor-pointer {colorConfig.background} {colorConfig.text} {collapsed
|
||||
? 'rounded-t-md'
|
||||
: 'rounded-md'}"
|
||||
onclick={stopPropagation(preventDefault(onToggleCollapse))}
|
||||
onpointerdown={stopPropagation(preventDefault(() => {}))}
|
||||
title={collapsed ? 'Expand group' : 'Collapse group'}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center shrink-0 opacity-60 transition-transform duration-100"
|
||||
class:rotate-90={!collapsed}
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
<div class="absolute inset-x-0 flex items-center justify-center h-full pointer-events-none px-7">
|
||||
{#if editingSummary}
|
||||
<div
|
||||
class="input-sizer inline-grid items-center pointer-events-auto text-2xs font-medium max-w-full"
|
||||
data-value={summaryInput || PLACEHOLDER}
|
||||
>
|
||||
<TextInput
|
||||
bind:this={textInputComponent}
|
||||
bind:value={summaryInput}
|
||||
size="xs"
|
||||
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 text-center !min-h-0 !h-auto nodrag nowheel"
|
||||
inputProps={{
|
||||
placeholder: PLACEHOLDER,
|
||||
onblur: saveSummary,
|
||||
onkeydown: handleSummaryKeydown,
|
||||
spellcheck: false,
|
||||
size: 1,
|
||||
style: 'padding: 2px !important; grid-area: 1 / 1'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
|
||||
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
|
||||
: ''}"
|
||||
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
|
||||
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
|
||||
>{summary || PLACEHOLDER}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-sizer::after {
|
||||
content: attr(data-value) ' ';
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
grid-area: 1 / 1;
|
||||
font: inherit;
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import GroupHeader from './GroupHeader.svelte'
|
||||
import GroupNoteArea from './GroupNoteArea.svelte'
|
||||
import GroupActionBar from './GroupActionBar.svelte'
|
||||
import { getGroupEditorContext } from './groupEditor.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
|
||||
interface Props {
|
||||
groupId: string
|
||||
summary?: string
|
||||
note?: string | null
|
||||
color?: string
|
||||
collapsed: boolean
|
||||
autocollapse: boolean
|
||||
editMode: boolean
|
||||
showNotes: boolean
|
||||
}
|
||||
|
||||
let { groupId, summary, note, color, collapsed, autocollapse, editMode, showNotes }: Props =
|
||||
$props()
|
||||
|
||||
const groupEditorContext = getGroupEditorContext()
|
||||
const graphContext = getGraphContext()
|
||||
const moveManager = graphContext?.moveManager
|
||||
|
||||
let moveModuleId = $derived(collapsed ? `collapsed-group:${groupId}` : `group:${groupId}`)
|
||||
|
||||
let hovered = $state(false)
|
||||
let menuOpen = $state(false)
|
||||
let actionBarHovered = $state(false)
|
||||
|
||||
let visible = $derived(hovered || menuOpen || actionBarHovered)
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="nodrag relative"
|
||||
onmouseenter={() => (hovered = true)}
|
||||
onmouseleave={() => (hovered = false)}
|
||||
>
|
||||
<GroupHeader
|
||||
{summary}
|
||||
{color}
|
||||
{collapsed}
|
||||
{editMode}
|
||||
onToggleCollapse={() => graphContext?.groupDisplayState?.toggleRuntimeCollapse(groupId)}
|
||||
onSummaryUpdate={(text) => groupEditorContext?.groupEditor.updateSummary(groupId, text)}
|
||||
/>
|
||||
{#if showNotes && note != null}
|
||||
<GroupNoteArea
|
||||
note={note ?? ''}
|
||||
{color}
|
||||
{collapsed}
|
||||
{editMode}
|
||||
onHeightChange={(h) => graphContext?.groupDisplayState?.setNoteHeight(groupId, h)}
|
||||
onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(groupId, text)}
|
||||
/>
|
||||
{/if}
|
||||
{#if editMode}
|
||||
<GroupActionBar
|
||||
{note}
|
||||
{color}
|
||||
{autocollapse}
|
||||
{visible}
|
||||
{menuOpen}
|
||||
{moveManager}
|
||||
{moveModuleId}
|
||||
onMenuOpenChange={(open) => (menuOpen = open)}
|
||||
onAddNote={() => groupEditorContext?.groupEditor.addNote(groupId)}
|
||||
onRemoveNote={() => groupEditorContext?.groupEditor.removeNote(groupId)}
|
||||
onUpdateColor={(c) => groupEditorContext?.groupEditor.updateColor(groupId, c)}
|
||||
onUpdateAutocollapse={(v) => groupEditorContext?.groupEditor.updateAutocollapse(groupId, v)}
|
||||
onDeleteGroup={() => groupEditorContext?.groupEditor.deleteGroup(groupId)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
import { getNodeColorClasses } from '$lib/components/graph'
|
||||
import type { FlowNodeState } from '$lib/components/graph/util'
|
||||
import type { GraphModuleState } from './model'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { GraphEventHandlers } from './graphBuilder.svelte'
|
||||
|
||||
interface Props {
|
||||
modules: FlowModule[]
|
||||
flowModuleStates?: Record<string, GraphModuleState> | undefined
|
||||
eventHandlers?: GraphEventHandlers
|
||||
}
|
||||
|
||||
let { modules, flowModuleStates, eventHandlers }: Props = $props()
|
||||
|
||||
const { selectionManager } = getGraphContext()
|
||||
|
||||
// Badge width model: icon(16) + pl(2) + gap(2) + pr(6) = 26px fixed + ~5.5px per char
|
||||
const BADGE_FIXED = 26
|
||||
const CHAR_WIDTH = 5.5
|
||||
const BADGE_MAX = 128 // 8rem
|
||||
const GAP = 4 // gap-1
|
||||
const ROW_WIDTH = 255 // 275px container - 2*px-2 padding
|
||||
const OVERFLOW_BTN_WIDTH = 32
|
||||
|
||||
function estimateBadgeWidth(id: string): number {
|
||||
return BADGE_FIXED + id.length * CHAR_WIDTH
|
||||
}
|
||||
|
||||
function totalWidth(mods: FlowModule[], capped: boolean): number {
|
||||
return mods.reduce(
|
||||
(sum, mod, i) =>
|
||||
sum +
|
||||
Math.min(estimateBadgeWidth(mod.id), capped ? BADGE_MAX : Infinity) +
|
||||
(i > 0 ? GAP : 0),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
let { displayModules, overflowModules, capIds } = $derived.by(() => {
|
||||
// Step 1: try all badges with full ids
|
||||
if (totalWidth(modules, false) <= ROW_WIDTH) {
|
||||
return { displayModules: modules, overflowModules: [], capIds: false }
|
||||
}
|
||||
|
||||
// Step 2: try all badges with ids capped at 8rem
|
||||
if (totalWidth(modules, true) <= ROW_WIDTH) {
|
||||
return { displayModules: modules, overflowModules: [], capIds: true }
|
||||
}
|
||||
|
||||
// Step 3: remove last modules until capped badges fit
|
||||
const available = ROW_WIDTH - OVERFLOW_BTN_WIDTH - GAP
|
||||
let count = modules.length
|
||||
while (count > 1 && totalWidth(modules.slice(0, count), true) > available) {
|
||||
count--
|
||||
}
|
||||
|
||||
return {
|
||||
displayModules: modules.slice(0, count),
|
||||
overflowModules: modules.slice(count),
|
||||
capIds: true
|
||||
}
|
||||
})
|
||||
|
||||
const STATE_PRIORITY: Record<string, number> = {
|
||||
WaitingForEvents: 4,
|
||||
InProgress: 3,
|
||||
WaitingForExecutor: 3,
|
||||
Failure: 2,
|
||||
Success: 1
|
||||
}
|
||||
|
||||
let overflowAggregateState = $derived.by<FlowNodeState | undefined>(() => {
|
||||
if (!flowModuleStates) return undefined
|
||||
let best: FlowNodeState | undefined = undefined
|
||||
let bestPriority = 0
|
||||
for (const mod of overflowModules) {
|
||||
const state = flowModuleStates[mod.id]?.type
|
||||
if (state) {
|
||||
const p = STATE_PRIORITY[state] ?? 0
|
||||
if (p > bestPriority) {
|
||||
bestPriority = p
|
||||
best = state
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
})
|
||||
|
||||
function moduleLabel(mod: FlowModule): string {
|
||||
if (mod.summary) return mod.summary
|
||||
const type = mod.value?.type
|
||||
if (type === 'forloopflow') return 'For loop'
|
||||
if (type === 'whileloopflow') return 'While loop'
|
||||
if (type === 'branchone') return 'Run one branch'
|
||||
if (type === 'branchall') return 'Run all branches'
|
||||
if (type === 'flow') return 'Flow'
|
||||
if (type === 'identity') return 'Identity'
|
||||
if (type === 'aiagent') return 'AI Agent'
|
||||
return mod.id
|
||||
}
|
||||
|
||||
function selectModule(mod: FlowModule) {
|
||||
selectionManager.selectId(mod.id)
|
||||
eventHandlers?.select(mod.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#each displayModules as mod (mod.id)}
|
||||
{@const selected = selectionManager.isNodeSelected(mod.id)}
|
||||
{@const nodeState = flowModuleStates?.[mod.id]?.type}
|
||||
{@const colorClasses = getNodeColorClasses(nodeState, selected)}
|
||||
<Tooltip placement="bottom">
|
||||
{#snippet children()}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="h-5 rounded-md overflow-hidden flex items-center gap-0.5 shrink-0 shadow-sm pl-0.5 pr-1.5 cursor-pointer hover:opacity-80 {colorClasses.bg} {colorClasses.outline}"
|
||||
style={capIds ? 'max-width: 8rem;' : ''}
|
||||
onclick={() => selectModule(mod)}
|
||||
>
|
||||
<div class="w-4 h-4 flex items-center justify-center shrink-0">
|
||||
<FlowModuleIcon module={mod} size={12} />
|
||||
</div>
|
||||
<span class="text-3xs font-medium truncate text-right flex-1 {colorClasses.text}"
|
||||
>{mod.id}</span
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet text()}
|
||||
<span class="font-medium">{mod.id}</span>: {moduleLabel(mod)}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/each}
|
||||
{#if overflowModules.length > 0}
|
||||
{@const overflowColorClasses = getNodeColorClasses(overflowAggregateState, false)}
|
||||
<DropdownV2 placement="bottom" customMenu usePointerDownOutside>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="h-5 rounded-md flex items-center justify-center shrink-0 shadow-sm px-1.5 cursor-pointer hover:opacity-80 {overflowColorClasses.bg} {overflowColorClasses.outline}"
|
||||
>
|
||||
<span class="text-3xs font-medium {overflowColorClasses.text}"
|
||||
>+{overflowModules.length}</span
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border rounded-lg shadow-lg py-1 w-56 overflow-y-auto"
|
||||
style="max-height: 50vh;"
|
||||
>
|
||||
{#each overflowModules as mod (mod.id)}
|
||||
{@const nodeState = flowModuleStates?.[mod.id]?.type}
|
||||
{@const colorClasses = getNodeColorClasses(nodeState, false)}
|
||||
{@const selected = selectionManager.isNodeSelected(mod.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-surface-hover text-2xs {selected
|
||||
? 'bg-surface-accent-selected'
|
||||
: ''}"
|
||||
onclick={() => selectModule(mod)}
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 rounded flex items-center justify-center shrink-0 {colorClasses.bg}"
|
||||
>
|
||||
<FlowModuleIcon module={mod} size={12} />
|
||||
</div>
|
||||
<span class="truncate flex-1">{moduleLabel(mod)}</span>
|
||||
<span class="text-tertiary shrink-0">{mod.id}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { Group } from 'lucide-svelte'
|
||||
import { getNodeColorClasses } from '$lib/components/graph'
|
||||
import { NOTE_COLORS, NoteColor } from './noteColors'
|
||||
import { NODE } from './util'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { preventDefault, stopPropagation } from 'svelte/legacy'
|
||||
import GroupNoteArea from './GroupNoteArea.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import GroupModuleIcons from './GroupModuleIcons.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
interface Props {
|
||||
summary?: string
|
||||
selected?: boolean
|
||||
stepCount?: number
|
||||
color?: string
|
||||
note?: string
|
||||
showNote?: boolean
|
||||
editMode?: boolean
|
||||
modules?: FlowModule[]
|
||||
onExpand?: () => void
|
||||
onSummaryUpdate?: (text: string) => void
|
||||
onNoteUpdate?: (text: string) => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
let {
|
||||
summary,
|
||||
selected = false,
|
||||
stepCount,
|
||||
color,
|
||||
note,
|
||||
showNote = false,
|
||||
editMode = false,
|
||||
modules,
|
||||
onExpand,
|
||||
onSummaryUpdate,
|
||||
onNoteUpdate,
|
||||
onHeightChange
|
||||
}: Props = $props()
|
||||
|
||||
let noteColorConfig = $derived(
|
||||
color ? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE]) : undefined
|
||||
)
|
||||
|
||||
let defaultColorClasses = $derived(getNodeColorClasses(undefined, selected))
|
||||
|
||||
// Inline summary editing
|
||||
let editingSummary = $state(false)
|
||||
let summaryInput = $state('')
|
||||
let textInputComponent: TextInput | undefined = $state(undefined)
|
||||
|
||||
function startEditingSummary() {
|
||||
if (!editMode) return
|
||||
editingSummary = true
|
||||
summaryInput = summary ?? ''
|
||||
requestAnimationFrame(() => {
|
||||
textInputComponent?.focus()
|
||||
textInputComponent?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveSummary() {
|
||||
editingSummary = false
|
||||
const trimmed = summaryInput.trim()
|
||||
if (trimmed !== (summary ?? '')) {
|
||||
onSummaryUpdate?.(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSummaryKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
saveSummary()
|
||||
} else if (event.key === 'Escape') {
|
||||
editingSummary = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset height to 0 when note is hidden
|
||||
$effect(() => {
|
||||
if (!showNote) {
|
||||
onHeightChange?.(0)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full module cursor-pointer max-w-full',
|
||||
'shadow-sm rounded-md overflow-clip',
|
||||
'bg-surface-tertiary'
|
||||
)}
|
||||
style="width: {NODE.width}px;"
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
'absolute z-0 outline-offset-0 inset-0',
|
||||
'rounded-md',
|
||||
noteColorConfig ? noteColorConfig.outline : defaultColorClasses.outline
|
||||
)}
|
||||
></div>
|
||||
<div class="flex items-center w-full gap-1.5 px-2 h-[34px] relative z-1">
|
||||
{#if modules && modules.length > 0}
|
||||
<GroupModuleIcons {modules} />
|
||||
{:else}
|
||||
<Group size={14} />
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-x-0 flex items-center justify-center h-[34px] pointer-events-none px-8"
|
||||
>
|
||||
{#if editingSummary}
|
||||
<TextInput
|
||||
bind:this={textInputComponent}
|
||||
bind:value={summaryInput}
|
||||
size="xs"
|
||||
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 w-full text-center !min-h-0 !h-auto nodrag nowheel pointer-events-auto"
|
||||
inputProps={{
|
||||
placeholder: 'Group',
|
||||
onblur: saveSummary,
|
||||
onkeydown: handleSummaryKeydown,
|
||||
spellcheck: false
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
|
||||
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
|
||||
: ''}"
|
||||
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
|
||||
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
|
||||
>{summary || 'Group'}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
{#if stepCount != null}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="text-3xs opacity-60 shrink-0 whitespace-nowrap {noteColorConfig
|
||||
? noteColorConfig.text
|
||||
: ''} {onExpand
|
||||
? 'cursor-pointer hover:opacity-100 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
: ''}"
|
||||
onclick={onExpand ? stopPropagation(preventDefault(onExpand)) : undefined}
|
||||
>{stepCount} node{stepCount !== 1 ? 's' : ''}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showNote}
|
||||
<div class="relative z-1">
|
||||
<GroupNoteArea
|
||||
note={note ?? ''}
|
||||
{color}
|
||||
{editMode}
|
||||
onHeightChange={(h) => onHeightChange?.(h)}
|
||||
onNoteUpdate={(text) => onNoteUpdate?.(text)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { Check, X } from 'lucide-svelte'
|
||||
import { NOTE_COLORS, NoteColor } from './noteColors'
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
|
||||
interface Props {
|
||||
note: string
|
||||
color?: string
|
||||
collapsed?: boolean
|
||||
editMode: boolean
|
||||
onHeightChange: (height: number) => void
|
||||
onNoteUpdate: (text: string) => void
|
||||
}
|
||||
|
||||
let { note, color, collapsed = false, editMode, onHeightChange, onNoteUpdate }: Props = $props()
|
||||
|
||||
let editing = $state(false)
|
||||
let editHeight = $state(0)
|
||||
let textContent = $state('')
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
|
||||
let containerElement: HTMLDivElement | undefined = $state(undefined)
|
||||
|
||||
function autoResize(el: HTMLTextAreaElement) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
let noteColorConfig = $derived(
|
||||
color
|
||||
? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE])
|
||||
: NOTE_COLORS[NoteColor.BLUE]
|
||||
)
|
||||
|
||||
// Measure height and report to parent (skip while editing to avoid full graph rebuilds)
|
||||
$effect(() => {
|
||||
if (containerElement && !editing) {
|
||||
const height = containerElement.clientHeight
|
||||
onHeightChange(height)
|
||||
}
|
||||
})
|
||||
|
||||
// Also observe resize for dynamic content
|
||||
$effect(() => {
|
||||
if (!containerElement) return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
if (editing) return
|
||||
for (const entry of entries) {
|
||||
onHeightChange(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
observer.observe(containerElement)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
onHeightChange(0)
|
||||
}
|
||||
})
|
||||
|
||||
function handleDoubleClick() {
|
||||
if (!editMode) return
|
||||
editHeight = containerElement?.clientHeight ?? 0
|
||||
editing = true
|
||||
textContent = note
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaElement) {
|
||||
autoResize(textareaElement)
|
||||
textareaElement.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
editing = false
|
||||
if (textContent !== note) {
|
||||
onNoteUpdate(textContent)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
editing = false
|
||||
textContent = note
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="nodrag nopan {collapsed ? 'mx-px' : 'w-full rounded-b-md'}"
|
||||
>
|
||||
<div class={collapsed ? 'relative' : 'w-full rounded-b-md relative'}>
|
||||
{#if editing}
|
||||
<div class="absolute top-0 right-1 flex gap-0.5 z-10">
|
||||
<button
|
||||
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
|
||||
onpointerdown={stopPropagation(preventDefault(handleSave))}
|
||||
title="Save (Esc)"
|
||||
>
|
||||
<Check size={11} />
|
||||
</button>
|
||||
<button
|
||||
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
|
||||
onpointerdown={stopPropagation(preventDefault(handleCancel))}
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={textContent}
|
||||
class="w-full shadow-none resize-none !text-2xs overflow-y-auto border-none bg-transparent p-1 nodrag nopan nowheel focus:outline-none select-text {noteColorConfig.text}"
|
||||
style:max-height="max({editHeight}px, 4lh)"
|
||||
oninput={() => textareaElement && autoResize(textareaElement)}
|
||||
placeholder="Write a note (markdown supported)"
|
||||
onblur={handleSave}
|
||||
onkeydown={handleKeydown}
|
||||
onpointerdown={stopPropagation(() => {})}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{:else if note}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {editMode
|
||||
? 'cursor-pointer'
|
||||
: ''}"
|
||||
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
|
||||
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
|
||||
>
|
||||
<GfmMarkdown md={note} noPadding />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="text-2xs italic opacity-60 p-2 {noteColorConfig.text} {editMode
|
||||
? 'cursor-pointer'
|
||||
: ''}"
|
||||
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
|
||||
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
|
||||
>
|
||||
Double click to edit the note
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { ViewportPortal, type Node } from '@xyflow/svelte'
|
||||
import { GROUP_HEADER_HEIGHT, groupKey, type FlowGroup } from './groupEditor.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
import { NoteColor, NOTE_COLORS } from './noteColors'
|
||||
import type { GroupHeadN } from './graphBuilder.svelte'
|
||||
|
||||
interface Props {
|
||||
allNodes: (Node & { type: string })[]
|
||||
groups: FlowGroup[]
|
||||
groupDepths: Record<string, number>
|
||||
}
|
||||
|
||||
let { allNodes, groups, groupDepths }: Props = $props()
|
||||
|
||||
const graphContext = getGraphContext()
|
||||
|
||||
// Pre-compute bounds for all groups reactively (tracks allNodes measured changes)
|
||||
let groupBoundsMap = $derived.by(() => {
|
||||
const map: Record<string, { x: number; y: number; width: number; height: number } | null> = {}
|
||||
const nodeMap = new Map(allNodes.map((n) => [n.id, n]))
|
||||
for (const group of groups) {
|
||||
if (graphContext?.groupDisplayState?.isRuntimeCollapsed(groupKey(group))) {
|
||||
continue
|
||||
}
|
||||
const headId = `group:${groupKey(group)}`
|
||||
const endId = `group:${groupKey(group)}-end`
|
||||
const headNode = nodeMap.get(headId)
|
||||
const endNode = nodeMap.get(endId)
|
||||
|
||||
if (headNode && endNode) {
|
||||
const d = headNode.data as GroupHeadN['data']
|
||||
const headCenterX = headNode.position.x + (headNode.measured?.width ?? 275) / 2
|
||||
const wrapperWidth = d.wrapperWidth ?? 275
|
||||
const headHeight = headNode.measured?.height ?? GROUP_HEADER_HEIGHT
|
||||
const topY = headNode.position.y + headHeight / 2
|
||||
map[groupKey(group)] = {
|
||||
x: headCenterX - wrapperWidth / 2,
|
||||
y: topY,
|
||||
width: wrapperWidth,
|
||||
height: endNode.position.y - topY
|
||||
}
|
||||
} else {
|
||||
map[groupKey(group)] = null
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getOutlineColorClass(color?: string): string {
|
||||
const config =
|
||||
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
|
||||
return config.outline
|
||||
}
|
||||
|
||||
function getBgColorClass(color?: string): string {
|
||||
return (
|
||||
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE]?.backgroundLight ??
|
||||
NOTE_COLORS[NoteColor.BLUE].backgroundLight
|
||||
)
|
||||
}
|
||||
|
||||
const moveManager = graphContext?.moveManager
|
||||
|
||||
function isGroupDragged(groupId: string): boolean {
|
||||
if (!moveManager) return false
|
||||
return (
|
||||
moveManager.draggedNodeIds.has(`group:${groupId}`) ||
|
||||
moveManager.draggedNodeIds.has(`collapsed-group:${groupId}`)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each groups as group (groupKey(group))}
|
||||
{@const bounds = groupBoundsMap[groupKey(group)]}
|
||||
{#if bounds}
|
||||
<ViewportPortal target="back">
|
||||
<div
|
||||
class="absolute rounded-lg outline outline-1 -outline-offset-1 pointer-events-none {getOutlineColorClass(
|
||||
group.color
|
||||
)} {getBgColorClass(group.color)}"
|
||||
class:opacity-30={isGroupDragged(groupKey(group))}
|
||||
style:transform="translate({bounds.x}px, {bounds.y}px)"
|
||||
style:width="{bounds.width}px"
|
||||
style:height="{bounds.height}px"
|
||||
style:z-index={-10 + (groupDepths[groupKey(group)] ?? 0)}
|
||||
></div>
|
||||
</ViewportPortal>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store'
|
||||
import { SvelteFlow, SvelteFlowProvider, type Node, type Edge, type Viewport } from '@xyflow/svelte'
|
||||
import {
|
||||
SvelteFlow,
|
||||
SvelteFlowProvider,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Viewport
|
||||
} from '@xyflow/svelte'
|
||||
import { setGraphContext } from './graphContext'
|
||||
import { SelectionManager } from './selectionUtils.svelte'
|
||||
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
|
||||
@@ -20,19 +26,37 @@
|
||||
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
|
||||
import AiToolNode from './renderers/nodes/AIToolNode.svelte'
|
||||
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
|
||||
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
|
||||
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
|
||||
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
|
||||
import BaseEdge from './renderers/edges/BaseEdge.svelte'
|
||||
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
|
||||
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
|
||||
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
|
||||
|
||||
let {
|
||||
nodes,
|
||||
edges,
|
||||
nodes: nodesProp,
|
||||
edges: edgesProp,
|
||||
width,
|
||||
height,
|
||||
initialViewport
|
||||
}: { nodes: Node[]; edges: Edge[]; width: number; height: number; initialViewport?: Viewport } =
|
||||
$props()
|
||||
}: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
width: number
|
||||
height: number
|
||||
initialViewport?: Viewport
|
||||
} = $props()
|
||||
|
||||
// Use $state.raw to avoid deep reactive proxies that trigger xyflow's performance warning
|
||||
let nodes = $state.raw<Node[]>([])
|
||||
let edges = $state.raw<Edge[]>([])
|
||||
$effect(() => {
|
||||
nodes = [...nodesProp]
|
||||
})
|
||||
$effect(() => {
|
||||
edges = [...edgesProp]
|
||||
})
|
||||
|
||||
setGraphContext({
|
||||
selectionManager: new SelectionManager(),
|
||||
@@ -58,7 +82,10 @@
|
||||
asset: AssetNode,
|
||||
assetsOverflowed: AssetsOverflowedNode,
|
||||
aiTool: AiToolNode,
|
||||
newAiTool: NewAiToolNode
|
||||
newAiTool: NewAiToolNode,
|
||||
collapsedGroup: CollapsedGroupNode,
|
||||
groupHead: GroupHeadNode,
|
||||
groupEnd: GroupEndNode
|
||||
} as any
|
||||
|
||||
const edgeTypes = {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
|
||||
import { StickyNote } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { getNoteEditorContext } from './noteEditor.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
children: Snippet
|
||||
selectedNodeIds: string[]
|
||||
}
|
||||
|
||||
let { children, selectedNodeIds }: Props = $props()
|
||||
|
||||
// Get NoteEditor context for group note creation
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
// Get Graph context for clearFlowSelection function
|
||||
const graphContext = getGraphContext()
|
||||
|
||||
const menuItems: ContextMenuItem[] = $derived([
|
||||
{
|
||||
id: 'create-group-note',
|
||||
label: `Create group note (${selectedNodeIds.length} nodes)`,
|
||||
icon: StickyNote,
|
||||
disabled: selectedNodeIds.length === 0 || !noteEditorContext?.noteEditor,
|
||||
onClick: () => {
|
||||
if (selectedNodeIds.length > 0 && noteEditorContext?.noteEditor && graphContext) {
|
||||
// Create the group note first
|
||||
noteEditorContext.noteEditor.createGroupNote(selectedNodeIds)
|
||||
|
||||
// Wait for next tick to ensure DOM updates
|
||||
tick().then(() => {
|
||||
graphContext?.clearFlowSelection?.()
|
||||
graphContext?.selectionManager.selectId(selectedNodeIds[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1}
|
||||
<ContextMenu items={menuItems}>
|
||||
{@render children()}
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
@@ -10,7 +10,11 @@
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props()
|
||||
let {
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
isOpen = $bindable(false)
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ViewportPortal, type Node } from '@xyflow/svelte'
|
||||
import { calculateNodesBoundsWithOffset } from './util'
|
||||
import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte'
|
||||
import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { getNoteEditorContext } from './noteEditor.svelte'
|
||||
import { getGroupEditorContext } from './groupEditor.svelte'
|
||||
import { getGraphContext } from './graphContext'
|
||||
import MoveHandleButton from './MoveHandleButton.svelte'
|
||||
import { tick } from 'svelte'
|
||||
@@ -36,18 +36,19 @@
|
||||
|
||||
let resolvedCount = $derived(resolvedModuleIds.length)
|
||||
|
||||
// Get NoteEditor context for group note creation
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
// Get GroupEditor context for group creation
|
||||
const groupEditorContext = getGroupEditorContext()
|
||||
// Get Graph context for clearFlowSelection function and moveManager
|
||||
const graphContext = getGraphContext()
|
||||
const moveManager = graphContext?.moveManager
|
||||
|
||||
function handleAddGroupNote() {
|
||||
if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) {
|
||||
// Create the group note first
|
||||
noteEditorContext.noteEditor.createGroupNote(selectedNodes)
|
||||
let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false)
|
||||
|
||||
function handleAddGroup() {
|
||||
if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) {
|
||||
const flowNodes = graphContext.getFlowNodes?.() ?? []
|
||||
groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes)
|
||||
|
||||
// Wait for next tick to ensure DOM updates
|
||||
tick().then(() => {
|
||||
graphContext?.clearFlowSelection?.()
|
||||
graphContext?.selectionManager.clearSelection()
|
||||
@@ -74,13 +75,13 @@
|
||||
shortcut: isMac() ? '⌫' : 'Del',
|
||||
action: () => onDeleteSelected?.()
|
||||
},
|
||||
...(noteEditorContext?.noteEditor
|
||||
...(groupEditorContext?.groupEditor
|
||||
? [
|
||||
{
|
||||
displayName: 'Add note',
|
||||
icon: StickyNote,
|
||||
separatorTop: true,
|
||||
action: handleAddGroupNote
|
||||
displayName: 'Create group',
|
||||
icon: Group,
|
||||
action: handleAddGroup,
|
||||
disabled: !canCreateGroup
|
||||
}
|
||||
]
|
||||
: [])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
|
||||
import { NODE } from './util'
|
||||
import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte'
|
||||
|
||||
type LayoutNode = {
|
||||
id: string
|
||||
@@ -14,7 +15,7 @@ type LayoutConstants = {
|
||||
}
|
||||
|
||||
type CompoundGroup = {
|
||||
type: 'branch' | 'loop'
|
||||
type: 'branch' | 'loop' | 'group'
|
||||
headId: string
|
||||
endId: string
|
||||
branches: {
|
||||
@@ -27,9 +28,12 @@ type LayoutResult = {
|
||||
positions: Map<string, { x: number; y: number }>
|
||||
bbox: { width: number; height: number }
|
||||
contentMinX: number
|
||||
groupDimensions?: Map<string, { width: number; height: number }>
|
||||
}
|
||||
|
||||
const LOOP_INDENT = 25
|
||||
export const GROUP_PADDING = 16
|
||||
export const GROUP_TOP_PADDING = 32
|
||||
|
||||
/**
|
||||
* Detect compound groups from a flat list of node IDs.
|
||||
@@ -83,6 +87,18 @@ function detectGroups(
|
||||
endId: id,
|
||||
branches: [{ labelId: `${baseId}-start`, innerIds }]
|
||||
})
|
||||
} else if (baseId.startsWith('group:')) {
|
||||
// Group pattern: group:{groupId} head + group:{groupId}-end
|
||||
// Body is everything reachable from head to end
|
||||
const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap)
|
||||
if (innerIds.length > 0) {
|
||||
groups.push({
|
||||
type: 'group',
|
||||
headId: baseId,
|
||||
endId: id,
|
||||
branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +246,29 @@ function runSugiyama(
|
||||
* 5. Run sugiyama on the simplified graph
|
||||
* 6. Expand wrapper positions back to absolute positions
|
||||
*/
|
||||
/**
|
||||
* Build nodeSizes map for sugiyama from nodeExtraSpace.
|
||||
* Each node's effective height = top + NODE.height + bottom.
|
||||
*/
|
||||
function buildNodeSizes(
|
||||
nodeIds: string[],
|
||||
constants: LayoutConstants,
|
||||
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
|
||||
): Map<string, { width: number; height: number }> | undefined {
|
||||
if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined
|
||||
const sizes = new Map<string, { width: number; height: number }>()
|
||||
for (const id of nodeIds) {
|
||||
const extra = nodeExtraSpace.get(id)
|
||||
if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) {
|
||||
sizes.set(id, {
|
||||
width: constants.nodeWidth + extra.left + extra.right,
|
||||
height: constants.nodeHeight + extra.top + extra.bottom
|
||||
})
|
||||
}
|
||||
}
|
||||
return sizes.size > 0 ? sizes : undefined
|
||||
}
|
||||
|
||||
const MAX_RECURSION_DEPTH = 50
|
||||
|
||||
function layoutLevel(
|
||||
@@ -237,7 +276,8 @@ function layoutLevel(
|
||||
allNodes: Map<string, LayoutNode>,
|
||||
constants: LayoutConstants,
|
||||
childrenMap: Map<string, string[]>,
|
||||
depth: number = 0
|
||||
depth: number = 0,
|
||||
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
|
||||
): LayoutResult {
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
@@ -256,8 +296,15 @@ function layoutLevel(
|
||||
const n = allNodes.get(id)!
|
||||
return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) }
|
||||
})
|
||||
const result = runSugiyama(flatNodes, constants)
|
||||
const extraSizes = buildNodeSizes(
|
||||
flatNodes.map((n) => n.id),
|
||||
constants,
|
||||
nodeExtraSpace
|
||||
)
|
||||
const result = runSugiyama(flatNodes, constants, extraSizes)
|
||||
for (const [id, pos] of result.positions) {
|
||||
const extra = nodeExtraSpace?.get(id)
|
||||
if (extra) pos.y += extra.top
|
||||
positions.set(id, pos)
|
||||
}
|
||||
return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 }
|
||||
@@ -322,7 +369,14 @@ function layoutLevel(
|
||||
const branchNodeIds = [branch.labelId, ...branch.innerIds]
|
||||
|
||||
// Find sub-groups within this branch
|
||||
const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1)
|
||||
const result = layoutLevel(
|
||||
branchNodeIds,
|
||||
allNodes,
|
||||
constants,
|
||||
childrenMap,
|
||||
depth + 1,
|
||||
nodeExtraSpace
|
||||
)
|
||||
|
||||
branchLayouts.push({
|
||||
labelId: branch.labelId,
|
||||
@@ -349,6 +403,16 @@ function layoutLevel(
|
||||
maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
|
||||
// head row + branch content + end row
|
||||
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
|
||||
} else if (group.type === 'group') {
|
||||
// Group: body is centered with padding on all sides
|
||||
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
|
||||
const bodyHeight = branchLayouts[0]?.bbox.height ?? 0
|
||||
wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth)
|
||||
maxBranchHeight = bodyHeight
|
||||
const headExtra = nodeExtraSpace?.get(group.headId)
|
||||
const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING
|
||||
// head row + body + bottom padding
|
||||
wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING
|
||||
} else {
|
||||
// Loop: body is indented
|
||||
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
|
||||
@@ -395,13 +459,30 @@ function layoutLevel(
|
||||
}
|
||||
|
||||
// Step 5: Run sugiyama on flattened nodes
|
||||
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
|
||||
// Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes
|
||||
const extraSizes = buildNodeSizes(
|
||||
flatNodes.map((n) => n.id),
|
||||
constants,
|
||||
nodeExtraSpace
|
||||
)
|
||||
const mergedSizes = new Map<string, { width: number; height: number }>()
|
||||
if (extraSizes) {
|
||||
for (const [id, size] of extraSizes) mergedSizes.set(id, size)
|
||||
}
|
||||
for (const [id, size] of wrapperSizes) mergedSizes.set(id, size)
|
||||
const sugResult = runSugiyama(
|
||||
flatNodes,
|
||||
constants,
|
||||
mergedSizes.size > 0 ? mergedSizes : undefined
|
||||
)
|
||||
|
||||
// Step 6: Resolve absolute positions
|
||||
// First, set positions for regular (non-group) nodes
|
||||
// Apply per-node y-offset from nodeExtraSpace so decorations above have room
|
||||
for (const [nid, pos] of sugResult.positions) {
|
||||
if (groupByHeadId.has(nid)) continue // Handle groups separately
|
||||
positions.set(nid, { x: pos.x, y: pos.y })
|
||||
const extra = nodeExtraSpace?.get(nid)
|
||||
positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) })
|
||||
}
|
||||
|
||||
// Now expand group wrappers into absolute positions
|
||||
@@ -411,9 +492,15 @@ function layoutLevel(
|
||||
|
||||
const rowHeight = constants.nodeHeight + constants.gapV
|
||||
const isBranch = gl.group.type === 'branch'
|
||||
const isGroup = gl.group.type === 'group'
|
||||
|
||||
// Position the head node at the top-center of the wrapper
|
||||
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
|
||||
// Apply extra top padding so decorations above the head node have room
|
||||
const headExtra = nodeExtraSpace?.get(headId)
|
||||
positions.set(headId, {
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y + (headExtra?.top ?? 0)
|
||||
})
|
||||
|
||||
if (isBranch) {
|
||||
// Reuse cached branchWidths and totalWidth
|
||||
@@ -441,6 +528,26 @@ function layoutLevel(
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV
|
||||
})
|
||||
} else if (isGroup) {
|
||||
// Group: body is centered within wrapper (no x offset)
|
||||
const headExtra = nodeExtraSpace?.get(gl.group.headId)
|
||||
const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING
|
||||
const bl = gl.branchLayouts[0]
|
||||
if (bl) {
|
||||
for (const [innerNodeId, innerPos] of bl.result.positions) {
|
||||
positions.set(innerNodeId, {
|
||||
x: wrapperPos.x + innerPos.x,
|
||||
y: wrapperPos.y + groupHeadRow + innerPos.y
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Position end node below body
|
||||
const bodyHeight = bl?.bbox.height ?? 0
|
||||
positions.set(gl.group.endId, {
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING
|
||||
})
|
||||
} else {
|
||||
// Loop: position start, body, and end
|
||||
const bl = gl.branchLayouts[0]
|
||||
@@ -463,16 +570,34 @@ function layoutLevel(
|
||||
}
|
||||
}
|
||||
|
||||
// Collect group dimensions from this level and child layouts
|
||||
const groupDimensions = new Map<string, { width: number; height: number }>()
|
||||
for (const [headId, gl] of groupLayouts) {
|
||||
groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight })
|
||||
// Propagate child groupDimensions from recursive branch layouts
|
||||
for (const bl of gl.branchLayouts) {
|
||||
if (bl.result.groupDimensions) {
|
||||
for (const [childId, dims] of bl.result.groupDimensions) {
|
||||
groupDimensions.set(childId, dims)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute overall bbox (nodes + group wrapper extents)
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
for (const pos of positions.values()) {
|
||||
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
|
||||
minY = Math.min(minY, pos.y)
|
||||
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
|
||||
for (const [nid, pos] of positions) {
|
||||
// Group end nodes are zero-height markers — skip them
|
||||
if (nid.startsWith('group:') && nid.endsWith('-end')) continue
|
||||
const extra = nodeExtraSpace?.get(nid)
|
||||
minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0))
|
||||
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0))
|
||||
// Account for top decoration space above the node
|
||||
minY = Math.min(minY, pos.y - (extra?.top ?? 0))
|
||||
maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0))
|
||||
}
|
||||
// Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes)
|
||||
for (const [headId, gl] of groupLayouts) {
|
||||
@@ -492,7 +617,12 @@ function layoutLevel(
|
||||
width: Math.max(bboxWidth, constants.nodeWidth),
|
||||
height: Math.max(bboxHeight, 0)
|
||||
}
|
||||
return { positions, bbox: finalBbox, contentMinX }
|
||||
return {
|
||||
positions,
|
||||
bbox: finalBbox,
|
||||
contentMinX,
|
||||
groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,10 +630,16 @@ function layoutLevel(
|
||||
*
|
||||
* Takes the flat list of nodes and edges from graphBuilder and produces
|
||||
* absolute positions that account for compound structure (branches, loops).
|
||||
*
|
||||
* nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout.
|
||||
* After layout, each node's y is shifted down by its top padding so decorations
|
||||
* (assets, AI tools, group headers) have room above. Left/right padding widens the
|
||||
* column allocated to the node so neighbors are pushed further away.
|
||||
*/
|
||||
export function compoundLayout(
|
||||
nodes: { id: string; parentIds?: string[] }[],
|
||||
constants?: Partial<LayoutConstants>
|
||||
constants?: Partial<LayoutConstants>,
|
||||
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
|
||||
): LayoutResult {
|
||||
const c: LayoutConstants = {
|
||||
nodeWidth: constants?.nodeWidth ?? NODE.width,
|
||||
@@ -528,7 +664,7 @@ export function compoundLayout(
|
||||
}
|
||||
|
||||
const nodeIds = nodes.map((n) => n.id)
|
||||
const result = layoutLevel(nodeIds, allNodes, c, childrenMap)
|
||||
const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace)
|
||||
|
||||
// Shift positions so minX=0 (left-aligned).
|
||||
// FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock modules that transitively import CSS/Monaco
|
||||
vi.mock('monaco-editor', () => ({}))
|
||||
vi.mock('@xyflow/svelte', () => ({}))
|
||||
vi.mock('./renderers/nodes/AssetNode.svelte', () => ({
|
||||
assetDisplaysAsOutputInFlowGraph: () => false
|
||||
}))
|
||||
vi.mock('../modulesTest.svelte', () => ({}))
|
||||
|
||||
import type { GraphGroup } from './groupEditor.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import {
|
||||
buildStructureTree,
|
||||
flattenStructureIds,
|
||||
deriveGroupsFromStructure,
|
||||
collectLeafIds,
|
||||
findInStructure
|
||||
} from './flowStructure'
|
||||
|
||||
function makeModule(id: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
value: { type: 'rawscript', content: '', language: 'python3' } as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule {
|
||||
return {
|
||||
id,
|
||||
value: {
|
||||
type: 'branchall',
|
||||
branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) }))
|
||||
} as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeForloop(id: string, innerIds: string[]): FlowModule {
|
||||
return {
|
||||
id,
|
||||
value: {
|
||||
type: 'forloopflow',
|
||||
modules: innerIds.map((iid) => makeModule(iid)),
|
||||
iterator: { type: 'javascript', expr: '' }
|
||||
} as any
|
||||
} as FlowModule
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
id: string,
|
||||
start_id: string,
|
||||
end_id: string,
|
||||
moduleIds: string[] = []
|
||||
): GraphGroup {
|
||||
return { id, start_id, end_id, moduleIds }
|
||||
}
|
||||
|
||||
describe('buildStructureTree', () => {
|
||||
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
|
||||
|
||||
it('builds structure tree for a valid group', () => {
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
|
||||
const result = buildStructureTree(modules, groups)
|
||||
// Should have a group node + the remaining leaf 'c'
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].kind).toBe('group')
|
||||
expect(result[0].id).toBe('g1')
|
||||
expect(result[0].branches[0].children).toHaveLength(2)
|
||||
expect(result[1].kind).toBe('leaf')
|
||||
expect(result[1].id).toBe('c')
|
||||
})
|
||||
|
||||
it('throws on duplicate group IDs', () => {
|
||||
const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i)
|
||||
})
|
||||
|
||||
it('throws on inverted range (start_id after end_id)', () => {
|
||||
const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i)
|
||||
})
|
||||
|
||||
it('throws on partially overlapping groups', () => {
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i)
|
||||
})
|
||||
|
||||
it('throws when group start_id is a virtual node (Input)', () => {
|
||||
const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
|
||||
})
|
||||
|
||||
it('throws when group end_id is a virtual node (Result)', () => {
|
||||
const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
|
||||
})
|
||||
|
||||
it('throws when group references Trigger', () => {
|
||||
const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])]
|
||||
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
|
||||
})
|
||||
|
||||
it('allows fully nested groups', () => {
|
||||
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
|
||||
const groups = [
|
||||
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
|
||||
makeGroup('inner', 'b', 'c', ['b', 'c'])
|
||||
]
|
||||
const result = buildStructureTree(mods, groups)
|
||||
expect(result).toHaveLength(1) // outer group contains everything
|
||||
expect(result[0].kind).toBe('group')
|
||||
// Inner group should be nested
|
||||
const outerChildren = result[0].branches[0].children
|
||||
expect(outerChildren).toHaveLength(3) // a, inner-group, d
|
||||
expect(outerChildren[1].kind).toBe('group')
|
||||
expect(outerChildren[1].id).toBe('inner')
|
||||
})
|
||||
|
||||
it('handles empty modules', () => {
|
||||
const result = buildStructureTree([], [])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles container modules (forloop)', () => {
|
||||
const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')]
|
||||
const result = buildStructureTree(mods, [])
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].kind).toBe('forloopflow')
|
||||
expect(result[0].branches).toHaveLength(1)
|
||||
expect(result[0].branches[0].children).toHaveLength(2)
|
||||
expect(result[0].branches[0].children[0].id).toBe('x')
|
||||
})
|
||||
|
||||
it('handles groups inside containers', () => {
|
||||
const mods = [makeForloop('loop', ['x', 'y', 'z'])]
|
||||
const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])]
|
||||
const result = buildStructureTree(mods, groups)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].kind).toBe('forloopflow')
|
||||
const innerChildren = result[0].branches[0].children
|
||||
expect(innerChildren).toHaveLength(2) // group + z
|
||||
expect(innerChildren[0].kind).toBe('group')
|
||||
expect(innerChildren[0].id).toBe('g1')
|
||||
})
|
||||
|
||||
it('throws when group spans parallel branches (branchall)', () => {
|
||||
const mods = [
|
||||
makeModule('a'),
|
||||
makeBranchAll('ba', [
|
||||
['x', 'y'],
|
||||
['p', 'q']
|
||||
]),
|
||||
makeModule('c')
|
||||
]
|
||||
const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])]
|
||||
expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenStructureIds', () => {
|
||||
it('flattens a simple tree', () => {
|
||||
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
|
||||
const tree = buildStructureTree(modules, groups)
|
||||
const ids = flattenStructureIds(tree)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('flattens nested groups', () => {
|
||||
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
|
||||
const groups = [
|
||||
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
|
||||
makeGroup('inner', 'b', 'c', ['b', 'c'])
|
||||
]
|
||||
const tree = buildStructureTree(mods, groups)
|
||||
const ids = flattenStructureIds(tree)
|
||||
expect(ids).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveGroupsFromStructure', () => {
|
||||
it('derives group definitions with correct start/end', () => {
|
||||
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
|
||||
const tree = buildStructureTree(modules, groups)
|
||||
const derived = deriveGroupsFromStructure(tree)
|
||||
expect(derived).toHaveLength(1)
|
||||
expect(derived[0].start_id).toBe('a')
|
||||
expect(derived[0].end_id).toBe('b')
|
||||
})
|
||||
|
||||
it('derives nested groups', () => {
|
||||
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
|
||||
const groups = [
|
||||
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
|
||||
makeGroup('inner', 'b', 'c', ['b', 'c'])
|
||||
]
|
||||
const tree = buildStructureTree(mods, groups)
|
||||
const derived = deriveGroupsFromStructure(tree)
|
||||
expect(derived).toHaveLength(2)
|
||||
expect(derived[0].start_id).toBe('a')
|
||||
expect(derived[0].end_id).toBe('d')
|
||||
expect(derived[1].start_id).toBe('b')
|
||||
expect(derived[1].end_id).toBe('c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findInStructure', () => {
|
||||
it('finds a leaf node', () => {
|
||||
const modules = [makeModule('a'), makeModule('b')]
|
||||
const tree = buildStructureTree(modules, [])
|
||||
const found = findInStructure(tree, 'b')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.index).toBe(1)
|
||||
})
|
||||
|
||||
it('finds a node inside a group', () => {
|
||||
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
|
||||
const tree = buildStructureTree(modules, groups)
|
||||
const found = findInStructure(tree, 'b')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.index).toBe(1)
|
||||
// parentChildren should be the group's branch children
|
||||
expect(found!.parentChildren).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('finds a group node by group id', () => {
|
||||
const modules = [makeModule('a'), makeModule('b')]
|
||||
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
|
||||
const tree = buildStructureTree(modules, groups)
|
||||
const found = findInStructure(tree, 'g1')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.index).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectLeafIds', () => {
|
||||
it('collects all leaf module IDs including inside containers', () => {
|
||||
const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')]
|
||||
const tree = buildStructureTree(mods, [])
|
||||
const ids = collectLeafIds(tree)
|
||||
expect(ids).toEqual(['loop', 'x', 'y', 'c'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,498 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
import type { FlowGroup, GraphGroup } from './groupEditor.svelte'
|
||||
import { getContainerInnerArrays } from './groupEditor.svelte'
|
||||
import { VIRTUAL_NODE_IDS } from './groupDetectionUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall'
|
||||
|
||||
export type StructureBranch = {
|
||||
label?: string
|
||||
children: FlowStructureNode[]
|
||||
}
|
||||
|
||||
export type FlowStructureNode = {
|
||||
/** FlowModule.id for modules, groupKey(g) for groups */
|
||||
id: string
|
||||
kind: 'leaf' | 'group' | ContainerKind
|
||||
/** Only present when kind === 'group' */
|
||||
group?: FlowGroup
|
||||
/** Only present when kind === 'group' — flat module IDs for step count */
|
||||
moduleIds?: string[]
|
||||
/** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */
|
||||
branches: StructureBranch[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
// Building the structure tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildStructureTree(
|
||||
modules: FlowModule[],
|
||||
groups: GraphGroup[]
|
||||
): FlowStructureNode[] {
|
||||
const { items, consumed } = buildStructureTreeRecurse(modules, groups)
|
||||
const unconsumed = groups.filter((g) => !consumed.has(g.id))
|
||||
if (unconsumed.length > 0) {
|
||||
throw new Error(
|
||||
`Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` +
|
||||
`their start/end nodes do not belong to the same branch`
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function moduleToStructureNode(mod: FlowModule): FlowStructureNode {
|
||||
const innerArrays = getContainerInnerArrays(mod)
|
||||
if (innerArrays.length === 0) {
|
||||
return { id: mod.id, kind: 'leaf', branches: [] }
|
||||
}
|
||||
|
||||
const kind = (mod.value as any).type as ContainerKind
|
||||
const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({
|
||||
label,
|
||||
children: [] // filled later by recursion
|
||||
}))
|
||||
|
||||
return { id: mod.id, kind, branches }
|
||||
}
|
||||
|
||||
function buildStructureTreeRecurse(
|
||||
modules: FlowModule[],
|
||||
groups: GraphGroup[]
|
||||
): { items: FlowStructureNode[]; consumed: Set<string> } {
|
||||
if (modules.length === 0) {
|
||||
return { items: [], consumed: new Set() }
|
||||
}
|
||||
|
||||
const indexMap = new Map<string, number>()
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
indexMap.set(modules[i].id, i)
|
||||
}
|
||||
|
||||
// Reject duplicate group IDs
|
||||
const seenGroupIds = new Set<string>()
|
||||
for (const g of groups) {
|
||||
if (seenGroupIds.has(g.id)) {
|
||||
throw new Error(`Duplicate group id: '${g.id}'`)
|
||||
}
|
||||
seenGroupIds.add(g.id)
|
||||
}
|
||||
|
||||
// Reject groups referencing virtual nodes
|
||||
for (const g of groups) {
|
||||
if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) {
|
||||
throw new Error(
|
||||
`Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Partition: groups for this level vs rest
|
||||
const levelGroups: GraphGroup[] = []
|
||||
const otherGroups: GraphGroup[] = []
|
||||
for (const g of groups) {
|
||||
if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) {
|
||||
const s = indexMap.get(g.start_id)!
|
||||
const e = indexMap.get(g.end_id)!
|
||||
if (s > e) {
|
||||
throw new Error(
|
||||
`Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})`
|
||||
)
|
||||
}
|
||||
levelGroups.push(g)
|
||||
} else {
|
||||
otherGroups.push(g)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no partial overlaps
|
||||
for (let i = 0; i < levelGroups.length; i++) {
|
||||
for (let j = i + 1; j < levelGroups.length; j++) {
|
||||
const a = levelGroups[i]
|
||||
const b = levelGroups[j]
|
||||
const aStart = indexMap.get(a.start_id)!
|
||||
const aEnd = indexMap.get(a.end_id)!
|
||||
const bStart = indexMap.get(b.start_id)!
|
||||
const bEnd = indexMap.get(b.end_id)!
|
||||
|
||||
if (aEnd < bStart || bEnd < aStart) continue
|
||||
if (aStart <= bStart && bEnd <= aEnd) continue
|
||||
if (bStart <= aStart && aEnd <= bEnd) continue
|
||||
|
||||
throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`)
|
||||
}
|
||||
}
|
||||
|
||||
// Build grouped structure for this level
|
||||
function build(
|
||||
startIdx: number,
|
||||
endIdx: number,
|
||||
availableGroups: GraphGroup[]
|
||||
): FlowStructureNode[] {
|
||||
const result: FlowStructureNode[] = []
|
||||
let i = startIdx
|
||||
while (i <= endIdx) {
|
||||
const candidates = availableGroups.filter((g) => {
|
||||
const gStart = indexMap.get(g.start_id)!
|
||||
const gEnd = indexMap.get(g.end_id)!
|
||||
return gStart === i && gEnd <= endIdx
|
||||
})
|
||||
candidates.sort((a, b) => {
|
||||
const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)!
|
||||
const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)!
|
||||
return spanB - spanA
|
||||
})
|
||||
|
||||
const group = candidates[0]
|
||||
if (group) {
|
||||
const gEnd = indexMap.get(group.end_id)!
|
||||
const remaining = availableGroups.filter((g) => g.id !== group.id)
|
||||
const innerNodes = build(i, gEnd, remaining)
|
||||
|
||||
const moduleIds: string[] = []
|
||||
for (let k = i; k <= gEnd; k++) {
|
||||
moduleIds.push(modules[k].id)
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: group.id,
|
||||
kind: 'group',
|
||||
group: {
|
||||
summary: group.summary,
|
||||
note: group.note,
|
||||
color: group.color,
|
||||
autocollapse: group.autocollapse,
|
||||
start_id: group.start_id,
|
||||
end_id: group.end_id
|
||||
},
|
||||
moduleIds,
|
||||
branches: [{ children: innerNodes }]
|
||||
})
|
||||
i = gEnd + 1
|
||||
} else {
|
||||
result.push(moduleToStructureNode(modules[i]))
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const result = build(0, modules.length - 1, levelGroups)
|
||||
|
||||
// Recurse into containers with remaining unconsumed groups
|
||||
const consumed = new Set(levelGroups.map((g) => g.id))
|
||||
let remaining = otherGroups
|
||||
|
||||
function recurseIntoContainers(items: FlowStructureNode[]): void {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'group') {
|
||||
recurseIntoContainers(item.branches[0].children)
|
||||
continue
|
||||
}
|
||||
if (item.branches.length === 0) continue
|
||||
|
||||
// This is a container module — get inner FlowModule arrays and recurse
|
||||
const modIdx = indexMap.get(item.id)
|
||||
if (modIdx === undefined) continue
|
||||
const mod = modules[modIdx]
|
||||
|
||||
const innerArrays = getContainerInnerArrays(mod)
|
||||
for (let bi = 0; bi < innerArrays.length; bi++) {
|
||||
const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining)
|
||||
item.branches[bi] = {
|
||||
label: item.branches[bi]?.label,
|
||||
children: inner.items
|
||||
}
|
||||
for (const id of inner.consumed) consumed.add(id)
|
||||
remaining = remaining.filter((g) => !inner.consumed.has(g.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
recurseIntoContainers(result)
|
||||
|
||||
return { items: result, consumed }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Traversal utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generic DFS over the structure tree */
|
||||
export function dfsStructure(
|
||||
nodes: FlowStructureNode[],
|
||||
fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
fn(node, nodes)
|
||||
for (const branch of node.branches) {
|
||||
dfsStructure(branch.children, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten to ordered module IDs (groups are transparent) */
|
||||
export function flattenStructureIds(nodes: FlowStructureNode[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group') {
|
||||
ids.push(...flattenStructureIds(node.branches[0].children))
|
||||
} else {
|
||||
ids.push(node.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Collect leaf module IDs recursively (including inside containers) */
|
||||
export function collectLeafIds(nodes: FlowStructureNode[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group') {
|
||||
ids.push(...collectLeafIds(node.branches[0].children))
|
||||
} else {
|
||||
ids.push(node.id)
|
||||
for (const branch of node.branches) {
|
||||
ids.push(...collectLeafIds(branch.children))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding nodes in the tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FindResult = { parentChildren: FlowStructureNode[]; index: number }
|
||||
|
||||
export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
if (node.id === id) return { parentChildren: nodes, index: i }
|
||||
for (const branch of node.branches) {
|
||||
const found = findInStructure(branch.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a structure node against a graph node ID.
|
||||
* Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X.
|
||||
*/
|
||||
export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean {
|
||||
if (node.id === nodeId) return true
|
||||
if (node.kind === 'group') {
|
||||
return (
|
||||
nodeId === `group:${node.id}` ||
|
||||
nodeId === `group:${node.id}-end` ||
|
||||
nodeId === `collapsed-group:${node.id}`
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Find insert index using graph node IDs (handles group:X-end etc.).
|
||||
* Returns the index OF the matched item (insert before it).
|
||||
* For group-end nodes, returns index AFTER the group (insert after it).
|
||||
*/
|
||||
export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number {
|
||||
// group-end: insert after the group
|
||||
if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) {
|
||||
const groupId = targetNodeId.slice('group:'.length, -'-end'.length)
|
||||
const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId)
|
||||
return idx >= 0 ? idx + 1 : items.length
|
||||
}
|
||||
// Everything else: insert at the matched item's position
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (matchStructureNode(items[i], targetNodeId)) return i
|
||||
}
|
||||
return items.length
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deriving groups from the structure tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] {
|
||||
const groups: FlowGroup[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group' && node.group) {
|
||||
const flatIds = flattenStructureIds(node.branches[0].children)
|
||||
if (flatIds.length === 0) {
|
||||
console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`)
|
||||
continue
|
||||
}
|
||||
groups.push({
|
||||
...node.group,
|
||||
start_id: flatIds[0],
|
||||
end_id: flatIds[flatIds.length - 1]
|
||||
})
|
||||
// Recurse for nested groups
|
||||
groups.push(...deriveGroupsFromStructure(node.branches[0].children))
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
groups.push(...deriveGroupsFromStructure(branch.children))
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Syncing structure back to FlowModule[]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruct a FlowModule[] from the structure tree, looking up originals
|
||||
* from moduleMap and patching container inner arrays to match the tree ordering.
|
||||
*/
|
||||
export function applyStructureToModules(
|
||||
nodes: FlowStructureNode[],
|
||||
moduleMap: Map<string, FlowModule>
|
||||
): FlowModule[] {
|
||||
const result: FlowModule[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group') {
|
||||
// Groups are transparent — splice their children into this level
|
||||
result.push(...applyStructureToModules(node.branches[0].children, moduleMap))
|
||||
} else {
|
||||
const mod = moduleMap.get(node.id)
|
||||
if (!mod) continue
|
||||
|
||||
// Patch container inner arrays
|
||||
if (node.branches.length > 0) {
|
||||
const innerArrays = getContainerInnerArrays(mod)
|
||||
for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) {
|
||||
innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap))
|
||||
}
|
||||
}
|
||||
|
||||
result.push(mod)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty groups cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk the tree, remove group nodes that have no leaf modules, and return
|
||||
* the removed groups. Mutates the input array in-place.
|
||||
* Recurses depth-first so inner groups are cleaned before checking outer ones.
|
||||
*/
|
||||
export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] {
|
||||
const removed: FlowGroup[] = []
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i]
|
||||
if (node.kind === 'group' && node.group) {
|
||||
// Recurse first — inner groups may become empty too
|
||||
removed.push(...removeEmptyGroups(node.branches[0].children))
|
||||
if (flattenStructureIds(node.branches[0].children).length === 0) {
|
||||
removed.push(node.group)
|
||||
nodes.splice(i, 1)
|
||||
}
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
removed.push(...removeEmptyGroups(branch.children))
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/** Walk the structure tree to compute nesting depth for each group (O(n)). */
|
||||
export function computeGroupDepths(tree: FlowStructureNode[]): Record<string, number> {
|
||||
const depths: Record<string, number> = {}
|
||||
function walk(nodes: FlowStructureNode[], groupDepth: number): void {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group') {
|
||||
depths[node.id] = groupDepth
|
||||
for (const branch of node.branches) {
|
||||
walk(branch.children, groupDepth + 1)
|
||||
}
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
walk(branch.children, groupDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(tree, 0)
|
||||
return depths
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicate groups in the structure tree (same start_id:end_id after mutation).
|
||||
* Returns the groups that should be removed (keeps the first, removes subsequent duplicates).
|
||||
*/
|
||||
export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
|
||||
const duplicates: FlowGroup[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
function walk(items: FlowStructureNode[]): void {
|
||||
for (const node of items) {
|
||||
if (node.kind === 'group' && node.group) {
|
||||
const flatIds = flattenStructureIds(node.branches[0].children)
|
||||
if (flatIds.length > 0) {
|
||||
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
|
||||
if (seen.has(key)) {
|
||||
duplicates.push(node.group)
|
||||
} else {
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
walk(node.branches[0].children)
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
walk(branch.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
return duplicates
|
||||
}
|
||||
|
||||
/** Remove duplicate groups from the structure tree (keeps first occurrence). */
|
||||
export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
|
||||
const removed: FlowGroup[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
function walk(items: FlowStructureNode[]): void {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const node = items[i]
|
||||
if (node.kind === 'group' && node.group) {
|
||||
walk(node.branches[0].children)
|
||||
const flatIds = flattenStructureIds(node.branches[0].children)
|
||||
if (flatIds.length > 0) {
|
||||
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
|
||||
if (seen.has(key)) {
|
||||
// Replace group node with its children (ungroup)
|
||||
removed.push(node.group)
|
||||
items.splice(i, 1, ...node.branches[0].children)
|
||||
} else {
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
walk(branch.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
return removed
|
||||
}
|
||||
@@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib'
|
||||
import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte'
|
||||
import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte'
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
import {
|
||||
type FlowStructureNode,
|
||||
collectLeafIds,
|
||||
findInsertIndexByNodeId,
|
||||
buildStructureTree
|
||||
} from './flowStructure'
|
||||
import { groupKey, type FlowGroup } from './groupEditor.svelte'
|
||||
import { computeGroupModuleIds } from './groupDetectionUtils'
|
||||
|
||||
export type InsertKind =
|
||||
| 'script'
|
||||
@@ -62,6 +70,7 @@ export type GraphEventHandlers = {
|
||||
simplifyFlow: (b: boolean) => void
|
||||
expandSubflow: (id: string, path: string) => void
|
||||
minimizeSubflow: (id: string) => void
|
||||
expandGroup: (groupId: string) => void
|
||||
updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void
|
||||
testUpTo: (id: string) => void
|
||||
editInput: (moduleId: string, key: string) => void
|
||||
@@ -111,6 +120,9 @@ export type FlowNode =
|
||||
| AssetsOverflowedN
|
||||
| AiToolN
|
||||
| NewAiToolN
|
||||
| CollapsedGroupN
|
||||
| GroupHeadN
|
||||
| GroupEndN
|
||||
|
||||
export type InputN = {
|
||||
type: 'input2'
|
||||
@@ -316,6 +328,48 @@ export type NewAiToolN = {
|
||||
}
|
||||
}
|
||||
|
||||
export type CollapsedGroupN = {
|
||||
type: 'collapsedGroup'
|
||||
data: {
|
||||
groupId: string
|
||||
summary: string | undefined
|
||||
note: string | undefined
|
||||
color: string | undefined
|
||||
autocollapse: boolean | undefined
|
||||
stepCount: number
|
||||
modules: FlowModule[]
|
||||
flowModuleStates: Record<string, GraphModuleState> | undefined
|
||||
flowJob: Job | undefined
|
||||
isOwner: boolean
|
||||
suspendStatus: Record<string, { job: Job; nb: number }>
|
||||
showNotes: boolean
|
||||
editMode: boolean
|
||||
eventHandlers: GraphEventHandlers
|
||||
}
|
||||
}
|
||||
|
||||
export type GroupHeadN = {
|
||||
type: 'groupHead'
|
||||
data: {
|
||||
groupId: string
|
||||
summary: string | undefined
|
||||
note: string | undefined
|
||||
color: string | undefined
|
||||
autocollapse: boolean | undefined
|
||||
editMode: boolean
|
||||
showNotes: boolean
|
||||
eventHandlers: GraphEventHandlers
|
||||
wrapperWidth?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type GroupEndN = {
|
||||
type: 'groupEnd'
|
||||
data: {
|
||||
groupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export function topologicalSort(
|
||||
nodes: { id: string; parentIds?: string[] }[]
|
||||
): { id: string; parentIds?: string[] }[] {
|
||||
@@ -336,22 +390,8 @@ export function topologicalSort(
|
||||
return result.reverse()
|
||||
}
|
||||
|
||||
// input2: InputNode,
|
||||
// module: ModuleNode,
|
||||
// branchAllStart: BranchAllStart,
|
||||
// branchAllEnd: BranchAllEndNode,
|
||||
// forLoopEnd: ForLoopEndNode,
|
||||
// forLoopStart: ForLoopStartNode,
|
||||
// result: ResultNode,
|
||||
// whileLoopStart: ForLoopStartNode,
|
||||
// whileLoopEnd: ForLoopEndNode,
|
||||
// branchOneStart: BranchOneStart,
|
||||
// branchOneEnd: BranchOneEndNode,
|
||||
// subflowBound: SubflowBound,
|
||||
// noBranch: NoBranchNode,
|
||||
// trigger: TriggersNode
|
||||
|
||||
export function graphBuilder(
|
||||
structureTree: FlowStructureNode[],
|
||||
modules: FlowModule[] | undefined,
|
||||
extra: {
|
||||
disableAi: boolean
|
||||
@@ -383,11 +423,9 @@ export function graphBuilder(
|
||||
selectedId: string | undefined,
|
||||
simplifiableFlow: SimplifiableFlow | undefined,
|
||||
flowPathForTriggerNode: string | undefined,
|
||||
expandedSubflows: Record<string, FlowModule[]>
|
||||
// triggerProps?: {
|
||||
// path?: string
|
||||
// flowIsSimplifiable?: boolean
|
||||
// }
|
||||
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>,
|
||||
showNotes: boolean,
|
||||
collapsedGroupIds: Set<string>
|
||||
): {
|
||||
nodes: { [key: string]: NodeLayout }
|
||||
edges: Edge[]
|
||||
@@ -403,7 +441,13 @@ export function graphBuilder(
|
||||
const nodes: NodeLayout[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
function addNode(module: FlowModule) {
|
||||
// Lookup map from module ID to the original reactive FlowModule objects.
|
||||
const moduleMap = new Map<string, FlowModule>()
|
||||
for (const m of getAllModules(modules, failureModule)) {
|
||||
moduleMap.set(m.id, m)
|
||||
}
|
||||
|
||||
function addNode(module: FlowModule, extraData?: Record<string, any>) {
|
||||
const duplicated = nodes.find((n) => n.id === module.id)
|
||||
if (duplicated) {
|
||||
console.log('Duplicated node detected: ', module, duplicated)
|
||||
@@ -424,7 +468,8 @@ export function graphBuilder(
|
||||
isOwner: extra.isOwner,
|
||||
flowJob: extra.flowJob,
|
||||
assets: getFlowModuleAssets(module, extra.additionalAssetsMap),
|
||||
moduleAction: extra.moduleActions?.[module.id]
|
||||
moduleAction: extra.moduleActions?.[module.id],
|
||||
...extraData
|
||||
},
|
||||
type: 'module',
|
||||
selectable: true
|
||||
@@ -483,14 +528,20 @@ export function graphBuilder(
|
||||
customId?: string
|
||||
type?: string
|
||||
subModules?: FlowModule[]
|
||||
currentItems?: FlowStructureNode[]
|
||||
disableMoveIds?: string[]
|
||||
}
|
||||
) {
|
||||
parents[targetId] = [...(parents[targetId] ?? []), sourceId]
|
||||
|
||||
const mods = options?.subModules ?? modules
|
||||
|
||||
let index = mods?.findIndex((m) => m.id === targetId) ?? -1
|
||||
let index: number
|
||||
if (options?.currentItems) {
|
||||
index = findInsertIndexByNodeId(options.currentItems, targetId)
|
||||
} else {
|
||||
const mods = options?.subModules ?? modules
|
||||
const found = mods?.findIndex((m) => m.id === targetId) ?? -1
|
||||
index = found >= 0 ? found : (mods?.length ?? 0)
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const recStack = new Set<string>()
|
||||
@@ -514,8 +565,7 @@ export function graphBuilder(
|
||||
simplifiedTriggerView: simplifiableFlow?.simplifiedFlow,
|
||||
disableMoveIds: options?.disableMoveIds,
|
||||
enableTrigger: sourceId === 'Input',
|
||||
// If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array
|
||||
index: index >= 0 ? index : (mods?.length ?? 0),
|
||||
index,
|
||||
...extra,
|
||||
insertable: extra.insertable && !options?.disableInsert && prefix == undefined,
|
||||
shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId)
|
||||
@@ -591,7 +641,7 @@ export function graphBuilder(
|
||||
}
|
||||
|
||||
function processModules(
|
||||
modules: FlowModule[],
|
||||
items: FlowStructureNode[],
|
||||
branch: { rootId: string; branch: number } | undefined,
|
||||
beforeNode: NodeLayout,
|
||||
nextNode: NodeLayout | undefined,
|
||||
@@ -600,31 +650,166 @@ export function graphBuilder(
|
||||
disableMoveIds: string[] = [],
|
||||
parentIndex?: string
|
||||
) {
|
||||
// For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs
|
||||
// (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived)
|
||||
if (prefix != undefined) {
|
||||
modules.forEach((m) => {
|
||||
if (!m['oid']) {
|
||||
m['oid'] = m.id
|
||||
items.forEach((item) => {
|
||||
if (item.kind === 'group') return
|
||||
const m = moduleMap.get(item.id)
|
||||
if (m) {
|
||||
const oid = m['oid'] ?? m.id
|
||||
const newId = 'subflow:' + prefix + oid
|
||||
const clone = { ...m, id: newId, oid } as FlowModule & { oid: string }
|
||||
clone['oid'] = oid
|
||||
moduleMap.set(newId, clone)
|
||||
item.id = newId
|
||||
}
|
||||
m.id = 'subflow:' + prefix + m['oid']
|
||||
})
|
||||
}
|
||||
let previousId: string | undefined = undefined
|
||||
|
||||
if (modules.length === 0) {
|
||||
if (items.length === 0) {
|
||||
if (nextNode) {
|
||||
addEdge(beforeNode.id, nextNode.id, branch, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
} else {
|
||||
modules.forEach((module, index) => {
|
||||
items.forEach((item, index) => {
|
||||
// --- Group items ---
|
||||
if (item.kind === 'group') {
|
||||
const g = item.group!
|
||||
const gId = item.id
|
||||
|
||||
if (collapsedGroupIds.has(gId)) {
|
||||
// Collapsed group: single node
|
||||
const nodeId = `collapsed-group:${gId}`
|
||||
const leafIds = collectLeafIds(item.branches[0].children)
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: {
|
||||
groupId: gId,
|
||||
summary: g.summary,
|
||||
note: g.note,
|
||||
color: g.color,
|
||||
autocollapse: g.autocollapse,
|
||||
stepCount: item.moduleIds?.length ?? 0,
|
||||
modules: leafIds
|
||||
.map((id) => moduleMap.get(id))
|
||||
.filter((m): m is FlowModule => !!m),
|
||||
flowModuleStates: extra.flowModuleStates,
|
||||
flowJob: extra.flowJob,
|
||||
isOwner: extra.isOwner,
|
||||
suspendStatus: extra.suspendStatus,
|
||||
showNotes,
|
||||
editMode: prefix == undefined && extra.editMode,
|
||||
eventHandlers
|
||||
},
|
||||
type: 'collapsedGroup',
|
||||
selectable: false
|
||||
})
|
||||
|
||||
// Wire: previous → collapsedGroup
|
||||
if (index > 0 && previousId) {
|
||||
addEdge(previousId, nodeId, branch, prefix, {
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
|
||||
previousId = nodeId
|
||||
} else {
|
||||
// Expanded group: head → recurse → end
|
||||
const headId = `group:${gId}`
|
||||
const endId = `group:${gId}-end`
|
||||
const localDisableMoveIds = [...disableMoveIds, headId]
|
||||
|
||||
const headNode: NodeLayout = {
|
||||
id: headId,
|
||||
data: {
|
||||
groupId: gId,
|
||||
summary: g.summary,
|
||||
note: g.note,
|
||||
color: g.color,
|
||||
autocollapse: g.autocollapse,
|
||||
editMode: prefix == undefined && extra.editMode,
|
||||
showNotes,
|
||||
eventHandlers
|
||||
},
|
||||
type: 'groupHead',
|
||||
selectable: false
|
||||
}
|
||||
|
||||
const endNode: NodeLayout = {
|
||||
id: endId,
|
||||
data: {
|
||||
groupId: gId
|
||||
},
|
||||
type: 'groupEnd',
|
||||
selectable: false
|
||||
}
|
||||
|
||||
nodes.push(headNode)
|
||||
nodes.push(endNode)
|
||||
|
||||
// Wire: previous → headNode
|
||||
if (index > 0 && previousId) {
|
||||
addEdge(previousId, headId, branch, prefix, {
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
|
||||
// Recurse inner modules
|
||||
processModules(
|
||||
item.branches[0].children,
|
||||
{ rootId: headId, branch: 0 },
|
||||
headNode,
|
||||
endNode,
|
||||
simplifiedTriggerView,
|
||||
prefix,
|
||||
localDisableMoveIds,
|
||||
parentIndex
|
||||
)
|
||||
|
||||
previousId = endId
|
||||
}
|
||||
|
||||
// Shared first/last edge wiring for groups
|
||||
if (index === 0) {
|
||||
addEdge(
|
||||
beforeNode.id,
|
||||
collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`,
|
||||
undefined,
|
||||
prefix,
|
||||
{
|
||||
currentItems: items,
|
||||
disableMoveIds,
|
||||
disableInsert: simplifiedTriggerView
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (index === items.length - 1 && previousId && nextNode) {
|
||||
addEdge(previousId, nextNode.id, branch, prefix, {
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// --- Regular FlowModule items ---
|
||||
const module = moduleMap.get(item.id)
|
||||
if (!module) return
|
||||
const localDisableMoveIds = [...disableMoveIds, module.id]
|
||||
|
||||
// Add the edge between the previous node and the current one
|
||||
// Inter-module edge: connect previous → current (expanded subflows handle their own)
|
||||
if (index > 0 && previousId && expandedSubflows[module.id] == undefined) {
|
||||
addEdge(previousId, module.id, branch, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
@@ -700,7 +885,7 @@ export function graphBuilder(
|
||||
)
|
||||
|
||||
processModules(
|
||||
branch.modules,
|
||||
item.branches[branchIndex]?.children ?? [],
|
||||
{ rootId: module.id, branch: branchIndex },
|
||||
startNode,
|
||||
endNode,
|
||||
@@ -722,7 +907,7 @@ export function graphBuilder(
|
||||
id: `${module.id}-start`,
|
||||
data: {
|
||||
id: module.id,
|
||||
module: module,
|
||||
module: moduleMap.get(module.id) ?? module,
|
||||
simplifiedTriggerView,
|
||||
eventHandlers: eventHandlers,
|
||||
editMode: extra.editMode,
|
||||
@@ -759,7 +944,7 @@ export function graphBuilder(
|
||||
const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex
|
||||
|
||||
processModules(
|
||||
module.value.modules,
|
||||
item.branches[0]?.children ?? [],
|
||||
{ rootId: module.id, branch: 0 },
|
||||
startNode,
|
||||
endNode,
|
||||
@@ -798,7 +983,7 @@ export function graphBuilder(
|
||||
const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex
|
||||
|
||||
processModules(
|
||||
module.value.modules,
|
||||
item.branches[0]?.children ?? [],
|
||||
{ rootId: module.id, branch: 0 },
|
||||
startNode,
|
||||
endNode,
|
||||
@@ -825,21 +1010,6 @@ export function graphBuilder(
|
||||
}
|
||||
nodes.push(endNode)
|
||||
|
||||
// // Add default branch
|
||||
// const defaultBranch: NodeLayout = {
|
||||
// id: `${module.id}-default`,
|
||||
// data: {
|
||||
// offset: 0,
|
||||
// label: 'Default',
|
||||
// id: module.id,
|
||||
// branchIndex: -1,
|
||||
// eventHandlers: eventHandlers,
|
||||
// branchOne: true,
|
||||
// ...extra
|
||||
// },
|
||||
// type: 'noBranch'
|
||||
// }
|
||||
|
||||
const defaultBranch: NodeLayout = {
|
||||
id: `${module.id}-branch-default`,
|
||||
data: {
|
||||
@@ -863,7 +1033,7 @@ export function graphBuilder(
|
||||
})
|
||||
|
||||
processModules(
|
||||
module.value.default,
|
||||
item.branches[0]?.children ?? [],
|
||||
{ rootId: module.id, branch: 0 },
|
||||
defaultBranch,
|
||||
endNode,
|
||||
@@ -899,7 +1069,7 @@ export function graphBuilder(
|
||||
})
|
||||
|
||||
processModules(
|
||||
branch.modules,
|
||||
item.branches[branchIndex + 1]?.children ?? [],
|
||||
{ rootId: module.id, branch: branchIndex + 1 },
|
||||
startNode,
|
||||
endNode,
|
||||
@@ -912,9 +1082,9 @@ export function graphBuilder(
|
||||
|
||||
previousId = endNode.id
|
||||
} else {
|
||||
let expanded = expandedSubflows[module.id]
|
||||
if (expanded) {
|
||||
expanded = $state.snapshot(expanded)
|
||||
const expandedData = expandedSubflows[module.id]
|
||||
if (expandedData) {
|
||||
const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[]
|
||||
const startId = `${module.id}`
|
||||
const idWithoutPrefix = module.id.startsWith('subflow:')
|
||||
? module.id.substring(8)
|
||||
@@ -936,12 +1106,12 @@ export function graphBuilder(
|
||||
|
||||
if (previousId) {
|
||||
addEdge(previousId, startNode.id, branch, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
} else {
|
||||
addEdge(beforeNode.id, startNode.id, undefined, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
@@ -962,8 +1132,20 @@ export function graphBuilder(
|
||||
|
||||
nodes.push(endNode)
|
||||
|
||||
// Register expanded subflow modules so prefix rewriting finds
|
||||
// the inner modules (not the parent flow's modules with same IDs)
|
||||
for (const em of getAllModules(expandedMods)) {
|
||||
moduleMap.set(em.id, em)
|
||||
}
|
||||
|
||||
const expandedGroups = (expandedData.groups ?? []).map((g) => ({
|
||||
...g,
|
||||
id: groupKey(g),
|
||||
moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods))
|
||||
}))
|
||||
|
||||
processModules(
|
||||
expanded,
|
||||
buildStructureTree(expandedMods, expandedGroups),
|
||||
undefined,
|
||||
startNode,
|
||||
endNode,
|
||||
@@ -981,15 +1163,15 @@ export function graphBuilder(
|
||||
|
||||
if (index === 0 && expandedSubflows[module.id] == undefined) {
|
||||
addEdge(beforeNode.id, module.id, undefined, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds,
|
||||
disableInsert: simplifiedTriggerView
|
||||
})
|
||||
}
|
||||
|
||||
if (index === modules.length - 1 && previousId && nextNode) {
|
||||
if (index === items.length - 1 && previousId && nextNode) {
|
||||
addEdge(previousId, nextNode.id, branch, prefix, {
|
||||
subModules: modules,
|
||||
currentItems: items,
|
||||
disableMoveIds
|
||||
})
|
||||
}
|
||||
@@ -997,10 +1179,12 @@ export function graphBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelItems = structureTree
|
||||
|
||||
if (simplifiableFlow?.simplifiedFlow === true && triggerNode) {
|
||||
processModules(modules, undefined, triggerNode, undefined, true, undefined)
|
||||
processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined)
|
||||
} else {
|
||||
processModules(modules, undefined, inputNode, resultNode, false, undefined)
|
||||
processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined)
|
||||
}
|
||||
|
||||
if (failureModule) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte'
|
||||
import type { MoveManager } from './moveManager.svelte'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import type { FlowDiffManager } from '../flows/flowDiffManager.svelte'
|
||||
import type { GroupDisplayState } from './groupEditor.svelte'
|
||||
|
||||
export type GraphContext = {
|
||||
selectionManager: SelectionManager
|
||||
@@ -14,6 +15,9 @@ export type GraphContext = {
|
||||
clearFlowSelection?: () => void
|
||||
yOffset?: number
|
||||
diffManager: FlowDiffManager
|
||||
/** Current flow nodes for group validation (set by FlowGraphV2) */
|
||||
getFlowNodes?: () => { id: string; parentIds?: string[] }[]
|
||||
groupDisplayState?: GroupDisplayState
|
||||
}
|
||||
|
||||
const graphContextKey = 'FlowGraphContext'
|
||||
|
||||
@@ -1,7 +1,127 @@
|
||||
import { topologicalSort } from './graphBuilder.svelte'
|
||||
|
||||
/** Node IDs synthesized by graphBuilder that are not real FlowModules */
|
||||
export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger'])
|
||||
|
||||
type FlowNode = { id: string; parentIds?: string[] }
|
||||
|
||||
/**
|
||||
* Use a simple algorithm to complete a group and split it into connected components
|
||||
* Compute the set of module IDs that belong to a group defined by start_id and end_id.
|
||||
* Uses the flattened module list (from getAllModules) and slices between start and end.
|
||||
* Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping.
|
||||
*/
|
||||
export function computeGroupModuleIds(
|
||||
startId: string,
|
||||
endId: string,
|
||||
allModules: { id: string }[]
|
||||
): string[] {
|
||||
if (startId === endId) {
|
||||
return allModules.some((m) => m.id === startId) ? [startId] : []
|
||||
}
|
||||
|
||||
const startIdx = allModules.findIndex((m) => m.id === startId)
|
||||
const endIdx = allModules.findIndex((m) => m.id === endId)
|
||||
|
||||
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
||||
if (startIdx > endIdx) {
|
||||
console.warn(
|
||||
`computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})`
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return allModules.slice(startIdx, endIdx + 1).map((m) => m.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a set of selected node IDs can form a valid group.
|
||||
* Normalizes marker IDs (branch/forloop) to parent module IDs,
|
||||
* then uses topologicalSort to derive start and end boundaries.
|
||||
*/
|
||||
export function canFormValidGroup(
|
||||
selectedIds: string[],
|
||||
flowNodes: FlowNode[],
|
||||
excludeIds?: Set<string>
|
||||
): { valid: true; startId: string; endId: string } | { valid: false } {
|
||||
if (selectedIds.length === 0) return { valid: false }
|
||||
|
||||
// Normalize marker IDs to parent module IDs.
|
||||
// -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject.
|
||||
const rawSet = new Set(selectedIds)
|
||||
const normalizedIds: string[] = []
|
||||
|
||||
for (const id of selectedIds) {
|
||||
const parentId = id.replace(/-(end|start|branch-.*)$/, '')
|
||||
if (parentId === id) {
|
||||
normalizedIds.push(id)
|
||||
continue
|
||||
}
|
||||
if (id.endsWith('-start')) {
|
||||
normalizedIds.push(parentId)
|
||||
continue
|
||||
}
|
||||
// -end or -branch-*: parent must be covered (directly or via -start)
|
||||
if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) {
|
||||
return { valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedIds.length === 0) return { valid: false }
|
||||
const normalizedSet = new Set(normalizedIds)
|
||||
|
||||
// Topo sort full graph, filter to normalized selection.
|
||||
// Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected.
|
||||
const sorted = topologicalSort(flowNodes)
|
||||
const selectedSorted = sorted.filter((n) => {
|
||||
if (normalizedSet.has(n.id)) return true
|
||||
const parentId = n.id.replace(/-(end|start|branch-.*)$/, '')
|
||||
return parentId !== n.id && normalizedSet.has(parentId)
|
||||
})
|
||||
|
||||
if (selectedSorted.length === 0) return { valid: false }
|
||||
|
||||
// Reject virtual or excluded nodes
|
||||
if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
// Topo order is bottom-first: first = bottom (end), last = top (start).
|
||||
// Use raw IDs for BFS traversal, normalize for the returned group boundaries.
|
||||
const rawStartId = selectedSorted[selectedSorted.length - 1].id
|
||||
const rawEndId = selectedSorted[0].id
|
||||
const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '')
|
||||
const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '')
|
||||
|
||||
// Verify all selected nodes lie between start and end in the DAG.
|
||||
// BFS backward from rawEndId to rawStartId to collect reachable nodes.
|
||||
// Normalize collected IDs so container markers map to their parent module.
|
||||
const between = new Set<string>()
|
||||
const queue = [rawEndId]
|
||||
const visited = new Set<string>()
|
||||
const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []]))
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!
|
||||
if (visited.has(cur)) continue
|
||||
visited.add(cur)
|
||||
const normalized = cur.replace(/-(end|start|branch-.*)$/, '')
|
||||
between.add(cur)
|
||||
between.add(normalized)
|
||||
if (cur === rawStartId) continue
|
||||
for (const p of parentMap.get(cur) ?? []) {
|
||||
queue.push(p)
|
||||
}
|
||||
}
|
||||
if (!normalizedIds.every((id) => between.has(id))) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
return { valid: true, startId, endId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy utility: complete a group and split it into connected components.
|
||||
* Still used by NoteEditor for FlowNote group notes (contained_node_ids).
|
||||
*/
|
||||
export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] {
|
||||
if (groupNodes.length <= 1) {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import type { ExtendedOpenFlow } from '../flows/types'
|
||||
|
||||
import { canFormValidGroup } from './groupDetectionUtils'
|
||||
import type { NoteColor } from './noteColors'
|
||||
import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors'
|
||||
import { getContext, setContext } from 'svelte'
|
||||
|
||||
/**
|
||||
* Type for a flow group (matches the generated type from OpenAPI).
|
||||
* Members are computed dynamically from all nodes on paths between start_id and end_id.
|
||||
*/
|
||||
export type FlowGroup = {
|
||||
summary?: string
|
||||
note?: string
|
||||
autocollapse?: boolean
|
||||
start_id: string
|
||||
end_id: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */
|
||||
export function groupKey(g: { start_id: string; end_id: string }): string {
|
||||
return `${g.start_id}:${g.end_id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Display state for flow groups inside the graph.
|
||||
* Handles runtime collapse state and note height tracking.
|
||||
* Similar to NoteManager — instantiated inside FlowGraphV2.
|
||||
*/
|
||||
export class GroupDisplayState {
|
||||
#getGroups: () => FlowGroup[]
|
||||
#runtimeCollapsedIds = $state<Set<string>>(new Set())
|
||||
#runtimeInitialized = $state(false)
|
||||
#noteHeights = $state<Record<string, number>>({})
|
||||
renderCount = $state(0)
|
||||
|
||||
constructor(getGroups: () => FlowGroup[]) {
|
||||
this.#getGroups = getGroups
|
||||
}
|
||||
|
||||
/** Initialize runtime state from autocollapse. Safe to call from event handlers. */
|
||||
private ensureRuntimeInitialized(): void {
|
||||
if (this.#runtimeInitialized) return
|
||||
const groups = this.#getGroups()
|
||||
this.#runtimeCollapsedIds = new Set(
|
||||
groups.filter((g) => g.autocollapse).map((g) => groupKey(g))
|
||||
)
|
||||
this.#runtimeInitialized = true
|
||||
}
|
||||
|
||||
/** Check if a group is currently collapsed (runtime). Safe to call from $derived. */
|
||||
isRuntimeCollapsed(groupId: string): boolean {
|
||||
if (!this.#runtimeInitialized) {
|
||||
return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false
|
||||
}
|
||||
return this.#runtimeCollapsedIds.has(groupId)
|
||||
}
|
||||
|
||||
/** Toggle runtime collapse (Minimize2 button) */
|
||||
toggleRuntimeCollapse(groupId: string): void {
|
||||
this.ensureRuntimeInitialized()
|
||||
const next = new Set(this.#runtimeCollapsedIds)
|
||||
if (next.has(groupId)) next.delete(groupId)
|
||||
else next.add(groupId)
|
||||
this.#runtimeCollapsedIds = next
|
||||
this.render()
|
||||
}
|
||||
|
||||
/** Expand a group at runtime (CollapsedGroupNode click) */
|
||||
expandGroup(groupId: string): void {
|
||||
this.ensureRuntimeInitialized()
|
||||
const next = new Set(this.#runtimeCollapsedIds)
|
||||
next.delete(groupId)
|
||||
this.#runtimeCollapsedIds = next
|
||||
this.render()
|
||||
}
|
||||
|
||||
/** Set note height for a group (used for layout spacing) */
|
||||
setNoteHeight(groupId: string, height: number): void {
|
||||
if (this.#noteHeights[groupId] !== height) {
|
||||
this.#noteHeights[groupId] = height
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all note heights */
|
||||
getNoteHeights(): Record<string, number> {
|
||||
return this.#noteHeights
|
||||
}
|
||||
|
||||
/** Bump render counter to trigger re-layout */
|
||||
render(): void {
|
||||
this.renderCount++
|
||||
}
|
||||
|
||||
/** Remap runtime state when a group's boundaries (and thus its key) change */
|
||||
remapGroupKey(oldKey: string, newKey: string): void {
|
||||
if (this.#runtimeCollapsedIds.has(oldKey)) {
|
||||
const next = new Set(this.#runtimeCollapsedIds)
|
||||
next.delete(oldKey)
|
||||
next.add(newKey)
|
||||
this.#runtimeCollapsedIds = next
|
||||
}
|
||||
if (oldKey in this.#noteHeights) {
|
||||
this.#noteHeights[newKey] = this.#noteHeights[oldKey]
|
||||
delete this.#noteHeights[oldKey]
|
||||
}
|
||||
}
|
||||
|
||||
/** Get currently collapsed groups for graph builder. Safe to call from $derived. */
|
||||
getCollapsedGroups(): FlowGroup[] {
|
||||
if (!this.#runtimeInitialized) {
|
||||
return this.#getGroups().filter((g) => g.autocollapse)
|
||||
}
|
||||
return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for editing flow groups via direct flowStore mutations.
|
||||
* Follows the same pattern as NoteEditor.
|
||||
*/
|
||||
export class GroupEditor {
|
||||
private flowStore: StateStore<ExtendedOpenFlow>
|
||||
|
||||
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
|
||||
this.flowStore = flowStore
|
||||
}
|
||||
|
||||
getGroups(): FlowGroup[] {
|
||||
return this.flowStore.val.value?.groups || []
|
||||
}
|
||||
|
||||
private setGroups(groups: FlowGroup[]): void {
|
||||
if (this.flowStore.val.value) {
|
||||
this.flowStore.val.value.groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
/** IDs that cannot be part of a group (preprocessor, failure module) */
|
||||
getExcludeIds(): Set<string> {
|
||||
const excludeIds = new Set<string>()
|
||||
const pp = this.flowStore.val.value?.preprocessor_module?.id
|
||||
if (pp) excludeIds.add(pp)
|
||||
const fm = this.flowStore.val.value?.failure_module?.id
|
||||
if (fm) excludeIds.add(fm)
|
||||
return excludeIds
|
||||
}
|
||||
|
||||
/** Check whether the given selection can form a valid group */
|
||||
canCreateGroup(
|
||||
selectedIds: string[],
|
||||
flowNodes: { id: string; parentIds?: string[] }[]
|
||||
): boolean {
|
||||
const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds())
|
||||
if (!result.valid) return false
|
||||
// Reject if a group with the same boundaries already exists
|
||||
return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group from selected node IDs.
|
||||
* Uses canFormValidGroup to determine start_id and end_id.
|
||||
* Returns the generated group ID.
|
||||
*/
|
||||
createGroup(
|
||||
moduleIds: string[],
|
||||
flowNodes: { id: string; parentIds?: string[] }[]
|
||||
): string | undefined {
|
||||
// Filter subflow node IDs (same logic as NoteEditor.createGroupNote)
|
||||
let filteredIds = [...moduleIds]
|
||||
const subflowIds: string[] = []
|
||||
for (const id of moduleIds) {
|
||||
if (id.startsWith('subflow:')) {
|
||||
const match = id.match(/^subflow:([^:]+)/)
|
||||
if (match) {
|
||||
subflowIds.push(match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subflowIds.length > 0) {
|
||||
filteredIds = filteredIds.filter((id) => !subflowIds.includes(id))
|
||||
filteredIds = [...filteredIds, ...subflowIds]
|
||||
}
|
||||
|
||||
const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds())
|
||||
if (!result.valid) return undefined
|
||||
|
||||
const groups = this.getGroups()
|
||||
|
||||
// Reject duplicate: a group with the same boundaries already exists
|
||||
if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) {
|
||||
return undefined
|
||||
}
|
||||
const usedColors = new Set<NoteColor>()
|
||||
for (const group of groups) {
|
||||
if (group.color) {
|
||||
usedColors.add(group.color as NoteColor)
|
||||
}
|
||||
}
|
||||
const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR
|
||||
|
||||
const newGroup: FlowGroup = {
|
||||
start_id: result.startId,
|
||||
end_id: result.endId,
|
||||
color
|
||||
}
|
||||
this.setGroups([...groups, newGroup])
|
||||
return groupKey(newGroup)
|
||||
}
|
||||
|
||||
deleteGroup(groupId: string): void {
|
||||
const groups = this.getGroups()
|
||||
this.setGroups(groups.filter((g) => groupKey(g) !== groupId))
|
||||
}
|
||||
|
||||
updateColor(groupId: string, color: NoteColor): void {
|
||||
const groups = this.getGroups()
|
||||
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g)))
|
||||
}
|
||||
|
||||
updateSummary(groupId: string, summary: string): void {
|
||||
const groups = this.getGroups()
|
||||
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g)))
|
||||
}
|
||||
|
||||
updateNote(groupId: string, note: string | undefined): void {
|
||||
const groups = this.getGroups()
|
||||
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g)))
|
||||
}
|
||||
|
||||
/** Add a note to a group (sets note to empty string to trigger the placeholder UI) */
|
||||
addNote(groupId: string): void {
|
||||
this.updateNote(groupId, '')
|
||||
}
|
||||
|
||||
/** Remove a note from a group */
|
||||
removeNote(groupId: string): void {
|
||||
this.updateNote(groupId, undefined)
|
||||
}
|
||||
|
||||
updateAutocollapse(groupId: string, autocollapse: boolean): void {
|
||||
const groups = this.getGroups()
|
||||
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g)))
|
||||
}
|
||||
}
|
||||
|
||||
export type GroupEditorContext = {
|
||||
groupEditor: GroupEditor
|
||||
canCreateGroup: StateStore<boolean>
|
||||
}
|
||||
|
||||
const CONTEXT_KEY = 'GroupEditorContext'
|
||||
|
||||
export function setGroupEditorContext(
|
||||
groupEditor: GroupEditor,
|
||||
canCreateGroup: StateStore<boolean>
|
||||
): void {
|
||||
setContext<GroupEditorContext>(CONTEXT_KEY, { groupEditor, canCreateGroup })
|
||||
}
|
||||
|
||||
export function getGroupEditorContext(): GroupEditorContext | undefined {
|
||||
return getContext<GroupEditorContext | undefined>(CONTEXT_KEY)
|
||||
}
|
||||
|
||||
/** Height of the group header bar */
|
||||
export const GROUP_HEADER_HEIGHT = 22
|
||||
|
||||
/** Extra margin between the header and the first node */
|
||||
export const GROUP_TOP_MARGIN = 30
|
||||
|
||||
export type GraphGroup = FlowGroup & {
|
||||
id: string
|
||||
moduleIds: string[]
|
||||
}
|
||||
|
||||
export type ContainerInnerArray = {
|
||||
get: () => FlowModule[]
|
||||
set: (v: any) => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** Get inner arrays from a container FlowModule with direct get/set accessors. */
|
||||
export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] {
|
||||
const val = mod.value as any
|
||||
if (val.type === 'forloopflow' || val.type === 'whileloopflow') {
|
||||
return [
|
||||
{
|
||||
get: () => val.modules,
|
||||
set: (v) => {
|
||||
val.modules = v
|
||||
}
|
||||
}
|
||||
]
|
||||
} else if (val.type === 'branchone') {
|
||||
return [
|
||||
{
|
||||
get: () => val.default,
|
||||
set: (v) => {
|
||||
val.default = v
|
||||
},
|
||||
label: 'Default'
|
||||
},
|
||||
...val.branches.map((b: any, i: number) => ({
|
||||
get: () => b.modules,
|
||||
set: (v: any) => {
|
||||
b.modules = v
|
||||
},
|
||||
label: b.summary || `Branch ${i + 1}`
|
||||
}))
|
||||
]
|
||||
} else if (val.type === 'branchall') {
|
||||
return val.branches.map((b: any, i: number) => ({
|
||||
get: () => b.modules,
|
||||
set: (v: any) => {
|
||||
b.modules = v
|
||||
},
|
||||
label: b.summary || `Branch ${i + 1}`
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { untrack } from 'svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import { getAllModules } from '../flows/flowExplorer'
|
||||
import { computeGroupModuleIds } from './groupDetectionUtils'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import {
|
||||
buildStructureTree,
|
||||
deriveGroupsFromStructure,
|
||||
applyStructureToModules,
|
||||
removeEmptyGroups,
|
||||
findDuplicateGroups,
|
||||
removeDuplicateGroups,
|
||||
flattenStructureIds,
|
||||
type FlowStructureNode
|
||||
} from './flowStructure'
|
||||
|
||||
export type ExtendedOpenFlow = {
|
||||
value: {
|
||||
modules: FlowModule[]
|
||||
groups?: FlowGroup[]
|
||||
[key: string]: any
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive read-only view of the flow structure tree.
|
||||
* The tree is always derived from flowStore (single source of truth).
|
||||
* Mutations go through prepareMutation: snapshot → mutate → clean empty groups → commit.
|
||||
*/
|
||||
export class GroupedModulesProxy {
|
||||
#items = $state<FlowStructureNode[]>([])
|
||||
#error = $state<unknown>(undefined)
|
||||
#flowStore: StateStore<ExtendedOpenFlow>
|
||||
|
||||
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
|
||||
this.#flowStore = flowStore
|
||||
this.rebuild()
|
||||
|
||||
// Rebuild tree whenever store changes (undo/load/mutation)
|
||||
$effect(() => {
|
||||
void flowStore.val.value.modules
|
||||
void flowStore.val.value.groups
|
||||
untrack(() => this.rebuild())
|
||||
})
|
||||
}
|
||||
|
||||
/** Reactive access to the structure tree (read-only view) */
|
||||
get items(): FlowStructureNode[] {
|
||||
return this.#items
|
||||
}
|
||||
|
||||
/** Reactive access to build errors */
|
||||
get error(): unknown {
|
||||
return this.#error
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a structural mutation without writing to the store yet.
|
||||
* Returns the list of groups that became empty (already removed from the snapshot)
|
||||
* and a `commit` function that writes the result to the store.
|
||||
*
|
||||
* If no groups were emptied, the caller can commit immediately.
|
||||
* If groups were emptied, the caller should show a confirmation modal
|
||||
* and call commit() only on user confirmation.
|
||||
*/
|
||||
prepareMutation(
|
||||
mutate: (tree: FlowStructureNode[]) => void,
|
||||
opts?: {
|
||||
extraModules?: FlowModule[]
|
||||
displayState?: import('./groupEditor.svelte').GroupDisplayState
|
||||
}
|
||||
): {
|
||||
emptiedGroups: FlowGroup[]
|
||||
duplicateGroups: FlowGroup[]
|
||||
commit: (commitOpts?: { removeDuplicates?: boolean }) => void
|
||||
} {
|
||||
const snapshot = $state.snapshot(this.#items) as FlowStructureNode[]
|
||||
mutate(snapshot)
|
||||
|
||||
// Clean up empty groups and collect which ones were removed
|
||||
const emptiedGroups = removeEmptyGroups(snapshot)
|
||||
// Detect groups that became duplicates after the mutation
|
||||
const duplicateGroups = findDuplicateGroups(snapshot)
|
||||
|
||||
const commit = (commitOpts?: { removeDuplicates?: boolean }) => {
|
||||
if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) {
|
||||
removeDuplicateGroups(snapshot)
|
||||
}
|
||||
|
||||
// Remap runtime state for groups whose boundaries shifted
|
||||
if (opts?.displayState) {
|
||||
this.#remapChangedGroupKeys(snapshot, opts.displayState)
|
||||
}
|
||||
|
||||
// Build moduleMap lazily at commit time so it reflects the latest store state
|
||||
const moduleMap = new Map<string, FlowModule>()
|
||||
for (const m of getAllModules(this.#flowStore.val.value.modules)) {
|
||||
moduleMap.set(m.id, m)
|
||||
}
|
||||
if (opts?.extraModules) {
|
||||
for (const m of opts.extraModules) {
|
||||
moduleMap.set(m.id, m)
|
||||
}
|
||||
}
|
||||
this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap)
|
||||
this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot)
|
||||
}
|
||||
|
||||
return { emptiedGroups, duplicateGroups, commit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: prepare + auto-commit. Only use for mutations that cannot
|
||||
* empty groups (e.g. inserts). Throws if groups are unexpectedly emptied.
|
||||
* For mutations that may empty groups, use prepareMutation() directly.
|
||||
*/
|
||||
applyTreeMutation(
|
||||
mutate: (tree: FlowStructureNode[]) => void,
|
||||
opts?: {
|
||||
extraModules?: FlowModule[]
|
||||
displayState?: import('./groupEditor.svelte').GroupDisplayState
|
||||
}
|
||||
): void {
|
||||
const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts)
|
||||
if (emptiedGroups.length > 0) {
|
||||
console.error('applyTreeMutation: unexpected empty groups', emptiedGroups)
|
||||
}
|
||||
if (duplicateGroups.length > 0) {
|
||||
console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups)
|
||||
}
|
||||
commit()
|
||||
}
|
||||
|
||||
/** Remap runtime state for group nodes whose boundaries shifted after a mutation. */
|
||||
#remapChangedGroupKeys(
|
||||
snapshot: FlowStructureNode[],
|
||||
displayState: import('./groupEditor.svelte').GroupDisplayState
|
||||
): void {
|
||||
const walk = (nodes: FlowStructureNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'group') {
|
||||
const oldKey = node.id
|
||||
const flatIds = flattenStructureIds(node.branches[0].children)
|
||||
const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null
|
||||
if (newKey && oldKey !== newKey) {
|
||||
displayState.remapGroupKey(oldKey, newKey)
|
||||
}
|
||||
walk(node.branches[0].children)
|
||||
} else {
|
||||
for (const branch of node.branches) {
|
||||
walk(branch.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(snapshot)
|
||||
}
|
||||
|
||||
/** Rebuild from flowStore */
|
||||
private rebuild(): void {
|
||||
const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[]
|
||||
const allGroups = this.#flowStore.val.value.groups ?? []
|
||||
const allModules = getAllModules(modules)
|
||||
const graphGroups: GraphGroup[] = allGroups.map((g) => ({
|
||||
...g,
|
||||
id: groupKey(g),
|
||||
moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules)
|
||||
}))
|
||||
try {
|
||||
this.#items = buildStructureTree(modules, graphGroups)
|
||||
this.#error = undefined
|
||||
} catch (e) {
|
||||
// Intentionally preserve last-known-good #items so the graph
|
||||
// can still render while the error is surfaced to the user.
|
||||
this.#error = e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,9 +204,6 @@ export class MoveManager {
|
||||
for (const [edgeId, zone] of this.#registeredDropZones) {
|
||||
if (zone.disableMoveIds.includes(draggedId)) continue
|
||||
|
||||
// Skip edges adjacent to the dragged node (no-op move)
|
||||
if (zone.sourceId === draggedId || zone.targetId === draggedId) continue
|
||||
|
||||
const dx = Math.abs(flowPos.x - zone.centerX)
|
||||
const dy = Math.abs(flowPos.y - zone.centerY)
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { FlowNote } from '../../gen'
|
||||
import type { AssetWithAltAccessType } from '../assets/lib'
|
||||
import {
|
||||
assetDisplaysAsInputInFlowGraph,
|
||||
assetDisplaysAsOutputInFlowGraph,
|
||||
NODE_WITH_READ_ASSET_Y_OFFSET,
|
||||
NODE_WITH_WRITE_ASSET_Y_OFFSET
|
||||
} from './renderers/nodes/AssetNode.svelte'
|
||||
import {
|
||||
AI_TOOL_BASE_OFFSET,
|
||||
AI_TOOL_ROW_OFFSET,
|
||||
BELOW_ADDITIONAL_OFFSET
|
||||
} from './renderers/nodes/AIToolNode.svelte'
|
||||
import { topologicalSort } from './graphBuilder.svelte'
|
||||
import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte'
|
||||
import type { GroupDisplayState } from './groupEditor.svelte'
|
||||
import type { GraphModuleState } from '.'
|
||||
|
||||
type NodeDep = {
|
||||
id: string
|
||||
parentIds?: string[]
|
||||
data?: { assets?: AssetWithAltAccessType[]; module?: any }
|
||||
}
|
||||
|
||||
type ExtraSpace = { top: number; bottom: number; left: number; right: number }
|
||||
|
||||
const MAX_TOOLS_PER_ROW = 2
|
||||
|
||||
/**
|
||||
* Pre-compute extra top/bottom space each node needs for decorations
|
||||
* (assets, AI tools, group headers, group notes).
|
||||
*/
|
||||
export function computeNodeExtraSpace(
|
||||
graphNodes: NodeDep[],
|
||||
opts: {
|
||||
showAssets: boolean
|
||||
showNotes: boolean
|
||||
notes: FlowNote[] | undefined
|
||||
noteTextHeights: Record<string, number>
|
||||
groupDisplayState: GroupDisplayState
|
||||
insertable: boolean
|
||||
flowModuleStates: Record<string, GraphModuleState> | undefined
|
||||
}
|
||||
): Map<string, ExtraSpace> | undefined {
|
||||
const extraSpace = new Map<string, ExtraSpace>()
|
||||
|
||||
// 1. Assets
|
||||
if (opts.showAssets) {
|
||||
for (const node of graphNodes) {
|
||||
const assets = node.data?.assets ?? []
|
||||
if (!assets.length) continue
|
||||
const hasRead = assets.some(assetDisplaysAsInputInFlowGraph)
|
||||
const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph)
|
||||
if (hasRead || hasWrite) {
|
||||
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
extraSpace.set(node.id, {
|
||||
...prev,
|
||||
top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0),
|
||||
bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI tools
|
||||
for (const node of graphNodes) {
|
||||
const mod = node.data?.module
|
||||
if (!mod || mod.value?.type !== 'aiagent') continue
|
||||
|
||||
const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions
|
||||
|
||||
if (agentActions) {
|
||||
// Execution mode: tools below
|
||||
const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW)
|
||||
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET
|
||||
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space })
|
||||
} else {
|
||||
// Edit mode: tools above
|
||||
const tools = mod.value.tools ?? []
|
||||
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0)
|
||||
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows
|
||||
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
extraSpace.set(node.id, { ...prev, top: prev.top + space })
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort (reversed: top-of-graph first) — shared by group notes and group headers
|
||||
const sortedNodes = topologicalSort(graphNodes).reverse()
|
||||
|
||||
// 3. Group notes (text above topmost node in each group note)
|
||||
if (opts.showNotes) {
|
||||
const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group')
|
||||
if (groupNotes.length > 0) {
|
||||
for (const groupNote of groupNotes) {
|
||||
if (!groupNote.contained_node_ids?.length) continue
|
||||
const topmostNodeId = sortedNodes.find((node) =>
|
||||
groupNote.contained_node_ids?.includes(node.id)
|
||||
)?.id
|
||||
if (topmostNodeId) {
|
||||
const textHeight = opts.noteTextHeights[groupNote.id] || 60
|
||||
const spacing = textHeight + 16 // padding
|
||||
const prev = extraSpace.get(topmostNodeId) ?? {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0
|
||||
}
|
||||
extraSpace.set(topmostNodeId, {
|
||||
...prev,
|
||||
top: Math.max(prev.top, spacing + prev.top)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Collapsed group nodes are taller than regular nodes (header + module icons)
|
||||
for (const node of graphNodes) {
|
||||
if (node.id.startsWith('collapsed-group:')) {
|
||||
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
extraSpace.set(node.id, {
|
||||
...prev,
|
||||
bottom: prev.bottom + GROUP_HEADER_HEIGHT
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Group nodes (expanded heads and collapsed) with notes need extra height
|
||||
if (opts.showNotes) {
|
||||
const noteHeights = opts.groupDisplayState.getNoteHeights()
|
||||
for (const node of graphNodes) {
|
||||
let groupId: string | undefined
|
||||
if (node.id.startsWith('group:') && !node.id.endsWith('-end')) {
|
||||
groupId = node.id.slice('group:'.length)
|
||||
} else if (node.id.startsWith('collapsed-group:')) {
|
||||
groupId = node.id.slice('collapsed-group:'.length)
|
||||
}
|
||||
if (groupId) {
|
||||
const noteHeight = noteHeights[groupId]
|
||||
if (noteHeight && noteHeight > 0) {
|
||||
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
extraSpace.set(node.id, {
|
||||
...prev,
|
||||
bottom: prev.bottom + noteHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extraSpace.size > 0 ? extraSpace : undefined
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export enum NoteColor {
|
||||
|
||||
export interface NoteColorConfig {
|
||||
background: string
|
||||
backgroundLight: string
|
||||
outline: string
|
||||
outlineHover: string
|
||||
text: string
|
||||
@@ -24,70 +25,80 @@ export interface NoteColorConfig {
|
||||
export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
|
||||
[NoteColor.YELLOW]: {
|
||||
background: 'bg-yellow-200 dark:bg-yellow-900',
|
||||
outline: 'outline-yellow-300 dark:outline-yellow-600',
|
||||
backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5',
|
||||
outline: 'outline-yellow-200 dark:outline-yellow-900',
|
||||
outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60',
|
||||
text: 'text-yellow-900 dark:text-yellow-100',
|
||||
hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800'
|
||||
},
|
||||
[NoteColor.BLUE]: {
|
||||
background: 'bg-blue-100 dark:bg-blue-950',
|
||||
outline: 'outline-blue-300 dark:outline-blue-600',
|
||||
backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5',
|
||||
outline: 'outline-blue-100 dark:outline-blue-950',
|
||||
outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60',
|
||||
text: 'text-blue-900 dark:text-blue-100',
|
||||
hover: 'hover:bg-blue-200 dark:hover:bg-blue-800'
|
||||
},
|
||||
[NoteColor.GREEN]: {
|
||||
background: 'bg-green-200 dark:bg-green-900',
|
||||
outline: 'outline-green-300 dark:outline-green-600',
|
||||
backgroundLight: 'bg-green-400/5 dark:bg-green-600/5',
|
||||
outline: 'outline-green-200 dark:outline-green-900',
|
||||
outlineHover: 'outline-green-300/60 dark:outline-green-600/60',
|
||||
text: 'text-green-900 dark:text-green-100',
|
||||
hover: 'hover:bg-green-200 dark:hover:bg-green-800'
|
||||
},
|
||||
[NoteColor.PURPLE]: {
|
||||
background: 'bg-purple-200 dark:bg-purple-900',
|
||||
outline: 'outline-purple-300 dark:outline-purple-600',
|
||||
backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5',
|
||||
outline: 'outline-purple-200 dark:outline-purple-900',
|
||||
outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60',
|
||||
text: 'text-purple-900 dark:text-purple-100',
|
||||
hover: 'hover:bg-purple-200 dark:hover:bg-purple-800'
|
||||
},
|
||||
[NoteColor.PINK]: {
|
||||
background: 'bg-pink-200 dark:bg-pink-900',
|
||||
outline: 'outline-pink-300 dark:outline-pink-600',
|
||||
backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5',
|
||||
outline: 'outline-pink-200 dark:outline-pink-900',
|
||||
outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60',
|
||||
text: 'text-pink-900 dark:text-pink-100',
|
||||
hover: 'hover:bg-pink-200 dark:hover:bg-pink-800'
|
||||
},
|
||||
[NoteColor.ORANGE]: {
|
||||
background: 'bg-orange-200 dark:bg-orange-900',
|
||||
outline: 'outline-orange-300 dark:outline-orange-600',
|
||||
backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5',
|
||||
outline: 'outline-orange-200 dark:outline-orange-900',
|
||||
outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60',
|
||||
text: 'text-orange-900 dark:text-orange-100',
|
||||
hover: 'hover:bg-orange-200 dark:hover:bg-orange-800'
|
||||
},
|
||||
[NoteColor.RED]: {
|
||||
background: 'bg-red-200 dark:bg-red-900',
|
||||
outline: 'outline-red-300 dark:outline-red-600',
|
||||
backgroundLight: 'bg-red-400/5 dark:bg-red-600/5',
|
||||
outline: 'outline-red-200 dark:outline-red-900',
|
||||
outlineHover: 'outline-red-300/60 dark:outline-red-600/60',
|
||||
text: 'text-red-900 dark:text-red-100',
|
||||
hover: 'hover:bg-red-200 dark:hover:bg-red-800'
|
||||
},
|
||||
[NoteColor.CYAN]: {
|
||||
background: 'bg-cyan-200 dark:bg-cyan-900',
|
||||
outline: 'outline-cyan-300 dark:outline-cyan-600',
|
||||
backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5',
|
||||
outline: 'outline-cyan-200 dark:outline-cyan-900',
|
||||
outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60',
|
||||
text: 'text-cyan-900 dark:text-cyan-100',
|
||||
hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800'
|
||||
},
|
||||
[NoteColor.LIME]: {
|
||||
background: 'bg-lime-200 dark:bg-lime-900',
|
||||
outline: 'outline-lime-300 dark:outline-lime-600',
|
||||
backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5',
|
||||
outline: 'outline-lime-200 dark:outline-lime-900',
|
||||
outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60',
|
||||
text: 'text-lime-900 dark:text-lime-100',
|
||||
hover: 'hover:bg-lime-200 dark:hover:bg-lime-800'
|
||||
},
|
||||
[NoteColor.GRAY]: {
|
||||
background: 'bg-gray-200 dark:bg-gray-800',
|
||||
outline: 'outline-gray-300 dark:outline-gray-600',
|
||||
backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5',
|
||||
outline: 'outline-gray-200 dark:outline-gray-800',
|
||||
outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60',
|
||||
text: 'text-gray-900 dark:text-gray-100',
|
||||
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user