mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
Merge remote-tracking branch 'origin/main' into fix/ghsa-hfh4-on-behalf-superadmin
# Conflicts: # backend/ee-repo-ref.txt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-5.6-sol`, `xhigh` reasoning; requires the `codex` CLI >= 0.144.1.
|
||||
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
|
||||
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Windmill
|
||||
|
||||
Open-source platform for internal tools, workflows, API integrations, background jobs and UIs. This file pins the vocabulary that is specific to Windmill's domain, so that code, docs and reviews name the same thing the same way.
|
||||
|
||||
## Language
|
||||
|
||||
### Flows
|
||||
|
||||
**Step**:
|
||||
One node of a flow — the unit a user selects in the graph and configures in the right-hand panel. Typed as `FlowModule` in code.
|
||||
_Avoid_: module (ambiguous with the architectural sense), node, action
|
||||
|
||||
**Step setting**:
|
||||
A per-step runtime option stored on the step itself: retries, error handling, timeout, concurrency limit, priority, cache, debounce, early stop, skip, suspend, sleep, lifetime. Distinct from the step's inputs and its code. The panel that edits them is the **run settings** tab; a single setting is still a step setting.
|
||||
_Avoid_: advanced setting, step config, flow option
|
||||
|
||||
**Configured**:
|
||||
Said of a step setting whose config object is present on the step. Deliberately not the same as "would change the runtime's behaviour" — a setting can be configured and still be a no-op (`sleep` of `0`). Every surface that answers "is this setting on?" answers it this way.
|
||||
_Avoid_: enabled, active, effective
|
||||
|
||||
**Trigger step**:
|
||||
The first step of a polling flow. It runs on a schedule and returns the items found since its last run; an empty return means there is nothing to process and the flow stops early, marked skipped rather than failed.
|
||||
_Avoid_: poll script, trigger node, schedule step
|
||||
|
||||
**Default predicate**:
|
||||
The `stop_after_if` expression seeded onto a trigger step at creation, encoding what "nothing new" looks like. One value, owned in one place, shared by every path that creates a trigger step.
|
||||
|
||||
**Connect**:
|
||||
Arming an input so that the next property picked fills it. A property can be picked from the prop picker or, when the panel is docked beside the graph, by clicking a step node's output. At most one input is armed per panel, so a pick always has exactly one destination.
|
||||
_Avoid_: link, bind, plug (the icon is a plug; the action is connecting)
|
||||
|
||||
**Step input**:
|
||||
One argument of a step, edited in the step's input form. Its prop picker is a pane beside the form, always visible, so previous results can be browsed without connecting.
|
||||
_Avoid_: argument field, param
|
||||
|
||||
**Expression input**:
|
||||
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
|
||||
_Avoid_: JS field, code input
|
||||
Generated
+98
-66
@@ -1671,6 +1671,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64-simd"
|
||||
version = "0.8.0"
|
||||
@@ -2270,6 +2276,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -6662,9 +6674,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.80"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -8286,6 +8298,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -10081,16 +10099,16 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -10127,7 +10145,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -10140,7 +10158,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"http 1.5.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"thiserror 2.0.19",
|
||||
"tower-service",
|
||||
@@ -10158,7 +10176,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
"hyper 1.11.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
"thiserror 2.0.19",
|
||||
@@ -10252,13 +10270,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.15.0"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
|
||||
checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -10268,8 +10285,8 @@ dependencies = [
|
||||
"oauth2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.12.28",
|
||||
"rand 0.10.2",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
@@ -10287,9 +10304,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.15.0"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
|
||||
checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -14220,9 +14237,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -14232,27 +14249,14 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.53"
|
||||
version = "0.4.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -14261,9 +14265,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -14271,50 +14275,65 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.53"
|
||||
version = "0.3.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c"
|
||||
checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.53"
|
||||
version = "0.3.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d"
|
||||
checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
@@ -14328,6 +14347,19 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm_dep_analyzer"
|
||||
version = "0.3.0"
|
||||
@@ -14354,9 +14386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.80"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -14522,7 +14554,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rumqttc",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
@@ -14599,7 +14631,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"lazy_static",
|
||||
"mime_guess",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14679,7 +14711,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rsa",
|
||||
"rust-embed",
|
||||
"rustls 0.23.35",
|
||||
@@ -14819,7 +14851,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14893,7 +14925,7 @@ dependencies = [
|
||||
"candle-transformers",
|
||||
"hf-hub",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14994,7 +15026,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp",
|
||||
"rumqttc",
|
||||
"serde",
|
||||
@@ -15044,7 +15076,7 @@ version = "1.780.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15111,7 +15143,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -15346,7 +15378,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"rsa",
|
||||
@@ -15488,7 +15520,7 @@ dependencies = [
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"oauth2",
|
||||
"reqwest 0.12.28",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15513,7 +15545,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -15575,7 +15607,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"object_store",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15898,7 +15930,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -15940,7 +15972,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15981,7 +16013,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16036,7 +16068,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -16100,7 +16132,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16153,7 +16185,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -16440,7 +16472,7 @@ dependencies = [
|
||||
"rand 0.9.0",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
|
||||
+7
-2
@@ -568,7 +568,12 @@ dashmap = "6.1.0"
|
||||
gosyn = "0.2.6"
|
||||
bytes = "1.4.0"
|
||||
gethostname = "0.4.3"
|
||||
wasm-bindgen = "=0.2.103"
|
||||
# Not pinned exactly: the excluded `parsers/windmill-parser-wasm` workspace pins
|
||||
# =0.2.103 to match its vendored `cli/wasm/*` artifacts, yet path-depends on
|
||||
# sibling parser crates that inherit this requirement from here. Two exact pins
|
||||
# on the same semver range cannot both resolve, so keep this a range and let each
|
||||
# workspace's lockfile settle it (here, js-sys forces 0.2.108).
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "^0"
|
||||
wasm-bindgen-test = "^0"
|
||||
convert_case = "0.6.0"
|
||||
@@ -612,7 +617,7 @@ nkeys = "0.4.4"
|
||||
nu-parser = { version = "0.101.0", default-features = false }
|
||||
globset = "0.4.16"
|
||||
croner = "2.2.0"
|
||||
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
rmcp = { version = "=3.1.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] }
|
||||
process-wrap = { version = "8.2.1", features = ["tokio1"] }
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
e46e1febdc5d464d923f6e74a12d49281faf3f04
|
||||
7622c1df38a1f858bd3f893537da0e2cca6c2d54
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Protocol-version negotiation for the MCP endpoint.
|
||||
//!
|
||||
//! The endpoint is dual-era: legacy revisions keep the `initialize` handshake,
|
||||
//! while `2026-07-28` carries its version as per-request metadata and is served
|
||||
//! statelessly. Both are answered on the same URL, so a bump of the rmcp SDK
|
||||
//! must not silently drop either side.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Every revision the server advertises, oldest first.
|
||||
const SUPPORTED: [&str; 5] = [
|
||||
"2024-11-05",
|
||||
"2025-03-26",
|
||||
"2025-06-18",
|
||||
"2025-11-25",
|
||||
"2026-07-28",
|
||||
];
|
||||
|
||||
const MODERN: &str = "2026-07-28";
|
||||
|
||||
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST one JSON-RPC message and return the HTTP status plus the decoded body.
|
||||
/// The endpoint answers either `application/json` or a single-event SSE stream,
|
||||
/// so strip the `data: ` framing before parsing.
|
||||
async fn post(
|
||||
port: u16,
|
||||
headers: &[(&str, &str)],
|
||||
body: Value,
|
||||
) -> anyhow::Result<(reqwest::StatusCode, Value)> {
|
||||
let mut req = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
||||
))
|
||||
.header("Authorization", "Bearer MCP_TOKEN")
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.json(&body);
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
let payload = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("data: "))
|
||||
.unwrap_or(text.trim());
|
||||
let parsed = serde_json::from_str(payload)
|
||||
.map_err(|e| anyhow::anyhow!("status {status}, unparseable body {text:?}: {e}"))?;
|
||||
Ok((status, parsed))
|
||||
}
|
||||
|
||||
fn modern_meta() -> Value {
|
||||
json!({
|
||||
"io.modelcontextprotocol/protocolVersion": MODERN,
|
||||
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_legacy_initialize_negotiates_requested_version(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// A legacy client must be answered with the revision it asked for, not with
|
||||
// whatever the SDK happens to call `LATEST`.
|
||||
for version in SUPPORTED.iter().filter(|v| **v != MODERN) {
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[("MCP-Protocol-Version", version)],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": version,
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(status, 200, "initialize {version} failed: {body}");
|
||||
assert_eq!(
|
||||
body["result"]["protocolVersion"], *version,
|
||||
"initialize {version} negotiated the wrong revision: {body}"
|
||||
);
|
||||
// `Implementation::from_build_env()` expands its `env!` inside rmcp, so
|
||||
// the obvious constructor makes the server introduce itself as the SDK.
|
||||
assert_eq!(
|
||||
body["result"]["serverInfo"]["name"], "windmill",
|
||||
"server must identify itself, not the SDK: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_modern_requests_are_served_without_initialize(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// `server/discover` is the modern replacement for the handshake: it must
|
||||
// exist and advertise exactly the revisions the server implements.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", MODERN),
|
||||
("Mcp-Method", "server/discover"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
|
||||
"params": { "_meta": modern_meta() }
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "server/discover failed: {body}");
|
||||
assert_eq!(
|
||||
body["result"]["supportedVersions"],
|
||||
json!(SUPPORTED),
|
||||
"server/discover advertised the wrong revisions: {body}"
|
||||
);
|
||||
|
||||
// A modern call carries its version in `_meta` and needs no prior session.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", MODERN),
|
||||
("Mcp-Method", "tools/list"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": { "_meta": modern_meta() }
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "modern tools/list failed: {body}");
|
||||
assert!(
|
||||
body["result"]["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|t| !t.is_empty()),
|
||||
"modern tools/list returned no tools: {body}"
|
||||
);
|
||||
|
||||
// SEP-2549 cache hints are required at 2026-07-28 and rmcp omits them unless
|
||||
// set, which makes strict clients (e.g. the Python SDK) reject the whole
|
||||
// response rather than degrade.
|
||||
assert!(
|
||||
body["result"]["ttlMs"].is_number(),
|
||||
"modern tools/list is missing ttlMs: {body}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["result"]["cacheScope"], "private",
|
||||
"tools/list must not be cached across callers: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_unsupported_version_lists_supported_ones(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The client's only way forward is the `supported` list, so an unknown
|
||||
// version must fail with it rather than with a generic error.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", "1900-01-01"),
|
||||
("Mcp-Method", "tools/list"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||
"params": { "_meta": {
|
||||
"io.modelcontextprotocol/protocolVersion": "1900-01-01",
|
||||
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(status, 400, "expected 400 for unknown version: {body}");
|
||||
assert_eq!(body["error"]["code"], -32022, "wrong error code: {body}");
|
||||
assert_eq!(
|
||||
body["error"]["data"]["supported"],
|
||||
json!(SUPPORTED),
|
||||
"error did not advertise the supported revisions: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -756,37 +756,26 @@ async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<
|
||||
.auth_header("MCP_TOKEN");
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "test-client".to_string(),
|
||||
title: None,
|
||||
version: "0.0.1".to_string(),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("test-client", "0.0.1"),
|
||||
);
|
||||
|
||||
let client: RunningService<RoleClient, InitializeRequestParams> =
|
||||
client_info.serve(transport).await?;
|
||||
|
||||
// --- Test getJob ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJob".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("getJob")
|
||||
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.and_then(|c| c.as_text())
|
||||
.expect("getJob should return text content");
|
||||
let job: serde_json::Value = serde_json::from_str(&text.text)?;
|
||||
assert_eq!(job["id"], job_id.to_string());
|
||||
@@ -800,18 +789,16 @@ async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<
|
||||
|
||||
// --- Test getJobLogs ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJobLogs".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("getJobLogs")
|
||||
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.and_then(|c| c.as_text())
|
||||
.expect("getJobLogs should return text content");
|
||||
// The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint
|
||||
let logs: String = serde_json::from_str(&text.text)?;
|
||||
|
||||
@@ -425,6 +425,27 @@ pub async fn run_server(
|
||||
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
|
||||
.allow_origin(Any);
|
||||
|
||||
// MCP carries protocol state in its own headers: `MCP-Protocol-Version` from
|
||||
// revision 2025-06-18 onward, plus `Mcp-Method` and `Mcp-Name` at 2026-07-28.
|
||||
// None of them are CORS-simple, so a browser-based MCP client fails preflight
|
||||
// unless they are allowed — hence a separate layer rather than widening the
|
||||
// one every other route shares. (`Mcp-Param-*` is only sent for tool inputs
|
||||
// annotated with `x-mcp-header`, which no tool here declares.)
|
||||
let mcp_cors = CorsLayer::new()
|
||||
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
|
||||
.allow_headers([
|
||||
http::header::CONTENT_TYPE,
|
||||
http::header::AUTHORIZATION,
|
||||
http::HeaderName::from_static("mcp-protocol-version"),
|
||||
http::HeaderName::from_static("mcp-method"),
|
||||
http::HeaderName::from_static("mcp-name"),
|
||||
])
|
||||
// The 401 challenge is how a client discovers where to authorize (RFC 9728),
|
||||
// and it is not a safelisted response header, so without this a browser
|
||||
// client sees an empty one and has no way to begin the OAuth flow.
|
||||
.expose_headers([http::header::WWW_AUTHENTICATE])
|
||||
.allow_origin(Any);
|
||||
|
||||
let sp_extension = Arc::new(saml_oss::build_sp_extension().await?);
|
||||
|
||||
if server_mode {
|
||||
@@ -821,13 +842,13 @@ pub async fn run_server(
|
||||
// Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead
|
||||
.nest(
|
||||
"/mcp/w/{workspace_id}/sse",
|
||||
mcp_router.clone().layer(cors.clone()),
|
||||
mcp_router.clone().layer(mcp_cors.clone()),
|
||||
)
|
||||
.nest(
|
||||
"/mcp/w/{workspace_id}/mcp",
|
||||
mcp_router.clone().layer(cors.clone()),
|
||||
mcp_router.clone().layer(mcp_cors.clone()),
|
||||
)
|
||||
.nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone()))
|
||||
.nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone()))
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
|
||||
@@ -570,12 +570,24 @@ pub async fn setup_mcp_server(
|
||||
let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache);
|
||||
let runner = Runner::new(backend);
|
||||
|
||||
let service_config = StreamableHttpServerConfig {
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
stateful_mode: false,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
sse_retry: Some(Duration::from_secs(15)),
|
||||
};
|
||||
let service_config = StreamableHttpServerConfig::default()
|
||||
.with_sse_keep_alive(Some(Duration::from_secs(15)))
|
||||
.with_sse_retry(Some(Duration::from_secs(15)))
|
||||
.with_cancellation_token(cancellation_token.clone())
|
||||
// Sessionless: every request re-resolves auth from its own bearer token, so
|
||||
// there is no session to bind. This also makes legacy `initialize` clients
|
||||
// take the same stateless path as 2026-07-28 ones.
|
||||
.with_legacy_session_mode(false)
|
||||
// rmcp's Host allowlist defaults to localhost, which guards an unauthenticated
|
||||
// locally-bound server against DNS rebinding. This endpoint instead sits behind
|
||||
// Windmill's own authentication, and is reached under whatever hostname the
|
||||
// instance is served on, so keeping that default would reject every remote MCP
|
||||
// client while adding nothing.
|
||||
.disable_allowed_hosts()
|
||||
// MCP bodies are ordinary API payloads — `createApp`/`updateApp` carry whole app
|
||||
// sources — so they follow the instance's request size limit rather than rmcp's
|
||||
// much smaller default, which would 413 them with no way to raise it.
|
||||
.with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await);
|
||||
|
||||
let service =
|
||||
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
|
||||
|
||||
@@ -17,7 +17,7 @@ auth = ["rmcp/auth", "dep:oauth2", "dep:sqlx", "dep:chrono"]
|
||||
oauth2 = { version = "5.0", optional = true }
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
anyhow.workspace = true
|
||||
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -85,10 +85,7 @@ impl McpClient {
|
||||
// and does not legitimately rely on redirects.
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
// Pin DNS to the address validated above so the connect cannot rebind to
|
||||
// an internal IP between the check and the request. `apply_dns_pinning`
|
||||
// lives on windmill-common's reqwest, but this crate resolves a
|
||||
// different reqwest version (via rmcp), so pin directly with the
|
||||
// std-typed host/addrs the validation surfaced. Empty addrs (IP literal
|
||||
// an internal IP between the check and the request. Empty addrs (IP literal
|
||||
// or ALLOW_PRIVATE_MCP_SERVER_URLS) leave resolution untouched.
|
||||
if !validated.addrs.is_empty() {
|
||||
client_builder = client_builder.resolve_to_addrs(&validated.host, &validated.addrs);
|
||||
@@ -102,19 +99,11 @@ impl McpClient {
|
||||
let transport = StreamableHttpClientTransport::with_client(reqwest_client, config);
|
||||
|
||||
// Set up client info
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "windmill-ai-agent".to_string(),
|
||||
title: Some("Windmill AI Agent".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("windmill-ai-agent", env!("CARGO_PKG_VERSION"))
|
||||
.with_title("Windmill AI Agent"),
|
||||
);
|
||||
|
||||
// Initialize the connection
|
||||
let client = client_info
|
||||
@@ -143,14 +132,14 @@ impl McpClient {
|
||||
let mcp_args =
|
||||
Self::openai_args_to_mcp_args(arguments).context("Failed to parse tool arguments")?;
|
||||
|
||||
let mut params = CallToolRequestParams::new(name.to_string());
|
||||
if let Some(args) = mcp_args {
|
||||
params = params.with_arguments(args);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: name.to_string().into(),
|
||||
arguments: mcp_args,
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(params)
|
||||
.await
|
||||
.context(format!("Failed to call MCP tool: {}", name))?;
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ pub async fn get_or_refresh_mcp_client(
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to create auth manager: {e}")))?;
|
||||
// Discovery hits the well-known endpoint on the MCP server host validated
|
||||
// above; pin to that address so it cannot rebind between check and connect.
|
||||
// Limitation: rmcp's discover_metadata may additionally follow server-supplied
|
||||
// Limitation: rmcp's resolve_metadata may additionally follow server-supplied
|
||||
// metadata URLs (resource_metadata / authorization_servers) on other hosts,
|
||||
// which this per-host pin does not cover — a pre-existing gap in rmcp discovery
|
||||
// that a validating resolver would need to close, out of scope for this pin.
|
||||
@@ -181,8 +181,7 @@ pub async fn get_or_refresh_mcp_client(
|
||||
.with_client(discovery_client)
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to configure auth manager: {e}")))?;
|
||||
|
||||
let metadata = manager
|
||||
.discover_metadata()
|
||||
let metadata = crate::oauth::discover_authorization_metadata(&manager)
|
||||
.await
|
||||
.map_err(|e| error::Error::BadRequest(format!("OAuth discovery failed: {e}")))?;
|
||||
|
||||
|
||||
@@ -40,10 +40,30 @@ pub mod oauth {
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
pub use rmcp::transport::auth::AuthorizationManager;
|
||||
use rmcp::transport::auth::AuthorizationMetadataSource;
|
||||
pub use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadata};
|
||||
|
||||
const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Discover the MCP server's OAuth metadata, refusing endpoints the server
|
||||
/// never advertised.
|
||||
///
|
||||
/// When a server publishes no metadata at all, rmcp's `resolve_metadata`
|
||||
/// falls back to inventing `/authorize`, `/token` and `/register` on the
|
||||
/// server's own host. Dynamic client registration and the token exchange
|
||||
/// both carry secrets, so they must only ever reach endpoints the server
|
||||
/// actually published — a guessed path would send them somewhere the
|
||||
/// operator never designated as an authorization server.
|
||||
pub async fn discover_authorization_metadata(
|
||||
manager: &AuthorizationManager,
|
||||
) -> anyhow::Result<AuthorizationMetadata> {
|
||||
let resolution = manager.resolve_metadata().await?;
|
||||
if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback {
|
||||
anyhow::bail!("MCP server does not publish OAuth authorization metadata");
|
||||
}
|
||||
Ok(resolution.metadata)
|
||||
}
|
||||
|
||||
pub fn no_redirect_http_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
no_redirect_http_client_with_timeout(DEFAULT_OAUTH_HTTP_TIMEOUT)
|
||||
}
|
||||
@@ -61,11 +81,8 @@ pub mod oauth {
|
||||
/// guard validated for the request URL so the connect cannot rebind to an
|
||||
/// internal IP after the check (TOCTOU). The OAuth DCR/discovery/token
|
||||
/// requests target author-controlled URLs and carry secrets, so they must
|
||||
/// go through this rather than the unpinned client. `apply_dns_pinning`
|
||||
/// lives on windmill-common's reqwest, which this crate resolves at a
|
||||
/// different version (via rmcp), so pin directly with the std-typed
|
||||
/// host/addrs. Empty `addrs` (IP literal or ALLOW_PRIVATE_MCP_SERVER_URLS)
|
||||
/// leaves resolution untouched.
|
||||
/// go through this rather than the unpinned client. Empty `addrs` (IP literal
|
||||
/// or ALLOW_PRIVATE_MCP_SERVER_URLS) leaves resolution untouched.
|
||||
pub fn no_redirect_http_client_pinned(
|
||||
target: &windmill_common::ssrf::ValidatedTarget,
|
||||
) -> Result<reqwest::Client, reqwest::Error> {
|
||||
@@ -124,5 +141,41 @@ pub mod oauth {
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
/// A server publishing no OAuth metadata must be rejected, not have its
|
||||
/// endpoints guessed: rmcp's own fallback would invent `/authorize`,
|
||||
/// `/token` and `/register` on that host, and DCR and the token exchange
|
||||
/// send secrets to whatever comes back.
|
||||
#[tokio::test]
|
||||
async fn discovery_refuses_endpoints_the_server_never_published() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Every discovery probe 404s, which is what a plain MCP server
|
||||
// with no authorization server looks like.
|
||||
while let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buffer = [0u8; 2048];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = std::io::Write::write_all(
|
||||
&mut stream,
|
||||
b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let manager = AuthorizationManager::new(format!("http://{addr}/mcp"))
|
||||
.await
|
||||
.expect("manager should construct");
|
||||
let err = discover_authorization_metadata(&manager)
|
||||
.await
|
||||
.expect_err("must not fall back to guessed endpoints");
|
||||
assert!(
|
||||
err.to_string().contains("does not publish OAuth"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +59,13 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
|
||||
// Create annotations based on HTTP method and endpoint characteristics
|
||||
let annotations = create_endpoint_annotations(tool);
|
||||
|
||||
Tool {
|
||||
name: tool.name.clone(),
|
||||
description: Some(description.into()),
|
||||
input_schema: Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
title: Some(tool.name.to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(annotations),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
tool.name.clone(),
|
||||
description,
|
||||
Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
)
|
||||
.with_title(tool.name.to_string())
|
||||
.with_annotations(annotations)
|
||||
}
|
||||
|
||||
/// Convert an endpoint tool to an MCP tool for multi-workspace mode.
|
||||
@@ -130,26 +126,19 @@ pub fn list_workspaces_tool() -> Tool {
|
||||
"required": []
|
||||
});
|
||||
|
||||
Tool {
|
||||
name: Cow::Borrowed("list_workspaces"),
|
||||
description: Some(
|
||||
"List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools."
|
||||
.into(),
|
||||
),
|
||||
input_schema: Arc::new(schema.as_object().unwrap().clone()),
|
||||
title: Some("List accessible workspaces".to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("List accessible workspaces".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
idempotent_hint: Some(true),
|
||||
open_world_hint: Some(false),
|
||||
}),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
Cow::Borrowed("list_workspaces"),
|
||||
"List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools.",
|
||||
Arc::new(schema.as_object().unwrap().clone()),
|
||||
)
|
||||
.with_title("List accessible workspaces")
|
||||
.with_annotations(
|
||||
ToolAnnotations::with_title("List accessible workspaces")
|
||||
.read_only(true)
|
||||
.destructive(false)
|
||||
.idempotent(true)
|
||||
.open_world(false),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create appropriate annotations for endpoint tools based on HTTP method
|
||||
@@ -166,13 +155,11 @@ fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
|
||||
_ => (false, true, false, true), // Default: assume can modify and be destructive
|
||||
};
|
||||
|
||||
ToolAnnotations {
|
||||
title: Some(format!("{} {}", method, tool.path)),
|
||||
read_only_hint: Some(read_only),
|
||||
destructive_hint: Some(destructive),
|
||||
idempotent_hint: Some(idempotent),
|
||||
open_world_hint: Some(open_world),
|
||||
}
|
||||
ToolAnnotations::with_title(format!("{} {}", method, tool.path))
|
||||
.read_only(read_only)
|
||||
.destructive(destructive)
|
||||
.idempotent(idempotent)
|
||||
.open_world(open_world)
|
||||
}
|
||||
|
||||
/// Merge schema into combined properties and required fields
|
||||
|
||||
@@ -23,10 +23,10 @@ pub use tools::create_tool_from_item;
|
||||
// Re-export rmcp types for convenience
|
||||
pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
Annotated, CallToolRequestParams, CallToolResult, Content, Implementation,
|
||||
InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, RawContent,
|
||||
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
|
||||
ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
|
||||
@@ -17,16 +17,42 @@ use crate::server::endpoints::{
|
||||
use crate::server::tools::create_tool_from_item;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
|
||||
Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
|
||||
ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Protocol revisions this server is willing to speak. `2026-07-28` is served
|
||||
/// statelessly with per-request metadata; the older revisions keep the
|
||||
/// `initialize` handshake, so both eras are answered on the same endpoint.
|
||||
const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
|
||||
ProtocolVersion::V_2024_11_05,
|
||||
ProtocolVersion::V_2025_03_26,
|
||||
ProtocolVersion::V_2025_06_18,
|
||||
ProtocolVersion::V_2025_11_25,
|
||||
ProtocolVersion::V_2026_07_28,
|
||||
];
|
||||
|
||||
/// SEP-2549 cache hints, required on every list result at `2026-07-28` — rmcp
|
||||
/// leaves them unset, and a strict client rejects the response without them.
|
||||
///
|
||||
/// Zero because nothing here is cacheable: the listing is rebuilt from the
|
||||
/// workspace's scripts and flows, which change at any time, and this server
|
||||
/// advertises no `listChanged` capability, so a client that cached a stale list
|
||||
/// would have no way to learn it had gone stale.
|
||||
const LIST_TTL_MS: u64 = 0;
|
||||
/// Every listing is filtered by the caller's token scopes and workspace
|
||||
/// membership, so no two callers necessarily see the same tools — a shared
|
||||
/// cache entry would leak one token's view to another.
|
||||
const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private;
|
||||
|
||||
// Re-export from http crate for extracting request parts
|
||||
use http::request::Parts as HttpParts;
|
||||
|
||||
@@ -307,24 +333,26 @@ fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -
|
||||
|
||||
impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::default(),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
// Not `Implementation::from_build_env()`: its `env!` expands inside rmcp, so it
|
||||
// would name the SDK crate rather than this server.
|
||||
let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION"))
|
||||
.with_title("Windmill")
|
||||
.with_website_url("https://windmill.dev");
|
||||
|
||||
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(server_info)
|
||||
.with_instructions(
|
||||
"This server provides a list of scripts and flows the user can run on Windmill. \
|
||||
Each flow and script is a tool callable with their respective arguments."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
Each flow and script is a tool callable with their respective arguments.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(self.get_info())
|
||||
/// Pinned rather than left to rmcp's default (every version the SDK knows), so a
|
||||
/// future SDK revision cannot start advertising a version this server has not been
|
||||
/// exercised against. Bounds `initialize` negotiation, `server/discover`, and
|
||||
/// per-request version validation alike.
|
||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||
Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
@@ -359,7 +387,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let (auth, mode) = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
@@ -370,7 +398,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
|
||||
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
|
||||
|
||||
match mode {
|
||||
// Every tool here runs to completion in one round trip: none of them ask the
|
||||
// client for input, so the MRTR variants of `CallToolResponse` are never built.
|
||||
let result = match mode {
|
||||
McpMode::Single(workspace_id) => {
|
||||
self.call_tool_single(
|
||||
&auth,
|
||||
@@ -386,7 +416,8 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}?;
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
@@ -394,7 +425,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None })
|
||||
Ok(ListResourcesResult::with_all_items(vec![])
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
@@ -402,7 +435,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, ErrorData> {
|
||||
Ok(ListPromptsResult::default())
|
||||
Ok(ListPromptsResult::default()
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
@@ -410,7 +445,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, ErrorData> {
|
||||
Ok(ListResourceTemplatesResult::default())
|
||||
Ok(ListResourceTemplatesResult::default()
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +584,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool));
|
||||
}
|
||||
|
||||
Ok(ListToolsResult { tools, next_cursor: None, meta: None })
|
||||
Ok(ListToolsResult::with_all_items(tools)
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
/// Handle a tool call for a single, bound workspace.
|
||||
@@ -575,7 +614,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
@@ -699,7 +738,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(CallToolResult::success(vec![Content::text(
|
||||
Ok(value) => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
@@ -733,7 +772,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
tools.push(endpoint_tool_to_mcp_tool_multi(&endpoint_tool));
|
||||
}
|
||||
|
||||
ListToolsResult { tools, next_cursor: None, meta: None }
|
||||
ListToolsResult::with_all_items(tools)
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE)
|
||||
}
|
||||
|
||||
/// Handle a tool call for a multi-workspace session. `base_auth` is the
|
||||
@@ -754,7 +795,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.list_accessible_workspaces(base_auth)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()),
|
||||
)]));
|
||||
}
|
||||
@@ -827,7 +868,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
|
||||
@@ -190,21 +190,17 @@ pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>(
|
||||
}
|
||||
};
|
||||
|
||||
Tool {
|
||||
name: Cow::Owned(path),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: Arc::new(input_schema_map),
|
||||
title: Some(title.clone()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some(title),
|
||||
read_only_hint: Some(false), // Can modify environment
|
||||
destructive_hint: Some(true), // Can potentially be destructive
|
||||
idempotent_hint: Some(false), // Are not guaranteed to be idempotent
|
||||
open_world_hint: Some(true), // Can interact with external services
|
||||
}),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
Cow::Owned(path),
|
||||
Cow::Owned(description),
|
||||
Arc::new(input_schema_map),
|
||||
)
|
||||
.with_title(title.clone())
|
||||
.with_annotations(
|
||||
ToolAnnotations::with_title(title)
|
||||
.read_only(false) // Can modify environment
|
||||
.destructive(true) // Can potentially be destructive
|
||||
.idempotent(false) // Are not guaranteed to be idempotent
|
||||
.open_world(true), // Can interact with external services
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::{BUN_CACHE_DIR, BUN_PATH, HOME_ENV, PATH_ENV, PROXY_ENVS, UV_CACHE_DIR};
|
||||
use crate::worker::non_empty_env;
|
||||
use crate::{
|
||||
BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST,
|
||||
UV_CACHE_DIR, UV_HTTP_TIMEOUT,
|
||||
};
|
||||
use windmill_common::worker::write_file;
|
||||
|
||||
const LOADER_BUILDER_CONTENT: &str = include_str!("../loader_builder.bun.js");
|
||||
@@ -87,6 +91,15 @@ lazy_static::lazy_static! {
|
||||
|
||||
/// UV binary path
|
||||
static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string());
|
||||
|
||||
/// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance
|
||||
/// settings the job path resolves are unreachable here: their env-var equivalents are the
|
||||
/// only registry configuration the debugger can see.
|
||||
static ref PY_INDEX_URL: Option<String> = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL"));
|
||||
static ref PY_EXTRA_INDEX_URL: Option<String> = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL"));
|
||||
/// uv defaults to `first-index`; the job path overrides it so a package missing from the
|
||||
/// first index is still resolved from the others. Same default here.
|
||||
static ref PY_INDEX_STRATEGY: String = non_empty_env("UV_INDEX_STRATEGY").unwrap_or_else(|| "unsafe-best-match".to_string());
|
||||
}
|
||||
|
||||
/// Simple loader that doesn't require Windmill API for relative imports
|
||||
@@ -114,6 +127,11 @@ pub struct PrepareResponse {
|
||||
pub job_dir: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
/// Raw stderr of the dependency installer when it exited non-zero, so a caller can show the
|
||||
/// registry/TLS failure verbatim instead of the bare ModuleNotFoundError that follows.
|
||||
/// Omitted from the JSON when absent, so callers that only know `success`/`error` are unaffected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install_stderr: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse Python imports and return a list of package names that need to be installed.
|
||||
@@ -160,6 +178,28 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap<String, String> {
|
||||
envs
|
||||
}
|
||||
|
||||
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
|
||||
fn uv_registry_args() -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![];
|
||||
if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() {
|
||||
for url in urls.split(',') {
|
||||
args.extend(["--extra-index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
}
|
||||
if let Some(url) = PY_INDEX_URL.as_ref() {
|
||||
args.extend(["--index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
if let Some(hosts) = TRUSTED_HOST.as_ref() {
|
||||
for host in hosts.split_whitespace() {
|
||||
args.extend(["--trusted-host".to_string(), host.to_string()]);
|
||||
}
|
||||
}
|
||||
if *NATIVE_CERT {
|
||||
args.push("--native-tls".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Prepare Python dependencies using uv
|
||||
async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
// Parse imports from the code
|
||||
@@ -172,6 +212,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: String::new(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,17 +230,37 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create job directory: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
let common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
|
||||
let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
|
||||
common_uv_envs.insert(
|
||||
"UV_INDEX_STRATEGY".to_string(),
|
||||
PY_INDEX_STRATEGY.to_string(),
|
||||
);
|
||||
if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() {
|
||||
common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string());
|
||||
}
|
||||
if let Some(cert_path) = INDEX_CERT.as_ref() {
|
||||
// uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle
|
||||
// reaches it through SSL_CERT_FILE, as in the job path.
|
||||
common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string());
|
||||
}
|
||||
|
||||
let registry_args = uv_registry_args();
|
||||
|
||||
// Step 1: Create virtual environment using uv
|
||||
// `--seed` resolves pip/setuptools from the index, so the venv also needs the registry
|
||||
// arguments: on a network that only reaches a private mirror it fails without them.
|
||||
let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()];
|
||||
venv_args.extend(registry_args.iter().cloned());
|
||||
|
||||
let output = Command::new(UV_PATH.as_str())
|
||||
.current_dir(&job_dir)
|
||||
.env_clear()
|
||||
.envs(common_uv_envs.clone())
|
||||
.args(["venv", &venv_dir, "--seed"])
|
||||
.args(&venv_args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
@@ -212,26 +273,33 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create venv: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
let out = output.unwrap();
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("uv venv failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Install packages using uv pip install
|
||||
let python_path = format!("{}/bin/python", venv_dir);
|
||||
let mut args = vec!["pip", "install", "--python", &python_path];
|
||||
let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
|
||||
args.extend(package_refs.iter());
|
||||
let mut args = vec![
|
||||
"pip".to_string(),
|
||||
"install".to_string(),
|
||||
"--python".to_string(),
|
||||
python_path,
|
||||
];
|
||||
args.extend(packages.iter().cloned());
|
||||
args.extend(registry_args);
|
||||
|
||||
let output = Command::new(UV_PATH.as_str())
|
||||
.current_dir(&job_dir)
|
||||
@@ -246,11 +314,19 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// Installation might fail for some packages (e.g., wrong package name)
|
||||
// Log the error but continue - the script might still work if the
|
||||
// package is actually installed elsewhere or the import is optional
|
||||
tracing::warn!("uv pip install warning: {}", stderr);
|
||||
// uv installs the whole set atomically, so a failure here means an empty venv:
|
||||
// returning success would leave the caller with a bare ModuleNotFoundError and
|
||||
// no way to see the registry/TLS/package-name error that caused it.
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
tracing::warn!("uv pip install failed: {}", stderr);
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("uv pip install failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -260,6 +336,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run uv pip install: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -292,6 +369,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +399,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
"Unsupported language for dependency preparation: {}",
|
||||
language
|
||||
)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -336,6 +415,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create job directory: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,6 +427,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write main.ts: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -366,6 +447,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write build.js: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -398,6 +480,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write empty package.json: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -411,6 +494,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run build.js: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -427,6 +511,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -441,6 +526,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to parse package.json: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -454,6 +540,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,13 +558,14 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("bun install failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -488,6 +576,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run bun install: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -500,6 +589,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
} else {
|
||||
PrepareResponse {
|
||||
@@ -508,6 +598,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,6 +622,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
job_dir: String::new(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to read stdin: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
return Ok(());
|
||||
@@ -550,6 +642,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
"Failed to parse JSON input: {}. Expected {{\"code\": \"...\", \"language\": \"bun\" or \"python3\"}}",
|
||||
e
|
||||
)),
|
||||
install_stderr: None,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
return Ok(());
|
||||
@@ -561,3 +654,43 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PrepareResponse;
|
||||
|
||||
/// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's
|
||||
/// stdout, so `install_stderr` has to stay additive: a response without an install failure
|
||||
/// must serialize to the shape callers already know.
|
||||
#[test]
|
||||
fn test_install_stderr_is_additive() {
|
||||
let ok = PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: Some("/tmp/windmill-deps/x/venv".to_string()),
|
||||
job_dir: "/tmp/windmill-deps/x".to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ok).unwrap(),
|
||||
serde_json::json!({
|
||||
"node_modules_path": null,
|
||||
"venv_path": "/tmp/windmill-deps/x/venv",
|
||||
"job_dir": "/tmp/windmill-deps/x",
|
||||
"success": true,
|
||||
"error": null,
|
||||
})
|
||||
);
|
||||
|
||||
let failed = PrepareResponse {
|
||||
install_stderr: Some("error: no such package".to_string()),
|
||||
success: false,
|
||||
error: Some("uv pip install failed: error: no such package".to_string()),
|
||||
..ok
|
||||
};
|
||||
let failed = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(failed["install_stderr"], "error: no such package");
|
||||
assert_eq!(failed["success"], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,20 +62,8 @@ lazy_static::lazy_static! {
|
||||
static ref PY_CONCURRENT_DOWNLOADS: usize =
|
||||
var("PY_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20);
|
||||
|
||||
// uv's HTTP request timeout (seconds). spawn_uv_install uses env_clear(), so a
|
||||
// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly.
|
||||
// Only forwarded when set; otherwise uv keeps its own default. Lets operators
|
||||
// raise it for slow/contended private registries ("operation timed out").
|
||||
static ref UV_HTTP_TIMEOUT: Option<String> =
|
||||
var("UV_HTTP_TIMEOUT").ok().filter(|v| !v.is_empty());
|
||||
|
||||
|
||||
static ref NON_ALPHANUM_CHAR: Regex = regex::Regex::new(r"[^0-9A-Za-z=.-]").unwrap();
|
||||
|
||||
static ref TRUSTED_HOST: Option<String> = var("PY_TRUSTED_HOST").ok().or(var("PIP_TRUSTED_HOST").ok());
|
||||
pub static ref INDEX_CERT: Option<String> = var("PY_INDEX_CERT").ok().or(var("PIP_INDEX_CERT").ok());
|
||||
pub static ref NATIVE_CERT: bool = var("PY_NATIVE_CERT").ok().or(var("UV_NATIVE_TLS").ok()).map(|flag| flag == "true").unwrap_or(false);
|
||||
|
||||
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap();
|
||||
|
||||
static ref EPHEMERAL_TOKEN_CMD: Option<String> = var("EPHEMERAL_TOKEN_CMD").ok();
|
||||
@@ -163,9 +151,10 @@ use crate::{
|
||||
handle_child::handle_child,
|
||||
is_sandboxing_enabled, read_ee_registry_with_workspace_override,
|
||||
worker_utils::ping_job_status,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV,
|
||||
PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH,
|
||||
TZ_ENV, UV_CACHE_DIR, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH,
|
||||
NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS,
|
||||
PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TRUSTED_HOST, TZ_ENV, UV_CACHE_DIR,
|
||||
UV_EXCLUDE_NEWER, UV_HTTP_TIMEOUT, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR,
|
||||
};
|
||||
use windmill_common::client::AuthedClient;
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ use crate::python_executor::UV_PATH;
|
||||
use crate::{
|
||||
common::{start_child_process, OccupancyMetrics},
|
||||
handle_child::handle_child,
|
||||
python_executor::{INDEX_CERT, NATIVE_CERT, PYTHON_PATH},
|
||||
HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, UV_CACHE_DIR,
|
||||
UV_PYTHON_INSTALL_MIRROR, WIN_ENVS,
|
||||
python_executor::PYTHON_PATH,
|
||||
HOME_ENV, INDEX_CERT, INSTANCE_PYTHON_VERSION, NATIVE_CERT, PATH_ENV, PROXY_ENVS,
|
||||
PY_INSTALL_DIR, UV_CACHE_DIR, UV_PYTHON_INSTALL_MIRROR, WIN_ENVS,
|
||||
};
|
||||
|
||||
impl From<PyV> for PyVAlias {
|
||||
|
||||
@@ -704,6 +704,26 @@ lazy_static::lazy_static! {
|
||||
pub static ref FLOW_RUNNER_RUNNING: Mutex<bool> = Mutex::new(false);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Registry TLS/timeout settings for uv. Env-only (they have no instance setting), and read
|
||||
/// both by the job path and by the DB-less `prepare-deps` CLI, which has no other source of
|
||||
/// registry configuration.
|
||||
pub static ref TRUSTED_HOST: Option<String> = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST"));
|
||||
pub static ref INDEX_CERT: Option<String> = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT"));
|
||||
pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false);
|
||||
/// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a
|
||||
/// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly.
|
||||
/// Only forwarded when set; otherwise uv keeps its own default. Lets operators
|
||||
/// raise it for slow/contended private registries ("operation timed out").
|
||||
pub static ref UV_HTTP_TIMEOUT: Option<String> = non_empty_env("UV_HTTP_TIMEOUT");
|
||||
}
|
||||
|
||||
/// A variable declared but left empty (a common shape in compose/k8s manifests) must not
|
||||
/// shadow the fallback name it is checked against.
|
||||
pub(crate) fn non_empty_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Optional override for the size of the `/tmp` tmpfs mount in nsjail sandboxes (in megabytes).
|
||||
/// When `None` (or non-positive), executors fall back to the unified
|
||||
|
||||
@@ -75,6 +75,48 @@ Options:
|
||||
| `DAP_NSJAIL_PATH` | nsjail binary path | nsjail |
|
||||
| `DAP_NSJAIL_CONFIG` | nsjail config file path | - |
|
||||
|
||||
### Python dependency preparation
|
||||
|
||||
Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which
|
||||
runs `uv` without a database connection. It cannot read the instance settings, so it takes its
|
||||
registry configuration from the environment of the debug service instead, and the Python server is
|
||||
handed the resulting venv with `--venv-path`. The install runs in the service rather than in the
|
||||
session because a private index URL usually embeds credentials and the Python server executes the
|
||||
debugged script inside its own interpreter, where anything it holds is readable by that script.
|
||||
|
||||
Set these on the debug service. Where two names are listed the first wins; a worker reads the
|
||||
`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are
|
||||
only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry
|
||||
with the other settings):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI |
|
||||
| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - |
|
||||
| `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - |
|
||||
| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - |
|
||||
| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false |
|
||||
| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match |
|
||||
| `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default |
|
||||
| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 |
|
||||
|
||||
When the install fails, the CLI answers `success: false` and carries the installer's stderr in both
|
||||
`error` and `install_stderr`; the service reports it to the client as an `output` event, so the
|
||||
reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a
|
||||
bare `ModuleNotFoundError` at the first import.
|
||||
|
||||
Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the
|
||||
service into each session, since the debugged script needs them for its own outbound calls, exactly
|
||||
as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults
|
||||
to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied.
|
||||
|
||||
Keeping the settings out of the session's environment only bounds what the debugged script can read
|
||||
from itself. An unsandboxed session runs under the same user as the service and can still read the
|
||||
service's environment through `/proc`, the same way a job can read a worker's when the worker runs
|
||||
unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config
|
||||
nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service
|
||||
out of reach, not the flag on its own.
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
```svelte
|
||||
|
||||
@@ -352,6 +352,45 @@ interface SpawnOptions {
|
||||
stderr?: 'pipe' | 'inherit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy settings forwarded to a debug session, matching what a worker gives a job's script.
|
||||
* spawnProcess intentionally does not inherit this process's environment, so an outbound proxy
|
||||
* is unreachable from a session unless these are passed explicitly. Registry settings are
|
||||
* deliberately absent: they carry credentials and are consumed by the service itself (see
|
||||
* PythonDebugSession.prepareDependencies).
|
||||
*/
|
||||
const SESSION_PROXY_ENV_VARS = [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
// The lowercase spellings take precedence in the worker, so forward both.
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy'
|
||||
]
|
||||
|
||||
/**
|
||||
* How long `windmill prepare-deps` may take before the session gives up on it and starts without
|
||||
* the dependencies. Raise it for slow private mirrors, where a large install can outlast the default.
|
||||
*/
|
||||
const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000
|
||||
|
||||
function sessionProxyEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const key of SESSION_PROXY_ENV_VARS) {
|
||||
const value = process.env[key]
|
||||
if (value) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
// A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it;
|
||||
// the worker defaults the same way (PROXY_ENVS in windmill-worker).
|
||||
if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) {
|
||||
env.NO_PROXY = 'localhost,127.0.0.1'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a process, optionally wrapped with nsjail.
|
||||
* This is the key function for sandboxed execution.
|
||||
@@ -489,6 +528,15 @@ abstract class BaseDebugSession {
|
||||
// Python Debug Session
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_DEBUGPY_TIMEOUT_MS = 10_000
|
||||
|
||||
// `launch` waits on dependency preparation in the Python server, which allows `windmill
|
||||
// prepare-deps` up to 120s; anything shorter here reports a timeout while the install is
|
||||
// still legitimately running.
|
||||
const DEBUGPY_TIMEOUT_MS_BY_COMMAND: Record<string, number> = {
|
||||
launch: 180_000
|
||||
}
|
||||
|
||||
class PythonDebugSession extends BaseDebugSession {
|
||||
private debugpyWs: WebSocket | null = null
|
||||
private debugpySeq = 1
|
||||
@@ -502,6 +550,7 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
private scriptResult: unknown = undefined
|
||||
private envVars: Record<string, string> = {}
|
||||
private windmillPath?: string
|
||||
private venvPath?: string
|
||||
private debugMode: boolean
|
||||
|
||||
constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) {
|
||||
@@ -531,11 +580,13 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
arguments: args
|
||||
}
|
||||
|
||||
const timeoutMs = DEBUGPY_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_DEBUGPY_TIMEOUT_MS
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingDebugpyRequests.delete(seq)
|
||||
reject(new Error(`Debugpy command timeout: ${command}`))
|
||||
}, 10000)
|
||||
reject(new Error(`Debugpy command timeout: ${command} (after ${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pendingDebugpyRequests.set(seq, {
|
||||
resolve: (value) => {
|
||||
@@ -627,6 +678,89 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the script's imports through `windmill prepare-deps` and return the venv to add to
|
||||
* the debugged script's sys.path.
|
||||
*
|
||||
* This runs here rather than in the Python server because the registry settings the CLI reads
|
||||
* (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python
|
||||
* server executes the submitted script inside its own interpreter: anything in that process is
|
||||
* recoverable by the script. The service never executes user code, so the credentials stop here.
|
||||
*
|
||||
* The trade-off is that the install itself is not jailed, so a source distribution's build
|
||||
* backend runs outside nsjail, as it already does for Bun sessions.
|
||||
*/
|
||||
private async prepareDependencies(code: string): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
}
|
||||
|
||||
const warn = (reason: string): null => {
|
||||
logger.error(`prepare-deps failed: ${reason}`)
|
||||
this.sendEvent('output', {
|
||||
category: 'stderr',
|
||||
output: `Failed to prepare dependencies: ${reason}\n`
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn({
|
||||
cmd: [this.windmillPath, 'prepare-deps'],
|
||||
stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
|
||||
// The launch response is already sent, so an install that never returns would leave the
|
||||
// client waiting on a session that never starts, with nothing on screen. The deadline
|
||||
// races the read rather than only killing the child: a grandchild holding the pipe open
|
||||
// keeps the read pending long after the child itself is gone.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const read = (async () => ({
|
||||
output: await new Response(proc.stdout).text(),
|
||||
stderr: await new Response(proc.stderr).text()
|
||||
}))()
|
||||
const result = await Promise.race([
|
||||
read,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), PREPARE_DEPS_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
clearTimeout(timer)
|
||||
|
||||
if (!result) {
|
||||
proc.kill()
|
||||
return warn(
|
||||
`dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
}
|
||||
const { output, stderr } = result
|
||||
|
||||
const lastLine = output.trim().split('\n').pop() || ''
|
||||
if (!lastLine.startsWith('{')) {
|
||||
return warn(stderr.trim() || 'windmill binary produced no response')
|
||||
}
|
||||
|
||||
const response = JSON.parse(lastLine)
|
||||
if (!response.success) {
|
||||
// install_stderr is the installer's raw output; `error` already contains it, so
|
||||
// prefer whichever the CLI version at hand provides.
|
||||
return warn(response.install_stderr || response.error || 'unknown error')
|
||||
}
|
||||
|
||||
if (response.venv_path) {
|
||||
logger.info(`Dependencies installed at: ${response.venv_path}`)
|
||||
} else {
|
||||
logger.info('No external dependencies to install')
|
||||
}
|
||||
return response.venv_path || null
|
||||
} catch (error) {
|
||||
return warn(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async startPythonProcess(cwd: string): Promise<void> {
|
||||
if (!this.scriptPath) {
|
||||
throw new Error('No script path')
|
||||
@@ -648,10 +782,11 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
'--host', '127.0.0.1'
|
||||
]
|
||||
|
||||
// Pass windmill path for dependency auto-installation if configured
|
||||
if (this.windmillPath) {
|
||||
cmd.push('--windmill', this.windmillPath)
|
||||
logger.info(`Python session: autoinstall enabled with windmill at ${this.windmillPath}`)
|
||||
// Dependencies are installed by the service (see prepareDependencies), so the server is
|
||||
// handed the resulting venv instead of the windmill binary it would install with.
|
||||
if (this.venvPath) {
|
||||
cmd.push('--venv-path', this.venvPath)
|
||||
logger.info(`Python session: using dependencies at ${this.venvPath}`)
|
||||
}
|
||||
|
||||
// Pass debug flag to Python subprocess
|
||||
@@ -662,7 +797,7 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.process = spawnProcess({
|
||||
cmd,
|
||||
cwd,
|
||||
env: { PYTHONUNBUFFERED: '1', ...this.envVars }
|
||||
env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars }
|
||||
})
|
||||
|
||||
// Read stderr to capture startup messages
|
||||
@@ -794,6 +929,13 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.debugpyWs.onclose = () => {
|
||||
logger.info('Debugpy WebSocket closed')
|
||||
this.debugpyWs = null
|
||||
// A Python server that dies mid-request must fail it now; otherwise the caller
|
||||
// waits out the command timeout, which for `launch` is minutes.
|
||||
const aborted = Array.from(this.pendingDebugpyRequests.values())
|
||||
this.pendingDebugpyRequests.clear()
|
||||
for (const pending of aborted) {
|
||||
pending.reject(new Error('Debugpy connection closed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -987,6 +1129,10 @@ sys.stdout.flush()
|
||||
this.sendResponse(request)
|
||||
|
||||
try {
|
||||
if (code) {
|
||||
this.venvPath = (await this.prepareDependencies(code)) ?? undefined
|
||||
}
|
||||
|
||||
await this.startPythonProcess(cwd)
|
||||
|
||||
// Re-apply breakpoints to the Python server using the actual script path
|
||||
@@ -1012,7 +1158,13 @@ sys.stdout.flush()
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendEvent('output', { category: 'stderr', output: `Failed to start Python: ${error}\n` })
|
||||
// Claim the terminated event before cleanup kills the process, otherwise the
|
||||
// `exited` handler sends a second one whose empty body erases this error.
|
||||
this.terminatedSent = true
|
||||
this.sendEvent('terminated', { error: String(error) })
|
||||
// A Python server that refused the launch stays in its connection loop, so
|
||||
// nothing else ever reaps it, its websocket or the temp dir.
|
||||
await this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,12 +277,55 @@ class WindmillDebugger(bdb.Bdb):
|
||||
return {}
|
||||
|
||||
|
||||
PREPARE_DEPS_TIMEOUT_SECONDS = 120
|
||||
PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrepareResult:
|
||||
"""
|
||||
Outcome of dependency preparation.
|
||||
|
||||
`error` holds anything worth telling the user, including a problem reported by an
|
||||
otherwise successful preparation. Only `fatal` means the packages are known to be
|
||||
missing: failing to reach the CLI at all says nothing about the script's imports and
|
||||
must not block a session that would otherwise run.
|
||||
"""
|
||||
|
||||
venv_path: str | None = None
|
||||
error: str | None = None
|
||||
fatal: bool = False
|
||||
|
||||
|
||||
def _prepare_error_detail(response: dict) -> str:
|
||||
"""
|
||||
Build the failure reason from a prepare-deps response.
|
||||
|
||||
`error` is the installer's own output prefixed with the step that failed, and
|
||||
`install_stderr` is that same output unprefixed, so take one rather than both: joining
|
||||
them prints the installer's output twice, and the prefix is what tells a reader whether
|
||||
the venv or the install was what went wrong.
|
||||
"""
|
||||
for key in ("error", "install_stderr"):
|
||||
detail = str(response.get(key) or "").strip()
|
||||
if detail:
|
||||
return detail
|
||||
return "unknown error"
|
||||
|
||||
|
||||
def _first_line(detail: str, limit: int = 300) -> str:
|
||||
"""Condense a multi-line failure into the single line a DAP response message allows."""
|
||||
line = next((s.strip() for s in detail.splitlines() if s.strip()), detail.strip())
|
||||
return line[:limit]
|
||||
|
||||
|
||||
class DebugSession:
|
||||
"""Manages a single debug session."""
|
||||
|
||||
def __init__(self, websocket, windmill_path: str | None = None):
|
||||
def __init__(self, websocket, windmill_path: str | None = None, prepared_venv_path: str | None = None):
|
||||
self.websocket = websocket
|
||||
self.windmill_path = windmill_path
|
||||
self._prepared_venv_path = prepared_venv_path
|
||||
self.seq = 1
|
||||
self.initialized = False
|
||||
self.configured = False
|
||||
@@ -304,14 +347,22 @@ class DebugSession:
|
||||
self.seq += 1
|
||||
return seq
|
||||
|
||||
def prepare_dependencies(self, code: str) -> str | None:
|
||||
def prepare_dependencies(self, code: str) -> PrepareResult:
|
||||
"""
|
||||
Prepare Python dependencies by calling the windmill CLI.
|
||||
Returns the path to the venv's site-packages directory, or None if no dependencies needed.
|
||||
|
||||
Blocks for as long as the install takes, so it must run off the event loop; use
|
||||
`_prepare_dependencies_with_progress` instead of calling this directly.
|
||||
"""
|
||||
if self._prepared_venv_path:
|
||||
# The debug service installs dependencies itself so that the registry credentials
|
||||
# the CLI needs never enter this interpreter, which executes the debugged script.
|
||||
logger.info(f"Using dependencies prepared by the debug service: {self._prepared_venv_path}")
|
||||
return PrepareResult(venv_path=self._prepared_venv_path)
|
||||
|
||||
if not self.windmill_path:
|
||||
logger.info("No windmill binary path configured, skipping dependency preparation")
|
||||
return None
|
||||
return PrepareResult()
|
||||
|
||||
logger.info(f"Preparing dependencies using {self.windmill_path}")
|
||||
|
||||
@@ -328,7 +379,7 @@ class DebugSession:
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minute timeout for dependency installation
|
||||
timeout=PREPARE_DEPS_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
@@ -337,7 +388,11 @@ class DebugSession:
|
||||
if result.returncode != 0:
|
||||
logger.error(f"prepare-deps failed (stderr): {result.stderr}")
|
||||
logger.error(f"prepare-deps failed (stdout): {result.stdout}")
|
||||
return None
|
||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
return PrepareResult(
|
||||
error=detail or f"windmill prepare-deps exited with code {result.returncode}",
|
||||
fatal=True,
|
||||
)
|
||||
|
||||
# Log raw output for debugging
|
||||
logger.debug(f"prepare-deps stdout: {result.stdout[:500] if result.stdout else '(empty)'}")
|
||||
@@ -350,15 +405,18 @@ class DebugSession:
|
||||
json_start = output.find('{')
|
||||
if json_start == -1:
|
||||
logger.error(f"No JSON in prepare-deps output: {output}")
|
||||
return None
|
||||
return PrepareResult(
|
||||
error=f"No JSON in prepare-deps output: {output[:500] or '(empty)'}"
|
||||
)
|
||||
|
||||
json_str = output[json_start:]
|
||||
response = json.loads(json_str)
|
||||
logger.debug(f"prepare-deps response: {response}")
|
||||
|
||||
if not response.get("success"):
|
||||
logger.error(f"prepare-deps error: {response.get('error')}")
|
||||
return None
|
||||
detail = _prepare_error_detail(response)
|
||||
logger.error(f"prepare-deps error: {detail}")
|
||||
return PrepareResult(error=detail, fatal=True)
|
||||
|
||||
venv_path = response.get("venv_path")
|
||||
cached = response.get("cached", False)
|
||||
@@ -371,18 +429,50 @@ class DebugSession:
|
||||
else:
|
||||
logger.info("No external dependencies detected in code")
|
||||
|
||||
return venv_path
|
||||
return PrepareResult(venv_path=venv_path)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("prepare-deps timed out after 120s")
|
||||
return None
|
||||
message = f"prepare-deps timed out after {PREPARE_DEPS_TIMEOUT_SECONDS}s"
|
||||
logger.error(message)
|
||||
return PrepareResult(error=message, fatal=True)
|
||||
except json.JSONDecodeError as e:
|
||||
raw = output[:500] if 'output' in dir() else '(not available)'
|
||||
logger.error(f"Failed to parse prepare-deps JSON output: {e}")
|
||||
logger.error(f"Raw output was: {output[:500] if 'output' in dir() else '(not available)'}")
|
||||
return None
|
||||
logger.error(f"Raw output was: {raw}")
|
||||
return PrepareResult(error=f"Failed to parse prepare-deps output: {e}\n{raw}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error preparing dependencies: {e}")
|
||||
return None
|
||||
return PrepareResult(error=f"Error preparing dependencies: {e}")
|
||||
|
||||
async def _prepare_dependencies_with_progress(self, code: str) -> PrepareResult:
|
||||
"""
|
||||
Run dependency preparation on a worker thread, reporting progress while it runs.
|
||||
|
||||
The install can take minutes on a cold cache; on the event loop it would stall
|
||||
websocket keepalive until it returns and block the progress events below.
|
||||
"""
|
||||
await self.send_event(
|
||||
"output", {"category": "stdout", "output": "Preparing dependencies...\n"}
|
||||
)
|
||||
|
||||
task = asyncio.create_task(asyncio.to_thread(self.prepare_dependencies, code))
|
||||
waited = 0
|
||||
while True:
|
||||
done, _ = await asyncio.wait(
|
||||
{task}, timeout=PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS
|
||||
)
|
||||
if done:
|
||||
break
|
||||
waited += PREPARE_DEPS_PROGRESS_INTERVAL_SECONDS
|
||||
await self.send_event(
|
||||
"output",
|
||||
{
|
||||
"category": "stdout",
|
||||
"output": f"Still preparing dependencies... ({waited}s)\n",
|
||||
},
|
||||
)
|
||||
|
||||
return task.result()
|
||||
|
||||
def _next_var_ref(self) -> int:
|
||||
ref = self._variables_ref_counter
|
||||
@@ -521,7 +611,25 @@ class DebugSession:
|
||||
|
||||
# Prepare dependencies before modifying the code
|
||||
if code:
|
||||
self._venv_path = self.prepare_dependencies(code)
|
||||
prepared = await self._prepare_dependencies_with_progress(code)
|
||||
if prepared.error:
|
||||
prefix = (
|
||||
"Failed to prepare dependencies"
|
||||
if prepared.fatal
|
||||
else "Warning: dependency preparation reported a problem, running anyway"
|
||||
)
|
||||
await self.send_event(
|
||||
"output",
|
||||
{"category": "stderr", "output": f"{prefix}:\n{prepared.error}\n"},
|
||||
)
|
||||
if prepared.fatal:
|
||||
await self.send_response(
|
||||
request,
|
||||
success=False,
|
||||
message=f"Failed to prepare dependencies: {_first_line(prepared.error)}",
|
||||
)
|
||||
return
|
||||
self._venv_path = prepared.venv_path
|
||||
|
||||
# If callMain is True, append a call to main() with the provided args
|
||||
if self._call_main and code:
|
||||
@@ -894,13 +1002,17 @@ class DebugSession:
|
||||
)
|
||||
|
||||
|
||||
# Module-level variable to store windmill binary path
|
||||
# Module-level variables to store the windmill binary path and, when the debug service
|
||||
# already installed the script's dependencies, the venv to use instead of installing here.
|
||||
_windmill_path: str | None = None
|
||||
_prepared_venv_path: str | None = None
|
||||
|
||||
|
||||
async def handle_connection(websocket) -> None:
|
||||
"""Handle a WebSocket connection."""
|
||||
session = DebugSession(websocket, windmill_path=_windmill_path)
|
||||
session = DebugSession(
|
||||
websocket, windmill_path=_windmill_path, prepared_venv_path=_prepared_venv_path
|
||||
)
|
||||
logger.info(f"New connection from {websocket.remote_address}")
|
||||
|
||||
try:
|
||||
@@ -924,13 +1036,21 @@ async def handle_connection(websocket) -> None:
|
||||
session._cleanup_temp_file()
|
||||
|
||||
|
||||
async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None:
|
||||
async def main(
|
||||
host: str = "localhost",
|
||||
port: int = 5679,
|
||||
windmill_path: str | None = None,
|
||||
prepared_venv_path: str | None = None,
|
||||
) -> None:
|
||||
"""Start the DAP WebSocket server."""
|
||||
global _windmill_path
|
||||
global _windmill_path, _prepared_venv_path
|
||||
_windmill_path = windmill_path
|
||||
_prepared_venv_path = prepared_venv_path
|
||||
|
||||
if windmill_path:
|
||||
logger.info(f"Windmill binary path: {windmill_path}")
|
||||
if prepared_venv_path:
|
||||
logger.info(f"Dependencies prepared by the debug service: {prepared_venv_path}")
|
||||
logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}")
|
||||
|
||||
async with serve(handle_connection, host, port):
|
||||
@@ -944,6 +1064,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--host", default="localhost", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=5679, help="Port to listen on")
|
||||
parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)")
|
||||
parser.add_argument("--venv-path", help="Site-packages directory of a venv the caller already prepared; skips dependency installation")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -957,6 +1078,6 @@ if __name__ == "__main__":
|
||||
windmill_path = args.windmill or os.environ.get("WINDMILL_PATH")
|
||||
|
||||
try:
|
||||
asyncio.run(main(args.host, args.port, windmill_path))
|
||||
asyncio.run(main(args.host, args.port, windmill_path, args.venv_path))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped")
|
||||
|
||||
@@ -222,6 +222,8 @@ function generateMainCallArgs(code: string, args: Record<string, unknown>): stri
|
||||
const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL // e.g., http://localhost:8000
|
||||
const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_DEBUG_REQUESTS !== 'false'
|
||||
|
||||
const PREPARE_DEPS_TIMEOUT_MS = 120_000
|
||||
|
||||
// Opt-in cross-origin protection (CSWSH defense-in-depth); see
|
||||
// dap_debug_service.ts for the rationale. Only enforced for this file's
|
||||
// standalone Bun.serve entrypoint (the windmill-extra runtime imports the
|
||||
@@ -1579,6 +1581,20 @@ export class DebugSession {
|
||||
|
||||
logger.info(`Preparing dependencies using ${this.windmillPath}`)
|
||||
|
||||
// The launch response is only sent once this returns, so without progress a cold
|
||||
// cache looks like a frozen debugger for as long as the install takes.
|
||||
this.sendEvent('output', { category: 'console', output: 'Preparing dependencies...\n' })
|
||||
let waited = 0
|
||||
const progress = setInterval(() => {
|
||||
waited += 5
|
||||
this.sendEvent('output', {
|
||||
category: 'console',
|
||||
output: `Still preparing dependencies... (${waited}s)\n`
|
||||
})
|
||||
}, 5000)
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let timedOut = false
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ code, language }) + '\n'
|
||||
logger.info(`prepare-deps input length: ${input.length}`)
|
||||
@@ -1591,9 +1607,27 @@ export class DebugSession {
|
||||
stderr: 'pipe'
|
||||
})
|
||||
|
||||
// Bound the wait: the only other ceiling is the DAP client's launch timeout,
|
||||
// which is minutes, so a wedged installer would hang the session that long.
|
||||
killTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`)
|
||||
proc.kill()
|
||||
}, PREPARE_DEPS_TIMEOUT_MS)
|
||||
|
||||
// Wait for completion
|
||||
const output = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (timedOut) {
|
||||
const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s`
|
||||
this.sendEvent('output', {
|
||||
category: 'console',
|
||||
output: `Warning: Failed to prepare dependencies: ${errorMsg}\n`
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`prepare-deps output: ${output.substring(0, 200)}`)
|
||||
logger.info(`prepare-deps stderr: ${stderr.substring(0, 200)}`)
|
||||
|
||||
@@ -1648,6 +1682,9 @@ export class DebugSession {
|
||||
output: `Warning: Failed to prepare dependencies: ${error}\n`
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
clearInterval(progress)
|
||||
clearTimeout(killTimer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ def main(x: str, count: int = 1):
|
||||
# Breakpoints for the main() test: lines 3 and 4 (inside main function)
|
||||
MAIN_BREAKPOINT_LINES = [3, 4]
|
||||
|
||||
# `launch` waits on dependency installation, so the import test below needs far more than
|
||||
# the default budget on a cold cache.
|
||||
REQUEST_TIMEOUTS = {"launch": 180.0}
|
||||
|
||||
|
||||
class DAPTestClient:
|
||||
def __init__(self, url: str = "ws://localhost:5679"):
|
||||
@@ -103,7 +107,7 @@ class DAPTestClient:
|
||||
|
||||
# Wait for response with timeout
|
||||
try:
|
||||
response = await asyncio.wait_for(future, timeout=10.0)
|
||||
response = await asyncio.wait_for(future, timeout=REQUEST_TIMEOUTS.get(command, 10.0))
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
print(f"Timeout waiting for response to {command}")
|
||||
|
||||
@@ -719,7 +719,8 @@
|
||||
onkeydown: () => (ignoreValueUndefined = true),
|
||||
placeholder: placeholder ?? defaultValue ?? '',
|
||||
min: extra['min'],
|
||||
max: extra['max']
|
||||
max: extra['max'],
|
||||
step: extra['step']
|
||||
}}
|
||||
{error}
|
||||
bind:value
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
/** False while the (async) chatMask is still loading. The select-all default
|
||||
* waits for this so it doesn't race the mask. Defaults to true. */
|
||||
chatMaskReady?: boolean
|
||||
/** Whether the mask also scopes the update direction (parent→fork). True only
|
||||
* for an explicit `?items=` deep link, which can legitimately name items to
|
||||
* pull in — a session's mask never can (see the preselect rule below). */
|
||||
maskAppliesToUpdate?: boolean
|
||||
/** Selecting `draft` asks the page to swap us out for CompareDrafts;
|
||||
* deploy_to/update are handled internally but reported so the page can
|
||||
* remember the direction. */
|
||||
@@ -146,6 +150,7 @@
|
||||
draftKeys = new Set<string>(),
|
||||
chatMask,
|
||||
chatMaskReady = true,
|
||||
maskAppliesToUpdate = false,
|
||||
onModeSelected,
|
||||
onChanged
|
||||
}: Props = $props()
|
||||
@@ -901,8 +906,10 @@
|
||||
// Items with a pending draft are also left out by default: the deployed
|
||||
// version (not the draft) is what moves, so we make the user opt in.
|
||||
// The update direction (parent→fork) is never something the chat caused, so
|
||||
// when scoped to a chat's items (chatMask set) preselect nothing there.
|
||||
if (chatMask && !mergeIntoParent) {
|
||||
// when scoped to a chat's items (chatMask set) preselect nothing there —
|
||||
// unless the mask came in as an explicit `?items=` deep link, which names
|
||||
// what to pull.
|
||||
if (chatMask && !mergeIntoParent && !maskAppliesToUpdate) {
|
||||
selectedItems = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -932,7 +932,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.splitter-hidden .splitpanes__splitter) {
|
||||
/* Direct child only: a descendant selector leaks into nested Splitpanes (e.g. the
|
||||
sessions preview reuses `.splitter-hidden`, which would otherwise hide the flow
|
||||
editor / modal splitters too). */
|
||||
:global(.splitter-hidden > .splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0 !important;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { buildWsUrl } from '$lib/wsUrl'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
|
||||
|
||||
// import libStdContent from '$lib/es6.d.ts.txt?raw'
|
||||
// import domContent from '$lib/dom.d.ts.txt?raw'
|
||||
@@ -1670,9 +1670,11 @@
|
||||
// Monaco swallows the keydown (addCommand prevents default and
|
||||
// stops propagation), so page-level Ctrl/Cmd+S handlers never
|
||||
// see it. Re-broadcast as a window event so editors that flush
|
||||
// a draft on the shortcut (raw apps) can react regardless of
|
||||
// which Monaco has focus.
|
||||
window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))
|
||||
// a draft on the shortcut can react regardless of which Monaco
|
||||
// has focus. Only after `tick()`: the autosave payload is parked
|
||||
// by a `$effect`, so dispatching synchronously would make every
|
||||
// listener flush the state from before `updateCode()`.
|
||||
void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')))
|
||||
})
|
||||
|
||||
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
|
||||
@@ -1971,7 +1973,13 @@
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('destroying editor')
|
||||
// Flush a pending keystroke debounce, or unmounting discards the last
|
||||
// `changeTimeout` ms of typing. Both guards are load-bearing: without a pending
|
||||
// timer Monaco isn't the newer side (an external write may be), and before init
|
||||
// `getCode()` is '' — flushing either case overwrites real content.
|
||||
if (editor && timeoutModel !== undefined) {
|
||||
updateCode()
|
||||
}
|
||||
valueAfterDispose = getCode()
|
||||
pasteCleanup?.()
|
||||
destroyed = true
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
import type { FlowBuilderProps } from './flow_builder'
|
||||
import { ModulesTestStates } from './modulesTest.svelte'
|
||||
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
|
||||
@@ -199,6 +199,30 @@
|
||||
// (session pane, drawer, etc.) where the viewport stays wide.
|
||||
let topbarWidth = $state(0)
|
||||
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
|
||||
|
||||
const diffEnabled = $derived(customUi?.topBar?.diff != false)
|
||||
// Nothing to compare against until a deployed version exists.
|
||||
const diffDisabled = $derived(!savedFlow || newFlow || savedFlow?.no_deployed === true)
|
||||
const diffTitle = $derived(
|
||||
diffDisabled ? 'Deploy this flow once to compare against the deployed version' : 'Diff'
|
||||
)
|
||||
// The narrow bar (sessions) and the width-collapsed one have no room for a Diff
|
||||
// button, so it moves into the menu ahead of Deployment History instead of
|
||||
// dropping out of reach.
|
||||
const diffInMenu = $derived(condensedHeader || compactTopbar)
|
||||
const diffMenuItems: Item[] = $derived(
|
||||
diffEnabled && diffInMenu
|
||||
? [
|
||||
{
|
||||
displayName: 'Diff',
|
||||
icon: DiffIcon,
|
||||
action: () => openDiffDrawer(),
|
||||
disabled: diffDisabled,
|
||||
tooltip: diffDisabled ? diffTitle : undefined
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
|
||||
|
||||
// AI changes warning modal
|
||||
@@ -327,6 +351,15 @@
|
||||
})
|
||||
}
|
||||
|
||||
// Monaco swallows the keydown, so an editor with focus never reaches the
|
||||
// window handler; Editor/SimpleEditor/TemplateEditor re-broadcast it
|
||||
// (untyped event, hence the manual listener). A step's code editor also
|
||||
// flushes through its `formatAction`, and a redundant flush is a no-op.
|
||||
$effect(() => {
|
||||
window.addEventListener('wm-monaco-save-shortcut', saveDraft)
|
||||
return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft)
|
||||
})
|
||||
|
||||
// Materialize a brand-new flow's draft before the session preview loads it by
|
||||
// path — an untouched new flow never autosaved, so forcePersist is the only
|
||||
// thing that creates the row. Gated to never-deployed: forcePersist skips the
|
||||
@@ -751,8 +784,10 @@
|
||||
|
||||
const stepsInputArgs = new StepsInputArgs()
|
||||
|
||||
// Every caller is a deliberate "show me that panel" action (a toolbar button, the
|
||||
// preview's trigger shortcut), so the panel must open even in modal mode.
|
||||
function select(selectedId: string) {
|
||||
selectionManager.selectId(selectedId)
|
||||
selectionManager.selectId(selectedId, { openPanel: true })
|
||||
}
|
||||
|
||||
let insertButtonOpen = writable<boolean>(false)
|
||||
@@ -863,7 +898,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
selectionManager.selectId('Input')
|
||||
// Undo restores a selection as a side effect; it is not a request to see Input.
|
||||
selectionManager.selectId('Input', { openPanel: false })
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
@@ -901,7 +937,9 @@
|
||||
}
|
||||
break
|
||||
case 's':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Shift excluded: the switch lowercases so Ctrl+Shift+S lands here
|
||||
// too, and swallowing it would steal the browser/OS shortcut.
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
|
||||
saveDraft()
|
||||
event.preventDefault()
|
||||
}
|
||||
@@ -911,7 +949,9 @@
|
||||
let ids = generateIds()
|
||||
let idx = ids.indexOf(selectedIdStore!)
|
||||
if (idx > -1 && idx < ids.length - 1) {
|
||||
selectionManager.selectId(ids[idx + 1])
|
||||
// Traversal, not a request to see any one panel: the ids list starts with
|
||||
// flow-level entries, and opening the modal mid-walk swallows the arrows.
|
||||
selectionManager.selectId(ids[idx + 1], { openPanel: false })
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -922,7 +962,7 @@
|
||||
let ids = generateIds()
|
||||
let idx = ids.indexOf(selectedIdStore!)
|
||||
if (idx > 0 && idx < ids.length) {
|
||||
selectionManager.selectId(ids[idx - 1])
|
||||
selectionManager.selectId(ids[idx - 1], { openPanel: false })
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -945,7 +985,9 @@
|
||||
onClick: () => void
|
||||
}> = []
|
||||
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
|
||||
// In a session pane every one of these leaves the session (details page, new
|
||||
// tab), so the deploy button carries no dropdown there — as in ScriptBuilder.
|
||||
if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) {
|
||||
if (!newFlow) {
|
||||
dropdownItems.push({
|
||||
label: 'Exit & see details',
|
||||
@@ -970,7 +1012,7 @@
|
||||
) {
|
||||
dropdownItems.push({
|
||||
label: editInForkLabel(opWorkspace, $userWorkspaces),
|
||||
onClick: () => window.open(buildForkEditUrl('flow', initialPath))
|
||||
onClick: () => openEditInFork('flow', initialPath, opWorkspace)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1028,15 +1070,16 @@
|
||||
const mod = isMac() ? '⌘' : 'Ctrl+'
|
||||
|
||||
function getMoreItems(): Item[] {
|
||||
const leadingItems = [...diffMenuItems, ...baseMenuItems]
|
||||
return [
|
||||
...baseMenuItems,
|
||||
...leadingItems,
|
||||
{
|
||||
displayName: 'Undo',
|
||||
icon: Undo,
|
||||
action: () => handleUndo(),
|
||||
disabled: $history.index === 0,
|
||||
shortcut: `${mod}Z`,
|
||||
separatorTop: baseMenuItems.length > 0
|
||||
separatorTop: leadingItems.length > 0
|
||||
},
|
||||
{
|
||||
displayName: 'Redo',
|
||||
@@ -1369,13 +1412,7 @@
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if customUi?.topBar?.diff != false}
|
||||
{@const isDraftOnly = savedFlow?.no_deployed === true}
|
||||
{@const diffDisabled = !savedFlow || newFlow || isDraftOnly}
|
||||
{@const diffTitle =
|
||||
newFlow || isDraftOnly
|
||||
? 'Deploy this flow once to compare against the deployed version'
|
||||
: 'Diff'}
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
it never shows on hover. pointer-events-none on the button lets the
|
||||
hover reach this titled wrapper instead. -->
|
||||
@@ -1386,7 +1423,6 @@
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={diffDisabled}
|
||||
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
||||
iconOnly={compactTopbar}
|
||||
title={diffTitle}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
@@ -1443,6 +1479,7 @@
|
||||
{disabledFlowInputs}
|
||||
disableAi={disableAi || customUi?.stepInputs?.ai == false}
|
||||
disableSettings={customUi?.settingsPanel === false}
|
||||
modalPanel={customUi?.modalPanel != false}
|
||||
{loading}
|
||||
on:reload={() => {
|
||||
renderCount += 1
|
||||
@@ -1464,7 +1501,7 @@
|
||||
{savedFlow}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
onEditInput={(moduleId, key) => {
|
||||
selectionManager.selectId(moduleId)
|
||||
selectionManager.selectId(moduleId, { openPanel: true })
|
||||
// Use new prop-based system
|
||||
forceTestTab[moduleId] = true
|
||||
highlightArg[moduleId] = key
|
||||
|
||||
@@ -5,25 +5,50 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
connecting: boolean;
|
||||
id?: undefined | string;
|
||||
wrapperClasses?: string;
|
||||
connecting: boolean
|
||||
id?: undefined | string
|
||||
wrapperClasses?: string
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
let { connecting, id = undefined, wrapperClasses = '' }: Props = $props();
|
||||
let {
|
||||
connecting,
|
||||
id = undefined,
|
||||
wrapperClasses = '',
|
||||
disabled = false,
|
||||
title = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// The ring is masked by an ::after that resolves `background: inherit` up to whatever
|
||||
// encloses the button. Give it an opaque ground while animating, or the gradient shows
|
||||
// straight through. It can't go on the wrapper itself — the scoped `.gradient-button`
|
||||
// rule outranks a utility class there.
|
||||
const animating = $derived(connecting)
|
||||
</script>
|
||||
|
||||
<AnimatedButton animate={connecting} baseRadius="6px" animationDuration="2s" marginWidth="2px">
|
||||
<Button
|
||||
variant="default"
|
||||
btnClasses={twMerge(
|
||||
connecting ? 'text-accent' : '',
|
||||
'bg-surface hover:bg-surface-hover group/plug-btn overflow-clip flex p-0'
|
||||
)}
|
||||
on:click
|
||||
{...id ? { id } : {}}
|
||||
{wrapperClasses}
|
||||
>
|
||||
<Plug size={14} />
|
||||
</Button>
|
||||
</AnimatedButton>
|
||||
<div class="flex {animating ? 'bg-surface rounded-md' : ''}">
|
||||
<AnimatedButton animate={animating} baseRadius="6px" animationDuration="2s" marginWidth="2px">
|
||||
<Button
|
||||
variant="default"
|
||||
btnClasses={twMerge(
|
||||
connecting ? 'text-accent' : '',
|
||||
'bg-surface hover:bg-surface-hover group/plug-btn overflow-clip flex p-0'
|
||||
)}
|
||||
on:click
|
||||
{disabled}
|
||||
{...title ? { title } : {}}
|
||||
{...id ? { id } : {}}
|
||||
wrapperClasses={twMerge(
|
||||
// h-5 matches ButtonType.UnifiedHeightClasses.xs, the height of the
|
||||
// static/expression switch it sits next to. Shrink by exactly the animated
|
||||
// ring's margin so the footprint holds and nothing beside it shifts.
|
||||
animating ? 'h-4 w-7' : 'h-5 w-8',
|
||||
'p-0',
|
||||
wrapperClasses
|
||||
)}
|
||||
>
|
||||
<Plug size={12} />
|
||||
</Button>
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
|
||||
@@ -638,7 +638,6 @@
|
||||
<div class="w-full my-6">
|
||||
<FlowExecutionStatus
|
||||
{job}
|
||||
workspaceId={opWs}
|
||||
{isOwner}
|
||||
innerModules={job?.flow_status?.modules}
|
||||
{suspendStatus}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { mergeSchema } from '$lib/common'
|
||||
import { type Job, JobService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { ExternalLink, X } from 'lucide-svelte'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
@@ -14,6 +13,8 @@
|
||||
|
||||
interface Props {
|
||||
isOwner: boolean
|
||||
/** The workspace the job ran in — pass `job.workspace_id`, never the navigation
|
||||
* workspace, which differs whenever the editor is embedded. */
|
||||
workspaceId: string | undefined
|
||||
job: Job
|
||||
light?: boolean
|
||||
@@ -39,12 +40,12 @@
|
||||
if (jobId === lastJobId) {
|
||||
return
|
||||
}
|
||||
if (!jobId) {
|
||||
if (!jobId || !workspaceId) {
|
||||
return {}
|
||||
}
|
||||
lastJobId = jobId
|
||||
let job_result = (await JobService.getCompletedJobResult({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
workspace: workspaceId,
|
||||
id: jobId
|
||||
})) as any
|
||||
const args = job_result?.default_args ?? {}
|
||||
@@ -64,10 +65,14 @@
|
||||
let loading = $state(false)
|
||||
let actionTaken = $state(false)
|
||||
async function continu(approve: boolean) {
|
||||
if (!workspaceId) {
|
||||
sendUserToast('Cannot resume: the job has no workspace', true)
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
workspace: workspaceId,
|
||||
jobId: job?.id ?? '',
|
||||
requestBody: {
|
||||
payload: approve ? (default_payload as any) : undefined,
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
{flowStateStore}
|
||||
{disableAi}
|
||||
{...props}
|
||||
customUi={{ modalPanel: false, ...props.customUi }}
|
||||
liveEditorDraftStoragePath={draftStoragePath || undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
<div>
|
||||
<IconedResourceType width="20px" height="20px" name={path.split('/')[2]} silent={true} />
|
||||
</div>
|
||||
<span class="text-sm truncate">{path}</span>
|
||||
<span class="truncate text-xs text-primary">{path}</span>
|
||||
{:else}
|
||||
<div class="center-center">
|
||||
<Building size={16} />
|
||||
</div>
|
||||
<span class="text-sm truncate">{path}</span>
|
||||
<span class="truncate text-xs text-primary">{path}</span>
|
||||
{#if hash}
|
||||
<Badge>{truncateHash(hash)}</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<script lang="ts">
|
||||
import type { Schema } from '$lib/common'
|
||||
import type { InputCat, DynamicInput as DynamicInputTypes } from '$lib/utils'
|
||||
import { createEventDispatcher, getContext, onDestroy, untrack } from 'svelte'
|
||||
import { createEventDispatcher, getContext, onDestroy, untrack, type Snippet } from 'svelte'
|
||||
import { computeShow } from '$lib/utils'
|
||||
|
||||
import ArgInput from './ArgInput.svelte'
|
||||
@@ -37,15 +37,33 @@
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import FlowPlugConnect from './FlowPlugConnect.svelte'
|
||||
import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import S3ArrayHelperButton from './S3ArrayHelperButton.svelte'
|
||||
import { inputBorderClass } from './text_input/TextInput.svelte'
|
||||
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
import InputError from './InputError.svelte'
|
||||
|
||||
interface Props {
|
||||
schema: Schema | { properties?: Record<string, any>; required?: string[] }
|
||||
arg: InputTransform | any
|
||||
argName: string
|
||||
/** Display name, when the schema key isn't what the user should read. */
|
||||
label?: string
|
||||
/** Replaces the label header, so a setting's own toggle can name the field. */
|
||||
header?: Snippet
|
||||
/** The kind this field always holds, for a value that doesn't carry a `type` of its
|
||||
* own — a flow predicate is stored as a bare `{ expr }`. */
|
||||
argType?: InputTransform['type']
|
||||
/** Keep only the header: the setting owning this field is switched off, so there is
|
||||
* no value yet. Rendering the header here (rather than swapping in a bare toggle
|
||||
* outside) keeps one persistent row, so the field can slide in and out under it. */
|
||||
collapsed?: boolean
|
||||
/** Slide the input in and out as `collapsed` flips. */
|
||||
animateAppear?: boolean
|
||||
/** Message shown under the field, which also turns its border red. */
|
||||
error?: string | undefined
|
||||
headerTooltip?: string | undefined
|
||||
headerTooltipIconClass?: string
|
||||
HeaderTooltipIcon?: any
|
||||
@@ -55,7 +73,16 @@
|
||||
pickForField?: string | undefined
|
||||
variableEditor?: VariableEditor | undefined
|
||||
itemPicker?: ItemPicker | undefined
|
||||
/** Hide the static/expression switch, for a field that only ever holds one kind.
|
||||
* The connect button and the AI helper stay. */
|
||||
noDynamicToggle?: boolean
|
||||
/** Replaces the default StepInputGen, for a field with its own AI helper. That
|
||||
* helper drives `suggestion` (its ghost text) and `aiOnKeyUp` (Tab to accept),
|
||||
* which the built-in one reaches through `stepInputGen` instead. */
|
||||
aiGen?: Snippet
|
||||
suggestion?: string
|
||||
focused?: boolean
|
||||
aiOnKeyUp?: (e: KeyboardEvent) => void
|
||||
argExtra?: Record<string, any>
|
||||
pickableProperties?: PickableProperties | undefined
|
||||
enableAi?: boolean
|
||||
@@ -74,6 +101,12 @@
|
||||
schema = $bindable(),
|
||||
arg = $bindable(),
|
||||
argName = $bindable(),
|
||||
label = undefined,
|
||||
header = undefined,
|
||||
argType = undefined,
|
||||
collapsed = false,
|
||||
animateAppear = false,
|
||||
error = undefined,
|
||||
headerTooltip = undefined,
|
||||
headerTooltipIconClass = '',
|
||||
HeaderTooltipIcon = InfoIcon,
|
||||
@@ -84,6 +117,10 @@
|
||||
variableEditor = undefined,
|
||||
itemPicker = undefined,
|
||||
noDynamicToggle = false,
|
||||
aiGen = undefined,
|
||||
suggestion = $bindable(),
|
||||
focused = $bindable(),
|
||||
aiOnKeyUp = undefined,
|
||||
argExtra = {},
|
||||
pickableProperties = undefined,
|
||||
enableAi = false,
|
||||
@@ -119,6 +156,11 @@
|
||||
|
||||
const propPickerWrapperContext: PropPickerWrapperContext | undefined =
|
||||
getContext<PropPickerWrapperContext>('PropPickerWrapper')
|
||||
const pickerMode = $derived(propPickerWrapperContext?.pickerMode?.() ?? 'pane')
|
||||
// Settings rows hand their properties to the wrapper, not to this form.
|
||||
const connectableProperties = $derived(
|
||||
pickableProperties ?? propPickerWrapperContext?.pickableProperties?.()
|
||||
)
|
||||
const {
|
||||
inputMatches,
|
||||
connectProp: focusProp,
|
||||
@@ -142,7 +184,11 @@
|
||||
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
|
||||
)
|
||||
|
||||
let propertyType = $state(getPropertyType(arg))
|
||||
// `argType` wins over whatever the value carries: a predicate has no `type` field, so
|
||||
// inferring would land it on the static input instead of the expression editor.
|
||||
const argKind = $derived(argType ?? arg?.type)
|
||||
// Seeded once: `propertyType` is what the user switches, and `argType` is fixed per field.
|
||||
let propertyType = $state(untrack(() => argType) ?? getPropertyType(arg))
|
||||
|
||||
function setExpr() {
|
||||
const newArg = $exprsToSet?.[argName]
|
||||
@@ -266,7 +312,7 @@
|
||||
) {
|
||||
setJavaScriptExpr(arg.value)
|
||||
} else {
|
||||
stepInputGen?.onKeyUp?.(e)
|
||||
;(aiOnKeyUp ?? stepInputGen?.onKeyUp)?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +507,6 @@
|
||||
resourceTypes = await getResourceTypes()
|
||||
}
|
||||
|
||||
let focused = $state(false)
|
||||
let stepInputGen: StepInputGen | undefined = $state(undefined)
|
||||
|
||||
loadResourceTypes()
|
||||
@@ -497,8 +542,6 @@
|
||||
['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType)
|
||||
)
|
||||
|
||||
let suggestion: string | undefined = $state()
|
||||
|
||||
// Svelte bug ...
|
||||
// Somehow the value is updated in the UI of the parent, but not in the children
|
||||
// when passed as a prop. setTimeout is a workaround to force the update
|
||||
@@ -509,85 +552,111 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if arg != undefined && !hidden}
|
||||
{#if (arg != undefined || collapsed) && !hidden}
|
||||
<div class={twMerge('relative group flex flex-col gap-1', className)}>
|
||||
<div class="flex flex-row flex-wrap justify-between gap-1">
|
||||
<div class="flex grow min-h-7 items-end">
|
||||
<FieldHeader
|
||||
label={argName}
|
||||
simpleTooltip={headerTooltip}
|
||||
simpleTooltipIconClass={headerTooltipIconClass}
|
||||
SimpleTooltipIcon={HeaderTooltipIcon}
|
||||
format={schema?.properties?.[argName]?.format}
|
||||
contentEncoding={schema?.properties?.[argName]?.contentEncoding}
|
||||
required={schema.required?.includes(argName)}
|
||||
type={schema.properties?.[argName]?.type}
|
||||
/>
|
||||
<!-- `relative` so the absolute button cluster below anchors to this row rather than
|
||||
to the whole field, letting it share the label's baseline. -->
|
||||
<div class="relative flex flex-row flex-wrap justify-between gap-1">
|
||||
<!-- min-h-7 reserves room for the button cluster beside a plain label; a custom
|
||||
header is a control of its own and sets the row's height itself. -->
|
||||
<div class="flex grow items-end {header ? '' : 'min-h-7'}">
|
||||
{#if header}
|
||||
{@render header()}
|
||||
{:else}
|
||||
<FieldHeader
|
||||
label={label ?? argName}
|
||||
simpleTooltip={headerTooltip}
|
||||
simpleTooltipIconClass={headerTooltipIconClass}
|
||||
SimpleTooltipIcon={HeaderTooltipIcon}
|
||||
format={schema?.properties?.[argName]?.format}
|
||||
contentEncoding={schema?.properties?.[argName]?.contentEncoding}
|
||||
required={schema.required?.includes(argName)}
|
||||
type={schema.properties?.[argName]?.type}
|
||||
/>
|
||||
|
||||
{#if isStaticTemplate(inputCat)}
|
||||
<div>
|
||||
<span
|
||||
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
|
||||
'static' && arg.type === 'javascript'
|
||||
? 'visible'
|
||||
: 'invisible'}"
|
||||
>
|
||||
{'${...}'}
|
||||
</span>
|
||||
</div>
|
||||
{#if isStaticTemplate(inputCat)}
|
||||
<div>
|
||||
<span
|
||||
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
|
||||
'static' && arg?.type === 'javascript'
|
||||
? 'visible'
|
||||
: 'invisible'}"
|
||||
>
|
||||
{'${...}'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !noDynamicToggle}
|
||||
<div
|
||||
class="flex flex-row gap-x-2 z-10 absolute right-0 group-hover:bg-surface transition-colors"
|
||||
>
|
||||
{#if enableAi}
|
||||
<StepInputGen
|
||||
bind:this={stepInputGen}
|
||||
{focused}
|
||||
{arg}
|
||||
schemaProperty={schema?.properties?.[argName]}
|
||||
on:showExpr={(e) => (suggestion = e.detail || undefined)}
|
||||
on:setExpr={(e) => {
|
||||
arg = { type: 'javascript', expr: e.detail }
|
||||
propertyType = 'javascript'
|
||||
monaco?.setCode('')
|
||||
monaco?.insertAtCursor(e.detail)
|
||||
}}
|
||||
{pickableProperties}
|
||||
{argName}
|
||||
btnClass={twMerge(
|
||||
'h-7 min-w-8 px-2',
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
!connecting ? 'opacity-0' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Nothing to connect to or switch while collapsed: there is no value yet. -->
|
||||
<div
|
||||
class="flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors {collapsed
|
||||
? 'hidden'
|
||||
: ''}"
|
||||
>
|
||||
{#if aiGen}
|
||||
{@render aiGen()}
|
||||
{:else if enableAi}
|
||||
<StepInputGen
|
||||
bind:this={stepInputGen}
|
||||
{focused}
|
||||
schemaProperty={schema?.properties?.[argName]}
|
||||
on:showExpr={(e) => (suggestion = e.detail || undefined)}
|
||||
on:setExpr={(e) => {
|
||||
arg = { type: 'javascript', expr: e.detail }
|
||||
propertyType = 'javascript'
|
||||
monaco?.setCode('')
|
||||
monaco?.insertAtCursor(e.detail)
|
||||
}}
|
||||
{pickableProperties}
|
||||
{argName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if propPickerWrapperContext}
|
||||
<FlowPlugConnect
|
||||
wrapperClasses={twMerge(
|
||||
connecting ? 'h-6 w-7' : 'h-7 w-8',
|
||||
'group-hover:opacity-100 transition-opacity p-0',
|
||||
!connecting ? 'opacity-0' : ''
|
||||
)}
|
||||
id="flow-editor-plug"
|
||||
{connecting}
|
||||
on:click={() => {
|
||||
if ($propPickerConfig?.propName == argName) {
|
||||
clearFocus()
|
||||
} else {
|
||||
focusProp?.(argName, (path) => {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
return true
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if propPickerWrapperContext && pickerMode === 'popover'}
|
||||
<!-- Settings rows have no picker pane, so the properties hang off the
|
||||
button itself, exactly as the other expression inputs do. -->
|
||||
<ExpressionPicker
|
||||
id={argName}
|
||||
pickableProperties={connectableProperties}
|
||||
result={propPickerWrapperContext.result?.()}
|
||||
extraResults={propPickerWrapperContext.extraResults?.()}
|
||||
onSelect={(path) => {
|
||||
// A predicate is usually half-written when you reach for a property, so
|
||||
// insert at the cursor and leave the rest of the expression alone. Only
|
||||
// a field that isn't an expression yet gets replaced outright.
|
||||
if (propertyType === 'javascript' && monaco) {
|
||||
propPickerWrapperContext.onPick?.(path)
|
||||
} else {
|
||||
connectProperty(path)
|
||||
}
|
||||
dispatch('change', { argName })
|
||||
}}
|
||||
/>
|
||||
{:else if propPickerWrapperContext}
|
||||
<FlowPlugConnect
|
||||
wrapperClasses={twMerge(
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
!connecting ? 'opacity-0' : ''
|
||||
)}
|
||||
id="flow-editor-plug"
|
||||
{connecting}
|
||||
on:click={() => {
|
||||
if ($propPickerConfig?.propName == argName) {
|
||||
clearFocus()
|
||||
} else {
|
||||
focusProp?.(argName, (path) => {
|
||||
connectProperty(path)
|
||||
dispatch('change', { argName })
|
||||
return true
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="{ButtonType.UnifiedHeightClasses.sm} relative">
|
||||
{#if !noDynamicToggle}
|
||||
<div class="{ButtonType.UnifiedHeightClasses.xs} relative">
|
||||
<ToggleButtonGroup
|
||||
selected={visiblePropertyType}
|
||||
class="h-full"
|
||||
@@ -708,215 +777,234 @@
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative w-full" onkeyup={handleKeyUp}>
|
||||
<!-- {inputCat}
|
||||
{#if !collapsed}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- A custom header means a setting's toggle owns this field, so the input is
|
||||
indented under the toggle's label: `xs` switch (w-7) plus its ml-2. -->
|
||||
<div
|
||||
class="relative w-full {header ? 'pl-9' : ''}"
|
||||
onkeyup={handleKeyUp}
|
||||
transition:slideDynamic|global={{ duration: animateAppear ? 150 : 0 }}
|
||||
>
|
||||
<!-- {inputCat}
|
||||
{propertyType} -->
|
||||
<div class="relative flex flex-row items-top gap-1 justify-between">
|
||||
<div class="min-w-0 grow">
|
||||
{#if suggestion}
|
||||
<div
|
||||
class={`bg-surface-input rounded-md pl-2 overflow-auto ${inputBorderClass({ forceFocus: true })}`}
|
||||
>
|
||||
<FakeMonacoPlaceHolder autoheight code={suggestion} fontSize={12} />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class={suggestion ? 'opacity-0 absolute' : ''}
|
||||
onkeydowncapture={(e) => {
|
||||
if (e.key === 'Tab' && suggestion) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{@render innerInput()}
|
||||
</div>
|
||||
|
||||
{#snippet innerInput()}
|
||||
{#if propertyType === 'ai'}
|
||||
<div
|
||||
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<InfoIcon size={13} />
|
||||
This field will be filled by the AI agent dynamically
|
||||
</span>
|
||||
</div>
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs italic py-1 text-hint">
|
||||
<pre class="font-main whitespace-normal">
|
||||
{schema.properties[argName].description}
|
||||
</pre>
|
||||
<div class="relative flex flex-row items-top gap-1 justify-between">
|
||||
<div class="min-w-0 grow">
|
||||
<!-- The ghost text covers the input and only the input: it has to stay mounted
|
||||
(Monaco, focus, Tab) and keep its height, or what follows slides up
|
||||
underneath it — and the overlay must not reach the rows below, which is
|
||||
what would bury the Help dropdown. -->
|
||||
<div class="relative">
|
||||
{#if suggestion}
|
||||
<div
|
||||
class={`absolute inset-0 z-10 bg-surface-input rounded-md pl-2 overflow-auto ${inputBorderClass({ forceFocus: true })}`}
|
||||
>
|
||||
<FakeMonacoPlaceHolder autoheight code={suggestion} fontSize={12} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class={suggestion ? 'opacity-0' : ''}
|
||||
onkeydowncapture={(e) => {
|
||||
if (e.key === 'Tab' && suggestion) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{@render innerInput()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputError {error} />
|
||||
|
||||
<!-- Rendered outside the `suggestion ? opacity-0` wrapper so the AI
|
||||
step-input autocompletion (ghost text, accepted with Tab) doesn't
|
||||
hide the Help dropdown — the two stay independent. -->
|
||||
{#if !hideHelpButton && propertyType === 'javascript' && argKind === 'javascript' && arg.expr != undefined}
|
||||
<DynamicInputHelpBox />
|
||||
{/if}
|
||||
|
||||
{#snippet innerInput()}
|
||||
{#if propertyType === 'ai'}
|
||||
<div
|
||||
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<InfoIcon size={13} />
|
||||
This field will be filled by the AI agent dynamically
|
||||
</span>
|
||||
</div>
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs text-secondary">
|
||||
<div class="text-xs italic py-1 text-hint">
|
||||
<pre class="font-main whitespace-normal">
|
||||
{schema.properties[argName].description}
|
||||
</pre>
|
||||
</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs text-secondary">
|
||||
<pre class="font-main whitespace-normal">
|
||||
{schema.properties[argName].description}
|
||||
</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if arg}
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
bind:code={arg.value}
|
||||
fontSize={12}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
loadAsync
|
||||
class="bg-surface-input"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
|
||||
<ArgInput
|
||||
{resourceTypes}
|
||||
noMargin
|
||||
compact
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
shouldDispatchChanges
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
label={argName}
|
||||
bind:editor={monaco}
|
||||
bind:description={schema.properties[argName].description}
|
||||
bind:value={arg.value}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
required={schema.required?.includes(argName)}
|
||||
bind:pattern={schema.properties[argName].pattern}
|
||||
bind:valid={inputCheck}
|
||||
defaultValue={schema.properties[argName].default}
|
||||
bind:enum_={schema.properties[argName].enum}
|
||||
bind:format={schema.properties[argName].format}
|
||||
contentEncoding={schema.properties[argName].contentEncoding}
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
properties={schema.properties[argName].properties}
|
||||
nestedRequired={schema.properties[argName].required}
|
||||
displayHeader={false}
|
||||
extra={argExtra}
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
showSchemaExplorer
|
||||
nullable={schema.properties[argName].nullable}
|
||||
bind:title={schema.properties[argName].title}
|
||||
bind:placeholder={schema.properties[argName].placeholder}
|
||||
{helperScript}
|
||||
{s3StorageConfigured}
|
||||
{chatInputEnabled}
|
||||
otherArgs={Object.fromEntries(
|
||||
Object.entries(otherArgs).map(([key, transform]) => [
|
||||
key,
|
||||
transform?.type === 'static'
|
||||
? transform.value
|
||||
: transform?.type === 'javascript'
|
||||
? transform.expr
|
||||
: undefined
|
||||
])
|
||||
)}
|
||||
>
|
||||
{#snippet innerBottomSnippet()}
|
||||
{#if shouldShowS3ArrayHelper}
|
||||
<S3ArrayHelperButton
|
||||
{connecting}
|
||||
onClick={() =>
|
||||
switchToJsAndConnect((path) =>
|
||||
appendPathToArrayExpr(arg?.type === 'javascript' ? arg.expr : '', path)
|
||||
)}
|
||||
{#if arg}
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
}}
|
||||
bind:code={arg.value}
|
||||
fontSize={12}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
loadAsync
|
||||
class="bg-surface-input"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ArgInput>
|
||||
{:else if arg?.type === 'javascript' && arg.expr != undefined}
|
||||
<div
|
||||
class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused })}`}
|
||||
>
|
||||
<SimpleEditor
|
||||
small
|
||||
bind:this={monaco}
|
||||
bind:code={arg.expr}
|
||||
{extraLib}
|
||||
lang="javascript"
|
||||
shouldBindKey={false}
|
||||
renderLineHighlight="none"
|
||||
hideLineNumbers
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
updatePropsBeingEdited(true)
|
||||
}}
|
||||
</div>
|
||||
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
|
||||
<ArgInput
|
||||
{resourceTypes}
|
||||
noMargin
|
||||
compact
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
updatePropsBeingEdited(false)
|
||||
}}
|
||||
shouldDispatchChanges
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
autoHeight
|
||||
loadAsync
|
||||
/>
|
||||
<!-- <input type="text" bind:value={arg.expr} /> -->
|
||||
</div>
|
||||
|
||||
{#if shouldShowS3ArrayHelper}
|
||||
<S3ArrayHelperButton
|
||||
class="mt-2"
|
||||
{connecting}
|
||||
onClick={() =>
|
||||
focusProp?.(argName, (path) => {
|
||||
appendPathToArrayExpr(arg.expr, path)
|
||||
return true
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs italic py-1 text-secondary">
|
||||
<pre class="font-main whitespace-normal"
|
||||
>{schema.properties[argName].description}</pre
|
||||
>
|
||||
label={argName}
|
||||
bind:editor={monaco}
|
||||
bind:description={schema.properties[argName].description}
|
||||
bind:value={arg.value}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
required={schema.required?.includes(argName)}
|
||||
bind:pattern={schema.properties[argName].pattern}
|
||||
bind:valid={inputCheck}
|
||||
defaultValue={schema.properties[argName].default}
|
||||
bind:enum_={schema.properties[argName].enum}
|
||||
bind:format={schema.properties[argName].format}
|
||||
contentEncoding={schema.properties[argName].contentEncoding}
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
properties={schema.properties[argName].properties}
|
||||
nestedRequired={schema.properties[argName].required}
|
||||
displayHeader={false}
|
||||
extra={argExtra}
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
showSchemaExplorer
|
||||
nullable={schema.properties[argName].nullable}
|
||||
bind:title={schema.properties[argName].title}
|
||||
bind:placeholder={schema.properties[argName].placeholder}
|
||||
{helperScript}
|
||||
{s3StorageConfigured}
|
||||
{chatInputEnabled}
|
||||
otherArgs={Object.fromEntries(
|
||||
Object.entries(otherArgs).map(([key, transform]) => [
|
||||
key,
|
||||
transform?.type === 'static'
|
||||
? transform.value
|
||||
: transform?.type === 'javascript'
|
||||
? transform.expr
|
||||
: undefined
|
||||
])
|
||||
)}
|
||||
>
|
||||
{#snippet innerBottomSnippet()}
|
||||
{#if shouldShowS3ArrayHelper}
|
||||
<S3ArrayHelperButton
|
||||
{connecting}
|
||||
onClick={() =>
|
||||
switchToJsAndConnect((path) =>
|
||||
appendPathToArrayExpr(arg?.type === 'javascript' ? arg.expr : '', path)
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ArgInput>
|
||||
{:else if argKind === 'javascript' && arg.expr != undefined}
|
||||
<div
|
||||
class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`}
|
||||
>
|
||||
<SimpleEditor
|
||||
small
|
||||
bind:this={monaco}
|
||||
bind:code={arg.expr}
|
||||
{extraLib}
|
||||
lang="javascript"
|
||||
shouldBindKey={false}
|
||||
renderLineHighlight="none"
|
||||
hideLineNumbers
|
||||
on:focus={() => {
|
||||
focused = true
|
||||
updatePropsBeingEdited(true)
|
||||
}}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
updatePropsBeingEdited(false)
|
||||
}}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
autoHeight
|
||||
loadAsync
|
||||
/>
|
||||
<!-- <input type="text" bind:value={arg.expr} /> -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hideHelpButton}
|
||||
<DynamicInputHelpBox />
|
||||
{/if}
|
||||
{#if shouldShowS3ArrayHelper}
|
||||
<S3ArrayHelperButton
|
||||
class="mt-2"
|
||||
{connecting}
|
||||
onClick={() =>
|
||||
focusProp?.(argName, (path) => {
|
||||
appendPathToArrayExpr(arg.expr, path)
|
||||
return true
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2"></div>
|
||||
{:else}
|
||||
<span class="text-xs text-red-500">
|
||||
Not recognized input type {argName} ({arg.expr}, {propertyType})
|
||||
</span>
|
||||
<div class="flex mt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
arg.expr = ''
|
||||
}}>Set expr to empty string</Button
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
<div class="text-xs italic py-1 text-secondary">
|
||||
<pre class="font-main whitespace-normal"
|
||||
>{schema.properties[argName].description}</pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2"></div>
|
||||
{:else}
|
||||
<span class="text-xs text-red-500">
|
||||
Not recognized input type {argName} ({arg.expr}, {propertyType})
|
||||
</span>
|
||||
<div class="flex mt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
arg.expr = ''
|
||||
}}>Set expr to empty string</Button
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<script lang="ts">
|
||||
import { Check, UserCog, Users, ExternalLink } from 'lucide-svelte'
|
||||
import MeltPopover from './meltComponents/Popover.svelte'
|
||||
import Portal from './Portal.svelte'
|
||||
import Modal from './common/modal/Modal.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { UserService, type User } from '$lib/gen'
|
||||
@@ -57,6 +58,22 @@
|
||||
* badge so the user sees where the value came from.
|
||||
*/
|
||||
folderDefault?: string | undefined
|
||||
/**
|
||||
* Raises the popover and user picker above a `ConfirmationModal`, which renders at a
|
||||
* z-index above the popover layer — without this they open behind the dialog hosting them.
|
||||
*/
|
||||
aboveConfirmationModal?: boolean
|
||||
/**
|
||||
* Fires as the user picker opens and closes. A host that binds its own Enter/Escape handler
|
||||
* must suspend it meanwhile: both handlers sit on `window`, where `stopPropagation` can't
|
||||
* separate them, so Escape in the picker would also dismiss the host.
|
||||
*/
|
||||
onPickerOpenChange?: (open: boolean) => void
|
||||
/**
|
||||
* `u/username` to label the "me" option with. Usernames are per-workspace, so the current
|
||||
* one is the wrong name whenever the deploy targets a different workspace.
|
||||
*/
|
||||
myPermissionedAs?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -68,7 +85,10 @@
|
||||
canPreserve,
|
||||
customValue,
|
||||
isDeployment = true,
|
||||
folderDefault = undefined
|
||||
folderDefault = undefined,
|
||||
aboveConfirmationModal = false,
|
||||
onPickerOpenChange = undefined,
|
||||
myPermissionedAs = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const isTrigger = $derived(kind === 'trigger' || isTriggerOrScheduleKind(kind))
|
||||
@@ -107,7 +127,9 @@
|
||||
|
||||
let targetDisplayName = $derived(resolveDisplayName(targetValue))
|
||||
let customDisplayName = $derived(resolveDisplayName(customValue))
|
||||
let myDisplayName = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
|
||||
let myDisplayName = $derived(
|
||||
myPermissionedAs ?? ($userStore?.username ? `u/${$userStore.username}` : undefined)
|
||||
)
|
||||
|
||||
let activeUsers = $derived(users.filter((u) => !u.disabled))
|
||||
let filteredUsers = $derived(
|
||||
@@ -152,6 +174,11 @@
|
||||
modalOpen = false
|
||||
}
|
||||
|
||||
// Covers every close path — the X, the overlay, Escape — not just `selectUser`.
|
||||
$effect(() => {
|
||||
onPickerOpenChange?.(modalOpen)
|
||||
})
|
||||
|
||||
let selectedDisplayName = $derived.by(() => {
|
||||
if (selected === 'target') return targetDisplayName
|
||||
if (selected === 'me') return myDisplayName
|
||||
@@ -160,7 +187,11 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<MeltPopover placement="bottom" on:openChange={(e) => e.detail && loadUsers()}>
|
||||
<MeltPopover
|
||||
placement="bottom"
|
||||
contentClasses={aboveConfirmationModal ? 'z-[10001]' : ''}
|
||||
on:openChange={(e) => e.detail && loadUsers()}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
|
||||
@@ -228,56 +259,65 @@
|
||||
{/snippet}
|
||||
</MeltPopover>
|
||||
|
||||
<!-- User selection modal -->
|
||||
<Modal title="Select a user" bind:open={modalOpen} kind="X">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-xs text-secondary">
|
||||
{#if isTrigger}
|
||||
Choose the user this trigger will be permissioned as {isDeployment
|
||||
? 'in the target workspace'
|
||||
: 'in this workspace'}. The selected user's permissions will be used when the trigger
|
||||
fires.
|
||||
{:else}
|
||||
Choose the user this {kind} will run on behalf of {isDeployment
|
||||
? 'in the target workspace'
|
||||
: 'in this workspace'}. The selected user's permissions will be used when executing.
|
||||
{/if}
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-500 hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput bind:value={searchQuery} inputProps={{ placeholder: 'Search users...' }} />
|
||||
|
||||
<div class="max-h-60 overflow-y-auto border rounded">
|
||||
{#each filteredUsers as user (user.email)}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left text-sm hover:bg-surface-hover border-b last:border-b-0"
|
||||
onclick={() => selectUser(user)}
|
||||
<!-- User selection modal. Portalled: the modal positions itself with `fixed`, which resolves
|
||||
against the nearest transformed ancestor — inside a dialog card (which is transformed for
|
||||
its open transition) it would be laid out within that card instead of the viewport. -->
|
||||
<Portal>
|
||||
<Modal
|
||||
title="Select a user"
|
||||
bind:open={modalOpen}
|
||||
kind="X"
|
||||
minZIndex={aboveConfirmationModal ? 10001 : undefined}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-xs text-secondary">
|
||||
{#if isTrigger}
|
||||
Choose the user this trigger will be permissioned as {isDeployment
|
||||
? 'in the target workspace'
|
||||
: 'in this workspace'}. The selected user's permissions will be used when the trigger
|
||||
fires.
|
||||
{:else}
|
||||
Choose the user this {kind} will run on behalf of {isDeployment
|
||||
? 'in the target workspace'
|
||||
: 'in this workspace'}. The selected user's permissions will be used when executing.
|
||||
{/if}
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-500 hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="font-medium truncate">u/{user.username}</span>
|
||||
<span class="text-xs text-tertiary truncate">{user.email}</span>
|
||||
Learn more
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput bind:value={searchQuery} inputProps={{ placeholder: 'Search users...' }} />
|
||||
|
||||
<div class="max-h-60 overflow-y-auto border rounded">
|
||||
{#each filteredUsers as user (user.email)}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left text-sm hover:bg-surface-hover border-b last:border-b-0"
|
||||
onclick={() => selectUser(user)}
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="font-medium truncate">u/{user.username}</span>
|
||||
<span class="text-xs text-tertiary truncate">{user.email}</span>
|
||||
</div>
|
||||
{#if selected === 'custom' && (customValue === `u/${user.username}` || customValue === user.email)}
|
||||
<Check class="w-4 h-4 text-green-500 ml-auto flex-shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-4 text-sm text-tertiary text-center">
|
||||
{#if !usersLoaded}
|
||||
Loading users…
|
||||
{:else}
|
||||
No users found
|
||||
{/if}
|
||||
</div>
|
||||
{#if selected === 'custom' && (customValue === `u/${user.username}` || customValue === user.email)}
|
||||
<Check class="w-4 h-4 text-green-500 ml-auto flex-shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-4 text-sm text-tertiary text-center">
|
||||
{#if !usersLoaded}
|
||||
Loading users…
|
||||
{:else}
|
||||
No users found
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
</Portal>
|
||||
|
||||
@@ -196,7 +196,10 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.splitter-hidden .splitpanes__splitter) {
|
||||
/* Direct child only: a descendant selector leaks into nested Splitpanes (e.g. the
|
||||
sessions preview reuses `.splitter-hidden`, which would otherwise hide the flow
|
||||
editor / modal splitters too). */
|
||||
:global(.splitter-hidden > .splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
opacity: 0 !important;
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section label="Delete after completion">
|
||||
<Section label="Delete after completion" eeOnly>
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
|
||||
@@ -223,9 +223,6 @@
|
||||
the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is
|
||||
irreversible. This settings ONLY applies when the script is used within a flow or triggered
|
||||
synchronously.
|
||||
{#if !$enterpriseLicense}
|
||||
This option is only available on Windmill Enterprise Edition.
|
||||
{/if}
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<div class="flex gap-2 shrink flex-col">
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
import WorkerTagSelect from './WorkerTagSelect.svelte'
|
||||
import type { ButtonType } from './common/button/model'
|
||||
import DebounceLimit from './flows/DebounceLimit.svelte'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
|
||||
import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
|
||||
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -866,7 +866,7 @@
|
||||
{
|
||||
label: editInForkLabel(opWorkspace, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('script', initialPath))
|
||||
openEditInFork('script', initialPath, opWorkspace)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -918,9 +918,11 @@
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
// Lowercased so Caps Lock (which yields `S`) still saves. Shift excluded:
|
||||
// Ctrl+Shift+S must reach the browser/OS.
|
||||
switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) {
|
||||
case 's':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
|
||||
saveDraft()
|
||||
event.preventDefault()
|
||||
}
|
||||
@@ -928,6 +930,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Monaco swallows the keydown, so a code editor with focus never reaches the
|
||||
// window handler above; Editor/SimpleEditor/TemplateEditor re-broadcast it
|
||||
// (untyped event, hence the manual listener).
|
||||
$effect(() => {
|
||||
window.addEventListener('wm-monaco-save-shortcut', saveDraft)
|
||||
return () => window.removeEventListener('wm-monaco-save-shortcut', saveDraft)
|
||||
})
|
||||
|
||||
let path: Path | undefined = $state(undefined)
|
||||
// Seed "path is already chosen" so the summary→path auto-slug (which only
|
||||
// runs for new scripts with initialPath == '') doesn't clobber a path the
|
||||
@@ -1445,7 +1455,7 @@
|
||||
<!-- Not for dbt: each kind tags a script for a role in a flow that a project
|
||||
bundle cannot fill (approval, trigger, preprocessor), and the runtime only
|
||||
ever runs it as an action. -->
|
||||
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt}
|
||||
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true && !isDbt}
|
||||
<Section label="Script kind">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
@@ -1904,8 +1914,7 @@
|
||||
// Keep the saved pair. A script that has no recorded principal yet
|
||||
// sends the email alone and the backend derives one from it.
|
||||
script.on_behalf_of_email = originalOnBehalfOfEmail
|
||||
script.on_behalf_of =
|
||||
originalOnBehalfOfPermissionedAs
|
||||
script.on_behalf_of = originalOnBehalfOfPermissionedAs
|
||||
customOnBehalfOfEmail = ''
|
||||
preserveOnBehalfOf = true
|
||||
} else if (choice === 'custom' && details) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
import { allClasses } from './apps/editor/componentsPanel/cssUtils'
|
||||
|
||||
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
|
||||
|
||||
import libStdContent from '$lib/es6.d.ts.txt?raw'
|
||||
import domContent from '$lib/dom.d.ts.txt?raw'
|
||||
@@ -458,8 +458,9 @@
|
||||
updateCode()
|
||||
shouldBindKey && format && format()
|
||||
// See Editor.svelte — re-broadcast the swallowed shortcut for
|
||||
// page-level draft-flush handlers.
|
||||
window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))
|
||||
// page-level draft-flush handlers, after `tick()` so they see
|
||||
// the value `updateCode()` just materialized.
|
||||
void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')))
|
||||
})
|
||||
|
||||
editor.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {
|
||||
@@ -490,8 +491,9 @@
|
||||
updateCode()
|
||||
shouldBindKey && format && format()
|
||||
// See Editor.svelte — re-broadcast the swallowed shortcut for
|
||||
// page-level draft-flush handlers.
|
||||
window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut'))
|
||||
// page-level draft-flush handlers, after `tick()` so they see
|
||||
// the value `updateCode()` just materialized.
|
||||
void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')))
|
||||
})
|
||||
|
||||
editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
|
||||
@@ -650,8 +652,12 @@
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
valueAfterDispose = getCode()
|
||||
// Same guards as Editor: only a pending keystroke debounce is ours to flush.
|
||||
if (editor && changeTimeoutId !== undefined) {
|
||||
updateCode()
|
||||
}
|
||||
cancelPendingChanges()
|
||||
valueAfterDispose = getCode()
|
||||
pasteListenerCleanup?.()
|
||||
vimDisposable?.dispose()
|
||||
model && model.dispose()
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import libStdContent from '$lib/es6.d.ts.txt?raw'
|
||||
import { editor as meditor, Uri as mUri, languages, Range, KeyMod, KeyCode } from 'monaco-editor'
|
||||
import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte'
|
||||
import { createEventDispatcher, getContext, onDestroy, onMount, tick, untrack } from 'svelte'
|
||||
import type { AppViewerContext } from './apps/types'
|
||||
import { writable } from 'svelte/store'
|
||||
// import '@codingame/monaco-vscode-standalone-languages'
|
||||
@@ -505,7 +505,13 @@
|
||||
editor.onDidFocusEditorText(() => {
|
||||
dispatch('focus')
|
||||
|
||||
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {})
|
||||
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {
|
||||
updateCode()
|
||||
// See Editor.svelte — re-broadcast the swallowed shortcut for
|
||||
// page-level draft-flush handlers, after `tick()` so they see
|
||||
// the value `updateCode()` just materialized.
|
||||
void tick().then(() => window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')))
|
||||
})
|
||||
|
||||
editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import { inputBorderClass } from './text_input/TextInput.svelte'
|
||||
import EEOnly from './EEOnly.svelte'
|
||||
|
||||
interface Props {
|
||||
options?: {
|
||||
@@ -55,99 +55,119 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ change: boolean }>()
|
||||
const bothOptions = Boolean(untrack(() => options).left) && Boolean(untrack(() => options).right)
|
||||
// Same badge the labelled containers (Label, Section, Subsection) show. Gated on
|
||||
// `disabled` rather than on the license: a caller can leave an EE-only toggle
|
||||
// interactive on CE precisely because it is already on, and "EE only" beside a control
|
||||
// that plainly works reads as a lie.
|
||||
const showEeBadge = $derived(eeOnly && disabled)
|
||||
</script>
|
||||
|
||||
<label
|
||||
for={id}
|
||||
class="{className || ''} z-auto flex flex-row items-center duration-50 {disabled
|
||||
? 'grayscale opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'}"
|
||||
title={options?.title}
|
||||
>
|
||||
{#if Boolean(options?.left)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'mr-2 font-normal duration-50 select-none',
|
||||
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
|
||||
size === '2xs' ? 'text-2xs' : 'text-xs',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
>
|
||||
{options?.left}
|
||||
{#if options?.leftTooltip}
|
||||
<Tooltip light={lightMode}>{options?.leftTooltip}</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative"
|
||||
onclick={stopPropagation(bubble('click'))}
|
||||
use:triggerableByAI={{
|
||||
id: aiId,
|
||||
description: aiDescription,
|
||||
callback: () => {
|
||||
checked = !checked
|
||||
}
|
||||
}}
|
||||
{#snippet control()}
|
||||
<label
|
||||
for={id}
|
||||
class="{className || ''} z-auto flex flex-row items-center duration-50 {disabled
|
||||
? 'grayscale opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'}"
|
||||
title={options?.title}
|
||||
>
|
||||
<input
|
||||
onfocus={bubble('focus')}
|
||||
onclick={bubble('click')}
|
||||
{disabled}
|
||||
type="checkbox"
|
||||
{id}
|
||||
class="sr-only peer"
|
||||
bind:checked
|
||||
onchange={stopPropagation((e) => {
|
||||
dispatch('change', !!checked)
|
||||
})}
|
||||
/>
|
||||
{#if Boolean(options?.left)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'mr-2 font-normal duration-50 select-none',
|
||||
bothOptions || textDisabled
|
||||
? checked
|
||||
? 'text-disabled'
|
||||
: 'text-primary'
|
||||
: 'text-primary',
|
||||
size === '2xs' ? 'text-2xs' : 'text-xs',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
>
|
||||
{options?.left}
|
||||
{#if options?.leftTooltip}
|
||||
<Tooltip light={lightMode}>{options?.leftTooltip}</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={classNames(
|
||||
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
|
||||
color == 'red'
|
||||
? 'peer-checked:bg-red-600'
|
||||
: color == 'blue'
|
||||
? 'peer-checked:bg-blue-400 '
|
||||
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
|
||||
size === 'md'
|
||||
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
|
||||
: size === 'sm'
|
||||
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
|
||||
: size === '2xs'
|
||||
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
|
||||
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
|
||||
inputBorderClass()
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
{#if Boolean(options?.right)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'ml-2 font-normal duration-50 select-none',
|
||||
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
|
||||
size === '2xs' ? 'text-2xs' : 'text-xs',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
class="relative"
|
||||
onclick={stopPropagation(bubble('click'))}
|
||||
use:triggerableByAI={{
|
||||
id: aiId,
|
||||
description: aiDescription,
|
||||
callback: () => {
|
||||
checked = !checked
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options?.right}
|
||||
{#if options?.rightTooltip}
|
||||
<Tooltip documentationLink={options.rightDocumentationLink}>
|
||||
{options.rightTooltip}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render right?.()}
|
||||
</label>
|
||||
{#if eeOnly && disabled}
|
||||
<span class="inline-flex text-xs items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</span>
|
||||
<input
|
||||
onfocus={bubble('focus')}
|
||||
onclick={bubble('click')}
|
||||
{disabled}
|
||||
type="checkbox"
|
||||
{id}
|
||||
class="sr-only peer"
|
||||
bind:checked
|
||||
onchange={stopPropagation((e) => {
|
||||
dispatch('change', !!checked)
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
class={classNames(
|
||||
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
|
||||
color == 'red'
|
||||
? 'peer-checked:bg-red-600'
|
||||
: color == 'blue'
|
||||
? 'peer-checked:bg-blue-400 '
|
||||
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
|
||||
size === 'md'
|
||||
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
|
||||
: size === 'sm'
|
||||
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
|
||||
: size === '2xs'
|
||||
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
|
||||
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
|
||||
inputBorderClass()
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
{#if Boolean(options?.right)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'ml-2 font-normal duration-50 select-none',
|
||||
bothOptions || textDisabled
|
||||
? checked
|
||||
? 'text-primary'
|
||||
: 'text-disabled'
|
||||
: 'text-primary',
|
||||
size === '2xs' ? 'text-2xs' : 'text-xs',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
>
|
||||
{options?.right}
|
||||
{#if options?.rightTooltip}
|
||||
<Tooltip documentationLink={options.rightDocumentationLink}>
|
||||
{options.rightTooltip}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render right?.()}
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
{#if showEeBadge}
|
||||
<!-- The badge sits outside the label: a disabled one is greyed out and half
|
||||
transparent, which would swallow it. -->
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{@render control()}
|
||||
<EEOnly />
|
||||
</div>
|
||||
{:else}
|
||||
{@render control()}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<script lang="ts">
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte'
|
||||
import { claimTab, devWorkspaceEditUrl } from '$lib/utils/editInFork'
|
||||
import {
|
||||
checkItemDeployAccess,
|
||||
checkItemExists,
|
||||
createFolderIfAbsent,
|
||||
deployItem,
|
||||
getOnBehalfOfOrThrow,
|
||||
type DeployResult,
|
||||
type DeployTargetAccess
|
||||
} from '$lib/utils_workspace_deploy'
|
||||
import { COMPARE_ITEMS_PARAM } from '$lib/components/sessions/modifiedItemsMask'
|
||||
import OnBehalfOfSelector, {
|
||||
needsOnBehalfOfSelection,
|
||||
type OnBehalfOfChoice,
|
||||
type OnBehalfOfDetails
|
||||
} from '$lib/components/OnBehalfOfSelector.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
const pending = $derived(updateDevWorkspaceModal.val)
|
||||
|
||||
let updating = $state(false)
|
||||
/** The user picker stacks above this dialog and owns the keyboard while it's up (`keyListen`). */
|
||||
let pickerOpen = $state(false)
|
||||
|
||||
/**
|
||||
* A lookup tagged with the request it answers. Requests outlive the prompt that started them
|
||||
* (no abort signal on the client), so cancelling and reopening leaves two in flight — without
|
||||
* the tag the last to settle decides this item's permissions and identity. `undefined` means
|
||||
* "not looked up yet", which is what gates confirming.
|
||||
*/
|
||||
type Tagged<T> = { req: NonNullable<typeof pending>; value: T }
|
||||
function forPending<T>(res: Tagged<T> | undefined): Tagged<T> | undefined {
|
||||
return res && res.req === pending ? res : undefined
|
||||
}
|
||||
|
||||
// Re-run per opened item: the modal is mounted for the whole session, the rules can change under
|
||||
// it, and write access is per-path so it can differ between two items in the same workspace.
|
||||
let accessLookup = $state<Tagged<DeployTargetAccess> | undefined>(undefined)
|
||||
let sourceLookup = $state<Tagged<{ onBehalfOf?: string; failed?: boolean }> | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const req = pending
|
||||
accessLookup = undefined
|
||||
sourceLookup = undefined
|
||||
onBehalfOfChoice = undefined
|
||||
customOnBehalfOf = undefined
|
||||
if (!req) return
|
||||
let live = true
|
||||
void checkItemDeployAccess(req.devWorkspaceId, req.itemPath).then((value) => {
|
||||
if (live) accessLookup = { req, value }
|
||||
})
|
||||
// `failed` rather than `undefined`: an unreadable source is not one with no identity, and
|
||||
// conflating them would quietly hand the copy to the deploying user.
|
||||
void getOnBehalfOfOrThrow(req.itemType, req.itemPath, req.prodWorkspaceId).then(
|
||||
(onBehalfOf) => {
|
||||
if (live) sourceLookup = { req, value: { onBehalfOf } }
|
||||
},
|
||||
() => {
|
||||
if (live) sourceLookup = { req, value: { failed: true } }
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
})
|
||||
|
||||
const access = $derived(forPending(accessLookup))
|
||||
const permission = $derived(access?.value.permission)
|
||||
|
||||
// The compare page's update direction (prod -> dev) with this one item preselected. Where the
|
||||
// confirm button leads when the user can't deploy here, so the request still has somewhere to go.
|
||||
const compareHref = $derived(
|
||||
pending
|
||||
? `${base}/forks/compare?workspace_id=${encodeURIComponent(pending.devWorkspaceId)}` +
|
||||
`&mode=fork&dir=update` +
|
||||
`&${COMPARE_ITEMS_PARAM}=${encodeURIComponent(`${pending.itemType}:${pending.itemPath}`)}`
|
||||
: ''
|
||||
)
|
||||
|
||||
// Falls open while the check is in flight so the modal doesn't flash a refusal it may retract;
|
||||
// confirming stays blocked until it lands (see `confirmBlocked`).
|
||||
const canDeploy = $derived(permission?.ok !== false)
|
||||
|
||||
// Identity the item will run under once it lands in the dev workspace. Offered only when the
|
||||
// prod item has an on_behalf_of of its own — otherwise there is no identity to carry over and
|
||||
// the deploying user is the only sensible answer (`needsOnBehalfOfSelection`).
|
||||
const sourceOnBehalfOf = $derived(forPending(sourceLookup))
|
||||
const showOnBehalfOf = $derived(
|
||||
!!pending && needsOnBehalfOfSelection(pending.itemType, sourceOnBehalfOf?.value.onBehalfOf)
|
||||
)
|
||||
|
||||
// Left unset until the user picks, and confirming is blocked meanwhile. The selector's own
|
||||
// "preserve the target's value" default can't apply: the item is absent from the dev workspace.
|
||||
let onBehalfOfChoice = $state<OnBehalfOfChoice>(undefined)
|
||||
let customOnBehalfOf = $state<OnBehalfOfDetails | undefined>(undefined)
|
||||
|
||||
// 'me' is sent explicitly rather than left blank. Sending nothing means "no preference", which
|
||||
// lets the target folder's `default_permissioned_as` claim the item — so the option labelled
|
||||
// "me" would deploy it as somebody else. No choice at all (selector hidden) still defers to it.
|
||||
const chosenIdentity = $derived(
|
||||
onBehalfOfChoice === 'custom'
|
||||
? customOnBehalfOf
|
||||
: onBehalfOfChoice === 'me'
|
||||
? access?.value.me
|
||||
: undefined
|
||||
)
|
||||
|
||||
const onBehalfOfUnset = $derived(showOnBehalfOf && onBehalfOfChoice === undefined)
|
||||
// Blocked until both lookups land *for this item* — Enter is bound to confirm, so a fast one
|
||||
// would otherwise deploy past the permission check and skip a required choice. Not blocked once
|
||||
// refused: the button leads to the compare page then.
|
||||
const sourceOnBehalfOfFailed = $derived(!!sourceOnBehalfOf?.value.failed)
|
||||
// An identity has to be picked but we don't know who "me" is there, so no choice can be honoured:
|
||||
// sending nothing would hand the item to the folder default instead.
|
||||
const targetIdentityUnknown = $derived(showOnBehalfOf && !!access && !access.value.me)
|
||||
const confirmBlocked = $derived(
|
||||
canDeploy &&
|
||||
(!access ||
|
||||
!sourceOnBehalfOf ||
|
||||
sourceOnBehalfOfFailed ||
|
||||
targetIdentityUnknown ||
|
||||
onBehalfOfUnset)
|
||||
)
|
||||
|
||||
function close() {
|
||||
updateDevWorkspaceModal.val = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploying `f/<folder>/<name>` into a workspace with no `<folder>` succeeds but orphans the
|
||||
* item — it lands with no folder to carry its permissions. Anything else it needs (resources,
|
||||
* variables, resource types) stays the compare page's job, exactly as it is there.
|
||||
*/
|
||||
async function ensureFolder(req: NonNullable<typeof pending>): Promise<DeployResult> {
|
||||
const folder = req.itemPath.match(/^f\/([^/]+)\//)?.[1]
|
||||
if (!folder) return { success: true }
|
||||
const folderPath = `f/${folder}`
|
||||
try {
|
||||
if (await checkItemExists('folder', folderPath, req.devWorkspaceId)) return { success: true }
|
||||
} catch (e) {
|
||||
// The one probe that must not fail open: deploying while the folder is in fact missing is
|
||||
// the orphaning above, and nothing downstream would catch it.
|
||||
return { success: false, error: `could not check whether ${folderPath} exists (${e})` }
|
||||
}
|
||||
// Create-only: nobody asked for this folder to be deployed, so it must never overwrite one
|
||||
// that appeared meanwhile. See `createFolderIfAbsent`.
|
||||
const result = await createFolderIfAbsent(folder, req.prodWorkspaceId, req.devWorkspaceId)
|
||||
if (result.droppedAccess?.length) {
|
||||
// Narrower than the source rather than wider, so it doesn't block the deploy — but it is
|
||||
// still not what the folder looked like where it came from.
|
||||
sendUserToast(
|
||||
`${folderPath} was created without access for ${result.droppedAccess.join(', ')} — ` +
|
||||
`no such user or group in ${req.devWorkspaceName}`
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function presenceInDev(
|
||||
req: NonNullable<typeof pending>
|
||||
): Promise<'present' | 'absent' | 'unknown'> {
|
||||
try {
|
||||
return (await checkItemExists(req.itemType, req.itemPath, req.devWorkspaceId))
|
||||
? 'present'
|
||||
: 'absent'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const req = pending
|
||||
if (!req || updating) return
|
||||
// Claimed before the first `await`, for the same reason the dropdown entry claims one: a tab
|
||||
// opened from a promise continuation never appears on Safari. Released on every path that
|
||||
// leaves the prompt up, so a retry starts from a clean slate.
|
||||
const tab = req.openInNewTab ? claimTab() : undefined
|
||||
async function leaveTo(url: string, destination: string) {
|
||||
if (tab) tab.show(url)
|
||||
else if (req!.openInNewTab) {
|
||||
// The claim was blocked, and so is this. Every caller has already closed the prompt and
|
||||
// may have deployed, so staying silent would read as the confirm having done nothing.
|
||||
if (!window.open(url)) sendUserToast(`Allow popups to open ${destination}`, true)
|
||||
} else await goto(url)
|
||||
}
|
||||
const itemInDev = `${req.itemPath} in ${req.devWorkspaceName}`
|
||||
if (!canDeploy) {
|
||||
// Read before closing: the href is derived from the request being answered, so clearing it
|
||||
// first leaves nothing to navigate to.
|
||||
const href = compareHref
|
||||
close()
|
||||
await leaveTo(href, 'the compare page')
|
||||
return
|
||||
}
|
||||
updating = true
|
||||
// Folders carry no on_behalf_of, so only the item itself takes one.
|
||||
let result = await ensureFolder(req)
|
||||
if (result.success) {
|
||||
// The prompt is only up because the item was absent, so this asks once more before writing
|
||||
// and the write itself refuses to become an update (`createOnly` below). Between them,
|
||||
// whoever landed it meanwhile is opened rather than overwritten.
|
||||
const presence = await presenceInDev(req)
|
||||
if (presence === 'present') {
|
||||
updating = false
|
||||
close()
|
||||
sendUserToast(`${req.itemPath} is already in ${req.devWorkspaceName}, opening it`)
|
||||
await leaveTo(
|
||||
devWorkspaceEditUrl(req.itemType, req.itemPath, req.devWorkspaceId),
|
||||
itemInDev
|
||||
)
|
||||
return
|
||||
}
|
||||
if (presence === 'unknown') {
|
||||
// Someone may have landed it meanwhile and writing would overwrite them, so this probe
|
||||
// can't fail open either. Prompt stays up so a retry is one click away.
|
||||
updating = false
|
||||
tab?.discard()
|
||||
sendUserToast(
|
||||
`Could not check whether ${req.itemPath} is already in ${req.devWorkspaceName}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
const deployed = await deployItem({
|
||||
kind: req.itemType,
|
||||
path: req.itemPath,
|
||||
workspaceFrom: req.prodWorkspaceId,
|
||||
workspaceTo: req.devWorkspaceId,
|
||||
onBehalfOf: chosenIdentity?.email,
|
||||
onBehalfOfPrincipal: chosenIdentity?.permissionedAs,
|
||||
createOnly: true
|
||||
})
|
||||
if (deployed.conflict) {
|
||||
// Landed between the probe above and the write, and `createOnly` refused rather than
|
||||
// replacing it. Their version stands; open it, as the probe's own branch does.
|
||||
updating = false
|
||||
close()
|
||||
sendUserToast(`${req.itemPath} is already in ${req.devWorkspaceName}, opening it`)
|
||||
await leaveTo(
|
||||
devWorkspaceEditUrl(req.itemType, req.itemPath, req.devWorkspaceId),
|
||||
itemInDev
|
||||
)
|
||||
return
|
||||
}
|
||||
result = deployed
|
||||
}
|
||||
updating = false
|
||||
if (!result.success) {
|
||||
// Kept open so the failure is attached to the item it happened on — a lone item can
|
||||
// fail to stand on its own (missing resource, resource type...).
|
||||
tab?.discard()
|
||||
sendUserToast(
|
||||
`Could not update ${req.devWorkspaceName} with ${req.itemPath}: ${result.error}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
close()
|
||||
await leaveTo(devWorkspaceEditUrl(req.itemType, req.itemPath, req.devWorkspaceId), itemInDev)
|
||||
}
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!pending}
|
||||
type="info"
|
||||
title="{pending?.devWorkspaceName} is behind on this item"
|
||||
confirmationText={canDeploy ? 'Update and edit' : 'Open compare page'}
|
||||
loading={updating}
|
||||
keyListen={!pickerOpen}
|
||||
confirmDisabled={confirmBlocked}
|
||||
onConfirmed={confirm}
|
||||
onCanceled={close}
|
||||
>
|
||||
{#if pending}
|
||||
<p>
|
||||
<span class="font-mono">{pending.itemPath}</span>
|
||||
exists in <b>{pending.prodWorkspaceId}</b> but not in its dev workspace
|
||||
<b>{pending.devWorkspaceName}</b>.
|
||||
</p>
|
||||
{#if canDeploy}
|
||||
<p class="mt-2">
|
||||
Update <b>{pending.devWorkspaceName}</b> with it to edit it there.
|
||||
</p>
|
||||
{#if sourceOnBehalfOfFailed}
|
||||
<div class="mt-2">
|
||||
<Alert type="error" size="xs" title="Could not read {pending.itemPath}">
|
||||
Its "run on behalf of" user is unknown, so updating could silently reassign the item to
|
||||
you. Retry from the compare page.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if targetIdentityUnknown}
|
||||
<div class="mt-2">
|
||||
<Alert
|
||||
type="error"
|
||||
size="xs"
|
||||
title="Could not read your account in {pending.devWorkspaceName}"
|
||||
>
|
||||
This item needs a "run on behalf of" user and none can be applied without it. Retry from
|
||||
the compare page.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if showOnBehalfOf}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span class="text-xs text-secondary">Runs on behalf of</span>
|
||||
<OnBehalfOfSelector
|
||||
targetWorkspace={pending.devWorkspaceId}
|
||||
targetValue={undefined}
|
||||
selected={onBehalfOfChoice}
|
||||
onSelect={(choice, details) => {
|
||||
onBehalfOfChoice = choice
|
||||
if (details) customOnBehalfOf = details
|
||||
}}
|
||||
kind={pending.itemType}
|
||||
canPreserve={access?.value.canPreserveOnBehalfOf ?? false}
|
||||
customValue={customOnBehalfOf?.permissionedAs}
|
||||
aboveConfirmationModal
|
||||
onPickerOpenChange={(open) => (pickerOpen = open)}
|
||||
myPermissionedAs={access?.value.me?.permissionedAs}
|
||||
/>
|
||||
</div>
|
||||
{#if onBehalfOfUnset}
|
||||
<span class="text-xs text-yellow-600">
|
||||
You must set the "on behalf of" user before updating
|
||||
<Tooltip class="text-yellow-600">
|
||||
The "run on behalf of" field defines which user's permissions will be applied during
|
||||
execution. Make sure this is set to an appropriate user before updating.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if permission}
|
||||
<div class="mt-2">
|
||||
<Alert type="warning" size="xs" title="You can't update {pending.devWorkspaceName}">
|
||||
{permission.reason}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
@@ -79,7 +79,7 @@
|
||||
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { updatePolicy } from './appPolicy'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -465,6 +465,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush the pending autosave (also covers the toggle-off parked case).
|
||||
* Returns whether there was a draft to flush — false in the AI session pane,
|
||||
* which owns no handle. */
|
||||
function flushDraft(): boolean {
|
||||
if (inSessionPane || !$workspaceStore || !userDraftPath) return false
|
||||
void UserDraftDbSyncer.flush({
|
||||
workspace: $workspaceStore,
|
||||
itemKind: 'app',
|
||||
path: userDraftPath
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Monaco swallows the keydown, so an inline script or template editor with
|
||||
// focus never reaches the window handler below; Editor/SimpleEditor/
|
||||
// TemplateEditor re-broadcast it (untyped event, hence the manual listener).
|
||||
$effect(() => {
|
||||
const onMonacoSave = () => void flushDraft()
|
||||
window.addEventListener('wm-monaco-save-shortcut', onMonacoSave)
|
||||
return () => window.removeEventListener('wm-monaco-save-shortcut', onMonacoSave)
|
||||
})
|
||||
|
||||
let lock = false
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (lock) return
|
||||
@@ -492,17 +514,12 @@
|
||||
}
|
||||
break
|
||||
case 's':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Shift excluded: the switch lowercases so Ctrl+Shift+S lands here
|
||||
// too, and swallowing it would steal the browser/OS shortcut.
|
||||
// Swallowed only when there is a draft to flush, so the contexts
|
||||
// that can't act on it (AI session pane) leave the key alone.
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && flushDraft()) {
|
||||
event.preventDefault()
|
||||
// Flush the pending autosave (also covers the toggle-off parked
|
||||
// case); no-op in the AI session pane (no handle there).
|
||||
if (!inSessionPane && $workspaceStore && userDraftPath) {
|
||||
void UserDraftDbSyncer.flush({
|
||||
workspace: $workspaceStore,
|
||||
itemKind: 'app',
|
||||
path: userDraftPath
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
// case 'ArrowDown': {
|
||||
@@ -1142,7 +1159,7 @@
|
||||
{
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('app', $appPath))
|
||||
openEditInFork('app', $appPath, $workspaceStore)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -355,8 +355,12 @@
|
||||
onblur={bubble('blur')}
|
||||
onmouseenter={bubble('mouseenter')}
|
||||
onmouseleave={bubble('mouseleave')}
|
||||
onclick={() => {
|
||||
onclick={(event) => {
|
||||
loading = true
|
||||
// A link button can still want to intercept its own click (e.g. to resolve
|
||||
// the real destination first and preventDefault), so `onClick` must run here
|
||||
// too — the button branch below is not the only one that takes a handler.
|
||||
onClick?.(event)
|
||||
dispatch('click', event)
|
||||
if (!loadUntilNav) {
|
||||
loading = false
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
confirmationText: string
|
||||
keyListen?: boolean
|
||||
loading?: boolean
|
||||
/** Blocks confirming (button and Enter) while a required choice in `children` is unmade. */
|
||||
confirmDisabled?: boolean
|
||||
open?: boolean
|
||||
type?: 'danger' | 'reload' | 'info'
|
||||
showIcon?: boolean
|
||||
@@ -31,6 +33,7 @@
|
||||
confirmationText,
|
||||
keyListen = true,
|
||||
loading = false,
|
||||
confirmDisabled = false,
|
||||
open = false,
|
||||
type: _type,
|
||||
showIcon = true,
|
||||
@@ -64,16 +67,35 @@
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
const popover = (event.target as HTMLElement | null)?.closest?.('[data-popover]')
|
||||
// Content carries no `aria-controls`; a trigger's resolves only while its content is
|
||||
// mounted, which is the only reliable open/closed signal — the trigger's own aria state
|
||||
// is stale because visibility is driven outside melt.
|
||||
const controls = popover?.getAttribute('aria-controls')
|
||||
const popoverOpen = !!popover && (!controls || !!document.getElementById(controls))
|
||||
|
||||
switch (event.key) {
|
||||
// Both keys are gated on the same state as the button they stand for, which is why
|
||||
// they swallow the event first and only then decide. Ungated, Enter re-enters an
|
||||
// in-flight confirm and Escape dismisses the modal out from under one — leaving the
|
||||
// action to finish against a caller that believes it was cancelled.
|
||||
case 'Enter':
|
||||
// A popover needs Enter both to open from its trigger and to choose from its
|
||||
// content, so leave it alone whether or not it is open.
|
||||
if (popover) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (loading || confirmDisabled) break
|
||||
dispatch('confirmed')
|
||||
onConfirmed?.()
|
||||
break
|
||||
case 'Escape':
|
||||
// Only an open popover has something to dismiss; on a closed trigger Escape is
|
||||
// still the dialog's.
|
||||
if (popoverOpen) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (loading) break
|
||||
dispatch('canceled')
|
||||
onCanceled?.()
|
||||
break
|
||||
@@ -170,7 +192,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
disabled={loading}
|
||||
disabled={loading || confirmDisabled}
|
||||
on:click={() => (dispatch('confirmed'), onConfirmed?.())}
|
||||
color={theme[type].color}
|
||||
size="sm"
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -174,17 +175,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
wrapperClasses="w-32"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl(app.raw_app ? 'raw_app' : 'app', app.path)}
|
||||
>
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
<EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} />
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
@@ -239,8 +230,10 @@
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl(app.raw_app ? 'raw_app' : 'app', path),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
devWorkspaceEditUrl,
|
||||
editInForkLabel,
|
||||
forkWorkspaceUrl,
|
||||
onEditInForkClick,
|
||||
type ItemType
|
||||
} from '$lib/utils/editInFork'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import Button from '../button/Button.svelte'
|
||||
|
||||
interface Props {
|
||||
itemType: ItemType
|
||||
path: string
|
||||
}
|
||||
|
||||
let { itemType, path }: Props = $props()
|
||||
|
||||
let dev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
|
||||
let label = $derived(editInForkLabel($workspaceStore, $userWorkspaces))
|
||||
// Built from the reactive `dev` rather than `buildForkEditUrl`, whose store reads are untracked:
|
||||
// the href would otherwise stay frozen at mount while the label kept updating, so a row that
|
||||
// outlives a workspace change would offer to fork a workspace that already has a dev.
|
||||
let href = $derived(
|
||||
dev ? devWorkspaceEditUrl(itemType, path, dev.id) : forkWorkspaceUrl(itemType, path)
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- title on the wrapper, not on <Button>: Button renders its `title` prop only in the
|
||||
<button> branch, so the <a> branch taken here (href is set) would silently drop it.
|
||||
On the wrapper it also covers the icon and padding, not just the label text. -->
|
||||
<div title={label}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
wrapperClasses="max-w-56"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Pen }}
|
||||
{href}
|
||||
onClick={(e) => onEditInForkClick(e, itemType, path, { hasHref: true })}
|
||||
>
|
||||
{#if dev}
|
||||
<!-- Split so only the workspace name ellipsizes — "Edit in" always stays whole. -->
|
||||
<span class="inline-flex items-center gap-1 min-w-0">
|
||||
<span class="shrink-0">Edit in</span>
|
||||
<span class="truncate">{dev.name}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="truncate">{label}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -37,7 +37,8 @@
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -195,17 +196,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
wrapperClasses="w-32"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl('flow', flow.path)}
|
||||
>
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
<EditInForkButton itemType="flow" path={flow.path} />
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
@@ -256,8 +247,10 @@
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl('flow', path),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'flow', path),
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
|
||||
import EditInForkButton from './EditInForkButton.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
interface Props {
|
||||
@@ -253,17 +254,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)}
|
||||
<div>
|
||||
<Button
|
||||
variant={!showEditButton ? 'default' : 'subtle'}
|
||||
wrapperClasses="w-32"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: GitFork }}
|
||||
href={buildForkEditUrl('script', script.path)}
|
||||
>
|
||||
{editInForkLabel($workspaceStore, $userWorkspaces)}
|
||||
</Button>
|
||||
</div>
|
||||
<EditInForkButton itemType="script" path={script.path} />
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
@@ -336,8 +327,10 @@
|
||||
},
|
||||
{
|
||||
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
icon: GitFork,
|
||||
href: buildForkEditUrl('script', script.path),
|
||||
icon: Pen,
|
||||
// No `href`: the handler resolves the destination asynchronously, and a melt
|
||||
// menu item's anchor navigates before a delegated onclick can preventDefault it.
|
||||
action: (e) => onEditInForkClick(e, 'script', script.path),
|
||||
hide:
|
||||
$userStore?.operator ||
|
||||
isCloudHosted() ||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Check, Loader2, Wand2 } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { getNonStreamingMetadataCompletion } from './lib'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Flow, InputTransform } from '$lib/gen'
|
||||
import ManualPopover from '../ManualPopover.svelte'
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import type { PickableProperties } from '../flows/previousResults'
|
||||
@@ -14,7 +11,6 @@
|
||||
import { yamlStringifyExceptKeys } from './utils'
|
||||
import { stepInputCompletionEnabled } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let generatedContent = $state('')
|
||||
let loading = $state(false)
|
||||
@@ -26,8 +22,6 @@
|
||||
|
||||
let { focused = false, arg, pickableProperties = undefined }: Props = $props()
|
||||
|
||||
let btnFocused = $state(false)
|
||||
|
||||
let empty = $derived(
|
||||
Object.keys(arg ?? {}).length === 0 ||
|
||||
(arg.type === 'static' && !arg.value) ||
|
||||
@@ -111,8 +105,7 @@ Only output the expression, do not explain or discuss.`
|
||||
|
||||
function cancelOnOutOfFocus() {
|
||||
setTimeout(() => {
|
||||
if (!focused && !btnFocused) {
|
||||
// only cancel if out of focus is not due to click on btn
|
||||
if (!focused) {
|
||||
cancel()
|
||||
}
|
||||
}, 150)
|
||||
@@ -142,60 +135,7 @@ Only output the expression, do not explain or discuss.`
|
||||
$effect(() => {
|
||||
dispatch('showExpr', generatedContent)
|
||||
})
|
||||
|
||||
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
|
||||
</script>
|
||||
|
||||
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
|
||||
<ManualPopover showTooltip={!empty && generatedContent.length > 0} placement="bottom" class="p-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
btnClasses={twMerge(
|
||||
'text-ai bg-violet-100 dark:bg-gray-700 dark:hover:bg-surface-hover',
|
||||
!loading && generatedContent.length > 0
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-100 dark:text-green-400 dark:bg-green-700 dark:hover:bg-green-700'
|
||||
: ''
|
||||
)}
|
||||
on:click={() => {
|
||||
if (!loading && generatedContent.length > 0) {
|
||||
dispatch('setExpr', generatedContent)
|
||||
generatedContent = ''
|
||||
}
|
||||
}}
|
||||
on:focus={() => {
|
||||
btnFocused = true
|
||||
}}
|
||||
on:blur={() => {
|
||||
btnFocused = false
|
||||
}}
|
||||
on:mouseenter={(ev) => {
|
||||
if (out) {
|
||||
out = false
|
||||
generateIteratorExpr()
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
out = true
|
||||
cancel()
|
||||
}}
|
||||
endIcon={{
|
||||
icon: loading ? Loader2 : generatedContent.length > 0 ? Check : Wand2,
|
||||
classes: loading ? 'animate-spin' : ''
|
||||
}}
|
||||
>
|
||||
{#if focused}
|
||||
{#if loading}
|
||||
ESC
|
||||
{:else if generatedContent.length > 0}
|
||||
TAB
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
{#snippet content()}
|
||||
<div class="text-sm text-primary">
|
||||
{generatedContent}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ManualPopover>
|
||||
{/if}
|
||||
<!-- Headless: the focus effect generates the ghost-text suggestion and
|
||||
`onKeyUp` accepts it with Tab. There is no on-screen trigger. -->
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import type { Flow } from '$lib/gen'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { AIBtnClasses } from './chat/AIButtonStyle'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let loading = $state(false)
|
||||
interface Props {
|
||||
@@ -94,14 +96,23 @@ Only return the expression without any wrapper. Do not explain or discuss.`
|
||||
contentClasses="p-4 flex w-96"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Sized to match FlowPlugConnect: the two sit side by side under every
|
||||
predicate input, so they have to read as one pair of controls. -->
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs3"
|
||||
color={loading ? 'red' : 'light'}
|
||||
size="xs"
|
||||
nonCaptureEvent={!loading}
|
||||
startIcon={{ icon: Wand2 }}
|
||||
iconOnly
|
||||
title="AI Assistant"
|
||||
btnClasses="min-h-[30px] text-ai bg-violet-100 dark:bg-gray-700"
|
||||
btnClasses={twMerge(AIBtnClasses(), 'bg-surface overflow-clip flex p-0')}
|
||||
wrapperClasses={twMerge(
|
||||
// Revealed by the row it sits in, like the connect plug beside it. A request in
|
||||
// flight keeps it visible so its cancel affordance stays reachable.
|
||||
'h-5 w-8 p-0 group-hover:opacity-100 transition-opacity',
|
||||
loading ? '' : 'opacity-0'
|
||||
)}
|
||||
{loading}
|
||||
clickableWhileLoading
|
||||
on:click={loading ? () => abortController?.abort() : () => {}}
|
||||
@@ -121,11 +132,11 @@ Only return the expression without any wrapper. Do not explain or discuss.`
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
unifiedSize="sm"
|
||||
color="light"
|
||||
variant="contained"
|
||||
buttonType="button"
|
||||
btnClasses="!p-1 !w-[38px] !ml-2 text-ai bg-violet-100 dark:bg-gray-700"
|
||||
btnClasses={'!ml-2 ' + AIBtnClasses()}
|
||||
title="Generate predicate from prompt"
|
||||
aria-label="Generate"
|
||||
iconOnly
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import { Check, Loader2, Wand2 } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { getNonStreamingMetadataCompletion } from './lib'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Flow, InputTransform } from '$lib/gen'
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import type { Flow } from '$lib/gen'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import type { PickableProperties } from '../flows/previousResults'
|
||||
import YAML from 'yaml'
|
||||
@@ -17,46 +13,34 @@
|
||||
import { stepInputCompletionEnabled } from '$lib/stores'
|
||||
import type { SchemaProperty } from '$lib/common'
|
||||
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { AIBtnClasses } from './chat/AIButtonStyle'
|
||||
import { Check, Wand2 } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let generatedContent = $state('')
|
||||
let loading = $state(false)
|
||||
interface Props {
|
||||
/** Whether the input this belongs to has focus — a suggestion is only worth keeping
|
||||
* while the user is still on that input. */
|
||||
focused?: boolean
|
||||
arg: InputTransform | any
|
||||
schemaProperty: SchemaProperty
|
||||
pickableProperties?: PickableProperties | undefined
|
||||
argName: string
|
||||
btnClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
focused = false,
|
||||
arg,
|
||||
schemaProperty,
|
||||
pickableProperties = undefined,
|
||||
argName,
|
||||
btnClass = ''
|
||||
}: Props = $props()
|
||||
|
||||
let empty = $state(false)
|
||||
run(() => {
|
||||
empty =
|
||||
Object.keys(arg ?? {}).length === 0 ||
|
||||
(arg.type === 'static' && !arg.value) ||
|
||||
(arg.type === 'javascript' && !arg.expr)
|
||||
})
|
||||
let { focused = false, schemaProperty, pickableProperties = undefined, argName }: Props = $props()
|
||||
|
||||
/** The button takes focus when clicked, which blurs the input — without tracking that,
|
||||
* asking for a suggestion would immediately look like leaving the field. */
|
||||
let btnFocused = $state(false)
|
||||
|
||||
let abortController = new AbortController()
|
||||
let newFlowInput = $state('')
|
||||
|
||||
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { stepInputsLoading, generatedExprs } =
|
||||
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
|
||||
const { generatedExprs } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
|
||||
|
||||
function createFlowInput() {
|
||||
if (!newFlowInput) {
|
||||
@@ -177,35 +161,18 @@ Only return the expression without any wrapper.`
|
||||
generatedContent = ''
|
||||
}
|
||||
|
||||
function automaticGeneration() {
|
||||
if (empty) {
|
||||
generateStepInput()
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOnOutOfFocus() {
|
||||
setTimeout(() => {
|
||||
if (!focused && !btnFocused) {
|
||||
// only cancel if out of focus is not due to click on btn
|
||||
// Drop a suggestion once the user has moved on, so the accept button can't sit there armed
|
||||
// against an input nobody is editing. Deferred because focus moves through nothing on its
|
||||
// way from the input to the button, and left alone while loading: the click that asked for
|
||||
// the suggestion is itself what blurred the input.
|
||||
$effect(() => {
|
||||
if (focused || btnFocused) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!focused && !btnFocused && !loading) {
|
||||
cancel()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!focused) {
|
||||
untrack(() => {
|
||||
cancelOnOutOfFocus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($copilotInfo.enabled && $stepInputCompletionEnabled && focused) {
|
||||
untrack(() => {
|
||||
automaticGeneration()
|
||||
})
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -216,8 +183,27 @@ Only return the expression without any wrapper.`
|
||||
dispatch('showExpr', $generatedExprs?.[argName] || '')
|
||||
})
|
||||
|
||||
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
|
||||
let openInputsModal = $state(false)
|
||||
|
||||
/** A suggestion is waiting to be accepted, rather than waiting to be asked for. */
|
||||
let ready = $derived(!loading && generatedContent.length > 0)
|
||||
|
||||
function accept() {
|
||||
dispatch('setExpr', generatedContent)
|
||||
if (newFlowInput) {
|
||||
openInputsModal = true
|
||||
}
|
||||
generatedContent = ''
|
||||
}
|
||||
|
||||
// A suggestion costs a model call, so nothing generates on its own — this button is the
|
||||
// only trigger, and the same control then accepts what it produced. Blur must not cancel:
|
||||
// clicking here takes focus out of the input, which is the gesture that started the call.
|
||||
function onClick() {
|
||||
if (loading) cancel()
|
||||
else if (ready) accept()
|
||||
else generateStepInput()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
|
||||
@@ -228,54 +214,27 @@ Only return the expression without any wrapper.`
|
||||
bind:open={openInputsModal}
|
||||
inputs={[newFlowInput]}
|
||||
/>
|
||||
<!-- Sized to match FlowPlugConnect: it shares the control row with the connect plug. -->
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
size="xs3"
|
||||
iconOnly
|
||||
{loading}
|
||||
clickableWhileLoading
|
||||
title={loading ? 'Cancel' : ready ? 'Accept the suggestion' : 'Suggest an expression with AI'}
|
||||
startIcon={{ icon: ready ? Check : Wand2 }}
|
||||
btnClasses={twMerge(
|
||||
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
|
||||
btnClass
|
||||
AIBtnClasses(ready ? 'green' : 'default'),
|
||||
'bg-surface overflow-clip flex p-0'
|
||||
)}
|
||||
on:click={() => {
|
||||
if (!loading && generatedContent.length > 0) {
|
||||
dispatch('setExpr', generatedContent)
|
||||
if (newFlowInput) {
|
||||
openInputsModal = true
|
||||
}
|
||||
generatedContent = ''
|
||||
}
|
||||
}}
|
||||
on:mouseenter={(ev) => {
|
||||
if (out) {
|
||||
out = false
|
||||
generateStepInput()
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
out = true
|
||||
cancel()
|
||||
}}
|
||||
endIcon={{
|
||||
icon:
|
||||
loading || ($stepInputsLoading && empty)
|
||||
? Loader2
|
||||
: generatedContent.length > 0
|
||||
? Check
|
||||
: Wand2,
|
||||
classes: loading || ($stepInputsLoading && empty) ? 'animate-spin' : ''
|
||||
}}
|
||||
on:focus={() => {
|
||||
btnFocused = true
|
||||
}}
|
||||
on:blur={() => {
|
||||
btnFocused = false
|
||||
}}
|
||||
>
|
||||
{#if focused}
|
||||
{#if loading}
|
||||
ESC
|
||||
{:else if generatedContent.length > 0}
|
||||
TAB
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
wrapperClasses={twMerge(
|
||||
'h-5 w-8 p-0 group-hover:opacity-100 transition-opacity',
|
||||
// Same reveal-on-hover as the connect plug beside it, but a request in flight or a
|
||||
// suggestion waiting to be accepted has to stay reachable once the pointer leaves.
|
||||
loading || ready ? '' : 'opacity-0'
|
||||
)}
|
||||
on:click={onClick}
|
||||
on:focus={() => (btnFocused = true)}
|
||||
on:blur={() => (btnFocused = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -168,10 +168,25 @@ input_name2: expression2
|
||||
}
|
||||
}
|
||||
|
||||
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
|
||||
let openInputsModal = $state(false)
|
||||
|
||||
let disabled = $derived(argNames.length === 0)
|
||||
|
||||
/** Suggestions are in hand and waiting to be applied, rather than waiting to be asked for. */
|
||||
let ready = $derived(!loading && Object.keys($generatedExprs || {}).length > 0)
|
||||
|
||||
function cancel() {
|
||||
abortController.abort()
|
||||
generatedExprs?.set({})
|
||||
}
|
||||
|
||||
// Filling every input costs a model call, so it takes a deliberate click — the pointer
|
||||
// merely crossing the button must not spend one. The same control then applies the result.
|
||||
function onClick() {
|
||||
if (loading) cancel()
|
||||
else if (ready) applyExprs()
|
||||
else generateStepInputs()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row justify-end">
|
||||
@@ -187,37 +202,23 @@ input_name2: expression2
|
||||
size="xs"
|
||||
wrapperClasses="flex-1"
|
||||
variant="default"
|
||||
btnClasses={twMerge(
|
||||
!disabled &&
|
||||
AIBtnClasses(
|
||||
!loading && Object.keys($generatedExprs || {}).length > 0 ? 'green' : 'default'
|
||||
)
|
||||
)}
|
||||
on:mouseenter={(ev) => {
|
||||
if (out) {
|
||||
out = false
|
||||
generateStepInputs()
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
out = true
|
||||
abortController.abort()
|
||||
generatedExprs?.set({})
|
||||
}}
|
||||
on:click={() => {
|
||||
if (!loading && Object.keys($generatedExprs || {}).length > 0) {
|
||||
applyExprs()
|
||||
}
|
||||
btnClasses={twMerge(!disabled && AIBtnClasses(ready ? 'green' : 'default'))}
|
||||
on:click={onClick}
|
||||
on:blur={() => {
|
||||
// Suggestions belong to the moment they were asked for; leaving the button drops
|
||||
// them so it can't sit on "Accept" against inputs the user has moved on from.
|
||||
// A request still in flight is left alone — it was asked for deliberately.
|
||||
if (!loading) cancel()
|
||||
}}
|
||||
startIcon={{
|
||||
icon: loading ? Loader2 : Object.keys($generatedExprs || {}).length > 0 ? Check : Wand2,
|
||||
icon: loading ? Loader2 : ready ? Check : Wand2,
|
||||
classes: loading ? 'animate-spin' : ''
|
||||
}}
|
||||
{disabled}
|
||||
>
|
||||
{#if loading}
|
||||
Loading
|
||||
{:else if Object.keys($generatedExprs || {}).length > 0}
|
||||
Cancel
|
||||
{:else if ready}
|
||||
Accept
|
||||
{:else}
|
||||
Fill inputs
|
||||
|
||||
@@ -149,7 +149,12 @@
|
||||
{
|
||||
role: 'assistant',
|
||||
content: aiChatManager.currentReply,
|
||||
...(aiChatManager.currentReasoning ? { reasoning: aiChatManager.currentReasoning } : {}),
|
||||
...(aiChatManager.currentReasoning
|
||||
? {
|
||||
reasoning: aiChatManager.currentReasoning,
|
||||
reasoningDurationMs: aiChatManager.currentReasoningDurationMs
|
||||
}
|
||||
: {}),
|
||||
streaming: true,
|
||||
contextElements: aiChatManager.contextManager
|
||||
.getSelectedContext()
|
||||
|
||||
@@ -428,6 +428,44 @@ export class AIChatManager {
|
||||
// a scalar: several workspace/provider pairs can be unavailable at once, and
|
||||
// the chat loop only notifies on first detection per pair.
|
||||
private reasoningSummaryUnavailableFor = $state<string[]>([])
|
||||
// Timed off arrival, not off the typewriter: the reveal paces *display*, so
|
||||
// reading the clock there would report how long the text took to paint.
|
||||
private reasoningStartedAt: number | undefined
|
||||
private reasoningEndedAt: number | undefined
|
||||
/** Set the moment thinking ends, which is mid-turn — the answer is still
|
||||
* streaming. Reactive so the live message settles to "Thought for X" then,
|
||||
* rather than waiting for the turn to finalize. */
|
||||
currentReasoningDurationMs = $state<number | undefined>(undefined)
|
||||
|
||||
private markReasoningStarted() {
|
||||
if (this.reasoningStartedAt === undefined) {
|
||||
this.reasoningStartedAt = Date.now()
|
||||
this.currentReasoningDurationMs = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Thinking ends at the first answer token; a turn that thinks straight into a
|
||||
* tool call ends it at the message boundary instead. */
|
||||
private markReasoningEnded() {
|
||||
if (this.reasoningStartedAt !== undefined && this.reasoningEndedAt === undefined) {
|
||||
this.reasoningEndedAt = Date.now()
|
||||
this.currentReasoningDurationMs = this.reasoningEndedAt - this.reasoningStartedAt
|
||||
}
|
||||
}
|
||||
|
||||
private resetReasoningTiming() {
|
||||
this.reasoningStartedAt = undefined
|
||||
this.reasoningEndedAt = undefined
|
||||
this.currentReasoningDurationMs = undefined
|
||||
}
|
||||
|
||||
/** Reads the duration and clears it, so the next reasoning pass of the same
|
||||
* turn (after a tool call) times itself from scratch. */
|
||||
private takeReasoningDuration(): number | undefined {
|
||||
const duration = this.currentReasoningDurationMs
|
||||
this.resetReasoningTiming()
|
||||
return duration
|
||||
}
|
||||
|
||||
private reasoningSummaryKey(provider: string): string {
|
||||
return `${this.operatingWorkspace ?? ''}:${provider}`
|
||||
@@ -2931,6 +2969,7 @@ export class AIChatManager {
|
||||
this.currentReply = ''
|
||||
this.currentReasoning = ''
|
||||
this.currentReasoningActive = false
|
||||
this.resetReasoningTiming()
|
||||
|
||||
// Compaction trigger. Without a known context window there is no limit
|
||||
// to enforce, so compaction stays off rather than guessing one.
|
||||
@@ -2996,9 +3035,19 @@ export class AIChatManager {
|
||||
messages: [...this.messages],
|
||||
abortController: this.abortController,
|
||||
callbacks: {
|
||||
onNewToken: (token) => this.replyReveal.push(token),
|
||||
onReasoningDelta: (token) => this.reasoningReveal.push(token),
|
||||
onReasoningStart: () => (this.currentReasoningActive = true),
|
||||
onNewToken: (token) => {
|
||||
this.markReasoningEnded()
|
||||
this.replyReveal.push(token)
|
||||
},
|
||||
// Not every provider fires onReasoningStart, so deltas start the clock too.
|
||||
onReasoningDelta: (token) => {
|
||||
this.markReasoningStarted()
|
||||
this.reasoningReveal.push(token)
|
||||
},
|
||||
onReasoningStart: () => {
|
||||
this.markReasoningStarted()
|
||||
this.currentReasoningActive = true
|
||||
},
|
||||
onMessageEnd: () => {
|
||||
// Drain any un-revealed backlog into currentReply first, so the reads
|
||||
// below see the full text. This funnel covers clean completion, tool
|
||||
@@ -3006,6 +3055,10 @@ export class AIChatManager {
|
||||
// keeps text from being lost or duplicated on any exit path.
|
||||
this.replyReveal.flush()
|
||||
this.reasoningReveal.flush()
|
||||
// A turn that reasoned straight into a tool call never saw an answer
|
||||
// token, so this is where its thinking stops.
|
||||
this.markReasoningEnded()
|
||||
const reasoningDurationMs = this.takeReasoningDuration()
|
||||
// Keep the streamed text for the abort/error paths. Non-empty only:
|
||||
// parsers flush (and reset) when a tool call starts after text, and
|
||||
// the catch's later empty call would wipe it — stale keeps are
|
||||
@@ -3019,7 +3072,9 @@ export class AIChatManager {
|
||||
{
|
||||
role: 'assistant',
|
||||
content: this.currentReply,
|
||||
...(this.currentReasoning ? { reasoning: this.currentReasoning } : {}),
|
||||
...(this.currentReasoning
|
||||
? { reasoning: this.currentReasoning, reasoningDurationMs }
|
||||
: {}),
|
||||
contextElements:
|
||||
this.mode === AIMode.SCRIPT
|
||||
? oldSelectedContext.filter((c) => c.type === 'code')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FlowAIChatHelpers } from './flow/core'
|
||||
import type { PipelineAIChatHelpers } from './pipeline/core'
|
||||
import type { CurrentEditor } from '$lib/components/flows/types'
|
||||
@@ -3003,3 +3003,90 @@ describe('AIChatManager.waitForPipelineHelpers', () => {
|
||||
await expect(manager.waitForPipelineHelpers(10)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager reasoning duration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mocks.getCurrentModel.mockReturnValue({ model: 'test-model', provider: 'openai' })
|
||||
})
|
||||
|
||||
// The file-level hook only clears call records, so the clock spy below would
|
||||
// stay installed and freeze time for anything that runs after it.
|
||||
afterEach(() => {
|
||||
nowSpy?.mockRestore()
|
||||
nowSpy = undefined
|
||||
})
|
||||
|
||||
let nowSpy: ReturnType<typeof vi.spyOn> | undefined
|
||||
|
||||
function assistantDurations(manager: AIChatManager): (number | undefined)[] {
|
||||
return manager.displayMessages
|
||||
.filter((m) => m.role === 'assistant')
|
||||
.map((m) => (m as { reasoningDurationMs?: number }).reasoningDurationMs)
|
||||
}
|
||||
|
||||
it('stops the clock at the first answer token, not at the end of the turn', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.changeMode(AIMode.ASK)
|
||||
manager.setAiChatInput({ restoreInstructions: vi.fn(), focusInput: vi.fn() } as any)
|
||||
|
||||
let now = 1_000
|
||||
nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
|
||||
vi.mocked(runChatLoop).mockImplementation(async (config) => {
|
||||
config.callbacks.onReasoningStart?.()
|
||||
config.callbacks.onReasoningDelta?.('weighing the options')
|
||||
now += 4_000
|
||||
config.callbacks.onNewToken('here is the answer')
|
||||
// The answer keeps streaming well past the end of thinking; none of it
|
||||
// may land in the duration.
|
||||
now += 9_000
|
||||
config.callbacks.onMessageEnd()
|
||||
return {
|
||||
addedMessages: [],
|
||||
tokenUsage: {} as any,
|
||||
lastIterationUsage: null,
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
manager.instructions = 'do a thing'
|
||||
await manager.sendRequest()
|
||||
|
||||
expect(assistantDurations(manager)).toEqual([4_000])
|
||||
})
|
||||
|
||||
it('times each reasoning pass of a tool-using turn independently', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.changeMode(AIMode.ASK)
|
||||
manager.setAiChatInput({ restoreInstructions: vi.fn(), focusInput: vi.fn() } as any)
|
||||
|
||||
let now = 1_000
|
||||
nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
|
||||
vi.mocked(runChatLoop).mockImplementation(async (config) => {
|
||||
// First pass reasons straight into a tool call — no answer token, so the
|
||||
// message boundary is where its thinking stops.
|
||||
config.callbacks.onReasoningDelta?.('which tool do I need')
|
||||
now += 3_000
|
||||
config.callbacks.onMessageEnd()
|
||||
// Tool execution must not be billed to either pass.
|
||||
now += 20_000
|
||||
config.callbacks.onReasoningDelta?.('now what does that result mean')
|
||||
now += 7_000
|
||||
config.callbacks.onNewToken('here is the answer')
|
||||
config.callbacks.onMessageEnd()
|
||||
return {
|
||||
addedMessages: [],
|
||||
tokenUsage: {} as any,
|
||||
lastIterationUsage: null,
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
manager.instructions = 'do a thing'
|
||||
await manager.sendRequest()
|
||||
|
||||
expect(assistantDurations(manager)).toEqual([3_000, 7_000])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { base } from '$lib/base'
|
||||
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { thinkingPreferences } from './thinkingPreferences.svelte'
|
||||
import {
|
||||
getReasoningCapability,
|
||||
resolveEffectiveReasoning,
|
||||
@@ -377,6 +378,19 @@
|
||||
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- A reading preference rather than a model parameter: it applies to every
|
||||
chat in this browser, including thinking already in the transcript. -->
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
|
||||
>
|
||||
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
|
||||
{#if thinkingPreferences.expandByDefault}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Brain, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { DisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import { thinkingPreferences } from './thinkingPreferences.svelte'
|
||||
import CodeDisplay from './script/CodeDisplay.svelte'
|
||||
import LinkRenderer from './LinkRenderer.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -24,15 +23,40 @@
|
||||
const reasoning = $derived(
|
||||
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
|
||||
)
|
||||
// Spinner while the reasoning text streams before the answer. Only the live
|
||||
// Set the moment thinking ends, which is mid-turn on the live message — the
|
||||
// answer streams on afterwards.
|
||||
const reasoningDurationMs = $derived(
|
||||
message.role === 'assistant' ? message.reasoningDurationMs : undefined
|
||||
)
|
||||
// Shimmer while the reasoning text streams before the answer. Only the live
|
||||
// synthetic message carries `streaming` — a finalized reasoning-only message
|
||||
// (thinking that led straight to a tool call) must not look in-progress.
|
||||
const reasoningStreaming = $derived(
|
||||
!!reasoning && message.role === 'assistant' && !!message.streaming && !message.content
|
||||
!!reasoning &&
|
||||
message.role === 'assistant' &&
|
||||
!!message.streaming &&
|
||||
!message.content &&
|
||||
reasoningDurationMs === undefined
|
||||
)
|
||||
// Expand while still thinking, collapse once the answer begins — unless toggled.
|
||||
// Undefined until this block is toggled by hand, so flipping the preference
|
||||
// reaches every block the reader hasn't already made a decision about.
|
||||
let reasoningToggled = $state<boolean | undefined>(undefined)
|
||||
const reasoningExpanded = $derived(reasoningToggled ?? reasoningStreaming)
|
||||
const reasoningExpanded = $derived(reasoningToggled ?? thinkingPreferences.expandByDefault)
|
||||
const reasoningLabel = $derived(
|
||||
reasoningDurationMs !== undefined
|
||||
? `Thought for ${formatThinkingDuration(reasoningDurationMs)}`
|
||||
: reasoningStreaming
|
||||
? 'Thinking...'
|
||||
: 'Thinking'
|
||||
)
|
||||
|
||||
function formatThinkingDuration(ms: number): string {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
|
||||
}
|
||||
|
||||
const candidatePaths = $derived(extractCandidatePaths(message.content))
|
||||
const rendererPlugin = {
|
||||
@@ -74,36 +98,17 @@
|
||||
</script>
|
||||
|
||||
{#if reasoning}
|
||||
<div class="mb-2 bg-surface border border-border-light rounded-md overflow-hidden text-xs">
|
||||
<button
|
||||
class={twMerge(
|
||||
'w-full p-2 bg-surface-secondary/30 hover:bg-surface-hover transition-colors flex items-center gap-2 text-left',
|
||||
reasoningExpanded ? 'border-b border-border-light' : ''
|
||||
)}
|
||||
onclick={() => (reasoningToggled = !reasoningExpanded)}
|
||||
>
|
||||
{#if reasoningExpanded}
|
||||
<ChevronDown class="w-3 h-3 text-secondary" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3 text-secondary" />
|
||||
{/if}
|
||||
{#if reasoningStreaming}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{:else}
|
||||
<Brain class="w-3.5 h-3.5 text-secondary" />
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">Thinking</span>
|
||||
</button>
|
||||
|
||||
{#if reasoningExpanded}
|
||||
<div
|
||||
transition:slide={{ duration: 150 }}
|
||||
class="p-2 bg-surface text-secondary {markdownProse.xs}"
|
||||
>
|
||||
<Markdown md={reasoning} plugins={[gfmPlugin()]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ChatCollapsibleCard
|
||||
label={reasoningLabel}
|
||||
expanded={reasoningExpanded}
|
||||
onToggle={() => (reasoningToggled = !reasoningExpanded)}
|
||||
shimmer={reasoningStreaming}
|
||||
class="mb-2"
|
||||
labelClass="truncate"
|
||||
contentClass="font-main text-secondary {markdownProse.xs}"
|
||||
>
|
||||
<Markdown md={reasoning} plugins={[gfmPlugin()]} />
|
||||
</ChatCollapsibleCard>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
// A card with nothing to reveal keeps the header inert (no chevron, no
|
||||
// hover affordance implying a toggle).
|
||||
toggleable?: boolean
|
||||
// Sweeps a highlight across the label while the row is in progress.
|
||||
shimmer?: boolean
|
||||
// Pinned to the right of the header row, outside the toggle button.
|
||||
headerRight?: Snippet
|
||||
// Always-visible content between the header and the expandable body.
|
||||
belowHeader?: Snippet
|
||||
children?: Snippet
|
||||
class?: string
|
||||
headerClass?: string
|
||||
labelClass?: string
|
||||
contentClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
expanded,
|
||||
onToggle,
|
||||
toggleable = true,
|
||||
shimmer = false,
|
||||
headerRight,
|
||||
belowHeader,
|
||||
children,
|
||||
class: className,
|
||||
headerClass,
|
||||
labelClass,
|
||||
contentClass
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class={twMerge('font-mono text-xs', className)}>
|
||||
{#snippet labelText()}
|
||||
<span class={twMerge('text-secondary font-medium text-2xs', labelClass)}>
|
||||
{label}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet headerButton()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'min-w-0 py-0.5 my-0.5 rounded-md hover:bg-surface-hover transition-colors inline-flex items-center gap-2 text-left',
|
||||
headerClass
|
||||
)}
|
||||
onclick={onToggle}
|
||||
disabled={!toggleable}
|
||||
>
|
||||
{#if shimmer}
|
||||
<span class="shimmer inline-flex items-center min-w-0">
|
||||
{@render labelText()}
|
||||
<span class="shimmer-band inline-flex items-center min-w-0" aria-hidden="true">
|
||||
{@render labelText()}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
{@render labelText()}
|
||||
{/if}
|
||||
{#if toggleable}
|
||||
<ChevronRight
|
||||
class={twMerge(
|
||||
'w-3 h-3 text-secondary transition-transform duration-150 shrink-0',
|
||||
expanded ? 'rotate-90' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if headerRight}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{@render headerButton()}
|
||||
{@render headerRight()}
|
||||
</div>
|
||||
{:else}
|
||||
{@render headerButton()}
|
||||
{/if}
|
||||
|
||||
{@render belowHeader?.()}
|
||||
|
||||
{#if expanded && children}
|
||||
<div
|
||||
transition:slide={{ duration: 150 }}
|
||||
class={twMerge('border border-border-light rounded-md bg-surface p-3', contentClass)}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A white copy of the label sits on top of the coloured one and is revealed
|
||||
through a travelling band, so the highlight is a colour change rather than
|
||||
an opacity change and the row keeps its own colour underneath. */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
}
|
||||
.shimmer-band {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* Forces the copy white whatever colour the caller gives the label, without
|
||||
having to out-specify its utility classes. */
|
||||
filter: brightness(0) invert(1);
|
||||
--wm-shimmer-band: linear-gradient(
|
||||
100deg,
|
||||
rgba(0, 0, 0, 0.2) 40%,
|
||||
rgba(0, 0, 0, 1) 50%,
|
||||
rgba(0, 0, 0, 0.2) 60%
|
||||
);
|
||||
-webkit-mask-image: var(--wm-shimmer-band);
|
||||
mask-image: var(--wm-shimmer-band);
|
||||
-webkit-mask-size: 250% 100%;
|
||||
mask-size: 250% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
animation: wm-shimmer-sweep 2.6s linear infinite;
|
||||
}
|
||||
/* The travel itself takes 1.5s — the rest of the period holds the band
|
||||
off-screen, so sweeps are spaced out instead of running back to back.
|
||||
250% wide is what puts it off-screen at both ends rather than popping at
|
||||
the edges; the row rests at the gradient's floor in between. */
|
||||
@keyframes wm-shimmer-sweep {
|
||||
0% {
|
||||
-webkit-mask-position: 100% 0;
|
||||
mask-position: 100% 0;
|
||||
}
|
||||
58%,
|
||||
100% {
|
||||
-webkit-mask-position: 0 0;
|
||||
mask-position: 0 0;
|
||||
}
|
||||
}
|
||||
/* The sweep is the only thing marking a row as running, so it degrades to a
|
||||
flat wash rather than disappearing — otherwise a running tool row would be
|
||||
indistinguishable from a settled one here. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-band {
|
||||
animation: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -889,7 +889,7 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-words;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
min-height: 1.9rem;
|
||||
}
|
||||
|
||||
/* Hide the textarea's own glyphs (the highlight overlay renders the text)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, ChevronRight, XCircle, Play } from 'lucide-svelte'
|
||||
import { XCircle, Play } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
@@ -33,14 +32,20 @@
|
||||
)
|
||||
const autoCollapseDetails = $derived(message.autoCollapseDetails !== false)
|
||||
|
||||
// Executing right now, as opposed to queued behind another tool or waiting on
|
||||
// the user — the only state that gets the shimmer.
|
||||
const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation))
|
||||
|
||||
// An errored tool must be expandable even if it never opted into details,
|
||||
// otherwise the error set on its status would be invisible.
|
||||
const detailsAvailable = $derived(message.showDetails === true || message.error !== undefined)
|
||||
|
||||
let isExpanded = $derived(
|
||||
(detailsAvailable && (!isSuccessful || !autoCollapseDetails)) ||
|
||||
(message.isStreamingArguments && hasParameters) ||
|
||||
(message.isLoading && message.needsConfirmation)
|
||||
Boolean(
|
||||
(detailsAvailable && (!isSuccessful || !autoCollapseDetails)) ||
|
||||
(message.isStreamingArguments && hasParameters) ||
|
||||
(message.isLoading && message.needsConfirmation)
|
||||
)
|
||||
)
|
||||
|
||||
const visibleActions = $derived(
|
||||
@@ -65,141 +70,109 @@
|
||||
{#if activeUserQuestion}
|
||||
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
|
||||
{:else}
|
||||
<!-- Queued calls (waiting their turn behind the executing tool) are faded: the
|
||||
reduced weight is what says "not started" — no icon, no spinner. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'font-mono text-xs',
|
||||
message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' : ''
|
||||
)}
|
||||
>
|
||||
<!-- Collapsible Header -->
|
||||
{#snippet headerButton()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'min-w-0 py-0.5 my-0.5 rounded-md hover:bg-surface-hover transition-colors inline-flex items-center text-left',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!detailsAvailable && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge('text-primary font-medium text-2xs', showPreviewChip ? 'truncate' : '')}
|
||||
>
|
||||
{message.content}
|
||||
</span>
|
||||
<!-- Discrete preview chip for an item a tool created/updated, pinned to the
|
||||
right of the header row. Rendered inline (not gated on expand) so it stays
|
||||
visible after the tool collapses. -->
|
||||
{#snippet previewChip()}
|
||||
{#if message.previewCard}
|
||||
<ToolPreviewCard card={message.previewCard} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if detailsAvailable || message.isStreamingArguments}
|
||||
<ChevronRight
|
||||
class={twMerge(
|
||||
'w-3 h-3 text-secondary transition-transform duration-150 shrink-0',
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
<!-- The shimmer is the only running indicator, so the states have to read off
|
||||
weight alone: queued calls (waiting their turn behind the executing tool)
|
||||
are faded, the running one sweeps, a settled one is plain. -->
|
||||
<ChatCollapsibleCard
|
||||
label={message.content}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => (isExpanded = !isExpanded)}
|
||||
toggleable={detailsAvailable || message.isStreamingArguments === true}
|
||||
shimmer={isRunning}
|
||||
class={message.isQueued && !message.error
|
||||
? 'opacity-60 hover:opacity-100 transition-opacity'
|
||||
: ''}
|
||||
headerClass={message.needsConfirmation ? 'opacity-80' : ''}
|
||||
labelClass={showPreviewChip ? 'truncate' : ''}
|
||||
contentClass="space-y-3"
|
||||
headerRight={showPreviewChip ? previewChip : undefined}
|
||||
>
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
{#snippet belowHeader()}
|
||||
{#if message.imageUrl}
|
||||
<div class="my-1">
|
||||
<ExpandableImage
|
||||
src={message.imageUrl}
|
||||
alt="App preview screenshot"
|
||||
class="max-h-48 max-w-full rounded border border-border-light"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Discrete preview chip for an item a tool created/updated, pinned to
|
||||
the right of the header row. Rendered inline (not gated on expand) so it
|
||||
stays visible after the tool collapses. -->
|
||||
{#if showPreviewChip && message.previewCard}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{@render headerButton()}
|
||||
<ToolPreviewCard card={message.previewCard} />
|
||||
</div>
|
||||
{:else}
|
||||
{@render headerButton()}
|
||||
{/if}
|
||||
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
{#if message.imageUrl}
|
||||
<div class="my-1">
|
||||
<ExpandableImage
|
||||
src={message.imageUrl}
|
||||
alt="App preview screenshot"
|
||||
class="max-h-48 max-w-full rounded border border-border-light"
|
||||
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
|
||||
{#if hasParameters || message.needsConfirmation}
|
||||
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
|
||||
<ToolContentDisplay
|
||||
title="Parameters"
|
||||
content={message.parameters}
|
||||
streaming={message.isStreamingArguments}
|
||||
toolName={message.toolName}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Expanded Content -->
|
||||
{#if isExpanded}
|
||||
<div
|
||||
transition:slide={{ duration: 150 }}
|
||||
class="border border-border-light rounded-md bg-surface p-3 space-y-3"
|
||||
>
|
||||
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
|
||||
{#if hasParameters || message.needsConfirmation}
|
||||
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
|
||||
<ToolContentDisplay
|
||||
title="Parameters"
|
||||
content={message.parameters}
|
||||
streaming={message.isStreamingArguments}
|
||||
toolName={message.toolName}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirmation Footer -->
|
||||
{#if message.needsConfirmation}
|
||||
<div class="flex flex-row items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: XCircle }}
|
||||
destructive
|
||||
></Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Play }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logs and Result - hide while streaming -->
|
||||
{:else if !message.isStreamingArguments}
|
||||
<ToolContentDisplay
|
||||
title="Logs"
|
||||
content={message.logs}
|
||||
loading={message.isLoading}
|
||||
showWhileLoading={false}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<ToolMessageActions actions={visibleActions} />
|
||||
{:else if message.webSearchSources?.length && !message.error}
|
||||
<WebSearchSourcesDisplay sources={message.webSearchSources} />
|
||||
{:else}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={message.result}
|
||||
error={message.error}
|
||||
loading={message.isLoading}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Confirmation Footer -->
|
||||
{#if message.needsConfirmation}
|
||||
<div class="flex flex-row items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: XCircle }}
|
||||
destructive
|
||||
></Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (message.tool_call_id) {
|
||||
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Play }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logs and Result - hide while streaming -->
|
||||
{:else if !message.isStreamingArguments}
|
||||
<ToolContentDisplay
|
||||
title="Logs"
|
||||
content={message.logs}
|
||||
loading={message.isLoading}
|
||||
showWhileLoading={false}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<ToolMessageActions actions={visibleActions} />
|
||||
{:else if message.webSearchSources?.length && !message.error}
|
||||
<WebSearchSourcesDisplay sources={message.webSearchSources} />
|
||||
{:else}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={message.result}
|
||||
error={message.error}
|
||||
loading={message.isLoading}
|
||||
showFade={message.showFade}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</ChatCollapsibleCard>
|
||||
{/if}
|
||||
|
||||
@@ -157,7 +157,9 @@
|
||||
},
|
||||
|
||||
selectStep: (id) => {
|
||||
selectionManager.selectId(id)
|
||||
// The step's editor must actually be on screen for the user to see what the
|
||||
// assistant is working on.
|
||||
selectionManager.selectId(id, { openPanel: true })
|
||||
},
|
||||
|
||||
testFlow: async (args, conversationId) => {
|
||||
@@ -175,8 +177,9 @@
|
||||
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
||||
}
|
||||
|
||||
// Focus the module first
|
||||
selectionManager.selectId(moduleId)
|
||||
// Lint is read off the mounted editor, so the panel has to be open — with it
|
||||
// closed the poll below would time out and report a clean script.
|
||||
selectionManager.selectId(moduleId, { openPanel: true })
|
||||
|
||||
// Poll until editor exists
|
||||
const maxWait = 3000
|
||||
|
||||
@@ -606,6 +606,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
role: 'assistant'
|
||||
/** Summarized reasoning/thinking text streamed before the answer (Anthropic + compat providers). */
|
||||
reasoning?: string
|
||||
/** Wall time the model spent reasoning, from the first thinking token to the
|
||||
* first answer token. Absent on messages finalized before it was recorded. */
|
||||
reasoningDurationMs?: number
|
||||
/**
|
||||
* True only on the synthetic live message appended while tokens stream
|
||||
* (see AIChat.svelte). Finalized messages never set it — without the flag,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
|
||||
const EXPAND_THINKING_SETTING = 'ai-chat-expand-thinking'
|
||||
|
||||
// How the reader wants to read, not anything about the conversation — so it
|
||||
// lives per browser rather than in chat history or workspace settings, and
|
||||
// applies to every chat at once.
|
||||
let expandByDefault = $state(BROWSER && getLocalSetting(EXPAND_THINKING_SETTING) === 'true')
|
||||
|
||||
export const thinkingPreferences = {
|
||||
get expandByDefault() {
|
||||
return expandByDefault
|
||||
},
|
||||
set expandByDefault(value: boolean) {
|
||||
expandByDefault = value
|
||||
storeLocalSetting(EXPAND_THINKING_SETTING, value ? 'true' : undefined)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { SupportedLanguage } from '$lib/common'
|
||||
|
||||
export type FlowBuilderWhitelabelCustomUi = {
|
||||
/** Opt out of FlowEditor's `modalPanel` to keep the classic always-docked pane. */
|
||||
modalPanel?: boolean
|
||||
topBar?: {
|
||||
path?: boolean
|
||||
editablePath?: boolean
|
||||
|
||||
@@ -82,6 +82,14 @@ const initialState: DebugState = {
|
||||
|
||||
export const debugState = writable<DebugState>({ ...initialState })
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
// `launch` waits on dependency installation in the debug server, which can take minutes on a
|
||||
// cold cache; anything shorter here reports a timeout while the install is still running.
|
||||
const REQUEST_TIMEOUT_MS_BY_COMMAND: Record<string, number> = {
|
||||
launch: 180_000
|
||||
}
|
||||
|
||||
export class DAPClient {
|
||||
private ws: WebSocket | null = null
|
||||
private seq = 1
|
||||
@@ -120,7 +128,13 @@ export class DAPClient {
|
||||
logs: s.logs,
|
||||
output: s.output
|
||||
}))
|
||||
// Reject rather than drop: a dropped `launch` leaves its caller awaiting
|
||||
// until the timeout below fires, which is minutes rather than seconds.
|
||||
const aborted = Array.from(this.pendingRequests.values())
|
||||
this.pendingRequests.clear()
|
||||
for (const pending of aborted) {
|
||||
pending.reject(new Error('DAP connection closed'))
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
@@ -164,11 +178,13 @@ export class DAPClient {
|
||||
arguments: args
|
||||
}
|
||||
|
||||
const timeoutMs = REQUEST_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(seq)
|
||||
reject(new Error(`Request timeout: ${command}`))
|
||||
}, 10000)
|
||||
reject(new Error(`Request timeout: ${command} (after ${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pendingRequests.set(seq, {
|
||||
resolve: (value) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import Label from '../Label.svelte'
|
||||
import type { Schema } from '$lib/common'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
let {
|
||||
debounce_delay_s = $bindable(),
|
||||
@@ -16,7 +17,8 @@
|
||||
placeholder,
|
||||
size = 'xs',
|
||||
color = undefined,
|
||||
fontClass = 'font-normal'
|
||||
fontClass = 'font-normal',
|
||||
indentContent = false
|
||||
}: {
|
||||
debounce_delay_s: number | undefined
|
||||
debounce_key: string | undefined
|
||||
@@ -28,6 +30,8 @@
|
||||
size: 'xs' | 'sm'
|
||||
color?: 'nord' | undefined
|
||||
fontClass?: string
|
||||
/** Align the fields under the toggle's label, as the flow step settings do. */
|
||||
indentContent?: boolean
|
||||
} = $props()
|
||||
|
||||
// Check if an originalType like "string | string[]" is a top-level
|
||||
@@ -85,6 +89,10 @@
|
||||
max_total_debounces_amount = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the
|
||||
// user is still typing, and reading that as off would disable the field mid-edit.
|
||||
let off = $derived(!$enterpriseLicense || debounce_delay_s === undefined)
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -94,9 +102,9 @@
|
||||
{color}
|
||||
{size}
|
||||
disabled={!$enterpriseLicense}
|
||||
checked={Boolean(debounce_delay_s)}
|
||||
checked={debounce_delay_s !== undefined}
|
||||
on:change={() => {
|
||||
if (debounce_delay_s) {
|
||||
if (debounce_delay_s !== undefined) {
|
||||
debounce_delay_s = undefined
|
||||
debounce_key = undefined
|
||||
} else {
|
||||
@@ -113,10 +121,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if debounce_delay_s}
|
||||
<div class="flex flex-col gap-4 mt-2">
|
||||
{#if !off}
|
||||
<div class="flex flex-col gap-4 mt-2 {indentContent ? 'pl-9' : ''}" transition:slideDynamic>
|
||||
<Label label="Delay in seconds">
|
||||
<SecondsInput disabled={!$enterpriseLicense} bind:seconds={debounce_delay_s} />
|
||||
<SecondsInput disabled={off} bind:seconds={debounce_delay_s} />
|
||||
</Label>
|
||||
<Label label="Custom debounce key (optional)">
|
||||
{#snippet header()}
|
||||
@@ -125,14 +133,7 @@
|
||||
`$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
autofocus
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:value={debounce_key}
|
||||
{placeholder}
|
||||
/>
|
||||
<input type="text" disabled={off} bind:value={debounce_key} {placeholder} />
|
||||
</Label>
|
||||
<Label label="Argument to accumulate (optional)">
|
||||
{#snippet header()}
|
||||
@@ -141,7 +142,7 @@
|
||||
debounced execution will be appended together.</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<select disabled={!$enterpriseLicense} bind:value={selectedArg}>
|
||||
<select disabled={off} bind:value={selectedArg}>
|
||||
<option value="">None</option>
|
||||
{#each arrayArgs as arg}
|
||||
<option value={arg}>{arg}</option>
|
||||
@@ -161,7 +162,7 @@
|
||||
this time is reached, the job will run regardless of ongoing debouncing.</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<SecondsInput disabled={!$enterpriseLicense} bind:seconds={max_total_debouncing_time} />
|
||||
<SecondsInput disabled={off} bind:seconds={max_total_debouncing_time} />
|
||||
</Label>
|
||||
<Label label="Max total debounces amount (optional)">
|
||||
{#snippet header()}
|
||||
@@ -170,12 +171,7 @@
|
||||
is reached, the job will run regardless of ongoing debouncing.</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<input
|
||||
type="number"
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:value={max_total_debounces_amount}
|
||||
min="0"
|
||||
/>
|
||||
<input type="number" disabled={off} bind:value={max_total_debounces_amount} min="0" />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
|
||||
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
|
||||
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
||||
import { Skeleton } from '../common'
|
||||
import { getContext, onDestroy, onMount, setContext } from 'svelte'
|
||||
import type { FlowEditorContext } from './types'
|
||||
import { getContext, onDestroy, onMount, setContext, untrack } from 'svelte'
|
||||
import type { FlowEditorContext, FlowPanelDetachContext } from './types'
|
||||
import { getOverlayHost } from '$lib/components/common/overlayHost.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { isFlowLevelPanelTarget } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import { useFlowPanelMode } from './flowPanelMode.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
|
||||
@@ -26,7 +31,10 @@
|
||||
import type { FlowOptions } from '../copilot/chat/ContextManager.svelte'
|
||||
import { extractAllModules } from '../copilot/chat/shared'
|
||||
import type { Snippet } from 'svelte'
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
import { Button } from '../common'
|
||||
import { MousePointerClick, X } from 'lucide-svelte'
|
||||
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
|
||||
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
|
||||
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
|
||||
|
||||
@@ -69,6 +77,9 @@
|
||||
flowHasChanged?: boolean
|
||||
previewOpen: boolean
|
||||
graphOverlay?: Snippet
|
||||
/** Allow the step-details pane to open as a modal. Whitelabel embeds turn this off
|
||||
* to keep the classic always-docked pane. */
|
||||
modalPanel?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -105,11 +116,84 @@
|
||||
onDelete,
|
||||
flowHasChanged,
|
||||
previewOpen,
|
||||
graphOverlay
|
||||
graphOverlay,
|
||||
modalPanel = true
|
||||
}: Props = $props()
|
||||
|
||||
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
|
||||
|
||||
// 'docked' = normal split pane; 'modal' = graph full-width, panel in a modal opened by
|
||||
// double-clicking a node. The controller resolves it from the user's Auto/Attached/
|
||||
// Detached preference and the width measured below.
|
||||
const panelController = useFlowPanelMode({ enabled: () => modalPanel })
|
||||
const panelMode = $derived(panelController.mode)
|
||||
let panelModalOpen = $state(false)
|
||||
|
||||
// Auto can move the panel back into the pane under a modal that is open — leaving it
|
||||
// open would keep an overlay registered for a modal nothing renders, swallowing Escape.
|
||||
$effect(() => {
|
||||
if (panelMode === 'docked' && untrack(() => panelModalOpen)) {
|
||||
panelModalOpen = false
|
||||
}
|
||||
})
|
||||
|
||||
let panelDisposable: Disposable | undefined = $state(undefined)
|
||||
// Disposable joins the stack through its methods, not by watching `open` — same sync
|
||||
// as Drawer and Modal, so setting `panelModalOpen` anywhere still registers the overlay.
|
||||
$effect(() => {
|
||||
panelModalOpen
|
||||
untrack(() => {
|
||||
panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer()
|
||||
})
|
||||
})
|
||||
|
||||
const overlayHost = getOverlayHost()
|
||||
const modalHost = $derived(overlayHost?.el())
|
||||
|
||||
// Only nodes that can take the selection, or the modal would open on whatever was
|
||||
// selected before — asset and note nodes are deliberately unselectable.
|
||||
//
|
||||
// The In/Out bar sits inside the node but is a picker of its own: it toggles open and
|
||||
// shut on click, so opening then closing it is a double-click the graph must not read
|
||||
// as "show me this step".
|
||||
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('[data-prop-picker]')) return null
|
||||
return target?.closest('.svelte-flow__node.selectable') ?? null
|
||||
}
|
||||
|
||||
function openPanelModalFromGraph(e: MouseEvent) {
|
||||
if (selectableNodeAt(e)) {
|
||||
panelModalOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
// A click on the step that is already selected is the second half of "select it, then
|
||||
// show it". Read in the capture phase: by the time the click bubbles here the graph has
|
||||
// applied its own selection, so a first click would look indistinguishable from this.
|
||||
let clickStartedOnSelected = false
|
||||
function noteSelectionBeforeClick(e: MouseEvent) {
|
||||
const node = selectableNodeAt(e)
|
||||
clickStartedOnSelected =
|
||||
Boolean(node?.classList.contains('selected')) && selectionManager.selectedIds.length === 1
|
||||
}
|
||||
|
||||
function openPanelModalIfReselected(e: MouseEvent) {
|
||||
if (clickStartedOnSelected && selectableNodeAt(e)) {
|
||||
panelModalOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
// In modal mode a step's editor is a click or two away but invisible until then —
|
||||
// keep a standing hint whenever the graph is showing (modal closed).
|
||||
const showStepHint = $derived.by(() => panelMode === 'modal' && !panelModalOpen)
|
||||
const stepHintText = $derived.by(() => {
|
||||
const ids = selectionManager.selectedIds
|
||||
return ids.length === 1 && !isFlowLevelPanelTarget(ids[0])
|
||||
? 'Click the selected step to explore its content'
|
||||
: 'Double click a step to explore its content'
|
||||
})
|
||||
|
||||
// When the graph pane is narrow, fall back to a top-centered overlay so the
|
||||
// preview buttons don't overlap the rightmost node ports (matches the dev
|
||||
// page layout).
|
||||
@@ -124,9 +208,46 @@
|
||||
flowModuleSchemaMap?.enableNotes?.()
|
||||
}
|
||||
|
||||
const flowPropPickerConfig = writable<FlowPropPickerConfig | undefined>(undefined)
|
||||
// Closing the modal unmounts the panel that started a graph connect, but the config
|
||||
// outlives it — a later pick would run a closure over a step nobody is editing.
|
||||
$effect(() => {
|
||||
if (!panelModalOpen) {
|
||||
flowPropPickerConfig.set(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
setContext<PropPickerContext>('PropPickerContext', {
|
||||
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
|
||||
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
|
||||
flowPropPickerConfig,
|
||||
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined),
|
||||
inModalPanel: () => panelMode === 'modal'
|
||||
})
|
||||
|
||||
// Read by graph step items (VirtualItem) to show a per-step "explore" hint on hover,
|
||||
// since in modal mode a step's editor is hidden until a double-click, or a click on the
|
||||
// step that is already selected.
|
||||
setContext<() => boolean>('flowGraphStepExploreHint', () => panelMode === 'modal')
|
||||
|
||||
// The panel's chrome lives inline in its card header (no dedicated row); panels without
|
||||
// a card header get FlowEditor's fallback strip instead, driven by the claim count.
|
||||
let detachClaims = $state(0)
|
||||
setContext<FlowPanelDetachContext>('flowPanelDetach', {
|
||||
claim: () => {
|
||||
detachClaims++
|
||||
return () => detachClaims--
|
||||
},
|
||||
modalOpen: () => modalPanel && panelMode === 'modal' && panelModalOpen,
|
||||
close: () => (panelModalOpen = false),
|
||||
enabled: () => modalPanel,
|
||||
preference: () => panelController.preference,
|
||||
setPreference: (preference) => {
|
||||
// Moving the panel must not lose what it was showing: docked, it is always on
|
||||
// screen, so the modal it becomes has to open on arrival. The reverse is handled
|
||||
// by the effect above, which closes a modal that is no longer rendered.
|
||||
const wasVisible = panelMode === 'docked' || panelModalOpen
|
||||
panelController.preference = preference
|
||||
panelModalOpen = panelController.mode === 'modal' && wasVisible
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -141,6 +262,14 @@
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (modalPanel) {
|
||||
selectionManager.setOnSelectIntent((id, opts) => {
|
||||
if (opts?.openPanel === false) return
|
||||
if (panelMode === 'modal' && (opts?.openPanel || isFlowLevelPanelTarget(id))) {
|
||||
panelModalOpen = true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!sessionScopedManager) {
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.FLOW)
|
||||
@@ -149,6 +278,9 @@
|
||||
|
||||
onDestroy(() => {
|
||||
aiChatManager.flowOptions = undefined
|
||||
if (modalPanel) {
|
||||
selectionManager.setOnSelectIntent(undefined)
|
||||
}
|
||||
if (!sessionScopedManager) {
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
||||
@@ -156,18 +288,44 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet panelBody()}
|
||||
<FlowEditorPanel
|
||||
{disabledFlowInputs}
|
||||
{newFlow}
|
||||
{savedFlow}
|
||||
enableAi={!disableAi}
|
||||
on:applyArgs
|
||||
on:testWithArgs
|
||||
{onDeployTrigger}
|
||||
{forceTestTab}
|
||||
{highlightArg}
|
||||
{onTestFlow}
|
||||
{job}
|
||||
{isOwner}
|
||||
{suspendStatus}
|
||||
onOpenDetails={onOpenPreview}
|
||||
{previewOpen}
|
||||
{flowModuleSchemaMap}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
bind:clientWidth={null, (w) => panelController.measure(w)}
|
||||
id="flow-editor"
|
||||
class={'h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
|
||||
class={'relative h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
|
||||
use:triggerableByAI={{
|
||||
id: 'flow-editor',
|
||||
description: 'Component to edit a flow'
|
||||
}}
|
||||
>
|
||||
<Splitpanes>
|
||||
<Pane size={50} minSize={15} class="h-full relative z-0">
|
||||
<Pane size={panelMode === 'docked' ? 50 : 100} minSize={15} class="h-full relative z-0">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:clientWidth={graphPaneWidth}
|
||||
ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined}
|
||||
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
|
||||
onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined}
|
||||
class="grow overflow-hidden bg-gray h-full bg-surface-secondary relative"
|
||||
>
|
||||
{#if graphOverlay}
|
||||
@@ -226,36 +384,96 @@
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane class="relative z-10" size={50} minSize={20}>
|
||||
{#if loading}
|
||||
<div class="w-full h-full">
|
||||
<div class="block m-auto pt-40 w-10">
|
||||
<WindmillIcon height="40px" width="40px" spin="fast" />
|
||||
{#if panelMode === 'docked'}
|
||||
<!-- Panels manage their own scrolling, so the pane must not scroll as well or a second
|
||||
scrollbar appears beside theirs. `!` because splitpanes' own `overflow: auto` rule
|
||||
has equal specificity and wins on cascade order. -->
|
||||
<Pane class="relative z-10 !overflow-hidden" size={50} minSize={20}>
|
||||
{#if loading}
|
||||
<div class="w-full h-full">
|
||||
<div class="block m-auto pt-40 w-10">
|
||||
<WindmillIcon height="40px" width="40px" spin="fast" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<FlowEditorPanel
|
||||
{disabledFlowInputs}
|
||||
{newFlow}
|
||||
{savedFlow}
|
||||
enableAi={!disableAi}
|
||||
on:applyArgs
|
||||
on:testWithArgs
|
||||
{onDeployTrigger}
|
||||
{forceTestTab}
|
||||
{highlightArg}
|
||||
{onTestFlow}
|
||||
{job}
|
||||
{isOwner}
|
||||
{suspendStatus}
|
||||
onOpenDetails={onOpenPreview}
|
||||
{previewOpen}
|
||||
{flowModuleSchemaMap}
|
||||
/>
|
||||
{/if}
|
||||
</Pane>
|
||||
{:else if modalPanel}
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Fallback for panels without a card header hosting the placement
|
||||
picker: a slim strip so moving the panel stays reachable. Toggled
|
||||
around a stable panelBody — re-parenting it would re-mount
|
||||
the claiming header and loop. -->
|
||||
{#if detachClaims === 0}
|
||||
<div class="flex items-center justify-end border-b px-1">
|
||||
<FlowPanelPlacementPicker variant="header" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1">
|
||||
{@render panelBody()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render panelBody()}
|
||||
{/if}
|
||||
</Pane>
|
||||
{/if}
|
||||
{#if !disableAi}
|
||||
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
|
||||
{#if showStepHint}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-2 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
|
||||
>
|
||||
<MousePointerClick size={13} />
|
||||
{stepHintText}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Portalled out of `#flow-editor` so the modal covers the chrome around the editor
|
||||
(sidebar, top bar) rather than only the editor's own box. A host that embeds the
|
||||
editor in its own box provides an anchor element instead, keeping the modal inside
|
||||
it — one flow editor's modal must never cover a sibling's tab. -->
|
||||
<!-- Disposable owns the overlay stack: it takes a place while open, arbitrates Escape against
|
||||
whatever else is open in this pane, and stays quiet while the pane is hidden. -->
|
||||
<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}>
|
||||
{#snippet children({ zIndex })}
|
||||
{#if panelMode === 'modal' && panelModalOpen}
|
||||
<Portal target={modalHost ?? 'body'} class="contents">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6"
|
||||
style="z-index: {zIndex}"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div>
|
||||
<div
|
||||
class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl"
|
||||
>
|
||||
<!-- Same fallback as the docked strip: a panel whose body has a card header
|
||||
hosts the id, placement and close inline, so this bar would double it. -->
|
||||
{#if detachClaims === 0}
|
||||
<div class="flex items-center justify-end gap-2 border-b px-2 py-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<FlowPanelPlacementPicker variant="header" />
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Close"
|
||||
on:click={() => (panelModalOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
{@render panelBody()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Disposable>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { writable, get } from 'svelte/store'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import type { ExtendedOpenFlow } from './types'
|
||||
import { reorderBranches } from './branchOps'
|
||||
|
||||
function branch(expr: string) {
|
||||
return { summary: expr, expr, modules: [] as FlowModule[] }
|
||||
}
|
||||
|
||||
function ctx(branches: ReturnType<typeof branch>[]) {
|
||||
const flow = {
|
||||
summary: '',
|
||||
value: {
|
||||
modules: [{ id: 'a', value: { type: 'branchone', branches, default: [] } } as FlowModule]
|
||||
}
|
||||
} as ExtendedOpenFlow
|
||||
const flowStore: StateStore<ExtendedOpenFlow> = { val: flow }
|
||||
const history = writable({ history: [] as ExtendedOpenFlow[], index: -1 })
|
||||
const read = () =>
|
||||
(flowStore.val.value.modules[0].value as { branches: ReturnType<typeof branch>[] }).branches
|
||||
return { flowStore, history, read }
|
||||
}
|
||||
|
||||
describe('reorderBranches', () => {
|
||||
it('commits the new order', () => {
|
||||
const [x, y, z] = [branch('x'), branch('y'), branch('z')]
|
||||
const { flowStore, history, read } = ctx([x, y, z])
|
||||
|
||||
reorderBranches('a', [z, x, y], { flowStore, history })
|
||||
|
||||
expect(read().map((b) => b.expr)).toEqual(['z', 'x', 'y'])
|
||||
})
|
||||
|
||||
it('records an undo entry, so a drag can be undone like an add or a delete', () => {
|
||||
const [x, y] = [branch('x'), branch('y')]
|
||||
const { flowStore, history } = ctx([x, y])
|
||||
|
||||
reorderBranches('a', [y, x], { flowStore, history })
|
||||
|
||||
expect(get(history).history).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('spends nothing when the drag lands where it started', () => {
|
||||
// `finalize` fires on every drop, so a no-op drag must not consume an undo step.
|
||||
const [x, y] = [branch('x'), branch('y')]
|
||||
const { flowStore, history, read } = ctx([x, y])
|
||||
|
||||
reorderBranches('a', [x, y], { flowStore, history })
|
||||
|
||||
expect(get(history).history).toHaveLength(0)
|
||||
expect(read().map((b) => b.expr)).toEqual(['x', 'y'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { ExtendedOpenFlow } from './types'
|
||||
import type { FlowState } from './flowState'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import type { History } from '$lib/history.svelte'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import { dfs } from './dfs'
|
||||
import { findModuleInFlow } from './flowTree'
|
||||
|
||||
type BranchList = Array<{ summary?: string; expr?: string; modules: FlowModule[] }>
|
||||
|
||||
type Ctx = {
|
||||
flowStore: StateStore<ExtendedOpenFlow>
|
||||
flowStateStore: StateStore<FlowState>
|
||||
history: History<ExtendedOpenFlow>
|
||||
}
|
||||
|
||||
/** Append an empty branch to a branchone/branchall step. */
|
||||
export function addBranch(moduleId: string, { flowStore, history }: Omit<Ctx, 'flowStateStore'>) {
|
||||
push(history, flowStore.val)
|
||||
const module = findModuleInFlow(flowStore.val.value, moduleId)
|
||||
if (!module) throw new Error(`Node ${moduleId} not found`)
|
||||
|
||||
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
|
||||
module.value.branches.push({ summary: '', expr: 'false', modules: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a branch and the flow state of every step inside it.
|
||||
*
|
||||
* `index` counts the way the graph lays the branches out, where a branchone's default
|
||||
* occupies slot 0 — one ahead of the same branch's position in `value.branches`. Callers
|
||||
* working from the array (the settings panel) must add that offset back.
|
||||
*/
|
||||
export function removeBranch(
|
||||
moduleId: string,
|
||||
index: number,
|
||||
{ flowStore, flowStateStore, history }: Ctx
|
||||
) {
|
||||
push(history, flowStore.val)
|
||||
const module = findModuleInFlow(flowStore.val.value, moduleId)
|
||||
if (!module) throw new Error(`Node ${moduleId} not found`)
|
||||
|
||||
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
|
||||
const offset = module.value.type === 'branchone' ? 1 : 0
|
||||
const at = index - offset
|
||||
|
||||
if (module.value.branches[at]?.modules) {
|
||||
const leaves = dfs(module.value.branches[at].modules, (mod) => mod.id)
|
||||
leaves.forEach((leafId: string) => delete flowStateStore.val[leafId])
|
||||
}
|
||||
|
||||
module.value.branches.splice(at, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a reordered branch list. Undoable like add/remove — a drag is a structural edit,
|
||||
* and for a branchone it changes which predicate is evaluated first.
|
||||
*/
|
||||
export function reorderBranches(
|
||||
moduleId: string,
|
||||
ordered: BranchList,
|
||||
{ flowStore, history }: Omit<Ctx, 'flowStateStore'>
|
||||
) {
|
||||
const module = findModuleInFlow(flowStore.val.value, moduleId)
|
||||
if (!module) throw new Error(`Node ${moduleId} not found`)
|
||||
if (module.value.type !== 'branchone' && module.value.type !== 'branchall') return
|
||||
|
||||
const current = module.value.branches
|
||||
// A drag that lands where it started must not spend an undo entry.
|
||||
if (ordered.length === current.length && ordered.every((b, i) => b === current[i])) return
|
||||
|
||||
push(history, flowStore.val)
|
||||
module.value.branches = ordered as typeof current
|
||||
}
|
||||
|
||||
/** Slot a branch occupies in the graph's numbering, from its index in `value.branches`. */
|
||||
export function graphBranchIndex(type: 'branchone' | 'branchall', arrayIndex: number): number {
|
||||
return type === 'branchone' ? arrayIndex + 1 : arrayIndex
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
title?: string | undefined
|
||||
summary?: string | undefined
|
||||
description?: string | undefined
|
||||
subtitle?: string | undefined
|
||||
subtitleDocLink?: string | undefined
|
||||
noEditor: boolean
|
||||
noHeader?: boolean
|
||||
flowModuleValue?: FlowModuleValue | undefined
|
||||
@@ -20,6 +22,8 @@
|
||||
title = undefined,
|
||||
summary = $bindable(undefined),
|
||||
description = $bindable(undefined),
|
||||
subtitle = undefined,
|
||||
subtitleDocLink = undefined,
|
||||
noEditor,
|
||||
noHeader = false,
|
||||
flowModuleValue = undefined,
|
||||
@@ -37,9 +41,12 @@
|
||||
<FlowCardHeader
|
||||
on:setHash
|
||||
on:reload
|
||||
on:fork
|
||||
{title}
|
||||
bind:summary
|
||||
bind:description
|
||||
{subtitle}
|
||||
{subtitleDocLink}
|
||||
{flowModuleValue}
|
||||
{action}
|
||||
{isAgentTool}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<script lang="ts" module>
|
||||
let cachedValues: Record<
|
||||
string,
|
||||
{
|
||||
latestHash: string | undefined
|
||||
}
|
||||
> = {}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import MetadataGen from '$lib/components/copilot/MetadataGen.svelte'
|
||||
import IconedPath from '$lib/components/IconedPath.svelte'
|
||||
import { ScriptService, type FlowModuleValue, type PathScript } from '$lib/gen'
|
||||
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
|
||||
import { Flag, Lock, RefreshCw, Unlock } from 'lucide-svelte'
|
||||
import { ScriptService, type FlowModuleValue } from '$lib/gen'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Flag,
|
||||
GitFork,
|
||||
Lock,
|
||||
Pen,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Unlock
|
||||
} from 'lucide-svelte'
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import FlowPanelChrome from './FlowPanelChrome.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
|
||||
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
|
||||
import { getLatestHashForScript } from '$lib/scripts'
|
||||
import { sendUserToast, type Item } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
|
||||
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
|
||||
import autosize from '$lib/autosize'
|
||||
|
||||
interface Props {
|
||||
@@ -28,6 +32,10 @@
|
||||
title?: string | undefined
|
||||
summary?: string | undefined
|
||||
description?: string | undefined
|
||||
/** Static one-line explanation of what this kind of step does. Not the editable
|
||||
* `description`, which is the AI-tool prompt the user writes. */
|
||||
subtitle?: string | undefined
|
||||
subtitleDocLink?: string | undefined
|
||||
children?: import('svelte').Snippet
|
||||
action?: import('svelte').Snippet
|
||||
isAgentTool?: boolean
|
||||
@@ -39,6 +47,8 @@
|
||||
title = undefined,
|
||||
summary = $bindable(undefined),
|
||||
description = $bindable(undefined),
|
||||
subtitle = undefined,
|
||||
subtitleDocLink = undefined,
|
||||
children,
|
||||
action,
|
||||
isAgentTool = false,
|
||||
@@ -49,50 +59,125 @@
|
||||
isAgentTool ? getToolNameError(summary ?? '', undefined, siblingToolNames) : undefined
|
||||
)
|
||||
|
||||
let latestHash: string | undefined = $state(undefined)
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
// Extract version_id from hub path (format: hub/{version_id}/{app}/{summary})
|
||||
let hubVersionId = $derived(
|
||||
flowModuleValue?.type === 'script' && flowModuleValue.path?.startsWith('hub/')
|
||||
? flowModuleValue.path.split('/')[1]
|
||||
: undefined
|
||||
)
|
||||
|
||||
function getCachedKey(path: string) {
|
||||
return `${opWs}-${path}`
|
||||
}
|
||||
function getCachedValues(path: string) {
|
||||
const key = getCachedKey(path)
|
||||
latestHash = cachedValues[key]?.latestHash
|
||||
}
|
||||
const untrackedFlowModuleValue = untrack(() => flowModuleValue)
|
||||
if (untrackedFlowModuleValue?.type === 'script' && untrackedFlowModuleValue.path) {
|
||||
getCachedValues(untrackedFlowModuleValue.path)
|
||||
}
|
||||
|
||||
async function loadLatestHash(value: PathScript) {
|
||||
let script = await ScriptService.getScriptByPath({
|
||||
workspace: opWs!,
|
||||
path: value.path
|
||||
})
|
||||
const key = getCachedKey(value.path)
|
||||
cachedValues[key] = {
|
||||
latestHash: script.hash
|
||||
}
|
||||
latestHash = script.hash
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const customUi: FlowBuilderWhitelabelCustomUi | undefined = getContext('customUi')
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { scriptEditorDrawer, workspaceScriptSettingsDrawer } = flowEditorContext
|
||||
|
||||
$effect.pre(() => {
|
||||
$workspaceStore &&
|
||||
flowModuleValue?.type === 'script' &&
|
||||
flowModuleValue.path &&
|
||||
!flowModuleValue.path.startsWith('hub/') &&
|
||||
untrack(() => loadLatestHash(flowModuleValue))
|
||||
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
|
||||
const scriptPath = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.path : undefined)
|
||||
const pinnedHash = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.hash : undefined)
|
||||
const isHub = $derived(scriptPath?.startsWith('hub/') ?? false)
|
||||
// Version id out of a hub path: hub/{version_id}/{app}/{summary}
|
||||
const hubVersionId = $derived(isHub ? scriptPath?.split('/')[1] : undefined)
|
||||
|
||||
let latestHash: string | undefined = $state(undefined)
|
||||
$effect(() => {
|
||||
const path = scriptPath
|
||||
if (!opWs || !path || isHub) return
|
||||
untrack(async () => {
|
||||
latestHash = (await ScriptService.getScriptByPath({ workspace: opWs, path })).hash
|
||||
})
|
||||
})
|
||||
|
||||
function reportIssue() {
|
||||
const targetHubBaseUrl =
|
||||
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION ? DEFAULT_HUB_BASE_URL : $hubBaseUrlStore
|
||||
window.open(
|
||||
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
// Every one of these acts on the referenced script rather than the step, so they share
|
||||
// a single menu instead of a row of icon buttons.
|
||||
const scriptItems: Item[] = $derived.by(() => {
|
||||
if (flowModuleValue?.type !== 'script') return []
|
||||
const items: Item[] = []
|
||||
if (!isHub && customUi?.scriptEdit != false) {
|
||||
items.push({
|
||||
displayName: "Edit the script's code",
|
||||
icon: Pen,
|
||||
disabled: pinnedHash != undefined,
|
||||
tooltip: pinnedHash != undefined ? 'Unlock the hash to edit' : undefined,
|
||||
action: async () => {
|
||||
if (flowModuleValue?.type !== 'script') return
|
||||
const hash =
|
||||
flowModuleValue.hash ?? (await getLatestHashForScript(flowModuleValue.path, opWs))
|
||||
$scriptEditorDrawer?.openDrawer(hash, () => {
|
||||
dispatch('reload')
|
||||
sendUserToast('Script has been updated')
|
||||
})
|
||||
}
|
||||
})
|
||||
// Only when the settings drawer is actually mounted (not in the local-dev
|
||||
// editors, which provide the context store but never render it).
|
||||
if ($workspaceScriptSettingsDrawer) {
|
||||
items.push({
|
||||
displayName: 'Runtime settings',
|
||||
icon: Settings,
|
||||
disabled: pinnedHash != undefined,
|
||||
tooltip: 'Concurrency, cache, timeout, …',
|
||||
action: () => {
|
||||
if (flowModuleValue?.type !== 'script') return
|
||||
$workspaceScriptSettingsDrawer?.openDrawer(
|
||||
flowModuleValue.path,
|
||||
flowModuleValue.hash,
|
||||
() => dispatch('reload')
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (customUi?.scriptFork != false) {
|
||||
items.push({
|
||||
displayName: 'Fork into an inline script',
|
||||
icon: GitFork,
|
||||
action: () => dispatch('fork')
|
||||
})
|
||||
}
|
||||
if (pinnedHash) {
|
||||
if (latestHash && latestHash !== pinnedHash) {
|
||||
items.push({
|
||||
displayName: 'Update to latest hash',
|
||||
icon: ArrowUpCircle,
|
||||
separatorTop: items.length > 0,
|
||||
action: () => {
|
||||
dispatch('setHash', latestHash)
|
||||
dispatch('reload')
|
||||
}
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
displayName: 'Unlock hash',
|
||||
icon: Unlock,
|
||||
tooltip: 'Always use the latest deployed version at that path',
|
||||
separatorTop: items.length > 0 && !items.at(-1)?.separatorTop,
|
||||
action: () => dispatch('setHash', undefined)
|
||||
})
|
||||
} else if (latestHash) {
|
||||
items.push({
|
||||
displayName: 'Lock hash',
|
||||
icon: Lock,
|
||||
tooltip: 'Always use this specific version',
|
||||
separatorTop: items.length > 0,
|
||||
action: () => dispatch('setHash', latestHash)
|
||||
})
|
||||
items.push({
|
||||
displayName: 'Reload latest hash',
|
||||
icon: RefreshCw,
|
||||
action: () => dispatch('reload')
|
||||
})
|
||||
}
|
||||
if (hubVersionId) {
|
||||
items.push({
|
||||
displayName: 'Report issue',
|
||||
icon: Flag,
|
||||
separatorTop: items.length > 0,
|
||||
action: reportIssue
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -101,8 +186,8 @@
|
||||
class="overflow-x-auto scrollbar-hidden flex items-center justify-between flex-nowrap w-full"
|
||||
>
|
||||
{#if flowModuleValue}
|
||||
<span class="text-sm w-full mr-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="mr-4 min-w-0 flex-1 text-sm">
|
||||
<div class="flex min-w-0 items-center space-x-2">
|
||||
{#if flowModuleValue.type === 'identity'}
|
||||
<span class="font-bold text-xs">Identity (input copied to output)</span>
|
||||
{:else if flowModuleValue.type === 'rawscript'}
|
||||
@@ -121,75 +206,16 @@
|
||||
{siblingToolNames}
|
||||
/>
|
||||
{:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path}
|
||||
<IconedPath path={flowModuleValue.path} hash={flowModuleValue.hash} class="grow" />
|
||||
|
||||
{#if hubVersionId}
|
||||
<Button
|
||||
title="Report an issue with this hub script"
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
const targetHubBaseUrl =
|
||||
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION
|
||||
? DEFAULT_HUB_BASE_URL
|
||||
: $hubBaseUrlStore
|
||||
window.open(
|
||||
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
|
||||
'_blank'
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Flag size={12} />Report issue
|
||||
</Button>
|
||||
<IconedPath
|
||||
path={flowModuleValue.path}
|
||||
hash={flowModuleValue.hash}
|
||||
class="!w-auto shrink min-w-0"
|
||||
/>
|
||||
{#if scriptItems.length > 0}
|
||||
<DropdownV2 size="sm" placement="bottom-end" items={scriptItems} />
|
||||
{/if}
|
||||
|
||||
{#if flowModuleValue.hash}
|
||||
{#if latestHash != flowModuleValue.hash}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (flowModuleValue.type == 'script') {
|
||||
dispatch('setHash', latestHash)
|
||||
}
|
||||
dispatch('reload')
|
||||
}}>Update to latest hash</Button
|
||||
>
|
||||
{/if}
|
||||
<Button
|
||||
title="Unlock hash to always use latest deployed version at that path"
|
||||
size="xs"
|
||||
btnClasses="text-primary inline-flex gap-1 items-center"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
if (flowModuleValue.type == 'script') {
|
||||
dispatch('setHash', undefined)
|
||||
}
|
||||
}}><Unlock size={12} />hash</Button
|
||||
>
|
||||
{:else if latestHash}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
title="Lock hash to always use this specific version"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (flowModuleValue.type == 'script') {
|
||||
dispatch('setHash', latestHash)
|
||||
}
|
||||
}}><Lock size={12} />hash</Button
|
||||
>
|
||||
<Button
|
||||
title="Reload latest hash"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
on:click={() => dispatch('reload')}
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col w-full grow">
|
||||
<div class="flex min-w-[8rem] flex-1 flex-col">
|
||||
<input
|
||||
bind:value={summary}
|
||||
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
|
||||
@@ -210,11 +236,27 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if title}
|
||||
<div class="text-sm font-bold text-primary pr-2">{title}</div>
|
||||
<!-- Absorbs the free space so the actions stay together on the right: with
|
||||
justify-between alone, adding the detach button centres them. -->
|
||||
<div class="mr-auto truncate pr-2 text-sm font-semibold text-emphasis">{title}</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{@render action?.()}
|
||||
<FlowPanelChrome />
|
||||
</div>
|
||||
{#if subtitle}
|
||||
<p class="text-xs leading-snug text-tertiary">
|
||||
{subtitle}
|
||||
{#if subtitleDocLink}
|
||||
<a
|
||||
href={subtitleDocLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-blue-500 hover:underline">Docs</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if isAgentTool}
|
||||
{#if toolNameError}
|
||||
<p class="text-3xs text-red-400 leading-tight w-full">{toolNameError}</p>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import type { FlowPanelDetachContext } from '../types'
|
||||
import FlowPanelPlacementPicker from './FlowPanelPlacementPicker.svelte'
|
||||
|
||||
const panelDetach = getContext<FlowPanelDetachContext | undefined>('flowPanelDetach')
|
||||
|
||||
// The detached modal draws no header of its own, so the header this sits in carries its
|
||||
// chrome too. onMount, not $effect: claim() increments (reads+writes) the claim count,
|
||||
// and a tracking effect would re-run on its own write.
|
||||
onMount(() => panelDetach?.claim())
|
||||
</script>
|
||||
|
||||
<div class="ml-2 flex shrink-0 items-center">
|
||||
<FlowPanelPlacementPicker variant="header" />
|
||||
</div>
|
||||
{#if panelDetach?.modalOpen()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
wrapperClasses="shrink-0"
|
||||
startIcon={{ icon: X }}
|
||||
title="Close"
|
||||
onClick={() => panelDetach.close()}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { PanelRight } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import type { FlowPanelDetachContext } from '../types'
|
||||
|
||||
interface Props {
|
||||
/** 'control' borrows the graph control bar's cell styling; 'header' matches the icon
|
||||
* buttons beside it in the panel's card header. */
|
||||
variant: 'control' | 'header'
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
let { variant, placement = 'bottom-end' }: Props = $props()
|
||||
|
||||
const panelDetach = getContext<FlowPanelDetachContext | undefined>('flowPanelDetach')
|
||||
|
||||
// Named options with a check, no per-row icons: whether the panel is attached is plain
|
||||
// from the layout, so the only thing worth spelling out is which of the three is active —
|
||||
// and Auto is the one name that doesn't say what it follows.
|
||||
const PANEL_PREFERENCES = [
|
||||
{
|
||||
value: 'auto' as const,
|
||||
displayName: 'Auto',
|
||||
tooltip: 'Follows the editor width: attached when there is room, detached when not'
|
||||
},
|
||||
{ value: 'docked' as const, displayName: 'Attached' },
|
||||
{ value: 'modal' as const, displayName: 'Detached' }
|
||||
]
|
||||
|
||||
const items = $derived(
|
||||
PANEL_PREFERENCES.map((p) => ({
|
||||
displayName: p.displayName,
|
||||
tooltip: p.tooltip,
|
||||
selected: panelDetach?.preference() === p.value,
|
||||
action: () => panelDetach?.setPreference(p.value)
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if panelDetach?.enabled()}
|
||||
<DropdownV2
|
||||
{items}
|
||||
{placement}
|
||||
customWidth={220}
|
||||
class={variant === 'control' ? 'svelte-flow__controls-button !justify-center' : 'shrink-0'}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
{#if variant === 'control'}
|
||||
<!-- The trigger itself carries the control-bar cell class. Wrapping a ControlButton
|
||||
instead would nest a sized 32x30 box inside the trigger's own box, growing the
|
||||
bar, and would take `:last-child` off the real last cell so its divider stayed. -->
|
||||
<span class="flex" title="Where the step panel opens">
|
||||
<PanelRight size="14" />
|
||||
</span>
|
||||
{:else}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: PanelRight }}
|
||||
title="Where the step panel opens"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
@@ -50,7 +50,7 @@
|
||||
/>
|
||||
{:else if isMcpTool(tool)}
|
||||
<!-- MCP tool - use McpToolEditor -->
|
||||
<McpToolEditor bind:tool />
|
||||
<McpToolEditor bind:tool {noEditor} />
|
||||
{:else if isWebsearchTool(tool)}
|
||||
<WebsearchToolDisplay />
|
||||
<WebsearchToolDisplay {noEditor} />
|
||||
{/if}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { getContext } from 'svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import PredicateGen from '$lib/components/copilot/PredicateGen.svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
branch: {
|
||||
@@ -21,13 +20,18 @@
|
||||
enableAi?: boolean
|
||||
}
|
||||
|
||||
let { branch = $bindable(), parentModule, previousModule, enableAi = false }: Props = $props()
|
||||
let { branch, parentModule, previousModule, enableAi = false }: Props = $props()
|
||||
|
||||
let editor: SimpleEditor | undefined = $state(undefined)
|
||||
|
||||
// The predicate is a bare string on `branch`, so the form is told its kind through
|
||||
// `argType` rather than inferring one from the value.
|
||||
let predicateSchema = $state(emptySchema())
|
||||
predicateSchema.properties['expr'] = { type: 'boolean' }
|
||||
|
||||
const { previewArgs, flowStateStore, flowStore } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let editor: SimpleEditor | undefined = $state(undefined)
|
||||
let open = $state(false)
|
||||
let stepPropPicker = $derived(
|
||||
getStepPropPicker(
|
||||
flowStateStore.val,
|
||||
@@ -41,49 +45,51 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<PropPickerWrapper
|
||||
notSelectable
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<!-- `branch` itself is the arg: the form reads and writes `arg.expr` in place, which
|
||||
is exactly where the predicate lives. Its other keys are inert here. -->
|
||||
<InputTransformForm
|
||||
bind:arg={branch}
|
||||
argName="expr"
|
||||
argType="javascript"
|
||||
label="Run this branch if"
|
||||
headerTooltip="The first branch whose expression evaluates to true is the one that runs."
|
||||
noDynamicToggle
|
||||
schema={predicateSchema}
|
||||
previousModuleId={previousModule?.id}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
paneClass="max-h-[320px] overflow-auto"
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
bind:editor
|
||||
>
|
||||
<div class="border border-gray-400">
|
||||
<SimpleEditor
|
||||
bind:this={editor}
|
||||
lang="javascript"
|
||||
bind:code={branch.expr}
|
||||
class="small-editor border "
|
||||
shouldBindKey={false}
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
/>
|
||||
</div>
|
||||
</PropPickerWrapper>
|
||||
{:else}
|
||||
<div class="flex justify-between gap-4 p-2">
|
||||
<div class="truncate"><pre class="text-sm truncate">{branch.expr}</pre></div>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
{#snippet aiGen()}
|
||||
{#if enableAi}
|
||||
<PredicateGen
|
||||
on:setExpr={(e) => {
|
||||
branch.expr = e.detail
|
||||
// Monaco owns its buffer once mounted: writing the value alone leaves
|
||||
// the visible code stale until the editor is torn down and rebuilt.
|
||||
editor?.setCode(e.detail)
|
||||
}}
|
||||
on:updateSummary={(e) => {
|
||||
// The prompt names the branch better than "Branch 2" does, but only
|
||||
// when the user hasn't already named it themselves.
|
||||
if (!branch.summary) {
|
||||
branch.summary = e.detail
|
||||
}
|
||||
}}
|
||||
on:updateSummary
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
size="xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="default"
|
||||
on:click={() => (open = !open)}
|
||||
id="flow-editor-edit-predicate"
|
||||
>
|
||||
Edit predicate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</InputTransformForm>
|
||||
</PropPickerWrapper>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
|
||||
let opened = $state(false);
|
||||
|
||||
let opened = $state(false)
|
||||
</script>
|
||||
|
||||
<div class="text-xs flex flex-row-reverse">
|
||||
<!-- Pulled up against the input above: it reads as a footnote on that field rather than
|
||||
as the next thing in the form. -->
|
||||
<div class="text-2xs flex flex-row-reverse -mt-2">
|
||||
<Button
|
||||
on:click={() => {
|
||||
opened = !opened
|
||||
@@ -16,7 +17,7 @@
|
||||
variant="divider"
|
||||
size="xs2"
|
||||
endIcon={{ icon: ChevronDown, classes: `rotate-0 duration-300 ${opened ? '!rotate-180' : ''}` }}
|
||||
btnClasses="text-hint text-2xs font-normal pt-1"
|
||||
btnClasses="text-hint !text-2xs font-normal py-0"
|
||||
>
|
||||
Help
|
||||
</Button>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
} from '../expandedSubflowStep'
|
||||
import { parseExpandedSubflowId } from '$lib/components/restartFromStepPath'
|
||||
import { base } from '$app/paths'
|
||||
import FlowPanelChrome from '../common/FlowPanelChrome.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Graph node id of the selected step, of the form `subflow:<step>[:<step>...]:<leaf>`. */
|
||||
@@ -100,9 +101,9 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if resolved}
|
||||
{@const { containingFlowPath, module } = resolved}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#if resolved}
|
||||
{@const { containingFlowPath, module } = resolved}
|
||||
{#if $flowEditorDrawer}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
@@ -124,8 +125,9 @@
|
||||
)}${module ? `&selected=${encodeURIComponent(leafId)}` : ''}`}
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<FlowPanelChrome />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 grow overflow-auto">
|
||||
{#if loaded == undefined}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let { noEditor, branch = $bindable() }: Props = $props()
|
||||
let { noEditor, branch }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
@@ -23,13 +23,8 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="p-4">
|
||||
<div class="mt-2 mb-2 text-sm font-bold">Skip failures</div>
|
||||
<Toggle
|
||||
bind:checked={branch.skip_failure}
|
||||
options={{
|
||||
right: 'Skip failures'
|
||||
}}
|
||||
/>
|
||||
<div class="mb-2 text-xs font-semibold text-emphasis">Skip failures</div>
|
||||
<Toggle bind:checked={branch.skip_failure} />
|
||||
</div>
|
||||
</FlowCard>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,7 @@
|
||||
enableAi?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
branch = $bindable(),
|
||||
parentModule,
|
||||
previousModule,
|
||||
noEditor,
|
||||
enableAi = false
|
||||
}: Props = $props()
|
||||
let { branch, parentModule, previousModule, noEditor, enableAi = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
@@ -32,19 +26,8 @@
|
||||
<input bind:value={branch.summary} placeholder={'Summary'} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="overflow-hidden flex-grow">
|
||||
<h3 class="p-2">Predicate expression</h3>
|
||||
<BranchPredicateEditor
|
||||
{branch}
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
{enableAi}
|
||||
on:updateSummary={(e) => {
|
||||
if (!branch.summary) {
|
||||
branch.summary = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="flex h-full min-h-0 flex-col overflow-auto p-4" style="scrollbar-gutter: stable">
|
||||
<BranchPredicateEditor {branch} {parentModule} {previousModule} {enableAi} />
|
||||
</div>
|
||||
</FlowCard>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Badge, Tab } from '$lib/components/common'
|
||||
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
|
||||
import { Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import {
|
||||
addBranch as addBranchOp,
|
||||
removeBranch as removeBranchOp,
|
||||
reorderBranches as reorderBranchesOp,
|
||||
graphBranchIndex
|
||||
} from '../branchOps'
|
||||
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import StepSettingsBadges from './StepSettingsBadges.svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
|
||||
import type { BranchAll, FlowModule } from '$lib/gen'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SplitPanesWrapper from '../../splitPanes/SplitPanesWrapper.svelte'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
import FlowModuleSleep from './FlowModuleSleep.svelte'
|
||||
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
|
||||
import FlowModuleMock from './FlowModuleMock.svelte'
|
||||
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import FlowModuleSkip from './FlowModuleSkip.svelte'
|
||||
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
|
||||
import FlowRunSettings from './FlowRunSettings.svelte'
|
||||
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
|
||||
|
||||
interface Props {
|
||||
@@ -31,110 +37,193 @@
|
||||
value = flowModule.value as BranchAll
|
||||
})
|
||||
|
||||
let selected = $state('early-stop')
|
||||
// dnd needs a stable id per item; branches have none and must not gain one (it would
|
||||
// land in the saved flow), so ids are held beside them, keyed by object identity.
|
||||
const branchIds = new WeakMap<object, string>()
|
||||
function idFor(branch: object): string {
|
||||
let id = branchIds.get(branch)
|
||||
if (!id) {
|
||||
id = randomUUID()
|
||||
branchIds.set(branch, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let items = $state(value.branches.map((b) => ({ id: idFor(b), branch: b })))
|
||||
// dnd owns `items` for the length of a gesture: mid-drag it holds a shadow placeholder
|
||||
// alongside the real entries, so rebuilding from `value.branches` there would splice a
|
||||
// second copy of the dragged branch into the list (duplicate keys).
|
||||
let dragging = false
|
||||
|
||||
$effect(() => {
|
||||
const next = value.branches.map((b) => ({ id: idFor(b), branch: b }))
|
||||
// untrack: this reads and writes `items`, which would otherwise re-invalidate itself.
|
||||
untrack(() => {
|
||||
if (dragging) return
|
||||
const same = next.length === items.length && next.every((it, i) => it.id === items[i].id)
|
||||
if (!same) items = next
|
||||
})
|
||||
})
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: typeof items }>) {
|
||||
dragging = true
|
||||
items = e.detail.items
|
||||
}
|
||||
function handleFinalize(e: CustomEvent<{ items: typeof items }>) {
|
||||
items = e.detail.items
|
||||
reorderBranchesOp(
|
||||
flowModule.id,
|
||||
items.map((it) => it.branch),
|
||||
{ flowStore, history }
|
||||
)
|
||||
dragging = false
|
||||
}
|
||||
|
||||
const { flowStore, flowStateStore, history } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
function addBranch() {
|
||||
addBranchOp(flowModule.id, { flowStore, history })
|
||||
refreshFlowStateStore(flowStore)
|
||||
}
|
||||
function removeBranch(arrayIndex: number) {
|
||||
// The shared op counts branches the way the graph does; see graphBranchIndex.
|
||||
removeBranchOp(flowModule.id, graphBranchIndex(value.type, arrayIndex), {
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
history
|
||||
})
|
||||
refreshFlowStateStore(flowStore)
|
||||
}
|
||||
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
let selectedTab = $state('branches')
|
||||
|
||||
useUiIntent(`branchall-${flowModule.id}`, {
|
||||
openTab: (tab) => {
|
||||
selected = tab
|
||||
openTab: async (tab) => {
|
||||
// Every setting the intent can name lives in the other tab, which only mounts
|
||||
// `runSettings` once selected.
|
||||
selectedTab = 'settings'
|
||||
await tick()
|
||||
runSettings?.openSetting(tab)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col w-full" id="flow-editor-branch-all-wrapper">
|
||||
<FlowCard {noEditor} title={value.type == 'branchall' ? 'Run all branches' : 'Run one branch'}>
|
||||
<SplitPanesWrapper>
|
||||
<Splitpanes horizontal>
|
||||
<Pane size={flowModule ? 60 : 100}>
|
||||
{#if !noEditor}
|
||||
<Alert
|
||||
type="info"
|
||||
title="All branches will be run"
|
||||
tooltip="Branch all"
|
||||
documentationLink="https://www.windmill.dev/docs/flows/flow_branches#branch-all"
|
||||
class="m-4"
|
||||
>
|
||||
The result of this step is the list of the result of each branch.
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="p-4 mt-4 w-full">
|
||||
<h3 class="mb-4"
|
||||
>{value.branches.length} branch{value.branches.length > 1 ? 'es' : ''}</h3
|
||||
>
|
||||
<div class="flex flex-col gap-y-4 py-2 w-full">
|
||||
{#each value.branches as branch, i}
|
||||
<div class="flex flex-row gap-x-4 w-full items-center">
|
||||
<div class="grow flex gap-2">
|
||||
<Badge large={true} color="blue">Branch {i + 1}</Badge>
|
||||
<input type="text" bind:value={branch.summary} placeholder="Summary" />
|
||||
</div>
|
||||
<div class="w-min-sm">
|
||||
<Toggle
|
||||
bind:checked={branch.skip_failure}
|
||||
options={{
|
||||
right: 'Skip failure'
|
||||
}}
|
||||
<FlowCard
|
||||
{noEditor}
|
||||
title={value.type == 'branchall' ? 'Run all branches' : 'Run one branch'}
|
||||
subtitle="Every branch runs. The result of this step is the list of each branch's result."
|
||||
subtitleDocLink="https://www.windmill.dev/docs/flows/flow_branches#branch-all"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
|
||||
<Tab value="branches" label="Branches" />
|
||||
<Tab value="settings" label="Run settings">
|
||||
{#snippet extra()}
|
||||
<StepSettingsBadges {flowModule} />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-6 overflow-auto p-4"
|
||||
style="scrollbar-gutter: stable"
|
||||
>
|
||||
{#if selectedTab === 'branches'}
|
||||
<section class="flex w-full flex-col gap-4">
|
||||
<div>
|
||||
<section
|
||||
class="flex flex-col gap-3"
|
||||
use:dragHandleZone={{ items, flipDurationMs: 150, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each items as item, i (item.id)}
|
||||
<!-- The handle and the delete button each own a column, so the row below
|
||||
lines up with the summary instead of running under them. -->
|
||||
<div
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0 rounded-md bg-surface-tertiary p-3 shadow-sm"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="cursor-move text-tertiary hover:text-primary"
|
||||
use:dragHandle
|
||||
aria-label="Reorder branch"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Badge color="blue" class="text-xs">Branch {i + 1}</Badge>
|
||||
<TextInput
|
||||
size="sm"
|
||||
class="grow"
|
||||
bind:value={
|
||||
() => item.branch.summary ?? '', (v) => (item.branch.summary = String(v))
|
||||
}
|
||||
inputProps={{ placeholder: 'Summary' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
destructive
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Delete branch"
|
||||
on:click={() => removeBranch(i)}
|
||||
/>
|
||||
<div class="col-start-2 py-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
bind:checked={item.branch.skip_failure}
|
||||
options={{
|
||||
right: 'Skip failure'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</section>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
wrapperClasses="mt-4 self-start"
|
||||
on:click={addBranch}
|
||||
>
|
||||
Add branch
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-sm">Add branches and steps directly on the graph.</p>
|
||||
<div class="mt-6 mb-2 text-sm font-bold">Run in parallel</div>
|
||||
<Toggle
|
||||
bind:checked={value.parallel}
|
||||
options={{
|
||||
right: 'All branches run in parallel'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
{#if flowModule}
|
||||
<Pane size={40}>
|
||||
<TabsV2 bind:selected>
|
||||
<Tab value="early-stop" label="Early Stop/Break" />
|
||||
<Tab value="skip" label="Skip" />
|
||||
<Tab value="suspend" label="Suspend/Approval/Prompt" />
|
||||
<Tab value="sleep" label="Sleep" />
|
||||
<Tab value="mock" label="Mock" />
|
||||
<Tab value="lifetime" label="Lifetime" />
|
||||
{#snippet content()}
|
||||
<div class="overflow-hidden bg-surface">
|
||||
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleEarlyStop bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="skip" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="mock" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleMock bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
|
||||
</div>
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</TabsV2>
|
||||
</Pane>
|
||||
<div>
|
||||
<label
|
||||
for="branchall-parallel-{flowModule.id}"
|
||||
class="mb-2 block w-fit cursor-pointer text-xs font-semibold text-emphasis"
|
||||
>
|
||||
Run in parallel
|
||||
</label>
|
||||
<Toggle
|
||||
id="branchall-parallel-{flowModule.id}"
|
||||
bind:checked={value.parallel}
|
||||
options={{
|
||||
right: 'All branches run in parallel'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<FlowRunSettings
|
||||
embedded
|
||||
loopSubset
|
||||
bind:this={runSettings}
|
||||
bind:flowModule
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
selectedId={flowModule.id}
|
||||
/>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
</SplitPanesWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</FlowCard>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Badge, Tab } from '$lib/components/common'
|
||||
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
|
||||
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
|
||||
import { Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import {
|
||||
addBranch as addBranchOp,
|
||||
removeBranch as removeBranchOp,
|
||||
reorderBranches as reorderBranchesOp,
|
||||
graphBranchIndex
|
||||
} from '../branchOps'
|
||||
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import StepSettingsBadges from './StepSettingsBadges.svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
|
||||
import type { BranchOne, FlowModule } from '$lib/gen'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import BranchPredicateEditor from './BranchPredicateEditor.svelte'
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
|
||||
import FlowModuleSleep from './FlowModuleSleep.svelte'
|
||||
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
|
||||
import SplitPanesWrapper from '../../splitPanes/SplitPanesWrapper.svelte'
|
||||
import FlowModuleMock from './FlowModuleMock.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import FlowModuleSkip from './FlowModuleSkip.svelte'
|
||||
import FlowRunSettings from './FlowRunSettings.svelte'
|
||||
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
|
||||
|
||||
interface Props {
|
||||
// import FlowRetries from './FlowRetries.svelte'
|
||||
flowModule: FlowModule
|
||||
previousModule: FlowModule | undefined
|
||||
parentModule: FlowModule | undefined
|
||||
@@ -39,120 +44,180 @@
|
||||
value = flowModule.value as BranchOne
|
||||
})
|
||||
|
||||
let selected = $state('early-stop')
|
||||
// dnd needs a stable id per item; branches have none and must not gain one (it would
|
||||
// land in the saved flow), so ids are held beside them, keyed by object identity.
|
||||
const branchIds = new WeakMap<object, string>()
|
||||
function idFor(branch: object): string {
|
||||
let id = branchIds.get(branch)
|
||||
if (!id) {
|
||||
id = randomUUID()
|
||||
branchIds.set(branch, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let items = $state(value.branches.map((b) => ({ id: idFor(b), branch: b })))
|
||||
// dnd owns `items` for the length of a gesture: mid-drag it holds a shadow placeholder
|
||||
// alongside the real entries, so rebuilding from `value.branches` there would splice a
|
||||
// second copy of the dragged branch into the list (duplicate keys).
|
||||
let dragging = false
|
||||
|
||||
$effect(() => {
|
||||
const next = value.branches.map((b) => ({ id: idFor(b), branch: b }))
|
||||
// untrack: this reads and writes `items`, which would otherwise re-invalidate itself.
|
||||
untrack(() => {
|
||||
if (dragging) return
|
||||
const same = next.length === items.length && next.every((it, i) => it.id === items[i].id)
|
||||
if (!same) items = next
|
||||
})
|
||||
})
|
||||
|
||||
function handleConsider(e: CustomEvent<{ items: typeof items }>) {
|
||||
dragging = true
|
||||
items = e.detail.items
|
||||
}
|
||||
function handleFinalize(e: CustomEvent<{ items: typeof items }>) {
|
||||
items = e.detail.items
|
||||
reorderBranchesOp(
|
||||
flowModule.id,
|
||||
items.map((it) => it.branch),
|
||||
{ flowStore, history }
|
||||
)
|
||||
dragging = false
|
||||
}
|
||||
|
||||
const { flowStore, flowStateStore, history } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
function addBranch() {
|
||||
addBranchOp(flowModule.id, { flowStore, history })
|
||||
refreshFlowStateStore(flowStore)
|
||||
}
|
||||
function removeBranch(arrayIndex: number) {
|
||||
// The shared op counts branches the way the graph does; see graphBranchIndex.
|
||||
removeBranchOp(flowModule.id, graphBranchIndex(value.type, arrayIndex), {
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
history
|
||||
})
|
||||
refreshFlowStateStore(flowStore)
|
||||
}
|
||||
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
let selectedTab = $state('branches')
|
||||
|
||||
useUiIntent(`branchone-${flowModule.id}`, {
|
||||
openTab: (tab) => {
|
||||
selected = tab
|
||||
openTab: async (tab) => {
|
||||
// Every setting the intent can name lives in the other tab, which only mounts
|
||||
// `runSettings` once selected.
|
||||
selectedTab = 'settings'
|
||||
await tick()
|
||||
runSettings?.openSetting(tab)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="h-full" id="flow-editor-branch-one-wrapper">
|
||||
<FlowCard {noEditor} title="Run one branch">
|
||||
<SplitPanesWrapper>
|
||||
<Splitpanes horizontal>
|
||||
<Pane size={flowModule ? 60 : 100}>
|
||||
{#if !noEditor}
|
||||
<Alert
|
||||
type="info"
|
||||
title="Only first branch whose condition is true will be run"
|
||||
tooltip="Branch one"
|
||||
documentationLink="https://www.windmill.dev/docs/flows/flow_branches#branch-one"
|
||||
class="m-4"
|
||||
>
|
||||
The result of this step is the result of the branch.
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<h3 class="my-4">
|
||||
{value.branches.length + 1} branch{value.branches.length + 1 > 1 ? 'es' : ''}
|
||||
</h3>
|
||||
<div class="py-2">
|
||||
<div class="flex flex-row gap-2 text-sm p-2">
|
||||
<Badge large={true} color="blue">Default branch</Badge>
|
||||
<p class="italic text-primary"
|
||||
>If none of the predicates' expressions evaluated in-order match, this branch is
|
||||
chosen</p
|
||||
>
|
||||
</div>
|
||||
{#each value.branches as branch, i}
|
||||
<div class="flex flex-col gap-x-2 items-center">
|
||||
<div class="w-full flex gap-2 px-2 pt-4 pb-2">
|
||||
<Badge large={true} color="blue">Branch {i + 1}</Badge>
|
||||
<input
|
||||
class="w-full"
|
||||
type="text"
|
||||
bind:value={branch.summary}
|
||||
placeholder="Summary"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full border">
|
||||
<BranchPredicateEditor
|
||||
{branch}
|
||||
on:updateSummary={(e) => {
|
||||
if (!branch.summary) {
|
||||
branch.summary = e.detail
|
||||
<FlowCard
|
||||
{noEditor}
|
||||
title="Run one branch"
|
||||
subtitle="The first branch whose predicate is true runs. The result of this step is that branch's result."
|
||||
subtitleDocLink="https://www.windmill.dev/docs/flows/flow_branches#branch-one"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
|
||||
<Tab value="branches" label="Branches" />
|
||||
<Tab value="settings" label="Run settings">
|
||||
{#snippet extra()}
|
||||
<StepSettingsBadges {flowModule} />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-6 overflow-auto p-4"
|
||||
style="scrollbar-gutter: stable"
|
||||
>
|
||||
{#if selectedTab === 'branches'}
|
||||
<section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<section
|
||||
class="flex flex-col gap-3"
|
||||
use:dragHandleZone={{ items, flipDurationMs: 150, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each items as item, i (item.id)}
|
||||
<!-- The handle and the delete button each own a column, so the predicate
|
||||
below lines up with the summary instead of running under them. -->
|
||||
<div
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0 rounded-md bg-surface-tertiary p-3 shadow-sm"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="cursor-move text-tertiary hover:text-primary"
|
||||
use:dragHandle
|
||||
aria-label="Reorder branch"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Badge color="blue" class="text-xs">Branch {i + 1}</Badge>
|
||||
<TextInput
|
||||
size="sm"
|
||||
class="grow"
|
||||
bind:value={
|
||||
() => item.branch.summary ?? '', (v) => (item.branch.summary = String(v))
|
||||
}
|
||||
}}
|
||||
parentModule={flowModule}
|
||||
{previousModule}
|
||||
{enableAi}
|
||||
inputProps={{ placeholder: 'Summary' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
destructive
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Delete branch"
|
||||
on:click={() => removeBranch(i)}
|
||||
/>
|
||||
<div class="col-start-2 py-2">
|
||||
<BranchPredicateEditor
|
||||
branch={item.branch}
|
||||
parentModule={flowModule}
|
||||
{previousModule}
|
||||
{enableAi}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</section>
|
||||
<div class="flex items-center gap-2 rounded-md bg-surface-tertiary p-3 shadow-sm">
|
||||
<Badge color="blue" class="text-xs">Default</Badge>
|
||||
<p class="text-xs italic text-tertiary">Runs if none of the above match</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm">Add branches and steps directly on the graph.</p>
|
||||
</div>
|
||||
</Pane>
|
||||
{#if flowModule}
|
||||
<Pane size={40}>
|
||||
<TabsV2 bind:selected>
|
||||
<Tab value="early-stop" label="Early Stop/Break" />
|
||||
<Tab value="skip" label="Skip" />
|
||||
<Tab value="suspend" label="Suspend/Approval/Prompt" />
|
||||
<Tab value="sleep" label="Sleep" />
|
||||
<Tab value="mock" label="Mock" />
|
||||
<Tab value="lifetime" label="Lifetime" />
|
||||
{#snippet content()}
|
||||
<div class="overflow-hidden bg-surface">
|
||||
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleEarlyStop bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="skip" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="mock" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleMock bind:flowModule />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
|
||||
</div>
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</TabsV2>
|
||||
</Pane>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
wrapperClasses="mt-4 self-start"
|
||||
on:click={addBranch}
|
||||
>
|
||||
Add branch
|
||||
</Button>
|
||||
</section>
|
||||
{:else}
|
||||
<FlowRunSettings
|
||||
embedded
|
||||
loopSubset
|
||||
bind:this={runSettings}
|
||||
bind:flowModule
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
selectedId={flowModule.id}
|
||||
/>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
</SplitPanesWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</FlowCard>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import FlowModuleWrapper from './FlowModuleWrapper.svelte'
|
||||
import { moduleSlot, savedModuleById } from '../moduleSlot'
|
||||
import FlowSettings from './FlowSettings.svelte'
|
||||
import FlowInput from './FlowInput.svelte'
|
||||
import FlowFailureModule from './FlowFailureModule.svelte'
|
||||
@@ -124,7 +125,7 @@
|
||||
{noEditor}
|
||||
disabled={disabledFlowInputs}
|
||||
on:openTriggers={(ev) => {
|
||||
selectionManager.selectId('Trigger')
|
||||
selectionManager.selectId('Trigger', { openPanel: true })
|
||||
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
|
||||
showCaptureHint.set(true)
|
||||
}}
|
||||
@@ -188,12 +189,13 @@
|
||||
{:else}
|
||||
{#key selectedId}
|
||||
{#each flowStore.val.value.modules as flowModule, index (flowModule.id ?? index)}
|
||||
{@const slot = moduleSlot(() => flowStore.val.value.modules, flowModule.id, flowModule)}
|
||||
<FlowModuleWrapper
|
||||
{noEditor}
|
||||
bind:flowModule={flowStore.val.value.modules[index]}
|
||||
bind:flowModule={slot.get, slot.set}
|
||||
previousModule={flowStore.val.value.modules[index - 1]}
|
||||
{enableAi}
|
||||
savedModule={savedFlow?.value.modules[index]}
|
||||
savedModule={savedModuleById(savedFlow?.value.modules, flowModule.id)}
|
||||
{forceTestTab}
|
||||
{highlightArg}
|
||||
/>
|
||||
|
||||
@@ -234,6 +234,11 @@
|
||||
connectProp: () => {},
|
||||
propPickerConfig: writable(undefined),
|
||||
clearConnect: () => {},
|
||||
pickerMode: () => 'popover' as const,
|
||||
pickableProperties: () => undefined,
|
||||
result: () => undefined,
|
||||
extraResults: () => undefined,
|
||||
onPick: () => {},
|
||||
exprBeingEdited: writable([])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { getContext, tick } from 'svelte'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
|
||||
// import FlowRetries from './FlowRetries.svelte'
|
||||
import { Button, Drawer, Tab, TabContent } from '$lib/components/common'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { Button, Drawer, Tab, Tabs } from '$lib/components/common'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
import FlowModuleSleep from './FlowModuleSleep.svelte'
|
||||
import FlowModuleMock from './FlowModuleMock.svelte'
|
||||
import { Play, FunctionSquare } from 'lucide-svelte'
|
||||
import { Play } from 'lucide-svelte'
|
||||
import type { FlowModule, ForloopFlow, Job } from '$lib/gen'
|
||||
import FlowLoopIterationPreview from '$lib/components/FlowLoopIterationPreview.svelte'
|
||||
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
|
||||
import IteratorGen from '$lib/components/copilot/IteratorGen.svelte'
|
||||
import FlowModuleSkip from './FlowModuleSkip.svelte'
|
||||
import FlowPlugConnect from '$lib/components/FlowPlugConnect.svelte'
|
||||
import FlowRunSettings from './FlowRunSettings.svelte'
|
||||
import StepSettingsBadges from './StepSettingsBadges.svelte'
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type { PropPickerContext } from '$lib/components/prop_picker'
|
||||
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
|
||||
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { slide } from 'svelte/transition'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { emptySchema, emptyString } from '$lib/utils'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
const { previewArgs, flowStateStore, flowStore, currentEditor } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -54,20 +45,21 @@
|
||||
|
||||
let editor: SimpleEditor | undefined = $state(undefined)
|
||||
let parallelismEditor: SimpleEditor | undefined = $state(undefined)
|
||||
let selected: string = $state('early-stop')
|
||||
let parallelismType: 'static' | 'javascript' | undefined = $state(
|
||||
mod.value.type === 'forloopflow'
|
||||
? mod.value.parallelism?.type === 'javascript'
|
||||
? 'javascript'
|
||||
: 'static'
|
||||
: undefined
|
||||
)
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
|
||||
let parallelismSchema = $state(emptySchema())
|
||||
parallelismSchema.properties['parallelism'] = {
|
||||
type: 'number'
|
||||
type: 'integer'
|
||||
}
|
||||
|
||||
// `array` keeps the field on the expression editor: an iterator is never a literal
|
||||
// the static input could hold, which is also why the type switch is hidden.
|
||||
let iteratorSchema = $state(emptySchema())
|
||||
iteratorSchema.properties['iterator'] = {
|
||||
type: 'array'
|
||||
}
|
||||
iteratorSchema.required = ['iterator']
|
||||
|
||||
if (mod.value.type === 'forloopflow') {
|
||||
const forloopValue = mod.value as ForloopFlow
|
||||
if (typeof forloopValue.parallelism === 'number') {
|
||||
@@ -78,14 +70,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// UI Intent handling for AI tool control
|
||||
let selectedTab = $state('loop')
|
||||
|
||||
useUiIntent(`forloopflow-${mod.id}`, {
|
||||
openTab: (tab) => {
|
||||
selected = tab
|
||||
openTab: async (tab) => {
|
||||
// Every setting the intent can name lives in the other tab, which only mounts
|
||||
// `runSettings` once selected.
|
||||
selectedTab = 'settings'
|
||||
await tick()
|
||||
runSettings?.openSetting(tab)
|
||||
}
|
||||
})
|
||||
|
||||
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
|
||||
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
|
||||
const { flowPropPickerConfig } = propPickerContext
|
||||
flowPropPickerConfig.set(undefined)
|
||||
|
||||
let stepPropPicker = $derived(
|
||||
@@ -125,8 +123,53 @@
|
||||
})
|
||||
|
||||
let suggestion: string | undefined = $state(undefined)
|
||||
|
||||
// A loop with nothing to iterate over fails at runtime, and the step's own inputs
|
||||
// aren't schema-checked like a script's — so the field says it here.
|
||||
const iterator = $derived(
|
||||
mod.value.type === 'forloopflow' ? (mod.value as ForloopFlow).iterator : undefined
|
||||
)
|
||||
const iteratorMissing = $derived.by(() => {
|
||||
if (iterator == undefined) return true
|
||||
if (iterator.type === 'javascript') return emptyString(iterator.expr)
|
||||
if (iterator.type === 'static') return iterator.value == undefined
|
||||
return false
|
||||
})
|
||||
|
||||
const ITERATOR_LABEL = 'Iterator expression'
|
||||
const ITERATOR_MISSING = 'An iterator expression is required for the loop to run.'
|
||||
const ITERATOR_TOOLTIP =
|
||||
'The JavaScript expression that will be evaluated to get the list of items to iterate over. Example: ["banana", "apple", flow_input.my_fruit].'
|
||||
const DEFAULT_PARALLELISM = 4
|
||||
const PARALLELISM_LABEL = 'Limit concurrent iterations'
|
||||
const SQUASH_PARALLEL_CONFLICT =
|
||||
'Squash and Run in parallel are mutually exclusive: squashing runs every iteration in sequence on a single worker. Turn the other one off to use this.'
|
||||
const PARALLELISM_TOOLTIP =
|
||||
'Cap how many iterations run at once, so a huge loop does not flood the workers. Without a cap every iteration starts at once.'
|
||||
|
||||
const parallelismCapped = $derived(
|
||||
mod.value.type === 'forloopflow' && mod.value.parallelism != undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet parallelismToggle()}
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={parallelismCapped}
|
||||
on:change={({ detail }) => {
|
||||
;(mod.value as ForloopFlow).parallelism = detail
|
||||
? { type: 'static', value: DEFAULT_PARALLELISM }
|
||||
: undefined
|
||||
}}
|
||||
options={{
|
||||
right: PARALLELISM_LABEL,
|
||||
rightTooltip: PARALLELISM_TOOLTIP,
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Drawer bind:open={previewOpen} alwaysOpen size="75%">
|
||||
<FlowLoopIterationPreview
|
||||
modules={mod.value.type == 'forloopflow' ? mod.value.modules : []}
|
||||
@@ -143,7 +186,7 @@
|
||||
<FlowCard {noEditor} title="For loop">
|
||||
{#snippet header()}
|
||||
<div class="grow">
|
||||
<div class="my-2 flex flex-row gap-2 items-center">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<div>
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
|
||||
Add steps inside the loop and specify an iterator expression that defines the sequence
|
||||
@@ -157,7 +200,7 @@
|
||||
<Button
|
||||
on:click={() => (previewOpen = true)}
|
||||
startIcon={{ icon: Play }}
|
||||
variant="accent"
|
||||
variant="default"
|
||||
size="sm">Test an iteration</Button
|
||||
>
|
||||
</div>
|
||||
@@ -165,332 +208,171 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Splitpanes horizontal class="h-full">
|
||||
<Pane size={50} minSize={20} class="p-4">
|
||||
{#if mod.value.type === 'forloopflow'}
|
||||
<div class="flex flex-row gap-6 mt-2 mb-6">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="mb-2 text-sm font-bold"
|
||||
>Skip failures <Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/flows/flow_loops"
|
||||
>If disabled, the flow will fail as soon as one of the iteration fail. Otherwise,
|
||||
the error will be collected as the result of the iteration. Regardless of this
|
||||
setting, if a flow level error handler is defined, it will process the error.
|
||||
(Workspace error handlers will NOT be used to process errors if enabled.)</Tooltip
|
||||
></div
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
{#if mod.value.type === 'forloopflow'}
|
||||
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
|
||||
<Tab value="loop" label="Loop" />
|
||||
<Tab value="settings" label="Run settings">
|
||||
{#snippet extra()}
|
||||
<StepSettingsBadges flowModule={mod} />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-8 overflow-auto p-4"
|
||||
style="scrollbar-gutter: stable"
|
||||
>
|
||||
{#if selectedTab === 'loop'}
|
||||
<section>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={
|
||||
() => (mod.value as ForloopFlow).iterator,
|
||||
(v) => {
|
||||
;(mod.value as ForloopFlow).iterator = v
|
||||
}
|
||||
}
|
||||
argName="iterator"
|
||||
label={ITERATOR_LABEL}
|
||||
headerTooltip={ITERATOR_TOOLTIP}
|
||||
error={iteratorMissing ? ITERATOR_MISSING : undefined}
|
||||
schema={iteratorSchema}
|
||||
noDynamicToggle
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:suggestion
|
||||
bind:focused={iteratorFieldFocused}
|
||||
aiOnKeyUp={iteratorGen?.onKeyUp}
|
||||
bind:editor
|
||||
>
|
||||
{#snippet aiGen()}
|
||||
{#if enableAi}
|
||||
<IteratorGen
|
||||
bind:this={iteratorGen}
|
||||
focused={iteratorFieldFocused}
|
||||
arg={(mod.value as ForloopFlow).iterator}
|
||||
on:showExpr={(e) => (suggestion = e.detail || undefined)}
|
||||
on:setExpr={(e) => setExpr(e.detail)}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</InputTransformForm>
|
||||
</PropPickerWrapper>
|
||||
</section>
|
||||
<section class="flex flex-col gap-6">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
bind:checked={mod.value.skip_failures}
|
||||
options={{
|
||||
right: 'Skip failures'
|
||||
right: 'Skip failures',
|
||||
rightTooltip:
|
||||
'If disabled, the flow will fail as soon as one of the iteration fail. Otherwise, the error will be collected as the result of the iteration. Regardless of this setting, if a flow level error handler is defined, it will process the error. (Workspace error handlers will NOT be used to process errors if enabled.)',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
|
||||
}}
|
||||
class="whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="mb-2 text-sm font-bold"
|
||||
>Squash
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
|
||||
Squashing a for loop runs all iterations on the same worker, using a single runner
|
||||
per step for the entire loop. This eliminates cold starts between iterations for
|
||||
supported languages (Bun, Deno, and Python).
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
bind:checked={mod.value.squash}
|
||||
on:change={({ detail }) => {
|
||||
;(mod.value as ForloopFlow).squash = detail
|
||||
}}
|
||||
options={{
|
||||
right: 'Squash'
|
||||
}}
|
||||
class="whitespace-nowrap"
|
||||
disabled={mod.value.parallel}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="mb-2 text-sm font-bold">Run in parallel</div>
|
||||
<Toggle
|
||||
bind:checked={mod.value.parallel}
|
||||
on:change={({ detail }) => {
|
||||
if (detail === false) {
|
||||
;(mod.value as ForloopFlow).parallelism = undefined
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'All iterations run in parallel'
|
||||
title: mod.value.parallel ? SQUASH_PARALLEL_CONFLICT : undefined,
|
||||
right: 'Squash',
|
||||
rightTooltip:
|
||||
'Squashing a for loop runs all iterations on the same worker, using a single runner per step for the entire loop. This eliminates cold starts between iterations for supported languages (Bun, Deno, and Python).',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
|
||||
}}
|
||||
class="whitespace-nowrap"
|
||||
disabled={mod.value.squash}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="mb-2 text-sm font-bold"
|
||||
>Parallelism <Tooltip
|
||||
>Assign a maximum number of branches run in parallel to control huge for-loops.</Tooltip
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-20 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-surface"
|
||||
disabled={!mod.value.parallel || parallelismType === 'javascript'}
|
||||
placeholder={parallelismType === 'javascript' ? 'Expression' : ''}
|
||||
bind:value={
|
||||
() => {
|
||||
const parallelismExpr = (mod.value as ForloopFlow).parallelism
|
||||
|
||||
return parallelismExpr && parallelismExpr.type === 'static'
|
||||
? parallelismExpr.value
|
||||
: ''
|
||||
},
|
||||
(value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
;(mod.value as ForloopFlow).parallelism = undefined
|
||||
} else {
|
||||
;(mod.value as ForloopFlow).parallelism = {
|
||||
type: 'static',
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
disabled={!mod.value.parallel}
|
||||
bind:selected={parallelismType}
|
||||
on:selected={(e) => {
|
||||
const forLoopFlow = mod.value as ForloopFlow
|
||||
if (e.detail == parallelismType) return
|
||||
if (e.detail === 'javascript') {
|
||||
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'javascript') {
|
||||
;(mod.value as ForloopFlow).parallelism = {
|
||||
type: 'javascript',
|
||||
expr: ''
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'static') {
|
||||
;(mod.value as ForloopFlow).parallelism = {
|
||||
type: 'static',
|
||||
value: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
<!-- Its own group: the setting's input belongs to the toggle above it, not
|
||||
24px away like the next setting. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
bind:checked={mod.value.parallel}
|
||||
on:change={({ detail }) => {
|
||||
// An absent `parallelism` means "no cap" to the worker, so switching
|
||||
// parallelism on must not seed one — the cap below is opted into.
|
||||
if (!detail) (mod.value as ForloopFlow).parallelism = undefined
|
||||
}}
|
||||
disabled={mod.value.squash}
|
||||
options={{
|
||||
title: mod.value.squash ? SQUASH_PARALLEL_CONFLICT : undefined,
|
||||
right: 'Run in parallel',
|
||||
rightTooltip: 'Run the iterations concurrently instead of one after the other.',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton small label="static" value="static" {item} />
|
||||
|
||||
<ToggleButton
|
||||
small
|
||||
tooltip="JavaScript expression ('flow_input' or 'results')."
|
||||
value="javascript"
|
||||
icon={FunctionSquare}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mod.value.type === 'forloopflow' && mod.value.parallel && mod.value.parallelism?.type == 'javascript'}
|
||||
<div class="my-2 flex flex-row gap-2 items-center">
|
||||
<div class="text-sm font-bold whitespace-nowrap">
|
||||
Parallelism expression
|
||||
<Tooltip>
|
||||
JavaScript expression that defines the maximum number of parallel executions.
|
||||
Example: flow_input.max_parallel || 3
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="border rounded-md overflow-auto w-full mb-2 h-full max-h-[250px]"
|
||||
id="flow-editor-parallel-expression"
|
||||
transition:slide={{ duration: 300 }}
|
||||
>
|
||||
<PropPickerWrapper
|
||||
notSelectable
|
||||
noPadding
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
parallelismEditor?.insertAtCursor(detail)
|
||||
parallelismEditor?.focus()
|
||||
}}
|
||||
>
|
||||
<SimpleEditor
|
||||
bind:this={parallelismEditor}
|
||||
autofocus
|
||||
lang="javascript"
|
||||
bind:code={
|
||||
() => {
|
||||
const parallelismExpr = (mod.value as ForloopFlow).parallelism
|
||||
|
||||
return parallelismExpr && parallelismExpr.type === 'javascript'
|
||||
? parallelismExpr.expr
|
||||
: ''
|
||||
},
|
||||
(expr) => {
|
||||
;(mod.value as ForloopFlow).parallelism = {
|
||||
type: 'javascript',
|
||||
expr
|
||||
}
|
||||
}
|
||||
}
|
||||
class="h-full"
|
||||
shouldBindKey={false}
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="my-2 flex flex-row gap-2 items-center">
|
||||
<div class="text-sm font-bold whitespace-nowrap">
|
||||
Iterator expression
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
|
||||
The JavaScript expression that will be evaluated to get the list of items to iterate
|
||||
over. Example : ["banana", "apple", flow_input.my_fruit].
|
||||
</Tooltip>
|
||||
</div>
|
||||
<FlowPlugConnect
|
||||
connecting={$flowPropPickerConfig != undefined}
|
||||
on:click={() => {
|
||||
const config = {
|
||||
onSelect: (code) => {
|
||||
setExpr(code)
|
||||
return true
|
||||
},
|
||||
clearFocus: () => {
|
||||
flowPropPickerConfig.set(undefined)
|
||||
}
|
||||
}
|
||||
flowPropPickerConfig.set({
|
||||
...config,
|
||||
clearFocus: () => {
|
||||
flowPropPickerConfig.set(undefined)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{#if enableAi}
|
||||
<IteratorGen
|
||||
bind:this={iteratorGen}
|
||||
focused={iteratorFieldFocused}
|
||||
arg={mod.value.iterator}
|
||||
on:showExpr={(e) => (suggestion = e.detail || undefined)}
|
||||
on:setExpr={(e) => {
|
||||
setExpr(e.detail)
|
||||
}}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mod.value.parallel}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
parallelismEditor?.insertAtCursor(detail)
|
||||
parallelismEditor?.focus()
|
||||
}}
|
||||
>
|
||||
<!-- Keyed on the toggle: the form seeds its static/expression mode once, so re-enabling
|
||||
the cap would hand a fresh static value to a form still in expression mode. -->
|
||||
{#key parallelismCapped}
|
||||
<InputTransformForm
|
||||
bind:arg={
|
||||
() => (mod.value as ForloopFlow).parallelism,
|
||||
(v) => {
|
||||
;(mod.value as ForloopFlow).parallelism = v
|
||||
}
|
||||
}
|
||||
argName="parallelism"
|
||||
collapsed={!parallelismCapped}
|
||||
header={parallelismToggle}
|
||||
schema={parallelismSchema}
|
||||
argExtra={{ min: 1, step: 1 }}
|
||||
animateAppear
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:editor={parallelismEditor}
|
||||
/>
|
||||
{/key}
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mod.value.iterator.type == 'javascript'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="border rounded-md overflow-auto w-full"
|
||||
id="flow-editor-iterator-expression"
|
||||
onkeyup={iteratorGen?.onKeyUp}
|
||||
>
|
||||
<PropPickerWrapper
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
if ($flowPropPickerConfig) {
|
||||
setExpr(detail)
|
||||
flowPropPickerConfig.set(undefined)
|
||||
return
|
||||
}
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
noPadding
|
||||
>
|
||||
<div class="relative w-full h-full overflow-clip">
|
||||
<SimpleEditor
|
||||
small
|
||||
bind:this={editor}
|
||||
on:focus={() => {
|
||||
iteratorFieldFocused = true
|
||||
}}
|
||||
on:blur={() => {
|
||||
iteratorFieldFocused = false
|
||||
}}
|
||||
lang="javascript"
|
||||
bind:code={mod.value.iterator.expr}
|
||||
class="h-full"
|
||||
shouldBindKey={false}
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
{suggestion}
|
||||
/>
|
||||
</div>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
<FlowModuleEarlyStop blocks="stop-after" bind:flowModule={mod} />
|
||||
</section>
|
||||
{:else}
|
||||
<Button
|
||||
on:click={() => {
|
||||
if (mod.value.type === 'forloopflow') mod.value.iterator.type = 'javascript'
|
||||
}}
|
||||
<FlowRunSettings
|
||||
embedded
|
||||
loopSubset
|
||||
earlyStopBlocks="all-iters"
|
||||
bind:this={runSettings}
|
||||
bind:flowModule={mod}
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
selectedId={mod.id}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</Pane>
|
||||
<Pane size={40} minSize={20} class="flex flex-col flex-1">
|
||||
<TabsV2 bind:selected>
|
||||
<!-- <Tab value="retries">Retries</Tab> -->
|
||||
<Tab value="early-stop" label="Early Stop/Break" />
|
||||
<Tab value="skip" label="Skip" />
|
||||
<Tab value="suspend" label="Suspend/Approval/Prompt" />
|
||||
<Tab value="sleep" label="Sleep" />
|
||||
<Tab value="mock" label="Mock" />
|
||||
<Tab value="lifetime" label="Lifetime" />
|
||||
|
||||
{#snippet content()}
|
||||
<div class="overflow-hidden bg-surface" style="height:calc(100% - 32px);">
|
||||
<!-- <TabContent value="retries" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowRetries bind:flowModule={mod} />
|
||||
</div>
|
||||
</TabContent> -->
|
||||
|
||||
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleEarlyStop bind:flowModule={mod} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="skip" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSkip bind:flowModule={mod} {parentModule} {previousModule} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule={mod} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule={mod} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="mock" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleMock bind:flowModule={mod} />
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
|
||||
<div class="p-4 overflow-y-auto">
|
||||
<FlowModuleDeleteAfterUse bind:flowModule={mod} disabled={!$enterpriseLicense} />
|
||||
</div>
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</TabsV2>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</FlowCard>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import { SecondsInput } from '../../common'
|
||||
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
@@ -30,18 +30,12 @@
|
||||
onEditWorkspaceScript
|
||||
}: Props = $props()
|
||||
|
||||
let isCacheEnabled = $derived(Boolean(flowModule.cache_ttl))
|
||||
// Presence, not truthiness: SecondsInput passes through 0 while a segment is being
|
||||
// retyped, and reading that as off would disable the field mid-edit.
|
||||
let isCacheEnabled = $derived(flowModule.cache_ttl !== undefined)
|
||||
</script>
|
||||
|
||||
<Section label="Cache" class="flex flex-col gap-4">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/cache">
|
||||
If defined, the result of the step will be cached for the number of seconds defined such that
|
||||
if this step were to be re-triggered with the same input it would retrieve and return its
|
||||
cached value instead of recomputing it.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if flowModule.value.type == 'script'}
|
||||
<WorkspaceScriptSettingInfo
|
||||
label="Cache"
|
||||
@@ -61,34 +55,41 @@
|
||||
</p>
|
||||
{:else}
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isCacheEnabled}
|
||||
on:change={() => {
|
||||
if (isCacheEnabled && flowModule.cache_ttl != undefined) {
|
||||
if (isCacheEnabled) {
|
||||
flowModule.cache_ttl = undefined
|
||||
} else {
|
||||
flowModule.cache_ttl = 600
|
||||
flowModule.cache_ttl = stepSettingDefaults('cache')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Cache the results for each possible inputs'
|
||||
right: 'Cache results',
|
||||
rightTooltip:
|
||||
'The result of the step is cached for the configured number of seconds; a re-trigger with the same input returns the cached value instead of recomputing it.',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/cache'
|
||||
}}
|
||||
/>
|
||||
{#if flowModule.cache_ttl}
|
||||
<Label label="How long to keep cache valid">
|
||||
<SecondsInput bind:seconds={flowModule.cache_ttl} />
|
||||
</Label>
|
||||
<Toggle
|
||||
size="2xs"
|
||||
bind:checked={
|
||||
() => flowModule.cache_ignore_s3_path,
|
||||
(v) => (flowModule.cache_ignore_s3_path = v || undefined)
|
||||
}
|
||||
options={{
|
||||
right: 'Ignore S3 Object paths for caching purposes',
|
||||
rightTooltip:
|
||||
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
|
||||
}}
|
||||
/>
|
||||
{#if isCacheEnabled}
|
||||
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
|
||||
<Label label="How long to keep cache valid">
|
||||
<SecondsInput bind:seconds={flowModule.cache_ttl} />
|
||||
</Label>
|
||||
<Toggle
|
||||
size="2xs"
|
||||
bind:checked={
|
||||
() => flowModule.cache_ignore_s3_path,
|
||||
(v) => (flowModule.cache_ignore_s3_path = v || undefined)
|
||||
}
|
||||
options={{
|
||||
right: 'Ignore S3 object paths',
|
||||
rightTooltip:
|
||||
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import StepSettingsBadges from './StepSettingsBadges.svelte'
|
||||
import Editor from '$lib/components/Editor.svelte'
|
||||
import EditorBar, {
|
||||
EDITOR_BAR_WIDTH_THRESHOLD,
|
||||
@@ -22,47 +20,27 @@
|
||||
import { getContext, onDestroy, tick, untrack } from 'svelte'
|
||||
import type { FlowEditorContext, FlowGraphAssetContext } from '../types'
|
||||
import FlowModuleScript from './FlowModuleScript.svelte'
|
||||
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
|
||||
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
|
||||
import FlowModuleCache from './FlowModuleCache.svelte'
|
||||
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
|
||||
import FlowRetries from './FlowRetries.svelte'
|
||||
import FlowRunSettings from './FlowRunSettings.svelte'
|
||||
import { getFailureStepPropPicker, getStepPropPicker } from '../previousResults'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import FlowModuleSleep from './FlowModuleSleep.svelte'
|
||||
import FlowPathViewer from './FlowPathViewer.svelte'
|
||||
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
|
||||
import AgentResourceBar from './AgentResourceBar.svelte'
|
||||
import AgentToolBindings from './AgentToolBindings.svelte'
|
||||
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import { flowLocalAgentSchema } from '../agentResourceUtils'
|
||||
import FlowModuleMockTransitionMessage from './FlowModuleMockTransitionMessage.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { SecondsInput } from '$lib/components/common'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
import type { ButtonProp } from '$lib/components/diffEditorTypes'
|
||||
import FlowModuleTimeout from './FlowModuleTimeout.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import s3Scripts from './s3Scripts/lib'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { loadSchemaFromModule } from '../flowInfers'
|
||||
import FlowModuleSkip from './FlowModuleSkip.svelte'
|
||||
import FlowModuleDebounce from './FlowModuleDebounce.svelte'
|
||||
import { type Job } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { checkIfParentLoop } from '../utils.svelte'
|
||||
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
|
||||
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
|
||||
import { getActiveScriptSettingsBadges } from '$lib/components/scriptSettings'
|
||||
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
|
||||
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
@@ -200,10 +178,8 @@
|
||||
flowModule.value.type === 'aiagent'
|
||||
)
|
||||
let visibleSelected = $derived(selected === 'chat' && !canShowChatTab ? 'inputs' : selected)
|
||||
let runSettings: FlowRunSettings | undefined = $state()
|
||||
let agentLinked = $derived(flowModule.value.type === 'aiagent' && Boolean(flowModule.value.agent))
|
||||
let advancedSelected = $state('retries')
|
||||
let advancedRuntimeSelected = $state('concurrency')
|
||||
let s3Kind = $state('s3_client')
|
||||
let validCode = $state(true)
|
||||
let width = $state(1200)
|
||||
let testJob: Job | undefined = $state(undefined)
|
||||
@@ -334,7 +310,7 @@
|
||||
|
||||
function selectAdvanced(subtab: string) {
|
||||
selected = 'advanced'
|
||||
advancedSelected = subtab
|
||||
tick().then(() => runSettings?.openSetting(subtab))
|
||||
}
|
||||
|
||||
function setOmitOutputFromConversation(omit: boolean) {
|
||||
@@ -351,7 +327,7 @@
|
||||
|
||||
let forceReload = $state(0)
|
||||
let editorPanelSize = $state(
|
||||
untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50
|
||||
untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 40
|
||||
)
|
||||
let editorSettingsPanelSize = $state(100 - untrack(() => editorPanelSize))
|
||||
let stepHistoryLoader = getStepHistoryLoaderContext()
|
||||
@@ -362,6 +338,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reached from both headers: the card header owns the script-path actions, the module
|
||||
// header the subflow ones.
|
||||
async function reloadModule() {
|
||||
if (flowModule.value.type == 'script') {
|
||||
if (flowModule.value.hash != undefined) {
|
||||
flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs)
|
||||
}
|
||||
forceReload++
|
||||
// Keep the surfaced concurrency/cache values and badges in sync after a
|
||||
// settings/code save from the header (path/hash may be unchanged).
|
||||
await referencedScriptSettings.reload()
|
||||
await reload(flowModule)
|
||||
}
|
||||
if (flowModule.value.type == 'flow') {
|
||||
forceReload++
|
||||
await reload(flowModule)
|
||||
}
|
||||
}
|
||||
|
||||
let leftPanelSize = $state(0)
|
||||
|
||||
function showDiffMode() {
|
||||
@@ -810,16 +805,18 @@
|
||||
<div class="h-full bg-surface" bind:clientWidth={width}>
|
||||
<FlowCard
|
||||
flowModuleValue={flowModule?.value}
|
||||
on:reload={() => {
|
||||
forceReload++
|
||||
reload(flowModule)
|
||||
}}
|
||||
{noEditor}
|
||||
on:setHash={(e) => {
|
||||
if (flowModule.value.type == 'script') {
|
||||
flowModule.value.hash = e.detail
|
||||
}
|
||||
}}
|
||||
on:fork={async () => {
|
||||
const [module, state] = await fork(flowModule, opWs)
|
||||
flowModule = module
|
||||
flowStateStore.val[module.id] = state
|
||||
}}
|
||||
on:reload={reloadModule}
|
||||
bind:summary={flowModule.summary}
|
||||
bind:description={toolDescription}
|
||||
{isAgentTool}
|
||||
@@ -837,35 +834,7 @@
|
||||
flowModule.value.tag = e.detail
|
||||
}
|
||||
}}
|
||||
on:toggleSuspend={() => selectAdvanced('suspend')}
|
||||
on:toggleSleep={() => selectAdvanced('sleep')}
|
||||
on:toggleMock={() => selectAdvanced('mock')}
|
||||
on:toggleRetry={() => selectAdvanced('retries')}
|
||||
on:togglePin={() => (selected = 'test')}
|
||||
on:toggleConcurrency={() => selectAdvanced('runtime')}
|
||||
on:toggleCache={() => selectAdvanced('cache')}
|
||||
on:toggleStopAfterIf={() => selectAdvanced('early-stop')}
|
||||
on:fork={async () => {
|
||||
const [module, state] = await fork(flowModule, opWs)
|
||||
flowModule = module
|
||||
flowStateStore.val[module.id] = state
|
||||
}}
|
||||
on:reload={async () => {
|
||||
if (flowModule.value.type == 'script') {
|
||||
if (flowModule.value.hash != undefined) {
|
||||
flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs)
|
||||
}
|
||||
forceReload++
|
||||
// Keep the surfaced concurrency/cache values and badges in sync after
|
||||
// a settings/code save from the header (path/hash may be unchanged).
|
||||
await referencedScriptSettings.reload()
|
||||
await reload(flowModule)
|
||||
}
|
||||
if (flowModule.value.type == 'flow') {
|
||||
forceReload++
|
||||
await reload(flowModule)
|
||||
}
|
||||
}}
|
||||
on:reload={reloadModule}
|
||||
on:createScriptFromInlineScript={async () => {
|
||||
const [module, state] = await createScriptFromInlineScript(
|
||||
flowModule,
|
||||
@@ -1131,7 +1100,11 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if !preprocessorModule && !isAgentTool}
|
||||
<Tab value="advanced" label="Advanced" />
|
||||
<Tab value="advanced" label="Run settings">
|
||||
{#snippet extra()}
|
||||
<StepSettingsBadges {flowModule} />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent')}
|
||||
@@ -1312,316 +1285,24 @@
|
||||
</Section>
|
||||
</div>
|
||||
{:else if visibleSelected === 'advanced'}
|
||||
<Tabs bind:selected={advancedSelected} wrapperClass="shrink-0">
|
||||
<Tab
|
||||
value="retries"
|
||||
active={flowModule.retry !== undefined || flowModule.continue_on_error}
|
||||
label="Error handling"
|
||||
/>
|
||||
{#if !selectedId.includes('failure')}
|
||||
<Tab value="runtime" label="Runtime" />
|
||||
<Tab value="cache" active={Boolean(flowModule.cache_ttl)} label="Cache" />
|
||||
<Tab
|
||||
value="early-stop"
|
||||
active={Boolean(
|
||||
flowModule.stop_after_if || flowModule.stop_after_all_iters_if
|
||||
)}
|
||||
label="Early Stop"
|
||||
/>
|
||||
<Tab value="skip" active={Boolean(flowModule.skip_if)} label="Skip" />
|
||||
<Tab value="suspend" active={Boolean(flowModule.suspend)} label="Suspend" />
|
||||
<Tab value="sleep" active={Boolean(flowModule.sleep)} label="Sleep" />
|
||||
<Tab
|
||||
value="debounce"
|
||||
active={Boolean(flowModule.debouncing?.debounce_delay_s)}
|
||||
label="Debounce"
|
||||
/>
|
||||
<Tab value="mock" active={Boolean(flowModule.mock?.enabled)} label="Mock" />
|
||||
<Tab value="same_worker" label="Shared Directory" />
|
||||
{#if flowModule.value['language'] === 'python3' || flowModule.value['language'] === 'deno'}
|
||||
<Tab value="s3" label="S3" />
|
||||
{/if}
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#if advancedSelected === 'runtime'}
|
||||
<Tabs bind:selected={advancedRuntimeSelected} wrapperClass="shrink-0">
|
||||
<Tab value="concurrency" label="Concurrency" />
|
||||
<Tab value="timeout" label="Timeout" />
|
||||
<Tab value="priority" label="Priority" />
|
||||
<Tab value="lifetime" label="Lifetime" />
|
||||
</Tabs>
|
||||
{/if}
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
{#if advancedSelected === 'retries'}
|
||||
<Section label="Continue on error">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
When enabled, the flow will continue to the next step even if this
|
||||
step fails (after exhausting all retries, if any). This enables to
|
||||
process the error in a branch one for instance.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={flowModule.continue_on_error}
|
||||
options={{
|
||||
left: 'Stop on error and propagate error up',
|
||||
right: "Continue on error with error as step's return"
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<div class="mt-4"></div>
|
||||
<Section label="Retries">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/flows/retries"
|
||||
>
|
||||
If defined, upon error this step will be retried with a delay and a
|
||||
maximum number of attempts as defined below.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<FlowRetries
|
||||
bind:flowModuleRetry={flowModule.retry}
|
||||
bind:flowModule
|
||||
{isAgentTool}
|
||||
/>
|
||||
</Section>
|
||||
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'concurrency'}
|
||||
<Section label="Concurrency limits" class="flex flex-col gap-4" eeOnly>
|
||||
{#snippet header()}
|
||||
<Tooltip>Allowed concurrency within a given timeframe</Tooltip>
|
||||
{/snippet}
|
||||
{#if flowModule.value.type == 'rawscript'}
|
||||
<Label label="Max number of executions within the time window">
|
||||
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
|
||||
<input
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:value={flowModule.value.concurrent_limit}
|
||||
type="number"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (flowModule.value.type == 'rawscript') {
|
||||
flowModule.value.concurrent_limit = undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2"> Remove Limits </div>
|
||||
</Button>
|
||||
</div>
|
||||
</Label>
|
||||
<Label label="Time window in seconds">
|
||||
<SecondsInput
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:seconds={flowModule.value.concurrency_time_window_s}
|
||||
clearable
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Custom concurrency key (optional)">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
Concurrency keys are global, you can have them be workspace
|
||||
specific using the variable `$workspace`. You can also use an
|
||||
argument's value using `$args[name_of_arg]`</Tooltip
|
||||
>
|
||||
{/snippet}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
autofocus
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:value={flowModule.value.custom_concurrency_key}
|
||||
placeholder={`$workspace/script/${$pathStore}-$args[foo]`}
|
||||
/>
|
||||
</Label>
|
||||
{:else if flowModule.value.type == 'script'}
|
||||
<WorkspaceScriptSettingInfo
|
||||
label="Concurrency limit"
|
||||
active={referencedConcurrentLimit != undefined}
|
||||
valueText={referencedConcurrentLimit != undefined
|
||||
? `Max ${referencedConcurrentLimit} execution${
|
||||
referencedConcurrentLimit === 1 ? '' : 's'
|
||||
}${
|
||||
referencedScriptSettings.settings?.concurrency_time_window_s !=
|
||||
undefined
|
||||
? ` within ${referencedScriptSettings.settings.concurrency_time_window_s}s`
|
||||
: ''
|
||||
}`
|
||||
: undefined}
|
||||
loading={referencedScriptSettings.loading}
|
||||
error={referencedScriptSettings.error}
|
||||
canEdit={canEditWorkspaceScriptSettings}
|
||||
noEditReason={workspaceScriptNoEditReason}
|
||||
onEdit={openWorkspaceScriptSettings}
|
||||
/>
|
||||
{:else}
|
||||
<Alert type="warning" title="Limitation" size="xs">
|
||||
The concurrency limit of a referenced flow is only settable in the
|
||||
flow settings directly.
|
||||
</Alert>
|
||||
{/if}
|
||||
</Section>
|
||||
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'timeout'}
|
||||
<div>
|
||||
<FlowModuleTimeout
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:flowModule
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'priority'}
|
||||
<Section label="Priority" class="flex flex-col gap-4">
|
||||
<!-- TODO: Add EE-only badge when we have it -->
|
||||
<Toggle
|
||||
disabled={!$enterpriseLicense || isCloudHosted()}
|
||||
checked={flowModule.priority !== undefined && flowModule.priority > 0}
|
||||
on:change={() => {
|
||||
if (flowModule.priority) {
|
||||
flowModule.priority = undefined
|
||||
} else {
|
||||
flowModule.priority = 100
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Enabled high priority flow step',
|
||||
rightTooltip: `Jobs scheduled from this step when the flow is executed are labeled as high priority and take precedence over the other jobs in the jobs queue. ${
|
||||
!$enterpriseLicense
|
||||
? 'This is a feature only available on enterprise edition.'
|
||||
: ''
|
||||
}`
|
||||
}}
|
||||
/>
|
||||
<Label label="Priority number">
|
||||
{#snippet header()}
|
||||
<Tooltip>The higher the number, the higher the priority.</Tooltip>
|
||||
{/snippet}
|
||||
<input
|
||||
type="number"
|
||||
class="!w-24"
|
||||
disabled={flowModule.priority === undefined}
|
||||
bind:value={flowModule.priority}
|
||||
onfocus={bubble('focus')}
|
||||
onchange={() => {
|
||||
if (flowModule.priority && flowModule.priority > 100) {
|
||||
flowModule.priority = 100
|
||||
} else if (flowModule.priority && flowModule.priority < 0) {
|
||||
flowModule.priority = 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Alert type="warning" title="Limitation" size="xs">
|
||||
Setting priority is only available for enterprise edition and not
|
||||
available on the cloud.
|
||||
</Alert>
|
||||
</Section>
|
||||
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'lifetime'}
|
||||
<div>
|
||||
<FlowModuleDeleteAfterUse
|
||||
bind:flowModule
|
||||
disabled={!$enterpriseLicense}
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'cache'}
|
||||
<div>
|
||||
<FlowModuleCache
|
||||
bind:flowModule
|
||||
workspaceScriptCacheTtl={referencedCacheTtl}
|
||||
loadingWorkspaceScript={referencedScriptSettings.loading}
|
||||
workspaceScriptError={referencedScriptSettings.error}
|
||||
canEditWorkspaceScript={canEditWorkspaceScriptSettings}
|
||||
{workspaceScriptNoEditReason}
|
||||
onEditWorkspaceScript={openWorkspaceScriptSettings}
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'early-stop'}
|
||||
<FlowModuleEarlyStop bind:flowModule />
|
||||
{:else if advancedSelected === 'skip'}
|
||||
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
|
||||
{:else if advancedSelected === 'suspend'}
|
||||
<div>
|
||||
<FlowModuleSuspend
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:flowModule
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'sleep'}
|
||||
<div>
|
||||
<FlowModuleSleep
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:flowModule
|
||||
{isAgentTool}
|
||||
/>
|
||||
</div>
|
||||
{:else if advancedSelected === 'debounce'}
|
||||
<div>
|
||||
<FlowModuleDebounce bind:flowModule {selectedId} />
|
||||
</div>
|
||||
{:else if advancedSelected === 'mock'}
|
||||
<div>
|
||||
<FlowModuleMockTransitionMessage />
|
||||
</div>
|
||||
{:else if advancedSelected === 'same_worker'}
|
||||
<div>
|
||||
<Alert type="info" title="Share a directory between steps">
|
||||
If shared directory is set, will share a folder that will be mounted on
|
||||
`./shared` for each of them to pass data between each other.
|
||||
</Alert>
|
||||
<Button
|
||||
btnClasses="mt-4"
|
||||
on:click={() => {
|
||||
selectionManager.selectId('settings-same-worker')
|
||||
}}
|
||||
>
|
||||
Set shared directory in the flow settings
|
||||
</Button>
|
||||
</div>
|
||||
{:else if advancedSelected === 's3'}
|
||||
<div>
|
||||
<h2 class="pb-4">
|
||||
S3 snippets
|
||||
<Tooltip>
|
||||
Read/Write object from/to S3 and leverage Polars and DuckDB to run
|
||||
efficient ETL processes.
|
||||
</Tooltip>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-between mb-4 items-center">
|
||||
<div class="flex gap-2">
|
||||
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
|
||||
{#snippet children({ item })}
|
||||
{#if flowModule.value['language'] === 'deno'}
|
||||
<ToggleButton
|
||||
value="s3_client"
|
||||
small
|
||||
label="S3 lite client"
|
||||
{item}
|
||||
/>
|
||||
{:else}
|
||||
<ToggleButton value="s3_client" small label="Boto3" {item} />
|
||||
<ToggleButton value="polars" small label="Polars" {item} />
|
||||
<ToggleButton value="duckdb" small label="DuckDB" {item} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={() =>
|
||||
editor?.setCode(s3Scripts[flowModule.value['language']][s3Kind])}
|
||||
>
|
||||
Apply snippet
|
||||
</Button>
|
||||
</div>
|
||||
<HighlightCode
|
||||
language={flowModule.value['language']}
|
||||
code={s3Scripts[flowModule.value['language']][s3Kind]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<FlowRunSettings
|
||||
bind:this={runSettings}
|
||||
onApplyS3Snippet={(code) => editor?.setCode(code)}
|
||||
bind:flowModule
|
||||
{isAgentTool}
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
{selectedId}
|
||||
{referencedConcurrentLimit}
|
||||
referencedConcurrencyTimeWindowS={referencedScriptSettings.settings
|
||||
?.concurrency_time_window_s}
|
||||
workspaceScriptCacheTtl={referencedCacheTtl}
|
||||
loadingWorkspaceScript={referencedScriptSettings.loading}
|
||||
workspaceScriptError={referencedScriptSettings.error}
|
||||
canEditWorkspaceScript={canEditWorkspaceScriptSettings}
|
||||
{workspaceScriptNoEditReason}
|
||||
onEditWorkspaceScript={openWorkspaceScriptSettings}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
@@ -1710,7 +1391,10 @@
|
||||
{scriptProgress}
|
||||
mod={flowModule}
|
||||
linkedAgentTools={agentLinked
|
||||
? getLinkedAgentTools(linkedToolsScope(opWs, $pathStore), linkedToolsModuleId)
|
||||
? getLinkedAgentTools(
|
||||
linkedToolsScope(opWs, $pathStore),
|
||||
linkedToolsModuleId
|
||||
)
|
||||
: undefined}
|
||||
{testIsLoading}
|
||||
disableMock={preprocessorModule || failureModule}
|
||||
|
||||
@@ -56,4 +56,6 @@
|
||||
schema={flowStateStore.val[selectedId]?.schema}
|
||||
placeholder={`$workspace/flow/<flow_path>-${flowModule.id}`}
|
||||
size="xs"
|
||||
fontClass="text-xs font-normal text-primary"
|
||||
indentContent
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import { SecondsInput } from '$lib/components/common'
|
||||
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
@@ -14,45 +13,33 @@
|
||||
let { flowModule = $bindable(), disabled = false }: Props = $props()
|
||||
|
||||
let enabled = $derived(flowModule.delete_after_secs != null)
|
||||
|
||||
const tip =
|
||||
'The logs, arguments and results of this flow step are permanently deleted after the configured delay once the flow completes (they may be briefly visible in the UI while running). This also applies to a failed step: the error will not be accessible. The deletion is irreversible. Set to 0 for immediate deletion.'
|
||||
</script>
|
||||
|
||||
<Section label="Delete after completion">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
The logs, arguments and results of this flow step will be completely deleted from Windmill
|
||||
after the specified delay once the flow is complete. They might be temporarily visible in UI
|
||||
while the flow is running.
|
||||
<br />
|
||||
This also applies to a flow step that has failed: the error will not be accessible.
|
||||
<br />
|
||||
<br />
|
||||
The deletion is irreversible. Set to 0 for immediate deletion.
|
||||
{#if disabled}
|
||||
<br />
|
||||
<br />
|
||||
This option is only available on Windmill Enterprise Edition.
|
||||
{/if}
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
{disabled}
|
||||
size="sm"
|
||||
eeOnly
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={enabled}
|
||||
on:change={() => {
|
||||
if (enabled) {
|
||||
flowModule.delete_after_secs = undefined
|
||||
} else {
|
||||
flowModule.delete_after_secs = 0
|
||||
flowModule.delete_after_secs = stepSettingDefaults('lifetime')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Delete logs, arguments and results after the flow is complete'
|
||||
right: 'Delete after use',
|
||||
rightTooltip: tip
|
||||
}}
|
||||
/>
|
||||
{#if enabled}
|
||||
<div class="mt-2">
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<SecondsInput bind:seconds={flowModule.delete_after_secs} {disabled} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
|
||||
import type { Flow, FlowModule } from '$lib/gen'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import type { Flow, FlowModule, StopAfterIf } from '$lib/gen'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import type { ExtendedOpenFlow, FlowEditorContext } from '../types'
|
||||
import { getContext } from 'svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { NEVER_TESTED_THIS_FAR } from '../models'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import { dfs } from '../previousResults'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
const { flowStateStore, flowStore, previewArgs } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
/** A loop shows both predicates, and puts the per-iteration one (`stop_after_if`)
|
||||
* next to its own settings rather than in the run-settings list. */
|
||||
blocks?: 'both' | 'stop-after' | 'all-iters'
|
||||
}
|
||||
|
||||
let { flowModule = $bindable() }: Props = $props()
|
||||
let { flowModule = $bindable(), blocks = 'both' }: Props = $props()
|
||||
|
||||
let stopAfterEditor: SimpleEditor | undefined = $state(undefined)
|
||||
let stopAfterAllItersEditor: SimpleEditor | undefined = $state(undefined)
|
||||
|
||||
// Both predicates are stored as a bare `{ expr }`, so the form is told their kind
|
||||
// through `argType` rather than inferring one from the value.
|
||||
let predicateSchema = $state(emptySchema())
|
||||
predicateSchema.properties['stop_after_if'] = { type: 'boolean' }
|
||||
predicateSchema.properties['stop_after_all_iters_if'] = { type: 'boolean' }
|
||||
|
||||
let editor: SimpleEditor | undefined = $state(undefined)
|
||||
let stepPropPicker = $derived(
|
||||
getStepPropPicker(
|
||||
flowStateStore.val,
|
||||
@@ -50,12 +67,20 @@
|
||||
}
|
||||
return null
|
||||
}
|
||||
let raise_error_message_stop_after_all_if = $state(
|
||||
flowModule.stop_after_all_iters_if?.error_message != undefined
|
||||
)
|
||||
let raise_error_message_stop_after_if = $state(
|
||||
flowModule.stop_after_if?.error_message != undefined
|
||||
)
|
||||
// `skip_if_stopped` and `error_message` are mutually exclusive in the worker, and
|
||||
// setting neither is a third outcome — so the three are one choice, not two flags.
|
||||
type StopStatus = 'success' | 'skipped' | 'error'
|
||||
function stopStatus(stop: StopAfterIf): StopStatus {
|
||||
if (stop.skip_if_stopped) return 'skipped'
|
||||
if (stop.error_message != undefined) return 'error'
|
||||
return 'success'
|
||||
}
|
||||
function setStopStatus(stop: StopAfterIf, status: StopStatus) {
|
||||
stop.skip_if_stopped = status === 'skipped'
|
||||
stop.error_message = status === 'error' ? (stop.error_message ?? '') : undefined
|
||||
if (status !== 'error') stop.error_include_result = false
|
||||
}
|
||||
|
||||
let { isLoop, isParallelLoop } = $derived(
|
||||
flowModule.value.type === 'forloopflow' || flowModule.value.type === 'whileloopflow'
|
||||
? { isLoop: true, isParallelLoop: flowModule.value.parallel ?? false }
|
||||
@@ -66,327 +91,248 @@
|
||||
let isStopAfterAllIterationsEnabled = $derived(Boolean(flowModule.stop_after_all_iters_if))
|
||||
let result = $derived(flowStateStore.val[flowModule.id]?.previewResult ?? NEVER_TESTED_THIS_FAR)
|
||||
let breakableParent = $derived(checkIfBreakableParent(flowStore.val))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start space-y-2">
|
||||
{#if !isBranchAll && !isParallelLoop}
|
||||
<Section
|
||||
label={(isLoop
|
||||
? 'Break loop'
|
||||
// One `stop_after_if` field, but what stopping early *does* depends on where the step
|
||||
// sits — so the name and the explanation are picked together rather than sharing one
|
||||
// tooltip that has to enumerate every case.
|
||||
let stopAfterCopy = $derived(
|
||||
isParallelLoop
|
||||
? {
|
||||
label: 'Break loop if',
|
||||
tooltip:
|
||||
'Unavailable on a parallel loop: iterations don\'t run in sequence, so there is nothing to break out of and the worker skips this predicate. Use "Stop flow if" to decide once every iteration has completed.'
|
||||
}
|
||||
: isLoop
|
||||
? {
|
||||
label: 'Break loop if',
|
||||
tooltip:
|
||||
'Evaluated after each iteration. When it returns true the loop stops iterating and the flow carries on with the iterations collected so far.'
|
||||
}
|
||||
: breakableParent
|
||||
? breakableParent.isParallel
|
||||
? breakableParent.type === 'loop'
|
||||
? 'Skip rest of steps in iteration'
|
||||
: 'Skip rest of steps in branch'
|
||||
: 'Break parent loop module ' + breakableParent.stepId
|
||||
: 'Stop flow early') + (isLoop ? ' (evaluated after each iteration)' : '')}
|
||||
class="w-full"
|
||||
>
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/early_stop">
|
||||
If defined, at the end of the step, the predicate expression will be evaluated to decide
|
||||
if the flow should stop early, skip rest of steps in iteration/branch if inside a parallel
|
||||
for loop or branch all, or break if inside a for/while loop or branch all.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
? {
|
||||
label: 'Skip rest of iteration if',
|
||||
tooltip:
|
||||
'Evaluated after this step. When it returns true the remaining steps of this iteration are skipped; the other iterations are unaffected.'
|
||||
}
|
||||
: {
|
||||
label: 'Skip rest of branch if',
|
||||
tooltip:
|
||||
'Evaluated after this step. When it returns true the remaining steps of this branch are skipped; the other branches are unaffected.'
|
||||
}
|
||||
: {
|
||||
label: 'Break parent loop if',
|
||||
tooltip: `Evaluated after this step. When it returns true the enclosing loop ${breakableParent.stepId} stops iterating and the flow carries on.`
|
||||
}
|
||||
: {
|
||||
label: 'Stop flow if',
|
||||
tooltip:
|
||||
"Evaluated after this step. When it returns true the flow stops here and returns this step's result."
|
||||
}
|
||||
)
|
||||
|
||||
<Toggle
|
||||
checked={isStopAfterIfEnabled}
|
||||
on:change={() => {
|
||||
if (isStopAfterIfEnabled && flowModule.stop_after_if) {
|
||||
flowModule.stop_after_if = undefined
|
||||
} else {
|
||||
flowModule.stop_after_if = {
|
||||
expr: 'result == undefined',
|
||||
skip_if_stopped: false,
|
||||
error_message: undefined,
|
||||
error_include_result: false
|
||||
// The all-iterations predicate runs once the loop or branch-all is done, over what
|
||||
// every iteration returned, so it can decide on the whole rather than on one result.
|
||||
let allItersPrefix = $derived(
|
||||
`Evaluated once ${isBranchAll ? 'every branch' : 'every iteration'} has completed, over their collected results.`
|
||||
)
|
||||
let stopAfterAllItersCopy = $derived(
|
||||
breakableParent
|
||||
? breakableParent.isParallel
|
||||
? breakableParent.type === 'loop'
|
||||
? {
|
||||
label: 'Skip rest of iteration if',
|
||||
tooltip: `${allItersPrefix} When it returns true the remaining steps of the enclosing iteration are skipped.`
|
||||
}
|
||||
: {
|
||||
label: 'Skip rest of branch if',
|
||||
tooltip: `${allItersPrefix} When it returns true the remaining steps of the enclosing branch are skipped.`
|
||||
}
|
||||
: {
|
||||
label: `Break parent loop ${breakableParent.stepId} if`,
|
||||
tooltip: `${allItersPrefix} When it returns true the enclosing loop stops iterating and the flow carries on.`
|
||||
}
|
||||
: {
|
||||
label: 'Stop flow if',
|
||||
tooltip: `${allItersPrefix} When it returns true the flow stops here and returns them.`
|
||||
}
|
||||
)
|
||||
|
||||
let earlyStopResult = $derived(
|
||||
isLoop
|
||||
? Array.isArray(result) && result.length > 0
|
||||
? result[result.length - 1]
|
||||
: result === NEVER_TESTED_THIS_FAR
|
||||
? result
|
||||
: undefined
|
||||
: result
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet stopStatusPicker(stop: StopAfterIf)}
|
||||
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
|
||||
<Label
|
||||
label="Flow status"
|
||||
tooltip="How the flow is reported once this condition stops it. Success returns this step's result, Skipped marks the flow as skipped, and Error fails it."
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
selected={stopStatus(stop)}
|
||||
onSelected={(v) => setStopStatus(stop, v)}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="success" label="Success" {item} small />
|
||||
<ToggleButton value="skipped" label="Skipped" {item} small />
|
||||
<ToggleButton value="error" label="Error" {item} small />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</Label>
|
||||
{#if stop.error_message != undefined}
|
||||
<div class="flex flex-col gap-2" transition:slideDynamic>
|
||||
<TextInput
|
||||
size="sm"
|
||||
bind:value={() => stop.error_message ?? '', (v) => (stop.error_message = String(v))}
|
||||
inputProps={{ placeholder: 'Enter custom error message (optional)' }}
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={
|
||||
() => stop.error_include_result ?? false, (v) => (stop.error_include_result = v)
|
||||
}
|
||||
options={{
|
||||
right: 'Include result in error',
|
||||
rightTooltip:
|
||||
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stopAfterToggle()}
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
disabled={isParallelLoop}
|
||||
checked={isStopAfterIfEnabled}
|
||||
on:change={() => {
|
||||
if (isStopAfterIfEnabled && flowModule.stop_after_if) {
|
||||
flowModule.stop_after_if = undefined
|
||||
} else {
|
||||
flowModule.stop_after_if = stepSettingDefaults('early-stop')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
title: isParallelLoop ? stopAfterCopy.tooltip : undefined,
|
||||
right: stopAfterCopy.label,
|
||||
rightTooltip: stopAfterCopy.tooltip,
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_stop'
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stopAfterAllItersToggle()}
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isStopAfterAllIterationsEnabled}
|
||||
on:change={() => {
|
||||
if (isStopAfterAllIterationsEnabled && flowModule.stop_after_all_iters_if) {
|
||||
flowModule.stop_after_all_iters_if = undefined
|
||||
} else {
|
||||
flowModule.stop_after_all_iters_if = stepSettingDefaults('early-stop')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: stopAfterAllItersCopy.label,
|
||||
rightTooltip: stopAfterAllItersCopy.tooltip,
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_stop'
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col items-start gap-6">
|
||||
{#if blocks !== 'all-iters' && !isBranchAll}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
result={earlyStopResult}
|
||||
extraResults={isLoop ? { all_iters: result } : undefined}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
stopAfterEditor?.insertAtCursor(detail)
|
||||
stopAfterEditor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={
|
||||
() => flowModule.stop_after_if,
|
||||
(v) => {
|
||||
flowModule.stop_after_if = v
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: isLoop
|
||||
? 'Break loop'
|
||||
: breakableParent
|
||||
? breakableParent.isParallel
|
||||
? breakableParent.type === 'loop'
|
||||
? 'Skip rest of steps in iteration'
|
||||
: 'Skip rest of steps in branch'
|
||||
: 'Break parent loop module'
|
||||
: 'Stop flow if condition met'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="w-full mt-2 border rounded-md p-2 flex flex-col gap-2 {flowModule.stop_after_if
|
||||
? ''
|
||||
: 'bg-surface-secondary'}"
|
||||
>
|
||||
{#if flowModule.stop_after_if}
|
||||
{@const earlyStopResult = isLoop
|
||||
? Array.isArray(result) && result.length > 0
|
||||
? result[result.length - 1]
|
||||
: result === NEVER_TESTED_THIS_FAR
|
||||
? result
|
||||
: undefined
|
||||
: result}
|
||||
{#if !breakableParent && !isLoop}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={flowModule.stop_after_if.skip_if_stopped}
|
||||
on:change={(event) => {
|
||||
if (flowModule.stop_after_if && event.detail) {
|
||||
flowModule.stop_after_if.error_message = undefined
|
||||
flowModule.stop_after_if.error_include_result = false
|
||||
raise_error_message_stop_after_if = false
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Label flow as "skipped" if stopped'
|
||||
}}
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={raise_error_message_stop_after_if}
|
||||
on:change={(event) => {
|
||||
if (flowModule.stop_after_if) {
|
||||
if (event.detail) {
|
||||
flowModule.stop_after_if.error_message = ''
|
||||
flowModule.stop_after_if.skip_if_stopped = false
|
||||
} else {
|
||||
flowModule.stop_after_if.error_message = undefined
|
||||
flowModule.stop_after_if.error_include_result = false
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Raise an error message if stopped',
|
||||
rightTooltip:
|
||||
'If enabled and the stop condition is met, an error message will be raised. A custom message can be provided; otherwise, a default message will be used. Mutually exclusive with "Label flow as skipped".'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if raise_error_message_stop_after_if}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={flowModule.stop_after_if.error_message}
|
||||
placeholder="Enter custom error message (optional)"
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={flowModule.stop_after_if.error_include_result}
|
||||
options={{
|
||||
right: "Include the stopping step's result in the error",
|
||||
rightTooltip:
|
||||
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
|
||||
<div class="border rounded-md w-full overflow-auto">
|
||||
<PropPickerWrapper
|
||||
noPadding
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
result={earlyStopResult}
|
||||
extraResults={isLoop ? { all_iters: result } : undefined}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<SimpleEditor
|
||||
bind:this={editor}
|
||||
lang="javascript"
|
||||
bind:code={flowModule.stop_after_if.expr}
|
||||
class="h-full"
|
||||
extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};\n` +
|
||||
stepPropPicker.extraLib +
|
||||
(isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !breakableParent && !isLoop}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Label flow as "skipped" if stopped'
|
||||
}}
|
||||
/>
|
||||
<Toggle
|
||||
disabled
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Raise an error message if stopped'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
|
||||
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
argName="stop_after_if"
|
||||
argType="javascript"
|
||||
collapsed={!isStopAfterIfEnabled || isParallelLoop}
|
||||
animateAppear
|
||||
header={stopAfterToggle}
|
||||
noDynamicToggle
|
||||
schema={predicateSchema}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};\n` +
|
||||
stepPropPicker.extraLib +
|
||||
(isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')}
|
||||
bind:editor={stopAfterEditor}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
{#if isStopAfterIfEnabled && !breakableParent && !isLoop && flowModule.stop_after_if}
|
||||
{@render stopStatusPicker(flowModule.stop_after_if)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoop || isBranchAll}
|
||||
<Section
|
||||
label={(breakableParent
|
||||
? breakableParent.isParallel
|
||||
? breakableParent.type === 'loop'
|
||||
? 'Skip rest of steps in iteration'
|
||||
: 'Skip rest of steps in branch'
|
||||
: 'Break parent loop module ' + breakableParent.stepId
|
||||
: 'Stop flow early') +
|
||||
(isBranchAll
|
||||
? ' (evaluated after all branches have been run)'
|
||||
: ' (evaluated after all iterations)')}
|
||||
class="w-full"
|
||||
>
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/early_stop">
|
||||
If defined, at the end of the step, the predicate expression will be evaluated to decide
|
||||
if the flow should stop early, skip rest of steps in iteration/branch if inside a parallel
|
||||
for loop or branch all, or break if inside a for/while loop or branch all.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<Toggle
|
||||
checked={isStopAfterAllIterationsEnabled}
|
||||
on:change={() => {
|
||||
if (isStopAfterAllIterationsEnabled && flowModule.stop_after_all_iters_if) {
|
||||
flowModule.stop_after_all_iters_if = undefined
|
||||
} else {
|
||||
flowModule.stop_after_all_iters_if = {
|
||||
expr: 'result == undefined',
|
||||
skip_if_stopped: false,
|
||||
error_message: undefined,
|
||||
error_include_result: false
|
||||
{#if blocks !== 'stop-after' && (isLoop || isBranchAll)}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
stopAfterAllItersEditor?.insertAtCursor(detail)
|
||||
stopAfterAllItersEditor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={
|
||||
() => flowModule.stop_after_all_iters_if,
|
||||
(v) => {
|
||||
flowModule.stop_after_all_iters_if = v
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right:
|
||||
(breakableParent
|
||||
? breakableParent.isParallel
|
||||
? breakableParent.type === 'loop'
|
||||
? 'Skip rest of steps in iteration'
|
||||
: 'Skip rest of steps in branch'
|
||||
: 'Break parent loop module ' + breakableParent.stepId
|
||||
: 'Stop flow') + ' if condition met'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="w-full border rounded-md mt-2 p-2 flex flex-col gap-2 {flowModule.stop_after_all_iters_if
|
||||
? ''
|
||||
: 'bg-surface-secondary'}"
|
||||
>
|
||||
{#if flowModule.stop_after_all_iters_if}
|
||||
{#if !breakableParent}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={flowModule.stop_after_all_iters_if.skip_if_stopped}
|
||||
on:change={(event) => {
|
||||
if (flowModule.stop_after_all_iters_if && event.detail) {
|
||||
flowModule.stop_after_all_iters_if.error_message = undefined
|
||||
flowModule.stop_after_all_iters_if.error_include_result = false
|
||||
raise_error_message_stop_after_all_if = false
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Label flow as "skipped" if stopped'
|
||||
}}
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={raise_error_message_stop_after_all_if}
|
||||
on:change={(event) => {
|
||||
if (flowModule.stop_after_all_iters_if) {
|
||||
if (event.detail) {
|
||||
flowModule.stop_after_all_iters_if.error_message = ''
|
||||
flowModule.stop_after_all_iters_if.skip_if_stopped = false
|
||||
} else {
|
||||
flowModule.stop_after_all_iters_if.error_message = undefined
|
||||
flowModule.stop_after_all_iters_if.error_include_result = false
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Raise an error message if stopped',
|
||||
rightTooltip:
|
||||
'If enabled and the stop condition is met, an error message will be raised. A custom message can be provided; otherwise, a default message will be used. Mutually exclusive with "Label flow as skipped".'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if raise_error_message_stop_after_all_if}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={flowModule.stop_after_all_iters_if.error_message}
|
||||
placeholder="Enter custom error message (optional)"
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={flowModule.stop_after_all_iters_if.error_include_result}
|
||||
options={{
|
||||
right: "Include the stopping step's result in the error",
|
||||
rightTooltip:
|
||||
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
|
||||
<div class="border rounded-md w-full overflow-auto">
|
||||
<PropPickerWrapper
|
||||
notSelectable
|
||||
noPadding
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
{result}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<SimpleEditor
|
||||
bind:this={editor}
|
||||
lang="javascript"
|
||||
bind:code={flowModule.stop_after_all_iters_if.expr}
|
||||
class="h-full"
|
||||
extraLib={`declare const result = ${JSON.stringify(result)};\n` +
|
||||
stepPropPicker.extraLib}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !breakableParent}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Label flow as "skipped" if stopped'
|
||||
}}
|
||||
/>
|
||||
<Toggle
|
||||
disabled
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Raise an error message if stopped'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
|
||||
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
argName="stop_after_all_iters_if"
|
||||
argType="javascript"
|
||||
collapsed={!isStopAfterAllIterationsEnabled}
|
||||
animateAppear
|
||||
header={stopAfterAllItersToggle}
|
||||
noDynamicToggle
|
||||
schema={predicateSchema}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib}
|
||||
bind:editor={stopAfterAllItersEditor}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
{#if isStopAfterAllIterationsEnabled && !breakableParent && flowModule.stop_after_all_iters_if}
|
||||
{@render stopStatusPicker(flowModule.stop_after_all_iters_if)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,10 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { type FlowModule } from '$lib/gen'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import {
|
||||
Bed,
|
||||
Database,
|
||||
Gauge,
|
||||
GitFork,
|
||||
Pen,
|
||||
PhoneIncoming,
|
||||
RefreshCcw,
|
||||
Repeat,
|
||||
Square,
|
||||
Pin,
|
||||
Save,
|
||||
Settings
|
||||
} from 'lucide-svelte'
|
||||
import Popover from '../../Popover.svelte'
|
||||
import { Pen, RefreshCcw, Save } from 'lucide-svelte'
|
||||
import DropdownV2 from '../../DropdownV2.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { getLatestHashForScript } from '$lib/scripts'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
|
||||
import FlowModuleWorkerTagSelect from './FlowModuleWorkerTagSelect.svelte'
|
||||
|
||||
@@ -29,134 +15,14 @@
|
||||
}
|
||||
|
||||
let { module, tag }: Props = $props()
|
||||
const { scriptEditorDrawer, workspaceScriptSettingsDrawer, flowEditorDrawer, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { flowEditorDrawer } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
|
||||
|
||||
let popoverClasses =
|
||||
'center-center rounded p-2 bg-blue-100 text-blue-800 border border-blue-300 hover:bg-blue-200 dark:bg-frost-700 dark:text-frost-100 dark:border-frost-600'
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-2 whitespace-nowrap">
|
||||
{#if module.value.type === 'script' || module.value.type === 'rawscript' || module.value.type == 'flow'}
|
||||
{#if module.retry?.constant || module.retry?.exponential}
|
||||
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleRetry')}>
|
||||
<Repeat size={14} />
|
||||
{#snippet text()}
|
||||
Retries
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module?.value?.['concurrent_limit'] != undefined}
|
||||
<Popover
|
||||
placement="bottom"
|
||||
class={popoverClasses}
|
||||
onClick={() => dispatch('toggleConcurrency')}
|
||||
>
|
||||
<Gauge size={14} />
|
||||
{#snippet text()}
|
||||
Concurrency Limits
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module.cache_ttl != undefined}
|
||||
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleCache')}>
|
||||
<Database size={14} />
|
||||
{#snippet text()}
|
||||
Cache
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module.stop_after_if || module.stop_after_all_iters_if}
|
||||
<Popover
|
||||
placement="bottom"
|
||||
class={popoverClasses}
|
||||
onClick={() => dispatch('toggleStopAfterIf')}
|
||||
>
|
||||
<Square size={14} />
|
||||
{#snippet text()}
|
||||
Early stop/break
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module.suspend}
|
||||
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleSuspend')}>
|
||||
<PhoneIncoming size={14} />
|
||||
{#snippet text()}
|
||||
Suspend
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module.sleep}
|
||||
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleSleep')}>
|
||||
<Bed size={14} />
|
||||
{#snippet text()}
|
||||
Sleep
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if module.mock?.enabled}
|
||||
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('togglePin')}>
|
||||
<Pin size={14} />
|
||||
{#snippet text()}
|
||||
This step is pinned
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex shrink-0 flex-row gap-2 whitespace-nowrap">
|
||||
{#if module.value.type === 'script'}
|
||||
{#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false}
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
if (module.value.type == 'script') {
|
||||
const hash =
|
||||
module.value.hash ??
|
||||
(await getLatestHashForScript(module.value.path, opWorkspace?.()))
|
||||
$scriptEditorDrawer?.openDrawer(hash, () => {
|
||||
dispatch('reload')
|
||||
sendUserToast('Script has been updated')
|
||||
})
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly
|
||||
aria-label="Edit the script's code"
|
||||
disabled={module.value.hash != undefined}
|
||||
/>
|
||||
{#snippet text()}Edit the script's code{/snippet}
|
||||
</Popover>
|
||||
<!-- Only when the settings drawer is actually mounted (not in the local-dev
|
||||
editors, which provide the context store but never render it). -->
|
||||
{#if $workspaceScriptSettingsDrawer}
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
if (module.value.type == 'script') {
|
||||
$workspaceScriptSettingsDrawer?.openDrawer(
|
||||
module.value.path,
|
||||
module.value.hash,
|
||||
() => {
|
||||
dispatch('reload')
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
startIcon={{ icon: Settings }}
|
||||
iconOnly
|
||||
aria-label="Edit the script's runtime settings"
|
||||
disabled={module.value.hash != undefined}
|
||||
/>
|
||||
{#snippet text()}Edit the script's runtime settings (concurrency, cache, timeout, ...){/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if customUi?.tagEdit != false}
|
||||
<FlowModuleWorkerTagSelect
|
||||
isPreprocessor={module.id == 'preprocessor'}
|
||||
@@ -167,19 +33,6 @@
|
||||
on:change={(e) => dispatch('tagChange', e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
{#if customUi?.scriptFork != false}
|
||||
<Popover notClickable placement="bottom">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => dispatch('fork')}
|
||||
startIcon={{ icon: GitFork }}
|
||||
iconOnly
|
||||
aria-label="Fork into an inline script"
|
||||
/>
|
||||
{#snippet text()}Fork into an inline script{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{:else if module.value.type === 'flow'}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
@@ -230,14 +83,16 @@
|
||||
tag={module.value.tag}
|
||||
on:change={(e) => dispatch('tagChange', e.detail)}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => dispatch('createScriptFromInlineScript')}
|
||||
iconOnly={false}
|
||||
>
|
||||
Save to workspace
|
||||
</Button>
|
||||
<DropdownV2
|
||||
size="sm"
|
||||
placement="bottom-end"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Save to workspace',
|
||||
icon: Save,
|
||||
action: () => dispatch('createScriptFromInlineScript')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Section } from '$lib/components/common'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import JsonEditor from '$lib/components/JsonEditor.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
@@ -56,47 +56,38 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section label="Mock">
|
||||
{#snippet header()}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Tooltip>
|
||||
If defined and enabled, the step will immediately return the mock value instead of being
|
||||
executed.
|
||||
</Tooltip>
|
||||
<Toggle
|
||||
checked={isMockEnabled}
|
||||
on:change={() => {
|
||||
if (isMockEnabled) {
|
||||
flowModule.mock = {
|
||||
enabled: false,
|
||||
return_value: flowModule.mock?.return_value
|
||||
}
|
||||
} else {
|
||||
flowModule.mock = {
|
||||
enabled: true,
|
||||
return_value: flowModule.mock?.return_value ?? { example: 'value' }
|
||||
}
|
||||
code = JSON.stringify(flowModule.mock?.return_value, null, 2)
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isMockEnabled}
|
||||
on:change={() => {
|
||||
if (isMockEnabled) {
|
||||
flowModule.mock = {
|
||||
enabled: false,
|
||||
return_value: flowModule.mock?.return_value
|
||||
}
|
||||
} else {
|
||||
flowModule.mock = {
|
||||
enabled: true,
|
||||
return_value: flowModule.mock?.return_value ?? { example: 'value' }
|
||||
}
|
||||
code = JSON.stringify(flowModule.mock?.return_value, null, 2)
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Pin output',
|
||||
rightTooltip:
|
||||
'While pinned, the step returns this value immediately instead of executing. The same control lives on the step in the graph.'
|
||||
}}
|
||||
/>
|
||||
{#if isMockEnabled}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<Label label="Pinned value">
|
||||
{#key renderCount}
|
||||
<JsonEditor {code} on:changeValue={updateMockValue} />
|
||||
{/key}
|
||||
</Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<span class="text-xs py-1">Mocked Return value</span>
|
||||
|
||||
{#if isMockEnabled}
|
||||
{#key renderCount}
|
||||
<JsonEditor {code} on:changeValue={updateMockValue} />
|
||||
{/key}
|
||||
{:else}
|
||||
<pre class="text-xs border rounded p-2 bg-surface-disabled"
|
||||
>{flowModule.mock?.return_value
|
||||
? JSON.stringify(flowModule.mock?.return_value, null, 2)
|
||||
: ''}</pre
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Pin } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
import { Info } from 'lucide-svelte'
|
||||
|
||||
let darkMode = $state(true)
|
||||
|
||||
function openFullscreen(event: MouseEvent) {
|
||||
const img = event.target as HTMLImageElement
|
||||
if (img.requestFullscreen) {
|
||||
img.requestFullscreen({
|
||||
navigationUI: 'auto'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="p-1.5">
|
||||
<!-- Header Banner -->
|
||||
<div
|
||||
class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20
|
||||
text-blue-700 dark:text-blue-300 rounded-md px-3 py-2 flex items-center justify-between mb-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="bg-blue-600 dark:bg-blue-400 px-1.5 py-0.5 text-white text-xs font-bold tracking-wide rounded shadow-sm"
|
||||
>NEW</div
|
||||
>
|
||||
<span class="font-normal text-sm">Mock has evolved into</span>
|
||||
<span class="font-semibold flex items-center gap-0.5 text-sm">
|
||||
<Pin size="14" strokeWidth="2.5" />
|
||||
PIN
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs bg-white/60 dark:bg-slate-800/60 py-0.5 px-2 rounded">
|
||||
<span class="font-medium">Find it in:</span>
|
||||
<span class="font-semibold">"Test this step"</span> tab
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="text-xs">
|
||||
<!-- Steps Header -->
|
||||
<span class="flex items-center mb-2 text-slate-500"> How to use the PIN feature: </span>
|
||||
|
||||
<!-- Steps Container -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Step 1 -->
|
||||
<div class="flex items-start gap-2 group">
|
||||
<div
|
||||
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
|
||||
>1</div
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="font-medium mb-1.5">Pick a result from history</div>
|
||||
<div
|
||||
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
|
||||
>
|
||||
<!--svelte-ignore a11y_click_events_have_key_events-->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
|
||||
src={darkMode ? `${base}/pin-history-dark.png` : `${base}/pin-history.png`}
|
||||
alt="History picker"
|
||||
onclick={openFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="flex items-start gap-2 group">
|
||||
<div
|
||||
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
|
||||
>2</div
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="font-medium mb-1.5">Pin it as a fixed output</div>
|
||||
<div
|
||||
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
|
||||
>
|
||||
<!--svelte-ignore a11y_click_events_have_key_events-->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
|
||||
src={darkMode ? `${base}/pin-pin-dark.png` : `${base}/pin-pin.png`}
|
||||
alt="Pin action"
|
||||
onclick={openFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex items-start gap-2 group">
|
||||
<div
|
||||
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
|
||||
><Info size="14" /></div
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="font-medium mb-1.5">The last pin can be recovered from history</div>
|
||||
<div
|
||||
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
|
||||
>
|
||||
<!--svelte-ignore a11y_click_events_have_key_events-->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
|
||||
src={darkMode ? `${base}/pin-restore-dark.png` : `${base}/pin-restore.png`}
|
||||
alt="Recover pins"
|
||||
onclick={openFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="flex items-start gap-2 group">
|
||||
<div
|
||||
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
|
||||
>
|
||||
<Info size="14" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="font-medium mb-1.5">All of this can be done from the flow view</div>
|
||||
<div
|
||||
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
|
||||
>
|
||||
<!--svelte-ignore a11y_click_events_have_key_events-->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
|
||||
src={darkMode ? `${base}/pin-flow-view-dark.png` : `${base}/pin-flow-view.png`}
|
||||
alt="Flow view pinning"
|
||||
onclick={openFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { getContext } from 'svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
|
||||
const { flowStateStore, flowStore, previewArgs } =
|
||||
@@ -21,6 +22,12 @@
|
||||
let { flowModule = $bindable(), parentModule, previousModule }: Props = $props()
|
||||
|
||||
let editor: SimpleEditor | undefined = $state(undefined)
|
||||
|
||||
// A predicate is stored as a bare `{ expr }`, so the form is told its kind through
|
||||
// `argType` rather than inferring one from the value.
|
||||
let schema = $state(emptySchema())
|
||||
schema.properties['skip_if'] = { type: 'boolean' }
|
||||
|
||||
let stepPropPicker = $derived(
|
||||
getStepPropPicker(
|
||||
flowStateStore.val,
|
||||
@@ -33,64 +40,66 @@
|
||||
)
|
||||
)
|
||||
|
||||
// The worker evaluates skip_if before this step runs, passing the last job result,
|
||||
// so `result` here is the previous step's output — not this step's.
|
||||
let result = $derived(
|
||||
previousModule ? flowStateStore.val[previousModule.id]?.previewResult : undefined
|
||||
)
|
||||
|
||||
let isSkipEnabled = $derived(Boolean(flowModule.skip_if))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start space-y-2">
|
||||
<Section label="Skip" class="w-full">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
If the condition is met, the step will behave as an identity step, passing the previous
|
||||
step's result through unchanged.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
{#snippet skipToggle()}
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isSkipEnabled}
|
||||
on:change={() => {
|
||||
if (isSkipEnabled && flowModule.skip_if) {
|
||||
flowModule.skip_if = undefined
|
||||
} else {
|
||||
flowModule.skip_if = stepSettingDefaults('skip')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Skip step if',
|
||||
rightTooltip:
|
||||
"If the condition is met, the step behaves as an identity step, passing the previous step's result through unchanged."
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Toggle
|
||||
checked={isSkipEnabled}
|
||||
on:change={() => {
|
||||
if (isSkipEnabled && flowModule.skip_if) {
|
||||
flowModule.skip_if = undefined
|
||||
} else {
|
||||
flowModule.skip_if = {
|
||||
expr: 'false'
|
||||
}
|
||||
<div class="w-full">
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={
|
||||
() => flowModule.skip_if,
|
||||
(v) => {
|
||||
flowModule.skip_if = v
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Skip step if condition is met'
|
||||
}}
|
||||
}
|
||||
argName="skip_if"
|
||||
argType="javascript"
|
||||
collapsed={!isSkipEnabled}
|
||||
animateAppear
|
||||
header={skipToggle}
|
||||
noDynamicToggle
|
||||
{schema}
|
||||
previousModuleId={previousModule?.id}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib}
|
||||
bind:editor
|
||||
/>
|
||||
|
||||
<div
|
||||
class="w-full border rounded-md p-2 mt-2 flex flex-col gap-1 {flowModule.skip_if
|
||||
? ''
|
||||
: 'bg-surface-secondary'}"
|
||||
>
|
||||
{#if flowModule.skip_if}
|
||||
<span class="mt-2 text-xs font-bold">Skip condition expression</span>
|
||||
<div class="border rounded-md w-full overflow-auto">
|
||||
<PropPickerWrapper
|
||||
noPadding
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<SimpleEditor
|
||||
bind:this={editor}
|
||||
lang="javascript"
|
||||
bind:code={flowModule.skip_if.expr}
|
||||
class="h-full"
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="mt-2 text-xs font-bold">Skip condition expression</span>
|
||||
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</Section>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { getContext } from 'svelte'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { SecondsInput } from '../../common'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
|
||||
@@ -51,66 +49,54 @@
|
||||
let sameWorker = $derived(Boolean(!isAgentTool && flowStore.val.value.same_worker))
|
||||
</script>
|
||||
|
||||
<Section label="Sleep" class="w-full">
|
||||
{#snippet header()}
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/sleep">
|
||||
If defined, at the end of the step, the flow will sleep for a number of seconds before
|
||||
scheduling the next job (if any, no effect if the step is the last one).
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if sameWorker}
|
||||
<Alert type="warning" size="xs" title="Disabled by the shared directory" class="mb-4">
|
||||
<Alert type="warning" size="xs" title="Disabled by the shared directory">
|
||||
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use a sleep.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isSleepEnabled}
|
||||
disabled={sameWorker}
|
||||
class="mb-6"
|
||||
on:change={() => {
|
||||
if (isSleepEnabled && flowModule.sleep != undefined) {
|
||||
flowModule.sleep = undefined
|
||||
} else {
|
||||
flowModule.sleep = {
|
||||
type: 'static',
|
||||
value: 0
|
||||
}
|
||||
flowModule.sleep = stepSettingDefaults('sleep')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Sleep after module successful execution'
|
||||
right: 'Sleep after step',
|
||||
rightTooltip:
|
||||
'At the end of the step, the flow sleeps for a number of seconds before scheduling the next job (no effect if the step is the last one).',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/sleep'
|
||||
}}
|
||||
/>
|
||||
<Label label="Sleep for duration">
|
||||
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
|
||||
<div class="border rounded-md overflow-auto">
|
||||
<PropPickerWrapper
|
||||
noFlowPlugConnect={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={undefined}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={flowModule.sleep}
|
||||
argName="sleep"
|
||||
{schema}
|
||||
{previousModuleId}
|
||||
argExtra={{ seconds: true, clearable: false }}
|
||||
bind:editor
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
<div class="text-secondary text-xs">OR use a dynamic expression</div>
|
||||
{/if}
|
||||
</Label>
|
||||
</Section>
|
||||
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
{result}
|
||||
displayContext={false}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={flowModule.sleep}
|
||||
argName="sleep"
|
||||
{schema}
|
||||
{previousModuleId}
|
||||
argExtra={{ seconds: true, clearable: false }}
|
||||
bind:editor
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
|
||||
import { Alert, Tab, Tabs } from '$lib/components/common'
|
||||
import { Alert, Button, Tab, Tabs } from '$lib/components/common'
|
||||
import { GroupService, type FlowModule } from '$lib/gen'
|
||||
import { emptySchema, emptyString } from '$lib/utils'
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores.js'
|
||||
import { SecondsInput } from '../../common'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import SuspendDrawer from './SuspendDrawer.svelte'
|
||||
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
|
||||
import AddProperty from '$lib/components/schema/AddProperty.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { Pen, Plus } from 'lucide-svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
const { selectionManager, flowStateStore, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -69,152 +70,157 @@
|
||||
}
|
||||
})
|
||||
|
||||
let jsonView: boolean = $state(false)
|
||||
let formEditor: EditableSchemaDrawer | undefined = $state(undefined)
|
||||
// Stands in for the form's schema until the step has one, so the editor can be mounted
|
||||
// (and thus openable) before the first field exists.
|
||||
let draftFormSchema = $state(emptySchema())
|
||||
|
||||
function openFormEditor() {
|
||||
if (flowModule.suspend && !flowModule.suspend.resume_form) {
|
||||
flowModule.suspend.resume_form = { schema: draftFormSchema }
|
||||
}
|
||||
formEditor?.openDrawer()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section label="Suspend/Approval/Prompt" class="w-full">
|
||||
{#snippet action()}
|
||||
<SuspendDrawer text="Approval/Prompt helpers" />
|
||||
{/snippet}
|
||||
{#snippet header()}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_approval">
|
||||
If defined, at the end of the step, the flow will be suspended until it receives external
|
||||
requests to be resumed or canceled. This is most useful to implement approval steps but can
|
||||
be used flexibly for other purposes.
|
||||
</Tooltip>
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={isSuspendEnabled}
|
||||
on:change={() => {
|
||||
if (isSuspendEnabled && flowModule.suspend != undefined) {
|
||||
flowModule.suspend = undefined
|
||||
} else {
|
||||
flowModule.suspend = {
|
||||
required_events: 1,
|
||||
timeout: 1800
|
||||
}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Suspend flow execution until events/approvals received'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={isSuspendEnabled}
|
||||
on:change={() => {
|
||||
if (isSuspendEnabled && flowModule.suspend != undefined) {
|
||||
flowModule.suspend = undefined
|
||||
} else {
|
||||
flowModule.suspend = stepSettingDefaults('suspend')
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Suspend until approval/resume',
|
||||
rightTooltip:
|
||||
'At the end of the step, the flow is suspended until it receives external requests to resume or cancel it. Most useful for approval steps, but can be used flexibly for other purposes.',
|
||||
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_approval'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="overflow-x-auto scrollbar-hidden">
|
||||
<Tabs bind:selected={suspendTabSelected}>
|
||||
<Tab value="core" disabled={!isSuspendEnabled} label="Core" />
|
||||
<Tab value="form" disabled={!isSuspendEnabled} label="Form" />
|
||||
<Tab value="permissions" disabled={!isSuspendEnabled} label="Permissions" />
|
||||
</Tabs>
|
||||
</div>
|
||||
{#if isSuspendEnabled}
|
||||
<div class="flex flex-col gap-3 pl-9" transition:slideDynamic>
|
||||
<div class="overflow-x-auto scrollbar-hidden">
|
||||
<Tabs bind:selected={suspendTabSelected}>
|
||||
<Tab value="core" label="Core" />
|
||||
<Tab value="form" label="Form" />
|
||||
<Tab value="permissions" label="Permissions" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{#if suspendTabSelected === 'core'}
|
||||
<div class="flex flex-col mt-4 gap-4">
|
||||
<Label label="Number of approvals/events required for resuming flow">
|
||||
{#if flowModule.suspend}
|
||||
<input
|
||||
bind:value={flowModule.suspend.required_events}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</Label>
|
||||
<Label label="Timeout">
|
||||
{#if flowModule.suspend}
|
||||
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
{/if}
|
||||
</Label>
|
||||
{#if suspendTabSelected === 'core'}
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label label="Number of approvals/events required for resuming flow">
|
||||
{#if flowModule.suspend}
|
||||
<input
|
||||
bind:value={flowModule.suspend.required_events}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
/>
|
||||
{:else}
|
||||
<input type="number" disabled />
|
||||
{/if}
|
||||
</Label>
|
||||
<Label label="Timeout">
|
||||
{#if flowModule.suspend}
|
||||
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
{/if}
|
||||
</Label>
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
right: 'Continue on disapproval/timeout',
|
||||
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
options={{
|
||||
right: 'Continue on disapproval/timeout',
|
||||
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
|
||||
If any disapproval/timeout event is received, the resume payload will be similar to every error result in Windmill, an object containing an "error" field which you can use
|
||||
to distinguish between approvals and disapproval/timeouts.
|
||||
|
||||
We recommend using the expr "resume?.error" to handle null payload values.
|
||||
To filter timeout, use "resume?.error?.name === "SuspendedTimedOut"
|
||||
To filter disapproval, use "resume?.error?.name === "SuspendedDisapproved"`
|
||||
}}
|
||||
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
|
||||
disabled={!Boolean(flowModule.suspend)}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.continue_on_disapprove_timeout = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
|
||||
<Alert type="info" title="Continue on disapproval/timeout">
|
||||
We recommend using the expr <code>resume?.error</code> to handle null payload values.
|
||||
<br />
|
||||
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>. <br />
|
||||
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if suspendTabSelected === 'permissions'}
|
||||
<div class="flex flex-col mt-4 gap-4">
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Editing permissions is only available in enterprise version" />
|
||||
{/if}
|
||||
{#if flowModule.suspend}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled={emptyString($enterpriseLicense)}
|
||||
checked={Boolean(flowModule.suspend.user_auth_required)}
|
||||
options={{
|
||||
right: 'Require approvers to be logged in'
|
||||
}}
|
||||
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
|
||||
disabled={!Boolean(flowModule.suspend)}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.user_auth_required = e.detail
|
||||
if (e.detail && flowModule.suspend?.user_groups_required === undefined) {
|
||||
flowModule.suspend.user_groups_required = {
|
||||
type: 'static',
|
||||
value: []
|
||||
flowModule.suspend.continue_on_disapprove_timeout = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
|
||||
<Alert type="info" title="Continue on disapproval/timeout">
|
||||
We recommend using the expr <code>resume?.error</code> to handle null payload values.
|
||||
<br />
|
||||
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>.
|
||||
<br />
|
||||
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if suspendTabSelected === 'permissions'}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
eeOnly
|
||||
disabled={!flowModule.suspend || emptyString($enterpriseLicense)}
|
||||
checked={Boolean(flowModule.suspend?.user_auth_required)}
|
||||
options={{
|
||||
right: 'Require approvers to be logged in'
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.user_auth_required = e.detail
|
||||
if (e.detail && flowModule.suspend?.user_groups_required === undefined) {
|
||||
flowModule.suspend.user_groups_required = {
|
||||
type: 'static',
|
||||
value: []
|
||||
}
|
||||
} else if (!e.detail) {
|
||||
flowModule.suspend.user_groups_required = undefined
|
||||
flowModule.suspend.self_approval_disabled = false
|
||||
}
|
||||
} else if (!e.detail) {
|
||||
flowModule.suspend.user_groups_required = undefined
|
||||
flowModule.suspend.self_approval_disabled = false
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
right: 'Disable self-approval',
|
||||
rightTooltip: 'The user who triggered the flow will not be allowed to approve it'
|
||||
}}
|
||||
checked={Boolean(flowModule.suspend.self_approval_disabled)}
|
||||
disabled={!Boolean(flowModule.suspend.user_auth_required)}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.self_approval_disabled = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
eeOnly
|
||||
options={{
|
||||
right: 'Disable self-approval',
|
||||
rightTooltip: 'The user who triggered the flow will not be allowed to approve it'
|
||||
}}
|
||||
checked={Boolean(flowModule.suspend?.self_approval_disabled)}
|
||||
disabled={!flowModule.suspend || !Boolean(flowModule.suspend?.user_auth_required)}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.self_approval_disabled = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-4"></div>
|
||||
<div class="mb-4"></div>
|
||||
|
||||
{#if Boolean(flowModule.suspend.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']}
|
||||
<span class="text-xs font-bold"
|
||||
>Require approvers to be members of one of the following user groups (leave empty for
|
||||
any)
|
||||
</span>
|
||||
<div class="border">
|
||||
{#if Boolean(flowModule.suspend?.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']}
|
||||
<span class="text-xs font-bold"
|
||||
>Require approvers to be members of one of the following user groups (leave empty
|
||||
for any)
|
||||
</span>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
{result}
|
||||
noFlowPlugConnect
|
||||
displayContext={false}
|
||||
pickableProperties={undefined}
|
||||
on:select={({ detail }) => {
|
||||
@@ -231,75 +237,79 @@
|
||||
bind:editor
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if flowModule?.suspend?.resume_form}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={openFormEditor}
|
||||
>
|
||||
Edit form
|
||||
</Button>
|
||||
</div>
|
||||
<!-- The approval page renders the form with the same component; here it only
|
||||
shows what approvers will see, and editing happens in the drawer. -->
|
||||
<div class="rounded-md border p-2">
|
||||
<SchemaForm
|
||||
schema={flowModule.suspend.resume_form.schema}
|
||||
disabled
|
||||
noVariablePicker
|
||||
/>
|
||||
</div>
|
||||
{:else if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
btnClasses="w-full border-dashed"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={openFormEditor}
|
||||
>
|
||||
Add a form
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<EditableSchemaDrawer
|
||||
bind:this={formEditor}
|
||||
bind:schema={
|
||||
() => flowModule.suspend?.resume_form?.schema ?? draftFormSchema,
|
||||
(v) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.resume_form = { schema: v }
|
||||
}
|
||||
}
|
||||
}
|
||||
drawerOnly
|
||||
/>
|
||||
|
||||
{#if flowModule.suspend?.resume_form}
|
||||
<Toggle
|
||||
textClass="text-xs font-normal text-primary"
|
||||
size="xs"
|
||||
checked={Boolean(flowModule.suspend.hide_cancel)}
|
||||
on:change={(e) => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.hide_cancel = e.detail
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Hide cancel button on approval page'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 mt-4 gap-8">
|
||||
<div class="col-span-2">
|
||||
{#if flowModule?.suspend?.resume_form}
|
||||
<EditableSchemaDrawer bind:schema={flowModule.suspend.resume_form.schema} {jsonView} />
|
||||
{:else if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
|
||||
{:else}
|
||||
<div class="flex flex-col items-end mb-2 w-full">
|
||||
<Toggle
|
||||
checked={false}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON editor',
|
||||
rightTooltip:
|
||||
'Arguments can be edited either using the wizard, or by editing their JSON Schema.'
|
||||
}}
|
||||
lightMode
|
||||
on:change={() => {
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.resume_form = {
|
||||
schema: emptySchema()
|
||||
}
|
||||
}
|
||||
jsonView = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AddProperty
|
||||
on:change={(e) => {
|
||||
jsonView = false
|
||||
if (flowModule.suspend) {
|
||||
flowModule.suspend.resume_form = {
|
||||
schema: e.detail
|
||||
}
|
||||
}
|
||||
}}
|
||||
schema={{}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col gap-4">
|
||||
{#if flowModule.suspend}
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex">
|
||||
<SuspendDrawer text="Default args & Dynamic enums help" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flowModule.suspend}
|
||||
<Toggle
|
||||
bind:checked={flowModule.suspend.hide_cancel}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Hide cancel button on approval page'
|
||||
}}
|
||||
disabled={!Boolean(flowModule?.suspend?.resume_form)}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<SuspendDrawer text="Approval/Prompt helpers" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Alert, SecondsInput } from '../../common'
|
||||
import { Alert } from '../../common'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { getContext } from 'svelte'
|
||||
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
|
||||
interface Props {
|
||||
flowModule: FlowModule
|
||||
@@ -56,15 +54,10 @@
|
||||
let istimeoutEnabled = $derived(Boolean(flowModule.timeout))
|
||||
</script>
|
||||
|
||||
<Section label="Timeout">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
If defined, the custom timeout will be used instead of the instance timeout for the step. The
|
||||
step's timeout cannot be greater than the instance timeout.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="xs"
|
||||
textClass="text-xs font-normal text-primary"
|
||||
checked={istimeoutEnabled}
|
||||
on:change={() => {
|
||||
if (istimeoutEnabled && flowModule.timeout != undefined) {
|
||||
@@ -77,39 +70,37 @@
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'Add a custom timeout for this step'
|
||||
right: 'Custom timeout',
|
||||
rightTooltip:
|
||||
"The custom timeout is used instead of the instance timeout for the step. The step's timeout cannot be greater than the instance timeout."
|
||||
}}
|
||||
/>
|
||||
<Label label="Timeout duration" class="mt-2">
|
||||
{#if flowModule.timeout && schema.properties['timeout']}
|
||||
<div class="border">
|
||||
<PropPickerWrapper
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={flowModule.timeout}
|
||||
argName="timeout"
|
||||
{schema}
|
||||
{previousModuleId}
|
||||
argExtra={{ seconds: true }}
|
||||
bind:editor
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
<SecondsInput disabled />
|
||||
<div class="text-secondary text-sm">OR use a dynamic expression</div>
|
||||
{/if}
|
||||
</Label>
|
||||
{#if flowModule.timeout && schema.properties['timeout']}
|
||||
<div class="pl-9" transition:slideDynamic>
|
||||
<PropPickerWrapper
|
||||
popover={true}
|
||||
flow_input={stepPropPicker.pickableProperties.flow_input}
|
||||
notSelectable
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
on:select={({ detail }) => {
|
||||
editor?.insertAtCursor(detail)
|
||||
editor?.focus()
|
||||
}}
|
||||
>
|
||||
<InputTransformForm
|
||||
bind:arg={flowModule.timeout}
|
||||
argName="timeout"
|
||||
{schema}
|
||||
{previousModuleId}
|
||||
argExtra={{ seconds: true }}
|
||||
bind:editor
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flowModule.timeout && flowModule.timeout.type !== 'static'}
|
||||
<div class="mt-4">
|
||||
<div class="mt-4 pl-9" transition:slideDynamic>
|
||||
<Alert title="Dynamic timeout only used when testing the full flow" type="info">
|
||||
<p class="text-xs">
|
||||
A dynamic timeout expression is evaluated when running the full flow. It is ignored when
|
||||
@@ -118,4 +109,4 @@
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user