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:
hugocasa
2026-08-05 16:13:13 +02:00
172 changed files with 8469 additions and 4648 deletions
+1
View File
@@ -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
View File
@@ -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
+98 -66
View File
@@ -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
View File
@@ -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
View File
@@ -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)?;
+24 -3
View File
@@ -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")]
{
+18 -6
View File
@@ -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);
+1 -1
View File
@@ -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
+12 -23
View File
@@ -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}")))?;
+59 -6
View File
@@ -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);
}
}
}
+25 -38
View File
@@ -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
+4 -4
View File
@@ -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::{
+71 -30
View File
@@ -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()),
),
+13 -17
View File
@@ -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
)
}
+146 -13
View File
@@ -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);
}
}
+4 -15
View File
@@ -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 {
+20
View File
@@ -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
+42
View File
@@ -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
+159 -7
View File
@@ -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()
}
}
+142 -21
View File
@@ -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")
+37
View File
@@ -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)
}
}
+5 -1
View File
@@ -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}")
+2 -1
View File
@@ -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;
+13 -5
View File
@@ -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
+56 -19
View File
@@ -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&hellip;
{: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&hellip;
{: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 () {})
})
+111 -91
View File
@@ -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)
}
}
+2
View File
@@ -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
+18 -2
View File
@@ -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