From 74c418570bb5bf1af54f367cfecae3e8b4ede764 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 16:25:52 +0000 Subject: [PATCH 01/40] fix: forward TLS trust roots to debug sessions and honor INIT_SCRIPT on windmill_extra (#10532) * fix: forward proxy and TLS settings to debugger subprocesses Co-Authored-By: Claude Opus 5 (1M context) * fix: reach uv and the bun debugger with the forwarded network settings Co-Authored-By: Claude Opus 5 (1M context) * fix: map every CA variable spelling onto the one uv reads Co-Authored-By: Claude Opus 5 (1M context) * fix: keep package-index credentials out of debugged user code Co-Authored-By: Claude Opus 5 (1M context) * fix: install debugger dependencies outside the interpreter running user code Co-Authored-By: Claude Opus 5 (1M context) * fix: sandbox and bound the debugger dependency installer Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the installer timeout rationale Co-Authored-By: Claude Opus 5 (1M context) * docs: scope the uv --cert note to the commands prepare-deps runs Co-Authored-By: Claude Opus 5 (1M context) * fix: build the debug venv against the interpreter that runs the script Co-Authored-By: Claude Opus 5 (1M context) * fix: do not start the debuggee for a session that already went away Co-Authored-By: Claude Opus 5 (1M context) * fix: remove the debug script when the session is gone before it starts Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-worker/src/prepare_deps.rs | 59 +++++++++++++++--- debugger/Dockerfile | 1 + debugger/README.md | 14 ++++- debugger/dap_debug_service.ts | 69 ++++++++++----------- debugger/dap_websocket_server_bun.ts | 16 ++++- debugger/env_passthrough.ts | 43 +++++++++++++ docker/DockerfileExtra | 1 + docker/entrypoint-extra.sh | 14 +++++ 8 files changed, 169 insertions(+), 48 deletions(-) create mode 100644 debugger/env_passthrough.ts diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 2e16e565c2..161744af40 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -116,6 +116,11 @@ const p = { pub struct PrepareRequest { pub code: String, pub language: String, + /// Interpreter the caller will run the script with. The venv must be built against it: + /// site-packages is put on that interpreter's sys.path, and a wheel with a compiled + /// extension built for another version is simply invisible there. + #[serde(default)] + pub python_path: Option, } #[derive(Serialize)] @@ -178,6 +183,17 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap { envs } +/// CA bundle for the package index, most specific spelling first. A host behind a TLS-intercepting +/// proxy configures it under whichever name its other tooling uses, and the environment is cleared +/// below, so falling back past `PY_INDEX_CERT` is what makes those hosts work at all. +fn index_ca_bundle() -> Option { + INDEX_CERT + .clone() + .or_else(|| non_empty_env("SSL_CERT_FILE")) + .or_else(|| non_empty_env("REQUESTS_CA_BUNDLE")) + .or_else(|| non_empty_env("CURL_CA_BUNDLE")) +} + /// uv registry arguments, mirroring what the job path passes in `python_executor`. fn uv_registry_args() -> Vec { let mut args: Vec = vec![]; @@ -201,7 +217,7 @@ fn uv_registry_args() -> Vec { } /// Prepare Python dependencies using uv -async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { +async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -> PrepareResponse { // Parse imports from the code let packages = parse_python_imports(code); @@ -242,10 +258,16 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string()); } - if let Some(cert_path) = INDEX_CERT.as_ref() { + if let Some(cert_path) = index_ca_bundle() { // uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle - // reaches it through SSL_CERT_FILE, as in the job path. - common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string()); + // reaches it through SSL_CERT_FILE, as in the job path. It replaces uv's own roots rather + // than adding to them, so the file has to be a complete bundle. + common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path); + } + // The other spelling uv accepts. Like the bundle above it replaces uv's own roots rather than + // adding to them, so a directory holding only a private CA leaves public indexes untrusted. + if let Some(cert_dir) = non_empty_env("SSL_CERT_DIR") { + common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir); } let registry_args = uv_registry_args(); @@ -254,6 +276,13 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry // arguments: on a network that only reaches a private mirror it fails without them. let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()]; + // Without this uv picks its own interpreter, and the caller then puts a site-packages built + // for that version on a different interpreter's sys.path: pure-Python packages still import, + // anything with a compiled extension does not, and the error names the missing extension + // rather than the mismatch. + if let Some(python_path) = python_path { + venv_args.extend(["-p".to_string(), python_path.to_string()]); + } venv_args.extend(registry_args.iter().cloned()); let output = Command::new(UV_PATH.as_str()) @@ -375,16 +404,25 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { /// Get common environment variables for Bun processes pub fn get_simple_bun_proc_envs() -> HashMap { - get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR))) + let mut envs = get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR))); + // Bun reads none of the spellings uv does, so a custom CA reaches `bun install` only here. + if let Some(cert_path) = non_empty_env("NODE_EXTRA_CA_CERTS").or_else(index_ca_bundle) { + envs.insert("NODE_EXTRA_CA_CERTS".to_string(), cert_path); + } + envs } /// Prepare dependencies for a script without requiring database access. /// This is meant to be called from the CLI. -pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareResponse { +pub async fn prepare_deps_standalone( + code: &str, + language: &str, + python_path: Option<&str>, +) -> PrepareResponse { // Route to the appropriate handler based on language match language { "python3" | "python" => { - return prepare_python_deps_standalone(code).await; + return prepare_python_deps_standalone(code, python_path).await; } "bun" | "typescript" | "deno" => { // Continue with JS/TS handling below @@ -649,7 +687,12 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { } }; - let response = prepare_deps_standalone(&request.code, &request.language).await; + let response = prepare_deps_standalone( + &request.code, + &request.language, + request.python_path.as_deref(), + ) + .await; println!("{}", serde_json::to_string(&response)?); Ok(()) diff --git a/debugger/Dockerfile b/debugger/Dockerfile index 51e993746a..4c1eb85c98 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -46,6 +46,7 @@ WORKDIR /app # Copy the debug service files COPY dap_debug_service.ts . COPY dap_websocket_server_bun.ts . +COPY env_passthrough.ts . COPY dap_websocket_server.py . # Expose the default port diff --git a/debugger/README.md b/debugger/README.md index 51c2838f92..614d2bf65e 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -94,7 +94,8 @@ with the other settings): | `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI | | `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - | | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | -| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - | +| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | +| `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | | `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | | `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | @@ -110,6 +111,17 @@ service into each session, since the debugged script needs them for its own outb as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied. +Trust roots are forwarded alongside them: `SSL_CERT_FILE`, `SSL_CERT_DIR`, `REQUESTS_CA_BUNDLE`, +`CURL_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`. Behind a TLS-intercepting proxy these are what let the +debugged script's own HTTPS calls verify, and installing the CA in the container's system store is +not enough on its own, since `requests` carries its own bundle and Node reads only +`NODE_EXTRA_CA_CERTS`. Registry settings are deliberately not forwarded: they carry credentials and +only the service needs them. + +To install that CA into the container's system store in the first place, set `INIT_SCRIPT` on the +`windmill_extra` container (e.g. `INIT_SCRIPT=update-ca-certificates`). It runs before any service +starts and aborts startup if it fails, the same hook a worker offers. + 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 diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 405cf15fde..6da3f0b1b0 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -41,6 +41,7 @@ import { join } from 'node:path' // Import the working Bun debug session from the standalone server import { DebugSession as BunDebugSessionWorking, type NsjailConfig } from './dap_websocket_server_bun' +import { sessionEnv } from './env_passthrough' // ============================================================================ // Configuration @@ -352,45 +353,12 @@ 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 { - const env: Record = {} - 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. @@ -551,6 +519,8 @@ class PythonDebugSession extends BaseDebugSession { private envVars: Record = {} private windmillPath?: string private venvPath?: string + private prepareDepsProcess: Subprocess | null = null + private disposed = false private debugMode: boolean constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { @@ -708,10 +678,16 @@ class PythonDebugSession extends BaseDebugSession { try { const proc = spawn({ cmd: [this.windmillPath, 'prepare-deps'], - stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']), + // The venv has to be built against the interpreter that will run the script: its + // site-packages goes on that interpreter's sys.path, and uv otherwise picks its + // own, which silently leaves compiled extensions unimportable. + stdin: new Blob([ + JSON.stringify({ code, language: 'python3', python_path: config.pythonPath }) + '\n' + ]), stdout: 'pipe', stderr: 'pipe' }) + this.prepareDepsProcess = proc // The launch response is already sent, so an install that never returns would leave the // client waiting on a session that never starts, with nothing on screen. The deadline @@ -729,9 +705,12 @@ class PythonDebugSession extends BaseDebugSession { }) ]) clearTimeout(timer) + this.prepareDepsProcess = null if (!result) { - proc.kill() + // SIGKILL, not the default SIGTERM: prepare-deps does not act on SIGTERM while uv + // is running, so a polite signal leaves it running after the session gave up. + proc.kill('SIGKILL') return warn( `dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` ) @@ -797,7 +776,7 @@ class PythonDebugSession extends BaseDebugSession { this.process = spawnProcess({ cmd, cwd, - env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars } + env: { PYTHONUNBUFFERED: '1', ...sessionEnv(), ...this.envVars } }) // Read stderr to capture startup messages @@ -1133,6 +1112,16 @@ sys.stdout.flush() this.venvPath = (await this.prepareDependencies(code)) ?? undefined } + // Installing takes long enough for the client to give up meanwhile, and cleanup() has + // then already run: starting the debuggee now would leave a process nothing owns + // executing the script for a session that is gone. Clean up again on the way out, + // since a teardown that landed before the script was written left it behind. + if (this.disposed) { + logger.info('Session torn down during dependency preparation, not starting Python') + await this.cleanup() + return + } + await this.startPythonProcess(cwd) // Re-apply breakpoints to the Python server using the actual script path @@ -1188,6 +1177,14 @@ sys.stdout.flush() } async cleanup(): Promise { + this.disposed = true + + // A client that gives up mid-install must not leave the package manager running + if (this.prepareDepsProcess) { + this.prepareDepsProcess.kill('SIGKILL') + this.prepareDepsProcess = null + } + if (this.debugpyWs) { this.debugpyWs.close() this.debugpyWs = null diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index ff060f3da6..8336c2f812 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -25,6 +25,7 @@ import { spawn, type Subprocess } from 'bun' import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { sessionEnv } from './env_passthrough' // Types for V8 Inspector Protocol interface V8Message { @@ -1599,7 +1600,10 @@ export class DebugSession { const input = JSON.stringify({ code, language }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) - // Spawn the windmill binary with prepare-deps command + // Spawn the windmill binary with prepare-deps command. This runs in the service + // process, so inheriting its environment is what gives prepare-deps the container's + // index and certificate settings; the allowlist above is what keeps them from the + // debugged script. const proc = spawn({ cmd: [this.windmillPath, 'prepare-deps'], stdin: new Blob([input]), // Use Blob for complete stdin data @@ -1612,7 +1616,9 @@ export class DebugSession { killTimer = setTimeout(() => { timedOut = true logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`) - proc.kill() + // SIGKILL, not the default SIGTERM: prepare-deps does not act on SIGTERM while the + // package manager is running, so a polite signal leaves it going after the timeout. + proc.kill('SIGKILL') }, PREPARE_DEPS_TIMEOUT_MS) // Wait for completion @@ -1735,12 +1741,16 @@ export class DebugSession { }, 10000) }) - // Only include essential env vars + client-provided ones + // Only include essential env vars + the network-config allowlist + client-provided ones. // Don't inherit all of process.env to keep debugger environment clean const envVars: Record = { // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, + // Proxy / TLS settings inherited from the container, before the client's env so an + // explicit override still wins. Package-index settings are deliberately absent: this + // runs user-supplied code and index URLs carry registry credentials. + ...sessionEnv(), // Client-provided env vars (WM_WORKSPACE, WM_TOKEN, etc.) // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set ...this.envVars diff --git a/debugger/env_passthrough.ts b/debugger/env_passthrough.ts new file mode 100644 index 0000000000..f713483d05 --- /dev/null +++ b/debugger/env_passthrough.ts @@ -0,0 +1,43 @@ +/** + * Container network configuration forwarded to a debug session, matching what a worker gives a + * job's script. The session environment is built from an allowlist rather than inherited, so an + * outbound proxy or a private CA is unreachable from a session unless these are passed + * explicitly. Registry settings are deliberately absent: they carry credentials and are consumed + * by the service itself (see PythonDebugSession.prepareDependencies). + * + * Lives in its own module because both session kinds build their own environment, and + * dap_debug_service.ts already imports from dap_websocket_server_bun.ts. + */ +export const SESSION_ENV_VARS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + // The lowercase spellings take precedence in the worker, so forward both. + 'http_proxy', + 'https_proxy', + 'no_proxy', + // Trust roots for a TLS-intercepting proxy. Installing the CA in the container's system + // store is not enough on its own: requests carries its own bundle and Node reads only + // NODE_EXTRA_CA_CERTS, so a debugged script's own HTTPS calls fail without these. + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', + 'NODE_EXTRA_CA_CERTS' +] + +export function sessionEnv(): Record { + const env: Record = {} + for (const key of SESSION_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + // A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it; + // the worker defaults the same way (PROXY_ENVS in windmill-worker). + if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) { + env.NO_PROXY = 'localhost,127.0.0.1' + } + return env +} diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index 808bc6ed2a..a0b4986102 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -94,6 +94,7 @@ WORKDIR /debugger # Copy debugger files COPY debugger/dap_debug_service.ts . COPY debugger/dap_websocket_server_bun.ts . +COPY debugger/env_passthrough.ts . COPY debugger/dap_websocket_server.py . COPY debugger/nsjail.debug.config.proto . diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index a06901e25c..e5cc4e61fc 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -33,6 +33,20 @@ if [ ! -w "$HOME" ]; then fi export HOME +# INIT_SCRIPT is the documented hook for preparing the host before anything reaches the network +# (CA certificates, proxies, mounts), matching the worker's INIT_SCRIPT. It must therefore complete +# before any service starts, and a failure has to abort: services that come up with an unprepared +# trust store fail every TLS handshake instead, which is far harder to diagnose. +if [ -n "$INIT_SCRIPT" ]; then + echo "[entrypoint] Running INIT_SCRIPT..." + bash -c "$INIT_SCRIPT" || { + code=$? + echo "[entrypoint] ERROR: INIT_SCRIPT failed with exit code $code, aborting" >&2 + exit "$code" + } + echo "[entrypoint] INIT_SCRIPT completed" +fi + # Setup NETRC if provided (for LSP) if [ -n "$NETRC" ]; then echo "$NETRC" > "$HOME/.netrc" From 0e42381df086d4463e5914d8a194fe5141793bd1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 19:05:26 +0000 Subject: [PATCH 02/40] fix(triggers): stop one failing trigger count from zeroing the rest (#10549) Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-api/src/triggers/handler.rs | 34 ++++++++++++-------- backend/windmill-trigger/src/handler.rs | 12 ++++++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index d86912d8d0..235e178609 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -187,14 +187,18 @@ pub async fn get_triggers_count_internal( .await? .unwrap_or(0); + // These counts are independent reads: they share a connection to avoid one acquire + // per trigger kind, but must not share a transaction. A single failing count would + // abort it and, since `trigger_count` falls back to 0 on error, silently zero every + // count after it. #[allow(unused)] - let mut tx = db.begin().await?; + let mut conn = db.acquire().await?; #[cfg(feature = "http_trigger")] let http_routes_count = { use crate::triggers::http::HttpTrigger; let count = HttpTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -205,7 +209,7 @@ pub async fn get_triggers_count_internal( let websocket_count = { use crate::triggers::websocket::WebsocketTrigger; let count = WebsocketTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -216,7 +220,7 @@ pub async fn get_triggers_count_internal( let kafka_count = { use crate::triggers::kafka::KafkaTrigger; let count = KafkaTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -227,7 +231,7 @@ pub async fn get_triggers_count_internal( let nats_count = { use crate::triggers::nats::NatsTrigger; let count = NatsTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -238,7 +242,7 @@ pub async fn get_triggers_count_internal( let postgres_count = { use crate::triggers::postgres::PostgresTrigger; let count = PostgresTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -249,7 +253,7 @@ pub async fn get_triggers_count_internal( let mqtt_count = { use crate::triggers::mqtt::MqttTrigger; let count = MqttTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -260,7 +264,7 @@ pub async fn get_triggers_count_internal( let amqp_count = { use crate::triggers::amqp::AmqpTrigger; let count = AmqpTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -270,7 +274,9 @@ pub async fn get_triggers_count_internal( #[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))] let sqs_count = { use crate::triggers::sqs::SqsTrigger; - let count = SqsTrigger.trigger_count(&mut tx, w_id, is_flow, path).await; + let count = SqsTrigger + .trigger_count(&mut conn, w_id, is_flow, path) + .await; count }; #[cfg(not(all(feature = "sqs_trigger", feature = "enterprise", feature = "private")))] @@ -279,7 +285,9 @@ pub async fn get_triggers_count_internal( #[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))] let gcp_count = { use crate::triggers::gcp::GcpTrigger; - let count = GcpTrigger.trigger_count(&mut tx, w_id, is_flow, path).await; + let count = GcpTrigger + .trigger_count(&mut conn, w_id, is_flow, path) + .await; count }; #[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))] @@ -289,7 +297,7 @@ pub async fn get_triggers_count_internal( let azure_count = { use crate::triggers::azure::AzureTrigger; let count = AzureTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -300,14 +308,14 @@ pub async fn get_triggers_count_internal( let email_count = { use crate::triggers::email::EmailTrigger; let count = EmailTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; #[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))] let email_count = 0; - tx.commit().await?; + drop(conn); let webhook_count = (if is_flow { sqlx::query_scalar!( diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 0c46ff25fd..ad3a6b097a 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -361,7 +361,17 @@ pub trait TriggerCrud: Send + Sync + 'static { .bind(script_path) .fetch_one(&mut *tx) .await - .unwrap_or(0); + // Falling back to 0 keeps one unreadable table from failing the whole count + // endpoint, but the cause must still reach the logs: a silent 0 is + // indistinguishable from "no triggers" in the UI. + .unwrap_or_else(|err| { + tracing::error!( + "failed to count {} triggers of {} {script_path} in {workspace_id}: {err:#}", + Self::TABLE_NAME, + if is_flow { "flow" } else { "script" } + ); + 0 + }); count } From a3b79d7732deba54fd6344229d5b91a80a3e0dd6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 19:07:00 +0000 Subject: [PATCH 03/40] feat: add a load all to the tree view's per-folder pager (#10548) * feat: add a load all next to load more in the tree view folder pager * fix: bound tree node rendering and make a long load resumable * fix: resume a failed first load from its saved cursor * fix: size the show-more step by what a node holds, not what it renders * fix: keep the pager visible mid-run and spin only the clicked button --- .../src/lib/components/home/ItemsList.svelte | 44 ++++++++-- .../src/lib/components/home/TreeView.svelte | 82 ++++++++++++++----- .../lib/components/home/TreeViewRoot.svelte | 2 +- 3 files changed, 99 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 52d53242be..a1369cdc68 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -371,6 +371,10 @@ // How far one "Load more" will page past rows it already has before giving up and // leaving the rest to another click (see the catch-up loop in loadOwnerItems). const OWNER_CATCH_UP_PAGES = 5 + // Ceiling on the pages one "Load all" issues. It exists so a prefix that never stops + // handing back a cursor can't spin forever; hitting it leaves `hasMore` set, so the + // footer stays and another click resumes where this one stopped. + const OWNER_LOAD_ALL_PAGES = 100 let ownerLoad = $state>({}) let treeOwnerItems = $state([]) let treeGen = 0 @@ -391,8 +395,15 @@ // `owner` is the full prefix a node covers: `f/`, `u/`, or any // folder under one. `force` re-fetches its first page even if already loaded (a - // re-sort / re-filter reload uses it to refresh the loaded rows in place). - async function loadOwnerItems(owner: string, more = false, force = false): Promise { + // re-sort / re-filter reload uses it to refresh the loaded rows in place); `all` + // keeps paging until the prefix's stream is exhausted instead of stopping at a page. + async function loadOwnerItems( + owner: string, + more = false, + opts?: { force?: boolean; all?: boolean } + ): Promise { + const force = opts?.force ?? false + const all = opts?.all ?? false const ws = $workspaceStore if (!ws || !$userStore) return // Track the prefix as open first — even a no-op call (re-expanding a cached node) @@ -448,8 +459,10 @@ // A nested prefix's own stream restarts at its first row, which an ancestor's pages // may already have brought in — that page then adds nothing and the click would read // as broken. Keep paging until one adds something or the stream ends, bounded so a - // single click can't turn into an unbounded fetch loop. - for (let page = 0; page < OWNER_CATCH_UP_PAGES; page++) { + // single click can't turn into an unbounded fetch loop. "Load all" instead stops + // only at the end of the stream, so `loading` stays set for the whole run and the + // footer resolves to an exact count in one click. + for (let page = 0; page < (all ? OWNER_LOAD_ALL_PAGES : OWNER_CATCH_UP_PAGES); page++) { let res: { items: RunnableItem[]; next_cursor?: string } try { res = await ScriptService.listRunnables({ @@ -466,7 +479,19 @@ }) } catch (e: any) { if (gen !== treeGen) return - ownerLoad[owner] = { ...ownerLoad[owner], loading: false } + // Keep the cursor the pages that did land reached, so the next click resumes + // instead of re-reading pages that now dedup to nothing. `loaded` moves with + // it: a node still marked unloaded is retried as a first load, which starts + // from no cursor and throws the saved one away. + const prev = ownerLoad[owner] + const advanced = nextCursor != undefined + ownerLoad[owner] = { + ...prev, + cursor: nextCursor ?? prev?.cursor, + hasMore: advanced || (prev?.hasMore ?? false), + loaded: advanced || (prev?.loaded ?? false), + loading: false + } sendUserToast(`Failed to load ${owner}: ${e?.body ?? e?.message ?? e}`, true) return } @@ -477,7 +502,10 @@ const added = mergePage(res.items) nextCursor = res.next_cursor cursor = nextCursor - if (added > 0 || nextCursor == undefined) break + // Collapsing the node is the only way to stop a run that spans many pages; + // without this it would keep paging a folder that is no longer on screen. What + // it reached is committed below, so its footer resumes from there. + if (nextCursor == undefined || !openOwners.has(owner) || (!all && added > 0)) break } ownerLoad = Object.fromEntries([ // A replacing load dropped every row under this prefix, so a nested folder that @@ -534,7 +562,9 @@ byDepth.set(d, [...(byDepth.get(d) ?? []), p]) } for (const d of [...byDepth.keys()].sort((a, b) => a - b)) { - await Promise.all((byDepth.get(d) ?? []).map((p) => loadOwnerItems(p, false, true))) + await Promise.all( + (byDepth.get(d) ?? []).map((p) => loadOwnerItems(p, false, { force: true })) + ) } } diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index cf34998bf2..e688c5b1a2 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -28,7 +28,8 @@ string, { cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean } > - onExpandOwner?: (prefix: string, more?: boolean) => void + // `all` pages the prefix to the end in one call instead of fetching a single page. + onExpandOwner?: (prefix: string, more?: boolean, opts?: { all?: boolean }) => void onCollapseOwner?: (prefix: string) => void // Position of this node among the rendered root nodes; "expand all" only // auto-loads the first EXPAND_ALL_LOAD_LIMIT of them (see the effect below). @@ -144,6 +145,10 @@ // so a subfolder's contents don't look truncated, but bounded: under "expand all" // this applies to every open owner at once (see effectiveMax). let showMax = $state(30) + // Ceiling on what one node mounts at once. Comfortably past a server page, so the + // usual node still shows everything it loaded, but "Load all" can leave thousands of + // rows under one prefix and mounting all of them locks up the tab. + const LAZY_RENDER_MAX = 500 // A node that paginates server-side ("Load more") shows all its already-loaded rows // when opened on its own, so there is one control to reach the rest and not a second, // confusing client "Show more" in front of it. EXCEPT under "expand all" @@ -151,12 +156,25 @@ // would be thousands of rows and freeze the tab — so there we cap to the client slice // and let "Show more" reveal the rest per node. let effectiveMax = $derived( - ownerLoad != undefined && nodePrefix != undefined && (isFolder(item) || isUser(item)) - ? collapseAll - ? item.items.length + isFolder(item) || isUser(item) + ? ownerLoad != undefined && nodePrefix != undefined && collapseAll + ? Math.min(item.items.length, Math.max(showMax, LAZY_RENDER_MAX)) : Math.min(item.items.length, showMax) : showMax ) + // Which of the two footer buttons started the run in flight, so only that one spins: a + // "Load all" can take minutes where a "Load more" takes one request. + let loadingAll = $state(false) + $effect(() => { + if (!nodeState?.loading) loadingAll = false + }) + // One "Show more" reveals a slice the size of the ceiling once a node holds more than + // that, so thousands of loaded rows don't take hundreds of clicks to unfold. Keyed off + // what the node holds rather than what it renders: under "expand all" only the small + // client slice is on screen however many rows arrived. + let showMoreStep = $derived( + (isFolder(item) || isUser(item)) && item.items.length >= LAZY_RENDER_MAX ? LAZY_RENDER_MAX : 30 + ) $effect(() => { const expandAll = !collapseAll @@ -329,7 +347,10 @@ unifiedSize="sm" variant="subtle" on:click={() => { - showMax += Math.min(30, item.items.length - showMax) + // Grown from what is rendered, not from showMax: the lazy ceiling can + // already be showing more than showMax, and stepping that would take + // several clicks to change anything on screen. + showMax = Math.min(item.items.length, effectiveMax + showMoreStep) }} > Show more @@ -342,13 +363,14 @@ re-sort/re-filter re-fetch keeps the old rows visible and swaps them in place, so flashing "Loading…" under them would just be noise. -->
Loading…
- {:else if nodeHasMore && effectiveMax >= item.items.length} + {:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)} + doesn't mean paging everything its owner holds. Under "expand all" this + waits for the client "Show more" above, so the two pagers don't stack + under every open node at once — but never while loading, or a long run + would unmount its own spinner on its first page. Spelling out the counts + is the point: without them this reads as an optional extra rather than + as rows still missing. -->
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix} - +
+ + + +
{/if} {/if} diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 1b6a351cfd..39c2188e59 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -35,7 +35,7 @@ string, { cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean } > - onExpandOwner?: (owner: string, more?: boolean) => void + onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void onCollapseOwner?: (owner: string) => void showEditButton?: boolean } From e203ab087a4ce885a1d424267f96db861207a0e6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 19:07:12 +0000 Subject: [PATCH 04/40] feat: allow a dev workspace to have its own dev workspace (#10534) * feat: allow a dev workspace to have its own dev workspace Fixes WIN-2324 Co-Authored-By: Claude Opus 5 (1M context) * fix: keep every dev workspace in a chain on a distinct deploy branch Co-Authored-By: Claude Opus 5 (1M context) * fix: count a dev workspace the caller has no seat in as holding its label Co-Authored-By: Claude Opus 5 (1M context) * refactor: keep the attach form standing when a candidate takes the last label Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the label toggle visible when a candidate's dev workspace clashes Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the cycle guard by what holds, not by what changed Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse to archive a fork-backed dev workspace that owns a nested dev Co-Authored-By: Claude Opus 5 (1M context) * fix: put the deploy target and item filters under the pairing they configure Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse to archive any dev workspace that owns a nested dev Co-Authored-By: Claude Opus 5 (1M context) * docs: fix the fixture family count Co-Authored-By: Claude Opus 5 (1M context) * fix: put the deploy target with the pairing line it restates, above protections Co-Authored-By: Claude Opus 5 (1M context) * fix: name the same family head in the workspace menu and the scope picker Co-Authored-By: Claude Opus 5 (1M context) * fix: stop offering to delete a dev workspace from the sidebar settings menu Co-Authored-By: Claude Opus 5 (1M context) * docs: state the visibility boundary the lineage root actually resolves to Co-Authored-By: Claude Opus 5 (1M context) * fix: serialize dev-pairing creation against teardown of the same workspace Co-Authored-By: Claude Opus 5 (1M context) * fix: lock both sides of an attach so adjacent pairings cannot share a label Co-Authored-By: Claude Opus 5 (1M context) * fix: serialize dev pairings on one key, the invariant being chain-wide Co-Authored-By: Claude Opus 5 (1M context) * fix: scope the pairing lock to the chains an operation reads Co-Authored-By: Claude Opus 5 (1M context) * fix: hold the pairing lock across renames and re-check the cycle under it Co-Authored-By: Claude Opus 5 (1M context) * fix: hide the fork-delete action until the workspace entry has loaded Co-Authored-By: Claude Opus 5 (1M context) * fix: lock archive before it reads the pairing state it acts on Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the archive lock test by what it pins, and drop an unused fixture row Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...e8c01515be10b2e40292d904d20e7d53bf289.json | 28 + ...5cab2539a701109727f997a3e5a0f4b0f1f22.json | 22 + ...933a2884dbb4ab59161881fdf121ab48f1b35.json | 28 + ...f9171b943e959eb5401a496af850aa686391b.json | 22 + ...50b5db1bf5a11d7a162130ebb826178c4ea08.json | 22 + ...8f2faa8ef6ead3f797a49be9f5b075bad1490.json | 23 + ...07e05b1ab61512e00034982dfd9b4ac3257aa.json | 34 ++ ...65e3f5fe96ff6dee2c6d41798328a3852fe76.json | 22 + .../tests/fixtures/nested_dev_workspace.sql | 76 +++ .../tests/nested_dev_workspace.rs | 434 ++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 429 +++++++++++-- .../src/workspaces_extra.rs | 6 + backend/windmill-common/src/workspaces.rs | 8 +- .../lib/components/DevWorkspaceSetting.svelte | 562 +++++++++++------- .../sidebar/DeleteForkedWorkspaceModal.svelte | 14 +- .../components/sidebar/SettingsMenu.svelte | 16 +- .../components/sidebar/WorkspaceMenu.svelte | 48 +- .../CreateWorkspaceInner.svelte | 94 ++- frontend/src/lib/utils/devWorkspaceLabel.ts | 3 + .../src/lib/utils/workspaceHierarchy.test.ts | 48 +- frontend/src/lib/utils/workspaceHierarchy.ts | 48 ++ .../(logged)/workspace_settings/+page.svelte | 84 +-- 22 files changed, 1723 insertions(+), 348 deletions(-) create mode 100644 backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json create mode 100644 backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json create mode 100644 backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json create mode 100644 backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json create mode 100644 backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json create mode 100644 backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json create mode 100644 backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json create mode 100644 backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json create mode 100644 backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql create mode 100644 backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs diff --git a/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json b/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json new file mode 100644 index 0000000000..c436cb3ed8 --- /dev/null +++ b/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE tree AS (\n SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,\n tree.depth + 1\n FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM tree\n WHERE depth > 0 AND is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289" +} diff --git a/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json b/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json new file mode 100644 index 0000000000..25bfaf16d3 --- /dev/null +++ b/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22" +} diff --git a/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json b/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json new file mode 100644 index 0000000000..c1df08d9b0 --- /dev/null +++ b/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE ancestors AS (\n SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted,\n 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,\n w.deleted, ancestors.depth + 1\n FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id\n WHERE ancestors.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM ancestors\n WHERE is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35" +} diff --git a/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json b/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json new file mode 100644 index 0000000000..75871561e9 --- /dev/null +++ b/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b" +} diff --git a/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json b/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json new file mode 100644 index 0000000000..f6598ded42 --- /dev/null +++ b/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM workspace\n WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08" +} diff --git a/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json b/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json new file mode 100644 index 0000000000..b825e2146e --- /dev/null +++ b/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS \"cycle!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "cycle!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490" +} diff --git a/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json b/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json new file mode 100644 index 0000000000..a698761d8a --- /dev/null +++ b/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (parent_workspace_id IS NOT NULL) AS \"is_fork!\", is_dev_workspace, deleted\n FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_fork!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + false, + false + ] + }, + "hash": "e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa" +} diff --git a/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json b/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json new file mode 100644 index 0000000000..ac9b645b01 --- /dev/null +++ b/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id),\n up AS (\n SELECT w.id, w.parent_workspace_id, 0 AS depth\n FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, w.parent_workspace_id, up.depth + 1\n FROM workspace w JOIN up ON w.id = up.parent_workspace_id\n WHERE up.depth < 20\n ),\n down AS (\n SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, down.depth + 1\n FROM workspace w JOIN down ON w.parent_workspace_id = down.id\n WHERE down.depth < 20 AND w.is_dev_workspace\n )\n SELECT id AS \"id!\" FROM (\n SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down\n ) n ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76" +} diff --git a/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql b/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql new file mode 100644 index 0000000000..76f266bb0f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql @@ -0,0 +1,76 @@ +-- Seven families for the nested dev-workspace (dev of a dev) guards. +-- +-- Family A is rooted at `test-workspace` (base fixture) and is the one a nested dev is attached to: +-- test-workspace -> tw-dev ('dev') +-- plus two standalone attach candidates, one of which already owns a 'dev'-labelled dev workspace. +-- +-- Family B carries a `wm-fork-` workspace re-designated as a dev workspace, which is the shape that +-- returns to being a throwaway fork on detach: +-- prod-b -> wm-fork-redev ('dev') -> redev-dev ('staging') +-- +-- Family C is the ordinary prefix-less nesting. Detaching its middle workspace is fine (it returns +-- to standalone and goes on hosting `c-dev-dev`), but archiving it is not: +-- prod-c -> c-dev ('dev') -> c-dev-dev ('staging') +-- +-- Family E has no nested dev yet, so both "give `wm-fork-edev` a dev" and "detach `wm-fork-edev`" +-- pass their own checks — the pair that must not both commit: +-- prod-e -> wm-fork-edev ('dev'), plus the standalone candidate `e-cand` +-- +-- Family F is three standalone workspaces, so "attach f-mid under prod-f" and "attach f-leaf under +-- f-mid" both pass on their own — adjacent attaches whose labels only collide once both land: +-- prod-f, f-mid, f-leaf +-- +-- Family G already nests, so two attaches at opposite ends of it touch no workspace in common — +-- their labels only collide once both land, three dev workspaces deep: +-- prod-g (root), g-mid -> g-sub ('dev'), and the standalone `g-leaf` +-- +-- Family H is a standalone that already owns a dev: archiving it resolves as "no pairing involved" +-- while still being an operation the pairing lock has to cover: +-- h-cand -> h-sub ('dev') + + +INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) VALUES + ('tw-dev', 'dev of test-workspace', 'test@windmill.dev', 'test-workspace', true, 'dev'), + ('standalone', 'standalone', 'test@windmill.dev', NULL, false, NULL), + ('standalone-dev', 'dev of standalone', 'test@windmill.dev', 'standalone', true, 'dev'), + ('spare', 'spare standalone', 'test@windmill.dev', NULL, false, NULL), + ('prod-b', 'prod b', 'test@windmill.dev', NULL, false, NULL), + ('wm-fork-redev', 'redesignated fork', 'test@windmill.dev', 'prod-b', true, 'dev'), + ('redev-dev', 'dev of the redesignated fork', 'test@windmill.dev', 'wm-fork-redev', true, 'staging'), + ('prod-c', 'prod c', 'test@windmill.dev', NULL, false, NULL), + ('c-dev', 'dev of prod-c', 'test@windmill.dev', 'prod-c', true, 'dev'), + ('c-dev-dev', 'dev of c-dev', 'test@windmill.dev', 'c-dev', true, 'staging'), + ('prod-e', 'prod e', 'test@windmill.dev', NULL, false, NULL), + ('wm-fork-edev', 'redesignated fork with no dev yet', 'test@windmill.dev', 'prod-e', true, 'dev'), + ('e-cand', 'attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('prod-f', 'prod f', 'test@windmill.dev', NULL, false, NULL), + ('f-mid', 'middle attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('f-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('prod-g', 'prod g', 'test@windmill.dev', NULL, false, NULL), + ('g-mid', 'standalone with a dev of its own', 'test@windmill.dev', NULL, false, NULL), + ('g-sub', 'dev of g-mid', 'test@windmill.dev', 'g-mid', true, 'dev'), + ('g-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('h-cand', 'standalone owning a dev', 'test@windmill.dev', NULL, false, NULL), + ('h-sub', 'dev of h-cand', 'test@windmill.dev', 'h-cand', true, 'dev'); + +CREATE TEMP VIEW new_workspaces AS SELECT unnest(ARRAY[ + 'tw-dev', 'standalone', 'standalone-dev', 'spare', 'prod-b', 'wm-fork-redev', 'redev-dev', + 'prod-c', 'c-dev', 'c-dev-dev', 'prod-e', 'wm-fork-edev', 'e-cand', + 'prod-f', 'f-mid', 'f-leaf', 'prod-g', 'g-mid', 'g-sub', 'g-leaf', + 'h-cand', 'h-sub' +]) AS id; + +INSERT INTO workspace_settings (workspace_id) + SELECT id FROM new_workspaces; + +INSERT INTO workspace_key (workspace_id, kind, key) + SELECT id, 'cloud', 'test-key' FROM new_workspaces; + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) + SELECT id, 'all', 'All users', '{}' FROM new_workspaces; + +INSERT INTO usr (workspace_id, email, username, is_admin, role) + SELECT id, 'test@windmill.dev', 'test-user', true, 'Admin' + FROM new_workspaces; + +DROP VIEW new_workspaces; diff --git a/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs new file mode 100644 index 0000000000..d8c3d55f0f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs @@ -0,0 +1,434 @@ +//! A dev workspace may itself be paired with one (a "dev of a dev"). The guards that keep that +//! shape well-formed are what these tests pin: a chain must stay acyclic, and no two dev workspaces +//! in it may carry the same environment label — they inherit the same git-sync repositories, so an +//! equal label means both deploy to one branch. + +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; + +async fn attach(port: u16, prod: &str, body: serde_json::Value) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{prod}/workspaces/attach_dev_workspace" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .json(&body) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +async fn detach(port: u16, prod: &str, dev: &str) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{prod}/workspaces/detach_dev_workspace" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .json(&json!({ "dev_workspace_id": dev })) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_nested_dev_workspace_attach_guards(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The family root is an ancestor of the dev workspace: reparenting it below would close a + // parent<->child cycle and hang every hierarchy walk. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "test-workspace", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_client_error(), + "cycle attach returned {status}: {body}" + ); + assert!(body.contains("ancestor"), "unexpected error: {body}"); + + // `tw-dev` is itself the 'dev' workspace of the root, so a dev nested under it cannot be one too. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "spare", "dev_workspace_label": "dev" }), + ) + .await; + assert!( + status.is_client_error(), + "label reuse returned {status}: {body}" + ); + assert!(body.contains("tw-dev"), "unexpected error: {body}"); + + // `standalone` brings its own 'dev'-labelled dev workspace into the chain, which collides with + // `tw-dev` whatever label the candidate itself is given. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "standalone", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_client_error(), + "subtree label reuse returned {status}: {body}" + ); + assert!(body.contains("standalone-dev"), "unexpected error: {body}"); + + // With a free label and nothing conflicting underneath, the nested pairing goes through. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "spare", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_success(), + "nested attach returned {status}: {body}" + ); + // Runtime-checked (not `query!`): a macro here would need its own `.sqlx` entry, which + // `cargo sqlx prepare --workspace` does not produce for test targets. + let (parent, is_dev, label): (Option, bool, Option) = sqlx::query_as( + "SELECT parent_workspace_id, is_dev_workspace, dev_workspace_label FROM workspace WHERE id = 'spare'", + ) + .fetch_one(&db) + .await?; + assert_eq!(parent.as_deref(), Some("tw-dev")); + assert!(is_dev); + assert_eq!(label.as_deref(), Some("staging")); + + Ok(()) +} + +async fn archive(port: u16, w_id: &str) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{w_id}/workspaces/archive" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_teardown_refuses_to_strand_a_nested_dev(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // A `wm-fork-` workspace keeps its parent when it stops being a dev workspace, so it returns to + // being a throwaway fork — which hosts no pairing, leaving `redev-dev` attached with no way to + // reach it. Detach and archive both clear the flag, so both have to refuse. + let (status, body) = detach(port, "prod-b", "wm-fork-redev").await; + assert!( + status.is_client_error(), + "stranding detach returned {status}: {body}" + ); + assert!(body.contains("redev-dev"), "unexpected error: {body}"); + + let (status, body) = archive(port, "wm-fork-redev").await; + assert!( + status.is_client_error(), + "stranding archive returned {status}: {body}" + ); + assert!(body.contains("redev-dev"), "unexpected error: {body}"); + + // Archive soft-deletes whatever the id looks like, so a prefix-less dev workspace strands its + // own dev too — even though detaching that same workspace is fine (it returns to standalone). + let (status, body) = archive(port, "c-dev").await; + assert!( + status.is_client_error(), + "prefix-less stranding archive returned {status}: {body}" + ); + assert!(body.contains("c-dev-dev"), "unexpected error: {body}"); + let (status, body) = detach(port, "prod-c", "c-dev").await; + assert!( + status.is_success(), + "prefix-less detach returned {status}: {body}" + ); + + // Bottom-up is the supported order. + let (status, body) = detach(port, "wm-fork-redev", "redev-dev").await; + assert!(status.is_success(), "leaf detach returned {status}: {body}"); + let (status, body) = detach(port, "prod-b", "wm-fork-redev").await; + assert!( + status.is_success(), + "detach after cleanup returned {status}: {body}" + ); + + Ok(()) +} + +/// Giving a workspace a dev and clearing its own dev flag each decide on state the other mutates, so +/// checked outside a common lock both commit and leave `e-cand` under a throwaway fork. Fired +/// together: whichever lands second must see the first and be rejected. +/// +/// Repeated, because how far each handler gets before the other's mutation lands is timing-dependent +/// — one pass caught an unlocked build only about a fifth of the time, and the runs are cheap. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_nested_attach_and_detach_cannot_both_commit( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + // Back to `prod-e -> wm-fork-edev ('dev')` with `e-cand` standalone, the state in which both + // requests pass their own checks. + sqlx::query( + "UPDATE workspace SET parent_workspace_id = 'prod-e', is_dev_workspace = true, + dev_workspace_label = 'dev' WHERE id = 'wm-fork-edev'", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id = 'e-cand'", + ) + .execute(&db) + .await?; + + let (attached, detached) = tokio::join!( + attach( + port, + "wm-fork-edev", + json!({ "dev_workspace_id": "e-cand", "dev_workspace_label": "staging" }), + ), + detach(port, "prod-e", "wm-fork-edev"), + ); + assert!( + attached.0.is_success() != detached.0.is_success(), + "round {round}: exactly one must win, got attach={} detach={}\n{}\n{}", + attached.0, + detached.0, + attached.1, + detached.1 + ); + + // Whichever won, `wm-fork-edev` is never left a throwaway fork with a dev workspace beneath it. + let (is_dev, cand_parent): (bool, Option) = sqlx::query_as( + "SELECT (SELECT is_dev_workspace FROM workspace WHERE id = 'wm-fork-edev'), + (SELECT parent_workspace_id FROM workspace WHERE id = 'e-cand')", + ) + .fetch_one(&db) + .await?; + assert!( + is_dev || cand_parent.is_none(), + "round {round}: stranded — wm-fork-edev is_dev={is_dev}, e-cand parent={cand_parent:?}" + ); + } + Ok(()) +} + +/// Two adjacent attaches — `f-mid` under `prod-f` and `f-leaf` under `f-mid` — each see a chain that +/// does not yet contain the other's dev workspace, so both pass their label check. Committing both +/// puts two `dev` workspaces in one chain, deploying to the same branch. Repeated for the same +/// reason as the detach race above. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_adjacent_attaches_cannot_both_claim_a_label( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id IN ('f-mid', 'f-leaf')", + ) + .execute(&db) + .await?; + + let (upper, lower) = tokio::join!( + attach( + port, + "prod-f", + json!({ "dev_workspace_id": "f-mid", "dev_workspace_label": "dev" }), + ), + attach( + port, + "f-mid", + json!({ "dev_workspace_id": "f-leaf", "dev_workspace_label": "dev" }), + ), + ); + assert!( + upper.0.is_success() != lower.0.is_success(), + "round {round}: exactly one must win, got upper={} lower={}\n{}\n{}", + upper.0, + lower.0, + upper.1, + lower.1 + ); + + // Never both: that is the chain prod-f -> f-mid('dev') -> f-leaf('dev'). + let chained: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM workspace mid + JOIN workspace leaf ON leaf.parent_workspace_id = mid.id + WHERE mid.id = 'f-mid' AND leaf.id = 'f-leaf' + AND mid.is_dev_workspace AND leaf.is_dev_workspace + AND mid.parent_workspace_id = 'prod-f' + )", + ) + .fetch_one(&db) + .await?; + assert!(!chained, "round {round}: both attaches committed"); + } + Ok(()) +} + +/// Attaching `g-mid` under `prod-g` and attaching `g-leaf` under `g-sub` touch no workspace in +/// common — `g-sub` already sits under `g-mid`, so the two operations are two hops apart. Each sees +/// a two-workspace chain with a free label; together they make a four-deep one that repeats +/// `staging`. Locking the endpoints alone leaves them free to both commit, which is why the pairing +/// lock covers every workspace its checks read. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_attaches_two_hops_apart_cannot_both_claim_a_label( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id IN ('g-mid', 'g-leaf')", + ) + .execute(&db) + .await?; + + let (upper, lower) = tokio::join!( + attach( + port, + "prod-g", + json!({ "dev_workspace_id": "g-mid", "dev_workspace_label": "staging" }), + ), + attach( + port, + "g-sub", + json!({ "dev_workspace_id": "g-leaf", "dev_workspace_label": "staging" }), + ), + ); + assert!( + upper.0.is_success() != lower.0.is_success(), + "round {round}: exactly one must win, got upper={} lower={}\n{}\n{}", + upper.0, + lower.0, + upper.1, + lower.1 + ); + + // No chain may carry one label twice. Walk every dev workspace up to its root and count. + let duplicated: Option = sqlx::query_scalar( + "WITH RECURSIVE chain AS ( + SELECT id AS leaf, id, parent_workspace_id, is_dev_workspace, + COALESCE(dev_workspace_label, 'dev') AS label, 0 AS depth + FROM workspace WHERE is_dev_workspace AND NOT deleted + UNION ALL + SELECT c.leaf, w.id, w.parent_workspace_id, w.is_dev_workspace, + COALESCE(w.dev_workspace_label, 'dev'), c.depth + 1 + FROM workspace w JOIN chain c ON w.id = c.parent_workspace_id + WHERE c.depth < 20 + ) + SELECT leaf FROM chain WHERE is_dev_workspace + GROUP BY leaf, label HAVING count(*) > 1 LIMIT 1", + ) + .fetch_optional(&db) + .await?; + assert!( + duplicated.is_none(), + "round {round}: a chain repeats a label, below {duplicated:?}" + ); + } + Ok(()) +} + +/// The pairing lock covers the chains an operation touches, not every pairing on the instance: a +/// transaction holding one family's nodes must not hold up another family's. Pinned because the +/// obvious way to make the races above safe — one key for the whole operation class — would serialize +/// dev-workspace creation database-wide, and creation holds its transaction across a full clone. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_pairing_lock_does_not_span_unrelated_families( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Hold family F's nodes the way an in-flight attach on it would, then act on family E. + let mut held = db.begin().await?; + for node in ["prod-f", "f-mid", "f-leaf"] { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))") + .bind(node) + .execute(&mut *held) + .await?; + } + // Also the un-suffixed key. Nothing takes it today, so holding it costs the passing case + // nothing — but a lock narrowed back to one key for every family would take it, and without + // this the test would sail through that exact regression. + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing'))") + .execute(&mut *held) + .await?; + + let (status, body) = tokio::time::timeout( + std::time::Duration::from_secs(20), + detach(port, "prod-e", "wm-fork-edev"), + ) + .await + .map_err(|_| anyhow::anyhow!("an unrelated family's pairing blocked on family F's locks"))?; + assert!( + status.is_success(), + "unrelated detach returned {status}: {body}" + ); + + held.rollback().await?; + Ok(()) +} + +/// Archive resolves the workspace's pairing state before its transaction, so it takes the pairing +/// lock before reading that state again rather than on the strength of it — an attach can be turning +/// the workspace into a dev in the meantime. `h-cand` is standalone, the shape whose resolved state +/// says no pairing is involved: its archive must wait on the lock all the same. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_archive_takes_the_pairing_lock_for_a_standalone_workspace( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let mut held = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))") + .bind("h-cand") + .execute(&mut *held) + .await?; + + let finished = tokio::time::timeout( + std::time::Duration::from_secs(5), + archive(port, "h-cand"), + ) + .await; + assert!( + finished.is_err(), + "archive of a standalone workspace completed while its pairing lock was held: {finished:?}" + ); + + held.rollback().await?; + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 804bdcd5a9..d6534bd736 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6691,7 +6691,15 @@ async fn create_workspace_fork_branch( // Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork). let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&w_id]).await?; - ensure_dev_parent_is_root(&db, &w_id).await?; + ensure_dev_parent_can_host_dev(&db, &w_id).await?; + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &w_id, + &nw.id, + false, + label.as_deref(), + ) + .await?; // Reject before creating any git branch if the parent already has a dev workspace, // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. ensure_no_existing_dev_workspace(&db, &w_id).await?; @@ -7012,6 +7020,14 @@ async fn create_workspace_fork( let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&parent_workspace_id]) .await?; + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &parent_workspace_id, + &nw.id, + false, + label.as_deref(), + ) + .await?; label } else { None @@ -7078,7 +7094,7 @@ async fn create_workspace_fork( } if nw.is_dev_workspace { - ensure_dev_parent_is_root(&db, &parent_workspace_id).await?; + ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?; // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod // mutates its protection rules), so require admin of the parent regardless of the lock flags — // mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could @@ -7089,6 +7105,22 @@ async fn create_workspace_fork( let mut tx: Transaction<'_, Postgres> = db.begin().await?; + if nw.is_dev_workspace { + // The checks above ran outside a transaction, so the parent's eligibility and the chain's + // labels could have changed under us: re-decide both here, under the pairing lock. + lock_dev_pairing(&mut tx, &[&parent_workspace_id]).await?; + ensure_dev_parent_can_host_dev(&mut *tx, &parent_workspace_id).await?; + ensure_no_existing_dev_workspace(&mut *tx, &parent_workspace_id).await?; + reject_dev_label_taken_in_chain( + &mut *tx, + &parent_workspace_id, + &nw.id, + false, + dev_workspace_label.as_deref(), + ) + .await?; + } + let forked_id = nw.id; sqlx::query!( @@ -7322,15 +7354,6 @@ async fn attach_dev_workspace( // The id is interpolated into a `wm-fork//` branch name like any fork. validate_dev_workspace_id(&dev_w_id)?; let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?; - // The attached workspace keeps its own sync repos and prod keeps its config; - // the label branch must not collide with either side's tracked branch. - reject_dev_label_matching_tracked_branch( - &db, - dev_workspace_label.as_deref(), - &[&prod_w_id, &dev_w_id], - ) - .await?; - let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, &dev_w_id @@ -7359,24 +7382,41 @@ async fn attach_dev_workspace( dev_w_id ))); } - // The candidate can't itself be a prod with its own dev workspace (no nested dev chains). - ensure_no_existing_dev_workspace(&db, &dev_w_id).await?; - - // Prod must be a root workspace, otherwise attaching could form a parent<->child cycle (e.g. - // attaching A as the dev of B when B is already the dev of A), which breaks hierarchy traversal. - let prod_has_parent = sqlx::query_scalar!( - r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#, + let prod_exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS "exists!""#, &prod_w_id ) - .fetch_optional(&db) - .await? - .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", prod_w_id)))?; - if prod_has_parent { - return Err(Error::BadRequest(format!( - "Workspace {} is itself a fork or dev workspace and cannot be a prod workspace", + .fetch_one(&db) + .await?; + if !prod_exists { + return Err(Error::NotFound(format!( + "Workspace {} not found", prod_w_id ))); } + // Prod may be a root workspace or another dev workspace (a dev of a dev); a throwaway fork + // can't host one. + ensure_dev_parent_can_host_dev(&db, &prod_w_id).await?; + reject_attach_cycle(&db, &prod_w_id, &dev_w_id).await?; + + // The attached workspace keeps its own sync repos and prod keeps its config; the label branch + // must not collide with either side's tracked branch. + reject_dev_label_matching_tracked_branch( + &db, + dev_workspace_label.as_deref(), + &[&prod_w_id, &dev_w_id], + ) + .await?; + // The candidate keeps its own subtree, so its dev descendants keep their labels and join the + // chain alongside it. + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &prod_w_id, + &dev_w_id, + true, + dev_workspace_label.as_deref(), + ) + .await?; // The caller must be admin of the dev workspace too (or a superadmin). let is_admin_of_dev = sqlx::query_scalar!( @@ -7393,9 +7433,47 @@ async fn attach_dev_workspace( ))); } - ensure_no_existing_dev_workspace(&db, &prod_w_id).await?; - let mut tx = db.begin().await?; + // Everything above ran outside a transaction, so prod's eligibility and the chain's labels could + // have changed under us: re-decide both here, under the pairing lock. + lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; + // The candidate was read before the lock, and archiving it is one of the operations the lock + // serializes: re-read it, or the pairing lands on a workspace that is gone or has since been + // taken by another prod. + let dev = sqlx::query!( + r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, + &dev_w_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?; + if dev.deleted { + return Err(Error::BadRequest(format!( + "Workspace {} is archived", + dev_w_id + ))); + } + if dev + .parent_workspace_id + .as_deref() + .is_some_and(|p| p != prod_w_id) + { + return Err(Error::BadRequest(format!( + "Workspace {} is already a fork or dev workspace of another workspace", + dev_w_id + ))); + } + ensure_dev_parent_can_host_dev(&mut *tx, &prod_w_id).await?; + reject_attach_cycle(&mut *tx, &prod_w_id, &dev_w_id).await?; + ensure_no_existing_dev_workspace(&mut *tx, &prod_w_id).await?; + reject_dev_label_taken_in_chain( + &mut *tx, + &prod_w_id, + &dev_w_id, + true, + dev_workspace_label.as_deref(), + ) + .await?; sqlx::query!( "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", &prod_w_id, @@ -7567,6 +7645,11 @@ async fn detach_dev_workspace( require_admin(authed.is_admin, &authed.username)?; let dev_w_id = req.dev_workspace_id; + + let mut tx = db.begin().await?; + // Under the pairing lock, so a dev workspace cannot appear beneath this one between the check + // below and the update. + lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; let is_dev_of_prod = sqlx::query_scalar!( r#"SELECT EXISTS( SELECT 1 FROM workspace @@ -7575,7 +7658,7 @@ async fn detach_dev_workspace( &dev_w_id, &prod_w_id ) - .fetch_one(&db) + .fetch_one(&mut *tx) .await? .unwrap_or(false); if !is_dev_of_prod { @@ -7584,8 +7667,8 @@ async fn detach_dev_workspace( dev_w_id, prod_w_id ))); } + reject_stranding_nested_dev(&mut *tx, &dev_w_id, DevTeardown::Detach).await?; - let mut tx = db.begin().await?; // A wm-fork- workspace re-designated as dev returns to being a plain fork // (keeps its parent); a standalone workspace that was attached returns to // being standalone — with the parent kept it would still classify as a @@ -7693,6 +7776,30 @@ pub(crate) async fn archive_workspace_impl( ) -> Result<(usize, usize, usize)> { // Step 1: Disable all schedules and clear their queued jobs let mut tx = db.begin().await?; + // Unconditionally, before reading any pairing state: whether this workspace is a dev, and whether + // it has one, is exactly what a concurrent attach changes, so gating the lock on the caller's + // `dev_lock_parent` would skip it on the strength of the value the race invalidates. + lock_dev_pairing(&mut tx, &[w_id]).await?; + let dev_parent = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + // The caller resolved this before the lock and authorized against it — its prod admin check, and + // the pairing teardown below, are both answers to that value. Refuse rather than act on a pairing + // nobody checked. + if dev_parent.as_deref() != dev_lock_parent { + return Err(Error::BadRequest(format!( + "The dev pairing of {w_id} changed while it was being archived. Retry." + ))); + } + if dev_parent.is_some() { + // Archiving a dev workspace clears its dev flag, so it must not strand a dev workspace of + // its own. + reject_stranding_nested_dev(&mut *tx, w_id, DevTeardown::Archive).await?; + } let disabled_schedules = sqlx::query_scalar!( "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path", w_id @@ -7833,7 +7940,9 @@ async fn archive_workspace( } // The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's - // transaction, atomically with `deleted = true`. + // transaction, atomically with `deleted = true` — including the guard that it strands no nested + // dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a + // root archived out from under its dev is the pre-existing shape and not this pairing's to police. let (schedules_count, canceled_count, deleted_tokens_count) = archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?; @@ -8885,7 +8994,10 @@ async fn lock_prod_workspace( /// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the /// partial unique index `workspace_canonical_dev_idx` with a friendly message. -async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<()> { +async fn ensure_no_existing_dev_workspace<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + parent_w_id: &str, +) -> Result<()> { let existing = sqlx::query_scalar!( "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", parent_w_id @@ -8901,22 +9013,261 @@ async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result< Ok(()) } -/// A dev workspace pairs with a root prod workspace; nesting dev workspaces (a dev of a dev) isn't -/// supported and would muddle the prod<->dev relationship. -async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> { - let parent_is_fork = sqlx::query_scalar!( - r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#, +/// A dev workspace pairs with a root workspace or — supported, though not the recommended shape — +/// with another dev workspace, giving a promotion chain (dev of dev -> dev -> prod). A throwaway +/// fork is never a valid prod: its deploys go to its own `wm-fork/**` branch and it is discarded +/// with its subtree, so a dev pinned under it has nowhere to promote to. Nor is an archived one, +/// which hosts nothing at all. +async fn ensure_dev_parent_can_host_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + parent_w_id: &str, +) -> Result<()> { + let parent = sqlx::query!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!", is_dev_workspace, deleted + FROM workspace WHERE id = $1"#, parent_w_id ) .fetch_optional(db) - .await? - .unwrap_or(false); - if parent_is_fork { + .await?; + let Some(parent) = parent else { + return Ok(()); + }; + if parent.deleted { return Err(Error::BadRequest(format!( - "Cannot create a dev workspace of '{}' because it is itself a fork or dev workspace.", + "Cannot create a dev workspace of '{}' because it is archived.", parent_w_id ))); } + if parent.is_fork && !parent.is_dev_workspace { + return Err(Error::BadRequest(format!( + "Cannot create a dev workspace of '{}' because it is a throwaway fork.", + parent_w_id + ))); + } + Ok(()) +} + +/// Prod may be a dev workspace, so the candidate can sit ABOVE it in the tree — reparenting it below +/// prod would close a parent<->child cycle and hang every hierarchy walk. Prod itself is at depth 0 +/// of the chain, so callers must have rejected `dev_w_id == prod_w_id` first. +/// +/// `reject_dev_label_taken_in_chain` would also reject a cyclic pairing, since a cycle puts one +/// workspace in the chain twice and so always repeats a label. It reports it as a workspace clashing +/// with itself, which describes nothing the caller can act on — hence this, first. +async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + prod_w_id: &str, + dev_w_id: &str, +) -> Result<()> { + let would_cycle = sqlx::query_scalar!( + r#"WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w + JOIN chain ON w.id = chain.parent_workspace_id + WHERE chain.depth < 20 + ) + SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS "cycle!""#, + prod_w_id, + dev_w_id, + ) + .fetch_one(db) + .await?; + if would_cycle { + return Err(Error::BadRequest(format!( + "Workspace {} is an ancestor of {} and cannot become its dev workspace", + dev_w_id, prod_w_id + ))); + } + Ok(()) +} + +/// Serialize everything that makes or breaks a dev pairing: giving a workspace a dev workspace +/// (create, attach) and clearing one's dev flag (detach, archive). Each decides on state the others +/// mutate — whether a workspace already has a dev, still has one, or leaves a label free — so +/// unserialized they all pass their checks and commit a shape those checks exist to reject. Take it +/// before re-running them inside the mutating transaction; it releases on commit or rollback. +/// +/// Locks every workspace the operation's own checks read: each seed, its ancestors, and the dev +/// workspaces beneath it. Locking just the endpoints is not enough — the label rule spans a whole +/// chain, so two operations a couple of hops apart would hold disjoint keys and both commit. Reading +/// the same set that is checked is what closes that: an attach splices two chains together and so +/// holds nodes from both, and any operation that could collide with it necessarily touches the +/// joined chain, hence shares a node. Acquired in id order, the only ordering rule that keeps two +/// overlapping sets from deadlocking. +/// +/// Recomputed inside the transaction, but from a set that may already be stale — harmless, because +/// whoever made it stale is the operation holding the node this one is missing. +pub(crate) async fn lock_dev_pairing(tx: &mut Transaction<'_, Postgres>, seeds: &[&str]) -> Result<()> { + let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); + // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. + let nodes = sqlx::query_scalar!( + r#"WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id), + up AS ( + SELECT w.id, w.parent_workspace_id, 0 AS depth + FROM workspace w JOIN seeded s ON w.id = s.id + UNION ALL + SELECT w.id, w.parent_workspace_id, up.depth + 1 + FROM workspace w JOIN up ON w.id = up.parent_workspace_id + WHERE up.depth < 20 + ), + down AS ( + SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id + UNION ALL + SELECT w.id, down.depth + 1 + FROM workspace w JOIN down ON w.parent_workspace_id = down.id + WHERE down.depth < 20 AND w.is_dev_workspace + ) + SELECT id AS "id!" FROM ( + SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down + ) n ORDER BY id"#, + &seeds[..] + ) + .fetch_all(&mut **tx) + .await?; + // One statement per node rather than a set-returning call: only a client-side loop actually + // guarantees the acquisition order the deadlock argument above rests on. + for node in nodes { + sqlx::query!( + "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))", + node + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// What is about to clear `is_dev_workspace` on a workspace, which decides whether the workspace can +/// go on hosting a dev workspace of its own afterwards. +enum DevTeardown { + /// Keeps `parent_workspace_id` only for a `wm-fork-` workspace, which then reads as a throwaway + /// fork. A prefix-less workspace returns to standalone and hosts its dev exactly as before. + Detach, + /// Soft-deletes the workspace whatever its id looks like, so it hosts nothing afterwards. + Archive, +} + +/// A nested dev workspace outlives whatever clears its parent's dev flag, and the parent is then a +/// shape that hosts no pairing: its settings tab offers no detach control, and `delete_workspace` +/// refuses a workspace that still has a dev child, so the pairing could never be undone. Reject the +/// teardown so it is done bottom-up instead. +async fn reject_stranding_nested_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + w_id: &str, + teardown: DevTeardown, +) -> Result<()> { + let action = match teardown { + DevTeardown::Detach => { + if !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) { + return Ok(()); + } + "Detaching" + } + DevTeardown::Archive => "Archiving", + }; + let nested = sqlx::query_scalar!( + "SELECT id FROM workspace + WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted", + w_id + ) + .fetch_optional(db) + .await?; + if let Some(nested) = nested { + return Err(Error::BadRequest(format!( + "{action} {w_id} would leave it unable to host a pairing, but it is the prod workspace \ + of '{nested}'. Detach '{nested}' first." + ))); + } + Ok(()) +} + +/// A dev workspace deploys to the branch named by its environment label, and every dev workspace in +/// a chain inherits the same git-sync repositories, so two of them sharing a label push to one +/// branch: each deploy clobbers the other environment, and the root's auto-pull routes that branch +/// to whichever dev it matches first. Require every dev workspace in the resulting chain to carry a +/// distinct label — the dev ancestors `new_dev_id` lands under, `new_dev_id` with `label`, and (when +/// it already exists and so keeps its own subtree) the dev workspaces it brings with it. Since +/// `normalize_dev_workspace_label` admits only 'dev' and 'staging', this caps a chain at two dev +/// workspaces. Dev workspaces only ever hang off a root or another dev (`ensure_dev_parent_can_host_dev`), +/// so that chain is linear and this is the whole of it. +async fn reject_dev_label_taken_in_chain( + db: &mut sqlx::PgConnection, + parent_w_id: &str, + new_dev_id: &str, + keeps_own_subtree: bool, + label: Option<&str>, +) -> Result<()> { + // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. + let mut chain = sqlx::query!( + r#"WITH RECURSIVE ancestors AS ( + SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted, + 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label, + w.deleted, ancestors.depth + 1 + FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id + WHERE ancestors.depth < 20 + ) + SELECT id AS "id!", dev_workspace_label FROM ancestors + WHERE is_dev_workspace AND NOT deleted"#, + parent_w_id + ) + .fetch_all(&mut *db) + .await? + .into_iter() + .map(|r| { + ( + r.id, + windmill_common::workspaces::dev_workspace_branch(r.dev_workspace_label.as_deref()), + ) + }) + .collect::>(); + chain.push(( + new_dev_id.to_string(), + windmill_common::workspaces::dev_workspace_branch(label), + )); + if keeps_own_subtree { + // `depth > 0`: the candidate itself is already in the list above, carrying its new label. + chain.extend( + sqlx::query!( + r#"WITH RECURSIVE tree AS ( + SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted, + tree.depth + 1 + FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id + WHERE tree.depth < 20 + ) + SELECT id AS "id!", dev_workspace_label FROM tree + WHERE depth > 0 AND is_dev_workspace AND NOT deleted"#, + new_dev_id + ) + .fetch_all(&mut *db) + .await? + .into_iter() + .map(|r| { + ( + r.id, + windmill_common::workspaces::dev_workspace_branch( + r.dev_workspace_label.as_deref(), + ), + ) + }), + ); + } + let mut by_branch: HashMap = HashMap::new(); + for (id, branch) in chain { + if let Some(other) = by_branch.insert(branch.clone(), id.clone()) { + return Err(Error::BadRequest(format!( + "'{other}' and '{id}' would both be '{branch}' workspaces in the same chain: dev \ + workspaces in a chain share their git-sync repositories, so both would deploy to \ + the '{branch}' branch. Use the other environment label." + ))); + } + } Ok(()) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 64ea639e91..55f7c4f15d 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -57,6 +57,12 @@ pub(crate) async fn change_workspace_id( let mut tx = db.begin().await?; + // A rename rewrites the workspace's dev flag and reparents its children, so it decides on the + // same state the pairing handlers do: without this lock a concurrent create/attach could commit + // an active dev workspace under the shell this rename is about to archive. Both ids, since the + // rename moves the chain from one to the other. + crate::workspaces::lock_dev_pairing(&mut tx, &[&old_id, &rw.new_id]).await?; + check_w_id_conflict(&mut tx, &rw.new_id).await?; info!( diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index ddf2af3a26..d0313c3476 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1526,9 +1526,11 @@ pub async fn resolve_fork_branch_target( .await? } else if branch != expected_base { // Environment-label branch (`dev`/`staging`) of a dev-workspace child. - // Dev workspaces only exist directly under a root, so no recursion here. - // The tracked-branch guard keeps a label that collides with the tracked - // branch from double-routing (the parent's own pull already covers it). + // Direct children only: a dev nested under another dev is parent-managed + // by that dev, which holds no auto-pull config of its own, so no branch + // pushed to this repo routes to it. The tracked-branch guard keeps a + // label that collides with the tracked branch from double-routing (the + // parent's own pull already covers it). sqlx::query_scalar!( "SELECT id FROM workspace \ WHERE parent_workspace_id = $1 AND NOT deleted AND is_dev_workspace \ diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index 8df0931ae3..8fed09418e 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -8,9 +8,20 @@ import { switchWorkspace } from '$lib/storeUtils' import { goto } from '$app/navigation' import { base } from '$lib/base' - import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' + import { + findCanonicalDevWorkspace, + findWorkspaceAncestors, + findWorkspaceDescendants, + devWorkspacesInChainAbove + } from '$lib/utils/workspaceHierarchy' import { getUserExt } from '$lib/user' - import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel' + import { + DEV_WORKSPACE_LABELS, + devBadgeText, + devLabelKey, + devLabelNoun, + type DevWorkspaceLabelKey + } from '$lib/utils/devWorkspaceLabel' import { loadProtectionRules, isRuleActiveInRulesets, @@ -19,18 +30,31 @@ } from '$lib/workspaceProtectionRules.svelte' import { GitFork, ExternalLink, Check, Minus, Pen } from 'lucide-svelte' import { resource } from 'runed' + import type { Snippet } from 'svelte' + + let { + // What this workspace promotes into its parent (deploy target + item filters). Rendered with + // the pairing statement it restates, ahead of the parent's protections and of the nested + // pairing below — hence handed over rather than placed by the page, which cannot see where + // that boundary falls. + deployTarget + }: { deployTarget?: Snippet } = $props() let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore)) let isDev = $derived(currentWs?.is_dev_workspace ?? false) let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label)) let parentId = $derived(currentWs?.parent_workspace_id ?? undefined) + let parentWs = $derived(parentId ? $userWorkspaces.find((w) => w.id === parentId) : undefined) + // A throwaway fork sits on neither side of a pairing: it is not its parent's dev workspace, and a + // dev of its own would be discarded along with it. + let isThrowawayFork = $derived(!!parentId && !isDev) let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces)) // A prod admin who isn't a member of the dev can't see it in their workspace list, so ask the // server (only when the client list doesn't already have it) — otherwise the tab would show the // attach form instead of the existing pairing and detach control. const devWorkspaceResource = resource( - () => (!isDev && !parentId && !canonicalDev ? $workspaceStore : undefined), + () => (!isThrowawayFork && !canonicalDev ? $workspaceStore : undefined), async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined) ) // The paired dev to display: the client entry when we're a member, else the server result (pairing @@ -57,23 +81,46 @@ let selectedDevId = $state(undefined) let lockProdDeploy = $state(true) let lockProdForking = $state(true) - // Cosmetic display label chosen when attaching an existing workspace as dev. - let attachLabel = $state<'dev' | 'staging'>('dev') + // The label is cosmetic on its own, but in a chain it also names the deploy branch, and dev + // workspaces in a chain share their git-sync repositories: two carrying the same label deploy to + // the same branch. So offer only a label none of the dev workspaces above holds. Computed without + // the selected candidate, so picking one can never take the form away mid-selection. With two + // labels a chain runs to two dev workspaces. + let chainTakenLabels = $derived( + new Set( + devWorkspacesInChainAbove($workspaceStore, $userWorkspaces).map((w) => + devLabelKey(w.dev_workspace_label) + ) + ) + ) + let availableAttachLabels = $derived(DEV_WORKSPACE_LABELS.filter((l) => !chainTakenLabels.has(l))) + let attachLabel = $state('dev') + $effect(() => { + if (availableAttachLabels.length > 0 && !availableAttachLabels.includes(attachLabel)) { + attachLabel = availableAttachLabels[0] + } + }) + // A candidate keeps its own dev workspaces through the attach, labels included: the first one + // whose label is already spoken for further up the resulting chain blocks the pairing, whatever + // label the candidate itself is given. + let candidateClash = $derived.by(() => { + if (!selectedDevId) return undefined + const taken = new Set([...chainTakenLabels, attachLabel]) + for (const w of findWorkspaceDescendants(selectedDevId, $userWorkspaces)) { + if (!w.is_dev_workspace) continue + const label = devLabelKey(w.dev_workspace_label) + if (taken.has(label)) return w.id + taken.add(label) + } + return undefined + }) let busy = $state(false) - // The pairing's locks always sit on the root (prod) workspace: this one when viewed from prod, the - // parent's when viewed from inside the dev workspace. A plain fork has no pairing of its own, so it - // reads nothing. - let lockWorkspace = $derived(parentId ? (isDev ? parentId : undefined) : $workspaceStore) - - // If this workspace already blocks direct deploy / forking through an existing protection rule, keep - // the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule, - // so turning it "off" here couldn't lift a separately-defined block. A failed fetch falls back to the - // editable default-on toggle (real rules still enforce). Once paired, the same rules report which - // locks are actually in force. - const rootProtectionRules = resource( - () => lockWorkspace, - async (ws, _prev, { signal }) => { + // Protection-rule state for one workspace. Instantiated twice because a dev workspace shows two + // panels at once: the locks its own prod carries, and the locks it would carry as the prod of a + // nested dev. + function useProtectionRules(getWs: () => string | undefined) { + const rules = resource(getWs, async (ws, _prev, { signal }) => { if (!ws) return undefined // `fetchProtectionRulesForWorkspace` fails open with an empty list, which the toggles below // want but the status panel must not read as "nothing is enforced" — so keep the failure. @@ -83,69 +130,103 @@ } catch (e) { console.error(`Failed to fetch protection rules for workspace ${ws}:`, e) } - // The generated client can't take an abort signal, so drop a superseded response here: a late - // result for a previously selected workspace must not overwrite the current one's rules. + // The generated client can't take an abort signal, so drop a superseded response here: a + // late result for a previously selected workspace must not overwrite the current one's. if (signal.aborted) throw new DOMException('superseded', 'AbortError') return { ws, rules: rules ?? [], failed: rules === undefined } + }) + // Only trust a result that belongs to the workspace we're currently reading rules for (guards + // the in-flight window and any out-of-order response); undefined means "not known yet" and is + // treated as locked below. + const result = $derived.by(() => { + const current = rules.current + return current && current.ws === getWs() ? current : undefined + }) + const list = $derived(result?.rules) + // Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as + // engaged during that window so the toggle is locked on and the effective value stays true: + // otherwise a user could turn a lock off and attach before an existing rule is detected, + // sending false and omitting the reserved rule — leaving prod unprotected if that rule is + // later removed. + const unknown = $derived(rules.loading || list === undefined) + // Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would + // create; a bypassable rule stays editable, otherwise forcing the lock on would revoke the + // bypassed users' direct-deploy / forking access. + const alreadyBlocksDeploy = $derived( + isRuleUnconditionallyActiveInRulesets(list ?? [], 'DisableDirectDeployment') + ) + const alreadyBlocksForking = $derived( + isRuleUnconditionallyActiveInRulesets(list ?? [], 'DisableWorkspaceForking') + ) + // What the paired view reports. Unlike the toggles above, this asks whether the rule is + // enforced at all: a ruleset with bypass users still blocks everyone outside that list, so it + // is in force here even though the attach form leaves its toggle editable. + const enforcesDeployBlock = $derived( + isRuleActiveInRulesets(list ?? [], 'DisableDirectDeployment') + ) + const enforcesForkingBlock = $derived( + isRuleActiveInRulesets(list ?? [], 'DisableWorkspaceForking') + ) + // The named rulesets actually carrying either lock. Reporting only "blocked / allowed" left no + // way to reach the rule that decides it, which is the one thing a reader here wants to change. + const enforcingRulesets = $derived( + (list ?? []).filter( + (r) => + r.rules.includes('DisableDirectDeployment') || r.rules.includes('DisableWorkspaceForking') + ) + ) + // A failed read is not "nothing is enforced": report it as unknown rather than claiming allowed. + const readFailed = $derived(result?.failed ?? false) + return { + get deployLocked() { + return alreadyBlocksDeploy || unknown + }, + get forkingLocked() { + return alreadyBlocksForking || unknown + }, + get alreadyBlocksDeploy() { + return alreadyBlocksDeploy + }, + get alreadyBlocksForking() { + return alreadyBlocksForking + }, + get enforcesDeployBlock() { + return enforcesDeployBlock + }, + get enforcesForkingBlock() { + return enforcesForkingBlock + }, + get enforcingRulesets() { + return enforcingRulesets + }, + get readFailed() { + return readFailed + }, + get enforcementUnknown() { + return unknown || readFailed + }, + refetch: () => rules.refetch() } - ) - // Only trust a result that belongs to the workspace we're currently reading rules for (guards the - // in-flight window and any out-of-order response); undefined means "not known yet" and is treated as - // locked below. - let rootResult = $derived.by(() => { - const current = rootProtectionRules.current - return current && current.ws === lockWorkspace ? current : undefined - }) - let rootRules = $derived(rootResult?.rules) - // Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a - // bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users' - // direct-deploy / forking access. - let alreadyBlocksDeploy = $derived( - isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment') - ) - let alreadyBlocksForking = $derived( - isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking') - ) - // Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as - // engaged during that window so the toggle is locked on and the effective value stays true: - // otherwise a user could turn a lock off and attach before an existing rule is detected, sending - // false and omitting the reserved rule — leaving prod unprotected if that rule is later removed. - let rulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined) - let deployLocked = $derived(alreadyBlocksDeploy || rulesUnknown) - let forkingLocked = $derived(alreadyBlocksForking || rulesUnknown) + } + + // The locks a pairing applies always sit on the prod side, so each panel reads its own workspace: + // the parent's rules describe what this dev workspace is promoted into, this workspace's own + // describe what its (possibly nested) dev workspace is promoted into. + const parentRules = useProtectionRules(() => (isDev ? parentId : undefined)) + const ownRules = useProtectionRules(() => (isThrowawayFork ? undefined : $workspaceStore)) + // Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the // toggle's raw state, keeping the request consistent with what the locked toggle shows. - let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy) - let effectiveLockProdForking = $derived(forkingLocked || lockProdForking) + let effectiveLockProdDeploy = $derived(ownRules.deployLocked || lockProdDeploy) + let effectiveLockProdForking = $derived(ownRules.forkingLocked || lockProdForking) - // What the paired view reports. Unlike the toggles above, this asks whether the rule is enforced at - // all: a ruleset with bypass users still blocks everyone outside that list, so it is in force here - // even though the attach form leaves its toggle editable. - let enforcesDeployBlock = $derived( - isRuleActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment') - ) - let enforcesForkingBlock = $derived( - isRuleActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking') - ) - // A failed read is not "nothing is enforced": report it as unknown rather than claiming allowed. - let enforcementReadFailed = $derived(rootResult?.failed ?? false) - let enforcementUnknown = $derived(rulesUnknown || enforcementReadFailed) - - // The named rulesets actually carrying either lock. Reporting only "blocked / allowed" left no way - // to reach the rule that decides it, which is the one thing a reader here wants to change. - let enforcingRulesets = $derived( - (rootRules ?? []).filter( - (r) => - r.rules.includes('DisableDirectDeployment') || r.rules.includes('DisableWorkspaceForking') - ) - ) - // Editing prod's rules from the dev side needs admin IN PROD, which membership does not imply and - // this workspace's own admin rights say nothing about: the rulesets tab is admin-only, so a link - // offered to anyone else lands them on a tab they cannot open. Asked of the parent directly, as - // `is_admin` is per-workspace. A superadmin is admin everywhere and has no `usr` row to find. - // Tagged with its workspace and guarded against a superseded response, like the rules resource - // above: runed keeps the previous `current` while a new source loads, so switching between dev - // workspaces would otherwise offer Edit based on the previous parent's role. + // Editing the parent's rules from the dev side needs admin IN THE PARENT, which membership does + // not imply and this workspace's own admin rights say nothing about: the rulesets tab is + // admin-only, so a link offered to anyone else lands them on a tab they cannot open. Asked of the + // parent directly, as `is_admin` is per-workspace. A superadmin is admin everywhere and has no + // `usr` row to find. Tagged with its workspace and guarded against a superseded response, like the + // rules resource above: runed keeps the previous `current` while a new source loads, so switching + // between dev workspaces would otherwise offer Edit based on the previous parent's role. const parentUser = resource( () => (isDev && parentId ? parentId : undefined), async (ws, _prev, { signal }) => { @@ -176,15 +257,22 @@ goto(rulesetsHref(name)) } - // A standalone root workspace, or an existing fork of this prod (same family), can be attached. - // A fork parented to a different workspace can't (the backend rejects a parent that isn't this - // prod), so it's excluded here. + // Reparenting an ancestor below this workspace would close a parent<->child cycle, which the + // backend rejects: with a dev workspace allowed as prod, the family root is such an ancestor and + // would otherwise show up here as a standalone candidate. + let ancestorIds = $derived( + new Set(findWorkspaceAncestors($workspaceStore, $userWorkspaces).map((w) => w.id)) + ) + // A standalone root workspace, or an existing fork of this workspace (same family), can be + // attached. A fork parented to a different workspace can't (the backend rejects a parent that + // isn't this one), so it's excluded here. let attachCandidates = $derived( $userWorkspaces .filter( (w) => w.id !== $workspaceStore && w.id !== 'admins' && + !ancestorIds.has(w.id) && (!w.parent_workspace_id || w.parent_workspace_id === $workspaceStore) ) .map((w) => ({ @@ -203,8 +291,8 @@ // member of the dev workspace (the one that reads the pairing from the server rather than // from the workspace list) keeps seeing the pre-attach/detach state until the tab remounts. devWorkspaceResource.refetch() - // Attach/detach changes this (root) workspace's protection rules; reload them so the - // direct-deploy / forking lock UI reflects the change without a workspace switch or reload. + // Attach/detach changes this workspace's protection rules; reload them so the direct-deploy / + // forking lock UI reflects the change without a workspace switch or reload. // Refetching duplicates the request `loadProtectionRules` just made, which is the price of it // being the only way to supersede whatever this resource already has in flight: `mutate` just // assigns, so an earlier fetch lands afterwards and puts the pre-attach rules back on screen. @@ -213,7 +301,7 @@ // action is cheaper than a panel that misreports what is enforced. if ($workspaceStore) { await loadProtectionRules($workspaceStore) - rootProtectionRules.refetch() + ownRules.refetch() } } @@ -265,41 +353,41 @@ so the button does not switch workspaces without saying so. --> {#snippet protectionsPanel(opts: { title: string + rules: ReturnType onOpen?: (name?: string) => void editLabel: string manageLabel: string })} {@const onOpen = opts.onOpen} + {@const rules = opts.rules}
{opts.title} - {#if enforcementUnknown} + {#if rules.enforcementUnknown} - {enforcementReadFailed - ? 'Could not read the protection rules' - : 'Checking protection rules…'} + {rules.readFailed ? 'Could not read the protection rules' : 'Checking protection rules…'} {:else} - {#if enforcesDeployBlock}{:else}{:else}{/if} - Direct edits {enforcesDeployBlock ? 'are blocked' : 'are allowed'} + Direct edits {rules.enforcesDeployBlock ? 'are blocked' : 'are allowed'} - {#if enforcesForkingBlock}{:else}{:else}{/if} - Forking {enforcesForkingBlock ? 'is blocked' : 'is allowed'} + Forking {rules.enforcesForkingBlock ? 'is blocked' : 'is allowed'} - {#if enforcesDeployBlock || enforcesForkingBlock} + {#if rules.enforcesDeployBlock || rules.enforcesForkingBlock} Workspace admins always bypass these rules. {/if} - {#if enforcingRulesets.length > 0} + {#if rules.enforcingRulesets.length > 0}
Enforced by - {#each enforcingRulesets as ruleset (ruleset.name)} + {#each rules.enforcingRulesets as ruleset (ruleset.name)}
{ruleset.name} @@ -322,7 +410,7 @@
{/if} {/if} - {#if onOpen && enforcingRulesets.length === 0} + {#if onOpen && rules.enforcingRulesets.length === 0}
{/snippet} -{#if isDev && parentId} + +{#snippet ownDevSection()} + {#if pairedDev} +
+

+ This workspace's {devLabelNoun(pairedDev.label)} is {pairedDev.name} + ({pairedDev.id}). Edits to this workspace are redirected there. +

+ {@render protectionsPanel({ + title: 'Protections in force on this workspace', + rules: ownRules, + onOpen: openRulesets, + editLabel: 'Edit', + manageLabel: 'Manage in Rulesets' + })} +
+ {#if pairedDev.isMember || $superadmin} + + {/if} + +
+
+ {:else if availableAttachLabels.length === 0} +

+ Every environment label (dev, + staging) is already taken by a dev workspace in this chain, and + two carrying the same label would deploy to the same branch. Promote through the existing + chain instead. +

+ {:else} +
+

+ Pair this workspace with a dev workspace: the same code with a different environment + (resource and variable values). Edits are made in the dev workspace and promoted here. +

+
+ Attach an existing workspace as dev + -
-
- Label: {devBadgeText(attachLabel)} - -
-
-
- Protect this workspace on attach - - Nothing is enforced until you attach: these add protection rules to this workspace so - changes are made in the dev workspace and promoted here. - -
- {#if deployLocked} -
- - {#if alreadyBlocksDeploy} - Already enforced by an existing protection rule - {/if} -
- {:else} - - {/if} - {#if forkingLocked} -
- - {#if alreadyBlocksForking} - Already enforced by an existing protection rule - {/if} -
- {:else} - - {/if} -
-
- - + This workspace's own dev workspace +

+ A dev workspace can itself be paired with one, giving a longer promotion chain (for example + dev into staging into prod). It is not the recommended shape — each extra level is another + promotion to run — but nothing prevents it. +

+ {@render ownDevSection()}
+{:else} + {@render ownDevSection()} {/if} diff --git a/frontend/src/lib/components/sidebar/DeleteForkedWorkspaceModal.svelte b/frontend/src/lib/components/sidebar/DeleteForkedWorkspaceModal.svelte index 49a0a49da2..ff049ceddb 100644 --- a/frontend/src/lib/components/sidebar/DeleteForkedWorkspaceModal.svelte +++ b/frontend/src/lib/components/sidebar/DeleteForkedWorkspaceModal.svelte @@ -26,8 +26,18 @@ const forkedDescendants = $derived( $workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : [] ) - // Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix. - const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? [])) + // Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix — except + // that the prefix answers before the workspace list has loaded, which is why the entry must be + // present and non-dev rather than merely absent from it. A dev workspace is torn down by + // detaching it, never deleted from here, and this guard also holds an already-open modal. + const currentWsIsFork = $derived.by(() => { + const current = $userWorkspaces?.find((w) => w.id === $workspaceStore) + return ( + !!current && + workspaceIsFork($workspaceStore, $userWorkspaces ?? []) && + !current.is_dev_workspace + ) + }) async function loadForkedDatatables() { if (!$workspaceStore) return diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index 8b5213da24..3322ba66d0 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -84,8 +84,18 @@ const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(settingsWs, $userStore?.email) ) - // Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix. - const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? [])) + // Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix. A dev + // workspace is excluded: it is a standing environment its whole team works in, torn down by + // detaching it in the dev-workspace settings, so offering a one-click delete beside the account + // menu puts a destructive action on the wrong surface. Requires the entry to be loaded, not just + // absent from the list: `workspaceIsFork` answers from the id prefix alone, so between a cold + // load restoring the workspace id and the list arriving, a `wm-fork-` dev workspace would read + // as a throwaway and offer its own deletion. + const currentWsIsThrowawayFork = $derived( + !!currentWs && + workspaceIsFork($workspaceStore, $userWorkspaces ?? []) && + !currentWs.is_dev_workspace + ) let leaveWorkspaceModal = $state(false) let deleteForkModal = $state() @@ -160,7 +170,7 @@ : []), // Fork deletion is a global-sidebar action on the active workspace, so keep it // out of the session rail's per-target settings entry (`workspaceSettingsTarget`). - ...(currentWsIsFork && !workspaceSettingsTarget + ...(currentWsIsThrowawayFork && !workspaceSettingsTarget ? [ { displayName: 'Delete forked workspace', diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 2e57cf88fa..286e044e6b 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -9,8 +9,7 @@ workspaceUsageStore, workspaceColor, clearWorkspaceFromStorage, - globalForkModal, - type UserWorkspace + globalForkModal } from '$lib/stores' import { Building, Check, ChevronDown, ChevronRight, Plus, Settings } from 'lucide-svelte' import { forkAccentStyle } from '$lib/utils/forkColor' @@ -30,7 +29,12 @@ import { workspaceAIClients } from '../copilot/lib' import { twMerge } from 'tailwind-merge' import type { MenubarBuilders } from '@melt-ui/svelte' - import { buildWorkspaceHierarchy, isForkOwner } from '$lib/utils/workspaceHierarchy' + import { + buildWorkspaceHierarchy, + findWorkspaceAncestors, + findWorkspaceRoot, + isForkOwner + } from '$lib/utils/workspaceHierarchy' import { canCreateFork } from '$lib/utils/editInFork' import { getContrastTextColor } from '$lib/utils' import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte' @@ -149,21 +153,25 @@ e.stopPropagation() } - function findRoot(id: string | undefined): UserWorkspace | undefined { - if (!id || !$userWorkspaces) return undefined - let current = $userWorkspaces.find((w) => w.id === id) - while (current?.parent_workspace_id) { - const parent = $userWorkspaces.find((w) => w.id === current!.parent_workspace_id) - if (!parent) break - current = parent - } - return current - } + // The active workspace's family root — shown in the trigger so a forked active workspace still + // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly + // like the scope picker right below, so the two never name different heads for one workspace: + // inside a dev workspace's own subtree the head is that dev workspace, not the far root. + const currentFamily = $derived( + findWorkspaceRoot($workspaceStore ?? undefined, $userWorkspaces ?? []) + ) - // The active workspace's family root — shown in the trigger so a forked - // active workspace still surfaces its family name here (the fork itself is - // shown in the breadcrumb). - const currentFamily = $derived(findRoot($workspaceStore ?? undefined)) + // The row mechanics below — which family to expand, which collapsed row carries the tick — key on + // a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the + // caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors` + // stops at that same visibility boundary, so it lands on the same row. The head displayed above + // can sit below it, hence the second resolution. + const lineageRoot = $derived.by(() => { + const id = $workspaceStore ?? undefined + if (!id) return undefined + const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? []) + return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id) + }) // Workspace names carry no uniqueness constraint, and this menu labels every // row by name alone: a prod/staging pair sharing one name renders as two @@ -183,8 +191,8 @@ // on the active fork's own row instead of on its collapsed root. function seedExpandedFamilies() { expandedFamilies.clear() - if (currentFamily && currentFamily.id !== $workspaceStore) { - expandedFamilies.add(currentFamily.id) + if (lineageRoot && lineageRoot.id !== $workspaceStore) { + expandedFamilies.add(lineageRoot.id) } } @@ -258,7 +266,7 @@ isActive || (!strictWorkspaceSelect && depth === 0 && - currentFamily?.id === workspace.id && + lineageRoot?.id === workspace.id && !expandedFamilies.has(workspace.id))} {@const expandable = !strictWorkspaceSelect && depth === 0 && familiesWithForks.has(workspace.id)} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index 71869c844a..c9ea79c971 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -25,7 +25,8 @@ workspaceIsFork, findWorkspaceRoot, findWorkspaceDescendants, - findDefaultForkBase + findDefaultForkBase, + devWorkspacesInChainAbove } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' import { @@ -34,7 +35,12 @@ } from '$lib/workspaceProtectionRules.svelte' import { resource } from 'runed' import { Badge, Button } from '$lib/components/common' - import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import { + DEV_WORKSPACE_LABELS, + devBadgeText, + devLabelKey, + type DevWorkspaceLabelKey + } from '$lib/utils/devWorkspaceLabel' import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { onMount } from 'svelte' @@ -83,8 +89,9 @@ copyMembers = createAsDevWorkspace }) - // A dev workspace can only be created off a root base (backend rejects a dev of a fork). Clear a - // stale toggle when the base no longer qualifies so we never submit is_dev_workspace against a fork. + // A dev workspace can only be created off a root or dev base (backend rejects a dev of a throwaway + // fork). Clear a stale toggle when the base no longer qualifies so we never submit + // is_dev_workspace against a fork. $effect(() => { if (!canDesignateDevWorkspace && createAsDevWorkspace) { createAsDevWorkspace = false @@ -136,34 +143,52 @@ baseWorkspaceId = defaultBaseWorkspaceId }) - // Cosmetic display label for the new dev workspace: 'dev' | 'staging'. Purely visual (badge text + - // wording); reset when the dev toggle is turned off. - let devWorkspaceLabel = $state<'dev' | 'staging'>('dev') - $effect(() => { - if (!createAsDevWorkspace) devWorkspaceLabel = 'dev' - }) - - // The dev-workspace option is only offered when forking a root workspace that doesn't already - // have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev). + // The dev-workspace option is only offered when forking a workspace that doesn't already have a + // dev: a workspace gets at most one. The base may be a root or, less conventionally, another dev + // workspace (a dev of a dev) — only a throwaway fork can't host one. let baseWorkspaceEntry = $derived(forkableWorkspaces.find((w) => w.id === baseWorkspaceId)) - // Require the base workspace to be loaded before treating it as a root: a missing entry must - // not read as root (it would offer invalid dev creation while the workspace list is still loading). + // Require the base workspace to be loaded before treating it as eligible: a missing entry must not + // read as a root (it would offer invalid dev creation while the workspace list is still loading). // `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent // FK was set null — it has no parent but is still a fork, so it can't host a dev workspace. - let currentIsRoot = $derived( - !!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, forkableWorkspaces) + let baseCanHostDev = $derived( + !!baseWorkspaceEntry && + (!workspaceIsFork(baseWorkspaceId, forkableWorkspaces) || + !!baseWorkspaceEntry.is_dev_workspace) ) // Ask the server whether a dev already exists: the caller may not be a member of this prod's dev, // so the client workspace list can't see it and would offer an invalid "create dev" action. const devWorkspaceResource = resource( - () => (currentIsRoot ? baseWorkspaceId : undefined), + () => (baseCanHostDev ? baseWorkspaceId : undefined), async (ws) => (ws ? await WorkspaceService.getDevWorkspace({ workspace: ws }) : undefined) ) + // Cosmetic display label for the new dev workspace — except in a chain, where it also names the + // deploy branch: the dev workspaces the new one would share a chain with already hold theirs, so + // offer only what is left rather than a choice the backend rejects. With two labels a chain runs + // to two dev workspaces, after which no label is free and dev designation is not offered at all. + let availableDevLabels = $derived.by(() => { + const taken = new Set( + devWorkspacesInChainAbove(baseWorkspaceId, forkableWorkspaces).map((w) => + devLabelKey(w.dev_workspace_label) + ) + ) + return DEV_WORKSPACE_LABELS.filter((l) => !taken.has(l)) + }) // Offer dev designation only once the server confirms there's no dev yet (returns null); stay // conservative (no offer) while the check is loading (current is undefined). - let canDesignateDevWorkspace = $derived(currentIsRoot && devWorkspaceResource.current === null) + let canDesignateDevWorkspace = $derived( + baseCanHostDev && availableDevLabels.length > 0 && devWorkspaceResource.current === null + ) + + let devWorkspaceLabel = $state('dev') + $effect(() => { + if (!createAsDevWorkspace) devWorkspaceLabel = 'dev' + else if (!availableDevLabels.includes(devWorkspaceLabel) && availableDevLabels.length > 0) { + devWorkspaceLabel = availableDevLabels[0] + } + }) let currentWorkspaceName = $derived( - baseWorkspaceEntry?.name ?? baseWorkspaceId ?? 'the root workspace' + baseWorkspaceEntry?.name ?? baseWorkspaceId ?? 'the base workspace' ) // If the root already blocks direct deploy / forking through an existing protection rule, keep the @@ -833,14 +858,21 @@ {#if createAsDevWorkspace}
Label: {devBadgeText(devWorkspaceLabel)} - + {#if availableDevLabels.length === 1} + + The other label is already taken by a dev workspace in this chain, which would + deploy to the same branch. + + {:else} + + {/if}
@@ -848,8 +880,8 @@ >Protect {currentWorkspaceName} - Adds protection rules to this (root) workspace so changes are made in the new - dev workspace and promoted here. + Adds protection rules to the base workspace so changes are made in the new dev + workspace and promoted there.
{#if deployLocked} @@ -907,7 +939,7 @@
{#if createAsDevWorkspace} - A dev workspace is always based on the root workspace. + A dev workspace is paired with the workspace it is based on, which here is {currentWorkspaceName}. {:else} Workspace to fork from: the new branch is based on the selected workspace's branch. Pick an existing fork to create a fork of a fork. diff --git a/frontend/src/lib/utils/devWorkspaceLabel.ts b/frontend/src/lib/utils/devWorkspaceLabel.ts index 026ec9a746..a8c2df0100 100644 --- a/frontend/src/lib/utils/devWorkspaceLabel.ts +++ b/frontend/src/lib/utils/devWorkspaceLabel.ts @@ -4,6 +4,9 @@ export type DevWorkspaceLabelKey = 'dev' | 'staging' +/** Every label the backend accepts, in offer order. */ +export const DEV_WORKSPACE_LABELS: DevWorkspaceLabelKey[] = ['dev', 'staging'] + /** Resolve the stored `dev_workspace_label` to a known key; anything unset/unknown is 'dev'. */ export function devLabelKey(label: string | null | undefined): DevWorkspaceLabelKey { return label === 'staging' ? 'staging' : 'dev' diff --git a/frontend/src/lib/utils/workspaceHierarchy.test.ts b/frontend/src/lib/utils/workspaceHierarchy.test.ts index 38dc12576e..14e063cba8 100644 --- a/frontend/src/lib/utils/workspaceHierarchy.test.ts +++ b/frontend/src/lib/utils/workspaceHierarchy.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { findDefaultForkBase } from './workspaceHierarchy' +import { + devWorkspacesInChainAbove, + findDefaultForkBase, + findWorkspaceAncestors, + findWorkspaceRoot +} from './workspaceHierarchy' import type { UserWorkspace } from '../stores' function ws(id: string, parent?: string, extra: Partial = {}): UserWorkspace { @@ -29,3 +34,44 @@ describe('findDefaultForkBase', () => { expect(findDefaultForkBase('wm-fork-a', disabledDev)?.id).toBe('prod') }) }) + +const nestedDev = ws('stgws', 'devws', { is_dev_workspace: true }) +const forkOfNestedDev = ws('wm-fork-c', 'stgws') +const nestedFamily = [...family, nestedDev, forkOfNestedDev] + +describe('findWorkspaceRoot', () => { + it('walks to the family root through a single dev workspace', () => { + expect(findWorkspaceRoot('devws', family)?.id).toBe('prod') + expect(findWorkspaceRoot('wm-fork-a', family)?.id).toBe('prod') + }) + + it('stops at the parent dev workspace of a dev of a dev', () => { + expect(findWorkspaceRoot('stgws', nestedFamily)?.id).toBe('devws') + expect(findWorkspaceRoot('wm-fork-c', nestedFamily)?.id).toBe('devws') + }) + + it('stops at the highest reachable ancestor', () => { + expect(findWorkspaceRoot('devws', [dev, forkOfDev])?.id).toBe('devws') + }) +}) + +describe('findWorkspaceAncestors', () => { + // The attach-candidate cycle filter depends on this NOT stopping where findWorkspaceRoot does. + it('walks past a dev-of-dev boundary to the true root', () => { + expect(findWorkspaceAncestors('wm-fork-c', nestedFamily).map((w) => w.id)).toEqual([ + 'stgws', + 'devws', + 'prod' + ]) + }) +}) + +describe('devWorkspacesInChainAbove', () => { + it('collects the dev workspaces at and above the prod side', () => { + expect(devWorkspacesInChainAbove('stgws', nestedFamily).map((w) => w.id)).toEqual([ + 'stgws', + 'devws' + ]) + expect(devWorkspacesInChainAbove('prod', nestedFamily)).toEqual([]) + }) +}) diff --git a/frontend/src/lib/utils/workspaceHierarchy.ts b/frontend/src/lib/utils/workspaceHierarchy.ts index 3597d6f4ea..86c5eaca9b 100644 --- a/frontend/src/lib/utils/workspaceHierarchy.ts +++ b/frontend/src/lib/utils/workspaceHierarchy.ts @@ -108,6 +108,12 @@ export function isRootWorkspace(workspace: UserWorkspace): boolean { * Walk up `parent_workspace_id` to the top of a workspace's family. Stops at the first ancestor not * present in `allWorkspaces` (e.g. a parent the user can't see) and returns it, so the result is * always the highest reachable ancestor. Returns undefined when the id itself isn't in the list. + * + * A dev workspace nested under another dev workspace (a dev of a dev, supported but not the + * recommended shape) ends the walk: it is the prod of everything below it, and presenting the far + * root — which such a family may promote to only through an intermediate, and which its members + * often can't even reach — as the family head makes every root-scoped affordance (fork base, deploy + * target, scope chip) point past the workspace actually being worked in. */ export function findWorkspaceRoot( workspaceId: string | undefined, @@ -118,11 +124,53 @@ export function findWorkspaceRoot( while (current?.parent_workspace_id) { const parent = allWorkspaces.find((w) => w.id === current!.parent_workspace_id) if (!parent) break + const crossedNestedDev = !!current.is_dev_workspace && !!parent.is_dev_workspace current = parent + if (crossedNestedDev) break } return current } +/** + * Every reachable ancestor of a workspace, nearest first. Unlike `findWorkspaceRoot` this never stops + * at a dev-of-dev boundary — it answers "is X above me in the real tree?", e.g. to keep an ancestor + * out of a dev-workspace attach list, where reparenting it below would close a cycle. + */ +export function findWorkspaceAncestors( + workspaceId: string | undefined, + allWorkspaces: UserWorkspace[] +): UserWorkspace[] { + const ancestors: UserWorkspace[] = [] + let current = workspaceId ? allWorkspaces.find((w) => w.id === workspaceId) : undefined + const seen = new Set(current ? [current.id] : []) + while (current?.parent_workspace_id) { + const parent = allWorkspaces.find((w) => w.id === current!.parent_workspace_id) + if (!parent || seen.has(parent.id)) break + seen.add(parent.id) + ancestors.push(parent) + current = parent + } + return ancestors +} + +/** + * The dev workspaces at or above `workspaceId`. A dev workspace placed under it joins their + * promotion chain, so their environment labels are the ones it cannot reuse — dev workspaces in a + * chain inherit the same git-sync repositories, and an equal label means one shared deploy branch. + * `disabled` is not filtered out, unlike elsewhere: it means the caller has no seat in that + * workspace, which does not free the branch it deploys to. Ancestors the caller cannot see end the + * walk, so this can under-report; the backend rejects on the full tree either way. + */ +export function devWorkspacesInChainAbove( + workspaceId: string | undefined, + allWorkspaces: UserWorkspace[] +): UserWorkspace[] { + const self = workspaceId ? allWorkspaces.find((w) => w.id === workspaceId) : undefined + return [...(self ? [self] : []), ...findWorkspaceAncestors(workspaceId, allWorkspaces)].filter( + (w) => w.is_dev_workspace + ) +} + /** * Whether a workspace (by id) is a fork or dev workspace. Forks and dev workspaces both set * `parent_workspace_id` (a dev workspace has no `wm-fork-` id prefix), but a `wm-fork-` workspace can diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 8b12bc16c3..782da1bb2c 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -1412,49 +1412,55 @@ description="Pair this workspace with a dev workspace: the same code with a different environment. Edits are made in the dev workspace and promoted to prod." link="https://www.windmill.dev/docs/core_concepts/staging_prod" /> - - {#if showDeployToTab} - - {#if $enterpriseLicense} - { - initialDeployUiSettings = clone(deployUiSettings) - }} - onDiscard={discardDeploySettingsChanges} - /> - {:else} -
Deploy to staging/prod from the web UI is only available with an enterprise - license
+ + {#snippet deployTarget()} + {#if showDeployToTab} + + {#if $enterpriseLicense} + { + initialDeployUiSettings = clone(deployUiSettings) + }} + onDiscard={discardDeploySettingsChanges} + /> + {:else} +
Deploy to staging/prod from the web UI is only available with an enterprise + license
+ {/if} {/if} - {/if} - -
- Deploy into another workspace -

- Promotion normally follows the lineage, from this workspace into its parent. For a - one-off migration you can instead point the merge UI at any workspace you administer; - it computes a full diff over both workspaces and deploys the items you pick, one way. -

-
- +

+ Promotion normally follows the lineage, from this workspace into its parent. For a + one-off migration you can instead point the merge UI at any workspace you + administer; it computes a full diff over both workspaces and deploys the items you + pick, one way. +

+
+ +
-
{:else if tab == 'rulesets'} Date: Wed, 5 Aug 2026 19:08:04 +0000 Subject: [PATCH 05/40] fix(debugger): confine prepare-deps under nsjail in both language paths (#10546) `windmill prepare-deps` was spawned with Bun's raw `spawn` in both the Python and the TypeScript session, bypassing the nsjail wrapping the debugged script itself gets. With ENABLE_NSJAIL=true, `uv pip install` (source distributions run their build backend) and `bun install` (postinstall scripts) therefore executed package-supplied code unconfined, next to the LSP, multiplayer and gateway services in the windmill-extra container. Both installers now go through the same nsjail wrapper as the debuggee, which the two files no longer build separately. The jail keeps the environment (`keep_env`), which is what carries the registry credentials and CA settings into the installer; the debugged script's environment is unchanged and still holds neither. Killing the installer also did not reap the `uv` or `bun` it had spawned: those were reparented to init and kept downloading, so both the timeout and the cancel-on-disconnect only half-worked. The installer now runs in its own process group and is signalled as a group, reading the group id back from /proc rather than assuming it, since a group kill aimed at the service's own group would take down every service in the container. Two things that cancellation exposed: a kill was reported to the client as an install failure, since it ends the read with nothing to parse - blaming the user for their own Stop; and the standalone Bun server's close handler only dropped the session from its map, so nothing there was ever cleaned up. The teardown flag is also scoped to a launch rather than the session, because cleanup() runs when a program finishes normally too. Co-authored-by: Claude Opus 5 (1M context) --- debugger/README.md | 8 ++ debugger/dap_debug_service.ts | 77 +++++++------ debugger/dap_websocket_server_bun.ts | 155 ++++++++++++++++++++++----- 3 files changed, 177 insertions(+), 63 deletions(-) diff --git a/debugger/README.md b/debugger/README.md index 614d2bf65e..0adb3b4809 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -129,6 +129,14 @@ unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service out of reach, not the flag on its own. +The installer is jailed on the same terms, in both languages: `uv pip install` builds source +distributions and `bun install` runs postinstall scripts, so a package's own code executes there +too. It keeps the service's environment across that boundary — the config sets `keep_env`, which is +how the settings above reach it — so replacing that with an allowlist would have to carry the +registry and CA variables in explicitly. It also runs in its own process group, because `uv` and +`bun` are grandchildren: signalling only the installer reparents them to init and they keep +downloading, which would make the timeout and the cancel-on-disconnect half-measures. + ### Frontend Integration ```svelte diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 6da3f0b1b0..6e16bae75a 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -40,7 +40,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' // Import the working Bun debug session from the standalone server -import { DebugSession as BunDebugSessionWorking, type NsjailConfig } from './dap_websocket_server_bun' +import { + DebugSession as BunDebugSessionWorking, + killProcessTree, + nsjailWrap, + type NsjailConfig +} from './dap_websocket_server_bun' import { sessionEnv } from './env_passthrough' // ============================================================================ @@ -349,6 +354,17 @@ interface SpawnOptions { cmd: string[] cwd?: string env?: Record + stdin?: Blob + /** + * Hand the child this process's whole environment rather than the minimal set below. Only + * the dependency installer wants it: the registry credentials and CA settings it reads are + * precisely what the minimal set exists to keep away from a debugged script. + */ + inheritEnv?: boolean + /** + * Start the child in its own process group, so killProcessTree can signal what it spawns. + */ + detached?: boolean stdout?: 'pipe' | 'inherit' stderr?: 'pipe' | 'inherit' } @@ -364,30 +380,9 @@ const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) * This is the key function for sandboxed execution. */ function spawnProcess(options: SpawnOptions): Subprocess { - let cmd = options.cmd + const cmd = nsjailWrap(options.cmd, config.nsjail, options.cwd) if (config.nsjail.enabled) { - // Build nsjail command - const nsjailCmd = [config.nsjail.binaryPath] - - // Add config file if specified - if (config.nsjail.configPath) { - nsjailCmd.push('--config', config.nsjail.configPath) - } - - // Add any extra nsjail arguments - nsjailCmd.push(...config.nsjail.extraArgs) - - // Add working directory if specified - if (options.cwd) { - nsjailCmd.push('--cwd', options.cwd) - } - - // Separator and actual command - nsjailCmd.push('--') - nsjailCmd.push(...cmd) - - cmd = nsjailCmd logger.info(`Spawning with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Spawning: ${cmd.join(' ')}`) @@ -398,9 +393,12 @@ function spawnProcess(options: SpawnOptions): Subprocess { return spawn({ cmd, cwd: options.cwd || process.cwd(), + ...(options.stdin ? { stdin: options.stdin } : {}), + ...(options.detached ? { detached: true } : {}), stdout: options.stdout || 'pipe', stderr: options.stderr || 'pipe', env: { + ...(options.inheritEnv ? process.env : {}), // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, @@ -657,8 +655,10 @@ class PythonDebugSession extends BaseDebugSession { * server executes the submitted script inside its own interpreter: anything in that process is * recoverable by the script. The service never executes user code, so the credentials stop here. * - * The trade-off is that the install itself is not jailed, so a source distribution's build - * backend runs outside nsjail, as it already does for Bun sessions. + * It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee: + * `uv pip install` builds source distributions, which executes their build backend's arbitrary + * Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the + * environment across the boundary, so nothing else has to carry them in. */ private async prepareDependencies(code: string): Promise { if (!this.windmillPath) { @@ -667,6 +667,12 @@ class PythonDebugSession extends BaseDebugSession { } const warn = (reason: string): null => { + // cleanup() kills the installer, which ends the read with nothing to parse. Reporting + // that as an install failure blames the user for their own disconnect, on a websocket + // that is being torn down anyway. + if (this.disposed) { + return null + } logger.error(`prepare-deps failed: ${reason}`) this.sendEvent('output', { category: 'stderr', @@ -676,7 +682,7 @@ class PythonDebugSession extends BaseDebugSession { } try { - const proc = spawn({ + const proc = spawnProcess({ cmd: [this.windmillPath, 'prepare-deps'], // The venv has to be built against the interpreter that will run the script: its // site-packages goes on that interpreter's sys.path, and uv otherwise picks its @@ -684,8 +690,8 @@ class PythonDebugSession extends BaseDebugSession { stdin: new Blob([ JSON.stringify({ code, language: 'python3', python_path: config.pythonPath }) + '\n' ]), - stdout: 'pipe', - stderr: 'pipe' + inheritEnv: true, + detached: true }) this.prepareDepsProcess = proc @@ -694,9 +700,10 @@ class PythonDebugSession extends BaseDebugSession { // 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 | undefined + // spawnProcess's return type does not carry the piped stdio through const read = (async () => ({ - output: await new Response(proc.stdout).text(), - stderr: await new Response(proc.stderr).text() + output: await new Response(proc.stdout as ReadableStream).text(), + stderr: await new Response(proc.stderr as ReadableStream).text() }))() const result = await Promise.race([ read, @@ -708,9 +715,7 @@ class PythonDebugSession extends BaseDebugSession { this.prepareDepsProcess = null if (!result) { - // SIGKILL, not the default SIGTERM: prepare-deps does not act on SIGTERM while uv - // is running, so a polite signal leaves it running after the session gave up. - proc.kill('SIGKILL') + killProcessTree(proc) return warn( `dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` ) @@ -1026,6 +1031,10 @@ class PythonDebugSession extends BaseDebugSession { } private async handleLaunch(request: DAPMessage): Promise { + // Per launch, not per session: cleanup() also runs when a program finishes normally, and + // the flag must only mean "torn down while this launch was still preparing". + this.disposed = false + const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined @@ -1181,7 +1190,7 @@ sys.stdout.flush() // A client that gives up mid-install must not leave the package manager running if (this.prepareDepsProcess) { - this.prepareDepsProcess.kill('SIGKILL') + killProcessTree(this.prepareDepsProcess) this.prepareDepsProcess = null } diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index 8336c2f812..8a0f2fb4e4 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -22,6 +22,7 @@ */ import { spawn, type Subprocess } from 'bun' +import { readFileSync } from 'node:fs' import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -557,6 +558,63 @@ export interface NsjailConfig { extraArgs?: string[] } +/** + * Wrap a command so nsjail runs it, or return it unchanged when sandboxing is off. + * The environment is not filtered here: the config sets `keep_env`, so the jailed process + * receives whatever the spawning call gives it. + */ +export function nsjailWrap(cmd: string[], nsjail: NsjailConfig | undefined, cwd?: string): string[] { + if (!nsjail?.enabled) { + return cmd + } + const wrapped = [nsjail.binaryPath] + if (nsjail.configPath) { + wrapped.push('--config', nsjail.configPath) + } + if (nsjail.extraArgs) { + wrapped.push(...nsjail.extraArgs) + } + if (cwd) { + wrapped.push('--cwd', cwd) + } + wrapped.push('--', ...cmd) + return wrapped +} + +/** + * SIGKILL a subprocess along with everything it spawned. + * + * SIGKILL because `windmill prepare-deps` does not act on SIGTERM while uv is running. The + * whole group because uv is a grandchild: signalling the child alone reparents uv to init and + * it keeps downloading. The group id is read back from /proc instead of assumed, since a group + * kill aimed at this service's own group would take down every service in the container; a + * child spawned without `detached` therefore only gets the plain kill. + */ +export function killProcessTree(proc: Subprocess): void { + if (proc.exitCode !== null || proc.signalCode !== null) { + // Nothing left to signal, and the pid may already have been handed to someone else + return + } + + let ownsGroup = false + try { + const stat = readFileSync(`/proc/${proc.pid}/stat`, 'utf8') + // The comm field can hold spaces and parentheses, so read the fields after its closing one + ownsGroup = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]) === proc.pid + } catch { + // Already reaped, or not Linux: fall back to killing the process alone + } + try { + if (ownsGroup) { + process.kill(-proc.pid, 'SIGKILL') + } else { + proc.kill('SIGKILL') + } + } catch (error) { + logger.error('Failed to kill process:', error) + } +} + /** * VLQ (Variable-Length Quantity) decoder for source maps. * Returns array of decoded integers from VLQ string. @@ -754,6 +812,11 @@ export class DebugSession { // Path to installed node_modules (set after prepare-deps runs) private nodeModulesPath?: string + // Running dependency installer, so a teardown mid-install can stop it + private prepareDepsProcess: Subprocess | null = null + + private disposed = false + constructor(ws: WebSocket, options?: { nsjailConfig?: NsjailConfig; bunPath?: string; windmillPath?: string }) { this.ws = ws this.nsjailConfig = options?.nsjailConfig @@ -1438,6 +1501,10 @@ export class DebugSession { * Handle the 'launch' request. */ async handleLaunch(request: DAPMessage): Promise { + // Per launch, not per session: cleanup() also runs when a program finishes normally, and + // the flag must only mean "torn down while this launch was still preparing". + this.disposed = false + const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined @@ -1492,6 +1559,16 @@ export class DebugSession { if (code) { this.nodeModulesPath = await this.prepareDependencies(code) || undefined + // Installing takes long enough for the client to give up meanwhile, and cleanup() has + // then already run: starting the debuggee now would leak a process nothing owns. + // The response still goes out, since a client that terminated without closing the + // socket is otherwise left waiting out its own launch timeout. + if (this.disposed) { + logger.info('Session was torn down during dependency preparation, not starting Bun') + this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') + return + } + // Remove version specifiers from imports (e.g., "lodash@4" -> "lodash") // This must happen AFTER prepareDependencies (which needs the versions) // but BEFORE the code is executed (Bun doesn't understand @version syntax) @@ -1573,6 +1650,11 @@ export class DebugSession { * Prepare dependencies by calling the windmill CLI's prepare-deps command. * This analyzes imports in the code and installs required npm packages. * Returns the path to node_modules if any were installed. + * + * Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall + * scripts, which is user-supplied code executing next to the other services in the container. + * Its environment is inherited rather than filtered, which is what carries the registry and + * CA settings into the installer (the jail keeps the environment across the boundary). */ private async prepareDependencies(code: string, language: string = 'bun'): Promise { if (!this.windmillPath) { @@ -1600,31 +1682,40 @@ export class DebugSession { const input = JSON.stringify({ code, language }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) - // Spawn the windmill binary with prepare-deps command. This runs in the service - // process, so inheriting its environment is what gives prepare-deps the container's - // index and certificate settings; the allowlist above is what keeps them from the - // debugged script. + // Spawn the windmill binary with prepare-deps command. Its environment is inherited + // rather than filtered, which is what gives prepare-deps the container's index and + // certificate settings; the allowlist above is what keeps them from the debugged + // script, and the jail keeps them across its own boundary. + const cmd = nsjailWrap([this.windmillPath, 'prepare-deps'], this.nsjailConfig) + logger.info(`Spawning${this.nsjailConfig?.enabled ? ' with nsjail' : ''}: ${cmd.join(' ')}`) const proc = spawn({ - cmd: [this.windmillPath, 'prepare-deps'], + cmd, stdin: new Blob([input]), // Use Blob for complete stdin data stdout: 'pipe', - stderr: 'pipe' + stderr: 'pipe', + // So the installer and the bun it spawns can be killed as one group + detached: true }) + this.prepareDepsProcess = proc // Bound the wait: the only other ceiling is the DAP client's launch timeout, // which is minutes, so a wedged installer would hang the session that long. killTimer = setTimeout(() => { timedOut = true logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`) - // SIGKILL, not the default SIGTERM: prepare-deps does not act on SIGTERM while the - // package manager is running, so a polite signal leaves it going after the timeout. - proc.kill('SIGKILL') + killProcessTree(proc) }, PREPARE_DEPS_TIMEOUT_MS) // Wait for completion const output = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() + // The read also ends when cleanup() kills the installer, which leaves no output to + // parse. Reporting that as an install failure blames the user for their own Stop. + if (this.disposed) { + return null + } + if (timedOut) { const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` this.sendEvent('output', { @@ -1682,6 +1773,9 @@ export class DebugSession { logger.info('No external dependencies to install') return null } catch (error) { + if (this.disposed) { + return null + } logger.error(`Failed to prepare dependencies: ${error}`) this.sendEvent('output', { category: 'console', @@ -1691,6 +1785,7 @@ export class DebugSession { } finally { clearInterval(progress) clearTimeout(killTimer) + this.prepareDepsProcess = null } } @@ -1706,24 +1801,13 @@ export class DebugSession { const inspectUrl = `127.0.0.1:${inspectPort}` // Build the command - optionally wrapped with nsjail - let cmd: string[] = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath] + const cmd = nsjailWrap( + [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath], + this.nsjailConfig, + cwd + ) if (this.nsjailConfig?.enabled) { - const nsjailCmd = [this.nsjailConfig.binaryPath] - - if (this.nsjailConfig.configPath) { - nsjailCmd.push('--config', this.nsjailConfig.configPath) - } - - if (this.nsjailConfig.extraArgs) { - nsjailCmd.push(...this.nsjailConfig.extraArgs) - } - - nsjailCmd.push('--cwd', cwd) - nsjailCmd.push('--') - nsjailCmd.push(...cmd) - - cmd = nsjailCmd logger.info(`Starting Bun with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`) @@ -2500,15 +2584,23 @@ export class DebugSession { } /** - * Clean up resources. + * Clean up resources. Public because both servers call it when a client goes away. */ - private async cleanup(): Promise { + async cleanup(): Promise { + this.disposed = true + // Close inspector connection if (this.inspectorWs) { this.inspectorWs.close() this.inspectorWs = null } + // A disconnect during dependency installation must not leave bun install running + if (this.prepareDepsProcess) { + killProcessTree(this.prepareDepsProcess) + this.prepareDepsProcess = null + } + // Kill process if (this.process) { this.process.kill() @@ -2648,9 +2740,14 @@ if (import.meta.main) { logger.error('Error handling message:', error) } }, - close(ws) { + async close(ws) { logger.info('Client disconnected') - sessions.delete(ws) + const session = sessions.get(ws) + if (session) { + // Dropping the session without this leaves its installer and debuggee running + await session.cleanup() + sessions.delete(ws) + } } } }) From d9d6ec82ab7ad5279ba8ebc059f58bd663bad87f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 19:13:29 +0000 Subject: [PATCH 06/40] feat: register mounted CA certificates in windmill_extra at startup (#10545) * feat: register mounted CA certificates in windmill_extra at startup Co-Authored-By: Claude Opus 5 (1M context) * fix: only claim a CA update when update-ca-certificates can read the mount Co-Authored-By: Claude Opus 5 (1M context) * fix: detect mounted CA certificates the way update-ca-certificates finds them Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- debugger/README.md | 15 ++++++++++++--- docker-compose.yml | 4 ++++ docker/entrypoint-extra.sh | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/debugger/README.md b/debugger/README.md index 0adb3b4809..31599f4db8 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -118,9 +118,18 @@ not enough on its own, since `requests` carries its own bundle and Node reads on `NODE_EXTRA_CA_CERTS`. Registry settings are deliberately not forwarded: they carry credentials and only the service needs them. -To install that CA into the container's system store in the first place, set `INIT_SCRIPT` on the -`windmill_extra` container (e.g. `INIT_SCRIPT=update-ca-certificates`). It runs before any service -starts and aborts startup if it fails, the same hook a worker offers. +Registering that CA in the container's system store happens on its own: mount it into +`/usr/local/share/ca-certificates/` **named `*.crt`**, the only extension `update-ca-certificates` +reads, and `windmill_extra` runs it before starting any service. `RUN_UPDATE_CA_CERTIFICATE_AT_START=true` forces the same thing whether or not +certificates are mounted there, and `RUN_UPDATE_CA_CERTIFICATE_PATH` overrides the tool, matching +the server and worker. Both are best-effort: a UID that cannot write `/etc/ssl/certs` logs a warning +and the container still boots. `INIT_SCRIPT` remains the hook for anything more involved, and unlike +the CA update it aborts startup when it fails. + +Note what the system store does *not* cover, which is most of what a debug session installs with: +uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read +only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib +`ssl`, `curl` and `git`; the rest still needs the variables above. Keeping the settings out of the session's environment only bounds what the debugged script can read from itself. An unsandboxed session runs under the same user as the service and can still read the diff --git a/docker-compose.yml b/docker-compose.yml index 93fbf28e8d..a801a0ce7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -187,6 +187,10 @@ services: # - DEBUG_ALLOWED_ORIGINS=https://your-windmill-host # Optional CSWSH hardening: comma-separated allowlist of browser Origins permitted to open debug WebSockets volumes: - lsp_cache:/pyls/.cache + # Behind a TLS-intercepting proxy, mount its CA here (as .crt) and it is registered in the + # system trust store before any service starts. That alone does not cover dependency + # installation — see debugger/README.md for the variables it also needs + # - ./corp-ca.crt:/usr/local/share/ca-certificates/corp-ca.crt:ro logging: *default-logging caddy: diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index e5cc4e61fc..b32ad2dd94 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -33,6 +33,44 @@ if [ ! -w "$HOME" ]; then fi export HOME +# Register CA certificates mounted into the image before anything opens a TLS connection. +# Best-effort on purpose, unlike INIT_SCRIPT below: a non-root UID cannot write /etc/ssl/certs, and +# a deployment that never needed a custom CA must still boot. Env var names and the default-off +# behavior match the server/worker binary, so one setting covers every container. What the system +# trust store does and does not reach is documented in debugger/README.md. +CA_CERT_DIR=/usr/local/share/ca-certificates + +update_ca_certificates() { + local reason="$1" + local tool="${RUN_UPDATE_CA_CERTIFICATE_PATH:-/usr/sbin/update-ca-certificates}" + local output + if [ ! -x "$tool" ]; then + echo "[entrypoint] $reason but $tool is not executable, skipping CA update" + return + fi + echo "[entrypoint] $reason, running $tool" + if output=$("$tool" 2>&1); then + echo "[entrypoint] CA certificates updated" + else + # Carry the tool's own message: the usual cause is an unwritable /etc/ssl/certs under a + # non-root UID, but guessing that in place of the real error hides everything else. + echo "[entrypoint] WARNING: $tool failed (UID $(id -u)): ${output:-no output}; continuing" >&2 + fi +} + +if [ "$(echo "${RUN_UPDATE_CA_CERTIFICATE_AT_START:-false}" | tr '[:upper:]' '[:lower:]')" = "true" ]; then + update_ca_certificates "RUN_UPDATE_CA_CERTIFICATE_AT_START=true" +elif [ -n "$(find -L "$CA_CERT_DIR" -type f -name '*.crt' -print -quit 2>/dev/null)" ]; then + # Certificates mounted there are unambiguous intent, and they do nothing until registered, so + # take the same action without making the operator also find the env var. + update_ca_certificates "Found certificates in $CA_CERT_DIR" +elif [ -n "$(ls -A "$CA_CERT_DIR" 2>/dev/null)" ]; then + # Reporting success over a mount update-ca-certificates ignores would be worse than saying + # nothing: .pem is the spelling people reach for, and only .crt is read. + echo "[entrypoint] WARNING: $CA_CERT_DIR has files but none named *.crt, the only extension" \ + "update-ca-certificates reads; they will be ignored" >&2 +fi + # INIT_SCRIPT is the documented hook for preparing the host before anything reaches the network # (CA certificates, proxies, mounts), matching the worker's INIT_SCRIPT. It must therefore complete # before any service starts, and a failure has to abort: services that come up with an unprepared From 1aee22296e228c54d4ca7251e9ae7e04229adb34 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 20:16:07 +0000 Subject: [PATCH 07/40] fix: keep the same_worker pin across a flow module that spawns no job (#10551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a module completes without spawning a job — an empty branch, an empty for-loop, or a module already marked Success — the flow hands itself back through the UpdateFlow channel, and the result processor resumed it with unrecoverable = true regardless of what sent it. That flag means "the previous step's worker died", which holds for none of the three producers except a suspend that ended without approval. The stale argument was inert until continue_on_same_worker and continue_with_runners started reading it, since when the step after such a module is pushed as an ordinary queued job. It is then routed by tag and can land on any worker in the pool, breaking both the ./shared directory contract and the guarantee that a same_worker flow stays on a worker able to run it — a step whose tag resolves to a worker group that cannot execute its language fails instantly, taking the flow with it. Carry the flag on the UpdateFlow message so each producer states its own case, rather than having the shared receiver assume the worst. The three that hand back a live flow forward whatever their caller reported, so a genuinely unrecoverable failure still crosses the hop unchanged. Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/worker.rs | 63 +++++++++++++++++++ .../windmill-worker/src/result_processor.rs | 3 +- backend/windmill-worker/src/worker.rs | 4 ++ backend/windmill-worker/src/worker_flow.rs | 7 +++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 3140cd2d7f..2e11ff3e4c 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -587,6 +587,69 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { ); Ok(()) } + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_same_worker_survives_empty_branch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server: ApiServer = ApiServer::start(db.clone()).await?; + + // No branch matches and the default is empty, so `a` completes without spawning a job and + // hands the flow back over the UpdateFlow channel. `b` must still be pinned to the worker. + let flow: FlowValue = serde_json::from_value(json!({ + "same_worker": true, + "modules": [ + { + "id": "a", + "value": { + "type": "branchone", + "branches": [{ + "expr": "false", + "modules": [{ + "id": "c", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(){ return 1 }", + } + }] + }], + "default": [] + } + }, + { + "id": "b", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(){ return 42 }", + } + } + ] + })) + .unwrap(); + + let job = run_job_in_new_worker_until_complete( + &db, + false, + JobPayload::RawFlow { value: flow, path: None, restarted_from: None }, + server.addr.port(), + ) + .await; + assert_eq!(job.json_result().unwrap(), json!(42)); + + let same_worker: Option = sqlx::query_scalar( + "SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'", + ) + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(same_worker, Some(true)); + + Ok(()) +} + #[sqlx::test(fixtures("base"))] async fn test_flow_result_by_id(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 693d0295c8..cdfe9895d2 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -465,6 +465,7 @@ pub fn start_background_processor( worker_dir, stop_early_override, token, + unrecoverable, }), time, }) => { @@ -485,7 +486,7 @@ pub fn start_background_processor( None, Arc::new(result), None, - true, + unrecoverable, &same_worker_tx, &worker_dir, stop_early_override, diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 10e436d326..b03cfa7e48 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -3639,6 +3639,10 @@ pub struct UpdateFlow { pub worker_dir: String, pub stop_early_override: Option, pub token: String, + /// Whether the flow must be resumed as if the previous step's worker had died: no retry, + /// straight to the failure module, and nothing pinned to this worker. Only true when the + /// flow is handed back from a state no live step can recover from. + pub unrecoverable: bool, } async fn do_nativets( diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index d7b43db2c9..df9e63511c 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3231,6 +3231,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + unrecoverable, }))); } @@ -3285,6 +3286,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + unrecoverable, } ))); } @@ -3329,6 +3331,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + unrecoverable, }))); } } @@ -3661,6 +3664,10 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + // A suspend that was disapproved or ran out its timeout cannot be + // resumed by anything the flow does next, so the failure module is the + // only way forward. + unrecoverable: true, }))); } } From 154f8f461ef01d60da73f9633d76bed45381a035 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 20:16:38 +0000 Subject: [PATCH 08/40] feat(debugger): install debug session deps from the instance registry settings (#10550) * feat(debugger): install debug session deps from the instance registry settings Co-Authored-By: Claude Opus 5 (1M context) * fix(debugger): keep install-time registry credentials out of the session-visible tree Co-Authored-By: Claude Opus 5 (1M context) * docs: drop em dashes from the debugger registry docs and comments Co-Authored-By: Claude Opus 5 (1M context) * fix(debugger): stop installing for a session that went away during the settings fetch Also serves nativets sessions the npm settings their installer reads. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...83ac80b669a910eada527c2a75f5cbf7d830d.json | 28 ++ backend/windmill-api-debug/Cargo.toml | 4 + backend/windmill-api-debug/src/lib.rs | 355 +++++++++++++++++- backend/windmill-api/Cargo.toml | 2 +- backend/windmill-worker/src/bun_executor.rs | 39 +- backend/windmill-worker/src/prepare_deps.rs | 224 ++++++++++- debugger/Dockerfile | 1 + debugger/README.md | 83 +++- debugger/dap_debug_service.ts | 35 +- debugger/dap_websocket_server_bun.ts | 34 +- debugger/nsjail.debug.config.proto | 9 + debugger/registry_config.ts | 84 +++++ docker/DockerfileExtra | 1 + 13 files changed, 841 insertions(+), 58 deletions(-) create mode 100644 backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json create mode 100644 debugger/registry_config.ts diff --git a/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json new file mode 100644 index 0000000000..28aa09d18d --- /dev/null +++ b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, value FROM global_settings WHERE name = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d" +} diff --git a/backend/windmill-api-debug/Cargo.toml b/backend/windmill-api-debug/Cargo.toml index 8531407416..c262ac3d32 100644 --- a/backend/windmill-api-debug/Cargo.toml +++ b/backend/windmill-api-debug/Cargo.toml @@ -8,6 +8,10 @@ edition.workspace = true name = "windmill_api_debug" path = "src/lib.rs" +[features] +default = [] +enterprise = [] + [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 3b43a04ad1..7b2f569821 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -20,15 +20,21 @@ //! - A job entry in v2_job (kind=preview) for traceability //! - A completed job entry in v2_job_completed //! - An audit log entry identical to script preview runs +//! +//! The same signature is what authorizes the debugger's requests back to the API: +//! /api/debug/registry_config serves the instance's dependency-registry settings, which the +//! debugger cannot read for itself, to sessions whose token carries the `registry_config` +//! claim. use axum::{ extract::Path, + http::HeaderMap, routing::{get, post}, Extension, Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::Utc; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::types::Json as SqlxJson; @@ -37,8 +43,18 @@ use tokio::sync::RwLock; use uuid::Uuid; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ - db::UserDB, error::JsonResult, jobs::JobKind, jwt::JWT_SECRET, scripts::ScriptLang, + db::UserDB, + error::{Error, JsonResult}, + global_settings::{ + BUNFIG_INSTALL_SCOPES_SETTING, EXTRA_PIP_INDEX_URL_SETTING, NPMRC_SETTING, + NPM_CONFIG_REGISTRY_SETTING, PIP_INDEX_URL_SETTING, UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, + }, + jobs::JobKind, + jwt::JWT_SECRET, + scripts::ScriptLang, users::username_to_permissioned_as, + DB, }; use windmill_api_auth::ApiAuthed; @@ -112,7 +128,9 @@ pub async fn reload_debug_signing_key() { } pub fn global_service() -> Router { - Router::new().route("/jwks", get(get_jwks)) + Router::new() + .route("/jwks", get(get_jwks)) + .route("/registry_config", get(get_registry_config)) } pub fn workspaced_service() -> Router { @@ -168,6 +186,220 @@ async fn get_jwks() -> JsonResult { })) } +/// The instance's dependency-registry configuration, as `windmill prepare-deps` consumes it. +/// Field names are the `global_settings` keys, so the debug service forwards this object to +/// the CLI as-is. +#[derive(Serialize, Default)] +pub struct DebugRegistryConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub npm_config_registry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub npmrc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bunfig_install_scopes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_extra_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uv_index_strategy: Option, + /// Why configured settings were withheld, for the debug service to show the user. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Placeholder an EE instance puts in an index URL for a token minted per install by +/// `EPHEMERAL_TOKEN_CMD` (`windmill-worker`'s `handle_ephemeral_token`). +const EPHEMERAL_TOKEN_MARKER: &str = "EPHEMERAL_TOKEN"; + +/// Every `global_settings` key [`get_registry_config`] reads, in one query. A setting +/// resolved there but missing here reads as unset, whatever the instance has stored. +const REGISTRY_SETTINGS: [&str; 7] = [ + NPM_CONFIG_REGISTRY_SETTING, + NPMRC_SETTING, + BUNFIG_INSTALL_SCOPES_SETTING, + PIP_INDEX_URL_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, + UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, +]; + +/// Which half of the settings a session installs with, from the language its token was signed +/// for: a session is served only what its own installer runs on, so a token minted for one +/// language cannot be replayed to read the other's credentials. Every language the debugger +/// accepts (`isDebuggableLanguage` in the frontend) has to appear here, or its sessions +/// silently install from the public registries. +fn registry_settings_for_language(language: &str) -> (bool, bool) { + match language { + "bun" | "typescript" | "deno" | "nativets" => (true, false), + "python3" | "python" => (false, true), + _ => (false, false), + } +} + +/// Resolve one setting the way a worker resolves it: a workspace override wins over the +/// instance value, which is `FORCE_` > `global_settings` > `` (the server's +/// `load_option_setting_value`). A blank value means unset from either source, so a +/// workspace can blank one out. +fn resolve_registry_setting( + stored: &std::collections::HashMap, + workspace_overrides: Option<&serde_json::Value>, + key: &str, + env_var: &str, +) -> Option { + let as_str = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(|s| s.to_string()); + let instance_value = std::env::var(format!("FORCE_{env_var}")) + .ok() + .or_else(|| as_str(stored.get(key))) + .or_else(|| std::env::var(env_var).ok()); + as_str(workspace_overrides.and_then(|w| w.get(key))) + .or(instance_value) + .filter(|v| !v.trim().is_empty()) +} + +/// Serve the dependency-registry settings to the debug service. +/// +/// `windmill prepare-deps` installs a debug session's imports without a database +/// connection, so the service fetches the settings here and passes them down over the +/// CLI's stdin request. They stop there: a private index URL embeds credentials and the +/// debugged script can read its own process, so nothing served here reaches the session's +/// environment (see `debugger/README.md`). +/// +/// Authorized by the launch token the service verified for that session, and only when the +/// token carries the `registry_config` claim (see [`sign_debug_request`] for what it means). +async fn get_registry_config( + Extension(db): Extension, + headers: HeaderMap, +) -> JsonResult { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| Error::NotAuthorized("Missing debug token".to_string()))?; + + let claims = verify_debug_token(token).await?; + if !claims.registry_config { + return Err(Error::NotAuthorized( + "This debug session is not allowed to read the registry configuration".to_string(), + )); + } + + let names = REGISTRY_SETTINGS.map(String::from); + let stored = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &names[..] + ) + .fetch_all(&db) + .await? + .into_iter() + .map(|r| (r.name, r.value)) + .collect::>(); + + let workspace_overrides = stored + .get(WORKSPACE_REGISTRIES_SETTING) + .and_then(|v| v.get(&claims.workspace_id)); + let (npm, python) = registry_settings_for_language(&claims.language); + let resolve = |serve: bool, key: &str, env_var: &str| { + serve + .then(|| resolve_registry_setting(&stored, workspace_overrides, key, env_var)) + .flatten() + }; + + let mut config = DebugRegistryConfig { + npm_config_registry: resolve(npm, NPM_CONFIG_REGISTRY_SETTING, "NPM_CONFIG_REGISTRY"), + npmrc: resolve(npm, NPMRC_SETTING, "NPMRC"), + bunfig_install_scopes: resolve( + npm, + BUNFIG_INSTALL_SCOPES_SETTING, + "BUNFIG_INSTALL_SCOPES", + ), + pip_index_url: resolve(python, PIP_INDEX_URL_SETTING, "PIP_INDEX_URL"), + pip_extra_index_url: resolve(python, EXTRA_PIP_INDEX_URL_SETTING, "PIP_EXTRA_INDEX_URL"), + // Not a private-registry setting: a worker reads it on any edition, so it is + // served below the Enterprise gate too. + uv_index_strategy: resolve(python, UV_INDEX_STRATEGY_SETTING, "UV_INDEX_STRATEGY"), + message: None, + }; + + if cfg!(feature = "enterprise") { + // A worker substitutes this marker with the output of `EPHEMERAL_TOKEN_CMD` + // (`handle_ephemeral_token`), a command the debug service has no way to run. Serving + // the placeholder would install with a literal token, so the value is withheld and + // the service falls back to the index URL in its own environment. + let ephemeral = |url: &Option| { + url.as_ref() + .is_some_and(|u| u.contains(EPHEMERAL_TOKEN_MARKER)) + }; + if ephemeral(&config.pip_index_url) || ephemeral(&config.pip_extra_index_url) { + config.pip_index_url = None; + config.pip_extra_index_url = None; + config.message = Some(format!( + "Python index configuration ignored: an {EPHEMERAL_TOKEN_MARKER} index URL can only be resolved on a worker" + )); + } + } else { + // A private registry is an Enterprise feature, and `read_ee_registry` drops these + // same settings on a CE worker, so a CE debug session installs from the public registries + // and says why, instead of gaining a capability jobs on that instance don't have. + let configured = config.npm_config_registry.is_some() + || config.npmrc.is_some() + || config.bunfig_install_scopes.is_some() + || config.pip_index_url.is_some() + || config.pip_extra_index_url.is_some(); + config.npm_config_registry = None; + config.npmrc = None; + config.bunfig_install_scopes = None; + config.pip_index_url = None; + config.pip_extra_index_url = None; + if configured { + config.message = Some( + "Private registry configuration ignored: this feature requires Windmill Enterprise Edition" + .to_string(), + ); + } + } + + Ok(Json(config)) +} + +/// Verify a token minted by [`sign_debug_request`] and return its claims. +/// +/// The debug service verifies the same token itself against the JWKS public key; this is +/// the server-side half, for the requests the service makes back on a session's behalf. +async fn verify_debug_token(token: &str) -> Result { + let key_guard = DEBUG_SIGNING_KEY.read().await; + let signing_key = key_guard + .as_ref() + .ok_or_else(|| Error::InternalErr("Debug signing key not initialized".to_string()))?; + + let invalid = || Error::NotAuthorized("Invalid debug token".to_string()); + let mut parts = token.split('.'); + let (header_b64, claims_b64, signature_b64) = + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(header), Some(claims), Some(signature), None) => (header, claims, signature), + _ => return Err(invalid()), + }; + + let signature = Signature::from_slice( + &URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?; + signing_key + .verifying_key() + .verify_strict(format!("{header_b64}.{claims_b64}").as_bytes(), &signature) + .map_err(|_| invalid())?; + + let claims: DebugTokenClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + if Utc::now().timestamp() > claims.exp { + return Err(Error::NotAuthorized("Debug token expired".to_string())); + } + Ok(claims) +} + #[derive(Deserialize)] pub struct SignDebugRequest { /// The code to be debugged @@ -193,6 +425,11 @@ pub struct DebugTokenClaims { pub exp: i64, /// Job ID for traceability pub job_id: String, + /// Whether this session may be served the instance's dependency-registry settings + /// (see [`get_registry_config`]). Defaults to `false` so a token that predates the + /// claim is refused rather than silently trusted. + #[serde(default)] + pub registry_config: bool, } #[derive(Serialize)] @@ -248,6 +485,14 @@ async fn sign_debug_request( iat: now_ts, exp, job_id: job_id.to_string(), + // The registry settings embed credentials, and the token reaches the browser, so they + // are only served for a session whose author can already install with them: someone + // who can run a preview job. For npm that discloses nothing new, since a worker leaves + // the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in; the + // Python index URL only ever appears as uv's argv, so serving it here does widen what + // a member of the workspace can read. Operators cannot run previews at all, so their + // sessions install from the public registries. + registry_config: !authed.is_operator, }; // Create JWT manually with Ed25519 signature @@ -504,3 +749,107 @@ async fn sign_multiplayer( Ok(Json(SignedMultiplayerPayload { token })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Sign `claims` the way [`sign_debug_request`] does, with a key only this test knows. + async fn signed(claims: &DebugTokenClaims) -> String { + let key = derive_signing_key_from_jwt_secret("test-secret"); + *DEBUG_SIGNING_KEY.write().await = Some(key.clone()); + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); + let message = format!("{header}.{payload}"); + let signature = URL_SAFE_NO_PAD.encode(key.sign(message.as_bytes()).to_bytes()); + format!("{message}.{signature}") + } + + fn claims(exp_in: i64) -> DebugTokenClaims { + DebugTokenClaims { + code_hash: "0".repeat(32), + language: "bun".to_string(), + workspace_id: "test".to_string(), + email: "user@windmill.dev".to_string(), + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + exp_in, + job_id: Uuid::nil().to_string(), + registry_config: true, + } + } + + /// The registry settings are credentials, and this signature is the only thing standing + /// between them and any caller of `/api/debug/registry_config`. Each rejection here is a + /// way in if it stops being checked: an edited claim, a session whose author may not read + /// them, or a token replayed long after its session. + #[tokio::test] + async fn only_an_unexpired_token_with_the_claim_verifies() { + let token = signed(&claims(60)).await; + assert!(verify_debug_token(&token).await.is_ok()); + + let no_claim = signed(&DebugTokenClaims { registry_config: false, ..claims(60) }).await; + assert!(!verify_debug_token(&no_claim).await.unwrap().registry_config); + + let expired = signed(&claims(-1)).await; + assert!(verify_debug_token(&expired).await.is_err()); + + // Re-signing is the only way to change a claim: swapping the payload of a valid token + // for one that grants itself the claim must not verify. + let (header, rest) = token.split_once('.').unwrap(); + let (_, signature) = rest.split_once('.').unwrap(); + let forged = URL_SAFE_NO_PAD.encode( + serde_json::to_string(&DebugTokenClaims { exp: i64::MAX, ..claims(60) }).unwrap(), + ); + assert!( + verify_debug_token(&format!("{header}.{forged}.{signature}")) + .await + .is_err() + ); + } + + /// Every language the debugger can start a session for installs dependencies, so each one + /// has to name the settings its installer reads. A language missing here is not a refusal: + /// its sessions quietly install from the public registries instead. + #[test] + fn every_debuggable_language_is_served_its_own_settings() { + // Mirrors `isDebuggableLanguage` in frontend/src/lib/components/debug/debugUtils.ts. + for language in ["bun", "typescript", "deno", "nativets"] { + assert_eq!(registry_settings_for_language(language), (true, false)); + } + assert_eq!(registry_settings_for_language("python3"), (false, true)); + assert_eq!(registry_settings_for_language("go"), (false, false)); + } + + /// A debug session must resolve a registry setting to what a job in the same workspace + /// resolves it to (`read_ee_registry_with_workspace_override`): the workspace override + /// replaces the instance value, and a blank value from either source means unset, which + /// is how a workspace opts out of an instance-wide registry. + #[test] + fn workspace_override_replaces_the_instance_value() { + let stored = [( + NPM_CONFIG_REGISTRY_SETTING.to_string(), + serde_json::json!("https://instance.example/"), + )] + .into_iter() + .collect::>(); + // Never set, so the environment fallback stays out of the comparison. + let env_var = "WM_TEST_DEBUG_REGISTRY_UNSET"; + let resolve = |overrides: Option<&serde_json::Value>| { + resolve_registry_setting(&stored, overrides, NPM_CONFIG_REGISTRY_SETTING, env_var) + }; + + assert_eq!(resolve(None).as_deref(), Some("https://instance.example/")); + let workspace = serde_json::json!({ "npm_config_registry": "https://workspace.example/" }); + assert_eq!( + resolve(Some(&workspace)).as_deref(), + Some("https://workspace.example/") + ); + let blanked = serde_json::json!({ "npm_config_registry": " " }); + assert_eq!(resolve(Some(&blanked)), None); + let unrelated = serde_json::json!({ "npmrc": "//other/:_authToken=x" }); + assert_eq!( + resolve(Some(&unrelated)).as_deref(), + Some("https://instance.example/") + ); + } +} diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 4c410b7785..e39c2a35c4 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-amqp?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private", "windmill-object-store/private"] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-debug/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] stripe = [] run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"] agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 01e1203b6b..d64499878b 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -571,12 +571,8 @@ async fn gen_bunfig( NPMRC.read().await.clone() }; - if let Some(ref npmrc_content) = npmrc { - if !npmrc_content.trim().is_empty() { - tracing::debug!("Writing .npmrc for bun from npmrc setting"); - write_file(job_dir, ".npmrc", npmrc_content)?; - return Ok(()); - } + if npmrc.as_ref().is_some_and(|c| !c.trim().is_empty()) { + return write_bun_registry_config(job_dir, npmrc, None, None); } let (registry, bunfig_install_scopes) = if let Some(conn) = db { @@ -606,6 +602,35 @@ async fn gen_bunfig( BUNFIG_INSTALL_SCOPES.read().await.clone(), ) }; + write_bun_registry_config(job_dir, None, registry, bunfig_install_scopes) +} + +/// The files [`write_bun_registry_config`] may create in the directory bun installs from. +/// Both can hold a registry auth token, so `prepare-deps` deletes them by these names once +/// the install is over. +pub(crate) const BUN_NPMRC_FILE: &str = ".npmrc"; +pub(crate) const BUN_CONFIG_FILE: &str = "bunfig.toml"; + +/// Write the registry configuration `bun install` picks up from its working directory: the +/// `npmrc` setting verbatim as `.npmrc` when set, otherwise a `bunfig.toml` holding the +/// registry URL, its auth token and the install scopes. +/// +/// Shared with the debugger's `prepare-deps`, which resolves the same settings without a +/// database (see `prepare_deps.rs`), so the two install paths configure bun identically. +pub(crate) fn write_bun_registry_config( + job_dir: &str, + npmrc: Option, + registry: Option, + bunfig_install_scopes: Option, +) -> Result<()> { + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + tracing::debug!("Writing .npmrc for bun from npmrc setting"); + write_file(job_dir, BUN_NPMRC_FILE, npmrc_content)?; + return Ok(()); + } + } + if registry.is_some() || bunfig_install_scopes.is_some() { let (url, token_opt) = if let Some(ref s) = registry { let url = s.trim(); @@ -635,7 +660,7 @@ registry = {} .unwrap_or("".to_string()) ); tracing::debug!("Writing following bunfig.toml: {bunfig_toml}"); - let _ = write_file(&job_dir, "bunfig.toml", &bunfig_toml)?; + let _ = write_file(&job_dir, BUN_CONFIG_FILE, &bunfig_toml)?; } Ok(()) } diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 161744af40..9c8a5179e3 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -12,6 +12,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; +use crate::bun_executor::{write_bun_registry_config, BUN_CONFIG_FILE, BUN_NPMRC_FILE}; use crate::worker::non_empty_env; use crate::{ BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST, @@ -92,9 +93,10 @@ lazy_static::lazy_static! { /// UV binary path static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string()); - /// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance - /// settings the job path resolves are unreachable here: their env-var equivalents are the - /// only registry configuration the debugger can see. + /// Fallbacks for the registry settings, used when the caller sends none: this process has + /// no database, so the instance settings only reach it through the request (see + /// [`RegistryConfig`]), and a debug service that cannot fetch them still configures the + /// installer from its own environment. static ref PY_INDEX_URL: Option = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL")); static ref PY_EXTRA_INDEX_URL: Option = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL")); /// uv defaults to `first-index`; the job path overrides it so a package missing from the @@ -112,6 +114,25 @@ const p = { }; "#; +/// The registry settings resolved from the instance settings by the caller. +/// +/// This process runs without a database, so `GET /api/debug/registry_config` is where the +/// debug service reads them and this request field is how they get here. They are consumed +/// to configure the installer and never handed to the debug session itself: an index URL +/// embeds credentials and the session executes user-supplied code. +/// +/// Field names are the `global_settings` keys, so the service forwards the endpoint's +/// response verbatim. +#[derive(Deserialize, Default)] +pub struct RegistryConfig { + pub npm_config_registry: Option, + pub npmrc: Option, + pub bunfig_install_scopes: Option, + pub pip_index_url: Option, + pub pip_extra_index_url: Option, + pub uv_index_strategy: Option, +} + #[derive(Deserialize)] pub struct PrepareRequest { pub code: String, @@ -121,6 +142,125 @@ pub struct PrepareRequest { /// extension built for another version is simply invisible there. #[serde(default)] pub python_path: Option, + #[serde(default)] + pub registry: RegistryConfig, +} + +/// A blank value means unset, as it does for the same setting on a worker. +fn configured(value: &Option) -> Option { + value.clone().filter(|v| !v.trim().is_empty()) +} + +/// Where the registry configuration for one `bun install` is written. +/// +/// Deliberately not the install directory: the debug session resolves its `node_modules` +/// symlink into that directory, and the sandbox bind-mounts all of `/tmp` into every session, +/// so a concurrent session could read the credentials of an install in flight. `/var/tmp` is a +/// tmpfs private to each jail (`debugger/nsjail.debug.config.proto`), which also takes the +/// credentials with it when a jailed install is killed. `bun install` reads them from here +/// through `--config` and `HOME`. +const REGISTRY_CONFIG_ROOT: &str = "/var/tmp/windmill-debug-registry"; + +/// How long a configuration directory may survive before the next install treats it as debris. +/// The caller kills an install with SIGKILL, leaving nothing able to clean up after it, and an +/// unjailed install is the only one that writes somewhere outliving the process at all. Well +/// past any install: the caller's own timeout is two minutes by default. +const REGISTRY_CONFIG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +/// Removes the registry configuration when the install ends, whichever way it ends. +struct RegistryConfigDir(Option); + +impl Drop for RegistryConfigDir { + fn drop(&mut self) { + if let Some(dir) = self.0.as_deref() { + remove_registry_config_dir(dir); + } + } +} + +/// Write the registry configuration for one install and return the directory holding it, empty +/// when there is nothing to write. +/// +/// Falls back to the install directory if the private root is not writable: an install that +/// reaches its registry matters more than the isolation above, which only holds for sessions +/// that run under nsjail in the first place. +fn write_registry_config_dir( + job_id: &uuid::Uuid, + job_dir: &str, + registry: &RegistryConfig, +) -> anyhow::Result { + let npmrc = configured(®istry.npmrc); + let npm_config_registry = configured(®istry.npm_config_registry); + let bunfig_install_scopes = configured(®istry.bunfig_install_scopes); + if npmrc.is_none() && npm_config_registry.is_none() && bunfig_install_scopes.is_none() { + return Ok(RegistryConfigDir(None)); + } + + let dir = format!("{}/{}", REGISTRY_CONFIG_ROOT, job_id); + let dir = match create_private_dir(&dir) { + Ok(()) => dir, + Err(e) => { + tracing::warn!("Could not create {dir} ({e}), keeping the registry configuration in the install directory"); + job_dir.to_string() + } + }; + // Claimed before anything is written to it, so a failure below still takes it down. + let held = RegistryConfigDir(Some(dir.clone())); + write_bun_registry_config(&dir, npmrc, npm_config_registry, bunfig_install_scopes)?; + Ok(held) +} + +fn create_private_dir(dir: &str) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::create_dir_all(REGISTRY_CONFIG_ROOT)?; + sweep_stale_registry_config(); + std::fs::DirBuilder::new().mode(0o700).create(dir) + } + #[cfg(not(unix))] + { + sweep_stale_registry_config(); + std::fs::create_dir_all(dir) + } +} + +/// Drop what an install that was killed could not clean up itself. +fn sweep_stale_registry_config() { + let Ok(entries) = std::fs::read_dir(REGISTRY_CONFIG_ROOT) else { + return; + }; + for entry in entries.flatten() { + let stale = entry + .metadata() + .and_then(|m| m.modified()) + .is_ok_and(|m| m.elapsed().is_ok_and(|age| age > REGISTRY_CONFIG_MAX_AGE)); + if stale { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +/// Delete the registry configuration once the install that needed it is over. A failure to +/// remove credentials has to be visible. +fn remove_registry_config_dir(dir: &str) { + if dir.starts_with(REGISTRY_CONFIG_ROOT) { + if let Err(e) = std::fs::remove_dir_all(dir) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {dir}: {e}"); + } + } + return; + } + // The fallback path above put them in the install directory, which has to survive. + for file in [BUN_NPMRC_FILE, BUN_CONFIG_FILE] { + let path = format!("{}/{}", dir, file); + if let Err(e) = std::fs::remove_file(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {path}: {e}"); + } + } + } } #[derive(Serialize)] @@ -195,14 +335,18 @@ fn index_ca_bundle() -> Option { } /// uv registry arguments, mirroring what the job path passes in `python_executor`. -fn uv_registry_args() -> Vec { +fn uv_registry_args(registry: &RegistryConfig) -> Vec { + let index_url = configured(®istry.pip_index_url).or_else(|| PY_INDEX_URL.clone()); + let extra_index_url = + configured(®istry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone()); + let mut args: Vec = vec![]; - if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() { + if let Some(urls) = extra_index_url.as_ref() { for url in urls.split(',') { args.extend(["--extra-index-url".to_string(), url.to_string()]); } } - if let Some(url) = PY_INDEX_URL.as_ref() { + if let Some(url) = index_url.as_ref() { args.extend(["--index-url".to_string(), url.to_string()]); } if let Some(hosts) = TRUSTED_HOST.as_ref() { @@ -217,7 +361,11 @@ fn uv_registry_args() -> Vec { } /// Prepare Python dependencies using uv -async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -> PrepareResponse { +async fn prepare_python_deps_standalone( + code: &str, + python_path: Option<&str>, + registry: &RegistryConfig, +) -> PrepareResponse { // Parse imports from the code let packages = parse_python_imports(code); @@ -253,7 +401,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) - let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); common_uv_envs.insert( "UV_INDEX_STRATEGY".to_string(), - PY_INDEX_STRATEGY.to_string(), + configured(®istry.uv_index_strategy).unwrap_or_else(|| PY_INDEX_STRATEGY.to_string()), ); if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string()); @@ -270,7 +418,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) - common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir); } - let registry_args = uv_registry_args(); + let registry_args = uv_registry_args(registry); // Step 1: Create virtual environment using uv // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry @@ -418,11 +566,12 @@ pub async fn prepare_deps_standalone( code: &str, language: &str, python_path: Option<&str>, + registry: &RegistryConfig, ) -> PrepareResponse { // Route to the appropriate handler based on language match language { "python3" | "python" => { - return prepare_python_deps_standalone(code, python_path).await; + return prepare_python_deps_standalone(code, python_path, registry).await; } "bun" | "typescript" | "deno" => { // Continue with JS/TS handling below @@ -489,7 +638,7 @@ pub async fn prepare_deps_standalone( }; } - let common_bun_proc_envs = get_simple_bun_proc_envs(); + let mut common_bun_proc_envs = get_simple_bun_proc_envs(); // Step 1: Run build.js to generate package.json let output = Command::new(&*BUN_PATH) @@ -582,17 +731,43 @@ pub async fn prepare_deps_standalone( }; } - // Step 2: Run bun install + // Step 2: Run bun install, from the same registry configuration a job installs with. + let mut args = vec!["install".to_string()]; + let registry_config_dir = match write_registry_config_dir(&job_id, &job_dir, registry) { + Ok(dir) => dir, + Err(e) => { + return PrepareResponse { + node_modules_path: None, + venv_path: None, + job_dir: job_dir.clone(), + success: false, + error: Some(format!("Failed to write registry configuration: {}", e)), + install_stderr: None, + }; + } + }; + if let Some(dir) = registry_config_dir.0.as_ref() { + // Only one of the two files exists: `.npmrc` is read from the installer's home, + // `bunfig.toml` only from the path named here. + common_bun_proc_envs.insert("HOME".to_string(), dir.to_string()); + let bunfig = format!("{}/{}", dir, BUN_CONFIG_FILE); + if std::path::Path::new(&bunfig).exists() { + args.push(format!("--config={}", bunfig)); + } + } + let output = Command::new(&*BUN_PATH) .current_dir(&job_dir) .env_clear() .envs(common_bun_proc_envs) - .args(vec!["install"]) + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await; + drop(registry_config_dir); + match output { Ok(out) => { if !out.status.success() { @@ -691,6 +866,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { &request.code, &request.language, request.python_path.as_deref(), + &request.registry, ) .await; println!("{}", serde_json::to_string(&response)?); @@ -700,7 +876,27 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::PrepareResponse; + use super::{PrepareRequest, PrepareResponse}; + + /// The debug service and this CLI are deployed as separate images, and the service + /// forwards `GET /api/debug/registry_config` verbatim, `message` field included. So a + /// request from an older service carries no `registry` at all, and one from a newer + /// service carries fields this binary does not know. + #[test] + fn test_registry_is_optional_and_tolerates_unknown_fields() { + let without: PrepareRequest = + serde_json::from_str(r#"{"code": "import lodash", "language": "bun"}"#).unwrap(); + assert!(without.registry.npm_config_registry.is_none()); + + let with: PrepareRequest = serde_json::from_str( + r#"{"code": "", "language": "bun", "registry": {"npm_config_registry": "https://npm.example", "message": "for the user"}}"#, + ) + .unwrap(); + assert_eq!( + with.registry.npm_config_registry.as_deref(), + Some("https://npm.example") + ); + } /// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's /// stdout, so `install_stderr` has to stay additive: a response without an install failure diff --git a/debugger/Dockerfile b/debugger/Dockerfile index 4c1eb85c98..a80643e823 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -48,6 +48,7 @@ COPY dap_debug_service.ts . COPY dap_websocket_server_bun.ts . COPY env_passthrough.ts . COPY dap_websocket_server.py . +COPY registry_config.ts . # Expose the default port EXPOSE 5679 diff --git a/debugger/README.md b/debugger/README.md index 31599f4db8..1dba49d977 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -75,36 +75,81 @@ Options: | `DAP_NSJAIL_PATH` | nsjail binary path | nsjail | | `DAP_NSJAIL_CONFIG` | nsjail config file path | - | -### Python dependency preparation +### Dependency preparation -Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which -runs `uv` without a database connection. It cannot read the instance settings, so it takes its -registry configuration from the environment of the debug service instead, and the Python server is -handed the resulting venv with `--venv-path`. The install runs in the service rather than in the -session because a private index URL usually embeds credentials and the Python server executes the -debugged script inside its own interpreter, where anything it holds is readable by that script. +Before debugging a script, its imports are installed through `windmill prepare-deps`, which runs +`uv` (Python) or `bun install` (TypeScript) without a database connection. The install runs in the +service rather than in the session because the registry configuration usually embeds credentials +and a debug server executes the submitted script inside a process the script can read; the Python +server is handed only the resulting venv, with `--venv-path`, and a Bun session only the resulting +`node_modules`. -Set these on the debug service. Where two names are listed the first wins; a worker reads the -`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are -only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry -with the other settings): +`DAP_PREPARE_DEPS_TIMEOUT_MS` bounds the install (default 120000); past it the session starts +without its dependencies. When the install fails, the CLI answers `success: false` and carries the +installer's stderr in both `error` and `install_stderr`; the service reports it to the client as an +`output` event, so the reason (unreachable mirror, untrusted certificate, unknown package) reaches +the user instead of a bare `ModuleNotFoundError` at the first import. + +### Registry configuration + +Because `prepare-deps` has no database, the service reads the instance settings for it from +`GET /api/debug/registry_config` on `WINDMILL_BASE_URL` and passes them down over the CLI's stdin +request. It is authorized by the launch token of the session being started, and serves only the +settings that session's own installer runs on, so a TypeScript session's token cannot be used to +read the Python index credentials. + +The token also reaches the browser, so what it can fetch is what a workspace member can fetch. +Sessions started by an operator are refused outright, since an operator cannot run a preview job +either; for a member who can, the npm settings are already exposed by a preview (a worker leaves +the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in), while the Python +index URL, which otherwise only appears as uv's argv, becomes readable where it was not before. + +These settings are Enterprise-only, exactly as they are for jobs, and a CE instance reports that in +the session's output rather than applying them: + +| Setting | Applies to | +|---------|------------| +| `npm_config_registry` | `bun install` registry and its `:_authToken=` | +| `npmrc` | written verbatim as `.npmrc`, taking precedence over `npm_config_registry` | +| `bunfig_install_scopes` | `[install.scopes]` in the generated `bunfig.toml` | +| `pip_index_url` | `uv --index-url` | +| `pip_extra_index_url` | `uv --extra-index-url`, comma-separated | + +`uv_index_strategy` is served on any edition, like it is to a worker. An index URL holding the +`EPHEMERAL_TOKEN` placeholder is not served at all: only a worker can run the command that +substitutes it. + +The credential-bearing files (`.npmrc`, `bunfig.toml`) are written under +`/var/tmp/windmill-debug-registry`, not into the directory the install runs in, and are deleted +when the install ends. That directory is not private to the install: a session resolves its +`node_modules` symlink back into it, and `nsjail.debug.config.proto` bind-mounts the whole of +`/tmp` into every session, so credentials left there would be readable by a concurrent session. +`/var/tmp` is a tmpfs in that same config, one instance per jail, so a session sees an empty one +and a jailed install's credentials go away with the jail even when it is killed (the service kills +an install with SIGKILL, which no cleanup in the installer can survive). An install running +unjailed writes to the host's `/var/tmp` instead, where a directory a kill left behind is removed +by the next install; a session running unjailed is unconfined anyway and sees the whole filesystem, +as it already does the rest of the service's state. + +The rest of the registry configuration has no instance setting and is read from the environment of +the debug service. Where two names are listed the first wins; a worker reads the same names: | Variable | Description | Default | |----------|-------------|---------| -| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI | -| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - | | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | | `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | | `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | | `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | -| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | -| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 | +| `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | -When the install fails, the CLI answers `success: false` and carries the installer's stderr in both -`error` and `install_stderr`; the service reports it to the client as an `output` event, so the -reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a -bare `ModuleNotFoundError` at the first import. +`PY_INDEX_URL` / `PIP_INDEX_URL` and `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL`, along with +`UV_INDEX_STRATEGY`, are still read from the same environment whenever the fetch yields no index: +because the instance has none set, because this is a CE instance, or because the session was not +allowed the settings. A Python debug service configured that way therefore keeps working, but setting +them is an instance-wide decision to install Python dependencies from that index, independent of who +opened the session; leave them unset to let the instance settings alone decide. The npm settings have +no such fallback: the instance settings are the only source. Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the service into each session, since the debugged script needs them for its own outbound calls, exactly diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 6e16bae75a..11616f7b11 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -47,6 +47,7 @@ import { type NsjailConfig } from './dap_websocket_server_bun' import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // ============================================================================ // Configuration @@ -650,17 +651,17 @@ class PythonDebugSession extends BaseDebugSession { * Install the script's imports through `windmill prepare-deps` and return the venv to add to * the debugged script's sys.path. * - * This runs here rather than in the Python server because the registry settings the CLI reads - * (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python - * server executes the submitted script inside its own interpreter: anything in that process is - * recoverable by the script. The service never executes user code, so the credentials stop here. + * This runs here rather than in the Python server because the registry settings the CLI is + * given routinely embed private-registry credentials, and the Python server executes the + * submitted script inside its own interpreter: anything in that process is recoverable by the + * script. The service never executes user code, so the credentials stop here. * * It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee: * `uv pip install` builds source distributions, which executes their build backend's arbitrary * Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the * environment across the boundary, so nothing else has to carry them in. */ - private async prepareDependencies(code: string): Promise { + private async prepareDependencies(code: string, registry: RegistryConfig): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null @@ -688,7 +689,12 @@ class PythonDebugSession extends BaseDebugSession { // site-packages goes on that interpreter's sys.path, and uv otherwise picks its // own, which silently leaves compiled extensions unimportable. stdin: new Blob([ - JSON.stringify({ code, language: 'python3', python_path: config.pythonPath }) + '\n' + JSON.stringify({ + code, + language: 'python3', + python_path: config.pythonPath, + registry + }) + '\n' ]), inheritEnv: true, detached: true @@ -1042,6 +1048,8 @@ class PythonDebugSession extends BaseDebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1055,7 +1063,6 @@ class PythonDebugSession extends BaseDebugSession { return } - const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') @@ -1118,7 +1125,19 @@ sys.stdout.flush() try { if (code) { - this.venvPath = (await this.prepareDependencies(code)) ?? undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs + // a source distribution's build backend, so starting one for a session that is + // already gone executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session torn down during the registry configuration fetch, not installing') + await this.cleanup() + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined } // Installing takes long enough for the client to give up meanwhile, and cleanup() has diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index 8a0f2fb4e4..3b5072b1e6 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -27,6 +27,7 @@ import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // Types for V8 Inspector Protocol interface V8Message { @@ -1512,6 +1513,8 @@ export class DebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1525,7 +1528,6 @@ export class DebugSession { return } - const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') @@ -1557,7 +1559,19 @@ export class DebugSession { // Prepare dependencies using the original code (before any modifications) // This analyzes imports and installs required npm packages if (code) { - this.nodeModulesPath = await this.prepareDependencies(code) || undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs the + // packages' postinstall scripts, so starting one for a session that is already gone + // executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session was torn down during the registry configuration fetch, not installing') + this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined // Installing takes long enough for the client to give up meanwhile, and cleanup() has // then already run: starting the debuggee now would leak a process nothing owns. @@ -1653,10 +1667,18 @@ export class DebugSession { * * Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall * scripts, which is user-supplied code executing next to the other services in the container. - * Its environment is inherited rather than filtered, which is what carries the registry and - * CA settings into the installer (the jail keeps the environment across the boundary). + * Its environment is inherited rather than filtered, which is what carries the CA settings + * into the installer (the jail keeps the environment across the boundary). + * + * The CLI has no database, so `registry` carries the instance's registry settings down to it + * instead. They configure `bun install` and nothing else: the debugged script never gets + * them, since it could read them back out of the process it runs in. */ - private async prepareDependencies(code: string, language: string = 'bun'): Promise { + private async prepareDependencies( + code: string, + registry: RegistryConfig, + language: string = 'bun' + ): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null @@ -1679,7 +1701,7 @@ export class DebugSession { let timedOut = false try { - const input = JSON.stringify({ code, language }) + '\n' + const input = JSON.stringify({ code, language, registry }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) // Spawn the windmill binary with prepare-deps command. Its environment is inherited diff --git a/debugger/nsjail.debug.config.proto b/debugger/nsjail.debug.config.proto index 5e538e6002..65ea3456cf 100644 --- a/debugger/nsjail.debug.config.proto +++ b/debugger/nsjail.debug.config.proto @@ -63,6 +63,15 @@ mount { rw: true } +# Private scratch, one instance per jail. `windmill prepare-deps` writes the registry +# credentials here rather than into its install directory under the shared /tmp above, so no +# other session can read them, and they go away with the jail even when it is killed. +mount { + dst: "/var/tmp" + fstype: "tmpfs" + rw: true +} + # Debugger scripts directory (for Python debugger server) mount { src: "/debugger" diff --git a/debugger/registry_config.ts b/debugger/registry_config.ts new file mode 100644 index 0000000000..b7cb0451e5 --- /dev/null +++ b/debugger/registry_config.ts @@ -0,0 +1,84 @@ +/** + * Dependency-registry settings for a debug session's install. + * + * `windmill prepare-deps` installs a session's imports with no database connection, so the + * instance settings that point at a private npm or pip registry cannot be read there. They + * are fetched here instead, from the backend that signed the session's launch token, and + * passed down to the CLI over its stdin request. + * + * They stop at the installer. A registry URL usually embeds credentials and a debugged + * script can read whatever the process running it holds, so none of these values are ever + * put in a session's environment (see README.md, "Registry configuration"). + */ + +export interface RegistryConfig { + npm_config_registry?: string + npmrc?: string + bunfig_install_scopes?: string + pip_index_url?: string + pip_extra_index_url?: string + uv_index_strategy?: string + /** Why the instance's settings are not in this response, for the user to see. */ + message?: string +} + +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL + +/** + * Bounds how long a launch waits on the backend. The session can still start without the + * settings, it just installs from the public registries, so an unreachable backend must + * not hold it up for longer than the install itself would take. + */ +const FETCH_TIMEOUT_MS = Number(process.env.DAP_REGISTRY_CONFIG_TIMEOUT_MS) || 10_000 + +/** + * Fetch the registry settings for a session, authorized by its launch token. + * + * Never throws and never blocks a launch: on any failure it returns a config carrying only + * a `message`, so the session starts against the public registries and the user is told why + * instead of being left with an unexplained "package not found". + */ +export async function fetchRegistryConfig( + token: string | undefined, + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void } +): Promise { + if (!token || !WINDMILL_BASE_URL) { + return {} + } + + const url = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/registry_config` + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (response.status === 401 || response.status === 403 || response.status === 404) { + // Expected answers, not something the user can act on: a session that may not read + // the settings (an operator's) is refused, and a backend older than this image has + // no such route at all. Both install from the public registries. + logger.info(`Registry configuration not served for this session (${response.status})`) + return {} + } + if (!response.ok) { + const detail = (await response.text().catch(() => '')).trim() + return { + message: `Could not read the registry configuration (${response.status}): ${detail || response.statusText}` + } + } + + const config: RegistryConfig = await response.json() + // The values carry registry credentials, so only their names are logged. + const configured = Object.entries(config) + .filter(([key, value]) => key !== 'message' && value) + .map(([key]) => key) + logger.info( + configured.length > 0 + ? `Registry configuration from instance settings: ${configured.join(', ')}` + : 'No registry configuration set on the instance' + ) + return config + } catch (error) { + logger.warn(`Failed to fetch registry configuration: ${error}`) + return { message: `Could not read the registry configuration: ${error}` } + } +} diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index a0b4986102..9eb2a987f2 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -96,6 +96,7 @@ COPY debugger/dap_debug_service.ts . COPY debugger/dap_websocket_server_bun.ts . COPY debugger/env_passthrough.ts . COPY debugger/dap_websocket_server.py . +COPY debugger/registry_config.ts . COPY debugger/nsjail.debug.config.proto . # Install Python debugger dependencies using uv From 2c189fea14749b8bb4604b1363eef907abc3b129 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 5 Aug 2026 22:17:05 +0200 Subject: [PATCH 09/40] fix(frontend): draw the tab strip's scroll bar instead of the native one (#10547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): draw the tab strip's scroll bar instead of the native one The strip sizes its scroll row to the tabs, but a native horizontal scrollbar claims layout height on top of that: Firefox spends 11px on `scrollbar-width: thin` — `--wm-scrollbar-size` is WebKit-only, so the 4px it asks for is ignored there — which clipped the tabs at the top of the 32px sessions strip and left a wide gutter under them. Hide the native bar and draw a 4px thumb from `scrollLeft`/`scrollWidth` instead: it costs no layout height, is the same size in every engine, and sits on the strip's bottom edge, flush under the tabs. Tabs drop to `h-6` so they clear it, and the strip's default height matches the sessions caller's `h-8` so every strip has the same geometry. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): clamp the tab strip thumb at both ends of its track WebKit's elastic overscroll drives `scrollLeft` negative, which slid the thumb out of the track's left edge and into the strip's padding. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/assets/app.css | 5 +- .../lib/components/common/ScrollableX.svelte | 4 +- .../common/tabs/DraggableTabs.svelte | 178 ++++++++++++++---- frontend/src/routes/kitchen_sink/+page.svelte | 8 +- 4 files changed, 153 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 249532c916..760f4c0261 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -280,8 +280,9 @@ } /* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both - axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size - via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the + axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size + via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes + `thin` itself and spends ~11px of the box on it. Higher specificity than the app-wide `*::-webkit-scrollbar`, so it overrides it. */ .scrollbar-subtle { scrollbar-width: thin; diff --git a/frontend/src/lib/components/common/ScrollableX.svelte b/frontend/src/lib/components/common/ScrollableX.svelte index 836439bfbb..698c7f57c9 100644 --- a/frontend/src/lib/components/common/ScrollableX.svelte +++ b/frontend/src/lib/components/common/ScrollableX.svelte @@ -4,7 +4,9 @@ // Subtle horizontal scroll: native overflow (so wheel/trackpad/drag always // work) with the shared `.scrollbar-subtle` thumb (thin, hover-revealed). Bar // thickness is tunable via the `--wm-scrollbar-size` CSS var (pass through - // `style`), so denser callers (e.g. the tab strip) can shrink it. + // `style`) — WebKit only, so Firefox spends its own ~11px of the box on the + // bar whatever this says. Not for a height-constrained row: draw the thumb + // yourself there, the way the tab strip does. let { class: c = '', style = '', diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index c1b610d49f..d89bd37fa6 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -29,7 +29,6 @@ import { X } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import { untrack } from 'svelte' - import ScrollableX from '../ScrollableX.svelte' interface Props { tabs: TabItem[] @@ -41,7 +40,9 @@ * activated via Enter/Space — lets the active tab host a secondary affordance * (e.g. toggling the breadcrumb picker rendered in `tabAccessory`). */ onActiveClick?: (id: string) => void - /** Extra classes for the outer tab strip. */ + /** Extra classes for the outer tab strip. It is 32px tall unless `trailing` + * content is taller; a fixed height here overrides that, and going taller + * pulls the tabs away from the scroll bar, which stays on the bottom edge. */ class?: string /** Render inside the scroll row, right after the last tab (e.g. a "+" new-tab * button) — scrolls with the tabs, unlike `trailing`. */ @@ -88,6 +89,85 @@ if (!isDragging) dndMiddle = next }) + // Scroll bar. The native one is hidden (`no-scrollbar`) and redrawn here: its + // height is a WebKit-only setting, so Firefox spends 11px of the strip on a + // bar there is no room for and clips the tabs. Ours is 4px in every engine and + // costs no layout height at all. + const MIN_THUMB = 24 + let scrollEl = $state(undefined) + let scrollLeft = $state(0) + let viewport = $state(0) + let content = $state(0) + const scrollable = $derived(Math.max(0, content - viewport)) + const overflowing = $derived(scrollable > 1) + // Clamped to the viewport: a pane dragged shut leaves a few pixels of strip, + // and an unclamped minimum-width thumb would hang out of it. + const thumbWidth = $derived( + overflowing ? Math.min(viewport, Math.max(MIN_THUMB, (viewport / content) * viewport)) : 0 + ) + // Clamped at both ends: `scrollLeft` is fractional on HiDPI while the widths + // are rounded, so the ratio can tip past 1, and WebKit's elastic overscroll + // drives it negative — either way the thumb would leave the track. + const thumbLeft = $derived( + scrollable > 0 + ? Math.max( + 0, + Math.min(viewport - thumbWidth, (scrollLeft / scrollable) * (viewport - thumbWidth)) + ) + : 0 + ) + + function measure() { + const el = scrollEl + if (!el) return + scrollLeft = el.scrollLeft + viewport = el.clientWidth + content = el.scrollWidth + } + + // Both ends move independently: the viewport on a pane resize, the content as + // tabs open, close and get renamed. + $effect(() => { + const el = scrollEl + if (!el) return + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + if (el.firstElementChild) ro.observe(el.firstElementChild) + return () => ro.disconnect() + }) + + // Drag the thumb: pointer capture keeps the gesture alive past the strip's + // edges, and the ratio maps thumb travel back onto scroll travel. Recomputing + // from the anchor each move (rather than accumulating) means clamping at + // either end doesn't drift, and reading the travel live keeps a tab opening + // mid-drag from scaling every later move against a stale track. + function handleThumbPointerDown(e: PointerEvent) { + const el = scrollEl + // Primary button only: a right-click would open the context menu without + // delivering the pointerup that ends the drag. + if (!el || e.button !== 0) return + e.preventDefault() + const target = e.currentTarget as HTMLElement + const startX = e.clientX + const startScroll = el.scrollLeft + target.setPointerCapture(e.pointerId) + const onMove = (ev: PointerEvent) => { + const travel = viewport - thumbWidth + if (travel <= 0) return + el.scrollLeft = startScroll + ((ev.clientX - startX) / travel) * scrollable + } + const onUp = (ev: PointerEvent) => { + target.releasePointerCapture(ev.pointerId) + target.removeEventListener('pointermove', onMove) + target.removeEventListener('pointerup', onUp) + target.removeEventListener('pointercancel', onUp) + } + target.addEventListener('pointermove', onMove) + target.addEventListener('pointerup', onUp) + target.addEventListener('pointercancel', onUp) + } + function handleConsider(e: CustomEvent>) { isDragging = true dndMiddle = e.detail.items @@ -100,7 +180,7 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' @@ -189,41 +269,69 @@
{/snippet} -
- - -
- {#each pinnedLeft as tab (tab.id)} - {@render tabButton(tab)} - {/each} - -
- {#each dndMiddle as tab (tab.id)} -
- {@render tabButton(tab)} -
+
+ +
+
scrollEl && (scrollLeft = scrollEl.scrollLeft)} + class="h-full overflow-x-auto overflow-y-hidden no-scrollbar pl-1" + > + +
+ {#each pinnedLeft as tab (tab.id)} + {@render tabButton(tab)} {/each} + +
+ {#each dndMiddle as tab (tab.id)} + +
+ {@render tabButton(tab)} +
+ {/each} +
+ + {#each pinnedRight as tab (tab.id)} + {@render tabButton(tab)} + {/each} + + {#if afterTabs} + {@render afterTabs()} + {/if}
- - {#each pinnedRight as tab (tab.id)} - {@render tabButton(tab)} - {/each} - - {#if afterTabs} - {@render afterTabs()} - {/if}
- + + {#if overflowing} + + + + {/if} +
{#if trailing}
diff --git a/frontend/src/routes/kitchen_sink/+page.svelte b/frontend/src/routes/kitchen_sink/+page.svelte index 4285ee0921..8681fb78da 100644 --- a/frontend/src/routes/kitchen_sink/+page.svelte +++ b/frontend/src/routes/kitchen_sink/+page.svelte @@ -12,8 +12,8 @@ let tab = $state('button') - // Enough tabs to overflow a narrow strip so the shared ScrollableX hover - // scrollbar is exercised: drag to reorder, hover to reveal the 4px thumb. + // Enough tabs to overflow a narrow strip so the strip's own hover scrollbar is + // exercised: drag to reorder, hover to reveal the 4px thumb. let draggableTabs = $state( Array.from({ length: 14 }, (_, i) => ({ id: `t${i}`, label: `Preview tab ${i + 1}` })) ) @@ -200,8 +200,8 @@ That's the full round-trip.`
- DraggableTabs (uses the shared ScrollableX, 4px bar): hover to reveal the - thumb, drag to reorder. + DraggableTabs (draws its own 4px bar on the strip's bottom edge): hover to reveal the thumb, + drag to reorder.
Date: Wed, 5 Aug 2026 22:18:11 +0200 Subject: [PATCH 10/40] fix: point re-opened previews at the tab already showing them (#10538) * fix: point re-opened previews at the tab already showing them * fix: judge composed preview mutations as one change * fix: treat a fullscreen preview as displayed when deciding to flash --- .../components/sessions/PreviewTabHost.svelte | 24 +++-- .../sessions/sessionPreviewTabs.svelte.ts | 87 ++++++++++++++++-- .../sessions/sessionPreviewTabs.test.ts | 91 +++++++++++++++++++ .../sessions/sessionRuntime.svelte.ts | 19 ++-- .../(root)/(logged)/sessions/+page.svelte | 12 ++- 5 files changed, 203 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 93559023bb..8cd91b456f 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -143,8 +143,10 @@ let flashing = $state(false) let flashTimer: ReturnType | undefined - // Guard against the effect's non-pulse reruns (tab/runtime changes) firing a flash. - let lastPulseNonce = -1 + // Guard against the effect's non-pulse reruns (tab/runtime changes) firing a + // flash. Seeded from the current nonce: a pulse from before this host mounted + // is moot, the tab appearing is itself the change the flash would point at. + let lastPulseNonce = runtime?.previewTabs.focusPulse.nonce ?? -1 $effect(() => { const pulse = runtime?.previewTabs.focusPulse if (!pulse || pulse.nonce === lastPulseNonce) return @@ -242,13 +244,6 @@ {:else if !runtime?.manager.artifacts.loading}
This artifact is no longer available.
{/if} - -
{:else if mounted} {/if} + + + diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index c4fbf47a3f..c96f687650 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -59,13 +59,17 @@ function targetUrl(target: PreviewTarget): string { // Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath` // (bound to the previous editor's item): a new editor re-stamps them, and // navigating to a plain page must drop the stale name so the tab falls back -// to the location label. +// to the location label. Only on an actual change of destination, though — +// nothing re-stamps a tab that stays on the item it already hosts, so wiping +// there would strand its label at the storage path (`…/draft_`). function retargetTab(tab: SessionPreviewTab, url: string): void { + if (tab.url !== url) { + tab.friendlyLabel = undefined + tab.friendlyPath = undefined + tab.editorNamed = undefined + } tab.url = url tab.loc = url - tab.friendlyLabel = undefined - tab.friendlyPath = undefined - tab.editorNamed = undefined } // Strip the query params the sessions preview injects into iframe URLs @@ -166,6 +170,13 @@ export class SessionPreviewTabs { // Ephemeral UI signals — not part of the persisted snapshot. #focusPulse = $state({ id: '', nonce: 0 }) #reloadPulse = $state({ id: '', nonce: 0 }) + // Set while a mutation sequence is being judged as a whole (see asOneChange). + #pulsing = false + // Fullscreen overrides the collapsed layout, so the panel can be on screen + // while `#collapsed` still says otherwise. Page-level state (it outlives a + // session switch, unlike the persisted per-session flag), pushed in here so + // the flash decision reads what the user can actually see. + #fullscreen = false readonly #adapter: PreviewTabsAdapter readonly #flushDelay: number #flushHandle: ReturnType | undefined @@ -228,10 +239,66 @@ export class SessionPreviewTabs { this.#schedulePersist() } + // Whether the panel is on screen at all — fullscreen wins over collapsed. + setFullscreen(fullscreen: boolean): void { + this.#fullscreen = fullscreen + } + + // The tab the user is actually looking at, or undefined when nothing is on screen. + #displayedTab(): SessionPreviewTab | undefined { + if (this.#collapsed && !this.#fullscreen) return undefined + return this.#tabs.find((t) => t.id === this.#activeId) + } + + // Run a caller's own multi-call sequence (select + navigate + reveal) as a + // single change for the flash decision. Judging each call separately would + // flash a tab the sequence had just switched to, since the step that made it + // visible is not the step that finds nothing left to change. `mutate` must be + // synchronous: the verdict is read the moment it returns. + asOneChange(mutate: () => T): T { + return this.#pulsingIfUnchanged(mutate) + } + + // Run a tab mutation, flashing the displayed tab's border when it left the + // panel showing exactly what it already showed. Re-opening a destination that + // is already on screen is otherwise indistinguishable from a dead click. + #pulsingIfUnchanged(mutate: () => T): T { + // Already inside a sequence: that outer call owns the decision, and an + // inner open()/navigate() must not rule on its own slice of it. + if (this.#pulsing) return mutate() + this.#pulsing = true + try { + return this.#pulseIfSameDestination(mutate) + } finally { + this.#pulsing = false + } + } + + #pulseIfSameDestination(mutate: () => T): T { + const before = this.#displayedTab() + const shown = before && { id: before.id, url: before.url, loc: before.loc } + const result = mutate() + const after = this.#displayedTab() + if ( + shown && + after && + after.id === shown.id && + after.url === shown.url && + after.loc === shown.loc + ) { + this.pulseFocus(after.id) + } + return result + } + // Open — or focus, if already shown — a tab for a destination, and reveal the // panel. An editable item dedupes against the tab already hosting that same // (kind, path); anything else dedupes on the tab's observed location. open(target: PreviewTarget): { status: 'opened' | 'focused' } { + return this.#pulsingIfUnchanged(() => this.#open(target)) + } + + #open(target: PreviewTarget): { status: 'opened' | 'focused' } { const editorTarget = editorTargetFor(target) // A fresh session starts collapsed, so without this the tab opens behind a // collapsed panel and the user sees nothing change. @@ -276,8 +343,12 @@ export class SessionPreviewTabs { } // Focus the tab currently *showing* this destination instead of opening a // duplicate. Matched on the observed `loc`, not `url`: a tab that was - // opened here but navigated away no longer counts as showing it. - const shown = this.#tabs.find((t) => t.loc === url) + // opened here but navigated away no longer counts as showing it. Both sides + // are canonicalized because a caller may bake `?workspace=` into the href + // (the frame re-injects it from the session anyway) while the observed loc + // has had it stripped — comparing raw would reopen the page as a duplicate. + const canonicalUrl = canonicalizeObservedLoc(url) + const shown = this.#tabs.find((t) => canonicalizeObservedLoc(t.loc) === canonicalUrl) if (shown) { this.#activeId = shown.id this.#flush() @@ -294,6 +365,10 @@ export class SessionPreviewTabs { // Re-point the active tab at a destination (breadcrumb pick / in-editor link / // iframe-posted editor navigation). navigate(target: PreviewTarget): void { + this.#pulsingIfUnchanged(() => this.#navigate(target)) + } + + #navigate(target: PreviewTarget): void { const t = this.#tabs.find((x) => x.id === this.#activeId) if (!t) return const editorTarget = editorTargetFor(target) diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index b8c1ba2f91..fa3b4ac11d 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -681,4 +681,95 @@ describe('SessionPreviewTabs.pulseFocus', () => { o.pulseFocus('tab-b') expect(o.focusPulse).toEqual({ id: 'tab-b', nonce: 3 }) }) + + it('fires on re-opening whatever the panel already displays, for any tab kind', () => { + const o = owner() + o.open(rawAppTarget) + expect(o.focusPulse.nonce).toBe(0) + o.open(rawAppTarget) + expect(o.focusPulse).toEqual({ id: o.activeId, nonce: 1 }) + // A second tab taking over is its own visible change. + o.open(pageTarget) + expect(o.focusPulse.nonce).toBe(1) + o.open(pageTarget) + expect(o.focusPulse).toEqual({ id: o.activeId, nonce: 2 }) + // Re-pointing the displayed tab elsewhere changes what is on screen. + o.navigate(scriptTarget) + expect(o.focusPulse.nonce).toBe(2) + o.navigate(scriptTarget) + expect(o.focusPulse.nonce).toBe(3) + }) + + it('still flashes a collapsed-but-fullscreen panel', () => { + const o = owner() + o.open(rawAppTarget) + o.setCollapsed(true) + // Fullscreen carries over from the previous session and overrides collapse, + // so the tab is on screen and a re-open of it changes nothing visible. + o.setFullscreen(true) + o.open(rawAppTarget) + expect(o.focusPulse.nonce).toBe(1) + }) + + it('judges a composed select+navigate as one change', () => { + const o = owner() + o.open(pageTarget) + const runs = o.tabs[0].id + o.open(rawAppTarget) + expect(o.focusPulse.nonce).toBe(0) + // open_page reusing a *background* page tab: the switch is the visible change. + o.asOneChange(() => { + o.select(runs) + o.navigate(pageTarget) + }) + expect(o.focusPulse.nonce).toBe(0) + // Same sequence once that tab is already displayed: nothing changes, so flash. + o.asOneChange(() => { + o.select(runs) + o.navigate(pageTarget) + }) + expect(o.focusPulse).toEqual({ id: runs, nonce: 1 }) + }) + + it('keeps the editor-stamped label when re-pointed at the same item', () => { + const o = owner() + o.open(scriptTarget) + o.setEditorFriendlyLabel({ kind: 'script', path: 'u/me/foo' }, 'My script', 'u/me/staged') + // Nothing re-stamps a tab that never changed item, so a wipe here would be + // permanent — and would make the "nothing changed" flash a lie. + o.navigate(scriptTarget) + expect(o.tabs[0].friendlyLabel).toBe('My script') + expect(o.tabs[0].friendlyPath).toBe('u/me/staged') + o.navigate(flowTarget) + expect(o.tabs[0].friendlyLabel).toBeUndefined() + }) + + it('focuses (and flashes) a run tab whose href carries ?workspace=', () => { + const o = owner() + const run: PreviewTarget = { + type: 'page', + href: `${base}/run/job-1?workspace=fork`, + label: 'Run' + } + o.open(run) + // What the frame reports back has the injected params stripped. + o.observeLocation(o.activeId, `${base}/run/job-1?workspace=fork&nomenubar=true`) + expect(o.tabs.length).toBe(1) + expect(o.open(run).status).toBe('focused') + expect(o.tabs.length).toBe(1) + expect(o.focusPulse.nonce).toBe(1) + }) + + it('stays quiet when the re-open is what reveals the tab', () => { + const o = owner() + o.open(rawAppTarget) + o.setCollapsed(true) + // Un-collapsing onto the same tab is already visible. + o.open(rawAppTarget) + expect(o.focusPulse.nonce).toBe(0) + // Switching back to a background tab is too. + o.open(pageTarget) + o.open(rawAppTarget) + expect(o.focusPulse.nonce).toBe(0) + }) }) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 5de8fb88ac..aaf0173140 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -482,14 +482,7 @@ function createRuntime(session: Session): SessionRuntime { } manager.openArtifact = (id, name) => { - // Capture before open() un-collapses / re-activates: flash only when the tab - // was already the displayed one (nothing else visibly changes). - const wasDisplayed = !previewTabs.collapsed - const prevActive = previewTabs.activeId - const { status } = previewTabs.open({ type: 'artifact', id, name }) - if (status === 'focused' && wasDisplayed && previewTabs.activeId === prevActive) { - previewTabs.pulseFocus(previewTabs.activeId) - } + previewTabs.open({ type: 'artifact', id, name }) } manager.closeArtifact = (id) => previewTabs.closeArtifact(id) // Key the store before any configureGlobalMode runs, so a new session's first create shows at once. @@ -1054,9 +1047,13 @@ setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab }) // would silently not re-fire — force a load. Hashless targets need no // reload: focusing the already-correct view is enough. const unchanged = href.includes('#') && (existing.loc || existing.url) === href - owner.select(existing.id) - owner.navigate({ type: 'page', href, label }) - owner.setCollapsed(false) + // One change, not three: switching to a background tab is already visible, + // so the navigate that follows must not read as "nothing happened". + owner.asOneChange(() => { + owner.select(existing.id) + owner.navigate({ type: 'page', href, label }) + owner.setCollapsed(false) + }) if (unchanged) { owner.pulseReload(existing.id) return `Re-opened the ${label} preview tab on the requested view.` diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index acbfb13164..1abf9c2d18 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -318,6 +318,12 @@ let emptyStateNewTabOpen = $state(false) let fullscreen = $state(false) + // Fullscreen is page state, not per-session, so it outlives a session switch — + // tell the incoming session's model, whose own collapsed flag it overrides, or + // re-opening the item plainly on screen would be judged invisible and not flash. + $effect(() => { + owner?.setFullscreen(fullscreen) + }) // Collapse the preview panel to give the chat the full width. Per-session and // owned by the runtime's previewTabs (restored on switch, written back on // toggle) so it survives session switches with the rest of the tab model. @@ -477,8 +483,7 @@ // (or focus, if already shown) the item's preview in the active session's panel — // the visible chat is always the active session, so `owner` is its panel. Read // `owner` lazily inside the handler (not in the effect body) so this registers - // once, not on every session switch. A 'focused' open leaves the tab where it is, - // so pulse it to make the click visibly land. + // once, not on every session switch. $effect(() => { return registerToolDisplayActionHandler('open_item_preview', (action) => { if (action.type !== 'open_item_preview') return @@ -486,8 +491,7 @@ if (!o) return const target = previewTargetForSessionTarget(action.previewKind, action.path) if (!target) return - const { status } = o.open(target) - if (status === 'focused') o.pulseFocus(o.activeId) + o.open(target) }) }) From c59b60c729b03c5be66738b1f4cbf0d989a7b438 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 21:59:42 +0000 Subject: [PATCH 11/40] fix: keep the same_worker pin when a suspend ends without approval (#10552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: keep the same_worker pin when a suspend ends without approval A disapproved or timed-out approval gate hands the flow back through the UpdateFlow channel with unrecoverable = true. That flag means "the previous step's worker died", and it is read by six sites. Five of them happen to want what it does here, but continue_on_same_worker and continue_with_runners do not: the worker that ran the approval step is alive, so unpinning the error handler and routing it by tag breaks the ./shared contract of a same_worker flow and can land it on a worker group that cannot run it — the same defect #10551 fixed for the three producers that hand back a live flow. Replace the boolean with StepFailureKind so the suspend producer can say "worker alive, but this failure is not the module's to handle" instead of overstating a worker death. The failed module's error policy is deliberately still bypassed: the failure is recorded against the step the gate was holding back, which never ran, so its retry would re-open the gate and its continue_on_error would skip it outright (verified: the gated step is marked Failure with a nil job id and the flow jumps past it). suspend. continue_on_disapprove_timeout remains the way to continue past a gate. Co-Authored-By: Claude Opus 5 (1M context) * feat(flow-editor): flag that continue on error does not cover the approval gate A resolved approval is recorded against the step the gate holds back, not the step carrying the suspend, so continue_on_error never sees it: the flow still stops on a disapproval or timeout. Point users at suspend.continue_on_disapprove_timeout, which is what actually continues past a gate, whenever both settings are on and that one is not. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/src/monitor.rs | 25 +++-- backend/tests/suspend_resume.rs | 82 ++++++++++++++ backend/tests/worker.rs | 6 +- .../windmill-worker/src/result_processor.rs | 16 +-- backend/windmill-worker/src/worker.rs | 42 ++++++-- backend/windmill-worker/src/worker_flow.rs | 101 +++++++++--------- .../flows/content/FlowRunSettings.svelte | 16 ++- 7 files changed, 207 insertions(+), 81 deletions(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 336956415e..ac9ba0ce70 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -119,15 +119,15 @@ use windmill_queue::{ }; use windmill_worker::{ result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel, - OtelTracingProxySettings, SameWorkerSender, WorkspaceRegistryMap, BUNFIG_INSTALL_SCOPES, - BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, - JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, - NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, - NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, - SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, - SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + OtelTracingProxySettings, SameWorkerSender, StepFailureKind, WorkspaceRegistryMap, + BUNFIG_INSTALL_SCOPES, BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, + JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, + MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, + NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, + PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, + SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, + SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -4700,7 +4700,12 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n memory_peak, None, error::Error::ExecutionErr(error_message.clone()), - matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie + // a same worker zombie means the worker itself is gone + if matches!(error_kind, ErrorMessage::SameWorker) { + StepFailureKind::Unrecoverable + } else { + StepFailureKind::Normal + }, Some(&same_worker_tx_never_used), "", node_name, diff --git a/backend/tests/suspend_resume.rs b/backend/tests/suspend_resume.rs index 29fe38d2c3..d112ca0b96 100644 --- a/backend/tests/suspend_resume.rs +++ b/backend/tests/suspend_resume.rs @@ -235,6 +235,88 @@ mod suspend_resume { Ok(()) } + /// A suspend gate that ends without approval leaves the worker that ran the approval step + /// alive, so the error handler it routes to must stay pinned to that worker rather than + /// being unpinned and routed by tag — which would break the `./shared` contract of a + /// `same_worker` flow. + #[cfg(feature = "deno_core")] + #[sqlx::test(fixtures("base"))] + async fn disapproved_suspend_keeps_same_worker_pin(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let value: FlowValue = serde_json::from_value(json!({ + "same_worker": true, + "modules": [{ + "id": "a", + "value": { + "input_transforms": { + "port": { "type": "javascript", "expr": "flow_input.port" }, + }, + "type": "rawscript", + "language": "deno", + "content": "\ + export async function main(port) {\ + const job = Deno.env.get('WM_JOB_ID');\ + const token = Deno.env.get('WM_TOKEN');\ + const secret = await (await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\ + { headers: { 'Authorization': `Bearer ${token}` } }\ + )).text();\ + await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs_u/cancel/${job}/0/${secret}?approver=ruben`,\ + { method: 'POST', body: JSON.stringify('from job'), headers: { 'content-type': 'application/json' } }\ + );\ + return 'a ran';\ + }", + }, + "suspend": { "required_events": 1 }, + }, { + "id": "b", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'b ran' }", + }, + // The gate holds `b` back, so `b` never runs and its error policy describes + // nothing: honouring it here would skip `b` instead of reaching the handler. + "continue_on_error": true, + }], + "failure_module": { + "id": "failure", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'handled' }", + }, + }, + }))?; + + let completed = + RunJob::from(JobPayload::RawFlow { value, path: None, restarted_from: None }) + .arg("port", json!(port)) + .run_until_complete(&db, false, port) + .await; + + server.close().await.unwrap(); + + assert_eq!(json!("handled"), completed.json_result().unwrap()); + + let same_worker: Option = sqlx::query_scalar( + "SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'failure'", + ) + .bind(completed.id) + .fetch_one(&db) + .await?; + assert_eq!(Some(true), same_worker); + + Ok(()) + } + /// Test that self-approval is blocked when self_approval_disabled is true. /// /// This test verifies that when a flow has an approval step with self_approval_disabled=true, diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 2e11ff3e4c..67aac0503e 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4055,7 +4055,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { /// Push `flow`, run it on a real worker until its first step is running, then simulate /// `monitor::handle_zombie_jobs` reaping that step unrecoverably (its worker crashed/OOM'd) -/// by calling `handle_job_error(..., unrecoverable = true, ...)` exactly as the monitor does. +/// by calling `handle_job_error(..., StepFailureKind::Unrecoverable, ...)` exactly as the monitor does. /// Returns the flow's completed result. #[cfg(feature = "deno_core")] async fn run_flow_until_step_running_then_fail_unrecoverably( @@ -4070,7 +4070,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( use windmill_common::client::AuthedClient; use windmill_common::KillpillSender; use windmill_queue::{get_queued_job_v2, MiniCompletedJob, SameWorkerPayload}; - use windmill_worker::{JobCompletedSender, SameWorkerSender}; + use windmill_worker::{JobCompletedSender, SameWorkerSender, StepFailureKind}; let flow_id = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) @@ -4135,7 +4135,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( windmill_common::error::Error::ExecutionErr( "simulated worker OOM crash".to_string(), ), - true, // unrecoverable + StepFailureKind::Unrecoverable, Some(&sw_tx), "", "test-monitor", diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index cdfe9895d2..a14b8aa739 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -52,7 +52,7 @@ use crate::{ otel_oss::add_root_flow_job_to_otlp, worker_flow::update_flow_status_after_job_completion, JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload, - UpdateFlow, SAME_WORKER_REQUIREMENTS, + StepFailureKind, UpdateFlow, SAME_WORKER_REQUIREMENTS, }; use windmill_common::client::AuthedClient; @@ -465,7 +465,7 @@ pub fn start_background_processor( worker_dir, stop_early_override, token, - unrecoverable, + step_failure, }), time, }) => { @@ -486,7 +486,7 @@ pub fn start_background_processor( None, Arc::new(result), None, - unrecoverable, + step_failure, &same_worker_tx, &worker_dir, stop_early_override, @@ -792,7 +792,7 @@ pub async fn handle_receive_completed_job( mem_peak, canceled_by, err, - false, + StepFailureKind::Normal, same_worker_tx.clone(), &worker_dir, worker_name, @@ -1594,7 +1594,7 @@ pub async fn process_completed_job( canceled_by, result, started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1701,7 +1701,7 @@ pub async fn process_completed_job( duration_ms: d, }) }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1979,7 +1979,7 @@ pub async fn handle_job_error( mem_peak: i32, canceled_by: Option, err: Error, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: Option<&SameWorkerSender>, worker_dir: &str, worker_name: &str, @@ -2029,7 +2029,7 @@ pub async fn handle_job_error( canceled_by.clone(), Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()), None, - unrecoverable, + step_failure, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(), worker_dir, None, diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b03cfa7e48..4703c12556 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1747,7 +1747,7 @@ pub async fn handle_all_job_kind_error( 0, None, err, - false, + StepFailureKind::Normal, same_worker_tx, &worker_dir, &worker_name, @@ -3639,10 +3639,38 @@ pub struct UpdateFlow { pub worker_dir: String, pub stop_early_override: Option, pub token: String, - /// Whether the flow must be resumed as if the previous step's worker had died: no retry, - /// straight to the failure module, and nothing pinned to this worker. Only true when the - /// flow is handed back from a state no live step can recover from. - pub unrecoverable: bool, + pub step_failure: StepFailureKind, +} + +/// Why the step a flow is being resumed from failed, which bounds what the engine may do next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepFailureKind { + /// The step failed by running, or did not fail at all. + Normal, + /// A suspend gate was disapproved or timed out. The worker that ran the approval step is + /// still alive, but the failure is recorded against the step the gate was holding back, + /// which never ran — so that step's `retry` and `continue_on_error` describe nothing that + /// happened, and honouring them would re-open the gate or skip the step outright. + /// `suspend.continue_on_disapprove_timeout` is how a flow opts into continuing past a gate. + SuspendNotApproved, + /// The step's worker died (OOM/zombie), or the flow status update itself errored. Neither + /// leaves state worth pinning to: in the first case that worker is gone, in the second the + /// flow's own bookkeeping is what just broke. + Unrecoverable, +} + +impl StepFailureKind { + /// Whether the failed module's own `retry` / `continue_on_error` still describe the + /// failure at hand. When they don't, the failure module is the only way forward. + pub fn honors_step_error_policy(self) -> bool { + matches!(self, Self::Normal) + } + + /// Whether follow-up work may still be pinned to the worker that ran the previous step, + /// via `same_worker` or dedicated flow-module runners. + pub fn keeps_worker_pin(self) -> bool { + !matches!(self, Self::Unrecoverable) + } } async fn do_nativets( @@ -3944,8 +3972,8 @@ pub async fn handle_queued_job( flow_runners, &killpill_rx, // A freshly pulled flow job is being executed by a live worker; the prior - // step (if any) completed normally, so this is never unrecoverable here. - false, + // step (if any) completed normally. + StepFailureKind::Normal, )) .warn_after_seconds(10) .await?; diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index df9e63511c..7066b35945 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -15,8 +15,8 @@ use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transfor use crate::js_eval::{eval_timeout, IdContext}; use crate::worker_utils::get_tag_and_concurrency; use crate::{ - JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, UpdateFlow, - KEEP_JOB_DIR, + JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, StepFailureKind, + UpdateFlow, KEEP_JOB_DIR, }; use anyhow::Context; @@ -169,7 +169,7 @@ pub async fn update_flow_status_after_job_completion( canceled_by: Option, result: Arc>, flow_job_duration: Option, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -192,7 +192,7 @@ pub async fn update_flow_status_after_job_completion( stop_early_override, has_triggered_error_handler: false, }; - let mut unrecoverable = unrecoverable; + let mut step_failure = step_failure; loop { potentially_crash_for_testing(); let nrec = match Box::pin(update_flow_status_after_job_completion_internal( @@ -205,7 +205,7 @@ pub async fn update_flow_status_after_job_completion( rec.canceled_by, rec.flow_job_duration.clone(), rec.result, - unrecoverable, + step_failure, same_worker_tx, worker_dir, rec.stop_early_override, @@ -234,7 +234,7 @@ pub async fn update_flow_status_after_job_completion( Arc::new(to_raw_value(&Json(&WrappedError { error: json!(e.to_string()), }))), - true, + StepFailureKind::Unrecoverable, same_worker_tx, worker_dir, rec.stop_early_override, @@ -249,7 +249,7 @@ pub async fn update_flow_status_after_job_completion( .await? } }; - unrecoverable = false; + step_failure = StepFailureKind::Normal; match nrec { UpdateFlowStatusAfterJobCompletion::Done(job) => { @@ -421,7 +421,7 @@ pub async fn update_flow_status_after_job_completion_internal( canceled_by: Option, mut flow_job_duration: Option, result: Arc>, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -1298,11 +1298,11 @@ pub async fn update_flow_status_after_job_completion_internal( }), ) } else { - // An unrecoverable failure (worker crash/OOM) must reach the error handler - // even on a continue_on_error step, so don't advance the step counter past - // the failed module — otherwise the flow would silently continue to the next - // step and hide the worker death. - let inc = if !unrecoverable && continue_on_error { + // A failure the module's error policy does not describe must reach the error + // handler even on a continue_on_error step, so don't advance the step + // counter past the failed module — otherwise the flow would silently + // continue to the next step and hide it. + let inc = if step_failure.honors_step_error_policy() && continue_on_error { let retry = current_module .as_ref() .and_then(|x| x.retry.clone()) @@ -1741,19 +1741,20 @@ pub async fn update_flow_status_after_job_completion_internal( // enclosing job/subflow. Detect that case and treat the flow as successful. let recoverable_failure_at_last_step = !success && is_last_step - && !unrecoverable + && step_failure.honors_step_error_policy() && (skip_seq_branch_failure || skip_loop_failures || continue_on_error); let should_continue_flow = match success { _ if stop_early => stop_early_err_msg.is_some() && flow_value.failure_module.is_some(), // if stop_early_err_msg some, we want to trigger the error handler before stopping the flow, if any _ if flow_job.is_canceled() => false, true => !is_last_step, - // An unrecoverable failure (a step killed by a worker crash/OOM and surfaced by - // the zombie handler, or an error raised while updating the flow status itself) - // must not be retried or silently skipped, but it should still trigger the flow's - // error handler: an OOM/worker death is precisely when the error handler is expected - // to run. Continue the flow only to reach the failure module, never to retry. - false if unrecoverable => { + // A failure the module's error policy does not describe (a worker crash/OOM + // surfaced by the zombie handler, an error raised while updating the flow status, + // a suspend gate that ended without approval) must not be retried or silently + // skipped, but it should still trigger the flow's error handler — that is + // precisely when the error handler is expected to run. Continue the flow only to + // reach the failure module, never to retry. + false if !step_failure.honors_step_error_policy() => { !is_failure_step && !has_triggered_error_handler && flow_value.failure_module.is_some() @@ -1776,7 +1777,7 @@ pub async fn update_flow_status_after_job_completion_internal( success = true; } - tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, unrecoverable = %unrecoverable, + tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, step_failure = ?step_failure, skip_seq_branch_failure = %skip_seq_branch_failure, skip_loop_failures = %skip_loop_failures, current_module_id = %current_module.map(|x| x.id.clone()).unwrap_or_default(), continue_on_error = %continue_on_error, should_continue_flow = %should_continue_flow, "computed if flow should continue"); @@ -2108,7 +2109,7 @@ pub async fn update_flow_status_after_job_completion_internal( worker_name, flow_runners, &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await @@ -2799,10 +2800,9 @@ pub async fn handle_flow( worker_name: &str, flow_runners: Option>, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The previous step failed unrecoverably (e.g. a worker crash/OOM surfaced by the - // zombie handler). The next pushed step can only be the error handler (failure - // module), and it must not be pinned to the dead worker via same_worker. - unrecoverable: bool, + // How the step this flow is resuming from failed, which bounds what may be pushed next: + // see [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> anyhow::Result<()> { let flow = flow_data.value(); @@ -2984,7 +2984,7 @@ pub async fn handle_flow( flow_runners.clone(), job_completed_tx.clone(), &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await?; @@ -3167,10 +3167,9 @@ async fn push_next_flow_job( flow_runners: Option>, job_completed_tx: JobCompletedSender, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The prior step failed unrecoverably (worker crash/OOM). The only step pushed - // from here is the error handler, which must run on a live worker rather than - // being pinned to the dead one via same_worker / dedicated runners. - unrecoverable: bool, + // How the prior step failed, which bounds what may be pushed next: see + // [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> error::Result { let job_root = flow_job .flow_innermost_root_job @@ -3231,7 +3230,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, }))); } @@ -3286,7 +3285,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, } ))); } @@ -3331,7 +3330,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, }))); } } @@ -3664,10 +3663,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - // A suspend that was disapproved or ran out its timeout cannot be - // resumed by anything the flow does next, so the failure module is the - // only way forward. - unrecoverable: true, + step_failure: StepFailureKind::SuspendNotApproved, }))); } } @@ -3737,10 +3733,11 @@ async fn push_next_flow_job( } }; - // An unrecoverable failure (worker crash/OOM) must not be retried — the original worker - // and its state are gone — so skip retry evaluation and fall straight through to the - // failure module below. - let retry = if !unrecoverable && matches!(&status_module, FlowStatusModule::Failure { .. },) { + // Retry is a policy on the step's own execution: skip it for a failure the step did not + // produce by running, and fall straight through to the failure module below. + let retry = if step_failure.honors_step_error_policy() + && matches!(&status_module, FlowStatusModule::Failure { .. },) + { let retry = &module.retry.clone().unwrap_or_default(); evaluate_retry( retry, @@ -3755,13 +3752,13 @@ async fn push_next_flow_job( None }; let get_args_from_id = match &status_module { - // `|| unrecoverable`: a worker crash/OOM routes to the failure module even on a - // continue_on_error step (whose failures are normally tolerated), matching the - // `unrecoverable` decision in update_flow_status_after_job_completion_internal. + // `|| !honors_step_error_policy()`: such a failure routes to the failure module even + // on a continue_on_error step (whose failures are normally tolerated), matching the + // decision in update_flow_status_after_job_completion_internal. FlowStatusModule::Failure { job, .. } if retry.as_ref().is_some() || !module.continue_on_error.is_some_and(|x| x) - || unrecoverable => + || !step_failure.honors_step_error_policy() => { if let Some((fail_count, retry_in)) = retry { tracing::debug!( @@ -4082,7 +4079,7 @@ async fn push_next_flow_job( None, result, None, - false, + StepFailureKind::Normal, same_worker_tx, worker_dir, None, @@ -4110,7 +4107,7 @@ async fn push_next_flow_job( .as_ref() .is_some_and(|fr| fr.job_id == flow_job.id); - let continue_with_runners = !unrecoverable + let continue_with_runners = step_failure.keeps_worker_pin() && (start_runners || (flow_runners.is_some() && !do_not_pass_runners)) && module.suspend.is_none() && module.sleep.is_none(); @@ -4120,10 +4117,10 @@ async fn push_next_flow_job( let job_same_worker = flow_job.same_worker && matches!(flow_job.kind, JobKind::Flow) && flow_job.runnable_id.is_some(); - // After an unrecoverable failure the original worker is gone, so the error handler - // step is pushed as a regular queued job (any live worker can pick it up) instead of - // being signaled to the dead worker via same_worker — which would strand it forever. - let continue_on_same_worker = !unrecoverable + // Without a worker worth pinning to, the error handler step is pushed as a regular queued + // job (any live worker can pick it up) instead of being signaled via same_worker to a + // worker that may be dead — which would strand it forever. + let continue_on_same_worker = step_failure.keeps_worker_pin() && (flow.same_worker || job_same_worker) && module.suspend.is_none() && module.sleep.is_none(); diff --git a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte index 9c0c6d7de5..a1dae0f3a4 100644 --- a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte @@ -107,6 +107,13 @@ const s3Snippet = $derived(s3Language ? s3Scripts[s3Language][s3Kind] : undefined) const concurrencyOn = $derived(hasInlineConcurrency(flowModule)) const concurrencyOff = $derived(!$enterpriseLicense || !concurrencyOn) + // A resolved approval is recorded against the step the gate holds back, not this one, so + // `continue_on_error` never sees it — the suspend option is the only way to continue past it. + const suspendNeedsItsOwnContinueToggle = $derived( + Boolean(flowModule.continue_on_error) && + Boolean(flowModule.suspend) && + !flowModule.suspend?.continue_on_disapprove_timeout + ) {#snippet sectionHeader(title: string)} @@ -153,7 +160,7 @@
-
+
+ {#if suspendNeedsItsOwnContinueToggle} + + This only applies when the step's own code fails. A disapproval or an approval timeout + is not a failure of this step, so it still stops the flow. To continue past those, + turn on "Continue on disapproval/timeout" in the approval settings. + + {/if}
diff --git a/frontend/src/lib/components/graph/flowRunStatus.svelte.ts b/frontend/src/lib/components/graph/flowRunStatus.svelte.ts new file mode 100644 index 0000000000..8f5ee97641 --- /dev/null +++ b/frontend/src/lib/components/graph/flowRunStatus.svelte.ts @@ -0,0 +1,56 @@ +import { getContext, hasContext, setContext } from 'svelte' +import { SvelteMap } from 'svelte/reactivity' +import type { Job } from '$lib/gen' +import type { GraphModuleState } from './model' + +const FLOW_RUN_STATUS_KEY = 'FlowRunStatus' + +export type SuspendStatus = Record + +/** + * Run status is read straight from here by the node and edge renderers rather than + * being carried in their `data`. Baking it into `data` means the only way to show a + * status change is to rebuild every node and edge, which re-runs the sugiyama layout + * and makes xyflow re-measure and re-create the whole graph on every poll. + */ +export class FlowRunStatus { + #moduleStates = new SvelteMap() + flowJob = $state.raw(undefined) + suspendStatus = $state.raw({}) + + getModuleState(id: string | undefined): GraphModuleState | undefined { + return id == undefined ? undefined : this.#moduleStates.get(id) + } + + setModuleStates(next: Record | undefined) { + const incoming = next ?? {} + for (const id of [...this.#moduleStates.keys()]) { + if (!(id in incoming)) { + this.#moduleStates.delete(id) + } + } + // Writing a key invalidates only that key's readers, so one step finishing + // never re-renders the other steps. + for (const [id, state] of Object.entries(incoming)) { + if (this.#moduleStates.get(id) !== state) { + this.#moduleStates.set(id, state) + } + } + } +} + +export function setFlowRunStatusContext(): FlowRunStatus { + const status = new FlowRunStatus() + setContext(FLOW_RUN_STATUS_KEY, status) + return status +} + +/** + * Graphs that never show run status (mini graph, diff viewer) provide no context, so + * every reader has to tolerate its absence. + */ +export function getFlowRunStatusContext(): FlowRunStatus | undefined { + return hasContext(FLOW_RUN_STATUS_KEY) + ? getContext(FLOW_RUN_STATUS_KEY) + : undefined +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 980aa56f5c..bd133dfeb3 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -1,4 +1,4 @@ -import type { FlowModule, Job, PathScript, RawScript, Script } from '$lib/gen' +import type { FlowModule, PathScript, RawScript, Script } from '$lib/gen' import { type Edge } from '@xyflow/svelte' import { getAllModules, getDependeeAndDependentComponents } from '../flows/flowExplorer' import { dfsByModule } from '../flows/previousResults' @@ -132,7 +132,6 @@ export type InputN = { editMode: boolean isRunning: boolean individualStepTests: boolean - flowJob: Job | undefined showJobStatus: boolean flowHasChanged: boolean chatInputEnabled: boolean @@ -148,11 +147,9 @@ export type ModuleN = { id: string parentIds: string[] eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined testModuleState: ModuleTestState | undefined insertable: boolean editMode: boolean - flowJob: Job | undefined isOwner: boolean assets: AssetWithAltAccessType[] | undefined moduleAction: ModuleActionInfo | undefined @@ -167,7 +164,6 @@ export type FailureModuleN = { id: string module: FlowModule eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -178,7 +174,6 @@ export type BranchAllStartN = { id: string branchIndex: number eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined insertable: boolean branchOne: boolean } @@ -189,7 +184,6 @@ export type BranchAllEndN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -199,7 +193,6 @@ export type ForLoopEndN = { id: string eventHandlers: GraphEventHandlers simplifiedTriggerView: boolean - flowModuleState: GraphModuleState | undefined } } @@ -208,7 +201,6 @@ export type ForLoopStartN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined selectedId: string | undefined editMode: boolean simplifiedTriggerView: boolean @@ -222,7 +214,6 @@ export type ResultN = { success: boolean | undefined eventHandlers: GraphEventHandlers editMode: boolean - job: Job | undefined showJobStatus: boolean } } @@ -244,7 +235,6 @@ export type BranchOneStartN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined selected: boolean insertable: boolean label: string @@ -259,7 +249,6 @@ export type BranchOneEndN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined } } @@ -280,7 +269,6 @@ export type NoBranchN = { data: { id: string eventHandlers: GraphEventHandlers - flowModuleState: GraphModuleState | undefined branchOne: boolean label: string branchIndex: number @@ -330,7 +318,6 @@ export type AiToolN = { // Tool of a linked agent: its inputs are editable but its structure comes from the resource, // so it can't be deleted here. readOnly?: boolean - flowModuleStates: Record | undefined } } @@ -352,10 +339,7 @@ export type CollapsedGroupN = { autocollapse: boolean | undefined stepCount: number modules: FlowModule[] - flowModuleStates: Record | undefined - flowJob: Job | undefined isOwner: boolean - suspendStatus: Record showNotes: boolean editMode: boolean eventHandlers: GraphEventHandlers @@ -416,6 +400,9 @@ export function graphBuilder( extra: { disableAi: boolean insertable: boolean + // Only for the parts of the graph's shape a run decides: which loop iteration is + // expanded and where the error-handler marker attaches. Everything a step merely + // displays comes from FlowRunStatus, never from here. flowModuleStates: Record | undefined testModuleStates: ModulesTestStates | undefined moduleActions?: Record @@ -428,9 +415,7 @@ export function graphBuilder( isOwner: boolean isRunning: boolean individualStepTests: boolean - flowJob: Job | undefined showJobStatus: boolean - suspendStatus: Record flowHasChanged: boolean chatInputEnabled: boolean additionalAssetsMap?: Record @@ -481,12 +466,10 @@ export function graphBuilder( id: module.id, parentIds: [], eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], testModuleState: extra.testModuleStates?.states?.[module.id], insertable: extra.insertable && !module.id.startsWith('subflow:'), editMode: extra.editMode, isOwner: extra.isOwner, - flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), moduleAction: extra.moduleActions?.[module.id], ...extraData @@ -511,8 +494,7 @@ export function graphBuilder( data: { id: module.id, module, - eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id] + eventHandlers: eventHandlers }, type: 'failureModule', selectable: false @@ -609,7 +591,11 @@ export function graphBuilder( disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', index, - ...extra, + // Only what the edge renderer reads. Anything listed here lands on every edge, + // so a value that changes each poll invalidates the whole edge set and makes + // Svelte re-create each one. + disableAi: extra.disableAi, + isOwner: extra.isOwner, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) }, @@ -631,7 +617,6 @@ export function graphBuilder( editMode: extra.editMode, isRunning: extra.isRunning, individualStepTests: extra.individualStepTests, - flowJob: extra.flowJob, showJobStatus: extra.showJobStatus, flowHasChanged: extra.flowHasChanged, chatInputEnabled: extra.chatInputEnabled, @@ -672,7 +657,6 @@ export function graphBuilder( eventHandlers: eventHandlers, success: success, editMode: extra.editMode, - job: extra.flowJob, showJobStatus: extra.showJobStatus }, type: 'result' @@ -741,10 +725,7 @@ export function graphBuilder( modules: leafIds .map((id) => moduleMap.get(id)) .filter((m): m is FlowModule => !!m), - flowModuleStates: extra.flowModuleStates, - flowJob: extra.flowJob, isOwner: extra.isOwner, - suspendStatus: extra.suspendStatus, showNotes, editMode: prefix == undefined && extra.editMode, eventHandlers @@ -866,8 +847,7 @@ export function graphBuilder( id: `${module.id}-end`, data: { id: module.id, - eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id] + eventHandlers: eventHandlers }, type: 'branchAllEnd' } @@ -882,7 +862,6 @@ export function graphBuilder( id: module.id, branchIndex: -1, eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], branchOne: false, label: 'No branches' }, @@ -908,7 +887,6 @@ export function graphBuilder( id: module.id, branchIndex: branchIndex, eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], insertable: extra.insertable, branchOne: false }, @@ -954,7 +932,6 @@ export function graphBuilder( simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, - flowModuleState: extra.flowModuleStates?.[module.id], selectedId: extra.selectedId }, type: 'forLoopStart' @@ -975,8 +952,7 @@ export function graphBuilder( data: { id: module.id, eventHandlers: eventHandlers, - simplifiedTriggerView, - flowModuleState: extra.flowModuleStates?.[module.id] + simplifiedTriggerView }, type: 'forLoopEnd' } @@ -1046,7 +1022,6 @@ export function graphBuilder( id: `${module.id}-end`, data: { eventHandlers: eventHandlers, - flowModuleState: extra.flowModuleStates?.[module.id], id: module.id }, type: 'branchOneEnd' @@ -1062,7 +1037,6 @@ export function graphBuilder( eventHandlers: eventHandlers, insertable: extra.insertable, preLabel: undefined, - flowModuleState: extra.flowModuleStates?.[module.id], selected: false, modules: module.value.default }, @@ -1098,7 +1072,6 @@ export function graphBuilder( branchIndex: branchIndex, eventHandlers: eventHandlers, insertable: extra.insertable, - flowModuleState: extra.flowModuleStates?.[module.id], selected: false, modules: branch.modules }, diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 650831a65f..89e7c88c94 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -8,12 +8,13 @@ import { NODE_WITH_WRITE_ASSET_Y_OFFSET } from '../nodes/AssetNode.svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import type { Job } from '$lib/gen' - import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() let { id, @@ -39,10 +40,7 @@ enableTrigger: boolean disableAi: boolean disableMoveIds: string[] - flowModuleStates: Record | undefined isOwner: boolean - flowJob: Job | undefined - suspendStatus?: Record shouldOffsetInsertBtnDueToAssetNode?: boolean } } = $props() @@ -75,12 +73,13 @@ // TODO: this is a hack to show the waiting for events indicator on the edge a proper way would be to have a edge state // and handle the edge state in the graph builder let waitingForEvents = $derived( - data?.flowModuleStates?.[data.targetId]?.type === 'WaitingForEvents' || - data?.flowModuleStates?.[`${data.sourceId}-v`]?.type === 'WaitingForEvents' + flowRunStatus?.getModuleState(data.targetId)?.type === 'WaitingForEvents' || + flowRunStatus?.getModuleState(`${data.sourceId}-v`)?.type === 'WaitingForEvents' ) + let flowJob: Job | undefined = $derived(flowRunStatus?.flowJob) let suspendStatus: Record | undefined = $derived( - data?.suspendStatus + flowRunStatus?.suspendStatus ) let centerY = $derived( @@ -132,7 +131,7 @@ - {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} + {#if waitingForEvents && flowJob && flowJob.type === 'QueuedJob'}
@@ -146,10 +145,10 @@
- {#if data?.flowJob && data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + {#if flowJob && flowJob.flow_status?.modules?.[flowJob.flow_status?.step]?.type === 'WaitingForEvents'} diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 8913aa9070..1876d5af33 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -43,7 +43,7 @@ let computeAIToolNodesCache: | { nodes: (Node & NodeLayout)[] - hasFlowModuleStates: boolean + agentActions: Record linkedAgentTools: Record | undefined ret: ReturnType } @@ -69,6 +69,22 @@ } } + function agentActionsOf( + nodes: (Node & NodeLayout)[], + flowModuleStates: Record | undefined, + insertable: boolean + ): Record { + const actions: Record = {} + // The editor renders the static tool set and ignores the run's actions, so snapshotting + // them there would deep-clone a value that changes every poll and never matches. + if (insertable) return actions + for (const node of nodes) { + if (node.type !== 'module' || node.data.module.value.type !== 'aiagent') continue + actions[node.id] = $state.snapshot(flowModuleStates?.[node.id]?.agent_actions) + } + return actions + } + export function computeAIToolNodes( nodes: (Node & NodeLayout)[], eventHandlers: GraphEventHandlers, @@ -83,7 +99,10 @@ } { if ( computeAIToolNodesCache && - !!flowModuleStates === computeAIToolNodesCache.hasFlowModuleStates && + deepEqual( + agentActionsOf(nodes, flowModuleStates, insertable), + computeAIToolNodesCache.agentActions + ) && deepEqual(nodes.map(getComparableNode), computeAIToolNodesCache.nodes) && deepEqual(linkedAgentTools, computeAIToolNodesCache.linkedAgentTools) ) { @@ -191,8 +210,7 @@ // misroute agent-node clicks into the graph's manual aiTool selection path. selectTarget: isLinkedAgent && !agentActions ? node.id : undefined, insertable, - readOnly: isLinkedAgent, - flowModuleStates + readOnly: isLinkedAgent }, id: `${node.id}-tool-${tool.id}`, width: inputToolWidth, @@ -253,7 +271,7 @@ computeAIToolNodesCache = { nodes: nodes.map(getComparableNode), - hasFlowModuleStates: !!flowModuleStates, + agentActions: agentActionsOf(nodes, flowModuleStates, insertable), linkedAgentTools: $state.snapshot(linkedAgentTools), ret } @@ -277,6 +295,7 @@ import { getNodeColorClasses } from '../../util' import { deepEqual } from 'fast-equals' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' let hover = $state(false) @@ -286,10 +305,11 @@ } let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() const { selectionManager } = getGraphContext() - const flowModuleState = $derived(data.flowModuleStates?.[data.moduleId]) + const flowModuleState = $derived(flowRunStatus?.getModuleState(data.moduleId)) let colorClasses = $derived( getNodeColorClasses( data.nameError ? 'Failure' : flowModuleState?.type, diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts index cdc6fa3f3b..a187c97436 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts @@ -83,6 +83,30 @@ describe('computeAIToolNodes', () => { } }) + it('picks up a tool call that arrives without moving any node', () => { + // Run status lives outside node data, so the memo has to key on the agent's actions + // itself. Two calls occupy one row, i.e. identical positions, so a memo keyed only on + // the nodes would serve the stale single-tool result forever. + const node = aiAgentNode('agent', [ + { id: 'tool_a', summary: 'my_tool', value: { tool_type: 'flowmodule', type: 'script' } } + ]) + const stateWith = (n: number) => + ({ + agent: { + type: 'InProgress', + agent_actions: Array.from({ length: n }, (_, i) => ({ + type: 'tool_call', + function_name: 'my_tool', + module_id: 'tool_a', + job_id: `j${i}` + })) + } + }) as any + + expect(computeAIToolNodes([node], eventHandlers, false, stateWith(1)).toolNodes.length).toBe(1) + expect(computeAIToolNodes([node], eventHandlers, false, stateWith(2)).toolNodes.length).toBe(2) + }) + it('still flags genuinely duplicate tool names in the editor (static tool set)', () => { const node = aiAgentNode('agent2', [ { id: 't1', summary: 'dup', value: { tool_type: 'flowmodule', type: 'script' } }, diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte index 9682d941b6..2f93567cc0 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchAllStart.svelte @@ -7,17 +7,19 @@ import type { BranchAllStartN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' import { computeBorderStatus } from '../utils' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: BranchAllStartN['data'] id: string } let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() const { selectionManager } = getGraphContext() let borderStatus = $derived( - computeBorderStatus(data.branchIndex, 'branchall', data.flowModuleState) + computeBorderStatus(data.branchIndex, 'branchall', flowRunStatus?.getModuleState(data.id)) ) diff --git a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte index 9902582ee0..3ba33a33b0 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/BranchOneStart.svelte @@ -7,6 +7,7 @@ import type { BranchOneStartN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' import { computeBorderStatus } from '../utils' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: BranchOneStartN['data'] id: string @@ -14,11 +15,12 @@ const { selectionManager } = getGraphContext() let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() // branchIndex is -1 for the default branch and 0-based for explicit branches; // branchChosen is 0 for default and 1-based, hence the +1. let borderStatus = $derived( - computeBorderStatus(data.branchIndex + 1, 'branchone', data.flowModuleState) + computeBorderStatus(data.branchIndex + 1, 'branchone', flowRunStatus?.getModuleState(data.id)) ) diff --git a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte index 98cae2b050..a4c3917075 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/CollapsedGroupNode.svelte @@ -8,6 +8,7 @@ import { Hourglass } from 'lucide-svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import { dfs } from '$lib/components/flows/dfs' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: CollapsedGroupN['data'] @@ -16,6 +17,8 @@ let { data, id }: Props = $props() + const flowRunStatus = getFlowRunStatusContext() + let outlineColorClass = $derived( (NOTE_COLORS[(data.color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]) .outline @@ -31,8 +34,8 @@ let waitingForEvents = $derived( allModuleIds.some( (mid) => - data.flowModuleStates?.[mid]?.type === 'WaitingForEvents' || - data.flowModuleStates?.[`${mid}-v`]?.type === 'WaitingForEvents' + flowRunStatus?.getModuleState(mid)?.type === 'WaitingForEvents' || + flowRunStatus?.getModuleState(`${mid}-v`)?.type === 'WaitingForEvents' ) ) @@ -55,16 +58,12 @@ /> {#if data.modules && data.modules.length > 0}
- +
{/if}
- {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'} + {#if waitingForEvents && flowRunStatus?.flowJob && flowRunStatus.flowJob.type === 'QueuedJob'}
@@ -79,16 +78,16 @@
- {#if data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} + {#if flowRunStatus?.flowJob?.flow_status?.modules?.[flowRunStatus.flowJob.flow_status?.step]?.type === 'WaitingForEvents'} - {:else if data.suspendStatus && Object.keys(data.suspendStatus).length > 0} + {:else if flowRunStatus?.suspendStatus && Object.keys(flowRunStatus.suspendStatus).length > 0}
- {#each Object.values(data.suspendStatus) as suspendCount (suspendCount.job.id)} + {#each Object.values(flowRunStatus.suspendStatus) as suspendCount (suspendCount.job.id)} diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index 195f920d03..c59a775bd2 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -12,6 +12,7 @@ import type { FlowEditorContext } from '$lib/components/flows/types' import { MessageSquare } from 'lucide-svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' import FunnelCog from '$lib/components/icons/FunnelCog.svelte' interface Props { @@ -21,6 +22,7 @@ let { data }: Props = $props() const { selectionManager, diffManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() const flowEditorContext = getContext('FlowEditorContext') const { previewArgs, flowStore } = flowEditorContext || {} @@ -110,7 +112,7 @@ data.eventHandlers.hideJobStatus() }} individualStepTests={data.individualStepTests} - job={data.flowJob} + job={flowRunStatus?.flowJob} showJobStatus={data.showJobStatus} flowHasChanged={data.flowHasChanged} > diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 01955d5e02..16d8a5c25d 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -8,6 +8,7 @@ import { isMac, type Item } from '$lib/utils' import { getContext } from 'svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: ModuleN['data'] @@ -17,11 +18,12 @@ // Get NoteEditor context for group note creation const noteEditorContext = getNoteEditorContext() + const flowRunStatus = getFlowRunStatusContext() let state = $derived.by(() => { return data.testModuleState - ? (jobToGraphModuleState(data.testModuleState) ?? data.flowModuleState) - : data.flowModuleState + ? (jobToGraphModuleState(data.testModuleState) ?? flowRunStatus?.getModuleState(data.id)) + : flowRunStatus?.getModuleState(data.id) }) let flowJobs = $derived( @@ -152,7 +154,7 @@ data.eventHandlers.updateMock(detail) }} onEditInput={data.eventHandlers.editInput} - flowJob={data.flowJob} + flowJob={flowRunStatus?.flowJob} isOwner={data.isOwner} maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value ? () => { diff --git a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte index 13ede87991..88febcd6ca 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ResultNode.svelte @@ -3,6 +3,7 @@ import NodeWrapper from './NodeWrapper.svelte' import type { ResultN } from '../../graphBuilder.svelte' import { getGraphContext } from '../../graphContext' + import { getFlowRunStatusContext } from '../../flowRunStatus.svelte' interface Props { data: ResultN['data'] @@ -12,6 +13,7 @@ let { data, id }: Props = $props() const { selectionManager } = getGraphContext() + const flowRunStatus = getFlowRunStatusContext() @@ -27,7 +29,7 @@ }} nodeKind="result" editMode={data.editMode} - job={data.job} + job={flowRunStatus?.flowJob} showJobStatus={data.showJobStatus} /> {/snippet} From e4e7782517a9fd0fbbec020694b263a948ed1c0c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 03:23:10 +0200 Subject: [PATCH 15/40] fix: restore the flow expression editor's property side panel (#10555) * fix: give flow expression editors their property side panel back * fix: keep the picker column tied to an input that can receive the pick --- .../lib/components/InputTransformForm.svelte | 60 +++++----- .../content/BranchPredicateEditor.svelte | 3 +- .../content/FlowEnvironmentVariables.svelte | 6 +- .../components/flows/content/FlowLoop.svelte | 6 +- .../flows/content/FlowModuleEarlyStop.svelte | 6 +- .../flows/content/FlowModuleSkip.svelte | 3 +- .../flows/content/FlowModuleSleep.svelte | 3 +- .../flows/content/FlowModuleSuspend.svelte | 4 +- .../flows/content/FlowModuleTimeout.svelte | 2 +- .../flows/content/FlowRetries.svelte | 3 +- .../flows/propPicker/ExpressionPicker.svelte | 105 ------------------ .../flows/propPicker/PropPickerWrapper.svelte | 104 +++++++++++------ 12 files changed, 116 insertions(+), 189 deletions(-) delete mode 100644 frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index da89d61f12..5df67638b0 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -37,7 +37,6 @@ import type { PickableProperties } from './flows/previousResults' import { twMerge } from 'tailwind-merge' import FlowPlugConnect from './FlowPlugConnect.svelte' - import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte' import { deepEqual } from 'fast-equals' import S3ArrayHelperButton from './S3ArrayHelperButton.svelte' import { inputBorderClass } from './text_input/TextInput.svelte' @@ -157,10 +156,6 @@ const propPickerWrapperContext: PropPickerWrapperContext | undefined = getContext('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, @@ -365,6 +360,18 @@ }) } + /** A predicate is usually half-written when you reach for a property, so insert at the + * cursor and leave the rest of the expression alone. Only a field that isn't an + * expression yet gets replaced outright. */ + function pickIntoArg(path: string) { + if (propertyType === 'javascript' && monaco) { + propPickerWrapperContext?.onPick?.(path) + } else { + connectProperty(path) + } + dispatch('change', { argName }) + } + function connectProperty(rawValue: string) { // Extract path from variable('x') or resource('x') format const varMatch = variableMatch(rawValue) @@ -465,8 +472,21 @@ } } + // The column beside a settings row delivers here rather than through the host's `select` + // handler, which can only reach a mounted expression editor. A collapsed setting has no + // field at all, so it gives the target up and the column closes with it. + $effect(() => { + if (pickerMode !== 'sidePane') return + propPickerWrapperContext?.setPickTarget?.( + collapsed ? undefined : { id: argName, onSelect: pickIntoArg } + ) + }) + onDestroy(() => { updatePropsBeingEdited(false) + if (pickerMode === 'sidePane') { + propPickerWrapperContext?.setPickTarget?.(undefined) + } }) let prevArg: any = undefined @@ -613,27 +633,7 @@ /> {/if} - {#if propPickerWrapperContext && pickerMode === 'popover'} - - { - // A predicate is usually half-written when you reach for a property, so - // insert at the cursor and leave the rest of the expression alone. Only - // a field that isn't an expression yet gets replaced outright. - if (propertyType === 'javascript' && monaco) { - propPickerWrapperContext.onPick?.(path) - } else { - connectProperty(path) - } - dispatch('change', { argName }) - }} - /> - {:else if propPickerWrapperContext} + {#if propPickerWrapperContext} { - connectProperty(path) - dispatch('change', { argName }) + if (pickerMode === 'sidePane') { + pickIntoArg(path) + } else { + connectProperty(path) + dispatch('change', { argName }) + } return true }) } diff --git a/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte b/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte index 493f04b86e..949862632a 100644 --- a/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte +++ b/frontend/src/lib/components/flows/content/BranchPredicateEditor.svelte @@ -46,10 +46,9 @@ { editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte index 44204dc786..cd14ddc23a 100644 --- a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte +++ b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte @@ -234,10 +234,8 @@ connectProp: () => {}, propPickerConfig: writable(undefined), clearConnect: () => {}, - pickerMode: () => 'popover' as const, - pickableProperties: () => undefined, - result: () => undefined, - extraResults: () => undefined, + pickerMode: () => 'pane' as const, + setPickTarget: () => {}, onPick: () => {}, exprBeingEdited: writable([]) }) diff --git a/frontend/src/lib/components/flows/content/FlowLoop.svelte b/frontend/src/lib/components/flows/content/FlowLoop.svelte index d78a3199b7..bfb80addd6 100644 --- a/frontend/src/lib/components/flows/content/FlowLoop.svelte +++ b/frontend/src/lib/components/flows/content/FlowLoop.svelte @@ -226,10 +226,9 @@ {#if selectedTab === 'loop'}
{ editor?.insertAtCursor(detail) @@ -323,10 +322,9 @@ {#if mod.value.parallel}
{ parallelismEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte index 8f0f091320..e9a824c294 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleEarlyStop.svelte @@ -256,12 +256,11 @@ {#if blocks !== 'all-iters' && !isBranchAll}
{ stopAfterEditor?.insertAtCursor(detail) @@ -299,11 +298,10 @@ {#if blocks !== 'stop-after' && (isLoop || isBranchAll)}
{ stopAfterAllItersEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte index 51d75b0ca6..40fb32b064 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSkip.svelte @@ -71,11 +71,10 @@
{ editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte index 03dcf2b6f5..f0645c4854 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte @@ -77,11 +77,10 @@ {#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
{ editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index a20bb8c671..43c65bc482 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -219,9 +219,9 @@ for any) { editor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte index 9f6a1b32b2..ae4c614ee2 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleTimeout.svelte @@ -78,7 +78,7 @@ {#if flowModule.timeout && schema.properties['timeout']}
{ retryIfEditor?.insertAtCursor(detail) diff --git a/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte b/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte deleted file mode 100644 index 6e29efe58d..0000000000 --- a/frontend/src/lib/components/flows/propPicker/ExpressionPicker.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - - - (detail ? connect.arm({ id, onSelect }) : connect.disarm())} -> - {#snippet trigger()} - - {/snippet} - {#snippet content()} -
- {#if pickableProperties} - { - connect.resolve(detail) - open = false - }} - /> - {:else} -
Nothing to pick from yet.
- {/if} -
- {/snippet} -
diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index 7820b1a53b..5fce4a972a 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -13,17 +13,17 @@ inputMatches: Writable<{ word: string; value: string }[] | undefined> connectProp: (propName: string, onSelect: SelectCallback) => void clearConnect: () => void - /** 'popover' hangs the picker off each input's own connect button instead of - * taking a pane — for single-argument settings rows, which are not the step's - * input form. */ - pickerMode: () => 'pane' | 'popover' - /** The wrapper owns these; nested inputs receive none of their own. */ - pickableProperties: () => PickableProperties | undefined - /** The step's own result, and anything extra worth offering beside it (a loop's - * `all_iters`). Only the pane renders them directly — in popover mode the picker - * hangs off each input, so it reads them from here instead. */ - result: () => any - extraResults: () => any + /** Where the properties are offered, and therefore what a pick does: + * - 'pane': the step's input form — a pick replaces the argument outright. + * - 'sidePane': a settings row — a pick lands at the expression's cursor, since a + * half-written predicate must survive it. */ + pickerMode: () => 'pane' | 'sidePane' + /** The single input a `sidePane` column belongs to. It stays a destination for as + * long as its field is mounted, so a pick lands whether or not a connect is armed — + * a static field has no editor for the host's own `select` handler to write into. + * `undefined` (the setting was switched off) leaves the column with nowhere to + * deliver, so it closes. */ + setPickTarget: (target: { id: string; onSelect: (path: string) => void } | undefined) => void /** Deliver a pick the way the pane does — as a `select` event, so each setting's own * handler inserts it at the cursor. Replacing the whole value is right for a step * input but destroys a half-written predicate. */ @@ -37,6 +37,7 @@ import PropPickerResult from '$lib/components/propertyPicker/PropPickerResult.svelte' import { clickOutside } from '$lib/utils' import { createEventDispatcher, getContext, setContext } from 'svelte' + import { fade } from 'svelte/transition' import { Pane, Splitpanes } from 'svelte-splitpanes' import { writable, type Writable } from 'svelte/store' import type { PickableProperties } from '../previousResults' @@ -55,8 +56,9 @@ noPadding?: boolean paneClass?: string /** Settings rows reuse the step-input form for one argument but are not the input - * form; their picker belongs in a popover. */ - popover?: boolean + * form: their picker is a column beside the row, revealed while the expression is + * being written, rather than a permanent split of the panel. */ + sidePane?: boolean children?: import('svelte').Snippet } @@ -70,7 +72,7 @@ notSelectable = false, noPadding = false, paneClass = '', - popover = false, + sidePane = false, children }: Props = $props() @@ -79,6 +81,7 @@ >(undefined) const inputMatches = writable<{ word: string; value: string }[] | undefined>(undefined) + const exprBeingEdited = writable([]) const dispatch = createEventDispatcher() const propPickerContext = getContext('PropPickerContext') @@ -100,13 +103,14 @@ propPickerConfig, inputMatches, connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }), - clearConnect: connect.disarm, - pickerMode: () => (popover ? 'popover' : 'pane'), - pickableProperties: () => pickableProperties, - result: () => result, - extraResults: () => extraResults, + clearConnect: closePicker, + pickerMode: () => (sidePane ? 'sidePane' : 'pane'), + setPickTarget: (target) => { + pickTarget = target + if (!target) closePicker() + }, onPick: (path) => dispatch('select', path), - exprBeingEdited: writable([]) + exprBeingEdited }) async function getPropPickerElements(): Promise { @@ -116,9 +120,38 @@ } let rightPaneHeight: number = $state(0) + + let pickTarget: { id: string; onSelect: (path: string) => void } | undefined = $state(undefined) + + // The side column stays put once the row is being worked on: picking a property blurs + // the editor, so closing on blur would take the column away mid-click. It is dismissed + // deliberately instead — by clicking away, by the input's own connect button, or by the + // setting being switched off. + let sidePaneOpen = $state(false) + $effect(() => { + if ($propPickerConfig != undefined || $exprBeingEdited.length > 0) { + sidePaneOpen = true + } + }) + + function closePicker() { + connect.disarm() + // A switched-off setting unmounts its editor rather than blurring it, so the focus + // claim outlives the field — left standing, it reopens the column on the next tick. + exprBeingEdited.set([]) + sidePaneOpen = false + } {#snippet pickerBody()} + + {@const deliver = (path: string) => + connect.armed + ? connect.resolve(path) + : pickTarget + ? pickTarget.onSelect(path) + : dispatch('select', path)}
{ - dispatch('select', detail) - connect.resolve(detail) - }} + on:select={({ detail }) => deliver(detail)} /> {:else if pickableProperties} { - dispatch('select', detail) - connect.resolve(detail) - }} + on:select={({ detail }) => deliver(detail)} /> {/if} @@ -167,13 +194,24 @@ // Through the controller, not the stores: it owns the armed target, and a // target left armed here would make the next click on that same input // read as a toggle-off. - onClickOutside: connect.disarm + onClickOutside: closePicker }} > - {#if popover} - - {@render children?.()} + {#if sidePane} + +
+
{@render children?.()}
+ {#if sidePaneOpen && (pickableProperties != undefined || result != undefined)} + +
+ {@render pickerBody()} +
+ {/if} +
{:else} From 386c66bef0534d4a63b3220050e98b9654ef8f34 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 03:39:43 +0200 Subject: [PATCH 16/40] fix: open the expression property column on demand, not from focus (#10558) --- .../lib/components/InputTransformForm.svelte | 7 +++++ .../content/FlowEnvironmentVariables.svelte | 1 + .../flows/propPicker/PropPickerWrapper.svelte | 29 ++++++++++--------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 5df67638b0..5fd6ff9816 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -161,6 +161,7 @@ connectProp: focusProp, propPickerConfig, clearConnect: clearFocus, + openPicker, exprBeingEdited } = propPickerWrapperContext ?? {} @@ -941,7 +942,12 @@ {/snippet} {:else if argKind === 'javascript' && arg.expr != undefined} + +
openPicker?.()} class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`} > { focused = true updatePropsBeingEdited(true) + openPicker?.() }} on:blur={() => { focused = false diff --git a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte index cd14ddc23a..4af530a08a 100644 --- a/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte +++ b/frontend/src/lib/components/flows/content/FlowEnvironmentVariables.svelte @@ -234,6 +234,7 @@ connectProp: () => {}, propPickerConfig: writable(undefined), clearConnect: () => {}, + openPicker: () => {}, pickerMode: () => 'pane' as const, setPickTarget: () => {}, onPick: () => {}, diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index 5fce4a972a..8bbe9b9792 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -13,6 +13,10 @@ inputMatches: Writable<{ word: string; value: string }[] | undefined> connectProp: (propName: string, onSelect: SelectCallback) => void clearConnect: () => void + /** Reveal a `sidePane` column: its input is being written in. Reaching for the editor + * again after dismissing the column has to say so, since an editor that kept focus + * throughout emits no new focus event. */ + openPicker: () => void /** Where the properties are offered, and therefore what a pick does: * - 'pane': the step's input form — a pick replaces the argument outright. * - 'sidePane': a settings row — a pick lands at the expression's cursor, since a @@ -102,8 +106,12 @@ setContext('PropPickerWrapper', { propPickerConfig, inputMatches, - connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }), + connectProp: (propName, onSelect) => { + connect.arm({ id: propName, onSelect }) + openPicker() + }, clearConnect: closePicker, + openPicker, pickerMode: () => (sidePane ? 'sidePane' : 'pane'), setPickTarget: (target) => { pickTarget = target @@ -123,22 +131,17 @@ let pickTarget: { id: string; onSelect: (path: string) => void } | undefined = $state(undefined) - // The side column stays put once the row is being worked on: picking a property blurs - // the editor, so closing on blur would take the column away mid-click. It is dismissed - // deliberately instead — by clicking away, by the input's own connect button, or by the - // setting being switched off. + // Opened and dismissed on demand rather than derived from focus: picking a property blurs + // the editor, so a column that followed focus would vanish mid-click — and one that + // latched onto focus would never let go of an editor unmounted by its own setting. let sidePaneOpen = $state(false) - $effect(() => { - if ($propPickerConfig != undefined || $exprBeingEdited.length > 0) { - sidePaneOpen = true - } - }) + + function openPicker() { + sidePaneOpen = true + } function closePicker() { connect.disarm() - // A switched-off setting unmounts its editor rather than blurring it, so the focus - // claim outlives the field — left standing, it reopens the column on the next tick. - exprBeingEdited.set([]) sidePaneOpen = false } From 4c4387d52adc192626c73e48228eb86db7a6c307 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 6 Aug 2026 04:17:34 +0200 Subject: [PATCH 17/40] feat(flow-editor): show an agent's tool-call status without moving the graph (#10557) * feat(flow-editor): surface an agent's tool-call status without moving the graph Co-Authored-By: Claude Opus 5 (1M context) * fix: count only an agent's tool calls and key them in one place Co-Authored-By: Claude Opus 5 (1M context) * feat: report an agent's replies alongside its tool calls Co-Authored-By: Claude Opus 5 (1M context) * feat: break the agent summary down by action kind Co-Authored-By: Claude Opus 5 (1M context) * fix: key agent tool nodes by kind and keep the summary clear of the tool row Co-Authored-By: Claude Opus 5 (1M context) * refactor: read agent action status from the run's success array Co-Authored-By: Claude Opus 5 (1M context) * test: pin the tool joins a local run cannot reach Co-Authored-By: Claude Opus 5 (1M context) * fix: place the agent summary beside the step and match MCP paths bare Co-Authored-By: Claude Opus 5 (1M context) * fix: feed a single-step agent test's calls into the graph status Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../components/FlowStatusViewerInner.svelte | 47 +++---- .../lib/components/flows/map/MapItem.svelte | 15 ++ .../lib/components/graph/FlowGraphV2.svelte | 20 ++- .../components/graph/graphBuilder.svelte.ts | 5 + frontend/src/lib/components/graph/model.ts | 3 + .../graph/renderers/nodes/AIToolNode.svelte | 128 +++++++++++++++--- .../graph/renderers/nodes/AIToolNode.test.ts | 48 ++++++- .../graph/renderers/nodes/ModuleNode.svelte | 55 ++++++++ .../src/lib/components/modulesTest.svelte.ts | 19 ++- 9 files changed, 288 insertions(+), 52 deletions(-) diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index d30d98a991..e09f9e79e2 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -49,6 +49,7 @@ AI_TOOL_MESSAGE_PREFIX, AI_MCP_TOOL_CALL_PREFIX, AI_WEBSEARCH_PREFIX, + getAgentActionStateId, getToolCallId } from './graph/renderers/nodes/AIToolNode.svelte' import JobAssetsViewer from './assets/JobAssetsViewer.svelte' @@ -761,34 +762,28 @@ if (mod.agent_actions && mod.id) { setModuleState(mod.id, { - agent_actions: mod.agent_actions + agent_actions: mod.agent_actions, + agent_actions_success: mod.agent_actions_success }) mod.agent_actions.forEach((action, idx) => { - if (mod.id) { - if (action.type == 'tool_call') { - const toolCallId = getToolCallId(idx, mod.id, action.module_id) - const success = mod.agent_actions_success?.[idx] - setModuleState(toolCallId, { - job_id: action.job_id, - type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' - }) - } else if (action.type == 'mcp_tool_call') { - const mcpToolCallId = AI_MCP_TOOL_CALL_PREFIX + '-' + mod.id + '-' + idx - const success = mod.agent_actions_success?.[idx] - setModuleState(mcpToolCallId, { - type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' - }) - } else if (action.type == 'web_search') { - const websearchId = AI_WEBSEARCH_PREFIX + '-' + mod.id + '-' + idx - setModuleState(websearchId, { - type: 'Success' - }) - } else if (action.type == 'message') { - const toolCallId = getToolCallId(idx, mod.id) - setModuleState(toolCallId, { - type: 'Success' - }) - } + if (!mod.id) { + return + } + const stateId = getAgentActionStateId(idx, mod.id, action) + const success = mod.agent_actions_success?.[idx] + if (action.type == 'tool_call') { + setModuleState(stateId, { + job_id: action.job_id, + type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' + }) + } else if (action.type == 'mcp_tool_call') { + setModuleState(stateId, { + type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress' + }) + } else { + setModuleState(stateId, { + type: 'Success' + }) } }) } diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index ccd9894bd3..29d10d5ca3 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -22,6 +22,9 @@ insertable: boolean moduleAction: ModuleActionInfo | undefined annotation?: string | undefined + annotationTitle?: string | undefined + sideAnnotation?: string | undefined + sideAnnotationTitle?: string | undefined nodeState?: FlowNodeState duration_ms?: number | undefined retries?: number | undefined @@ -55,6 +58,9 @@ insertable, moduleAction = undefined, annotation = undefined, + annotationTitle = undefined, + sideAnnotation = undefined, + sideAnnotationTitle = undefined, nodeState, duration_ms = undefined, retries = undefined, @@ -134,8 +140,17 @@ {msToSec(duration_ms)}s
{/if} + {#if sideAnnotation && sideAnnotation != ''} +
+ {sideAnnotation} +
+ {/if} {#if annotation && annotation != ''}
flowRunStatus.setModuleStates(flowModuleStates)) + // The loader mutates `flow_status` on the job it already handed us, so subscribing to the + // test state alone would never see an agent's calls land. + Object.values(testModuleStates?.states ?? {}).forEach((s) => [ + s.loading, + s.testJob?.['flow_status']?.modules?.[0]?.agent_actions?.length + ]) + untrack(() => { + // Testing one step is its own small run, and its agent calls arrive on the test job + // rather than the flow's states. Fold them in so the renderers keep a single source. + let states = flowModuleStates + for (const [id, testState] of Object.entries(testModuleStates?.states ?? {})) { + const tested = jobToGraphModuleState(testState) + if (!tested?.agent_actions) continue + states = { ...(states ?? {}), [id]: { ...(states?.[id] ?? {}), ...tested } } + } + flowRunStatus.setModuleStates(states) + }) }) if (triggerContext && untrack(() => allowSimplifiedPoll)) { diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index bd133dfeb3..450debbef4 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -311,6 +311,11 @@ export type AiToolN = { nameError?: string eventHandlers: GraphEventHandlers moduleId: string + /** An MCP tool's server, which its calls are keyed to. */ + resourcePath?: string + /** The agent step this tool hangs off. The editor draws the declared tools, whose ids the + * run's per-call state is not keyed by, so the node needs its agent to find its calls. */ + agentModuleId: string // Set on a linked agent's display-only tools: clicking selects this module (the agent step) // instead of the tool, whose resource-owned id is not flow-unique. selectTarget?: string diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index 3618406371..07c7fb0e19 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -66,6 +66,9 @@ export type GraphModuleState = { isListJob?: boolean skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] + /** Positionally aligned with `agent_actions`: every push of an action appends one entry, so a + * missing entry means that action has not finished yet. */ + agent_actions_success?: FlowStatusModule['agent_actions_success'] script_hash?: string workflow_as_code_status?: WorkflowStatus } diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 1876d5af33..9465f5b49f 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -55,6 +55,58 @@ : AI_TOOL_MESSAGE_PREFIX + '-' + agentModuleId + '-' + idx } + export type AgentAction = NonNullable[number] + + function bareResourcePath(path: string | undefined): string | undefined { + return path?.startsWith('$res:') ? path.slice('$res:'.length) : path + } + + /** Whether a run's action was a call of this declared tool. The editor keeps one node per + * declared tool, so each kind of action has to find its way back to the right one: a flow + * module by id, web search by being the only one of its kind, an MCP server by its resource + * path. A miss leaves the node undecorated rather than decorating the wrong tool. */ + export function agentActionMatchesTool( + action: AgentAction, + tool: { moduleId: string; type?: string; resourcePath?: string } + ): boolean { + switch (action.type) { + case 'tool_call': + return action.module_id === tool.moduleId + case 'web_search': + return tool.type === 'websearch' + case 'mcp_tool_call': + // One MCP node stands for a whole server and many function names, so the server path + // is the only join that holds. The worker strips `$res:` before building the action, + // while a flow authored outside the resource picker can still carry it. + return ( + tool.type === 'mcp' && + bareResourcePath(action.resource_path) === bareResourcePath(tool.resourcePath) + ) + case 'message': + return false + } + } + + /** The one id an agent action's state is written and read under. Every writer and reader has + * to rebuild the same key, so they all come here; a switch with no default makes a new action + * kind a compile error rather than a status that silently never resolves. */ + export function getAgentActionStateId( + idx: number, + agentModuleId: string, + action: AgentAction + ): string { + switch (action.type) { + case 'tool_call': + return getToolCallId(idx, agentModuleId, action.module_id) + case 'mcp_tool_call': + return AI_MCP_TOOL_CALL_PREFIX + '-' + agentModuleId + '-' + idx + case 'web_search': + return AI_WEBSEARCH_PREFIX + '-' + agentModuleId + '-' + idx + case 'message': + return getToolCallId(idx, agentModuleId) + } + } + function getComparableNode(node: Node & NodeLayout): Node & NodeLayout { if (node.type === 'module' && node.data.module.value.type === 'aiagent') { return { @@ -128,6 +180,7 @@ name: string type?: string stateType?: GraphModuleState['type'] + resourcePath?: string }[] = sourceTools.map((t, idx) => { // Handle FlowModule, MCP, and Websearch tools const toolType = @@ -141,7 +194,8 @@ return { id: t.id, name: t.summary ?? '', - type: toolType + type: toolType, + resourcePath: t.value.tool_type === 'mcp' ? t.value.resource_path : undefined } }) @@ -151,26 +205,13 @@ baseOffset = BELOW_ADDITIONAL_OFFSET + AI_TOOL_BASE_OFFSET rowOffset = AI_TOOL_ROW_OFFSET tools = agentActions.map((a, idx) => { + const id = getAgentActionStateId(idx, node.id, a) if (a.type === 'tool_call' || a.type === 'mcp_tool_call') { - const id = - a.type === 'tool_call' - ? getToolCallId(idx, node.id, a.module_id) - : AI_MCP_TOOL_CALL_PREFIX + '-' + node.id + '-' + idx - return { - id, - name: a.function_name - } + return { id, name: a.function_name } } else if (a.type === 'web_search') { - return { - id: AI_WEBSEARCH_PREFIX + '-' + node.id + '-' + idx, - name: 'Web Search', - type: 'websearch' - } + return { id, name: 'Web Search', type: 'websearch' } } else { - return { - id: getToolCallId(idx, node.id), - name: 'Message' - } + return { id, name: 'Message' } } }) } @@ -210,7 +251,9 @@ // misroute agent-node clicks into the graph's manual aiTool selection path. selectTarget: isLinkedAgent && !agentActions ? node.id : undefined, insertable, - readOnly: isLinkedAgent + readOnly: isLinkedAgent, + agentModuleId: node.id, + resourcePath: tool.resourcePath }, id: `${node.id}-tool-${tool.id}`, width: inputToolWidth, @@ -310,9 +353,43 @@ const { selectionManager } = getGraphContext() const flowModuleState = $derived(flowRunStatus?.getModuleState(data.moduleId)) + + /** + * The editor draws the agent's declared tools, one node per tool, while a run keys its state + * per call. Roll every call of this tool into the one node it already has, so a run shows up + * here without adding nodes and shifting the graph. A run graph looks its own state up + * directly, so it never gets here. + */ + const toolCalls = $derived.by(() => { + if (flowModuleState) return undefined + const agentState = flowRunStatus?.getModuleState(data.agentModuleId) + const actions = agentState?.agent_actions + if (!actions) return undefined + let count = 0 + let failed = 0 + let pending = 0 + actions.forEach((action, index) => { + if ( + !agentActionMatchesTool(action, { + moduleId: data.moduleId, + type: data.type, + resourcePath: data.resourcePath + }) + ) + return + count++ + const success = agentState?.agent_actions_success?.[index] + if (success === undefined) pending++ + else if (!success) failed++ + }) + if (count === 0) return undefined + const type = pending > 0 ? 'InProgress' : failed > 0 ? 'Failure' : 'Success' + return { type, count } as const + }) + let colorClasses = $derived( getNodeColorClasses( - data.nameError ? 'Failure' : flowModuleState?.type, + data.nameError ? 'Failure' : (flowModuleState?.type ?? toolCalls?.type), selectionManager?.getSelectedId() === (data.selectTarget ?? data.moduleId) ) ) @@ -363,6 +440,17 @@ {data.tool || 'Missing name'} + + + {#if toolCalls && toolCalls.count > 1} + + {toolCalls.count} + + {/if} {#if data.insertable && !data.readOnly}