From 2caee41fdfe1e69010a1f4544d45aaa5db49f590 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 21 Jul 2026 17:49:29 +0200 Subject: [PATCH 01/66] fix(worker): mount /dev/shm as tmpfs in the Docker v2 nsjail sandbox (#10240) The Docker v2 nsjail profile provided /dev/null, /dev/zero, /dev/random, and /dev/urandom but omitted /dev/shm, since generate_rootfs_mounts() skips the image's own /dev in favor of the profile's device nodes. Any program needing POSIX shared memory (Ansible/Python multiprocessing, Chromium) failed with "No such file or directory: /dev/shm". Add a /dev/shm tmpfs mount, matching run.ansible.config.proto and run.python3.config.proto. Fixes WIN-2216 Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-worker/nsjail/run.docker.config.proto | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto index a2da459fbe..5cc1e4ca03 100644 --- a/backend/windmill-worker/nsjail/run.docker.config.proto +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -77,6 +77,13 @@ mount { is_bind: true } +mount { + dst: "/dev/shm" + fstype: "tmpfs" + rw: true + is_bind: false +} + # Host DNS config layered over the image's /etc so name resolution works on the # job's network (mandatory:false: some minimal images have no /etc files to shadow). mount { From dc5b006e7f02baeb262d3f9e1dcc17ab99688442 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 21 Jul 2026 15:59:26 +0000 Subject: [PATCH 02/66] fix npm checks --- frontend/src/lib/components/ShareModal.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 0996e6ca7b..65ba6215fb 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -42,6 +42,7 @@ | 'postgres_trigger' | 'gcp_trigger' | 'azure_trigger' + | 'amqp_trigger' | 'email_trigger' | 'volume' let kind: Kind From 7fb8a2e390cef3cd01a33c89ee55d3f53cb1e20f Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:25:06 +0200 Subject: [PATCH 03/66] fix(parsers): keep s3 asset path suffix verbatim to preserve storage distinction (#10241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(parsers): keep s3 asset path suffix verbatim to preserve storage distinction Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01An2pTqSmqJd2XwnagvX4kM * package json * fix(pipelines): preserve named storage in generated TS/Python S3 URIs The TS/Python templates emitted `s3:///${s3Key(path)}`, stripping the leading slash and pinning the URI to default storage. For a named-storage asset path (`secondary/key`) that produced `s3:///secondary/key`, which resolves to the default storage with key `secondary/key`, dropping the named-storage dependency and reading/writing the wrong object. Emit the path verbatim after `s3://` (matching the DuckDB template) so a named-storage input/output keeps its storage; identical to the previous output for default-storage paths. Removes the now-unused `s3Key` helper. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cli): align bun.lock parser versions with frontend The PR bumped windmill-parser-wasm-asset (1.749.0→1.753.0) and windmill-parser-wasm-regex (1.692.0→1.764.0) in package.json and the npm package-lock.json for both cli and frontend, but cli/bun.lock was left pinned to the old versions. Sync it so the CLI's wasm asset parser (used by localGraph inference) matches the frontend and deploy-time parser. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ruben Fiszel --- .../windmill-parser-py-asset/src/lib.rs | 33 +++--- .../src/asset_parser.rs | 76 +++++++------ .../windmill-parser-ts-asset/src/lib.rs | 25 +++-- .../windmill-parser/src/asset_parser.rs | 104 ++++++------------ .../tests/fixtures/pipeline_annotations.json | 10 +- backend/windmill-common/src/assets.rs | 15 +-- cli/bun.lock | 8 +- cli/package-lock.json | 8 +- cli/package.json | 4 +- cli/src/commands/pipeline/boundedCascade.ts | 10 +- cli/src/commands/pipeline/localGraph.ts | 23 ++-- .../pipeline_bounded_cascade_unit.test.ts | 16 ++- cli/test/pipeline_local_graph_unit.test.ts | 23 ++-- docs/pipeline-local-dev.md | 35 +++--- frontend/package-lock.json | 8 +- frontend/package.json | 4 +- .../assets/AssetGraph/boundedCascade.test.ts | 16 ++- .../assets/AssetGraph/boundedCascade.ts | 9 +- .../AssetGraph/parsePipelineAnnotations.ts | 17 +-- .../AssetGraph/pipelineTemplates.test.ts | 21 ++-- .../assets/AssetGraph/pipelineTemplates.ts | 55 +++++---- 21 files changed, 227 insertions(+), 293 deletions(-) diff --git a/backend/parsers/windmill-parser-py-asset/src/lib.rs b/backend/parsers/windmill-parser-py-asset/src/lib.rs index 8d2e87a53e..b8a12fb476 100644 --- a/backend/parsers/windmill-parser-py-asset/src/lib.rs +++ b/backend/parsers/windmill-parser-py-asset/src/lib.rs @@ -383,7 +383,7 @@ def main(): s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "test.csv".to_string(), + path: "/test.csv".to_string(), access_type: Some(R), columns: None, },]) @@ -441,7 +441,7 @@ def main(): s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "analytics/x.csv".to_string(), + path: "/analytics/x.csv".to_string(), access_type: Some(W), columns: None, },]) @@ -450,10 +450,11 @@ def main(): #[test] fn test_py_write_key_matches_duckdb_read_key() { - // Cross-language lineage: this write records `exports/x`, the same path a - // DuckDB `read_csv('s3://exports/x')` resolves to (see - // windmill-parser-sql-asset `test_duckdb_read_key_matches_sdk_write_key`), - // so the producer and consumer connect in the pipeline graph. + // Cross-language lineage: this default-storage write records + // `/exports/x`, the same path a DuckDB `read_csv('s3:///exports/x')` + // resolves to (see windmill-parser-sql-asset + // `test_duckdb_read_key_matches_sdk_write_key`), so the producer and + // consumer connect in the pipeline graph. let input = r#" import wmill from wmill import S3Object @@ -465,7 +466,7 @@ def main(): s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "exports/x".to_string(), + path: "/exports/x".to_string(), access_type: Some(W), columns: None, },]) @@ -508,7 +509,7 @@ def main(): s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "dir/in.csv".to_string(), + path: "/dir/in.csv".to_string(), access_type: Some(R), columns: None, },]) @@ -531,14 +532,14 @@ def main(): Ok(vec![ ParseAssetsResult { kind: AssetKind::S3Object, - path: "mybucket/dir/in.csv".to_string(), - access_type: Some(R), + path: "/out.json".to_string(), + access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "out.json".to_string(), - access_type: Some(W), + path: "mybucket/dir/in.csv".to_string(), + access_type: Some(R), columns: None, }, ]) @@ -564,25 +565,25 @@ def main(): Ok(vec![ ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/enriched.json".to_string(), + path: "/pipelines/km_real/enriched.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/raw_events.json".to_string(), + path: "/pipelines/km_real/raw_events.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/report.json".to_string(), + path: "/pipelines/km_real/report.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/summary.json".to_string(), + path: "/pipelines/km_real/summary.json".to_string(), access_type: Some(W), columns: None, }, diff --git a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs index 4b523d5792..12d7932a16 100644 --- a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs +++ b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs @@ -1261,13 +1261,13 @@ mod tests { Ok(vec![ ParseAssetsResult { kind: AssetKind::S3Object, - path: "a.parquet".to_string(), + path: "/a.parquet".to_string(), access_type: Some(R), columns: None }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "c.parquet".to_string(), + path: "/c.parquet".to_string(), access_type: Some(W), columns: None }, @@ -1284,25 +1284,35 @@ mod tests { #[test] fn test_duckdb_read_key_matches_sdk_write_key() { // Cross-language lineage: a TS `writeS3File({ s3: "exports/x" })` or - // Python `write_s3_file(S3Object(s3="exports/x"))` records the asset path - // `exports/x` (default storage). A DuckDB reader of the same object must - // resolve to the identical path so the graph connects the producer and - // consumer — both the bare `s3://exports/x` and the triple-slash - // `s3:///exports/x` default-storage form must yield `exports/x`. - for uri in ["s3://exports/x", "s3:///exports/x"] { - let input = format!("SELECT * FROM read_csv('{uri}');"); - let assets = parse_assets(&input).expect("parse").assets; - assert_eq!( - assets, - vec![ParseAssetsResult { - kind: AssetKind::S3Object, - path: "exports/x".to_string(), - access_type: Some(R), - columns: None - }], - "DuckDB read of {uri} must resolve to the SDK write key" - ); - } + // Python `write_s3_file(S3Object(s3="exports/x"))` records the asset + // path `/exports/x` (default storage, leading slash). A DuckDB reader + // of the same object uses the triple-slash default-storage URI and + // must resolve to the identical path so the graph connects producer + // and consumer. The bare `s3://exports/x` form names storage + // `exports` instead — a different object, a different path. + let input = "SELECT * FROM read_csv('s3:///exports/x');"; + let assets = parse_assets(input).expect("parse").assets; + assert_eq!( + assets, + vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "/exports/x".to_string(), + access_type: Some(R), + columns: None + }], + ); + + let input = "SELECT * FROM read_csv('s3://exports/x');"; + let assets = parse_assets(input).expect("parse").assets; + assert_eq!( + assets, + vec![ParseAssetsResult { + kind: AssetKind::S3Object, + path: "exports/x".to_string(), + access_type: Some(R), + columns: None + }], + ); } #[test] @@ -1318,7 +1328,7 @@ mod tests { s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "out.csv".to_string(), + path: "/out.csv".to_string(), access_type: Some(W), columns: None }]) @@ -1335,7 +1345,7 @@ mod tests { s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "referenced.csv".to_string(), + path: "/referenced.csv".to_string(), access_type: Some(R), columns: None }]) @@ -1355,7 +1365,7 @@ mod tests { s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "data.csv".to_string(), + path: "/data.csv".to_string(), access_type: Some(RW), columns: None }]) @@ -1375,7 +1385,7 @@ mod tests { s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "data.parquet".to_string(), + path: "/data.parquet".to_string(), access_type: Some(RW), columns: None }]) @@ -1393,13 +1403,13 @@ mod tests { Ok(vec![ ParseAssetsResult { kind: AssetKind::S3Object, - path: "a.parquet".to_string(), + path: "/a.parquet".to_string(), access_type: Some(R), columns: None }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "b.parquet".to_string(), + path: "/b.parquet".to_string(), access_type: Some(R), columns: None } @@ -1419,7 +1429,7 @@ mod tests { s.map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "data.parquet".to_string(), + path: "/data.parquet".to_string(), access_type: Some(RW), columns: None }]) @@ -2101,7 +2111,7 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].kind, AssetKind::S3Object); - assert_eq!(result[0].path, "example_file.parquet"); + assert_eq!(result[0].path, "/example_file.parquet"); assert_eq!(result[0].access_type, Some(R)); let columns = result[0].columns.as_ref().expect("Should have columns"); @@ -2132,7 +2142,7 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].kind, AssetKind::S3Object); - assert_eq!(result[0].path, "example_file.parquet"); + assert_eq!(result[0].path, "/example_file.parquet"); assert!(result[0].columns.is_none()); } @@ -2145,7 +2155,7 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].kind, AssetKind::S3Object); - assert_eq!(result[0].path, "example_file.parquet"); + assert_eq!(result[0].path, "/example_file.parquet"); let columns = result[0].columns.as_ref().expect("Should have columns"); assert_eq!(columns.get("col1"), Some(&R)); @@ -2164,7 +2174,7 @@ mod tests { assert_eq!(result.len(), 2); assert!(result.iter().any(|a| { - a.path == "file1.parquet" + a.path == "/file1.parquet" && a.columns.as_ref().map_or(false, |c| c.contains_key("col1")) })); assert!(result.iter().any(|a| { @@ -2197,7 +2207,7 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].kind, AssetKind::S3Object); - assert_eq!(result[0].path, "test.parquet"); + assert_eq!(result[0].path, "/test.parquet"); assert_eq!(result[0].access_type, Some(R)); let columns = result[0].columns.as_ref().expect("Should have columns"); diff --git a/backend/parsers/windmill-parser-ts-asset/src/lib.rs b/backend/parsers/windmill-parser-ts-asset/src/lib.rs index f273db5cbe..68b1287aa0 100644 --- a/backend/parsers/windmill-parser-ts-asset/src/lib.rs +++ b/backend/parsers/windmill-parser-ts-asset/src/lib.rs @@ -433,7 +433,7 @@ mod tests { s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "test.csv".to_string(), + path: "/test.csv".to_string(), access_type: Some(R), columns: None, },]) @@ -461,7 +461,7 @@ mod tests { s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/raw_events.json".to_string(), + path: "/pipelines/km_real/raw_events.json".to_string(), access_type: Some(W), columns: None, },]) @@ -470,10 +470,11 @@ mod tests { #[test] fn test_ts_write_key_matches_duckdb_read_key() { - // Cross-language lineage: this write records `exports/x`, the same path a - // DuckDB `read_csv('s3://exports/x')` resolves to (see - // windmill-parser-sql-asset `test_duckdb_read_key_matches_sdk_write_key`), - // so the producer and consumer connect in the pipeline graph. + // Cross-language lineage: this default-storage write records + // `/exports/x`, the same path a DuckDB `read_csv('s3:///exports/x')` + // resolves to (see windmill-parser-sql-asset + // `test_duckdb_read_key_matches_sdk_write_key`), so the producer and + // consumer connect in the pipeline graph. let input = r#" import * as wmill from "windmill-client" export async function main() { @@ -485,7 +486,7 @@ mod tests { s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "exports/x".to_string(), + path: "/exports/x".to_string(), access_type: Some(W), columns: None, },]) @@ -570,25 +571,25 @@ mod tests { Ok(vec![ ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/enriched.json".to_string(), + path: "/pipelines/km_real/enriched.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/raw_events.json".to_string(), + path: "/pipelines/km_real/raw_events.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/report.json".to_string(), + path: "/pipelines/km_real/report.json".to_string(), access_type: Some(W), columns: None, }, ParseAssetsResult { kind: AssetKind::S3Object, - path: "pipelines/km_real/summary.json".to_string(), + path: "/pipelines/km_real/summary.json".to_string(), access_type: Some(W), columns: None, }, @@ -609,7 +610,7 @@ mod tests { s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![ParseAssetsResult { kind: AssetKind::S3Object, - path: "out.json".to_string(), + path: "/out.json".to_string(), access_type: Some(W), columns: None, },]) diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index 211c205579..de35ccdd6e 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -722,26 +722,13 @@ pub fn parse_asset_syntax(s: &str, enable_default_syntax: bool) -> Option<(Asset } for (prefix, kind) in ASSET_KINDS.iter() { if s.starts_with(prefix) { - let path = &s[prefix.len()..]; - // Canonicalize S3 keys to a single asset identity. The SDK object - // form (`{ s3: "key" }` / `S3Object(s3="key")`, default storage) - // resolves to `s3:///key`, whose path is `/key`, while DuckDB - // `s3://key` and `// on s3://key` yield the bare `key`. Strip every - // leading slash so the triple-slash default-storage form and the - // `s3://storage/key` form share one path — otherwise a TS/Python - // writer and a DuckDB reader of the same object become disconnected - // nodes in the pipeline graph. Stripping ALL leading slashes (not - // just one) keeps the identity stable through URI reconstruction: - // `trigger_spec_to_row` rebuilds `s3://`, so a canonical path - // must never itself start with `/` or the rebuilt ref would parse - // back to a different key. Only leading slashes are touched, so - // Hive-partition keys (`s3://b/y=2024/f.parquet`) are untouched. - let path = if matches!(kind, AssetKind::S3Object) { - path.trim_start_matches('/') - } else { - path - }; - return Some((*kind, path)); + // The suffix is kept verbatim. For S3 the path encodes the storage: + // `s3:///`, with an EMPTY storage segment for the + // workspace default — so `s3:///key` yields `/key` (leading slash + // significant, default storage) while `s3://secondary/key` yields + // `secondary/key`. Stripping leading slashes here would conflate a + // default-storage object with a named-storage one. + return Some((*kind, &s[prefix.len()..])); } } None @@ -1560,52 +1547,43 @@ mod pipeline_annotation_tests { use super::*; #[test] - fn s3_key_normalization_unifies_uri_forms() { - // A TS/Python SDK write of `{ s3: "exports/x" }` (default storage) - // resolves to the URI `s3:///exports/x`, while a DuckDB read of - // `s3://exports/x` and the `// on s3://exports/x` trigger form yield the - // bare `exports/x`. All three must canonicalize to one asset key so - // the writer and reader connect in the pipeline graph. - let sdk_write = parse_asset_syntax("s3:///exports/x", false); - let duckdb_read = parse_asset_syntax("s3://exports/x", false); - assert_eq!(sdk_write, Some((AssetKind::S3Object, "exports/x"))); - assert_eq!(duckdb_read, Some((AssetKind::S3Object, "exports/x"))); - assert_eq!(sdk_write, duckdb_read); + fn s3_path_keeps_storage_distinction() { + // An S3 asset path is `/` with an empty storage segment + // for the workspace default. The default-storage form `s3:///key` + // yields `/key` (leading slash significant); the named-storage form + // `s3://secondary/key` yields `secondary/key`. The two name DIFFERENT + // objects and must never collapse to one identity. + assert_eq!( + parse_asset_syntax("s3:///exports/x", false), + Some((AssetKind::S3Object, "/exports/x")) + ); + assert_eq!( + parse_asset_syntax("s3://exports/x", false), + Some((AssetKind::S3Object, "exports/x")) + ); + assert_ne!( + parse_asset_syntax("s3:///exports/x", false), + parse_asset_syntax("s3://exports/x", false) + ); // The `// on` trigger annotation goes through the same function. assert_eq!( parse_asset_syntax("s3:///exports/x", true), - parse_asset_syntax("s3://exports/x", true) + Some((AssetKind::S3Object, "/exports/x")) ); - // Explicit-storage form is unaffected (no leading slash to strip). assert_eq!( - parse_asset_syntax("s3://mybucket/exports/x", false), - Some((AssetKind::S3Object, "mybucket/exports/x")) + parse_asset_syntax("s3://secondary_storage/path/to/file.csv", false), + Some((AssetKind::S3Object, "secondary_storage/path/to/file.csv")) ); - // Hive-partition keys and nested paths under default storage are - // preserved verbatim (only leading slashes are stripped). + // Hive-partition keys are preserved verbatim. assert_eq!( parse_asset_syntax("s3:///t/year=2024/month=01/f.parquet", false), - Some((AssetKind::S3Object, "t/year=2024/month=01/f.parquet")) + Some((AssetKind::S3Object, "/t/year=2024/month=01/f.parquet")) ); - // Every leading slash is stripped so a canonical S3 path never starts - // with `/`. `S3Object(s3="/x")` resolves to the quad-slash URI - // `s3:////x`; the identity must be the bare `x` (not `/x`) so the ref - // that `trigger_spec_to_row` rebuilds round-trips back to it. - assert_eq!( - parse_asset_syntax("s3:////x", false), - Some((AssetKind::S3Object, "x")) - ); - assert_eq!( - parse_asset_syntax("s3://///deep///", false), - Some((AssetKind::S3Object, "deep///")) - ); - - // Non-S3 kinds keep their leading slash (their paths are workspace- - // relative and the slash is significant). + // Non-S3 kinds also keep their suffix verbatim. assert_eq!( parse_asset_syntax("res://f/foo", false), Some((AssetKind::Resource, "f/foo")) @@ -1616,26 +1594,6 @@ mod pipeline_annotation_tests { ); } - #[test] - fn s3_explicit_storage_aliases_default_storage_nested_key() { - // Accepted tradeoff of one canonical key: the explicit-storage form - // `s3://storage/key` and the default-storage nested-key form - // `s3:///storage/key` collapse to the same node `storage/key`, even - // though they name different objects. This is a best-effort lineage - // graph that does not split the first segment as a storage name; the - // collision only happens when a storage config is named to match a - // default-storage prefix. Pinned so the aliasing is intentional, not a - // latent surprise. - assert_eq!( - parse_asset_syntax("s3://mybucket/x", false), - parse_asset_syntax("s3:///mybucket/x", false) - ); - assert_eq!( - parse_asset_syntax("s3://mybucket/x", false), - Some((AssetKind::S3Object, "mybucket/x")) - ); - } - #[test] fn bare_pipeline_marker() { let out = parse_pipeline_annotations("// pipeline\nconsole.log('hi')"); diff --git a/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json b/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json index 226f32914c..a09349647d 100644 --- a/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json +++ b/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json @@ -741,12 +741,12 @@ } }, { - "name": "s3 triple-slash default-storage trigger canonicalizes to bare key", + "name": "s3 triple-slash default-storage trigger keeps its leading slash", "code": "// pipeline\n// on s3:///exports/x\nexport function main() {}", "expected": { "in_pipeline": true, "asset_triggers": [ - "s3object:exports/x" + "s3object:/exports/x" ], "native_triggers": [], "partition": null, @@ -756,12 +756,12 @@ } }, { - "name": "s3 quad-slash trigger strips all leading slashes to the bare key", - "code": "// pipeline\n// on s3:////x\nexport function main() {}", + "name": "s3 named-storage trigger keeps the storage segment", + "code": "// pipeline\n// on s3://secondary_storage/exports/x\nexport function main() {}", "expected": { "in_pipeline": true, "asset_triggers": [ - "s3object:x" + "s3object:secondary_storage/exports/x" ], "native_triggers": [], "partition": null, diff --git a/backend/windmill-common/src/assets.rs b/backend/windmill-common/src/assets.rs index d1bbed1a18..d6a4156f38 100644 --- a/backend/windmill-common/src/assets.rs +++ b/backend/windmill-common/src/assets.rs @@ -567,11 +567,9 @@ mod trigger_ref_roundtrip_tests { // `trigger_spec_to_row` rebuilds a stored ref as `s3://`, and // `parse_asset_trigger_ref` parses it back. The two must be inverse for // every S3 URI form, or a consumer's `// on` trigger lands on a different - // graph node than the producer's inferred write. Because `parse_asset_syntax` - // strips ALL leading slashes, a canonical path never starts with `/`, so the - // naive `prefix + path` rebuild round-trips — including the `S3Object(s3="/x")` - // quad-slash case that previously desynced (path `/x` rebuilt to `s3:///x`, - // which re-parsed to `x`). + // graph node than the producer's inferred write. `parse_asset_syntax` + // keeps the URI suffix verbatim (a default-storage path starts with `/`), + // so the naive `prefix + path` rebuild round-trips for every form. fn roundtrip(uri: &str) -> String { let (pkind, path) = parse_asset_syntax(uri, false).expect("parse uri"); assert_eq!(pkind, PAssetKind::S3Object); @@ -590,11 +588,10 @@ mod trigger_ref_roundtrip_tests { #[test] fn s3_trigger_ref_roundtrips_for_every_uri_form() { - assert_eq!(roundtrip("s3:///exports/x"), "exports/x"); // SDK default storage - assert_eq!(roundtrip("s3://exports/x"), "exports/x"); // DuckDB / bare + assert_eq!(roundtrip("s3:///exports/x"), "/exports/x"); // SDK default storage + assert_eq!(roundtrip("s3://exports/x"), "exports/x"); // named storage `exports` assert_eq!(roundtrip("s3://mybucket/exports/x"), "mybucket/exports/x"); // explicit - assert_eq!(roundtrip("s3:////x"), "x"); // S3Object(s3="/x") quad-slash - assert_eq!(roundtrip("s3:///y=2024/f.parquet"), "y=2024/f.parquet"); // Hive + assert_eq!(roundtrip("s3:///y=2024/f.parquet"), "/y=2024/f.parquet"); // Hive } } diff --git a/cli/bun.lock b/cli/bun.lock index 7c76732a90..c783e411c7 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -19,7 +19,7 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.45.2", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.749.0", + "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", @@ -28,7 +28,7 @@ "windmill-parser-wasm-py": "1.693.1", "windmill-parser-wasm-py-imports": "1.693.1", "windmill-parser-wasm-r": "1.668.1", - "windmill-parser-wasm-regex": "1.692.0", + "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", @@ -290,7 +290,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.749.0", "", {}, "sha512-gj8g9sWQ0tXKfXso7xJxR56sS8Loe/RsnFy+0af5R8siZeCag9ikquGbQ8d8kOqIK2U8eCNA0LqsU/xAFDJIOg=="], + "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.753.0", "", {}, "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="], "windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="], @@ -308,7 +308,7 @@ "windmill-parser-wasm-r": ["windmill-parser-wasm-r@1.668.1", "", {}, "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ=="], - "windmill-parser-wasm-regex": ["windmill-parser-wasm-regex@1.692.0", "", {}, "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw=="], + "windmill-parser-wasm-regex": ["windmill-parser-wasm-regex@1.764.0", "", {}, "sha512-V2eFdKD90gqWikOvjl2fwMpFqiFt/21+4iQMbiNJYl7Lm2UiEcEZ4r9bpgJLG4TLOLqvD6+u4Ju3WaytxN2O2w=="], "windmill-parser-wasm-ruby": ["windmill-parser-wasm-ruby@1.526.1", "", {}, "sha512-rMBQA8s21wmL2kA5ztRs/ZgVA3ckxe9/NLjxl3iQPL0CX6DlvfaUH0O+AnhpXXDMyBs1Y1SZIhcnbnvsHZ3R8g=="], diff --git a/cli/package-lock.json b/cli/package-lock.json index 96dd0681e0..b0b610a045 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -20,7 +20,7 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.45.2", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.749.0", + "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", @@ -1413,9 +1413,9 @@ } }, "node_modules/windmill-parser-wasm-asset": { - "version": "1.749.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.749.0.tgz", - "integrity": "sha512-gj8g9sWQ0tXKfXso7xJxR56sS8Loe/RsnFy+0af5R8siZeCag9ikquGbQ8d8kOqIK2U8eCNA0LqsU/xAFDJIOg==" + "version": "1.753.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", + "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", diff --git a/cli/package.json b/cli/package.json index c13802c0b0..548588a402 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,7 +28,7 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.45.2", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.749.0", + "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", @@ -54,4 +54,4 @@ "@types/ws": "^8.5.0", "typescript": "^5.7.0" } -} +} \ No newline at end of file diff --git a/cli/src/commands/pipeline/boundedCascade.ts b/cli/src/commands/pipeline/boundedCascade.ts index 57130ba129..31c780a272 100644 --- a/cli/src/commands/pipeline/boundedCascade.ts +++ b/cli/src/commands/pipeline/boundedCascade.ts @@ -87,12 +87,10 @@ export function assetUriToNodeId(uri: string): string | undefined { if (!m) return undefined; const prefix = m[1].toLowerCase(); const kind = prefix === "s3" ? "s3object" : prefix; - // Mirror Rust `parse_asset_syntax`: strip all leading slashes from S3 keys so a - // `--to s3:///exports/x` token resolves to the canonical graph node - // `s3object:exports/x` (default storage), same as `s3://exports/x`, and a - // canonical key never starts with `/`. - const path = kind === "s3object" ? m[2].replace(/^\/+/, "") : m[2]; - return `${kind}:${path}`; + // The suffix is kept verbatim (mirrors Rust `parse_asset_syntax`): an S3 + // path encodes the storage, with a leading `/` for the workspace default + // (`s3:///key` → `/key`) vs `s3://secondary/key` → `secondary/key`. + return `${kind}:${m[2]}`; } export type LineageDag = { diff --git a/cli/src/commands/pipeline/localGraph.ts b/cli/src/commands/pipeline/localGraph.ts index be5c8f1567..bb51cd98eb 100644 --- a/cli/src/commands/pipeline/localGraph.ts +++ b/cli/src/commands/pipeline/localGraph.ts @@ -300,13 +300,10 @@ function fallbackParse(content: string, language: string): ParseAssetsRaw { if (uri) { const prefix = uri[1].toLowerCase(); const kind = prefix === "s3" ? "s3object" : prefix; - // Mirror Rust `parse_asset_syntax`: strip all leading slashes from S3 keys - // so `s3:///key` (default storage) and `s3://key` / DuckDB canonicalize - // to the same node id (and a canonical key never starts with `/`) — - // otherwise a go/bash fallback consumer's `// on s3:///x` would not - // connect to a wasm-inferred `x` producer. - const path = kind === "s3object" ? uri[2].replace(/^\/+/, "") : uri[2]; - out.triggers!.push({ kind: "asset", asset_kind: kind, path }); + // The suffix is kept verbatim (mirrors Rust `parse_asset_syntax`): an S3 + // path encodes the storage, with a leading `/` for the workspace default + // (`s3:///key` → `/key`) vs `s3://secondary/key` → `secondary/key`. + out.triggers!.push({ kind: "asset", asset_kind: kind, path: uri[2] }); } else if (NATIVE_KINDS.has(firstTok) && rest === firstTok) { // A native marker (`// on data_upload`) must stand alone: the canonical // parser rejects a marker line with trailing content (`// on data_upload @@ -393,14 +390,10 @@ export function parseMuteAnnotations(content: string): { } for (const [prefix, kind] of MUTE_ASSET_PREFIXES) { if (arg.startsWith(prefix)) { - // S3 canonicalization as in `parse_asset_syntax`: strip every leading - // slash so `s3:///key` (default storage) mutes the same node as the - // inferred bare `key`. - const p = - kind === "s3object" - ? arg.slice(prefix.length).replace(/^\/+/, "") - : arg.slice(prefix.length); - muted.add(`${kind}:${p}`); + // The suffix is kept verbatim, as in `parse_asset_syntax` — a muted + // `s3:///key` (default storage, path `/key`) only matches an inferred + // default-storage read of the same object. + muted.add(`${kind}:${arg.slice(prefix.length)}`); break; } } diff --git a/cli/test/pipeline_bounded_cascade_unit.test.ts b/cli/test/pipeline_bounded_cascade_unit.test.ts index 06781e0c6a..6946ba13eb 100644 --- a/cli/test/pipeline_bounded_cascade_unit.test.ts +++ b/cli/test/pipeline_bounded_cascade_unit.test.ts @@ -256,16 +256,14 @@ test("assetUriToNodeId maps s3 → s3object, others verbatim", () => { expect(assetUriToNodeId("nope")).toBe(undefined); }); -test("assetUriToNodeId strips leading slashes from S3 keys (canonical node)", () => { - // Mirror of Rust `parse_asset_syntax`: `--to s3:///exports/x` must resolve to - // the same canonical node as the graph's `s3object:exports/x`. - expect(assetUriToNodeId("s3:///exports/x")).toBe("s3object:exports/x"); - expect(assetUriToNodeId("s3:///exports/x")).toBe(assetUriToNodeId("s3://exports/x")); - // All leading slashes stripped so a canonical key never starts with `/` - // (the quad-slash `S3Object(s3="/x")` form collapses to `x`). - expect(assetUriToNodeId("s3:////x")).toBe("s3object:x"); +test("assetUriToNodeId keeps the S3 storage distinction (verbatim suffix)", () => { + // Mirror of Rust `parse_asset_syntax`: the suffix is kept verbatim, so a + // default-storage `--to s3:///exports/x` resolves to `s3object:/exports/x` + // while `s3://exports/x` names storage `exports` — a different node. + expect(assetUriToNodeId("s3:///exports/x")).toBe("s3object:/exports/x"); + expect(assetUriToNodeId("s3://exports/x")).toBe("s3object:exports/x"); // Hive-partition keys and non-S3 kinds are untouched. - expect(assetUriToNodeId("s3:///t/y=2024/f.parquet")).toBe("s3object:t/y=2024/f.parquet"); + expect(assetUriToNodeId("s3:///t/y=2024/f.parquet")).toBe("s3object:/t/y=2024/f.parquet"); }); test("resolveToken: short name, full path, and asset URI", () => { diff --git a/cli/test/pipeline_local_graph_unit.test.ts b/cli/test/pipeline_local_graph_unit.test.ts index 59058bbcf4..7b8b8b0dc5 100644 --- a/cli/test/pipeline_local_graph_unit.test.ts +++ b/cli/test/pipeline_local_graph_unit.test.ts @@ -381,18 +381,19 @@ test("derived triggers dedup against explicit `// on` and never self-trigger a m test("parseMuteAnnotations mirrors the canonical annotation grammar", () => { // Any comment prefix regardless of language, header-only scan, complete-word - // keyword, s3 leading-slash canonicalization — in lockstep with the Rust + // keyword, verbatim s3 paths — in lockstep with the Rust // `parse_pipeline_annotations` / frontend parsePipelineAnnotations.ts. const all3 = parseMuteAnnotations( `// mute ducklake://main/a\n-- mute datatable://main/b\n# mute s3:///lead/slash\nSELECT 1;\n// mute ducklake://main/body\n`, ); expect(all3.muteAll).toBe(false); - // all three prefixes accepted; s3 triple-slash canonicalizes to the bare key; - // the line PAST the first non-comment line is ignored (header-only) + // all three prefixes accepted; the s3 triple-slash default-storage path keeps + // its leading slash; the line PAST the first non-comment line is ignored + // (header-only) expect([...all3.muted].sort()).toEqual([ "datatable:main/b", "ducklake:main/a", - "s3object:lead/slash", + "s3object:/lead/slash", ]); // `mute` must be a complete word, and prose args are not asset URIs @@ -478,11 +479,11 @@ test("go/bash fallback: leading-header `// on` only, options stripped, no body p ); }); -test("go/bash fallback: `// on s3:///key` canonicalizes to the slashless key", async () => { - // Mirror of the Rust/wasm `parse_asset_syntax` S3 strip: a fallback consumer's - // triple-slash default-storage trigger must resolve to the bare key `exports/x` - // — the same identity a wasm-inferred SDK/DuckDB producer uses — or the local - // graph shows a disconnected `/exports/x` node. Explicit storage is untouched. +test("go/bash fallback: `// on s3:///key` keeps its default-storage leading slash", async () => { + // Mirror of the Rust/wasm `parse_asset_syntax`: the suffix is kept verbatim. + // A triple-slash default-storage trigger resolves to `/exports/x` — the same + // identity a wasm-inferred SDK write of `{ s3: "exports/x" }` uses — while the + // bare `s3://exports/x` names storage `exports` (a different object/node). await withFolder( { "triple.go": `// pipeline\n// on s3:///exports/x\npackage inner\nfunc main() {}\n`, @@ -495,10 +496,8 @@ test("go/bash fallback: `// on s3:///key` canonicalizes to the slashless key", a graph.triggers.find( (t) => t.trigger_kind === "asset" && t.runnable_path === p ) as Extract<(typeof graph.triggers)[number], { trigger_kind: "asset" }> | undefined; - // triple-slash and bare both canonicalize to `exports/x` → same node - expect(pathFor("f/mypipe/triple")?.asset_path).toBe("exports/x"); + expect(pathFor("f/mypipe/triple")?.asset_path).toBe("/exports/x"); expect(pathFor("f/mypipe/bare")?.asset_path).toBe("exports/x"); - // explicit storage keeps its `storage/key` path (no leading slash to strip) expect(pathFor("f/mypipe/storage")?.asset_path).toBe("mybucket/exports/x"); }, ); diff --git a/docs/pipeline-local-dev.md b/docs/pipeline-local-dev.md index 6ee6a59a14..e05acec9f0 100644 --- a/docs/pipeline-local-dev.md +++ b/docs/pipeline-local-dev.md @@ -85,21 +85,16 @@ makes the client own the whole cascade so the backend dispatcher never double-fi python3, and SQL dialects all get wasm inference (SQL dialects route to `parse_assets_sql`; its comment-header annotation scan is dialect-independent). `go`/`bash` have no wasm asset parser, so they fall back to a minimal `// pipeline` + `// on` scan (annotation-only). Inferred asset paths -must match to connect nodes, and all S3 URI forms canonicalize to one key: `parse_asset_syntax` -(shared by the native and wasm parsers) strips leading slashes from S3 paths, so the SDK -object forms — TS `writeS3File({s3:"x"})` and python `write_s3_file(S3Object(s3="x"))`, which -resolve to `s3:///x` (empty default storage) — the triple-slash annotation `// on s3:///x`, DuckDB -`read_csv('s3://x')` / `COPY ... TO 's3://x'`, and `// on s3://x` all yield path `x`. A TS/Python -writer and a DuckDB reader of the same object therefore connect regardless of which URI form each -side uses. (Only leading slashes are stripped — so a canonical key never starts with `/`, which -keeps the identity stable through `// on` trigger-ref reconstruction — while Hive-partition keys -like `s3://bucket/y=2024/f.parquet` and the explicit-storage form `s3://storage/key` keep their -`bucket/…` / `storage/key` paths.) Tradeoff of collapsing to one canonical key: the explicit-storage -form `s3://storage/key` and the default-storage nested-key form `s3:///storage/key` now alias to -the same node `storage/key`, even though they name different objects (a bucket `storage` vs. an -object under the `storage/` prefix in default storage). This only collides when a storage config is -named to match a default-storage prefix — unlikely, and acceptable for a best-effort lineage graph -that already doesn't split the first segment as a storage name. +must match **exactly** to connect nodes. An S3 asset path is `/` with an empty +storage segment for the workspace default: `parse_asset_syntax` (shared by the native and wasm +parsers) keeps the URI suffix verbatim, so the SDK object forms — TS `writeS3File({s3:"x"})` and +python `write_s3_file(S3Object(s3="x"))`, which resolve to `s3:///x` — the triple-slash annotation +`// on s3:///x`, and DuckDB `read_csv('s3:///x')` / `COPY ... TO 's3:///x'` all yield path `/x` +(leading slash significant). The named-storage form `s3://secondary/key` yields `secondary/key` — +a **different** object and a different node, so the storage distinction is never collapsed. In +practice: use the triple-slash form everywhere for default-storage objects and the producer and +consumer connect regardless of language; mixing in the no-slash form (`s3://x`) names a storage +called `x` and will not connect to a default-storage write. ## How to test @@ -159,12 +154,12 @@ http://localhost:3000/pipeline_dev?workspace=&wm_token=&folder=demo_pip `startProxyServer` for embedders that need a localhost origin (e.g. Claude Code preview). 5. **`pipeline dev` editing**: the dev page is view+run only (editing stays in the user's editor). If in-browser editing with file round-trip is wanted, mirror flow-dev's `handleFlowRoundTrip`. -6. **Asset-path normalization**: done — the python parser resolves the +6. **Asset-path normalization**: the python parser resolves the `S3Object(s3=…, storage=…?)` constructor / dict-literal forms to the same canonical path as the - TS `{s3, storage}` object form, and `parse_asset_syntax` now strips leading slashes from - S3 keys so the SDK-form `s3:///x` (`/x`) and the bare-URI no-slash form (`x`, DuckDB and - `// on s3://x`) canonicalize to one key (see Language coverage). SDK writes and DuckDB reads of - the same object connect regardless of URI convention. + TS `{s3, storage}` object form, so SDK writes and reads connect across ts/python. The + SDK-form path keeps its default-storage leading slash (`/x`), matching the triple-slash URI + forms (`s3:///x` in DuckDB and `// on s3:///x`); the no-slash `s3://x` form names a storage + called `x` and is intentionally a distinct node (see Language coverage). ## Plan reference diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 61d94a137a..daf5e8f6b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -81,7 +81,7 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.749.0", + "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", @@ -14283,9 +14283,9 @@ } }, "node_modules/windmill-parser-wasm-asset": { - "version": "1.749.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.749.0.tgz", - "integrity": "sha512-gj8g9sWQ0tXKfXso7xJxR56sS8Loe/RsnFy+0af5R8siZeCag9ikquGbQ8d8kOqIK2U8eCNA0LqsU/xAFDJIOg==" + "version": "1.753.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", + "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", diff --git a/frontend/package.json b/frontend/package.json index 0486cb503f..dec46b6f2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -156,7 +156,7 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.749.0", + "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", @@ -628,4 +628,4 @@ "@rollup/rollup-linux-x64-gnu": "^4.35.0", "fsevents": "^2.3.3" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/components/assets/AssetGraph/boundedCascade.test.ts b/frontend/src/lib/components/assets/AssetGraph/boundedCascade.test.ts index e4692de6b8..89c9f24708 100644 --- a/frontend/src/lib/components/assets/AssetGraph/boundedCascade.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/boundedCascade.test.ts @@ -509,15 +509,13 @@ describe('assetUriToNodeId', () => { expect(assetUriToNodeId('ducklake://lake/t')).toBe('ducklake:lake/t') expect(assetUriToNodeId('not-a-uri')).toBeUndefined() }) - it('strips leading slashes from S3 keys so s3:/// and s3:// share a node', () => { - // Mirror of Rust `parse_asset_syntax`: `--to s3:///exports/x` must resolve - // to the same canonical node as the graph's `s3object:exports/x`. - expect(assetUriToNodeId('s3:///exports/x')).toBe('s3object:exports/x') - expect(assetUriToNodeId('s3:///exports/x')).toBe(assetUriToNodeId('s3://exports/x')) - // All leading slashes are stripped so a canonical key never starts with - // `/` (the quad-slash `S3Object(s3="/x")` form collapses to `x`). - expect(assetUriToNodeId('s3:////x')).toBe('s3object:x') + it('keeps the S3 storage distinction (verbatim suffix)', () => { + // Mirror of Rust `parse_asset_syntax`: the suffix is kept verbatim, so a + // default-storage `s3:///exports/x` resolves to `s3object:/exports/x` + // while `s3://exports/x` names storage `exports` — a different node. + expect(assetUriToNodeId('s3:///exports/x')).toBe('s3object:/exports/x') + expect(assetUriToNodeId('s3://exports/x')).toBe('s3object:exports/x') // Hive-partition keys and non-S3 kinds are untouched. - expect(assetUriToNodeId('s3:///t/y=2024/f.parquet')).toBe('s3object:t/y=2024/f.parquet') + expect(assetUriToNodeId('s3:///t/y=2024/f.parquet')).toBe('s3object:/t/y=2024/f.parquet') }) }) diff --git a/frontend/src/lib/components/assets/AssetGraph/boundedCascade.ts b/frontend/src/lib/components/assets/AssetGraph/boundedCascade.ts index 810ab36cc1..f3ed9a4f66 100644 --- a/frontend/src/lib/components/assets/AssetGraph/boundedCascade.ts +++ b/frontend/src/lib/components/assets/AssetGraph/boundedCascade.ts @@ -38,11 +38,10 @@ export function assetUriToNodeId(uri: string): string | undefined { // `s3` is the URI prefix for the `s3object` asset kind (mirrors the CLI // `assetUri` and the canvas). All other kinds use their name verbatim. const kind = prefix === 's3' ? 's3object' : prefix - // Mirror Rust `parse_asset_syntax`: strip all leading slashes from S3 keys so - // `s3:///key` (default storage) and `s3://key` resolve to the same node id - // and a canonical key never starts with `/`. - const path = kind === 's3object' ? m[2].replace(/^\/+/, '') : m[2] - return `${kind}:${path}` + // The suffix is kept verbatim (mirrors Rust `parse_asset_syntax`): an S3 + // path encodes the storage, with a leading `/` for the workspace default + // (`s3:///key` → `/key`) vs `s3://secondary/key` → `secondary/key`. + return `${kind}:${m[2]}` } // Native trigger kinds that fan out *per event*: a single event always flows diff --git a/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts b/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts index 34b9ac2eab..5fdbc4c748 100644 --- a/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts +++ b/frontend/src/lib/components/assets/AssetGraph/parsePipelineAnnotations.ts @@ -277,18 +277,11 @@ function stripTrailingKvOpts(s: string): string { function parseAssetSyntax(s: string): PipelineTriggerAsset | undefined { for (const [prefix, kind] of ASSET_PREFIXES) { if (s.startsWith(prefix)) { - let path = s.slice(prefix.length) - // Mirror the Rust `parse_asset_syntax` S3 canonicalization: strip all - // leading slashes so the SDK object form (`s3:///key`, default - // storage) and DuckDB / `// on s3://key` share one asset path, and a - // canonical key never starts with `/` (so ref reconstruction - // round-trips). Without this the live graph preview would show - // disconnected `/key` and `key` nodes. S3-only; leading slashes only, - // so Hive-partition keys are untouched. - if (kind === 's3object') { - path = path.replace(/^\/+/, '') - } - return { kind, path } + // The suffix is kept verbatim, mirroring the Rust `parse_asset_syntax`. + // For S3 the path encodes the storage: `s3:///key` yields `/key` + // (default storage, leading slash significant) while + // `s3://secondary/key` yields `secondary/key` — two different objects. + return { kind, path: s.slice(prefix.length) } } } return undefined diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.test.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.test.ts index 00fdd085ed..08ec089491 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.test.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.test.ts @@ -9,16 +9,15 @@ import { // The seeded draft asset (`autoOutputAsset`, stored as `outputAssets` and used // by resolveGraph for inactive-draft node identity) must match the asset // identity the deploy-time / wasm parser infers from the generated body. The -// parser canonicalizes any S3 URI by stripping the `s3://` prefix and all -// leading slashes (see backend `parse_asset_syntax`); if the seed carried a -// leading slash while the body wrote `s3:///key`, the preview would render a -// duplicate `/key` node and a phantom post-deploy drift. This pins the two in -// lockstep so that class of drift can't regress. +// parser keeps the suffix after `s3://` verbatim (see backend +// `parse_asset_syntax`), so a default-storage object's path carries a leading +// slash (`s3:///key` → `/key`). If the seed and the body's write URI disagree, +// the preview renders a duplicate node and a phantom post-deploy drift. This +// pins the two in lockstep so that class of drift can't regress. -// Mirror of the parser's S3 canonicalization for a raw `s3://…` URI. +// Mirror of the parser's S3 path extraction for a raw `s3://…` URI. function canonicalS3Key(uri: string): string { - const rest = uri.replace(/^s3:\/\//, '') - return rest.replace(/^\/+/, '') + return uri.replace(/^s3:\/\//, '') } const S3_KINDS: PipelineOutputKind[] = ['s3_parquet', 's3_object'] @@ -32,10 +31,10 @@ describe('pipelineTemplates S3 seed/body parity', () => { expect(output).toBeDefined() const asset = output! - // The seed must be a canonical slashless key so it matches the - // identity the parser infers from the generated body. + // The seed must carry the default-storage leading slash so it + // matches the identity the parser infers from the generated body. expect(asset.kind).toBe('s3object') - expect(asset.path.startsWith('/')).toBe(false) + expect(asset.path.startsWith('/')).toBe(true) const body = generatePipelineDraft({ language, diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts index bf0c14d16e..1e26f2bb31 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineTemplates.ts @@ -175,16 +175,16 @@ export function autoOutputAsset( case 'ducklake': case 'materialize': return { kind: 'ducklake', path: `main/${adj}_${pick(TABLE_NOUNS)}_${slug}` } - // s3 outputs use the canonical slashless key. `parse_asset_syntax` - // normalizes `s3:///` (default storage) and `s3://` to the - // bare ``, so the seeded draft asset must be slashless to match the - // deploy-time inferred identity — otherwise the post-deploy drift check - // would flag the output as a phantom `/`-prefixed node. The generated - // bodies still emit the `s3:///` default-storage URI for runtime I/O. + // s3 paths carry the canonical leading slash of a default-storage + // object (`s3:///` parses to path `/`). The deploy-time + // parser stores writes in that form — a slashless seeded path would + // never match it, and the post-deploy drift check would report the + // output as lost (it isn't; the key differs by one '/'). Bodies emit + // the path verbatim after `s3://`, so the slash round-trips. case 's3_parquet': return { kind: 's3object', - path: `pipelines/${folder}/${adj}_${pick(DATASET_NOUNS)}_${slug}.parquet` + path: `/pipelines/${folder}/${adj}_${pick(DATASET_NOUNS)}_${slug}.parquet` } case 's3_object': { // duckdb's natural output for a generic blob is CSV (one COPY TO @@ -194,7 +194,7 @@ export function autoOutputAsset( const ext = language === 'duckdb' ? 'csv' : 'json' return { kind: 's3object', - path: `pipelines/${folder}/${adj}_${pick(FILE_NOUNS)}_${slug}.${ext}` + path: `/pipelines/${folder}/${adj}_${pick(FILE_NOUNS)}_${slug}.${ext}` } } // A macro library produces no asset — its "output" is the registry @@ -222,13 +222,6 @@ export function assetUri(asset: { kind: AssetKind; path: string }): string { return `${ASSET_URI_PREFIX[asset.kind]}${asset.path}` } -// Bare object key for the SDK's `{ s3: }` / `s3:///` forms. Asset -// paths are already canonical slashless keys; strip stray leading slashes -// defensively so the emitted key never starts with '/'. -function s3Key(path: string): string { - return path.replace(/^\/+/, '') -} - // Splits a datatable asset path (`/` or `/.
`) // into its constituent parts. The `.
` grammar is owned by // `parseDbInputFromAssetSyntax` in $lib/utils.ts (which consumes a full @@ -438,11 +431,12 @@ function bodyTs(ctx: TemplateContext): string { if (!input) return '' switch (input.kind) { case 's3object': - // `s3:///` URI — one spelling shared with the `// on - // s3:///…` annotation form (the object literal `{ s3: }` - // is equivalent). + // `input.path` encodes storage as `/` (an empty + // storage segment — leading slash — is the workspace default). + // Emit it verbatim after `s3://` so a named-storage input keeps + // its storage; stripping the slash reads the default-storage key. return [ - ` const buf = await wmill.loadS3File(${JSON.stringify(`s3:///${s3Key(input.path)}`)})`, + ` const buf = await wmill.loadS3File(${JSON.stringify(`s3://${input.path}`)})`, ` const rows = JSON.parse(new TextDecoder().decode(buf))`, `` ].join('\n') @@ -468,10 +462,10 @@ function bodyTs(ctx: TemplateContext): string { switch (outputKind) { case 's3_parquet': case 's3_object': - // `s3:///` URI — see the loadS3File note above. + // `s3:///` URI — see the loadS3File note above. return [ ` const payload = new TextEncoder().encode(JSON.stringify(rows))`, - ` await wmill.writeS3File(${JSON.stringify(`s3:///${s3Key(output.path)}`)}, payload)` + ` await wmill.writeS3File(${JSON.stringify(`s3://${output.path}`)}, payload)` ].join('\n') case 'datatable': { const dbName = output.path.split('/')[0] ?? 'main' @@ -525,11 +519,12 @@ function bodyPython(ctx: TemplateContext): string { if (!input) return '' switch (input.kind) { case 's3object': - // `s3:///` URI — SDK string params must be s3:// URIs - // (bare keys are rejected), and this form matches the - // `# on s3:///…` annotation spelling. + // SDK string params must be s3:// URIs (bare keys are rejected). + // `input.path` encodes storage as `/` (empty storage + // segment — leading slash — is the workspace default), so emit it + // verbatim after `s3://` to preserve a named-storage input. return [ - ` buf = wmill.load_s3_file(${JSON.stringify(`s3:///${s3Key(input.path)}`)})`, + ` buf = wmill.load_s3_file(${JSON.stringify(`s3://${input.path}`)})`, ` import json; rows = json.loads(buf.decode("utf-8"))` ].join('\n') case 'datatable': @@ -552,10 +547,10 @@ function bodyPython(ctx: TemplateContext): string { switch (outputKind) { case 's3_parquet': case 's3_object': - // `s3:///` URI — see the load_s3_file note above. + // `s3:///` URI — see the load_s3_file note above. return [ ` import json`, - ` wmill.write_s3_file(${JSON.stringify(`s3:///${s3Key(output.path)}`)}, json.dumps(rows).encode("utf-8"))` + ` wmill.write_s3_file(${JSON.stringify(`s3://${output.path}`)}, json.dumps(rows).encode("utf-8"))` ].join('\n') case 'datatable': { const dbName = output.path.split('/')[0] ?? 'main' @@ -647,7 +642,7 @@ function bodyDuckdb(ctx: TemplateContext): string { if (!input) return null switch (input.kind) { case 's3object': - return `read_parquet('s3:///${input.path}')` + return `read_parquet('s3://${input.path}')` case 'datatable': // `pg` is the attached Postgres catalog (see ATTACH above). // Use a 2-part `pg.
` ref so the asset parser maps it @@ -670,7 +665,7 @@ function bodyDuckdb(ctx: TemplateContext): string { `COPY (`, ` SELECT *`, ` FROM ${fromExpr}`, - `) TO 's3:///${output.path}' (FORMAT 'parquet');` + `) TO 's3://${output.path}' (FORMAT 'parquet');` ) } break @@ -681,7 +676,7 @@ function bodyDuckdb(ctx: TemplateContext): string { `COPY (`, ` SELECT *`, ` FROM ${fromExpr}`, - `) TO 's3:///${output.path}' (FORMAT 'csv', HEADER);` + `) TO 's3://${output.path}' (FORMAT 'csv', HEADER);` ) } break From 555c751016fea4087be09fa2520af331cc872bfc Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:03:42 +0200 Subject: [PATCH 04/66] fix(db): repair s3 asset paths missing default-storage leading slash (#10243) * fix(db): repair s3 asset paths missing default-storage leading slash Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01An2pTqSmqJd2XwnagvX4kM * fix(db): also repair script_trigger refs + exclude _default_ storage alias Extend the s3 leading-slash repair beyond the asset table: - script_trigger.trigger_ref (pipeline cascade edges, stored as s3://) suffered the identical corruption: a window-era default-storage edge was recorded as s3://exports/x instead of s3:///exports/x, so it no longer matches the producer's post-fix write ref at dispatch (asset_dispatch does an exact trigger_ref = match with no DISTINCT), silently breaking the edge. Repaired with the same storage-name heuristic, with a dedup DELETE to avoid double-dispatch. - Exclude the reserved _default_ alias from the storage-name set. The runtime treats s3://_default_/key as the primary storage (fork_storage_ref), so _default_/key is a valid explicit-default ref; prepending a slash would corrupt it. Applies to both asset and script_trigger via the shared cache. join_pending_inputs (transient AND-join state) and materialized_asset_schema (ducklake-only) are intentionally left alone; documented inline. Verified end-to-end on a fresh DB: seed prior-state rows as the pre-fix parser would have persisted them for data pipelines and scripts, run the full migration suite, assert every row matches the fixed-parser identity (default repair, hive/root/nested keys, named-storage + _default_ untouched, pre-cutoff untouched, non-s3 untouched, duplicate collapse) across both tables. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ruben Fiszel --- ...asset_paths_missing_leading_slash.down.sql | 3 + ...3_asset_paths_missing_leading_slash.up.sql | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.down.sql create mode 100644 backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.up.sql diff --git a/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.down.sql b/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.down.sql new file mode 100644 index 0000000000..9cf6c8c064 --- /dev/null +++ b/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.down.sql @@ -0,0 +1,3 @@ +-- Irreversible data repair: once the leading slash is restored, the rows are +-- indistinguishable from paths that always had it. Intentionally a no-op. +SELECT 1; diff --git a/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.up.sql b/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.up.sql new file mode 100644 index 0000000000..cbf0382fcc --- /dev/null +++ b/backend/migrations/20260721154835_fix_s3_asset_paths_missing_leading_slash.up.sql @@ -0,0 +1,144 @@ +-- Repair s3object asset paths recorded without their default-storage leading +-- slash. An S3 asset path is `/` with an empty storage segment +-- (leading slash) for the workspace default: `s3:///exports/x` -> `/exports/x`. +-- Between 2026-07-06 (#9939) and the parser fix, the asset parser stripped +-- leading slashes, so default-storage assets were recorded as `exports/x` — +-- indistinguishable from a secondary storage named `exports`. For rows created +-- in that window (cutoff one day early for safety), a slashless path whose +-- first segment is NOT a storage name — neither a configured secondary storage +-- nor the reserved `_default_` alias — can only be a default-storage key, so it +-- gets its slash back. Rows already starting with `/` are always correct. +-- +-- Best-effort by nature: the corruption itself conflated a stripped default key +-- with a named ref, so identity is inferred from the storage config AS IT IS NOW. +-- A named ref to a storage that was since removed/renamed (or never configured) +-- is the one residual false-positive — it would be repaired as if default. The +-- `created_at` window bounds this for `asset`; `script_trigger` has no timestamp +-- and relies on the storage-name heuristic alone. Both are acceptable given how +-- rare mid-window storage churn is versus the common default-key case this fixes. +-- +-- Same corruption hit `script_trigger.trigger_ref` (the pipeline cascade edges, +-- stored as `s3://`): a default-storage edge recorded as `s3://exports/x` +-- instead of `s3:///exports/x` no longer matches the producer's post-fix write +-- ref at dispatch (asset_dispatch rebuilds `s3://` + the repaired asset path and +-- does an exact `trigger_ref =` match), silently breaking the edge. Repaired with +-- the same storage-name heuristic — script_trigger has no created_at, but a +-- correct default ref is always `s3:///…` and a correct named ref always leads +-- with a real storage name, so a `s3:///…` ref whose seg isn't a storage is +-- unambiguously a slash-stripped default-storage ref. +-- +-- `join_pending_inputs.trigger_ref` (the AND-join barrier) is deliberately NOT +-- repaired: it is transient slot state cleared on fire, so a window-era `s3://…` +-- slot is superseded once inputs re-arrive under the corrected ref (and deleting +-- live slots could drop an in-flight accumulation). materialized_asset_schema is +-- unaffected — it only ever holds ducklake asset_kind, never s3object. +-- +-- Data-repair only: wrapped so a failure NOTICEs and never blocks the release. +DO $migration$ +BEGIN + CREATE TEMP TABLE __asset_slash_fix_cache ( + workspace_id TEXT PRIMARY KEY, + names TEXT[] NOT NULL + ) ON COMMIT DROP; + + -- Reserved first-path-segments that denote a real storage (so a slashless + -- path leading with one is a genuine named ref, NOT a slash-stripped default + -- key): the workspace's secondary_storage names PLUS `_default_`, the alias + -- the runtime treats as the primary storage (workspaces.rs fork_storage_ref). + -- `s3://_default_/key` is a valid explicit-default ref recorded verbatim as + -- `_default_/key`; prepending a slash would corrupt it to key `_default_/key`. + -- The JSON is parsed at most once per workspace (candidate assets can repeat + -- a workspace millions of times via job usages), and only workspaces that + -- actually have candidate rows are ever fetched. + CREATE FUNCTION pg_temp.__asset_slash_fix_storages(ws TEXT) RETURNS TEXT[] AS $fn$ + DECLARE + result TEXT[]; + BEGIN + SELECT c.names INTO result FROM __asset_slash_fix_cache c WHERE c.workspace_id = ws; + IF FOUND THEN + RETURN result; + END IF; + SELECT ARRAY['_default_'] || COALESCE(array_agg(k), '{}') INTO result + FROM workspace_settings s + CROSS JOIN LATERAL jsonb_object_keys( + CASE WHEN jsonb_typeof(s.large_file_storage -> 'secondary_storage') = 'object' + THEN s.large_file_storage -> 'secondary_storage' + ELSE '{}'::JSONB END + ) k + WHERE s.workspace_id = ws; + result := COALESCE(result, ARRAY['_default_']); + INSERT INTO __asset_slash_fix_cache VALUES (ws, result); + RETURN result; + END + $fn$ LANGUAGE plpgsql; + + -- Duplicates first: when the corrected `/path` row already exists for the + -- same usage (recorded before the regression, or re-recorded after the + -- parser fix), prepending the slash would violate the primary key + -- (workspace_id, path, kind, usage_path, usage_kind) — drop the slashless + -- duplicate instead. + DELETE FROM asset a + WHERE a.kind = 's3object' + AND a.created_at > '2026-07-05 00:00:00+00'::TIMESTAMPTZ + AND a.path NOT LIKE '/%' + AND a.path <> '' + AND length(a.path) < 255 + AND split_part(a.path, '/', 1) <> ALL (pg_temp.__asset_slash_fix_storages(a.workspace_id)) + AND EXISTS ( + SELECT 1 FROM asset b + WHERE b.workspace_id = a.workspace_id + AND b.path = '/' || a.path + AND b.kind = a.kind + AND b.usage_path = a.usage_path + AND b.usage_kind = a.usage_kind + ); + + -- length < 255 keeps the prepend within the VARCHAR(255) column; a 255-char + -- slashless path cannot be repaired and is left as-is rather than erroring. + UPDATE asset a + SET path = '/' || a.path + WHERE a.kind = 's3object' + AND a.created_at > '2026-07-05 00:00:00+00'::TIMESTAMPTZ + AND a.path NOT LIKE '/%' + AND a.path <> '' + AND length(a.path) < 255 + AND split_part(a.path, '/', 1) <> ALL (pg_temp.__asset_slash_fix_storages(a.workspace_id)); + + -- script_trigger.trigger_ref for asset edges is `s3://`. A corrupted + -- default-storage edge reads `s3:///…` (exactly two slashes); a correct + -- default ref is `s3:///…` and is excluded by the NOT LIKE. `substring(from 6)` + -- is the `` after the `s3://` prefix. Delete a slashless edge whose + -- corrected twin already exists for the same runnable (fetch_subscribers has + -- no DISTINCT, so a duplicate would double-dispatch the subscriber). + DELETE FROM script_trigger a + WHERE a.trigger_kind = 'asset' + AND a.trigger_ref LIKE 's3://%' + AND a.trigger_ref NOT LIKE 's3:///%' + AND substring(a.trigger_ref FROM 6) <> '' + AND split_part(substring(a.trigger_ref FROM 6), '/', 1) + <> ALL (pg_temp.__asset_slash_fix_storages(a.workspace_id)) + AND EXISTS ( + SELECT 1 FROM script_trigger b + WHERE b.workspace_id = a.workspace_id + AND b.runnable_kind = a.runnable_kind + AND b.runnable_path = a.runnable_path + AND b.trigger_kind = a.trigger_kind + AND b.trigger_ref = 's3:///' || substring(a.trigger_ref FROM 6) + ); + + UPDATE script_trigger a + SET trigger_ref = 's3:///' || substring(a.trigger_ref FROM 6) + WHERE a.trigger_kind = 'asset' + AND a.trigger_ref LIKE 's3://%' + AND a.trigger_ref NOT LIKE 's3:///%' + AND substring(a.trigger_ref FROM 6) <> '' + AND split_part(substring(a.trigger_ref FROM 6), '/', 1) + <> ALL (pg_temp.__asset_slash_fix_storages(a.workspace_id)); + + -- The temp table is ON COMMIT DROP; drop the function too so nothing + -- lingers on a pooled connection. + DROP FUNCTION pg_temp.__asset_slash_fix_storages(TEXT); +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'skipping s3 asset leading-slash repair: %', SQLERRM; +END +$migration$; From 4a898247a21ae918fa952a993fbe407ebc49e404 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 21 Jul 2026 19:04:01 +0200 Subject: [PATCH 05/66] fix(apps): let entitled viewers read pre-existing S3 files from deployed apps (#10245) * fix(apps): let entitled viewers read pre-existing S3 files from deployed apps Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): confine S3 viewer fallback to full unscoped sessions A scope-restricted token (e.g. apps:read:, or an app-embed token) is allowed on apps_u/* but rejected by the route-scope middleware on job_helpers/*, so granting it the viewer fallback would be a new capability it cannot obtain directly. Gate the fallback on scopes.is_none() so only full sessions (which can already read via job_helpers) delegate; scoped and anonymous callers stay gated. Add a scoped-token isolation assertion. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): treat filter-tags-only tokens as unscoped for S3 viewer fallback The scopes.is_none() guard wrongly denied the viewer fallback to tokens that are effectively unscoped (empty scope arrays and if_jobs:filter_tags:-only tokens), which the route-scope middleware treats as unrestricted and which can therefore read the same file via job_helpers directly. Reuse that semantics via a shared is_effectively_unscoped helper so the relaxation covers exactly the tokens that gain no new capability, while genuinely scoped tokens stay gated. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/tests/app_s3_onbehalf.rs | 310 +++++++++++++++++---------- backend/windmill-api-auth/src/lib.rs | 9 + backend/windmill-api/src/apps.rs | 125 +++++++---- 3 files changed, 287 insertions(+), 157 deletions(-) diff --git a/backend/tests/app_s3_onbehalf.rs b/backend/tests/app_s3_onbehalf.rs index 9dd96e30f1..7b233b505d 100644 --- a/backend/tests/app_s3_onbehalf.rs +++ b/backend/tests/app_s3_onbehalf.rs @@ -1,7 +1,9 @@ -//! Deployed-app S3 reads authorize on-behalf of the app author and are confined -//! to app provenance (declared keys or recent job outputs): a viewer cannot read -//! an arbitrary `file_key` as the author. Requires the `parquet` feature — the -//! real `apps_u/*` S3 handlers are gated on it. +//! Deployed-app S3 reads authorize on-behalf of the app author and are confined to +//! app provenance (declared keys or recent job outputs): an anonymous viewer cannot +//! read an arbitrary `file_key` as the author. A viewer on a full (unscoped) session +//! instead falls back to reading as THEMSELVES (bounded by their own S3 perms), so the +//! gate is exercised here through the anonymous identity it still fully protects. +//! Requires the `parquet` feature — the real `apps_u/*` S3 handlers are gated on it. //! //! `base` fixture: test-user (admin, SECRET_TOKEN); test-user-2 (non-admin, //! SECRET_TOKEN_2, no S3 folder permission). @@ -21,6 +23,19 @@ fn client() -> reqwest::Client { reqwest::Client::new() } +/// Mint an API token for test-user (admin) restricted to `scopes`. +async fn mint_scoped_token(port: u16, scopes: Vec<&str>) -> anyhow::Result { + let resp = authed( + client().post(format!("http://localhost:{port}/api/users/tokens/create")), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "scoped", "scopes": scopes, "workspace_id": "test-workspace" })) + .send() + .await?; + assert_eq!(resp.status(), 201, "mint scoped token"); + Ok(resp.text().await?) +} + fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { builder.header("Authorization", format!("Bearer {}", token)) } @@ -50,62 +65,54 @@ async fn test_deployed_app_s3_onbehalf_provenance(db: Pool) -> anyhow: .await?; assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); - // GET an app-scoped S3 route as `token`. No workspace storage is configured, - // so a request that clears the provenance gate fails later at the storage - // lookup (or the CE OSS stub), never with "File restricted" — which is what - // lets these assertions distinguish "gate passed" from "gate denied". - let get = |route: &str, token: &'static str| { + // GET an app-scoped S3 route ANONYMOUSLY. Anonymous callers have no viewer + // identity to fall back to, so the provenance gate still fully applies to them + // (unlike logged-in viewers, who now read as themselves — see the union test). + // No workspace storage is configured, so a request that clears the gate fails + // later at the storage lookup (or the CE OSS stub), never with the denial + // message — which is what lets these assertions distinguish pass from deny. + let get = |route: &str| { let url = format!("{ws}/apps_u/{route}"); - authed(client().get(url), token).send() + client().get(url).send() }; - let denied = |body: &str| body.contains("File restricted"); + let denied = |body: &str| body.contains("is not accessible from this app"); - // download_s3_file: author-on-behalf allowed for the declared key, denied for - // a key the app never declared (the confused-deputy guard). - let body = get(&format!("download_s3_file/{APP}?s3={DECLARED}"), USER_TOKEN) + // download_s3_file: allowed for the declared key, denied for a key the app never + // declared (the confused-deputy guard). + let body = get(&format!("download_s3_file/{APP}?s3={DECLARED}")) .await? .text() .await?; assert!(!denied(&body), "declared key must clear the gate: {body}"); - let body = get( - &format!("download_s3_file/{APP}?s3={NON_PROVENANCE}"), - USER_TOKEN, - ) - .await? - .text() - .await?; + let body = get(&format!("download_s3_file/{APP}?s3={NON_PROVENANCE}")) + .await? + .text() + .await?; assert!(denied(&body), "non-provenance key must be denied: {body}"); // load_table_count and load_csv_preview enforce the same gate. The preview's // numeric `limit`/`offset` must deserialize (regression: a flattened query // struct 400s on them under serde_urlencoded). - let body = get( - &format!("load_table_count/{APP}?file_key={DECLARED}"), - USER_TOKEN, - ) - .await? - .text() - .await?; + let body = get(&format!("load_table_count/{APP}?file_key={DECLARED}")) + .await? + .text() + .await?; assert!( !denied(&body), "table_count declared key must clear the gate: {body}" ); - let body = get( - &format!("load_table_count/{APP}?file_key={NON_PROVENANCE}"), - USER_TOKEN, - ) - .await? - .text() - .await?; + let body = get(&format!("load_table_count/{APP}?file_key={NON_PROVENANCE}")) + .await? + .text() + .await?; assert!( denied(&body), "table_count non-provenance key must be denied: {body}" ); - let resp = get( - &format!("load_csv_preview/{APP}?file_key={DECLARED}&limit=5&offset=0"), - USER_TOKEN, - ) + let resp = get(&format!( + "load_csv_preview/{APP}?file_key={DECLARED}&limit=5&offset=0" + )) .await?; let status = resp.status(); let body = resp.text().await?; @@ -116,23 +123,16 @@ async fn test_deployed_app_s3_onbehalf_provenance(db: Pool) -> anyhow: ); // load_file_preview: `read_bytes_from` / `read_bytes_length` are required. - let resp = get( - &format!("load_file_preview/{APP}?file_key={DECLARED}"), - USER_TOKEN, - ) - .await?; + let resp = get(&format!("load_file_preview/{APP}?file_key={DECLARED}")).await?; assert_eq!( resp.status(), 400, "file_preview without byte range must 400: {}", resp.text().await? ); - let body = get( - &format!( - "load_file_preview/{APP}?file_key={DECLARED}&read_bytes_from=0&read_bytes_length=4096" - ), - USER_TOKEN, - ) + let body = get(&format!( + "load_file_preview/{APP}?file_key={DECLARED}&read_bytes_from=0&read_bytes_length=4096" + )) .await? .text() .await?; @@ -144,6 +144,94 @@ async fn test_deployed_app_s3_onbehalf_provenance(db: Pool) -> anyhow: Ok(()) } +/// The viewer-perm union: a viewer on a full (unscoped) session is no longer hard-denied +/// by the provenance gate for a pre-existing file. It falls back to reading as ITSELF +/// (bounded by its own S3 perms downstream), while an anonymous caller (no identity) and +/// a scope-restricted token (can hit `apps_u/*` but not `job_helpers/*`, so the fallback +/// would be a new capability) both stay fully gated with the actionable denial. +#[sqlx::test(fixtures("base"))] +async fn test_deployed_app_s3_viewer_union(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP, + "summary": "s3 viewer union test", + "value": {}, + "policy": { + "execution_mode": "anonymous", + "triggerables": {}, + "allowed_s3_keys": [{ "s3_path": DECLARED }] + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); + + let url = format!("{ws}/apps_u/download_s3_file/{APP}?s3={NON_PROVENANCE}"); + + // Anonymous: still gated. The denial is the actionable message and echoes the key. + let body = client().get(&url).send().await?.text().await?; + assert!( + body.contains("is not accessible from this app"), + "anonymous viewer must stay gated with the actionable denial: {body}" + ); + assert!( + body.contains(NON_PROVENANCE), + "denial must echo the requested key: {body}" + ); + + // Logged-in viewer: no longer hard-denied — the gate delegates to reading as the + // viewer, so the request falls through to the storage read (no gate denial in + // EITHER the old or new form). No workspace storage is configured here, so it + // surfaces a downstream storage/OSS error, not a gate denial. + let body = authed(client().get(&url), USER_TOKEN) + .send() + .await? + .text() + .await?; + assert!( + !body.contains("is not accessible from this app") && !body.contains("File restricted"), + "logged-in viewer must delegate to its own read, not be gate-denied: {body}" + ); + + // Scope-restricted token: an `apps:read:` token reaches this route but is + // REJECTED by the route-scope middleware on `job_helpers/*`, so it must NOT get the + // viewer fallback (that would be a capability it cannot obtain directly). It stays + // gated with the denial, unlike the unscoped session above. + let apps_read_scope = format!("apps:read:{APP}"); + let scoped = mint_scoped_token(port, vec![apps_read_scope.as_str()]).await?; + let body = authed(client().get(&url), &scoped) + .send() + .await? + .text() + .await?; + assert!( + body.contains("is not accessible from this app"), + "scope-restricted token must stay gated, not get the viewer fallback: {body}" + ); + + // A filter-tags-only token carries no real scope restriction (the route-scope + // middleware treats it as unscoped), so it can read via job_helpers directly and + // MUST get the viewer fallback here — not be gated like a genuinely scoped token. + let tag_only = mint_scoped_token(port, vec!["if_jobs:filter_tags:default"]).await?; + let body = authed(client().get(&url), &tag_only) + .send() + .await? + .text() + .await?; + assert!( + !body.contains("is not accessible from this app") && !body.contains("File restricted"), + "filter-tags-only token is effectively unscoped and must delegate, not be gated: {body}" + ); + + Ok(()) +} + /// Mint a presigned bearer (`exp=..&sig=..`) exactly as `sign_s3_objects` does: /// `HMAC-SHA256(workspace_key, "file_key={s3}&exp={exp}")` (no storage param, since /// these routes send none). `validate_s3_signature` is `private`-gated, so this test @@ -198,16 +286,19 @@ async fn test_deployed_app_s3_presigned_bypasses_gate(db: Pool) -> any let url = format!("{ws}/apps_u/{route}"); authed(client().get(url), token).send() }; - let denied = |body: &str| body.contains("File restricted"); + let denied = |body: &str| body.contains("is not accessible from this app"); - // Control: NON_PROVENANCE without a signature is denied by the gate. - let body = get( - format!("download_s3_file/{APP}?s3={NON_PROVENANCE}"), - USER_TOKEN, - ) - .await? - .text() - .await?; + // Control: NON_PROVENANCE without a signature is denied by the gate. Sent + // anonymously — a logged-in viewer would instead fall back to reading as + // themselves, so anonymous is the identity that isolates the presigned bypass. + let body = client() + .get(format!( + "{ws}/apps_u/download_s3_file/{APP}?s3={NON_PROVENANCE}" + )) + .send() + .await? + .text() + .await?; assert!( denied(&body), "unsigned non-provenance key must be denied: {body}" @@ -304,12 +395,14 @@ async fn seed_completed_job( } /// A deployed app that renders S3 files it produced (e.g. a SQL query persisted to -/// S3 by a component) must clear the provenance gate for the viewer whose own app -/// run produced them, while (a) a viewer cannot forge provenance by running a -/// runnable directly (no app marker), (b) another app's outputs stay denied, and -/// (c) another viewer's outputs stay denied (cross-viewer isolation). Provenance is -/// keyed on the app-origination marker (`trigger_kind='app'` + `trigger=`) -/// that `execute_component` stamps, plus `created_by = ` for isolation. +/// S3 by a component) must clear the provenance gate for the caller whose own app run +/// produced them, while (a) provenance cannot be forged by running a runnable directly +/// (no app marker), (b) another app's outputs stay denied, and (c) another caller's +/// outputs stay denied (per-caller isolation). Provenance is keyed on the +/// app-origination marker (`trigger_kind='app'` + `trigger=`) that +/// `execute_component` stamps, plus `created_by = ` for isolation. +/// Exercised anonymously: the gate still fully governs anonymous callers, whereas a +/// logged-in viewer would instead fall back to reading as themselves. #[sqlx::test(fixtures("base"))] async fn test_deployed_app_s3_onbehalf_flow_script_provenance( db: Pool, @@ -322,10 +415,10 @@ async fn test_deployed_app_s3_onbehalf_flow_script_provenance( const FS_APP: &str = "u/test-user/s3flowscript"; const OTHER_APP: &str = "u/test-user/other_app"; - // Produced by test-user-2's own app run of THIS app. - const USER_KEY: &str = "results/user2_output.parquet"; - // Produced by test-user's own app run of THIS app. - const ADMIN_KEY: &str = "results/admin_output.parquet"; + // Produced by the anonymous caller's own app run of THIS app. + const OWN_KEY: &str = "results/own_output.parquet"; + // Produced by a DIFFERENT caller's app run of THIS app → isolation, must stay denied. + const OTHER_CALLER_KEY: &str = "results/user2_output.parquet"; // Produced by an app run of a DIFFERENT app → must stay denied. const OTHER_APP_KEY: &str = "results/other_app_output.parquet"; // Produced by a DIRECT run (no app marker) → the forgery attempt, must stay denied. @@ -342,64 +435,49 @@ async fn test_deployed_app_s3_onbehalf_flow_script_provenance( .await?; assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); - // Seed the produced-file jobs (all within the 3h window). - seed_completed_job(&db, "test-user-2", Some(FS_APP), USER_KEY).await?; - seed_completed_job(&db, "test-user", Some(FS_APP), ADMIN_KEY).await?; - seed_completed_job(&db, "test-user-2", Some(OTHER_APP), OTHER_APP_KEY).await?; - seed_completed_job(&db, "test-user-2", None, FORGED_KEY).await?; + // Seed the produced-file jobs (all within the 3h window). The gate's `created_by` + // filter uses "anonymous" for an unauthenticated caller. + seed_completed_job(&db, "anonymous", Some(FS_APP), OWN_KEY).await?; + seed_completed_job(&db, "test-user-2", Some(FS_APP), OTHER_CALLER_KEY).await?; + seed_completed_job(&db, "anonymous", Some(OTHER_APP), OTHER_APP_KEY).await?; + seed_completed_job(&db, "anonymous", None, FORGED_KEY).await?; - let get = |route: &str, token: &'static str| { + let denied = |body: &str| body.contains("is not accessible from this app"); + // Anonymous GET (borrows `ws`, reusable across calls: the URL is built before the + // `async move` so only the owned `url` is moved into the future, not `ws`). + let anon_body = |route: String| { let url = format!("{ws}/apps_u/{route}"); - authed(client().get(url), token).send() - }; - let denied = |body: &str| body.contains("File restricted"); - let body_of = |route: String, token: &'static str| async move { - get(&route, token).await.unwrap().text().await.unwrap() + async move { + client() + .get(url) + .send() + .await + .unwrap() + .text() + .await + .unwrap() + } }; - // The viewer's own app run's output clears the gate (the case that regressed to - // "File restricted"). - let body = body_of( - format!("download_s3_file/{FS_APP}?s3={USER_KEY}"), - USER_TOKEN, - ) - .await; + // The caller's own app run's output clears the gate (the case that regressed to + // a hard denial). + let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OWN_KEY}")).await; assert!( !denied(&body), - "viewer's own app-produced key must clear the gate: {body}" + "caller's own app-produced key must clear the gate: {body}" ); - // The admin viewer's own app run's output clears — the gate has no admin bypass, - // it just matches the caller's own runs. - let body = body_of( - format!("download_s3_file/{FS_APP}?s3={ADMIN_KEY}"), - ADMIN_TOKEN, - ) - .await; - assert!( - !denied(&body), - "admin's own app-produced key must clear the gate: {body}" - ); - - // Cross-viewer isolation: the admin cannot pull test-user-2's result even though - // it is a genuine app-marked job of the same app (no admin bypass either). - let body = body_of( - format!("download_s3_file/{FS_APP}?s3={USER_KEY}"), - ADMIN_TOKEN, - ) - .await; + // Per-caller isolation: another caller's result stays denied even though it is a + // genuine app-marked job of the same app. + let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OTHER_CALLER_KEY}")).await; assert!( denied(&body), - "another viewer's app-produced key must stay denied (isolation): {body}" + "another caller's app-produced key must stay denied (isolation): {body}" ); // A key produced by a direct run (no app marker) stays denied — the forgery the // app-origination marker closes. - let body = body_of( - format!("download_s3_file/{FS_APP}?s3={FORGED_KEY}"), - USER_TOKEN, - ) - .await; + let body = anon_body(format!("download_s3_file/{FS_APP}?s3={FORGED_KEY}")).await; assert!( denied(&body), "key from a direct run (no app marker) must stay denied: {body}" @@ -407,11 +485,7 @@ async fn test_deployed_app_s3_onbehalf_flow_script_provenance( // A key produced by a DIFFERENT app stays denied — provenance is scoped to THIS // app's path. - let body = body_of( - format!("download_s3_file/{FS_APP}?s3={OTHER_APP_KEY}"), - USER_TOKEN, - ) - .await; + let body = anon_body(format!("download_s3_file/{FS_APP}?s3={OTHER_APP_KEY}")).await; assert!( denied(&body), "key produced by a different app must stay denied: {body}" diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 961f88d547..8120c16605 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -275,6 +275,15 @@ fn scope_restrictions(scopes: Option<&[String]>) -> Option> { (!restrictions.is_empty()).then_some(restrictions) } +/// True when the token carries no real scope restriction — unscoped, an empty scope +/// list, or only `if_jobs:filter_tags:` filters — so it holds the full privileges of +/// its user and can reach any non-job route they are authorized for (mirrors +/// `check_scopes` / `check_route_access`). A `false` result means the token is +/// genuinely scope-restricted. +pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool { + scope_restrictions(scopes).is_none() +} + /// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes /// a credential on behalf of `authed`: the resulting credential must never be /// more privileged than the caller's own token. diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index fe10229af3..4e7942b16d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -3883,6 +3883,18 @@ async fn get_on_behalf_authed_from_app( Ok((on_behalf_authed, policy)) } +/// Which identity a deployed `apps_u/*` S3 read runs as. +#[cfg(feature = "parquet")] +enum AppS3ReadIdentity { + /// The gate passed: read with the policy's on-behalf identity (the app author in + /// author-mode, the viewer in viewer-mode). + OnBehalf, + /// The gate did not pass but a logged-in, non-embed viewer is present: read with + /// the viewer's OWN identity so the downstream S3 permission check self-enforces + /// their entitlement (never the author's). + AsViewer(ApiAuthed), +} + #[cfg(feature = "parquet")] async fn check_if_allowed_to_access_s3_file_from_app( db: &DB, @@ -3891,7 +3903,7 @@ async fn check_if_allowed_to_access_s3_file_from_app( w_id: &str, path: &str, policy: &Policy, -) -> Result<()> { +) -> Result { let is_app_embed = opt_authed.as_ref().is_some_and(|authed| { windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) }); @@ -3912,7 +3924,7 @@ async fn check_if_allowed_to_access_s3_file_from_app( &db, ) .await?; - return Ok(()); + return Ok(AppS3ReadIdentity::OnBehalf); } } @@ -3921,24 +3933,25 @@ async fn check_if_allowed_to_access_s3_file_from_app( // get_workspace_s3_resource_and_check_paths already bounds the read by // their own perms — no provenance gate (it would over-restrict). Embed // tokens are excluded (untrusted app JS stays confined below). - Ok(()) - } else { - // Author-mode/embed: confine reads to the app's declared keys or files THIS - // app produced, else a viewer could launder the author's S3 perms via an - // arbitrary file_key (confused deputy). Provenance is the un-forgeable - // app-origination marker (`trigger_kind='app'` + `trigger=`); - // `created_by=` is ANDed only as a per-viewer isolation filter (it - // can narrow — one viewer can't read another's result — never forge). - let creator = opt_authed - .as_ref() - .map(|authed| authed.username.clone()) - .unwrap_or_else(|| "anonymous".to_string()); - let allowed = policy.allowed_s3_keys.as_ref().is_some_and(|keys| { - keys.iter() - .any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage) - }) || { - sqlx::query_scalar!( - r#"SELECT EXISTS ( + return Ok(AppS3ReadIdentity::OnBehalf); + } + + // Author-mode/embed: confine reads to the app's declared keys or files THIS + // app produced, else a viewer could launder the author's S3 perms via an + // arbitrary file_key (confused deputy). Provenance is the un-forgeable + // app-origination marker (`trigger_kind='app'` + `trigger=`); + // `created_by=` is ANDed only as a per-viewer isolation filter (it + // can narrow — one viewer can't read another's result — never forge). + let creator = opt_authed + .as_ref() + .map(|authed| authed.username.clone()) + .unwrap_or_else(|| "anonymous".to_string()); + let allowed = policy.allowed_s3_keys.as_ref().is_some_and(|keys| { + keys.iter() + .any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage) + }) || { + sqlx::query_scalar!( + r#"SELECT EXISTS ( SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.workspace_id = $2 AND c.started_at > now() - interval '3 hours' @@ -3947,21 +3960,45 @@ async fn check_if_allowed_to_access_s3_file_from_app( AND j.trigger = $3 AND j.created_by = $4 )"#, - file_query.s3, - w_id, - path, - creator, - ) - .fetch_one(db) - .await? - .unwrap_or(false) - }; + file_query.s3, + w_id, + path, + creator, + ) + .fetch_one(db) + .await? + .unwrap_or(false) + }; - if !allowed { - Err(Error::BadRequest("File restricted".to_string())) - } else { - Ok(()) + if allowed { + return Ok(AppS3ReadIdentity::OnBehalf); + } + + // Gate denied. A viewer whose token is effectively unscoped falls back to reading + // as THEMSELVES: the file is still bounded by their own S3 perms downstream, and + // such a token can already fetch it via `job_helpers/download_s3_file`, so the + // fallback adds zero capability. `is_effectively_unscoped` (the same predicate the + // route-scope middleware uses) is what makes that true: a genuinely scope-restricted + // token (e.g. `apps:read:`) is allowed on `apps_u/*` but REJECTED on + // `job_helpers/*`, so serving it the file here WOULD be a new capability — it stays + // gated. `!is_app_embed` keeps that confinement explicit (embed tokens carry the + // `app_embed` scope, so they are already scope-restricted). Anonymous callers (no + // identity) also have no viewer to fall back to. Only the confused-deputy denial + // reaches the message below. + match opt_authed.as_ref() { + Some(viewer) + if !is_app_embed + && windmill_api_auth::is_effectively_unscoped(viewer.scopes.as_deref()) => + { + Ok(AppS3ReadIdentity::AsViewer(viewer.clone())) } + _ => Err(Error::BadRequest(format!( + "S3 file \"{}\" is not accessible from this app. A deployed app running on \ + behalf of its author only serves files it generated, files in its declared \ + allowlist, or presigned files. To expose a pre-existing file, sign it \ + (signS3Object / sign_s3_object) or set the app's execution mode to \"viewer\".", + file_query.s3 + ))), } } @@ -4029,7 +4066,7 @@ async fn download_s3_file_from_app( get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, force_viewer_allowed_s3_keys) .await?; - check_if_allowed_to_access_s3_file_from_app( + let read_authed = match check_if_allowed_to_access_s3_file_from_app( &db, &opt_authed, &query.file_query, @@ -4037,10 +4074,14 @@ async fn download_s3_file_from_app( &path, &policy, ) - .await?; + .await? + { + AppS3ReadIdentity::OnBehalf => on_behalf_authed, + AppS3ReadIdentity::AsViewer(viewer) => viewer, + }; download_s3_file_internal( - OptJobAuthed { authed: on_behalf_authed, job_id: None }, + OptJobAuthed { authed: read_authed, job_id: None }, &db, None, &w_id, @@ -4092,9 +4133,15 @@ async fn app_s3_on_behalf_and_provenance( } let (on_behalf_authed, policy) = get_on_behalf_authed_from_app(db, path, w_id, opt_authed, None).await?; - check_if_allowed_to_access_s3_file_from_app(db, opt_authed, file_query, w_id, path, &policy) - .await?; - Ok(crate::db::OptJobAuthed { authed: on_behalf_authed, job_id: None }) + let read_authed = match check_if_allowed_to_access_s3_file_from_app( + db, opt_authed, file_query, w_id, path, &policy, + ) + .await? + { + AppS3ReadIdentity::OnBehalf => on_behalf_authed, + AppS3ReadIdentity::AsViewer(viewer) => viewer, + }; + Ok(crate::db::OptJobAuthed { authed: read_authed, job_id: None }) } // The app-scoped display ops carry the app path in the URL and everything else From 7ac27c1ef240729fc38d5fa506b96bf60be74e25 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 21 Jul 2026 19:04:15 +0200 Subject: [PATCH 06/66] fix(frontend): limit compare & deploy rows to the active direction (#10234) Co-authored-by: Claude Fable 5 --- .../lib/components/CompareWorkspaces.svelte | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index ac4fcfad99..fbebaf573a 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -910,22 +910,18 @@ // Trigger and schedule rows now flow through `comparison.diffs` like every // other deployable kind — the backend's `compareWorkspaces` populates them // from `workspace_diff`, with runtime fields ignored by `compare_two_*`. - let deployableItems = $derived.by(() => { - return (comparison?.diffs ?? []) - .filter((diff) => { - const key = getItemKey(diff) - const isSelectable = selectableDiffs.includes(diff) - const isDeployedAndIrrelevant = - deploymentStatus[key]?.status === 'deployed' && !isSelectable - return !isDeployedAndIrrelevant - }) - .map((diff) => ({ - key: getItemKey(diff), - path: diff.path, - kind: diff.kind as Kind, - diff - })) - }) + // Rows are limited to the active direction (conflicts are ahead AND behind, + // so they show in both): an opposite-direction-only row would render as an + // unexplained disabled line. The other direction stays visible through the + // toggle badge counts and the behind/hidden alerts. + let deployableItems = $derived( + selectableDiffs.map((diff) => ({ + key: getItemKey(diff), + path: diff.path, + kind: diff.kind as Kind, + diff + })) + ) let ciTestResults = $state>({}) @@ -1050,10 +1046,9 @@
selectableDiffs.some((d) => getItemKey(d) === item.key)} {allSelected} onToggleItem={(item) => toggleKey(item.key)} onSelectAll={selectAll} @@ -1110,9 +1105,6 @@
{/if}
- - {comparison.summary.total_diffs} total items - {selectableDiffs.length} {mergeIntoParent ? 'deployable' : 'updateable'} @@ -1278,7 +1270,6 @@ {diff.path} {:else} - {@const isSelectable = selectableDiffs.includes(diff)} {@const oldSummary = mergeIntoParent ? summaryCache[key]?.parent : summaryCache[key]?.current} @@ -1294,7 +1285,7 @@ {editUrl} {oldSummary} {newSummary} - renamed={oldSummary != newSummary && isSelectable && existsInBothWorkspaces} + renamed={oldSummary != newSummary && existsInBothWorkspaces} /> {/if} {/snippet} From dae7c49f21eda2249217641b3503eb6bd6b3ebdc Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 21 Jul 2026 19:04:39 +0200 Subject: [PATCH 07/66] test(git-sync): cover fork-of-dev-workspace branch naming and routing (#10231) * test(git-sync): cover fork-of-dev-workspace branch naming and routing A throwaway fork of a dev workspace pushes to `wm-fork//` (the tracked branch, not the dev's label), and the root's `sync_forks` poller enumerates `wm-fork//*` and routes commits on that branch into the nested fork through the root. This was twice assumed to instead live on `wm-fork//` and therefore never be collected/reconciled; these tests pin the real behavior. - CLI unit: `computeGitSyncDeployBranch` for a fork whose parent is a dev workspace resolves to `wm-fork/main/`, explicitly not `wm-fork/dev/`. - git-sync E2E: fork a dev workspace, assert the created branch is `wm-fork/main/` (not `wm-fork/dev/*`), then assert a commit on it deploys into the fork via the root's sync_forks poller while the root is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) * test(git-sync): reconcile single-dev-per-root in fork-of-dev e2e The root workspace allows only one dev workspace, and a sibling test leaves one attached, so attach_dev_workspace failed with "already has a dev workspace". Detach any pre-existing dev before attaching, and detach ours via addCleanup so the test doesn't leak its own. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to aaa6cb89b05b76139252c64f057e53b94d12ac60 This commit updates the EE repository reference after PR #680 was merged in windmill-ee-private. Previous ee-repo-ref: 4c08634af953db5c1125b1fb03f5af211fe21db3 New ee-repo-ref: aaa6cb89b05b76139252c64f057e53b94d12ac60 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: windmill-internal-app[bot] --- cli/test/git_unit.test.ts | 20 +++++ integration_tests/test/git_sync_test.py | 111 ++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index 140da33866..2db2834bd2 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -199,6 +199,26 @@ describe("computeGitSyncDeployBranch", () => { }) ).toBe("wm-fork/main/mydev"); }); + + // A throwaway fork OF a dev workspace is named after the tracked (cloned) + // branch, NOT the parent dev's label: the child is not itself a dev + // workspace, so it carries no devWorkspaceLabel. The dev label reaches this + // deploy only as the checkout base + PR target (handled in sync.ts), never as + // the branch name — otherwise the branch would be `wm-fork/
{/if} - + mention deselects). Hence showContext={false} below. Session-scoped + assets (attached files/folders) render in the footer row instead. --> {#if inputPreface} {@render inputPreface()} {/if} @@ -863,12 +885,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->

Attach files or link a folder

- Text files stay in your browser, and a folder is linked live from disk. - The assistant lists, searches, and reads them on demand, so their contents - are sent only when it reads one. + Files and images attach to your next message. Images are seen directly; + file contents stay in your browser and are read on demand.

- Images are sent with your next message, so the assistant can see them. + A linked folder is a session-wide resource: the assistant lists, searches, + and reads its files whenever it needs them.

{/snippet} @@ -876,7 +898,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/snippet} + `accept` only steers the picker; the content sniff at attach is authoritative. -->
{:else}
+ {#if aiChatManager.mode === AIMode.GLOBAL} + + {/if} {#if !hideModeSelector} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 427856d291..336246b368 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -2,10 +2,10 @@ import AppAvailableContextList from './AppAvailableContextList.svelte' import ContextElementBadge from './ContextElementBadge.svelte' import ContextTextarea from './ContextTextarea.svelte' - import AttachedFilesBar from './files/AttachedFilesBar.svelte' import autosize from '$lib/autosize' import { contextElementKey, + createAttachedFileContextElement, isSameContextElement, type AppDomSelectorElement, type ContextElement @@ -31,6 +31,16 @@ } from './imageUtils' import { modelSupportsVision } from '../modelConfig' import { tryGetCurrentModel } from '$lib/aiStore' + import { createLongHash } from '$lib/editorLangUtils' + import { + fileToAttachedTextFile, + MAX_ATTACHED_FILES, + MAX_CONVERSATION_FILE_BYTES, + MAX_TEXT_FILE_BYTES, + textByteLength, + type AttachedTextFile + } from './textFileUtils' + import { MessageDraft } from './messageDraft.svelte' import ExpandableImage, { isImageViewerOpen } from '$lib/components/common/image/ExpandableImage.svelte' @@ -46,6 +56,7 @@ initialInstructions?: string initialPastes?: PasteAttachment[] initialImages?: AttachedImage[] + initialFiles?: AttachedTextFile[] editingMessageIndex?: number | null onEditEnd?: () => void className?: string @@ -76,6 +87,7 @@ initialInstructions = '', initialPastes = undefined, initialImages = undefined, + initialFiles = undefined, editingMessageIndex = null, onEditEnd = () => {}, className = '', @@ -142,16 +154,22 @@ let contextTextareaComponent: ContextTextarea | undefined = $state() let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state() - let instructions = $state(untrack(() => initialInstructions)) + // The four lanes that ship with the next send — text, collapsed big-paste + // blobs, per-message images, per-message text files — owned by one draft so + // every aggregation applies the draft rules. The composer keeps only the + // async in-flight accounting (pending counters, byte reservations). + const draft = new MessageDraft( + untrack(() => ({ + text: initialInstructions, + pastes: initialPastes ?? [], + images: initialImages ?? [], + files: initialFiles ?? [] + })) + ) $effect(() => { - const text = instructions + const text = draft.text untrack(() => onDraftChange?.(text)) }) - // Collapsed big-paste blobs referenced by tokens in `instructions`. - let pastes = $state(untrack(() => initialPastes ?? [])) - // Per-message image attachments (drag/drop/paste), GLOBAL mode only. One-shot: - // they attach to the next send and clear, unlike the persistent attached-files store. - let images = $state(untrack(() => initialImages ?? [])) // Images being decoded right now. Holds off sending so a message can never go // out without an attachment the user already dropped, and reserves cap slots // against a concurrent drop. @@ -171,10 +189,10 @@ sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true) return } - // Count decodes already in flight: two drops that both read `images.length` + // Count decodes already in flight: two drops that both read the image count // before either resolves would each claim the same free slots and overshoot // the cap. - const remaining = MAX_ATTACHED_IMAGES - images.length - pendingImages + const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages if (remaining <= 0) { sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true) return @@ -210,7 +228,7 @@ failed++ } } - if (added.length > 0) images = [...images, ...added] + if (added.length > 0) draft.addImages(added) if (failed > 0) sendUserToast(`Could not attach ${failed} image(s).`, true) } finally { pendingImages -= batch.length @@ -218,7 +236,140 @@ } function removeImage(index: number) { - images = images.filter((_, i) => i !== index) + draft.images = draft.images.filter((_, i) => i !== index) + } + + // Files being read right now — same send-hold/slot-reservation role as pendingImages. + let pendingFiles = $state(0) + // Drop routing resolves file-system handles/entries asynchronously before it + // can call addTextFiles/addImages; a send during that window would land the + // dropped files on the NEXT message. Holds block sending (no slot or chip + // impact) until the drop handler finishes routing. + let ingestionHolds = $state(0) + export function holdSendForIngestion(): () => void { + ingestionHolds += 1 + let released = false + return () => { + if (!released) { + released = true + ingestionHolds -= 1 + } + } + } + // Bytes those in-flight reads have claimed against the conversation budget: + // two overlapping drops that both read the budget before either lands would + // otherwise each spend the same remaining allowance. + let pendingFileBytes = $state(0) + + // Publish this composer's staged bytes (committed attachments + in-flight + // reads) to the manager so a concurrently-mounted composer — the edit box + // while editing an earlier message — sees them in its own budget check and + // the two can't each spend the whole conversation allowance. + const composerKey = untrack(() => createLongHash()) + let stagedBytes = $derived( + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes + ) + $effect(() => { + aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + }) + $effect(() => () => aiChatManager.clearComposerStaged(composerKey)) + + /** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */ + export async function addTextFiles(candidates: File[]) { + if (aiChatManager.mode !== AIMode.GLOBAL) return + if (candidates.length === 0) return + const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles + if (remaining <= 0) { + sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true) + return + } + const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES) + if (oversized.length > 0) { + const mb = Math.round(MAX_TEXT_FILE_BYTES / 1_000_000) + sendUserToast( + `${oversized.length} file(s) over ${mb}MB were skipped — link their folder to read them on demand.`, + true + ) + } + const usable = candidates.filter((f) => f.size <= MAX_TEXT_FILE_BYTES) + if (usable.length === 0) return + let batch = usable.slice(0, remaining) + if (batch.length < usable.length) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`, + true + ) + } + // Conversation-level byte budget: transcript + queue + every live + // composer's stage (this one and, mid-edit, the other) + this composer's + // own pending reads. File content is persisted with every history save, so + // an unbounded total would grow the chat record without limit. The + // transcript sum skips any message a composer is editing — that composer's + // stage stands in for it, so counting both would charge those bytes twice. + let budget = + MAX_CONVERSATION_FILE_BYTES - + aiChatManager.attachmentBytesExcluding(composerKey) - + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - + pendingFileBytes + const withinBudget: File[] = [] + for (const f of batch) { + if (f.size <= budget) { + withinBudget.push(f) + budget -= f.size + } + } + if (withinBudget.length < batch.length) { + const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) + sendUserToast( + `${batch.length - withinBudget.length} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`, + true + ) + } + batch = withinBudget + if (batch.length === 0) return + pendingFiles += batch.length + const reservedBytes = batch.reduce((sum, f) => sum + f.size, 0) + pendingFileBytes += reservedBytes + try { + const reads: { name: string; content: string }[] = [] + let skipped = 0 + for (const file of batch) { + try { + const attached = await fileToAttachedTextFile(file) + if (attached) reads.push(attached) + else skipped++ + } catch { + skipped++ + } + } + // Commit through the draft in one synchronous step — fold (dedupe, + // courtesy rename) and decoded-byte admission both run against the live + // list, so another batch landing between this one's file reads can't be + // missed, and malformed input that inflates on decode can't slip past the + // raw-size admission above. This batch's own raw reservation is excluded + // from the budget — the decoded sizes replace it. + const liveBudget = + MAX_CONVERSATION_FILE_BYTES - + aiChatManager.attachmentBytesExcluding(composerKey) - + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - + (pendingFileBytes - reservedBytes) + const { droppedAtBudget } = draft.addFiles(reads, liveBudget) + if (droppedAtBudget > 0) { + const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) + sendUserToast( + `${droppedAtBudget} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`, + true + ) + } + if (skipped > 0) sendUserToast(`Skipped ${skipped} file(s) (non-text).`, true) + } finally { + pendingFiles -= batch.length + pendingFileBytes -= reservedBytes + } + } + + function removeFile(index: number) { + draft.files = draft.files.filter((_, i) => i !== index) } // App mode @ mention state @@ -250,9 +401,9 @@ * leave duplicate tokens. */ export function insertMention(title: string) { const target = `@${title}` - if (instructions.split(/\s+/).includes(target)) return - const sep = instructions.length === 0 || /\s$/.test(instructions) ? '' : ' ' - instructions = `${instructions}${sep}${target} ` + if (draft.text.split(/\s+/).includes(target)) return + const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' + draft.text = `${draft.text}${sep}${target} ` } /** Strip every `@title` token from the textarea — used when the user @@ -268,7 +419,7 @@ contextTextareaComponent?.unsyncMention(title) const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g') - instructions = instructions.replace(re, (_m, lead, trail) => { + draft.text = draft.text.replace(re, (_m, lead, trail) => { // Boundary on at least one side → drop the mention entirely. if (!lead || !trail) return '' // Middle of text: keep ONE of the bracketing whitespace chars so @@ -296,12 +447,23 @@ export function restoreInstructions( value: string, restoredPastes: PasteAttachment[] = [], - restoredImages: AttachedImage[] = [] + restoredImages: AttachedImage[] = [], + restoredFiles: AttachedTextFile[] = [] ): boolean { - if (instructions.trim() || images.length > 0 || pendingImages > 0) return false - instructions = value - pastes = restoredPastes - images = restoredImages + // Attachments still decoding/reading (or mid-drop-routing) count as + // occupancy too — they belong to a draft the user started even though + // their lane is still empty. + if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false + if ( + !draft.replaceIfEmpty({ + text: value, + pastes: restoredPastes, + images: restoredImages, + files: restoredFiles + }) + ) { + return false + } focusInput() return true } @@ -311,24 +473,30 @@ * the user typed is lost. Restored images join whatever is already * attached, up to the cap — dropping them would lose the attachment * silently, which is the whole reason the queue carries them. */ - export function prependText(text: string, restoredImages: AttachedImage[] = []): boolean { - // Whether the restored text landed on top of a draft the user was already - // writing: both instructions now share one composer, so the caller must keep - // both their contexts rather than replacing one with the other. - const mergedIntoDraft = !!text && !!instructions.trim() - // An image-only restore has empty text; prepending it would only add blank lines. - if (text) { - instructions = instructions.trim() ? `${text}\n\n${instructions}` : text + export function prependText( + text: string, + restoredImages: AttachedImage[] = [], + restoredFiles: AttachedTextFile[] = [] + ): boolean { + // mergedIntoDraft: the restored text landed on top of a draft the user was + // already writing — both instructions now share one composer, so the caller + // must keep both their contexts rather than replacing one with the other. + const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({ + text, + images: restoredImages, + files: restoredFiles + }) + if (droppedImages > 0) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_IMAGES} images; ${droppedImages} restored image(s) were dropped.`, + true + ) } - if (restoredImages.length > 0) { - const merged = [...images, ...restoredImages] - if (merged.length > MAX_ATTACHED_IMAGES) { - sendUserToast( - `You can attach up to ${MAX_ATTACHED_IMAGES} images; ${merged.length - MAX_ATTACHED_IMAGES} restored image(s) were dropped.`, - true - ) - } - images = merged.slice(0, MAX_ATTACHED_IMAGES) + if (droppedFiles > 0) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_FILES} files; ${droppedFiles} restored file(s) were dropped.`, + true + ) } focusInput() return mergedIntoDraft @@ -336,8 +504,8 @@ /** Insert a plain @filename mention for an attached file (used by the @ menu Files category). */ export function insertFileMention(name: string) { - const sep = instructions.length === 0 || instructions.endsWith(' ') ? '' : ' ' - instructions = `${instructions}${sep}${formatMention(name)} ` + const sep = draft.text.length === 0 || draft.text.endsWith(' ') ? '' : ' ' + draft.text = `${draft.text}${sep}${formatMention(name)} ` focusInput() } @@ -429,8 +597,8 @@ function sendRequest() { // The send button is disabled while decoding, but Enter reaches here directly. - // Sending now would drop the in-flight images onto the following message. - if (pendingImages > 0) { + // Sending now would drop the in-flight attachments onto the following message. + if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) { return } if (aiChatManager.loading) { @@ -444,17 +612,16 @@ // chips picked at press time. if ( editingMessageIndex === null && - (instructions.trim() || - images.length > 0 || - (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0)) + (!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0)) ) { - aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)), images, [ - ...selectedContext - ]) + const sent = draft.take() + aiChatManager.queueMessage( + expanded(chatDraft(sent.text, sent.pastes)), + sent.images, + [...selectedContext], + sent.files + ) contextTextareaComponent?.clearForSend() - instructions = '' - pastes = [] - images = [] } return } @@ -462,25 +629,30 @@ // In edit mode selectedContext is the edit box's own copy (seeded from the // message's original chips), so send exactly what's shown — the user may // have added or removed chips. + const sent = draft.take() aiChatManager.restartGeneration( editingMessageIndex, - instructions, - pastes, - images, - selectedContext + sent.text, + sent.pastes, + sent.images, + selectedContext, + sent.files ) onEditEnd() } else { - aiChatManager.sendRequest({ instructions, pastes, images }) + const sent = draft.take() + aiChatManager.sendRequest({ + instructions: sent.text, + pastes: sent.pastes, + images: sent.images, + files: sent.files + }) // clearForSend() pre-zaps the textarea's mention-sync so the wipe // doesn't drop `selectedContext` before `AIChatManager.beforeSend` // snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the - // fallback textarea still rely on the plain `instructions = ''` - // reset (no `@`-mention state to coordinate). + // fallback textarea still rely on the draft reset alone (no + // `@`-mention state to coordinate). contextTextareaComponent?.clearForSend() - instructions = '' - pastes = [] - images = [] } } @@ -489,7 +661,7 @@ // for the conversation bubble and expands them for the LLM inside the manager. function submitRequest() { if (onSendRequest) { - onSendRequest(expanded(chatDraft(instructions, pastes))) + onSendRequest(expanded(chatDraft(draft.text, draft.pastes))) } else { sendRequest() } @@ -661,7 +833,7 @@ } function handleAppInput(_e: Event) { - const words = instructions.split(/\s+/) + const words = draft.text.split(/\s+/) const lastWord = words[words.length - 1] if ( @@ -680,9 +852,9 @@ function handleAppContextSelection(contextElement: ContextElement) { void addContextToSelection(contextElement) // Update instructions with the selected context title - const index = instructions.lastIndexOf('@') + const index = draft.text.lastIndexOf('@') if (index !== -1) { - instructions = instructions.substring(0, index) + `@${contextElement.title}` + draft.text = draft.text.substring(0, index) + `@${contextElement.title}` } showAppContextTooltip = false } @@ -696,7 +868,7 @@ {#snippet sendStopButton()} {@const isLoading = loading ?? aiChatManager.loading} - {@const emptyDraft = instructions.trim().length === 0 && images.length === 0} + {@const emptyDraft = draft.isEmpty} +{#snippet badgeRow()} + {@const contextChips = showContext ? selectedContext : domSelectorChips} + {#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
- {#each selectedContext as element (contextKey(element))} + {#each contextChips as element (contextKey(element))} { selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element)) - removeMention(element.title) + if (showContext) removeMention(element.title) }} /> {/each} -
- {/if} -{/snippet} - - -{#snippet domSelectorChipRow()} - {#if domSelectorChips.length > 0} -
- {#each domSelectorChips as element (contextKey(element))} + {#each draft.files as file, i (i)} { - selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element)) - }} + onDelete={() => removeFile(i)} /> {/each} + {#each { length: pendingFiles } as _, i (i)} +
+ +
+ {/each}
{/if} {/snippet} {#snippet imageChipsRow()} - {#if images.length > 0 || pendingImages > 0} -
- {#each images as image, i (i)} + {#if draft.images.length > 0 || pendingImages > 0} +
+ {#each draft.images as image, i (i)}
@@ -811,10 +987,13 @@
void addImages(files) + ? (pasted) => void addImages(pasted) + : undefined} + onTextFiles={aiChatManager.mode === AIMode.GLOBAL + ? (pasted) => void addTextFiles(pasted) : undefined} {availableContext} {selectedContext} @@ -833,16 +1012,7 @@ {onKeyDown} > {#snippet leading()} - {#if aiChatManager.mode === AIMode.GLOBAL} -
- -
- {/if} - {#if showContext} - {@render contextPickerRow()} - {:else} - {@render domSelectorChipRow()} - {/if} + {@render badgeRow()} {@render imageChipsRow()} {/snippet}
@@ -854,12 +1024,12 @@
{:else if aiChatManager.mode === AIMode.APP} {#if showContext} - {@render contextPickerRow()} + {@render badgeRow()} {/if}
+ + Markdown supported. Editable any time before and after publication. + + +
+
+ + Data table migrations +
+ {#if s.migrationsGenerating} +
+ + Detecting data tables used by this project… +
+ {:else if s.migrationDrafts.length === 0} + + No data table usage detected in this project's scripts, flows, or raw apps. + + {:else} + + We detected these data tables. When included, the migration recreates their tables + on import. Best-effort — review and edit before publishing. + + {#each s.migrationDrafts as m (m.datatable_name)} +
+
+ {m.datatable_name} + +
+ +
+ {/each} + {/if} +
+
+ {#snippet actions()} + + {/snippet} + + + {/key} +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/MigrationSqlEditor.svelte b/frontend/src/lib/components/workspaceSettings/MigrationSqlEditor.svelte new file mode 100644 index 0000000000..76e3da3e07 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/MigrationSqlEditor.svelte @@ -0,0 +1,40 @@ + + +
+
+ {#each [{ id: 'up', label: 'Up' }, { id: 'down', label: 'Down' }] as t (t.id)} + + {/each} +
+ {#key generation} +
+ {#if tab === 'up'} + + {:else} + + {/if} +
+ {/key} +
diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts new file mode 100644 index 0000000000..11926c804b --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts @@ -0,0 +1,1635 @@ +import { untrack } from 'svelte' +import { base } from '$lib/base' +import { + AppService, + FlowService, + JobService, + RawAppService, + ResourceService, + ScriptService, + WorkspaceService, + ScheduleService +} from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { sleep, emptySchema } from '$lib/utils' +import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte' +import { + buildProjectBundle, + buildPathMap, + classifyPath, + extractScriptRefs, + extractFlowRefs, + extractAppRefs, + extractTriggerConfigResourceRefs, + extractVarRefsFromValue, + rewriteTriggerConfig, + rewriteVarRefsInValue, + type BundleDeps, + type BundledItem, + type FetchedItem, + type ItemKind, + type ItemRef, + type ProjectBundle +} from './projectBundle' +import { + detectDatatableTables, + generateDatatableMigrations, + type GeneratedMigration +} from './projectMigrations' +import type { Kind } from '$lib/utils_deployable' +import { + TRIGGER_KINDS, + listAllWorkspaceTriggers, + triggerResourcePath, + triggerHandlerRefs, + portableTriggerConfig, + type WorkspaceTrigger, + type WorkspaceTriggerKind +} from '../triggers/workspaceTriggersList' + +export type Phase = 'predeploy' | 'draft' | 'under_review' | 'live' +export type RecStatus = 'none' | 'recorded' +export interface DeployItem { + key: string + path: string + kind: Kind + summary?: string + rec: RecStatus + published?: boolean + publicUrl?: string + [k: string]: unknown +} + +export const canRecord = (k: Kind) => k === 'script' || k === 'flow' +// Legacy raw apps live only in the `raw_app` table, but the iframe share flow +// drives AppService (the `app` table), so it can only target apps stored there. +export const canShareAsIframe = (it: DeployItem): boolean => + it.kind === 'app' || (it.kind === 'raw_app' && it.appTable === true) + +// Hub rehydration only carries draft membership, not the live share state of an +// app. Copy the public-execution flag, public URL, and app-table origin from the +// loaded workspace items onto matching draft items so a still-public app keeps its +// Public badge, Unpublish, and iframe controls after its draft is reopened. Returns +// the original array unchanged when nothing needs merging (stable reference). +export function mergeShareState( + draftItems: DeployItem[], + workspaceItems: DeployItem[] +): DeployItem[] { + if (draftItems.length === 0 || workspaceItems.length === 0) return draftItems + const byKey = new Map(workspaceItems.map((w) => [w.key, w])) + let changed = false + const merged = draftItems.map((d) => { + const w = byKey.get(d.key) + if (!w) return d + if (w.published !== d.published || w.publicUrl !== d.publicUrl || w.appTable !== d.appTable) { + changed = true + return { ...d, published: w.published, publicUrl: w.publicUrl, appTable: w.appTable } + } + return d + }) + return changed ? merged : draftItems +} + +export function sanitizeSlug(s: string): string { + return s + .toLowerCase() + .replace(/[_\s]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50) + .replace(/-+$/g, '') +} +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/ +export function isValidSlug(s: string): boolean { + return SLUG_RE.test(s) +} + +export type RunState = 'idle' | 'running' | 'success' | 'failed' + +const ITEM_KIND_ROUTE: Record = { + script: 'scripts/get', + flow: 'flows/get', + app: 'apps/get', + raw_app: 'apps_raw/get' +} + +const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache']) + +function typesFromSchema(schema: any): string[] { + const out = new Set() + const props = schema?.properties + if (props && typeof props === 'object') { + for (const key of Object.keys(props)) { + const fmt = props[key]?.format + if (typeof fmt === 'string' && fmt.startsWith('resource-')) { + out.add(fmt.slice('resource-'.length)) + } + } + } + return [...out] +} + +type DependencyUsage = + | { role: 'input'; label: string; kind: ItemKind; itemPath: string } + | { role: 'hardcoded'; label: string; kind: ItemKind; path: string; itemPath: string } + | { role: 'trigger'; label: string; triggerKind: WorkspaceTriggerKind; path: string } +export interface DependencyType { + resource_type: string + hasHardcoded: boolean + usages: DependencyUsage[] +} + +interface SessionDeps { + hasEeLicense: () => boolean +} + +/** + * All state and async operations for one Deploy-to-Hub surface, bound to an + * immutable (workspace, folder) pair. A workspace or folder change never mutates + * a session — `useDeployToHubSession` replaces the instance, so in-flight async + * work keeps writing to the discarded object and cannot leak into the new scope. + * The only invalidation tokens left are intra-session (competing calls on the + * same session), not lifecycle guards. + */ +export class DeployToHubSession { + readonly workspace: string + readonly folder: string + /** `f/`-prefixed folder path the project is scoped to. */ + readonly selectedFolder: string + + #disposed = false + #deps: SessionDeps + + phase = $state('predeploy') + workspaceItems = $state([]) + draftItems = $state([]) + workspaceTriggers = $state([]) + triggersLoading = $state(false) + // True when a trigger kind's discovery failed (not a feature-gated 404): + // the trigger list may be incomplete, so publishing is blocked until a + // retry succeeds. + triggerDiscoveryFailed = $state(false) + schedulePreviews = $state>({}) + manualDeselected = $state>(new Set()) + loading = $state(false) + workspaceRateLimit = $state(undefined) + deploymentStatus = $state< + Record + >({}) + deploying = $state(false) + + recordTarget = $state() + recordArgs = $state>({}) + recordValid = $state(true) + recordSchema = $state>(emptySchema()) + recordSchemaLoading = $state(false) + runState = $state('idle') + runJobId = $state(undefined) + runResult = $state(undefined) + runError = $state(undefined) + recordings = $state>({}) + + publishTarget = $state() + publishing = $state(false) + + hubName = $state('') + hubSummary = $state('') + hubReadme = $state('') + effectiveSlug = $state('') + hubItemIds = $state>({}) + + // Best-effort data table migrations for the bundle, editable in the drawer and + // pushed on deploy. Regenerated when the bundle drawer opens. + migrationDrafts = $state([]) + migrationsGenerating = $state(false) + // Bumped whenever the drafts are (re)generated, to re-key the Monaco editors so + // they pick up the fresh SQL (Monaco doesn't sync external `code` changes). + migrationsGeneration = $state(0) + + bundlePreview = $state(undefined) + detectingResources = $state(false) + // Data tables (→ tables) the current selection reads/writes, detected off the + // same bundle preview. Drives the predeploy "Data table dependencies" summary; + // the editable migration itself is generated in the bundle drawer. + datatableUsage = $state>>(new Map()) + detectingDatatables = $state(false) + + submitting = $state(false) + syncing = $state(false) + + // Intra-session tokens: latest call wins among competing calls on this session. + #triggerLoadTok = 0 + #recordRunTok = 0 + #migrationsTok = 0 + #schedulePreviewsInFlight = new Set() + // Preview-only cache: toggling checkboxes re-runs the closure walk, but item + // contents don't change mid-session. deployAll bypasses this and fetches fresh. + #previewItemCache = new Map>() + #previewTypeCache = new Map>() + + constructor(workspace: string, folder: string, deps: SessionDeps) { + this.workspace = workspace + this.folder = folder + this.selectedFolder = `f/${folder}` + this.#deps = deps + } + + dispose() { + this.#disposed = true + } + + load() { + void this.#loadWorkspace() + void this.#loadTriggers() + void this.rehydrateFromHub() + } + + filteredWorkspaceItems = $derived( + this.workspaceItems.filter((i) => i.path.startsWith(this.selectedFolder + '/')) + ) + // Derived (not merged at load time) so it settles regardless of which of the + // racing loads (#loadWorkspace / rehydrateFromHub) finishes last. + draftItemsWithLocalState = $derived(mergeShareState(this.draftItems, this.workspaceItems)) + items = $derived( + this.phase === 'predeploy' ? this.filteredWorkspaceItems : this.draftItemsWithLocalState + ) + selectedItems = $derived( + this.phase === 'predeploy' + ? this.filteredWorkspaceItems.filter((i) => !this.manualDeselected.has(i.key)) + : [] + ) + selectedItemKeys = $derived(this.selectedItems.map((i) => i.key)) + allSelected = $derived( + this.phase === 'predeploy' && + this.selectedItemKeys.length === this.filteredWorkspaceItems.length + ) + recordableItems = $derived(this.items.filter((i) => canRecord(i.kind))) + allRecorded = $derived( + this.recordableItems.length > 0 && this.recordableItems.every((i) => i.rec === 'recorded') + ) + hubSlug = $derived(this.effectiveSlug || sanitizeSlug(this.hubName)) + + relevantTriggers = $derived.by(() => { + const selectedScripts = new Set( + this.selectedItems.filter((i) => i.kind === 'script').map((i) => i.path) + ) + const selectedFlows = new Set( + this.selectedItems.filter((i) => i.kind === 'flow').map((i) => i.path) + ) + return this.workspaceTriggers.filter((t) => + t.is_flow ? selectedFlows.has(t.script_path) : selectedScripts.has(t.script_path) + ) + }) + + triggersByKind = $derived.by(() => { + const out = new Map() + for (const t of this.relevantTriggers) { + const arr = out.get(t.kind) ?? [] + arr.push(t) + out.set(t.kind, arr) + } + return Array.from(out.entries()).sort((a, b) => a[0].localeCompare(b[0])) + }) + + runnableSummaryByPath = $derived.by(() => { + const m = new Map() + for (const it of this.workspaceItems) { + if (it.kind === 'script' || it.kind === 'flow') { + m.set(`${it.kind}:${it.path}`, it.summary) + } + } + return m + }) + + // `hasHardcoded` = pinned via $res: path (relocated as a stub); else input-only. + dependencyTypes = $derived.by(() => { + const b = this.bundlePreview + if (!b) return [] as DependencyType[] + const stubByNewPath = new Map(b.resourceStubs.map((s) => [s.newPath, s])) + const byType = new Map() + const ensure = (rt: string) => { + let e = byType.get(rt) + if (!e) { + e = { resource_type: rt, hasHardcoded: false, usages: [] } + byType.set(rt, e) + } + return e + } + for (const it of b.items) { + const label = (it.summary?.trim() || it.path) ?? it.path + const refs = + it.kind === 'flow' + ? extractFlowRefs(it.value).filter((r) => r.kind === 'resource') + : it.kind === 'app' + ? extractAppRefs(it.value) + : extractScriptRefs(it.content ?? '') + for (const r of refs) { + const stub = stubByNewPath.get(r.path) + if (!stub || HIDDEN_RESOURCE_TYPES.has(stub.resource_type)) continue + const e = ensure(stub.resource_type) + e.hasHardcoded = true + e.usages.push({ + role: 'hardcoded', + label, + kind: it.kind, + path: stub.originalPath, + itemPath: it.path + }) + } + for (const t of typesFromSchema(it.schema)) { + if (HIDDEN_RESOURCE_TYPES.has(t)) continue + ensure(t).usages.push({ role: 'input', label, kind: it.kind, itemPath: it.path }) + } + } + // Resources referenced only by a trigger (no item uses them in code) — + // its kind resource field or any `$res:` token in its config. + const stubByOriginal = new Map(b.resourceStubs.map((s) => [s.originalPath, s])) + for (const t of this.relevantTriggers) { + const refs = new Set( + extractTriggerConfigResourceRefs(portableTriggerConfig(t.kind, t.config)) + ) + const rp = triggerResourcePath(t) + if (rp) refs.add(rp) + for (const ref of refs) { + const stub = stubByOriginal.get(ref) + if (!stub || HIDDEN_RESOURCE_TYPES.has(stub.resource_type)) continue + ensure(stub.resource_type).usages.push({ + role: 'trigger', + label: t.summary?.trim() || t.path, + triggerKind: t.kind, + path: stub.originalPath + }) + } + } + return [...byType.values()].sort((a, b) => a.resource_type.localeCompare(b.resource_type)) + }) + + toggleItem = (item: { key: string }) => { + const next = new Set(this.manualDeselected) + if (next.has(item.key)) next.delete(item.key) + else next.add(item.key) + this.manualDeselected = next + } + selectAll = () => { + this.manualDeselected = new Set() + } + deselectAll = () => { + this.manualDeselected = new Set(this.filteredWorkspaceItems.map((i) => i.key)) + } + + #folderQs(): string { + return `?folder=${encodeURIComponent(this.folder)}` + } + + itemUrl(kind: ItemKind, path: string): string | undefined { + if (!path) return undefined + return `${base}/${ITEM_KIND_ROUTE[kind]}/${path}?workspace=${this.workspace}` + } + triggerListUrl(kind: WorkspaceTriggerKind): string { + return `${base}/${TRIGGER_KINDS[kind].route}?workspace=${this.workspace}` + } + + #patchItem(key: string, patch: Partial) { + this.workspaceItems = this.workspaceItems.map((i) => (i.key === key ? { ...i, ...patch } : i)) + this.draftItems = this.draftItems.map((i) => (i.key === key ? { ...i, ...patch } : i)) + } + + async #listAllPages( + fetcher: (params: { perPage: number; page: number }) => Promise + ): Promise { + const perPage = 100 + const out: T[] = [] + for (let page = 1; page <= 1000; page++) { + const batch = await fetcher({ perPage, page }) + out.push(...batch) + if (batch.length < perPage) return out + } + return out + } + + async #loadWorkspace() { + const workspace = this.workspace + this.loading = true + try { + const [apps, rawApps, flows, scripts, settings] = await Promise.all([ + this.#listAllPages((p) => AppService.listApps({ workspace, ...p })), + this.#listAllPages((p) => RawAppService.listRawApps({ workspace, ...p })), + this.#listAllPages((p) => FlowService.listFlows({ workspace, ...p })), + this.#listAllPages((p) => ScriptService.listScripts({ workspace, ...p })), + WorkspaceService.getSettings({ workspace }).catch(() => undefined) + ]) + if (this.#disposed) return + + this.workspaceRateLimit = settings?.public_app_execution_limit_per_minute + + const next: DeployItem[] = [] + const publicApps = apps.filter((a) => a.execution_mode === 'anonymous') + const publicUrls = await Promise.all(publicApps.map((a) => this.#resolvePublicUrl(a.path))) + const publicUrlByPath = new Map(publicApps.map((a, i) => [a.path, publicUrls[i]])) + for (const a of apps) { + const isPublic = a.execution_mode === 'anonymous' + // Raw apps live in the `app` table (value = files/runnables) but must be + // published to the Hub as raw apps, not low-code apps. + const isRaw = (a as any).raw_app === true + next.push({ + key: `${isRaw ? 'raw_app' : 'app'}:${a.path}`, + path: a.path, + kind: isRaw ? 'raw_app' : 'app', + appTable: isRaw || undefined, + summary: a.summary, + rec: 'none', + published: isPublic, + publicUrl: isPublic ? publicUrlByPath.get(a.path) : undefined + }) + } + for (const a of rawApps) { + next.push({ + key: `raw_app:${a.path}`, + path: a.path, + kind: 'raw_app', + summary: a.summary, + rec: 'none' + }) + } + for (const f of flows) { + next.push({ + key: `flow:${f.path}`, + path: f.path, + kind: 'flow', + summary: f.summary, + rec: 'none' + }) + } + for (const s of scripts) { + next.push({ + key: `script:${s.path}`, + path: s.path, + kind: 'script', + summary: s.summary, + rec: 'none' + }) + } + if (this.#disposed) return + this.workspaceItems = next + } catch (e: any) { + if (!this.#disposed) { + sendUserToast(`Failed to load project items: ${e?.message ?? e}`, true) + } + } finally { + if (!this.#disposed) this.loading = false + } + } + + /** Re-fetch triggers, e.g. after the EE license hydrates late. */ + reloadTriggers() { + void this.#loadTriggers() + } + + async #loadTriggers() { + const tok = ++this.#triggerLoadTok + this.triggersLoading = true + try { + const { triggers, failedKinds } = await listAllWorkspaceTriggers(this.workspace, { + includeEeOnly: this.#deps.hasEeLicense(), + onError: (message) => { + if (!this.#disposed) sendUserToast(message, true) + } + }) + if (this.#disposed || tok !== this.#triggerLoadTok) return + this.workspaceTriggers = triggers + this.triggerDiscoveryFailed = failedKinds.length > 0 + } finally { + if (!this.#disposed && tok === this.#triggerLoadTok) this.triggersLoading = false + } + } + + async #resolvePublicUrl(path: string): Promise { + try { + const secret = await AppService.getPublicSecretOfApp({ workspace: this.workspace, path }) + return computeSecretUrl(secret) + } catch { + return undefined + } + } + + async rehydrateFromHub() { + try { + const res = await fetch(`/api/w/${this.workspace}/hub/project${this.#folderQs()}`, { + credentials: 'include', + headers: { accept: 'application/json' } + }) + if (this.#disposed) return + if (!res.ok) return // 404 = no project published for this folder yet + const p = JSON.parse(await res.text()) + if (this.#disposed || !p?.slug) return + this.effectiveSlug = p.slug + this.hubName = p.name ?? '' + this.hubSummary = p.summary ?? '' + this.hubReadme = p.readme ?? '' + this.phase = + p.status === 'live' ? 'live' : p.status === 'under_review' ? 'under_review' : 'draft' + const ids: Record = {} + this.draftItems = (p.items ?? []).map((it: any) => { + const wpath = it.source_path ?? it.path + const key = `${it.kind}:${wpath}` + if (typeof it.hub_id === 'number') ids[key] = it.hub_id + return { + key, + path: wpath, + kind: it.kind as Kind, + summary: it.summary ?? undefined, + rec: it.has_recording ? 'recorded' : 'none' + } satisfies DeployItem + }) + this.hubItemIds = ids + } catch {} + } + + /** Kick off schedule-preview fetches for any relevant schedule trigger missing one. */ + ensureSchedulePreviews() { + for (const t of this.relevantTriggers) { + if (t.kind !== 'schedule') continue + const c = t.config as any + const key = `${c.schedule}|${c.timezone}` + if (this.schedulePreviews[key] || this.#schedulePreviewsInFlight.has(key)) continue + this.#schedulePreviewsInFlight.add(key) + ScheduleService.previewSchedule({ + requestBody: { + schedule: c.schedule, + timezone: c.timezone, + cron_version: c.cron_version ?? 'v2' + } + }) + .then((dates) => { + this.schedulePreviews = { ...this.schedulePreviews, [key]: dates.slice(0, 3) } + }) + .catch(() => {}) + .finally(() => this.#schedulePreviewsInFlight.delete(key)) + } + } + + /** + * Rebuild the predeploy bundle preview (resource + data table dependency + * summaries), debounced so rapid checkbox toggles coalesce into one walk. + * Reads its reactive inputs synchronously and returns a cancel function, so + * it can be driven from an `$effect` with proper cleanup. + */ + queueBundlePreview(): (() => void) | undefined { + if (this.phase !== 'predeploy') { + this.bundlePreview = undefined + this.datatableUsage = new Map() + return undefined + } + this.detectingResources = true + this.detectingDatatables = true + const slug = this.hubSlug + const seed: ItemRef[] = [ + ...this.selectedItems + .filter((i) => i.kind !== 'resource') + .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })), + ...this.#triggerHandlerSeed(this.relevantTriggers, slug) + ] + const triggerResources = this.#triggerResourcePaths(this.relevantTriggers) + const triggerVars = this.#triggerVarPaths(this.relevantTriggers) + let cancelled = false + const timer = setTimeout(() => { + buildProjectBundle(seed, slug, this.#cachedBundleDeps(), triggerResources, triggerVars) + .then((b) => { + if (cancelled) return + this.bundlePreview = b + // Detect data table usage off the same fetched items. + detectDatatableTables(b.items) + .then((usage) => { + if (!cancelled) this.datatableUsage = usage + }) + .finally(() => { + if (!cancelled) this.detectingDatatables = false + }) + }) + .finally(() => { + if (!cancelled) this.detectingResources = false + }) + }, 250) + return () => { + cancelled = true + clearTimeout(timer) + } + } + + #buildBundleDeps(): BundleDeps { + const workspace = this.workspace + return { + fetchItem: async (ref: ItemRef): Promise => { + try { + if (ref.kind === 'script') { + const s = await ScriptService.getScriptByPath({ workspace, path: ref.path }) + return { + kind: 'script', + path: ref.path, + summary: s.summary, + description: s.description ?? undefined, + content: s.content, + language: s.language, + schema: s.schema, + lock: s.lock ?? undefined, + scriptKind: typeof s.kind === 'string' ? s.kind.toLowerCase() : 'script' + } + } else if (ref.kind === 'flow') { + const f = await FlowService.getFlowByPath({ workspace, path: ref.path }) + return { + kind: 'flow', + path: ref.path, + summary: f.summary, + description: f.description ?? undefined, + value: f.value, + schema: f.schema + } + } else if (ref.kind === 'app') { + const a = await AppService.getAppByPath({ workspace, path: ref.path }) + return { kind: 'app', path: ref.path, summary: a.summary, value: a.value } + } else if (ref.kind === 'raw_app') { + // Modern raw apps live in the `app` table: fetch source files + + // runnables + the compiled bundle, and shape them into the `raw` + // payload the Hub's RawAppView expects (JSON is valid YAML). + const isModern = this.workspaceItems.some( + (i) => i.kind === 'raw_app' && i.path === ref.path && i.appTable + ) + if (isModern) { + const a = await AppService.getAppByPath({ workspace, path: ref.path }) + const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ + workspace, + path: ref.path + }) + // The compiled JS bundle is required; a missing one means the app + // was never built/deployed, so fail loudly instead of pushing a blank app. + const [jsRes, cssRes] = await Promise.all([ + fetch(`/api/w/${workspace}/apps/get_data/v/${secret}.js`, { + credentials: 'include' + }), + fetch(`/api/w/${workspace}/apps/get_data/v/${secret}.css`, { + credentials: 'include' + }) + ]) + if (!jsRes.ok) { + throw new Error(`raw app ${ref.path} has no compiled bundle — deploy it first`) + } + const js = await jsRes.text() + const css = cssRes.ok ? await cssRes.text() : '' + const v: any = a.value ?? {} + const content = JSON.stringify({ + files: { ...(v.files ?? {}), '/bundle.js': js, '/bundle.css': css }, + runnables: v.runnables ?? {}, + // Preserve the full-code app's explicit data table declaration so it + // survives publish/import and feeds migration detection. + ...(v.data !== undefined ? { data: v.data } : {}), + ...(v.datatables !== undefined ? { datatables: v.datatables } : {}) + }) + return { kind: 'raw_app', path: ref.path, summary: a.summary, content } + } + const r = await fetch(`/api/w/${workspace}/raw_apps/get_data/0/${ref.path}`, { + credentials: 'include' + }) + if (!r.ok) return undefined + return { kind: 'raw_app', path: ref.path, content: await r.text() } + } + } catch (e: any) { + return undefined + } + return undefined + }, + resolveResourceType: async (path: string): Promise => { + try { + const r = await ResourceService.getResource({ workspace, path }) + return r.resource_type ?? undefined + } catch (e: any) { + return undefined + } + } + } + } + + #cachedBundleDeps(): BundleDeps { + const deps = this.#buildBundleDeps() + // Memoize only successful lookups: a miss (undefined) is likely transient, so + // evict it once it resolves. Otherwise a fixed/retried dependency can never + // clear `bundlePreview.unresolved` until the whole session is recreated. + const memoize = ( + cache: Map>, + key: string, + run: () => Promise + ) => { + let p = cache.get(key) + if (!p) { + p = run() + cache.set(key, p) + void p.then((r) => { + if (r === undefined && cache.get(key) === p) cache.delete(key) + }) + } + return p + } + return { + fetchItem: (ref) => + memoize(this.#previewItemCache, `${ref.kind}:${ref.path}`, () => deps.fetchItem(ref)), + resolveResourceType: (path) => + memoize(this.#previewTypeCache, path, () => deps.resolveResourceType(path)) + } + } + + async #postHub(path: string, body: unknown): Promise | undefined> { + const res = await fetch(`/api/w/${this.workspace}${path}${this.#folderQs()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }) + const text = await res.text() + if (!res.ok) throw new Error(text) + try { + return JSON.parse(text) + } catch { + return undefined + } + } + + async regenerateMigrations() { + const tok = ++this.#migrationsTok + this.migrationsGenerating = true + try { + // Same handler-augmented seed as deployAll: a data table used only by a + // bundled trigger handler must still get its migration. + const seed: ItemRef[] = [ + ...this.selectedItems + .filter((i) => i.kind !== 'resource') + .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })), + ...this.#triggerHandlerSeed(this.relevantTriggers, this.hubSlug || 'project') + ] + // Detection is independent of the final slug (data table refs aren't + // relocated), so any placeholder slug works for this throwaway bundle. + const bundle = await buildProjectBundle( + seed, + this.hubSlug || 'project', + this.#buildBundleDeps(), + [] + ) + const usage = await detectDatatableTables(bundle.items) + const drafts = await generateDatatableMigrations(this.workspace, usage) + if (this.#disposed || tok !== this.#migrationsTok) return + this.migrationDrafts = drafts + this.migrationsGeneration++ + } catch (e: any) { + if (!this.#disposed && tok === this.#migrationsTok) { + this.migrationDrafts = [] + this.migrationsGeneration++ + // Toast so a genuine failure isn't mistaken for "no data table usage". + sendUserToast(`Could not generate data table migrations: ${e?.message ?? e}`, true) + } + } finally { + if (!this.#disposed && tok === this.#migrationsTok) this.migrationsGenerating = false + } + } + + /** Prefill bundle metadata and start migration detection (bundle drawer opening). */ + prepareBundle() { + this.hubName = this.hubName || this.folder + void this.regenerateMigrations() + } + + /** + * Create the Hub draft then push the full bundle. `deploying` is set + * synchronously before the first request so a double-click cannot start a + * second publish, and the whole run is refused while triggers are still + * loading — an incomplete `relevantTriggers` snapshot would permanently + * omit triggers (and their handlers and migrations) from the draft. + * `onDraftCreated` fires once the draft exists (the bundle drawer closes + * there while items continue publishing). + */ + async publishBundle(onDraftCreated?: () => void): Promise { + if (this.deploying || this.triggersLoading || this.triggerDiscoveryFailed) return + this.deploying = true + try { + if (!(await this.#createDraft())) return + onDraftCreated?.() + await this.#deployAll() + } finally { + this.deploying = false + } + } + + /** + * Create the Hub draft project. Returns true when the draft exists and + * publishing can proceed. + */ + async #createDraft(): Promise { + this.hubName = this.hubName.trim() + this.hubSummary = this.hubSummary.trim() + this.hubReadme = this.hubReadme.trim() + try { + const res = await fetch(`/api/w/${this.workspace}/hub/publish_draft${this.#folderQs()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + slug: this.hubSlug, + name: this.hubName, + summary: this.hubSummary || this.hubName, + readme: this.hubReadme || undefined + }) + }) + const text = await res.text() + if (!res.ok) { + sendUserToast(`Hub draft creation failed: ${text}`, true) + return false + } + // Abort if Hub didn't echo a slug — guessing here lands items under + // a folder the Hub never locked. + let returnedSlug: string | undefined + try { + const parsed = JSON.parse(text) + if (typeof parsed?.slug === 'string') returnedSlug = parsed.slug + } catch {} + if (!returnedSlug) { + sendUserToast(`Hub did not return a slug. Aborting publish to avoid path drift.`, true) + return false + } + // Session replaced mid-request (workspace/folder switch): publishing now + // would push another scope's items into this draft. Abort. + if (this.#disposed) { + sendUserToast(`Workspace changed during publish — aborted to avoid mixing items.`, true) + return false + } + this.effectiveSlug = returnedSlug + return true + } catch (e: any) { + sendUserToast(`Hub draft creation failed: ${e?.message ?? e}`, true) + return false + } + } + + async #pushBundledItem(slug: string, it: BundledItem): Promise { + const key = `${it.kind}:${it.path}` + if (it.kind === 'script') { + const resp = await this.#postHub('/hub/scripts', { + summary: it.summary || it.newPath, + app: slug, + description: it.description ?? '', + kind: it.scriptKind ?? 'script', + content: it.content, + language: it.language, + schema: it.schema ?? undefined, + lockfile: it.lock ?? undefined, + path: it.newPath, + source_path: it.path, + project_slug: slug + }) + if (typeof resp?.id === 'number') this.hubItemIds = { ...this.hubItemIds, [key]: resp.id } + } else if (it.kind === 'flow') { + const resp = await this.#postHub('/hub/flows', { + flow: { + summary: it.summary || it.newPath, + description: it.description ?? undefined, + value: it.value, + schema: it.schema ?? undefined + }, + apps: [], + path: it.newPath, + source_path: it.path, + project_slug: slug + }) + if (typeof resp?.id === 'number') this.hubItemIds = { ...this.hubItemIds, [key]: resp.id } + } else if (it.kind === 'app') { + await this.#postHub('/hub/apps', { + app: it.value, + apps: [], + summary: it.summary || it.newPath, + description: undefined, + path: it.newPath, + source_path: it.path, + project_slug: slug + }) + } else if (it.kind === 'raw_app') { + const resp = await this.#postHub('/hub/raw_apps', { + raw: it.content ?? '', + apps: [], + summary: it.summary || it.newPath, + path: it.newPath, + source_path: it.path, + description: undefined, + project_slug: slug + }) + if (typeof resp?.id === 'number') this.hubItemIds = { ...this.hubItemIds, [key]: resp.id } + } + } + + // Handler runnables (trigger error handlers, schedule on_* handlers) ship + // with the bundle like the primary runnables do; hub refs stay external. + #triggerHandlerSeed(triggers: WorkspaceTrigger[], slug: string): ItemRef[] { + return triggers.flatMap(triggerHandlerRefs).filter((r) => classifyPath(r.path, slug) !== 'hub') + } + + // Every resource a trigger's exported config references: the kind-specific + // broker/auth field plus any `$res:` token nested in it (schedule args, + // handler extra args, …) — all must enter the bundle path map. + #triggerResourcePaths(triggers: WorkspaceTrigger[]): string[] { + const out = new Set() + for (const t of triggers) { + const rp = triggerResourcePath(t) + if (rp) out.add(rp) + for (const p of extractTriggerConfigResourceRefs(portableTriggerConfig(t.kind, t.config))) { + out.add(p) + } + } + return [...out] + } + + // Every whole-string `$var:`/`$jsonvar:` value a trigger's config resolves (SQS + // queue_url, schedule args, …) — relocated through the bundle map like item vars. + #triggerVarPaths(triggers: WorkspaceTrigger[]): string[] { + const out = new Set() + for (const t of triggers) { + for (const p of extractVarRefsFromValue(portableTriggerConfig(t.kind, t.config))) out.add(p) + } + return [...out] + } + + async #pushTriggers( + slug: string, + resourcePathMap: Map, + relevant: WorkspaceTrigger[] + ): Promise { + const pathMap = buildPathMap( + relevant.map((t) => t.path), + slug + ) + const triggers: Array> = [] + const skipped: string[] = [] + for (const t of relevant) { + const itemKind: ItemKind = t.is_flow ? 'flow' : 'script' + const runnableKey = `${itemKind}:${t.script_path}` + const hubId = this.hubItemIds[runnableKey] + if (!hubId) { + skipped.push(t.path) + continue + } + // Full-config remap: resource paths, error-handler paths, schedule on_* + // handler refs and whole-string `$var:` values all relocate through the map. + const config = rewriteVarRefsInValue( + rewriteTriggerConfig(portableTriggerConfig(t.kind, t.config), resourcePathMap), + resourcePathMap + ) + triggers.push({ + path: pathMap.get(t.path) ?? t.path, + kind: t.kind, + summary: t.summary ?? null, + description: (t.config as any)?.description ?? null, + config, + script_ask_id: t.is_flow ? null : hubId, + flow_id: t.is_flow ? hubId : null + }) + } + if (skipped.length > 0) { + sendUserToast( + `Skipped ${skipped.length} trigger(s) whose runnable did not publish: ${skipped.join(', ')}`, + true + ) + } + // Full-set sync: always push (an empty list clears the Hub's triggers on a + // re-deploy), so removing every trigger doesn't leave stale ones on the Hub. + await this.#postHub('/hub/triggers', { triggers, project_slug: slug }) + } + + // Builtin types (git_repository, ...) aren't in resource_type — push with empty schema. + async #pushResourceTypes(slug: string, types: string[]): Promise { + const results = await Promise.all( + types.map(async (name) => { + let schema: unknown = undefined + let description: string | undefined = undefined + try { + const rt = await ResourceService.getResourceType({ + workspace: this.workspace, + path: name + }) + schema = rt.schema ?? undefined + description = rt.description ?? undefined + } catch (e: any) {} + try { + await this.#postHub('/hub/resource_types', { + name, + schema, + description, + project_slug: slug + }) + return 0 + } catch (e: any) { + sendUserToast(`Resource type ${name} push failed: ${e?.message ?? e}`, true) + return 1 + } + }) + ) + return results.reduce((a: number, b) => a + b, 0) + } + + async #deployAll() { + const slug = this.hubSlug + // Snapshot the selection up-front: `selectedItems`/`relevantTriggers` are + // derived from live workspace data and `migrationDrafts` is edited in the + // drawer — the deploy must publish exactly what the user confirmed. + const itemsSnapshot = this.selectedItems.slice() + const triggersSnapshot = this.relevantTriggers.slice() + const migrationsSnapshot = this.migrationDrafts.slice() + this.hubItemIds = {} + this.deploymentStatus = {} + let failures = 0 + try { + const seed: ItemRef[] = [ + ...itemsSnapshot + .filter((i) => i.kind !== 'resource') + .map((i) => ({ kind: i.kind as ItemRef['kind'], path: i.path })), + ...this.#triggerHandlerSeed(triggersSnapshot, slug) + ] + const triggerResources = this.#triggerResourcePaths(triggersSnapshot) + const triggerVars = this.#triggerVarPaths(triggersSnapshot) + const bundle = await buildProjectBundle( + seed, + slug, + this.#buildBundleDeps(), + triggerResources, + triggerVars + ) + // Full path map (incl. unresolved) so a trigger's resource path is always + // relocated — never leaks the publisher's original private path to the Hub. + const resourcePathMap = bundle.pathMap + + // A dangling reference (a selected root or transitive runnable that failed + // to fetch, or a resource whose type can't be resolved) means the bundle + // doesn't close: the root would silently vanish, or a published item would + // still point at the publisher's private source-workspace path. Refuse to + // publish until every reference resolves rather than ship a broken project. + if (bundle.unresolved.length > 0) { + sendUserToast( + `Cannot publish: ${bundle.unresolved.length} unresolved reference(s): ${bundle.unresolved.join(', ')}. Deselect or fix them, then retry.`, + true + ) + return + } + + // Bundle building is slow — bail before the first Hub write if the session + // was replaced (workspace/folder switch) in the meantime. + if (this.#disposed) return + + // Types come from $res: stubs AND schema inputs (resource-). + const inputTypes = bundle.items + .flatMap((i) => typesFromSchema(i.schema)) + .filter((t) => !HIDDEN_RESOURCE_TYPES.has(t)) + const types = [ + ...new Set([...bundle.resourceStubs.map((s) => s.resource_type), ...inputTypes]) + ] + const depFailures = await this.#pushResourceTypes(slug, types) + + // Input-type deps with no path get a conventional f// stub. + const stubsByPath = new Map() + for (const s of bundle.resourceStubs) + stubsByPath.set(s.newPath, { path: s.newPath, resource_type: s.resource_type }) + for (const t of inputTypes) { + const path = `f/${slug}/${t}` + if (!stubsByPath.has(path)) stubsByPath.set(path, { path, resource_type: t }) + } + const stubs = [...stubsByPath.values()] + if (stubs.length > 0) { + try { + await this.#postHub('/hub/resources', { resources: stubs, project_slug: slug }) + } catch (e: any) { + sendUserToast(`Resource sync failed: ${e?.message ?? e}`, true) + failures++ + } + } + failures += depFailures + if (failures > 0) { + sendUserToast( + `Resource dependency sync failed — items not published to avoid broken references.`, + true + ) + return + } + + for (const it of bundle.items) { + // Stop writing item status / Hub IDs once the session is replaced — + // continuing would publish into a project the user has moved away from. + if (this.#disposed) return + const key = `${it.kind}:${it.path}` + this.deploymentStatus = { ...this.deploymentStatus, [key]: { status: 'loading' } } + try { + await this.#pushBundledItem(slug, it) + this.deploymentStatus = { ...this.deploymentStatus, [key]: { status: 'deployed' } } + } catch (e: any) { + failures++ + this.deploymentStatus = { + ...this.deploymentStatus, + [key]: { status: 'failed', error: e?.message ?? String(e) } + } + } + } + // A re-bundle clears the Hub-side embed (idempotent replace), so re-push it + // for any raw app that is already public — keeps the live iframe in sync + // without forcing an unpublish/share round-trip. Updates by hub id, safe in parallel. + const embedResults = await Promise.all( + bundle.items + .filter((it) => it.kind === 'raw_app') + .map(async (it) => { + const hubId = this.hubItemIds[`${it.kind}:${it.path}`] + const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path) + if (!hubId || !src?.published) return 0 + // The re-bundle cleared the embed; a public raw app with no resolved URL + // can't have its iframe restored, so it's an incomplete publish too — + // count it (like a push failure) so the draft can't become submit-ready. + if (!src.publicUrl) { + sendUserToast(`Cannot restore the iframe for ${it.path}: missing public URL`, true) + return 1 + } + try { + await this.#pushRawAppEmbed(hubId, src.publicUrl) + return 0 + } catch (e: any) { + sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true) + return 1 + } + }) + ) + failures += embedResults.reduce((a: number, b) => a + b, 0) + if (this.#disposed) return + try { + await this.#pushTriggers(slug, resourcePathMap, triggersSnapshot) + } catch (e: any) { + sendUserToast(`Trigger sync failed: ${e?.message ?? e}`, true) + failures++ + } + + // Full-set sync: always push (an empty list clears the Hub's migrations on + // a re-deploy). The Hub drops empty-SQL entries, so disabled placeholders + // don't persist. + try { + await this.#postHub('/hub/migrations', { + migrations: migrationsSnapshot.map((m) => ({ + datatable_name: m.datatable_name, + sql: m.sql, + sql_down: m.sql_down, + enabled: m.enabled + })), + project_slug: slug + }) + } catch (e: any) { + sendUserToast(`Data table migration sync failed: ${e?.message ?? e}`, true) + failures++ + } + + await sleep(150) + if (this.#disposed) return + // An incomplete push must never become submittable: a failed transitive item + // can leave a pushed runnable pointing at content that never landed. Stay in + // predeploy (deploymentStatus keeps the failed items visible) so re-publishing + // retries every write — createDraft and the item pushes are idempotent. + if (failures > 0) { + sendUserToast( + `Publish incomplete: ${failures} write(s) failed. Nothing was submitted — fix them and re-publish.`, + true + ) + return + } + this.deploymentStatus = {} + this.recordings = {} + // Deterministic baseline so a transient Hub read failure can't leave the + // UI stuck in `predeploy`; rehydrate then upgrades to authoritative state. + this.draftItems = itemsSnapshot.map((i) => ({ ...i, rec: 'none' })) + this.phase = 'draft' + await this.rehydrateFromHub() + sendUserToast(`Draft created on the Hub. Add recordings before submitting for review.`) + } finally { + this.deploying = false + } + } + + submitForReview = async () => { + const slug = this.hubSlug + if (!slug) return + this.submitting = true + try { + const res = await fetch( + `/api/w/${this.workspace}/hub/projects/${encodeURIComponent(slug)}/submit${this.#folderQs()}`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + } + ) + if (!res.ok) { + sendUserToast(`Submit for review failed: ${await res.text()}`, true) + return + } + this.phase = 'under_review' + sendUserToast('Submitted for review by the Windmill team.') + } finally { + this.submitting = false + } + } + + syncWithHub = async () => { + this.syncing = true + try { + if (this.phase === 'draft') { + await this.#loadWorkspace() + const prev = new Map(this.draftItems.map((i) => [i.key, { rec: i.rec }])) + this.draftItems = this.workspaceItems + .filter((i) => prev.has(i.key)) + .map((i) => ({ ...i, rec: prev.get(i.key)?.rec ?? 'none' })) + } else { + // under_review / live: re-fetch the Hub project to pick up an + // admin status change (under_review -> live). + const before = this.phase + await this.rehydrateFromHub() + sendUserToast( + this.phase === before + ? 'Still waiting for review.' + : this.phase === 'live' + ? 'Approved — your project is now live.' + : `Status updated: ${this.phase}.` + ) + } + } catch (e: any) { + sendUserToast(`Sync failed: ${e?.message ?? e}`, true) + } finally { + this.syncing = false + } + } + + startNewDraft = () => { + this.draftItems = [] + this.recordings = {} + this.phase = 'predeploy' + } + + /** Reset record-drawer state and load the target's schema. */ + async openRecord(it: DeployItem) { + const tok = ++this.#recordRunTok + this.recordTarget = it + this.recordArgs = {} + this.recordValid = true + this.recordSchema = emptySchema() + this.recordSchemaLoading = true + this.runState = 'idle' + this.runJobId = undefined + this.runResult = undefined + this.runError = undefined + try { + if (it.kind === 'script') { + const s = await ScriptService.getScriptByPath({ + workspace: this.workspace, + path: it.path + }) + if (tok !== this.#recordRunTok) return + this.recordSchema = (s.schema as Record) ?? emptySchema() + } else if (it.kind === 'flow') { + const f = await FlowService.getFlowByPath({ workspace: this.workspace, path: it.path }) + if (tok !== this.#recordRunTok) return + this.recordSchema = (f.schema as Record) ?? emptySchema() + } + } catch (e: any) { + if (tok !== this.#recordRunTok) return + sendUserToast(`Failed to load schema: ${e?.message ?? e}`, true) + } finally { + if (tok === this.#recordRunTok) this.recordSchemaLoading = false + } + } + + /** Invalidate any in-flight record run/poll (record drawer closed). */ + cancelRecordRun = () => { + this.#recordRunTok++ + } + + runJob = async () => { + const it = this.recordTarget + if (!it) return + const tok = ++this.#recordRunTok + this.runState = 'running' + this.runJobId = undefined + this.runResult = undefined + this.runError = undefined + try { + let jobId: string + if (it.kind === 'script') { + jobId = await JobService.runScriptByPath({ + workspace: this.workspace, + path: it.path, + requestBody: this.recordArgs + }) + } else if (it.kind === 'flow') { + jobId = await JobService.runFlowByPath({ + workspace: this.workspace, + path: it.path, + requestBody: this.recordArgs + }) + } else { + if (tok === this.#recordRunTok) this.runState = 'idle' + return + } + if (tok !== this.#recordRunTok) return + this.runJobId = jobId + await this.#pollJobUntilComplete(jobId, tok) + } catch (e: any) { + if (tok !== this.#recordRunTok) return + this.runState = 'failed' + this.runError = `Failed to start: ${e?.message ?? e}` + } + } + + async #pollJobUntilComplete(jobId: string, tok: number) { + // First check immediately (fast scripts complete in ms), then back off to 2s. + const deadline = Date.now() + 5 * 60_000 + let interval = 250 + while (Date.now() < deadline) { + if (tok !== this.#recordRunTok) return + try { + const r = await JobService.getCompletedJobResultMaybe({ + workspace: this.workspace, + id: jobId + }) + if (tok !== this.#recordRunTok) return + if (r.completed) { + this.runResult = r.result + if (r.success) { + this.runState = 'success' + } else { + this.runState = 'failed' + this.runError = typeof r.result === 'string' ? r.result : JSON.stringify(r.result) + } + return + } + } catch (e: any) { + if (tok !== this.#recordRunTok) return + this.runState = 'failed' + this.runError = `Polling failed: ${e?.message ?? e}` + return + } + await sleep(interval) + interval = Math.min(interval * 2, 2000) + } + if (tok !== this.#recordRunTok) return + this.runState = 'failed' + this.runError = 'Timed out after 5 minutes' + } + + async #buildScriptRecording(it: DeployItem, jobId: string) { + const workspace = this.workspace + const s = await ScriptService.getScriptByPath({ workspace, path: it.path }) + const job = await JobService.getCompletedJob({ workspace, id: jobId }) + const initial_job = { ...(job as any), type: 'CompletedJob' } + const events = [{ t: 0, data: { completed: true, job: initial_job } }] + const duration = (initial_job.duration_ms as number) ?? 0 + return { + version: 1, + type: 'script' as const, + recorded_at: new Date().toISOString(), + script_path: it.path, + total_duration_ms: duration, + code: s.content, + language: s.language, + args: (job.args ?? {}) as Record, + schema: s.schema, + job: { initial_job, events } + } + } + + async #buildFlowRecording(it: DeployItem, jobId: string) { + const workspace = this.workspace + const f = await FlowService.getFlowByPath({ workspace, path: it.path }) + const root = (await JobService.getCompletedJob({ workspace, id: jobId })) as any + const jobs: Record = {} + const collect = async (j: any) => { + const stamped = { ...j, type: 'CompletedJob' } + jobs[j.id] = { + initial_job: stamped, + events: [{ t: 0, data: { completed: true, job: stamped } }] + } + const modules = (j.flow_status?.modules ?? []).filter( + (m: any) => m.job && typeof m.job === 'string' + ) + // Sub-jobs at the same level are independent reads. + await Promise.all( + modules.map(async (m: any) => { + try { + const sub = (await JobService.getCompletedJob({ workspace, id: m.job })) as any + await collect(sub) + } catch { + /* sub-job missing — skip */ + } + }) + ) + } + await collect(root) + return { + version: 1, + recorded_at: new Date().toISOString(), + flow_path: it.path, + total_duration_ms: (root.duration_ms as number) ?? 0, + flow: { + path: it.path, + value: f.value, + schema: f.schema ?? { type: 'object', properties: {}, required: [] }, + summary: f.summary ?? '', + archived: false, + edited_at: '', + edited_by: '', + extra_perms: {} + }, + jobs + } + } + + /** Save the current successful run as the Hub recording. Returns true on success. */ + async saveRecording(): Promise { + const it = this.recordTarget + if (!it || !this.runJobId || this.runState !== 'success') return false + const hubId = this.hubItemIds[it.key] + if (!hubId) { + sendUserToast(`Push the bundle to the Hub first before saving recordings`, true) + return false + } + if (it.kind !== 'script' && it.kind !== 'flow') { + sendUserToast(`Recordings only supported for script/flow`, true) + return false + } + try { + const recording = + it.kind === 'script' + ? await this.#buildScriptRecording(it, this.runJobId) + : await this.#buildFlowRecording(it, this.runJobId) + const path = it.kind === 'script' ? 'scripts' : 'flows' + await this.#postHub(`/hub/${path}/${hubId}/recording`, { + recording, + project_slug: this.hubSlug + }) + this.recordings = { ...this.recordings, [it.key]: this.runJobId } + this.#patchItem(it.key, { rec: 'recorded' }) + sendUserToast(`Recording saved — job ${this.runJobId}`) + return true + } catch (e: any) { + sendUserToast(`Failed to save recording: ${e?.message ?? e}`, true) + return false + } + } + + // Set the Hub raw app's live-iframe URL (or clear it with null). The Hub renders + // from external_embed_url; project_slug scopes ownership. + async #pushRawAppEmbed(hubId: number, url: string | null) { + await this.#postHub(`/hub/raw_apps/${hubId}/embed`, { + external_embed_url: url, + project_slug: this.hubSlug + }) + } + + // Flip an app/raw app between public (anonymous) and private (publisher) and keep + // the Hub raw-app iframe in sync. Returns the resolved public URL when shared. + async #setAppShared(it: DeployItem, shared: boolean): Promise { + const workspace = this.workspace + const hubId = it.kind === 'raw_app' ? this.hubItemIds[it.key] : undefined + // Sharing a raw app as an iframe needs its Hub item to wire the embed. Fail + // before flipping the app public so it can't be left anonymous with no embed. + if (shared && it.kind === 'raw_app' && !hubId) { + throw new Error('Push the bundle to the Hub first to share the live iframe') + } + const app = await AppService.getAppByPath({ workspace, path: it.path }) + const prevMode = (app.policy?.execution_mode ?? 'publisher') as 'anonymous' | 'publisher' + const nextMode = (shared ? 'anonymous' : 'publisher') as 'anonymous' | 'publisher' + const setMode = (mode: 'anonymous' | 'publisher', message: string) => + AppService.updateApp({ + workspace, + path: it.path, + requestBody: { + policy: { ...(app.policy ?? {}), execution_mode: mode }, + deployment_message: message + } + }) + // Undo the policy flip so the app's public state stays consistent when a later + // step of the share fails. Best-effort: a revert failure must not mask the cause. + const rollback = () => setMode(prevMode, 'Revert iframe share').catch(() => {}) + await setMode(nextMode, shared ? 'Share as iframe' : 'Unshare iframe') + const url = shared ? ((await this.#resolvePublicUrl(it.path)) ?? null) : null + // A share with no resolvable public URL is incomplete (no embeddable link, no + // Unpublish control); don't leave the app anonymous while reporting success. + if (shared && url === null) { + await rollback() + throw new Error(`Could not resolve the public URL for ${it.path}`) + } + if (hubId && it.kind === 'raw_app' && (!shared || url)) { + try { + await this.#pushRawAppEmbed(hubId, shared ? url : null) + } catch (e) { + await rollback() + throw e + } + } + return url + } + + /** Make the publish target public. Returns true on success. */ + async confirmPublish(): Promise { + const it = this.publishTarget + if (!it || !canShareAsIframe(it)) return false + this.publishing = true + try { + const url = await this.#setAppShared(it, true) + this.#patchItem(it.key, { published: true, publicUrl: url ?? undefined }) + sendUserToast(`${it.path} is now public`) + return true + } catch (e: any) { + sendUserToast(`Failed to publish: ${e?.message ?? e}`, true) + return false + } finally { + this.publishing = false + } + } + + unpublishApp = async (it: DeployItem) => { + if (!canShareAsIframe(it)) return + try { + await this.#setAppShared(it, false) + this.#patchItem(it.key, { published: false, publicUrl: undefined }) + sendUserToast('App unpublished') + } catch (e: any) { + sendUserToast(`Failed to unpublish: ${e?.message ?? e}`, true) + } + } +} + +/** + * Owns the session lifecycle: a new `DeployToHubSession` is created whenever the + * (workspace, folder) identity actually changes — a spurious same-value store + * emit reuses the live session — and the previous one is disposed, which is the + * single mechanism invalidating its in-flight work. Also hosts the reactive + * plumbing the session itself can't (license-hydration reload, schedule + * previews, debounced bundle preview). + */ +export function useDeployToHubSession(args: { + workspace: () => string | undefined + folder: () => string + hasEeLicense: () => boolean +}) { + let session = $state() + + $effect(() => { + const workspace = args.workspace() + const folder = args.folder() + if (!workspace) return + untrack(() => { + if (session && session.workspace === workspace && session.folder === folder) return + session?.dispose() + const next = new DeployToHubSession(workspace, folder, { + hasEeLicense: args.hasEeLicense + }) + session = next + next.load() + }) + }) + + // The EE license hydrates async; if it lands after a license-less trigger load, + // EE kinds stay empty. Re-fetch on false→true (the session reads the license + // getter at call time). + let prevHadLicense: boolean | undefined = undefined + $effect(() => { + const hasLicense = args.hasEeLicense() + untrack(() => { + if (hasLicense && prevHadLicense === false) session?.reloadTriggers() + prevHadLicense = hasLicense + }) + }) + + // Leaving/entering predeploy invalidates manual selection tweaks. + $effect(() => { + const s = session + if (!s) return + s.phase + untrack(() => { + s.manualDeselected = new Set() + }) + }) + + // Schedule previews for relevant schedule triggers (deduped in the session). + $effect(() => { + session?.ensureSchedulePreviews() + }) + + // Debounced predeploy bundle preview; the session reads its reactive inputs + // synchronously and returns the cancel function used as effect cleanup. + $effect(() => { + const s = session + if (!s) return + return s.queueBundlePreview() + }) + + return { + get session() { + return session + } + } +} diff --git a/frontend/src/lib/components/workspaceSettings/deployToHubSession.test.ts b/frontend/src/lib/components/workspaceSettings/deployToHubSession.test.ts new file mode 100644 index 0000000000..7b61251fcc --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/deployToHubSession.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { canShareAsIframe, mergeShareState, type DeployItem } from './deployToHubSession.svelte' + +function item(over: Partial & Pick): DeployItem { + return { rec: 'none', ...over } +} + +describe('canShareAsIframe', () => { + it('allows low-code apps and app-table raw apps', () => { + expect(canShareAsIframe(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(true) + expect( + canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })) + ).toBe(true) + }) + it('hides the action for legacy raw apps (raw_app table only)', () => { + // Legacy entries from RawAppService carry no appTable flag; AppService can't load them. + expect(canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false) + }) + it('never offers the action for flows or scripts', () => { + expect(canShareAsIframe(item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' }))).toBe(false) + }) +}) + +describe('mergeShareState', () => { + it('carries live public-share state from workspace items onto matching drafts', () => { + const drafts = [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })] + const workspace = [ + item({ + key: 'app:f/a', + path: 'f/a', + kind: 'app', + published: true, + publicUrl: 'https://x/app' + }) + ] + const merged = mergeShareState(drafts, workspace) + expect(merged[0].published).toBe(true) + expect(merged[0].publicUrl).toBe('https://x/app') + }) + it('restores the app-table origin so app-table raw apps stay shareable', () => { + const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })] + const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })] + expect(canShareAsIframe(mergeShareState(drafts, workspace)[0])).toBe(true) + }) + it('returns the same reference when nothing changes', () => { + const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })] + expect(mergeShareState(drafts, drafts)).toBe(drafts) + }) + it('leaves drafts without a workspace match untouched', () => { + const drafts = [item({ key: 'app:f/gone', path: 'f/gone', kind: 'app' })] + const merged = mergeShareState(drafts, [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })]) + expect(merged).toBe(drafts) + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/projectBundle.test.ts b/frontend/src/lib/components/workspaceSettings/projectBundle.test.ts new file mode 100644 index 0000000000..669c0a43de --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectBundle.test.ts @@ -0,0 +1,869 @@ +import { describe, it, expect } from 'vitest' +import { + classifyPath, + extractScriptRefs, + extractFlowRefs, + extractAppRefs, + buildPathMap, + rewriteContent, + rewriteTriggerConfig, + rewriteFlowValue, + rewriteAppValue, + extractRawAppRefs, + rewriteRawAppContent, + buildProjectBundle, + retargetProjectExport, + collectExportVarPaths, + extractTriggerConfigResourceRefs, + extractVarRefsFromValue, + type ProjectExport, + type FetchedItem, + type ItemRef +} from './projectBundle' + +describe('classifyPath', () => { + it('internal for paths under the project folder', () => { + expect(classifyPath('f/proj/db', 'proj')).toBe('internal') + expect(classifyPath('f/proj', 'proj')).toBe('internal') + }) + it('hub for hub paths', () => { + expect(classifyPath('hub/16043/discord/send', 'proj')).toBe('hub') + }) + it('external for user and other folders', () => { + expect(classifyPath('u/admin/db', 'proj')).toBe('external') + expect(classifyPath('f/other/db', 'proj')).toBe('external') + }) + it('does not treat a prefix-only match as internal', () => { + expect(classifyPath('f/project2/db', 'proj')).toBe('external') + }) +}) + +describe('extractScriptRefs', () => { + it('finds $res: and res:// resource refs, deduped', () => { + const c = `const a = "$res:u/admin/db"; const b = "res://f/x/api"; const c2 = "$res:u/admin/db"` + expect(extractScriptRefs(c)).toEqual([ + { kind: 'resource', path: 'u/admin/db' }, + { kind: 'resource', path: 'f/x/api' } + ]) + }) + it('returns nothing when no refs', () => { + expect(extractScriptRefs('export async function main() {}')).toEqual([]) + }) +}) + +describe('extractFlowRefs', () => { + it('finds inline-code, static-input, and script-path refs', () => { + const value = { + modules: [ + { + id: 'a', + value: { + type: 'rawscript', + content: 'const db = "$res:u/admin/pg"', + input_transforms: { + other: { type: 'static', value: '$res:f/shared/api' }, + expr1: { type: 'javascript', expr: 'flow_input.x' } + } + } + }, + { + id: 'b', + value: { + type: 'branchone', + branches: [ + { + modules: [ + { id: 'c', value: { type: 'script', path: 'u/admin/my_script' } }, + { id: 'd', value: { type: 'script', path: 'hub/123/x/y' } } + ] + } + ], + default: [{ id: 'e', value: { type: 'rawscript', content: 'no refs' } }] + } + } + ] + } + const refs = extractFlowRefs(value) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' }) + expect(refs).toContainEqual({ kind: 'resource', path: 'f/shared/api' }) + expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/my_script' }) + expect(refs).toContainEqual({ kind: 'script', path: 'hub/123/x/y' }) + // a javascript expr (flow_input) is not a hardcoded ref + expect(refs.filter((r) => r.path === 'flow_input.x')).toEqual([]) + }) + it('finds sub-flow refs from type: flow steps', () => { + const value = { + modules: [ + { id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } }, + { id: 'b', value: { type: 'flow', path: 'hub/9/x/y' } } + ] + } + const refs = extractFlowRefs(value) + expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sub_flow' }) + expect(refs).toContainEqual({ kind: 'flow', path: 'hub/9/x/y' }) + }) +}) + +describe('buildPathMap', () => { + it('reparents into the project folder keeping the leaf name', () => { + const m = buildPathMap(['u/admin/db', 'f/other/api'], 'proj') + expect(m.get('u/admin/db')).toBe('f/proj/db') + expect(m.get('f/other/api')).toBe('f/proj/api') + }) + it('suffixes collisions deterministically', () => { + const m = buildPathMap(['u/alice/db', 'f/shared/db', 'u/bob/db'], 'proj') + // sorted: f/shared/db, u/alice/db, u/bob/db + expect(m.get('f/shared/db')).toBe('f/proj/db') + expect(m.get('u/alice/db')).toBe('f/proj/db_2') + expect(m.get('u/bob/db')).toBe('f/proj/db_3') + }) + it('maps internal paths to themselves, preserving subfolder depth', () => { + const m = buildPathMap(['f/proj/api', 'f/proj/sub/deep/script'], 'proj') + expect(m.get('f/proj/api')).toBe('f/proj/api') + expect(m.get('f/proj/sub/deep/script')).toBe('f/proj/sub/deep/script') + }) + it('does not flatten two internal items sharing a leaf name', () => { + const m = buildPathMap(['f/proj/a/x', 'f/proj/b/x'], 'proj') + expect(m.get('f/proj/a/x')).toBe('f/proj/a/x') + expect(m.get('f/proj/b/x')).toBe('f/proj/b/x') + }) + it('relocates an external onto a suffix when its leaf collides with an internal path', () => { + const m = buildPathMap(['f/proj/db', 'u/admin/db'], 'proj') + expect(m.get('f/proj/db')).toBe('f/proj/db') + expect(m.get('u/admin/db')).toBe('f/proj/db_2') + }) +}) + +describe('rewriteContent', () => { + it('rewrites mapped refs and leaves unmapped ones', () => { + const map = new Map([['u/admin/db', 'f/proj/db']]) + expect(rewriteContent('x = "$res:u/admin/db"', map)).toBe('x = "$res:f/proj/db"') + expect(rewriteContent('x = "res://u/admin/db"', map)).toBe('x = "$res:f/proj/db"') + expect(rewriteContent('x = "$res:hub/1/a/b"', map)).toBe('x = "$res:hub/1/a/b"') + }) + it('does not partial-match a longer path', () => { + const map = new Map([['u/admin/db', 'f/proj/db']]) + // u/admin/db2 must not be rewritten by the u/admin/db entry + expect(rewriteContent('x = "$res:u/admin/db2"', map)).toBe('x = "$res:u/admin/db2"') + }) +}) + +describe('rewriteTriggerConfig', () => { + const map = new Map([ + ['f/proj/kafka', 'f/target/kafka'], + ['f/proj/script', 'f/target/script'] + ]) + it('remaps plain resource path fields', () => { + expect( + rewriteTriggerConfig({ kafka_resource_path: 'f/proj/kafka', group_id: 'g1' }, map) + ).toEqual({ kafka_resource_path: 'f/target/kafka', group_id: 'g1' }) + }) + it('remaps nested objects, arrays, and $res: tokens', () => { + expect( + rewriteTriggerConfig( + { + nested: { path: 'f/proj/script' }, + list: ['f/proj/kafka', 'unrelated'], + code: 'x = "$res:f/proj/kafka"' + }, + map + ) + ).toEqual({ + nested: { path: 'f/target/script' }, + list: ['f/target/kafka', 'unrelated'], + code: 'x = "$res:f/target/kafka"' + }) + }) + it('leaves non-matching strings and non-string values untouched', () => { + const config = { url: 'wss://example.com', port: 9092, enabled: true, extra: null } + expect(rewriteTriggerConfig(config, map)).toEqual(config) + }) +}) + +describe('rewriteFlowValue', () => { + it('rewrites inline code, static inputs, and script paths; clones input', () => { + const map = new Map([ + ['u/admin/pg', 'f/proj/pg'], + ['f/shared/api', 'f/proj/api'], + ['u/admin/my_script', 'f/proj/my_script'] + ]) + const value = { + modules: [ + { + id: 'a', + value: { + type: 'rawscript', + content: 'const db = "$res:u/admin/pg"', + input_transforms: { other: { type: 'static', value: '$res:f/shared/api' } } + } + }, + { id: 'b', value: { type: 'script', path: 'u/admin/my_script' } }, + { id: 'c', value: { type: 'script', path: 'hub/1/keep/me' } } + ] + } + const out = rewriteFlowValue(value, map) + expect(out.modules[0].value.content).toBe('const db = "$res:f/proj/pg"') + expect(out.modules[0].value.input_transforms.other.value).toBe('$res:f/proj/api') + expect(out.modules[1].value.path).toBe('f/proj/my_script') + expect(out.modules[2].value.path).toBe('hub/1/keep/me') + // original untouched (deep clone) + expect(value.modules[0].value.content).toBe('const db = "$res:u/admin/pg"') + }) +}) + +// A trimmed app value: a runnable-by-path component, a hub runnable, a $res in an +// inline script, and incidental `f/...` text that must NOT be rewritten. +const appValue = () => ({ + grid: [ + { + data: { + componentInput: { + runnable: { type: 'runnableByPath', runType: 'script', path: 'u/admin/charts' } + } + } + }, + { + data: { + componentInput: { + runnable: { type: 'runnableByPath', runType: 'flow', path: 'f/shared/sync' } + } + } + }, + { + data: { + componentInput: { + runnable: { type: 'runnableByPath', runType: 'hubscript', path: 'hub/1/keep' } + } + } + } + ], + hiddenInlineScripts: [ + { name: 'h', inlineScript: { content: 'x = "$res:u/admin/pg"', language: 'deno' } } + ], + someLabel: 'see docs at f/shared/sync for details' +}) + +describe('extractAppRefs', () => { + it('extracts runnable-by-path scripts/flows and $res resources, skips hub', () => { + const refs = extractAppRefs(appValue()) + expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/charts' }) + expect(refs).toContainEqual({ kind: 'flow', path: 'f/shared/sync' }) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' }) + expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false) + }) +}) + +describe('rewriteAppValue', () => { + it('relocates runnable paths and $res, leaves hub refs and incidental text intact', () => { + const map = new Map([ + ['u/admin/charts', 'f/proj/charts'], + ['f/shared/sync', 'f/proj/sync'], + ['u/admin/pg', 'f/proj/pg'] + ]) + const value = appValue() + const out = rewriteAppValue(value, map) + expect(out.grid[0].data.componentInput.runnable.path).toBe('f/proj/charts') + expect(out.grid[1].data.componentInput.runnable.path).toBe('f/proj/sync') + expect(out.grid[2].data.componentInput.runnable.path).toBe('hub/1/keep') + expect(out.hiddenInlineScripts[0].inlineScript.content).toBe('x = "$res:f/proj/pg"') + // incidental text untouched + expect(out.someLabel).toBe('see docs at f/shared/sync for details') + // original untouched (deep clone) + expect(value.grid[0].data.componentInput.runnable.path).toBe('u/admin/charts') + }) +}) + +describe('raw app (value.raw JSON string)', () => { + const rawContent = () => + JSON.stringify({ + runnables: { + a: { type: 'path', runType: 'flow', path: 'u/admin/sync' }, + b: { type: 'path', runType: 'script', path: 'f/shared/calc' }, + c: { type: 'path', runType: 'hubscript', path: 'hub/1/keep' } + }, + files: { '/bundle.js': 'const conn = "$res:u/admin/pg"' } + }) + + it('extractRawAppRefs sees nested runnables and $res, skips hub', () => { + const refs = extractRawAppRefs(rawContent()) + expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sync' }) + expect(refs).toContainEqual({ kind: 'script', path: 'f/shared/calc' }) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' }) + expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false) + }) + + it('rewriteRawAppContent relocates nested runnable paths and $res', () => { + const map = new Map([ + ['u/admin/sync', 'f/proj/sync'], + ['f/shared/calc', 'f/proj/calc'], + ['u/admin/pg', 'f/proj/pg'] + ]) + const out = JSON.parse(rewriteRawAppContent(rawContent(), map)) + expect(out.runnables.a.path).toBe('f/proj/sync') + expect(out.runnables.b.path).toBe('f/proj/calc') + expect(out.runnables.c.path).toBe('hub/1/keep') + expect(out.files['/bundle.js']).toBe('const conn = "$res:f/proj/pg"') + }) + + it('falls back to $res scan on non-JSON content', () => { + expect(extractRawAppRefs('x = "$res:u/admin/pg"')).toContainEqual({ + kind: 'resource', + path: 'u/admin/pg' + }) + expect( + rewriteRawAppContent('x = "$res:u/admin/pg"', new Map([['u/admin/pg', 'f/proj/pg']])) + ).toBe('x = "$res:f/proj/pg"') + }) +}) + +describe('buildProjectBundle', () => { + // A flow that calls an external script which itself hardcodes a resource. + const flow: FetchedItem = { + kind: 'flow', + path: 'u/admin/my_flow', + summary: 'Flow', + value: { + modules: [ + { id: 'a', value: { type: 'script', path: 'u/admin/helper' } }, + { + id: 'b', + value: { + type: 'rawscript', + content: 'const x = "$res:f/shared/api"', + input_transforms: {} + } + } + ] + } + } + const helper: FetchedItem = { + kind: 'script', + path: 'u/admin/helper', + summary: 'Helper', + language: 'bun', + content: 'const db = "$res:u/admin/pg"; export async function main(){}' + } + + const deps = { + fetchItem: async (ref: ItemRef) => { + if (ref.path === 'u/admin/my_flow') return flow + if (ref.path === 'u/admin/helper') return helper + return undefined + }, + resolveResourceType: async (path: string) => { + if (path === 'u/admin/pg') return 'postgresql' + if (path === 'f/shared/api') return 'http_api' + return undefined + } + } + + it('pulls in referenced scripts + resources and rewrites everything under the folder', async () => { + const bundle = await buildProjectBundle( + [{ kind: 'flow', path: 'u/admin/my_flow' }], + 'proj', + deps + ) + + // flow + transitively-pulled helper script are both bundled + const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i])) + expect(Object.keys(byPath).sort()).toEqual(['u/admin/helper', 'u/admin/my_flow']) + + // items relocated under f/proj/ + expect(byPath['u/admin/my_flow'].newPath).toBe('f/proj/my_flow') + expect(byPath['u/admin/helper'].newPath).toBe('f/proj/helper') + + // flow's script-path ref rewritten to the helper's new path + expect(byPath['u/admin/my_flow'].value.modules[0].value.path).toBe('f/proj/helper') + // flow inline + helper code resource refs rewritten + expect(byPath['u/admin/my_flow'].value.modules[1].value.content).toBe( + 'const x = "$res:f/proj/api"' + ) + expect(byPath['u/admin/helper'].content).toContain('"$res:f/proj/pg"') + + // resource stubs created at new paths with resolved types + const stubs = Object.fromEntries(bundle.resourceStubs.map((s) => [s.originalPath, s])) + expect(stubs['u/admin/pg'].newPath).toBe('f/proj/pg') + expect(stubs['u/admin/pg'].resource_type).toBe('postgresql') + expect(stubs['f/shared/api'].resource_type).toBe('http_api') + + expect(bundle.unresolved).toEqual([]) + }) + + it('pulls in a sub-flow referenced by a type: flow step and rewrites its path', async () => { + const parent: FetchedItem = { + kind: 'flow', + path: 'u/admin/parent_flow', + value: { modules: [{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } }] } + } + const sub: FetchedItem = { + kind: 'flow', + path: 'u/admin/sub_flow', + value: { + modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/keep/me' } }] + } + } + const d = { + fetchItem: async (ref: ItemRef) => { + if (ref.path === 'u/admin/parent_flow') return parent + if (ref.path === 'u/admin/sub_flow') return sub + return undefined + }, + resolveResourceType: async () => undefined + } + const bundle = await buildProjectBundle( + [{ kind: 'flow', path: 'u/admin/parent_flow' }], + 'proj', + d + ) + const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i])) + // both flows bundled + expect(Object.keys(byPath).sort()).toEqual(['u/admin/parent_flow', 'u/admin/sub_flow']) + // parent's type: flow ref rewritten to the sub-flow's new path + expect(byPath['u/admin/parent_flow'].value.modules[0].value.path).toBe('f/proj/sub_flow') + expect(byPath['u/admin/sub_flow'].newPath).toBe('f/proj/sub_flow') + // hub ref inside the sub-flow left untouched + expect(byPath['u/admin/sub_flow'].value.modules[0].value.path).toBe('hub/1/keep/me') + expect(bundle.unresolved).toEqual([]) + }) + + it('leaves hub script references untouched and does not fetch them', async () => { + const hubFlow: FetchedItem = { + kind: 'flow', + path: 'u/admin/hub_flow', + value: { modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/x/y' } }] } + } + const d = { + fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/hub_flow' ? hubFlow : undefined), + resolveResourceType: async () => undefined + } + const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/hub_flow' }], 'proj', d) + expect(bundle.items.map((i) => i.path)).toEqual(['u/admin/hub_flow']) + expect(bundle.items[0].value.modules[0].value.path).toBe('hub/1/x/y') + expect(bundle.unresolved).toEqual([]) + }) + + it('reports a missing item and an unresolvable resource as unresolved', async () => { + const root: FetchedItem = { + kind: 'flow', + path: 'u/admin/root', + value: { + modules: [ + { id: 'a', value: { type: 'script', path: 'u/admin/gone' } }, + { + id: 'b', + value: { + type: 'rawscript', + content: 'const x = "$res:u/admin/untyped"', + input_transforms: {} + } + } + ] + } + } + const d = { + fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined), + resolveResourceType: async () => undefined + } + const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d) + expect(bundle.unresolved.sort()).toEqual(['u/admin/gone', 'u/admin/untyped']) + }) + + it('relocates $var:/$jsonvar: refs into the slug when it differs from the source folder', async () => { + const flow: FetchedItem = { + kind: 'flow', + path: 'f/source_folder/main', + value: { + flow_env: { CFG: '$jsonvar:f/source_folder/cfg' }, + modules: [ + { + id: 'a', + value: { + type: 'rawscript', + // Whole-value ref is relocated; the inline literal is not. + content: 'return "$var:f/source_folder/key"', + input_transforms: { k: { type: 'static', value: '$var:f/source_folder/key' } } + } + } + ] + } + } + const d = { + fetchItem: async (ref: ItemRef) => (ref.path === 'f/source_folder/main' ? flow : undefined), + resolveResourceType: async () => undefined + } + const bundle = await buildProjectBundle( + [{ kind: 'flow', path: 'f/source_folder/main' }], + 'kit', + d + ) + const v = bundle.items[0].value + expect(v.modules[0].value.input_transforms.k.value).toBe('$var:f/kit/key') + expect(v.flow_env.CFG).toBe('$jsonvar:f/kit/cfg') + // Inline code literal is untouched. + expect(v.modules[0].value.content).toBe('return "$var:f/source_folder/key"') + }) + + it('dedupes a path missing as both a script and a flow', async () => { + // A missing script + flow sharing a path each push the bare path once; the + // list must stay unique so a keyed UI render of it can't collide. + const root: FetchedItem = { + kind: 'flow', + path: 'u/admin/root', + value: { + modules: [ + { id: 'a', value: { type: 'script', path: 'u/admin/dup' } }, + { id: 'b', value: { type: 'flow', path: 'u/admin/dup' } } + ] + } + } + const d = { + fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined), + resolveResourceType: async () => undefined + } + const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d) + expect(bundle.unresolved).toEqual(['u/admin/dup']) + }) +}) + +describe('extractVarRefsFromValue', () => { + it('collects whole-value `$var:`/`$jsonvar:` refs, deduped, walking nested JSON', () => { + const value = { + flow_env: { API: '$var:u/admin/key' }, + modules: [ + { value: { input_transforms: { a: { type: 'static', value: '$var:f/proj/token' } } } }, + { value: { input_transforms: { b: { type: 'static', value: '$jsonvar:u/admin/cfg' } } } }, + { value: { input_transforms: { c: { type: 'static', value: '$var:u/admin/key' } } } } + ] + } + expect(extractVarRefsFromValue(value).sort()).toEqual([ + 'f/proj/token', + 'u/admin/cfg', + 'u/admin/key' + ]) + }) + it('ignores a `$var:` token embedded in inline code (not a whole value)', () => { + // The worker only substitutes a value that *is* the reference, so an inline + // script literal must not be treated as a variable arg. + const value = { + modules: [{ value: { type: 'rawscript', content: 'return "$var:u/example/template"' } }] + } + expect(extractVarRefsFromValue(value)).toEqual([]) + }) +}) + +describe('retargetProjectExport', () => { + const baseExport = (): ProjectExport => ({ + project: { slug: 'proj', name: 'Proj', summary: '', readme: null }, + scripts: [ + { + path: 'f/proj/hello', + content: 'const r = "$res:f/proj/db"', + summary: 'hello' + } + ], + flows: [ + { + path: 'f/proj/main_flow', + value: { + modules: [ + { id: 'a', value: { type: 'script', path: 'f/proj/hello', input_transforms: {} } } + ] + } + } + ], + apps: [ + { + path: 'f/proj/dashboard', + value: { grid: [{ data: { componentInput: { runnable: {} } } }] } + }, + { + path: 'f/proj/rawapp', + app_type: 'raw', + value: { raw: JSON.stringify({ files: {}, runnables: {} }) } + } + ], + resources: [{ path: 'f/proj/db', resource_type: 'postgresql' }], + triggers: [ + { + path: 'f/proj/every_day', + kind: 'schedule', + runnable_path: 'f/proj/hello', + runnable_kind: 'script', + config: { schedule: '0 0 12 * * *' } + }, + { + path: 'f/proj/kafka_in', + kind: 'kafka', + runnable_path: 'f/proj/hello', + runnable_kind: 'script', + config: { kafka_resource_path: 'f/proj/db' } + } + ] + }) + + it('returns the bundle unchanged when the folder matches the slug', () => { + const bundle = baseExport() + expect(retargetProjectExport(bundle, 'proj', 'proj')).toBe(bundle) + }) + + it('relocates every item path and internal reference into the target folder', () => { + const out = retargetProjectExport(baseExport(), 'proj', 'dest') + expect(out.scripts[0].path).toBe('f/dest/hello') + expect(out.scripts[0].content).toContain('$res:f/dest/db') + expect(out.flows[0].path).toBe('f/dest/main_flow') + expect(out.flows[0].value.modules[0].value.path).toBe('f/dest/hello') + expect(out.apps.map((a) => a.path)).toEqual(['f/dest/dashboard', 'f/dest/rawapp']) + expect(out.resources[0].path).toBe('f/dest/db') + expect(out.triggers[0].path).toBe('f/dest/every_day') + expect(out.triggers[0].runnable_path).toBe('f/dest/hello') + // Plain-string resource path in a trigger config is remapped too. + expect(out.triggers[1].config.kafka_resource_path).toBe('f/dest/db') + }) + + it('leaves external and hub paths untouched', () => { + const bundle = baseExport() + bundle.scripts[0].content = 'const a = "$res:u/admin/db"; const b = "$res:hub/1/x"' + const out = retargetProjectExport(bundle, 'proj', 'dest') + expect(out.scripts[0].content).toContain('$res:u/admin/db') + expect(out.scripts[0].content).toContain('$res:hub/1/x') + }) + + it('retargets internal $var:/$jsonvar: refs but leaves external ones', () => { + const bundle = baseExport() + bundle.flows[0].value.modules[0].value.input_transforms = { + key: { type: 'static', value: '$var:f/proj/api_key' }, + ext: { type: 'static', value: '$var:u/admin/personal' } + } + bundle.flows[0].value.flow_env = { CFG: '$jsonvar:f/proj/cfg' } + bundle.triggers[1].config.queue_url = '$var:f/proj/sqs' + const out = retargetProjectExport(bundle, 'proj', 'dest') + const it = out.flows[0].value.modules[0].value.input_transforms + expect(it.key.value).toBe('$var:f/dest/api_key') + expect(it.ext.value).toBe('$var:u/admin/personal') + expect(out.flows[0].value.flow_env.CFG).toBe('$jsonvar:f/dest/cfg') + expect(out.triggers[1].config.queue_url).toBe('$var:f/dest/sqs') + }) + + it('leaves an inert $var: literal embedded in inline code unchanged', () => { + const bundle = baseExport() + // Same path as a real runtime ref, but here it is a literal inside code: it + // must not be rewritten even once the path enters the retarget map. + bundle.flows[0].value.modules[0].value = { + type: 'rawscript', + content: 'return "$var:f/proj/api_key"', + input_transforms: { real: { type: 'static', value: '$var:f/proj/api_key' } } + } + const out = retargetProjectExport(bundle, 'proj', 'dest') + const mod = out.flows[0].value.modules[0].value + expect(mod.content).toBe('return "$var:f/proj/api_key"') + expect(mod.input_transforms.real.value).toBe('$var:f/dest/api_key') + }) +}) + +describe('collectExportVarPaths', () => { + it('gathers variable refs from flows, apps, and triggers (deduped)', () => { + const bundle: ProjectExport = { + project: { slug: 'proj', name: 'P', summary: '', readme: null }, + scripts: [], + flows: [{ path: 'f/proj/f', value: { flow_env: { A: '$var:f/proj/a' }, modules: [] } }], + apps: [ + { + path: 'f/proj/raw', + app_type: 'raw', + value: { raw: JSON.stringify({ runnables: { r: { fields: { x: '$var:u/admin/b' } } } }) } + } + ], + triggers: [{ path: 'f/proj/t', kind: 'sqs', config: { queue_url: '$jsonvar:f/proj/a' } }], + resources: [] + } + expect(collectExportVarPaths(bundle).sort()).toEqual(['f/proj/a', 'u/admin/b']) + }) +}) + +describe('trigger handler relocation', () => { + it('rewriteTriggerConfig remaps script/- and flow/-prefixed handler refs', () => { + const map = new Map([ + ['u/admin/handler', 'f/proj/handler'], + ['u/admin/recovery_flow', 'f/proj/recovery_flow'] + ]) + const out = rewriteTriggerConfig( + { + error_handler_path: 'u/admin/handler', + on_failure: 'script/u/admin/handler', + on_recovery: 'flow/u/admin/recovery_flow', + on_success: 'script/u/admin/unmapped' + }, + map + ) + expect(out.error_handler_path).toBe('f/proj/handler') + expect(out.on_failure).toBe('script/f/proj/handler') + expect(out.on_recovery).toBe('flow/f/proj/recovery_flow') + expect(out.on_success).toBe('script/u/admin/unmapped') + }) + + it('remaps $script:/$flow: only in the url field, never in literal payloads', () => { + const map = new Map([['u/admin/builder', 'f/proj/builder']]) + const out = rewriteTriggerConfig( + { + url: '$script:u/admin/builder', + initial_messages: [{ raw_message: '$script:u/admin/builder' }] + }, + map + ) + expect(out.url).toBe('$script:f/proj/builder') + expect(out.initial_messages[0].raw_message).toBe('$script:u/admin/builder') + }) + + it('leaves literal handler-shaped strings in args untouched', () => { + const map = new Map([['f/proj/handler', 'f/dest/handler']]) + const out = rewriteTriggerConfig( + { + on_failure: 'script/f/proj/handler', + args: { note: 'script/f/proj/handler' } + }, + map + ) + expect(out.on_failure).toBe('script/f/dest/handler') + expect(out.args.note).toBe('script/f/proj/handler') + }) + + it('leaves nested url keys untouched, rewriting only the top-level websocket url', () => { + const map = new Map([['u/admin/builder', 'f/proj/builder']]) + const out = rewriteTriggerConfig( + { + url: '$script:u/admin/builder', + args: { url: '$script:u/admin/builder' } + }, + map + ) + expect(out.url).toBe('$script:f/proj/builder') + expect(out.args.url).toBe('$script:u/admin/builder') + }) + + it('extracts and relocates $res refs nested in static input transform JSON', () => { + const value = { + modules: [ + { + id: 'a', + value: { + type: 'script', + path: 'f/proj/step', + input_transforms: { + provider: { type: 'static', value: { resource: '$res:u/admin/openai' } }, + note: { type: 'static', value: 'plain text' } + } + } + } + ] + } + const refs = extractFlowRefs(value) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/openai' }) + const out = rewriteFlowValue(value, new Map([['u/admin/openai', 'f/proj/openai']])) + const it0 = out.modules[0].value.input_transforms + expect(it0.provider.value).toEqual({ resource: '$res:f/proj/openai' }) + expect(typeof it0.note.value).toBe('string') + }) + + it('retargetProjectExport remaps trigger error handlers with the bundle', () => { + const bundle: ProjectExport = { + project: { slug: 'proj', name: 'P', summary: '', readme: null }, + scripts: [{ path: 'f/proj/handler', content: '' }], + flows: [], + apps: [], + resources: [], + triggers: [ + { + path: 'f/proj/sched', + kind: 'schedule', + runnable_path: 'f/proj/handler', + runnable_kind: 'script', + config: { schedule: '0 0 * * * *', on_failure: 'script/f/proj/handler' } + }, + { + path: 'f/proj/mq', + kind: 'mqtt', + runnable_path: 'f/proj/handler', + runnable_kind: 'script', + config: { error_handler_path: 'f/proj/handler' } + } + ] + } + const out = retargetProjectExport(bundle, 'proj', 'dest') + expect(out.triggers[0].config.on_failure).toBe('script/f/dest/handler') + expect(out.triggers[1].config.error_handler_path).toBe('f/dest/handler') + }) +}) + +describe('extractTriggerConfigResourceRefs', () => { + it('collects $res: tokens nested anywhere in a trigger config', () => { + expect( + extractTriggerConfigResourceRefs({ + schedule: '0 0 * * * *', + args: { channel: '$res:u/admin/slack' }, + on_failure_extra_args: { db: 'res://f/other/pg' }, + error_handler_args: { nested: { deep: '$res:u/admin/slack' } } + }) + ).toEqual(['u/admin/slack', 'f/other/pg']) + }) +}) + +describe('flow_env and preprocessor_module', () => { + const flowValue = { + modules: [], + preprocessor_module: { + id: 'pre', + value: { type: 'script', path: 'u/admin/preproc', input_transforms: {} } + }, + flow_env: { SLACK: '$res:u/admin/slack', PLAIN: 'not-a-ref' } + } + + it('walks nested children of the failure module', () => { + const refs = extractFlowRefs({ + modules: [], + failure_module: { + id: 'failure', + value: { + type: 'forloopflow', + modules: [ + { id: 'f-a', value: { type: 'script', path: 'u/admin/cleanup', input_transforms: {} } } + ] + } + } + }) + expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/cleanup' }) + }) + + it('extractFlowRefs sees preprocessor scripts and flow_env resources', () => { + const refs = extractFlowRefs(flowValue) + expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/preproc' }) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/slack' }) + }) + + it('sees and relocates $res refs nested inside JSON flow_env values', () => { + const value = { + modules: [], + flow_env: { CFG: { db: '$res:u/admin/pg', opts: ['res://u/admin/s3'] } } + } + const refs = extractFlowRefs(value) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' }) + expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/s3' }) + const map = new Map([ + ['u/admin/pg', 'f/proj/pg'], + ['u/admin/s3', 'f/proj/s3'] + ]) + const out = rewriteFlowValue(value, map) + expect(out.flow_env.CFG.db).toBe('$res:f/proj/pg') + expect(out.flow_env.CFG.opts[0]).toBe('$res:f/proj/s3') + }) + + it('rewriteFlowValue relocates both', () => { + const map = new Map([ + ['u/admin/preproc', 'f/proj/preproc'], + ['u/admin/slack', 'f/proj/slack'] + ]) + const out = rewriteFlowValue(flowValue, map) + expect(out.preprocessor_module.value.path).toBe('f/proj/preproc') + expect(out.flow_env.SLACK).toBe('$res:f/proj/slack') + expect(out.flow_env.PLAIN).toBe('not-a-ref') + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/projectBundle.ts b/frontend/src/lib/components/workspaceSettings/projectBundle.ts new file mode 100644 index 0000000000..1fd62498d5 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectBundle.ts @@ -0,0 +1,652 @@ +// Pure logic for the "project = folder" Hub bundle. A project is one folder +// `f//...`. Bundling: collect the transitive closure, relocate external +// refs (`u//`, `f//` -> `f//`, `_2`/`_3`… +// on collision) and rewrite them. Hub refs stay external; runtime string-concat +// paths are out of scope. No API/Svelte deps so it's unit-testable. + +import { getAllModules } from '$lib/components/flows/flowExplorer' +import { isRunnableByPath } from '$lib/components/apps/inputType' + +export type RefKind = 'resource' | 'script' | 'flow' + +export interface Ref { + kind: RefKind + /** Bare path, without the `$res:` / `res://` prefix for resources. */ + path: string +} + +export type PathClass = 'internal' | 'hub' | 'external' + +/** A single `$res:PATH` / `res://PATH` token (path captured in group 1). */ +const RES_TOKEN_RE = /(?:\$res:|res:\/\/)([\w\-./]+)/g + +// A whole-string `$var:PATH` / `$jsonvar:PATH` value. The worker substitutes these +// only when an argument value *is* the reference (walking nested JSON), never a +// token embedded in inline code, so the whole value must match. `_KIND` captures +// the prefix (group 1) and path (group 2) so a rewrite can preserve `var`/`jsonvar`. +const VAR_VALUE_RE = /^\$(?:json)?var:([\w\-./]+)$/ +const VAR_VALUE_RE_KIND = /^\$(var|jsonvar):([\w\-./]+)$/ + +// Variable paths a value will resolve at runtime (flow static inputs, flow_env, +// app runnable inputs, trigger config fields). Walk the parsed structure and match +// whole string values so inline code carrying a literal `$var:` string is ignored. +export function extractVarRefsFromValue(value: any): string[] { + const out = new Set() + const walk = (v: any) => { + if (typeof v === 'string') { + const m = VAR_VALUE_RE.exec(v) + if (m) out.add(m[1]) + } else if (Array.isArray(v)) { + for (const x of v) walk(x) + } else if (v && typeof v === 'object') { + for (const k of Object.keys(v)) walk(v[k]) + } + } + walk(value) + return [...out] +} + +export function classifyPath(path: string, slug: string): PathClass { + if (path.startsWith(`f/${slug}/`) || path === `f/${slug}`) return 'internal' + if (path.startsWith('hub/')) return 'hub' + return 'external' +} + +export function extractScriptRefs(content: string): Ref[] { + const out: Ref[] = [] + const seen = new Set() + let m: RegExpExecArray | null + RES_TOKEN_RE.lastIndex = 0 + while ((m = RES_TOKEN_RE.exec(content)) !== null) { + if (!seen.has(m[1])) { + seen.add(m[1]) + out.push({ kind: 'resource', path: m[1] }) + } + } + return out +} + +/** + * References inside a flow value: + * - inline rawscript code with `$res:` (resource) + * - static step inputs whose value is a `$res:` literal (resource) + * - `type: script` steps that reference a script by path (script) + * - `type: flow` steps that reference a sub-flow by path (flow) + */ +export function extractFlowRefs(value: any): Ref[] { + const out: Ref[] = [] + const seen = new Set() + const add = (kind: RefKind, path: string) => { + const key = `${kind}:${path}` + if (!seen.has(key)) { + seen.add(key) + out.push({ kind, path }) + } + } + // getAllModules flattens the whole tree (loops, branches, aiagent tools, + // failure module) so each module only needs local inspection; the + // preprocessor module sits outside `modules` and is walked the same way. + for (const mod of allFlowModules(value)) { + const v: any = (mod as any)?.value + if (!v || typeof v !== 'object') continue + if (v.type === 'script' && typeof v.path === 'string') add('script', v.path) + if (v.type === 'flow' && typeof v.path === 'string') add('flow', v.path) + if (typeof v.content === 'string') { + for (const r of extractScriptRefs(v.content)) add('resource', r.path) + } + const it = v.input_transforms + if (it && typeof it === 'object') { + for (const key of Object.keys(it)) { + const t = it[key] + // Static values can be a bare `$res:` string or arbitrary JSON with + // refs nested anywhere — the worker resolves both, so scan the full + // serialization. + if (t?.type === 'static' && t.value !== undefined) { + const text = typeof t.value === 'string' ? t.value : JSON.stringify(t.value) + for (const r of extractScriptRefs(text)) add('resource', r.path) + } + } + } + } + // flow_env values support `$res:path` references — as whole string values or + // nested inside JSON values (the worker resolves both), so scan the full + // serialization. + if (value?.flow_env && typeof value.flow_env === 'object') { + for (const r of extractScriptRefs(JSON.stringify(value.flow_env))) add('resource', r.path) + } + return out +} + +// Every module of a flow value: the tree under `modules`, the failure module, +// and the preprocessor module (which lives outside `modules`). Any walk over a +// flow's modules must go through this — a walk that misses a module class +// silently drops its dependencies from bundles or migrations. All three go in +// the root list (not getAllModules' failure_module parameter, which appends +// the module without expanding its descendants) so nested children of a +// failure or preprocessor module are walked too. +export function allFlowModules(value: any) { + return getAllModules([ + ...(value?.modules ?? []), + ...(value?.preprocessor_module ? [value.preprocessor_module] : []), + ...(value?.failure_module ? [value.failure_module] : []) + ]) +} + +// Visit every object node in an app value tree (JSON-safe, no cycles). +function walkAppNodes(value: any, visit: (node: Record) => void): void { + if (value == null || typeof value !== 'object') return + if (Array.isArray(value)) { + for (const v of value) walkAppNodes(v, visit) + return + } + visit(value) + for (const k of Object.keys(value)) walkAppNodes(value[k], visit) +} + +// `runnableByPath`/`path` nodes reference a workspace runnable by path. +function runnableRef(node: Record): Ref | undefined { + if (!isRunnableByPath(node as any) || typeof node.path !== 'string') return undefined + if (node.runType === 'flow') return { kind: 'flow', path: node.path } + if (node.runType === 'script') return { kind: 'script', path: node.path } + return undefined // hubscript -> external hub, ignored +} + +// App refs: `$res:` resources anywhere in the value, plus script/flow runnables +// referenced by path in components. +export function extractAppRefs(value: any): Ref[] { + const out: Ref[] = [] + const seen = new Set() + const add = (kind: RefKind, path: string) => { + const key = `${kind}:${path}` + if (!seen.has(key)) { + seen.add(key) + out.push({ kind, path }) + } + } + walkAppNodes(value, (node) => { + const r = runnableRef(node) + if (r) add(r.kind, r.path) + }) + for (const r of extractScriptRefs(JSON.stringify(value ?? {}))) add('resource', r.path) + return out +} + +/** + * Build the relocation map. Internal paths (`f//...`) map to themselves + * and are reserved first; external paths relocate to `f//` (`_2`/`_3`… + * on collision). Input is sorted so suffix assignment is deterministic. + */ +export function buildPathMap(paths: Iterable, slug: string): Map { + const map = new Map() + const used = new Set() + const sorted = [...new Set(paths)].sort() + for (const p of sorted) { + if (classifyPath(p, slug) === 'internal') { + map.set(p, p) + used.add(p) + } + } + for (const old of sorted) { + if (map.has(old)) continue + const name = old.split('/').filter(Boolean).pop() ?? old + let candidate = `f/${slug}/${name}` + let n = 2 + while (used.has(candidate)) candidate = `f/${slug}/${name}_${n++}` + used.add(candidate) + map.set(old, candidate) + } + return map +} + +// Both ref forms normalize to `$res:` on rewrite. +export function rewriteContent(content: string, map: Map): string { + return content.replace(RES_TOKEN_RE, (whole, path) => { + const next = map.get(path) + return next ? `$res:${next}` : whole + }) +} + +// Structurally relocate whole-string `$var:`/`$jsonvar:` values — the only form the +// worker resolves. Walks the parsed value so an inert token embedded in inline code +// or arbitrary text is left untouched, unlike token replacement over serialized +// strings. Only paths present in the map move (the retarget map carries variables). +export function rewriteVarRefsInValue(value: any, map: Map): any { + if (typeof value === 'string') { + const m = VAR_VALUE_RE_KIND.exec(value) + if (m) { + const next = map.get(m[2]) + if (next) return `$${m[1]}:${next}` + } + return value + } + if (Array.isArray(value)) return value.map((v) => rewriteVarRefsInValue(v, map)) + if (value && typeof value === 'object') { + const out: Record = {} + for (const k of Object.keys(value)) out[k] = rewriteVarRefsInValue(value[k], map) + return out + } + return value +} + +/** + * `$res:`/`res://` tokens anywhere in a trigger config — schedule args, + * on_*_extra_args, error_handler_args, … (e.g. the built-in Slack handler + * stores its channel resource this way). These must enter the bundle path map + * so `rewriteTriggerConfig` relocates them and a stub is exported. + */ +export function extractTriggerConfigResourceRefs(config: any): string[] { + return extractScriptRefs(JSON.stringify(config ?? {})).map((r) => r.path) +} + +/** + * Trigger configs reference resources as plain path strings (e.g. + * `kafka_resource_path: "f/slug/db"`), not `$res:` tokens, so token rewriting + * misses them. Deep-walk the config and remap any string that exact-matches a + * map key (map keys are full bundle paths, so an exact match is a reference), + * or a `script/`/`flow/` handler reference (schedules' on_failure + * et al.), falling back to `$res:` token rewriting for embedded refs. + */ +// Top-level config fields whose string values are prefixed runnable refs. +// Prefixed forms are remapped ONLY in these known positions: deciding meaning +// from string shape alone rewrote literal payloads that merely looked like +// refs. Bare-path exact matches and $res: tokens stay position-independent. +const HANDLER_REF_FIELDS = new Set(['on_failure', 'on_recovery', 'on_success']) + +export function rewriteTriggerConfig(config: any, map: Map, depth = 0): any { + if (typeof config === 'string') { + const direct = map.get(config) + if (direct) return direct + return rewriteContent(config, map) + } + if (Array.isArray(config)) return config.map((v) => rewriteTriggerConfig(v, map, depth + 1)) + if (config && typeof config === 'object') { + return Object.fromEntries( + Object.entries(config).map(([k, v]) => { + if (depth === 0 && typeof v === 'string') { + // Websocket url: $script: / $flow:. + if (k === 'url') { + const m = /^\$(script|flow):(.+)$/.exec(v) + if (m && map.has(m[2])) return [k, `$${m[1]}:${map.get(m[2])}`] + } + // Schedule handlers: script/ / flow/. + if (HANDLER_REF_FIELDS.has(k)) { + const m = /^(script|flow)\/(.+)$/.exec(v) + if (m && map.has(m[2])) return [k, `${m[1]}/${map.get(m[2])}`] + } + } + return [k, rewriteTriggerConfig(v, map, depth + 1)] + }) + ) + } + return config +} + +export function rewriteFlowValue(value: any, map: Map): any { + const cloned = JSON.parse(JSON.stringify(value ?? {})) + for (const mod of allFlowModules(cloned)) { + const v: any = (mod as any)?.value + if (!v || typeof v !== 'object') continue + if ( + (v.type === 'script' || v.type === 'flow') && + typeof v.path === 'string' && + map.has(v.path) + ) { + v.path = map.get(v.path) + } + if (typeof v.content === 'string') v.content = rewriteContent(v.content, map) + const it = v.input_transforms + if (it && typeof it === 'object') { + for (const key of Object.keys(it)) { + const t = it[key] + // Mirror extraction: rewrite refs wherever they sit, preserving the + // value's type (a string stays a string, JSON round-trips). + if (t?.type === 'static' && t.value !== undefined) { + if (typeof t.value === 'string') { + t.value = rewriteContent(t.value, map) + } else { + t.value = JSON.parse(rewriteContent(JSON.stringify(t.value), map)) + } + } + } + } + } + if (cloned?.flow_env && typeof cloned.flow_env === 'object') { + // Tokens can sit inside nested JSON values, not just string values; the + // serialize→rewrite→parse round-trip reaches all of them (paths contain + // no characters that would break JSON string literals). + cloned.flow_env = JSON.parse(rewriteContent(JSON.stringify(cloned.flow_env), map)) + } + return cloned +} + +// Relocate `$res:` tokens (one round-trip, also produces a fresh clone) then +// runnable-by-path refs structurally. Incidental `f//` strings stay intact. +export function rewriteAppValue(value: any, map: Map): any { + if (value == null) return value + const cloned = JSON.parse(rewriteContent(JSON.stringify(value), map)) + walkAppNodes(cloned, (node) => { + if (runnableRef(node) && map.has(node.path)) node.path = map.get(node.path) + }) + return cloned +} + +// Raw/compiled apps store their structure as a JSON string (`{ runnables, files }`). +// Parse it so runnable-by-path refs in the runnables map are seen, reusing the +// same walk; fall back to plain `$res:` scanning if it isn't valid JSON. +export function extractRawAppRefs(content: string): Ref[] { + let parsed: any + try { + parsed = JSON.parse(content) + } catch { + return extractScriptRefs(content) + } + return extractAppRefs(parsed) +} + +export function rewriteRawAppContent(content: string, map: Map): string { + let parsed: any + try { + parsed = JSON.parse(content) + } catch { + return rewriteContent(content, map) + } + return JSON.stringify(rewriteAppValue(parsed, map)) +} + +// --------------------------------------------------------------------------- +// Hub project export format (what /projects/{slug}/export returns) and its +// retargeting into a destination folder. Kept here, next to the rewriters, +// so the bundle format is defined in one module for both publish and install. +// --------------------------------------------------------------------------- + +export type ExportItem = Record +export interface ProjectMigration { + datatable_name: string + sql: string + sql_down?: string + enabled: boolean +} +export interface ProjectExport { + project: { slug: string; name: string; summary: string; readme: string | null } + scripts: ExportItem[] + flows: ExportItem[] + apps: ExportItem[] + resources: ExportItem[] + triggers: ExportItem[] + migrations?: ProjectMigration[] +} + +// Map bundled paths `f//...` -> `f//...`. Only enumerated +// paths go in, so rewriters touch real refs, never incidental text. +export function buildRetargetMap( + bundle: ProjectExport, + fromSlug: string, + folder: string +): Map { + const map = new Map() + const prefix = `f/${fromSlug}/` + const add = (p: unknown) => { + if (typeof p === 'string' && p.startsWith(prefix)) { + map.set(p, `f/${folder}/${p.slice(prefix.length)}`) + } + } + for (const s of bundle.scripts) add(s.path) + for (const f of bundle.flows) add(f.path) + for (const a of bundle.apps) add(a.path) + for (const r of bundle.resources) add(r.path) + for (const t of bundle.triggers) { + add(t.path) + add(t.runnable_path) + } + // Variables aren't enumerated in the export; their `$var:`/`$jsonvar:` refs live + // inside item values. Relocate the internal ones so a renamed-folder import + // rewrites them into the target folder instead of retaining the old prefix. + for (const p of collectExportVarPaths(bundle)) add(p) + return map +} + +// Internal-or-external variable paths referenced by the export's flows, apps and +// triggers. Scripts carry no variable args. Raw apps hold their structure in the +// `value.raw` JSON string. +export function collectExportVarPaths(bundle: ProjectExport): string[] { + const out = new Set() + const collect = (value: any) => { + for (const p of extractVarRefsFromValue(value)) out.add(p) + } + for (const f of bundle.flows) collect(f.value) + for (const a of bundle.apps) collect(a.app_type === 'raw' ? safeParseRaw(a.value?.raw) : a.value) + for (const t of bundle.triggers) collect(t.config) + return [...out] +} + +function safeParseRaw(raw: unknown): any { + if (typeof raw !== 'string') return undefined + try { + return JSON.parse(raw) + } catch { + return undefined + } +} + +// Structural retarget: rewrite each item's path and its internal refs, +// leaving Hub refs and arbitrary content untouched. +export function retargetProjectExport( + bundle: ProjectExport, + fromSlug: string, + folder: string +): ProjectExport { + if (folder === fromSlug) return bundle + const map = buildRetargetMap(bundle, fromSlug, folder) + const remap = (p: unknown) => (typeof p === 'string' ? (map.get(p) ?? p) : p) + return { + ...bundle, + scripts: bundle.scripts.map((s) => ({ + ...s, + path: remap(s.path), + content: rewriteContent(s.content ?? '', map) + })), + flows: bundle.flows.map((f) => ({ + ...f, + path: remap(f.path), + value: rewriteVarRefsInValue(rewriteFlowValue(f.value, map), map) + })), + apps: bundle.apps.map((a) => ({ + ...a, + path: remap(a.path), + // Raw apps keep their structure in the `value.raw` JSON string. + value: + a.app_type === 'raw' + ? { + ...a.value, + raw: rewriteRawVarRefs(rewriteRawAppContent(a.value?.raw ?? '', map), map) + } + : rewriteVarRefsInValue(rewriteAppValue(a.value, map), map) + })), + resources: bundle.resources.map((r) => ({ ...r, path: remap(r.path) })), + triggers: bundle.triggers.map((t) => ({ + ...t, + path: remap(t.path), + runnable_path: remap(t.runnable_path), + // Configs hold `$res:` tokens, plain resource paths (kafka_resource_path + // etc.) and whole-string `$var:` values — rewrite all three. + config: t.config ? rewriteVarRefsInValue(rewriteTriggerConfig(t.config, map), map) : t.config + })) + } +} + +// Var relocation for a raw app's `value.raw` JSON string: parse, structurally +// rewrite whole-string var values, re-serialize; leave invalid JSON untouched. +function rewriteRawVarRefs(raw: string, map: Map): string { + const parsed = safeParseRaw(raw) + if (parsed === undefined) return raw + return JSON.stringify(rewriteVarRefsInValue(parsed, map)) +} + +export type ItemKind = 'script' | 'flow' | 'app' | 'raw_app' + +export interface ItemRef { + kind: ItemKind + path: string +} + +export interface FetchedItem { + kind: ItemKind + path: string + summary?: string + description?: string + /** scripts + raw_apps */ + content?: string + /** flows + apps */ + value?: any + /** scripts */ + language?: string + schema?: any + lock?: string + scriptKind?: string +} + +export interface BundleDeps { + /** Fetch a workspace item by ref, or undefined if it doesn't exist. */ + fetchItem: (ref: ItemRef) => Promise + /** Resolve a resource path to its type, or undefined if missing. */ + resolveResourceType: (path: string) => Promise +} + +export interface BundledItem extends FetchedItem { + /** Path the item takes inside the project folder. */ + newPath: string +} + +export interface ResourceStub { + originalPath: string + newPath: string + resource_type: string +} + +export interface ProjectBundle { + items: BundledItem[] + resourceStubs: ResourceStub[] + /** Original -> relocated path for every item and resource (incl. unresolved). */ + pathMap: Map + /** External paths we couldn't fetch/resolve (missing items or untyped resources). */ + unresolved: string[] +} + +function refsForFetched(item: FetchedItem): Ref[] { + if (item.kind === 'script') return extractScriptRefs(item.content ?? '') + if (item.kind === 'flow') return extractFlowRefs(item.value) + if (item.kind === 'app') return extractAppRefs(item.value) + if (item.kind === 'raw_app') return extractRawAppRefs(item.content ?? '') + return [] +} + +// Whole-string `$var:`/`$jsonvar:` paths an item resolves at runtime. Scripts carry +// no variable args; raw apps hold their structure in the `content` JSON string. +function varRefsForFetched(item: FetchedItem): string[] { + if (item.kind === 'flow' || item.kind === 'app') return extractVarRefsFromValue(item.value) + if (item.kind === 'raw_app') return extractVarRefsFromValue(safeParseRaw(item.content)) + return [] +} + +// Walks the transitive closure: scripts referenced by path are pulled in +// recursively, resources become empty stubs, hub refs stay external. +export async function buildProjectBundle( + seed: ItemRef[], + slug: string, + deps: BundleDeps, + extraResourcePaths: string[] = [], + extraVarPaths: string[] = [] +): Promise { + const fetched = new Map() + const queued = new Set() + const resourcePaths = new Set() + const varPaths = new Set() + const unresolved: string[] = [] + + // Resources and variables referenced by triggers (by config value, not `$res:` + // in code) — relocated through the same map so the export stays slug-relative. + for (const p of extraResourcePaths) { + if (classifyPath(p, slug) !== 'hub') resourcePaths.add(p) + } + for (const p of extraVarPaths) varPaths.add(p) + + // Key by `${kind}:${path}`, not bare path: a script and flow can share a path, + // and keying by path alone would silently drop one. + const refKey = (kind: string, path: string) => `${kind}:${path}` + + // Refs at the same BFS depth are independent: fetch each level concurrently. + let level: ItemRef[] = [] + for (const s of seed) { + const key = refKey(s.kind, s.path) + if (!queued.has(key)) { + queued.add(key) + level.push(s) + } + } + while (level.length > 0) { + const results = await Promise.all( + level.map(async (ref) => ({ ref, item: await deps.fetchItem(ref) })) + ) + const next: ItemRef[] = [] + for (const { ref, item } of results) { + if (!item) { + unresolved.push(ref.path) + continue + } + fetched.set(refKey(ref.kind, ref.path), item) + for (const r of refsForFetched(item)) { + if (classifyPath(r.path, slug) === 'hub') continue + if (r.kind === 'resource') { + resourcePaths.add(r.path) + } else if (r.kind === 'script' || r.kind === 'flow') { + const key = refKey(r.kind, r.path) + if (!queued.has(key)) { + queued.add(key) + next.push({ kind: r.kind, path: r.path }) + } + } + } + // Relocate the item's runtime variable refs into the project folder too, so + // the export is slug-relative regardless of the source folder (import then + // materializes them as placeholders). Variables are never hub-hosted. + for (const p of varRefsForFetched(item)) varPaths.add(p) + } + level = next + } + + const fetchedItems = [...fetched.values()] + const itemPaths = fetchedItems.map((it) => it.path) + const map = buildPathMap([...itemPaths, ...resourcePaths, ...varPaths], slug) + + const items: BundledItem[] = fetchedItems.map((it) => { + const rewritten: BundledItem = { ...it, newPath: map.get(it.path) ?? it.path } + if (it.kind === 'script') { + rewritten.content = rewriteContent(it.content ?? '', map) + } else if (it.kind === 'raw_app') { + rewritten.content = rewriteRawVarRefs(rewriteRawAppContent(it.content ?? '', map), map) + } else if (it.kind === 'flow') { + rewritten.value = rewriteVarRefsInValue(rewriteFlowValue(it.value, map), map) + } else if (it.kind === 'app') { + rewritten.value = rewriteVarRefsInValue(rewriteAppValue(it.value, map), map) + } + return rewritten + }) + + const resourceStubs: ResourceStub[] = [] + const resolved = await Promise.all( + [...resourcePaths].map(async (path) => ({ path, type: await deps.resolveResourceType(path) })) + ) + for (const { path, type } of resolved) { + if (!type) { + unresolved.push(path) + continue + } + resourceStubs.push({ originalPath: path, newPath: map.get(path) ?? path, resource_type: type }) + } + + // `unresolved` keys missing items by kind:path but stores the bare path, so a + // missing script and flow (or a runnable and resource) sharing a path can push + // the same string twice. Dedupe: callers use it as a display/blocker list where + // duplicate keys would break keyed rendering. + return { items, resourceStubs, pathMap: map, unresolved: [...new Set(unresolved)] } +} diff --git a/frontend/src/lib/components/workspaceSettings/projectInstall.test.ts b/frontend/src/lib/components/workspaceSettings/projectInstall.test.ts new file mode 100644 index 0000000000..a02d4e264d --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectInstall.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { refContainmentViolation, varContainmentViolation } from './projectInstall' +import type { Ref } from './projectBundle' + +describe('refContainmentViolation', () => { + const folder = 'proj' + const violation = (r: Ref) => refContainmentViolation([r], folder) + + it('allows references relocated into the target folder', () => { + expect(violation({ kind: 'resource', path: 'f/proj/db' })).toBeUndefined() + expect(violation({ kind: 'script', path: 'f/proj/helper' })).toBeUndefined() + expect(violation({ kind: 'flow', path: 'f/proj/sub' })).toBeUndefined() + }) + + it('allows hub script/flow references but never hub resources', () => { + expect(violation({ kind: 'script', path: 'hub/1/x/y' })).toBeUndefined() + expect(violation({ kind: 'flow', path: 'hub/1/a/b' })).toBeUndefined() + // Resources are not hub-hosted, so a hub/ resource path is still an escape. + expect(violation({ kind: 'resource', path: 'hub/1/x/y' })).toBeDefined() + }) + + it('rejects references bound to another namespace', () => { + // The crux: an in-folder runnable pointing its resource at an existing asset. + expect(violation({ kind: 'resource', path: 'u/admin/db' })).toContain('escapes') + expect(violation({ kind: 'script', path: 'f/other/helper' })).toContain('escapes') + expect(violation({ kind: 'flow', path: 'u/admin/sub' })).toContain('escapes') + }) + + it('does not treat a prefix-only folder match as internal', () => { + expect(violation({ kind: 'script', path: 'f/proj2/helper' })).toContain('escapes') + }) + + it('reports the first offending reference and passes a fully-contained set', () => { + expect( + refContainmentViolation( + [ + { kind: 'resource', path: 'f/proj/db' }, + { kind: 'script', path: 'hub/1/x/y' } + ], + folder + ) + ).toBeUndefined() + expect( + refContainmentViolation( + [ + { kind: 'resource', path: 'f/proj/db' }, + { kind: 'resource', path: 'u/admin/secret' } + ], + folder + ) + ).toContain('u/admin/secret') + }) +}) + +describe('varContainmentViolation', () => { + const folder = 'proj' + + it('allows in-folder variable references', () => { + expect(varContainmentViolation({ token: '$var:f/proj/token' }, folder)).toBeUndefined() + expect(varContainmentViolation({ x: 'no refs here' }, folder)).toBeUndefined() + }) + + it('rejects a `$var:` or `$jsonvar:` bound to another namespace', () => { + // The crux: a variable arg the ref extractors miss, resolved under the perms. + expect(varContainmentViolation({ queue_url: '$var:u/admin/token' }, folder)).toContain( + 'u/admin/token' + ) + expect(varContainmentViolation({ cfg: '$jsonvar:f/other/secret' }, folder)).toContain('escapes') + }) + + it('ignores a `$var:` literal embedded in inline code', () => { + const flowValue = { + flow_env: { API: '$var:f/proj/api_key' }, + modules: [{ value: { type: 'rawscript', content: 'return "$var:u/admin/should_not_flag"' } }] + } + expect(varContainmentViolation(flowValue, folder)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/projectInstall.ts b/frontend/src/lib/components/workspaceSettings/projectInstall.ts new file mode 100644 index 0000000000..1827e00283 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectInstall.ts @@ -0,0 +1,405 @@ +// Imports a Hub project export into a workspace: one importer per item kind, +// each item reported individually so one bad item never aborts the rest. +// UI-free — the install page owns folder choice and migration review. + +import { + AppService, + FlowService, + FolderService, + ResourceService, + ScriptService, + VariableService, + WorkspaceService +} from '$lib/gen' +import { + TRIGGER_KINDS, + createWorkspaceTriggerDisabled, + triggerHandlerRefs, + type WorkspaceTrigger, + type WorkspaceTriggerKind +} from '../triggers/workspaceTriggersList' +import { updatePolicy } from '$lib/components/apps/editor/appPolicy' +import { updateRawAppPolicy } from '$lib/sharedUtils' +import type { App } from '$lib/components/apps/types' +import { runScriptAndPollResult } from '$lib/components/jobs/utils' +import { + classifyPath, + collectExportVarPaths, + extractAppRefs, + extractFlowRefs, + extractRawAppRefs, + extractScriptRefs, + extractTriggerConfigResourceRefs, + extractVarRefsFromValue, + retargetProjectExport, + type ExportItem, + type ProjectExport, + type ProjectMigration, + type Ref +} from './projectBundle' + +export interface InstallResult { + path: string + ok: boolean + error?: string +} + +// Guarding an item's own path is not enough: the `$res:`/script/flow refs baked +// into its content are live bindings the backend acts on. A well-formed export +// relocates them all into f// (hub/ script refs stay external); anything +// else points a runnable at an existing asset in another namespace, so refuse the +// item rather than bind it there. Resources are never hub-hosted, so a hub/ path +// there is not a valid escape hatch. Mirrors the trigger-config containment. +export function refContainmentViolation(refs: Ref[], folder: string): string | undefined { + for (const r of refs) { + const cls = classifyPath(r.path, folder) + if (cls === 'internal') continue + if (cls === 'hub' && r.kind !== 'resource') continue + return `reference '${r.path}' escapes the target folder f/${folder}/ — skipped` + } + return undefined +} + +// `$var:`/`$jsonvar:` references (in flow static inputs, flow_env, app runnable +// inputs, trigger config) are resolved at runtime under the imported runnable's +// permissions and are never hub-hosted. Retargeting relocates a project's own refs +// into the target folder; anything still outside it points at another namespace, so +// reject those. Takes the parsed value so inline code carrying a literal is ignored. +export function varContainmentViolation(value: any, folder: string): string | undefined { + for (const p of extractVarRefsFromValue(value)) { + if (classifyPath(p, folder) !== 'internal') { + return `variable '${p}' escapes the target folder f/${folder}/ — skipped` + } + } + return undefined +} + +// Surface the backend's explanation: API errors carry the real message in +// `.body` (plain text for Windmill 4xx), while `.message` is the generic +// status text ("Bad Request"). Prefer the body so e.g. a path/route_path +// collision reads as the actual reason, not just "Bad Request". +function errorMessage(e: any): string { + const body = e?.body + if (typeof body === 'string' && body.trim() !== '') return body + if (body && typeof body === 'object') + return body.error?.message ?? body.message ?? JSON.stringify(body) + return e?.message ?? String(e) +} + +// Recompute an app's execution policy from its (retargeted) value, mirroring +// what the editor does on deploy. `triggerables_v2` is keyed by +// `:rawscript/`; retargeting rewrites that +// content, so a copied or empty policy would leave every inline runnable +// "forbidden by policy" at runtime. Default to publisher (auth required). +async function computeAppPolicy(value: any): Promise { + const policy = (await updatePolicy(value as App, undefined)) as any + if (!policy.execution_mode) policy.execution_mode = 'publisher' + return policy +} +async function computeRawAppPolicy(runnables: Record): Promise { + const policy = (await updateRawAppPolicy(runnables, undefined)) as any + if (!policy.execution_mode) policy.execution_mode = 'publisher' + return policy +} + +function importScript(workspace: string, s: ExportItem): Promise { + return ScriptService.createScript({ + workspace, + requestBody: { + path: s.path, + summary: s.summary ?? '', + description: s.description ?? '', + content: s.content ?? '', + language: s.language, + schema: s.schema ?? undefined, + kind: s.kind ?? 'script', + lock: s.lockfile ?? undefined + } + }) +} + +function importFlow(workspace: string, f: ExportItem): Promise { + return FlowService.createFlow({ + workspace, + requestBody: { + path: f.path, + summary: f.summary ?? '', + description: f.description ?? '', + value: f.value, + schema: f.schema ?? undefined + } + }) +} + +// Stubs only: never overwrite an existing resource's value (updateIfExists +// stays false so a path collision is reported as a failed item instead). +function importResourceStub(workspace: string, r: ExportItem): Promise { + return ResourceService.createResource({ + workspace, + updateIfExists: false, + requestBody: { + path: r.path, + resource_type: r.resource_type, + value: {}, + description: 'Imported stub — fill in the value.' + } + }) +} + +// Variables hold secrets/config, so their values are never shipped. Create an empty +// secret placeholder for a project variable the importer must fill, mirroring the +// resource stubs. Conflict-safe: an already-present variable (the importer filled it, +// or a re-import) is left untouched rather than clobbered. +async function importVariablePlaceholder(workspace: string, path: string): Promise { + if (await VariableService.existsVariable({ workspace, path })) return + await VariableService.createVariable({ + workspace, + requestBody: { + path, + value: '', + is_secret: true, + description: 'Imported placeholder — fill in the value.' + } + }) +} + +async function importApp(workspace: string, a: ExportItem): Promise { + if (a.app_type === 'raw') { + let parsed: any + try { + parsed = JSON.parse(a.value?.raw ?? '{}') + } catch (e: any) { + throw new Error(`invalid raw app bundle: ${e?.message ?? String(e)}`) + } + const files = { ...(parsed.files ?? {}) } + const js = files['/bundle.js'] ?? '' + const css = files['/bundle.css'] ?? '' + delete files['/bundle.js'] + delete files['/bundle.css'] + const runnables = parsed.runnables ?? {} + return AppService.createAppRaw({ + workspace, + formData: { + app: { + path: a.path, + summary: a.summary ?? '', + value: { + files, + runnables, + // Keep the full-code app's explicit data table declaration. + ...(parsed.data !== undefined ? { data: parsed.data } : {}), + ...(parsed.datatables !== undefined ? { datatables: parsed.datatables } : {}) + }, + policy: await computeRawAppPolicy(runnables) + }, + js, + css + } + }) + } + return AppService.createApp({ + workspace, + requestBody: { + path: a.path, + summary: a.summary ?? '', + value: a.value, + policy: await computeAppPolicy(a.value) + } + }) +} + +// Apply one migration to the target data table. If the data table opted into +// migrations, record it (datatable_migrations + _wm_migrations, run only this +// version); otherwise run the SQL once as a preview job (unrecorded). +async function applyOneMigration( + workspace: string, + projectSlug: string, + m: ProjectMigration +): Promise { + let recorded = false + try { + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName: m.datatable_name + }) + recorded = !!status.enabled + } catch {} + + if (recorded) { + // Record the shipped down migration (DROP the created tables) so it can be + // rolled back. + const codeDown = (m.sql_down ?? '').trim() + const created = await WorkspaceService.createDatatableMigration({ + workspace, + datatableName: m.datatable_name, + requestBody: { + name: `hub_import_${projectSlug}`, + code_up: m.sql, + code_down: codeDown || undefined + } + }) + await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName: m.datatable_name, + only: created.timestamp + }) + } else { + await runScriptAndPollResult({ + workspace, + requestBody: { + language: 'postgresql', + content: m.sql, + args: { database: `datatable://${m.datatable_name}` } + } + }) + } +} + +/** + * Install a project export into `workspace` under `f//`: create the + * folder, retarget every item, import kind by kind, then apply the (already + * reviewed) migrations. Each item's outcome is reported through `onResult`; + * failures never abort the remaining items. + */ +export async function installProject(args: { + workspace: string + exportData: ProjectExport + folder: string + migrations: ProjectMigration[] + hasEeLicense: boolean + onResult: (r: InstallResult) => void +}): Promise { + const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args + + const record = (path: string, p: Promise): Promise => + p.then( + () => onResult({ path, ok: true }), + (e: any) => onResult({ path, ok: false, error: errorMessage(e) }) + ) + + try { + await FolderService.createFolder({ workspace, requestBody: { name: folder } }) + } catch {} + + const proj = retargetProjectExport(exportData, exportData.project.slug, folder) + + // The export is remote input: every path it wants to write must stay inside + // the folder the user chose. Anything else (crafted export, or an export + // whose items weren't relocated into f// at publish) is refused + // per-item instead of being created in another namespace. + const prefix = `f/${folder}/` + const guard = (path: unknown, ...also: unknown[]): string | undefined => { + for (const p of [path, ...also]) { + if (typeof p !== 'string' || !p.startsWith(prefix)) { + return `path '${String(p)}' escapes the target folder ${prefix} — skipped` + } + } + return undefined + } + const checked = (path: unknown, run: () => Promise, ...also: unknown[]) => { + const violation = guard(path, ...also) + return violation + ? record(String(path), Promise.reject(new Error(violation))) + : record(String(path), run()) + } + + // `refs` catches structured runnable/`$res:` refs; `varValue` is the parsed item + // walked for `$var:`/`$jsonvar:` argument refs (which the ref extractors miss). + const checkedItem = (path: unknown, refs: Ref[], varValue: any, run: () => Promise) => { + const violation = + guard(path) ?? + refContainmentViolation(refs, folder) ?? + varContainmentViolation(varValue, folder) + return violation + ? record(String(path), Promise.reject(new Error(violation))) + : record(String(path), run()) + } + + for (const s of proj.scripts) { + // `$var:` is resolved in job args (flow inputs, schedule args, trigger config), + // not in script source, so there is no variable arg to contain here. + await checkedItem(s.path, extractScriptRefs(s.content ?? ''), undefined, () => + importScript(workspace, s) + ) + } + for (const f of proj.flows) { + await checkedItem(f.path, extractFlowRefs(f.value), f.value, () => importFlow(workspace, f)) + } + for (const r of proj.resources) { + await checked(r.path, () => importResourceStub(workspace, r)) + } + // Placeholders for the project's internal `$var:`/`$jsonvar:` refs (retargeted + // into this folder). External refs are rejected per-item, so only stub in-folder + // ones; guard again in case an out-of-folder ref slipped through retargeting. + for (const p of collectExportVarPaths(proj)) { + if (!p.startsWith(prefix)) continue + await record(`variable: ${p}`, importVariablePlaceholder(workspace, p)) + } + for (const a of proj.apps) { + const isRaw = a.app_type === 'raw' + const refs = isRaw ? extractRawAppRefs(a.value?.raw ?? '') : extractAppRefs(a.value) + // Raw apps hold their runnables in the `value.raw` JSON string; parse it so the + // walk sees the same structure the backend resolves. Malformed raw fails at import. + let varValue: any = a.value + if (isRaw) { + try { + varValue = JSON.parse(a.value?.raw ?? '{}') + } catch { + varValue = undefined + } + } + await checkedItem(a.path, refs, varValue, () => importApp(workspace, a)) + } + // A trigger's config is a live binding, not inert content: resource fields, + // handler runnables and $res: refs it names are acted on by the backend, so + // every one must stay inside the chosen folder (handlers may also point at + // hub/ scripts). Otherwise a crafted export could bind the trigger to + // existing assets in another namespace. + const triggerConfigViolation = (t: ExportItem): string | undefined => { + const cfg = (t.config ?? {}) as Record + for (const r of triggerHandlerRefs({ kind: t.kind, config: cfg } as WorkspaceTrigger)) { + if (!r.path.startsWith(prefix) && !r.path.startsWith('hub/')) { + return `handler '${r.path}' escapes the target folder ${prefix} — skipped` + } + } + const resourceRefs = new Set(extractTriggerConfigResourceRefs(cfg)) + const field = TRIGGER_KINDS[t.kind as WorkspaceTriggerKind]?.resourceField + const fieldValue = field ? cfg[field] : undefined + if (typeof fieldValue === 'string' && fieldValue !== '') resourceRefs.add(fieldValue) + for (const p of resourceRefs) { + if (!p.startsWith(prefix)) { + return `resource '${p}' escapes the target folder ${prefix} — skipped` + } + } + // Config fields (e.g. SQS queue_url) can carry `$var:`/`$jsonvar:` refs too. + return varContainmentViolation(cfg, folder) + } + for (const t of proj.triggers) { + const violation = guard(t.path, t.runnable_path) ?? triggerConfigViolation(t) + await record( + String(t.path), + violation + ? Promise.reject(new Error(violation)) + : createWorkspaceTriggerDisabled( + workspace, + { + kind: t.kind, + path: t.path, + script_path: t.runnable_path, + is_flow: t.runnable_kind === 'flow', + summary: t.summary ?? null, + config: t.config ?? null + }, + { hasEeLicense } + ) + ) + } + + // Apply the reviewed data table migrations after items exist. + for (const m of migrations) { + await record( + `data table: ${m.datatable_name}`, + applyOneMigration(workspace, exportData.project.slug, m) + ) + } +} diff --git a/frontend/src/lib/components/workspaceSettings/projectMigrations.test.ts b/frontend/src/lib/components/workspaceSettings/projectMigrations.test.ts new file mode 100644 index 0000000000..7d35c51650 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectMigrations.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// inferAssets loads WASM; stub it so script detection is deterministic and no +// wasm init runs in the test. +const inferAssetsMock = vi.fn() +vi.mock('$lib/infer', () => ({ inferAssets: (...a: any[]) => inferAssetsMock(...a) })) + +// Only getDatatableFullSchema is used by the generator; stub the whole service. +const getDatatableFullSchemaMock = vi.fn() +vi.mock('$lib/gen', () => ({ + WorkspaceService: { + getDatatableFullSchema: (...a: any[]) => getDatatableFullSchemaMock(...a) + } +})) + +import { detectDatatableTables, generateDatatableMigrations } from './projectMigrations' +import type { FetchedItem } from './projectBundle' + +describe('detectDatatableTables', () => { + beforeEach(() => inferAssetsMock.mockReset()) + + it('collects datatable/table refs from scripts (re-parsed), flows and raw apps', async () => { + inferAssetsMock.mockResolvedValue({ + status: 'ok', + assets: [ + { kind: 'datatable', path: 'main/customers' }, + { kind: 'resource', path: 'u/admin/pg' } // ignored + ] + }) + const items: FetchedItem[] = [ + { kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'select 1' }, + { + kind: 'flow', + path: 'f/p/fl', + value: { + modules: [ + { + id: 'a', + value: { + type: 'rawscript', + language: 'duckdb', + content: '', + assets: [{ kind: 'datatable', path: 'main/orders' }] + } + } + ] + } + }, + { + kind: 'raw_app', + path: 'f/p/app', + content: JSON.stringify({ + runnables: { + r1: { inlineScript: { assets: [{ kind: 'datatable', path: 'analytics/events' }] } } + } + }) + } + ] + const usage = await detectDatatableTables(items) + expect([...(usage.get('main') ?? [])].sort()).toEqual(['customers', 'orders']) + expect([...(usage.get('analytics') ?? [])]).toEqual(['events']) + }) + + it('collects datatable refs from the preprocessor module', async () => { + inferAssetsMock.mockResolvedValue({ status: 'ok', assets: [] }) + const items: FetchedItem[] = [ + { + kind: 'flow', + path: 'f/p/fl', + value: { + modules: [], + preprocessor_module: { + id: 'pre', + value: { + type: 'rawscript', + language: 'duckdb', + content: '', + assets: [{ kind: 'datatable', path: 'main/inbox' }] + } + } + } + } + ] + const usage = await detectDatatableTables(items) + expect([...(usage.get('main') ?? [])]).toEqual(['inbox']) + }) + + it('records a datatable used with no specific table', async () => { + inferAssetsMock.mockResolvedValue({ + status: 'ok', + assets: [{ kind: 'datatable', path: 'main' }] + }) + const usage = await detectDatatableTables([ + { kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'x' } + ]) + expect(usage.has('main')).toBe(true) + expect(usage.get('main')?.size).toBe(0) + }) + + it('reads a full-code app’s explicit data.tables declaration', async () => { + const items: FetchedItem[] = [ + { + kind: 'raw_app', + path: 'f/p/app', + content: JSON.stringify({ + runnables: {}, + data: { + datatable: 'main', + schema: 'app1', + tables: ['main/customers', 'main/app1:orders'] + } + }) + } + ] + const usage = await detectDatatableTables(items) + // public-schema ref keeps the bare name; non-public keeps schema.table. + expect([...(usage.get('main') ?? [])].sort()).toEqual(['app1.orders', 'customers']) + }) +}) + +describe('generateDatatableMigrations', () => { + beforeEach(() => getDatatableFullSchemaMock.mockReset()) + + const schema = { + public: { + customers: { + name: 'customers', + columns: [ + { name: 'id', datatype: 'integer', primary_key: true, nullable: false }, + { name: 'email', datatype: 'text', nullable: true } + ], + foreign_keys: [] + }, + orders: { + name: 'orders', + columns: [ + { name: 'id', datatype: 'integer', primary_key: true, nullable: false }, + { name: 'customer_id', datatype: 'integer', nullable: false } + ], + foreign_keys: [ + { + target_table: 'public.customers', + columns: [{ source_column: 'customer_id', target_column: 'id' }], + on_delete: 'NO ACTION', + on_update: 'NO ACTION' + } + ] + } + } + } + + it('creates referenced tables in FK-dependency order in one transaction, enabled', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['orders', 'customers'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations).toHaveLength(1) + const m = migrations[0] + expect(m.datatable_name).toBe('main') + expect(m.enabled).toBe(true) + expect(m.sql.startsWith('BEGIN;')).toBe(true) + expect(m.sql.trimEnd().endsWith('COMMIT;')).toBe(true) + // customers (FK target) must be created before orders (FK source). + expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"')) + // A single wrapping transaction, not one per table. + expect(m.sql.match(/BEGIN;/g)?.length).toBe(1) + // Idempotent: won't abort if a pulled-in parent already exists in the target. + expect(m.sql).toContain('CREATE TABLE IF NOT EXISTS "public"."customers"') + // Down migration lists drops commented out (nothing dropped by default), + // in reverse order: orders (child) before customers (parent). + expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."orders";') + expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."customers";') + // No uncommented DROP TABLE anywhere. + expect(/^\s*DROP TABLE/m.test(m.sql_down)).toBe(false) + expect(m.sql_down.indexOf('"public"."orders"')).toBeLessThan( + m.sql_down.indexOf('"public"."customers"') + ) + }) + + it('accepts schema-qualified table refs', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['public.customers'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].enabled).toBe(true) + expect(migrations[0].sql).toContain('"public"."customers"') + }) + + it('leaves a qualified ref unresolved when its schema misses, never another schema\'s table', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['sales.orders'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].sql).toContain('"sales.orders" is referenced but was not found') + expect(migrations[0].sql).not.toContain('CREATE TABLE "') + }) + + it('emits all CREATE TABLEs before any FK constraint so circular FKs work', async () => { + const cyclicSchema = { + public: { + a: { + name: 'a', + columns: [ + { name: 'id', datatype: 'integer', primary_key: true, nullable: false }, + { name: 'b_id', datatype: 'integer', nullable: true } + ], + foreign_keys: [ + { + target_table: 'public.b', + columns: [{ source_column: 'b_id', target_column: 'id' }], + on_delete: 'NO ACTION', + on_update: 'NO ACTION' + } + ] + }, + b: { + name: 'b', + columns: [ + { name: 'id', datatype: 'integer', primary_key: true, nullable: false }, + { name: 'a_id', datatype: 'integer', nullable: true } + ], + foreign_keys: [ + { + target_table: 'public.a', + columns: [{ source_column: 'a_id', target_column: 'id' }], + on_delete: 'NO ACTION', + on_update: 'NO ACTION' + } + ] + } + } + } + getDatatableFullSchemaMock.mockResolvedValue(cyclicSchema) + const usage = new Map([['main', new Set(['a', 'b'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + const sql = migrations[0].sql + expect(sql).toContain('"public"."a"') + expect(sql).toContain('"public"."b"') + // Both FK constraints present, and every CREATE TABLE precedes the first one. + expect(sql.match(/ADD CONSTRAINT/g)?.length).toBe(2) + const lastCreate = sql.lastIndexOf('CREATE TABLE IF NOT EXISTS') + const firstConstraint = sql.indexOf('DO $$') + expect(lastCreate).toBeGreaterThan(-1) + expect(firstConstraint).toBeGreaterThan(lastCreate) + }) + + it('guards FK creation so re-running on an existing table does not abort', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['orders'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + const sql = migrations[0].sql + // The ADD CONSTRAINT must be wrapped in a pg_constraint existence check. + expect(sql).toContain('DO $$') + expect(sql).toContain('SELECT 1 FROM pg_constraint') + expect(sql).toContain(`conrelid = '"public"."orders"'::regclass`) + // No unguarded ALTER TABLE ... ADD at the start of a line. + expect(/^ALTER TABLE .* ADD CONSTRAINT/m.test(sql)).toBe(false) + }) + + it('creates non-public schemas before their tables', async () => { + const appSchema = { + app: { + customers: { + name: 'customers', + columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }], + foreign_keys: [] + } + } + } + getDatatableFullSchemaMock.mockResolvedValue(appSchema) + const usage = new Map([['main', new Set(['app.customers'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + const sql = migrations[0].sql + expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS "app";') + expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "app";')).toBeLessThan( + sql.indexOf('CREATE TABLE IF NOT EXISTS "app"."customers"') + ) + expect(sql).not.toContain('CREATE SCHEMA IF NOT EXISTS "public"') + }) + + it('keeps same-named tables from different schemas both created', async () => { + const twoSchemas = { + public: { + customers: { + name: 'customers', + columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }], + foreign_keys: [] + } + }, + app: { + customers: { + name: 'customers', + columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }], + foreign_keys: [] + } + } + } + getDatatableFullSchemaMock.mockResolvedValue(twoSchemas) + const usage = new Map([['main', new Set(['public.customers', 'app.customers'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].sql).toContain('"public"."customers"') + expect(migrations[0].sql).toContain('"app"."customers"') + }) + + it('transitively pulls in FK-referenced tables not directly used', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + // Only `orders` is referenced; `customers` (its FK target) must still be + // created, and before `orders`. + const usage = new Map([['main', new Set(['orders'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + const m = migrations[0] + expect(m.enabled).toBe(true) + expect(m.sql).toContain('"public"."customers"') + expect(m.sql).toContain('"public"."orders"') + expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"')) + }) + + it('drops a foreign key whose target is not in the schema', async () => { + // `orders` references a `warehouses` table that no longer exists in the + // schema: the FK must be pruned so the migration still runs. + const schemaWithDanglingFk = { + public: { + orders: { + name: 'orders', + columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }], + foreign_keys: [ + { + target_table: 'public.warehouses', + columns: [{ source_column: 'id', target_column: 'id' }], + on_delete: 'NO ACTION', + on_update: 'NO ACTION' + } + ] + } + } + } + getDatatableFullSchemaMock.mockResolvedValue(schemaWithDanglingFk) + const usage = new Map([['main', new Set(['orders'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].enabled).toBe(true) + expect(migrations[0].sql).toContain('"public"."orders"') + expect(migrations[0].sql).not.toContain('warehouses') + }) + + it('emits a disabled comment entry when a referenced table is not found', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['nonexistent'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations).toHaveLength(1) + expect(migrations[0].enabled).toBe(false) + expect(migrations[0].sql).toContain('-- Table "nonexistent" is referenced but was not found') + expect(migrations[0].sql).not.toContain('BEGIN;') + }) + + it('keeps found tables and comments the missing ones in one migration', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set(['customers', 'ghost'])]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].enabled).toBe(true) + expect(migrations[0].sql).toContain('"public"."customers"') + expect(migrations[0].sql).toContain('-- Table "ghost" is referenced but was not found') + // Comments precede the runnable transaction. + expect(migrations[0].sql.indexOf('-- Table "ghost"')).toBeLessThan( + migrations[0].sql.indexOf('BEGIN;') + ) + }) + + it('comments a data table used with no specific table', async () => { + getDatatableFullSchemaMock.mockResolvedValue(schema) + const usage = new Map([['main', new Set()]]) + const migrations = await generateDatatableMigrations('ws', usage) + expect(migrations[0].enabled).toBe(false) + expect(migrations[0].sql).toContain('no specific table was referenced') + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/projectMigrations.ts b/frontend/src/lib/components/workspaceSettings/projectMigrations.ts new file mode 100644 index 0000000000..b4e72389c0 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/projectMigrations.ts @@ -0,0 +1,345 @@ +// Best-effort data table migration generation for the "project = folder" Hub +// bundle. Detects which data tables (and tables within them) a project's +// scripts/flows/raw apps reference via `datatable` assets, then generates a +// `CREATE TABLE` bundle per data table from the source workspace's live schema, +// so importing the project into another workspace can recreate those tables. +// +// Best-effort by design: the generated SQL is shown to the publisher and is +// fully editable before publishing. Low-code (non-raw) apps have no persisted +// asset list and are not scanned. + +import { inferAssets } from '$lib/infer' +import type { SupportedLanguage } from '$lib/common' +import { allFlowModules } from './projectBundle' +import { getFlowModuleAssets } from '$lib/components/assets/lib' +import { extractDataConfig, parseDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils' +import { + apiSchemaToEditorSchema, + generateAddedTableSql, + type DatabaseSchema +} from '$lib/components/datatableSchemaSql' +import { WorkspaceService } from '$lib/gen' +import type { FetchedItem } from './projectBundle' + +export interface GeneratedMigration { + datatable_name: string + /** Up migration: creates the tables. */ + sql: string + /** Down migration: drops the created tables. Best-effort, generated once and + * editable by the publisher (not re-derived from `sql`). */ + sql_down: string + enabled: boolean +} + +// A datatable asset path is `datatable`, `datatable/table`, or +// `datatable/schema.table` (see the SQL asset parser). The first segment is the +// data table name; the remainder identifies a specific table (absent = whole +// data table, no table to create). +function parseDatatableAssetPath(path: string): { datatable: string; table?: string } { + const slash = path.indexOf('/') + if (slash === -1) return { datatable: path } + const datatable = path.slice(0, slash) + const table = path.slice(slash + 1).trim() + return { datatable, table: table || undefined } +} + +function addDatatableTable( + map: Map>, + datatable: string, + table: string | undefined +): void { + if (!datatable) return + const set = map.get(datatable) ?? new Set() + if (table) set.add(table) + map.set(datatable, set) +} + +function addUsage(map: Map>, path: string): void { + const { datatable, table } = parseDatatableAssetPath(path) + addDatatableTable(map, datatable, table) +} + +/** + * Scan a project's fetched items for data table usage and return + * `datatable -> set of table refs` (a table ref is `table` or `schema.table`). + * - scripts: re-parse the code with the asset parser (`inferAssets`) + * - flows: read each module's stored `assets` + * - full-code (raw) apps: read the explicit `data.tables` declaration; fall back + * to `runnables[key].inlineScript.assets` for older apps + */ +export async function detectDatatableTables( + items: FetchedItem[] +): Promise>> { + const map = new Map>() + + for (const item of items) { + if (item.kind === 'script') { + const res = await inferAssets( + item.language as SupportedLanguage | undefined, + item.content ?? '' + ) + if (res.status === 'ok') { + for (const a of res.assets) if (a.kind === 'datatable') addUsage(map, a.path) + } + } else if (item.kind === 'flow') { + for (const mod of allFlowModules(item.value)) { + const assets = getFlowModuleAssets(mod) + if (assets) for (const a of assets) if (a.kind === 'datatable') addUsage(map, a.path) + } + } else if (item.kind === 'raw_app') { + let parsed: any + try { + parsed = JSON.parse(item.content ?? '{}') + } catch { + continue + } + // Full-code apps explicitly declare the data tables/tables they use + // (`data.tables`, refs like `main/customers` or `main/schema:table`), so + // read that rather than parsing assets. + const config = extractDataConfig(parsed) + if (config) { + for (const ref of config.tables) { + const r = parseDataTableRef(ref) + const table = r.table + ? r.schema && r.schema !== 'public' + ? `${r.schema}.${r.table}` + : r.table + : undefined + addDatatableTable(map, r.datatable, table) + } + } + // Older raw apps instead carry datatable usage as inline-script assets. + const runnables = parsed?.runnables ?? {} + for (const key of Object.keys(runnables)) { + const assets = runnables[key]?.inlineScript?.assets + if (Array.isArray(assets)) + for (const a of assets) + if (a?.kind === 'datatable' && typeof a.path === 'string') addUsage(map, a.path) + } + } + } + return map +} + +// Resolve a table ref (`table` or `schema.table`) to a concrete +// `{ schemaName, tableName }` present in the live schema, or undefined if the +// table can't be found (dropped since, typo, …). A schema-qualified ref that +// misses stays unresolved: falling back to a same-named table in another +// schema would generate a migration for an unrelated table while the code +// still references the missing one. +function resolveTable( + schema: DatabaseSchema, + tableRef: string +): { schemaName: string; tableName: string } | undefined { + const dot = tableRef.indexOf('.') + if (dot !== -1) { + const schemaName = tableRef.slice(0, dot) + const tableName = tableRef.slice(dot + 1) + return schema[schemaName]?.[tableName] ? { schemaName, tableName } : undefined + } + // Bare name: find it across every schema, first match wins. + for (const schemaName of Object.keys(schema)) { + if (schema[schemaName][tableRef]) return { schemaName, tableName: tableRef } + } + return undefined +} + +type ResolvedTable = { schemaName: string; tableName: string } + +const tableKey = (t: ResolvedTable) => `${t.schemaName}.${t.tableName}` + +// Grow the set of tables to create so it's closed under foreign keys: a used +// table's FK targets (and their FK targets, transitively) are pulled in, so the +// generated CREATE TABLEs never reference a table that isn't also created. FK +// targets that don't resolve in this schema are left out (their FK is pruned by +// pruneSchemaForTables). +function expandFkClosure(schema: DatabaseSchema, seed: ResolvedTable[]): ResolvedTable[] { + const inSet = new Map(seed.map((t) => [tableKey(t), t])) + const queue = [...seed] + while (queue.length > 0) { + const t = queue.shift()! + const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? [] + for (const fk of fks) { + const target = resolveTable(schema, fk.targetTable ?? '') + if (target && !inSet.has(tableKey(target))) { + inSet.set(tableKey(target), target) + queue.push(target) + } + } + } + return [...inSet.values()] +} + +// A copy of the schema restricted to `tables`, with each table's foreign keys +// filtered to targets that are also in `tables`. generateAddedTableSql emits every +// FK it finds on a table, so pruning here keeps a stray FK (to a table outside the +// migration) from making the generated SQL fail. +function pruneSchemaForTables(schema: DatabaseSchema, tables: ResolvedTable[]): DatabaseSchema { + const inSet = new Set(tables.map(tableKey)) + const pruned: DatabaseSchema = {} + for (const t of tables) { + const orig = schema[t.schemaName]?.[t.tableName] + if (!orig) continue + ;(pruned[t.schemaName] ??= {})[t.tableName] = { + ...orig, + foreignKeys: (orig.foreignKeys ?? []).filter((fk) => { + const target = resolveTable(schema, fk.targetTable ?? '') + return target != null && inSet.has(tableKey(target)) + }) + } + } + return pruned +} + +// Order tables so a table is created after the in-set tables it references via a +// foreign key. Keyed by schema-qualified name (like the rest of the pipeline) so +// two same-named tables in different schemas aren't collapsed. Falls back to input +// order on a cycle so generation never hangs. +function orderByFkDependency(schema: DatabaseSchema, tables: ResolvedTable[]): ResolvedTable[] { + const inSet = new Set(tables.map(tableKey)) + const deps = new Map>() + for (const t of tables) { + const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? [] + const targets = new Set() + for (const fk of fks) { + const target = resolveTable(schema, fk.targetTable ?? '') + if (target && tableKey(target) !== tableKey(t) && inSet.has(tableKey(target))) { + targets.add(tableKey(target)) + } + } + deps.set(tableKey(t), targets) + } + const ordered: ResolvedTable[] = [] + const done = new Set() + const visiting = new Set() + const byKey = new Map(tables.map((t) => [tableKey(t), t])) + const visit = (key: string) => { + if (done.has(key) || visiting.has(key)) return + visiting.add(key) + for (const dep of deps.get(key) ?? []) visit(dep) + visiting.delete(key) + done.add(key) + const t = byKey.get(key) + if (t) ordered.push(t) + } + for (const t of tables) visit(tableKey(t)) + return ordered +} + +// Pull a readable one-line message out of an API error for embedding in a SQL +// comment (collapse whitespace so it can't break out of the `--` line). +function errorText(e: any): string { + const body = e?.body + const raw = + typeof body === 'string' && body.trim() + ? body + : body && typeof body === 'object' + ? (body.error?.message ?? body.message ?? JSON.stringify(body)) + : (e?.message ?? String(e)) + return String(raw).replace(/\s+/g, ' ').trim() +} + +/** + * Generate one best-effort migration per used data table. Resolved tables (plus + * the tables they depend on via foreign key, in FK-dependency order) become a + * single CREATE TABLE transaction, enabled by default. Anything that couldn't be + * auto-generated — a table not found in the schema, a data table referenced as a + * whole, or a schema that couldn't be loaded — is written as a `--` SQL comment + * describing the problem, so the publisher sees what's missing instead of a blank + * entry. A migration with no runnable statements (only comments) is left disabled. + */ +export async function generateDatatableMigrations( + workspace: string, + usage: Map> +): Promise { + const out: GeneratedMigration[] = [] + for (const [datatable, tableRefs] of usage) { + let schema: DatabaseSchema + try { + const api = await WorkspaceService.getDatatableFullSchema({ + workspace, + requestBody: { source: `datatable://${datatable}` } + }) + schema = apiSchemaToEditorSchema(api) + } catch (e) { + // Couldn't reach the schema at all: leave a commented stub explaining why, + // so the publisher can fill it in rather than seeing a silent blank. + out.push({ + datatable_name: datatable, + sql: + `-- Could not load the schema of data table "${datatable}": ${errorText(e)}\n` + + `-- Add the CREATE TABLE statement(s) for the tables this project uses.`, + sql_down: '', + enabled: false + }) + continue + } + // Resolve the referenced tables; record a comment for each one we can't find + // so a partial migration still explains what's missing. + const resolved: ResolvedTable[] = [] + const comments: string[] = [] + for (const ref of tableRefs) { + const t = resolveTable(schema, ref) + if (t) resolved.push(t) + else + comments.push( + `-- Table "${ref}" is referenced but was not found in data table "${datatable}"; add its CREATE TABLE manually.` + ) + } + if (tableRefs.size === 0) { + comments.push( + `-- Data table "${datatable}" is used but no specific table was referenced; nothing to generate automatically.` + ) + } + // Pull in the tables the referenced ones depend on via FK, then generate + // against a schema whose FKs are restricted to this set, so the migration + // creates everything it references and never emits a dangling FK. + const closure = expandFkClosure(schema, resolved) + const ordered = orderByFkDependency(schema, closure) + const prunedSchema = pruneSchemaForTables(schema, ordered) + // Every CREATE TABLE is emitted before any FK constraint: circular FKs have + // no valid creation order, so constraints can only run once all tables exist. + const creates: string[] = [] + const constraints: string[] = [] + for (const t of ordered) { + // IF NOT EXISTS: FK closure pulls in shared parent tables (e.g. a + // referenced `orders` drags in `customers`) that often already exist in + // the target, so a plain CREATE would abort the whole transaction. The + // caveat — an existing differently-shaped table is silently left as-is — + // is acceptable for a best-effort, editable migration. + const gen = generateAddedTableSql( + { schemaName: t.schemaName, tableName: t.tableName, kind: 'added' }, + prunedSchema, + { ifNotExists: true } + ) + if (!gen) continue + creates.push(gen.create) + constraints.push(...gen.constraints) + } + const statements = [...creates, ...constraints] + // Comments (the errors) go on top; the CREATE TABLE transaction, if any, + // follows. Enabled only when there's something to run. + const parts: string[] = [] + if (comments.length > 0) parts.push(comments.join('\n')) + if (statements.length > 0) parts.push(`BEGIN;\n${statements.join('\n\n')}\nCOMMIT;`) + // Best-effort down migration: the DROP TABLE statements are commented out + // because the FK closure pulls in shared parent tables that may have + // pre-existed in the target (dropping them would lose data the project never + // created). The publisher uncomments the tables this migration should drop. + const drops = [...ordered] + .reverse() + .map((t) => `-- DROP TABLE IF EXISTS "${t.schemaName}"."${t.tableName}";`) + const sqlDown = + drops.length > 0 + ? `-- Rollback: uncomment the tables this migration should drop (leave shared\n` + + `-- tables that already existed in the workspace commented out).\nBEGIN;\n${drops.join('\n')}\nCOMMIT;` + : '' + out.push({ + datatable_name: datatable, + sql: parts.join('\n\n'), + sql_down: sqlDown, + enabled: statements.length > 0 + }) + } + return out.sort((a, b) => a.datatable_name.localeCompare(b.datatable_name)) +} diff --git a/frontend/src/routes/(root)/(logged)/folders/+page.svelte b/frontend/src/routes/(root)/(logged)/folders/+page.svelte index 42de9d38a5..343e7a3e48 100644 --- a/frontend/src/routes/(root)/(logged)/folders/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/folders/+page.svelte @@ -14,7 +14,8 @@ import { sendUserToast } from '$lib/utils' import DataTable from '$lib/components/table/DataTable.svelte' import Cell from '$lib/components/table/Cell.svelte' - import { Pen, Trash, Plus } from 'lucide-svelte' + import { Pen, Trash, Plus, UploadCloud } from 'lucide-svelte' + import DeployToHub from '$lib/components/workspaceSettings/DeployToHub.svelte' import Head from '$lib/components/table/Head.svelte' import Row from '$lib/components/table/Row.svelte' import Badge from '$lib/components/common/badge/Badge.svelte' @@ -30,6 +31,8 @@ let newFolderName: string = $state('') let folders: FolderW[] | undefined = $state(undefined) let folderDrawer: Drawer | undefined = $state() + let hubDrawer: Drawer | undefined = $state() + let publishFolderName: string = $state('') async function loadFolders(): Promise { folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => { @@ -88,6 +91,22 @@ + + { + hubDrawer?.closeDrawer() + publishFolderName = '' + }} + > + {#if publishFolderName} + {#key publishFolderName} + + {/key} + {/if} + + + {#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.folders}
+ + + {#each selectedAssetSample.columns as c} + + {/each} + + + + {#each selectedAssetSample.rows as row} + + {#each selectedAssetSample.columns as c} + + {/each} + + {/each} + +
+ {c.field} + {#if c.datatype}· {c.datatype}{/if} +
+ {fmtCell((row as any)?.[c.field])} +
+ + {/if} + + {/if} + + + {/if} + + + diff --git a/frontend/src/lib/components/recording/pipelineAssetSample.ts b/frontend/src/lib/components/recording/pipelineAssetSample.ts new file mode 100644 index 0000000000..a5b9cf6456 --- /dev/null +++ b/frontend/src/lib/components/recording/pipelineAssetSample.ts @@ -0,0 +1,77 @@ +import type { AssetKind } from '$lib/gen' +import { parseDbInputFromAssetSyntax } from '$lib/utils' +import { loadAllTablesMetaData } from '$lib/components/apps/components/display/dbtable/metadata' +import { dbTableOpsWithPreviewScripts } from '$lib/components/dbOps' +import type { PipelineAssetSample } from './types' + +// How many rows to sample per asset — a preview, not a dump. +const SAMPLE_LIMIT = 100 + +/** + * Capture a data-sample of a pipeline asset for the recorder, reusing the exact + * same query path the live asset-preview panes use (`loadAllTablesMetaData` + + * `dbTableOpsWithPreviewScripts.getRows`), so a replayed sample matches what the + * pane would have shown. Only ducklake / datatable assets are sampleable this + * way (s3object files use a different preview); other kinds return an error + * marker the player renders as "no sample". + * + * Never throws — a failed capture (missing table, unconfigured datatable) is + * returned as a `PipelineAssetSample` with `error` set so the recording still + * completes. + */ +export async function capturePipelineAssetSample( + workspace: string, + kind: AssetKind, + path: string +): Promise { + const uri = `${kind}://${path}` + const base: PipelineAssetSample = { kind, path, uri, columns: [], rows: [] } + if (kind !== 'ducklake' && kind !== 'datatable') { + return { ...base, error: `no sample for ${kind} assets` } + } + try { + const input = parseDbInputFromAssetSyntax(uri) + if (!input) return { ...base, error: 'could not parse asset uri' } + const table = 'specificTable' in input ? (input.specificTable as string | undefined) : undefined + const schema = + 'specificSchema' in input ? (input.specificSchema as string | undefined) : undefined + if (!table) return { ...base, error: 'asset uri has no table' } + + const defs = await loadAllTablesMetaData(workspace, input) + if (!defs) return { ...base, error: 'table metadata unavailable' } + + // Same table-key resolution as the ducklake/datatable preview panes: + // try `schema.table` then bare `table`, then any key ending in `.table`. + const defaultSchema = kind === 'ducklake' ? 'main' : 'public' + const colDefs = + defs[`${schema ?? defaultSchema}.${table}`] ?? + defs[table] ?? + (() => { + const key = Object.keys(defs).find((k) => k === table || k.endsWith(`.${table}`)) + return key ? defs[key] : undefined + })() + if (!colDefs) return { ...base, error: 'table does not exist yet' } + + const tableKey = schema && table ? `${schema}.${table}` : table + const ops = dbTableOpsWithPreviewScripts({ input, tableKey, colDefs, workspace }) + const rows = await ops.getRows({ + offset: 0, + limit: SAMPLE_LIMIT, + quicksearch: '', + order_by: '', + is_desc: false + }) + let rowCount: number | undefined + try { + rowCount = await ops.getCount({ quicksearch: '' }) + } catch { + // count is best-effort — the sample rows are the important part + } + const columns = colDefs + .filter((c) => c.field) + .map((c) => ({ field: c.field as string, datatype: (c as any).datatype })) + return { ...base, columns, rows: rows.slice(0, SAMPLE_LIMIT), rowCount } + } catch (e) { + return { ...base, error: e instanceof Error ? e.message : String(e) } + } +} diff --git a/frontend/src/lib/components/recording/pipelineRecording.svelte.ts b/frontend/src/lib/components/recording/pipelineRecording.svelte.ts new file mode 100644 index 0000000000..557a3636ec --- /dev/null +++ b/frontend/src/lib/components/recording/pipelineRecording.svelte.ts @@ -0,0 +1,335 @@ +import { JobService, ScriptService, type Job } from '$lib/gen' +import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types' +import { runBoundedCascade } from '$lib/components/assets/AssetGraph/cascadeRun' +import type { + CascadeNodeState, + CascadeRunResult +} from '$lib/components/assets/AssetGraph/cascadeOrchestrator' +import { truncateUuids } from './flowRecording.svelte' +import { capturePipelineAssetSample } from './pipelineAssetSample' +import type { + PipelineAssetSample, + PipelineRecordedCode, + PipelineRecording, + PipelineTimelineFrame, + RecordedJob, + RecordedNodeState +} from './types' + +/** + * Recorder for a data-pipeline cascade run. Unlike the flow/script recorders + * there is no single root job streaming sub-jobs over SSE — a pipeline run is a + * cascade of independent script jobs launched client-side and polled to + * completion. So this store captures two things: + * + * 1. the resolved asset graph (rendered read-only by the player), and + * 2. a timeline of per-node status snapshots (from the cascade orchestrator's + * `onUpdate`), each node mapped to its job id. + * + * For each launched node it opens the job's own SSE stream (`watchJob`) to + * capture incremental logs/result, storing them in the shared `RecordedJob` + * shape so the player can replay each node's details through the same + * `JobLoader` replay path the flow/script players use. + */ +export function createPipelineRecording(): PipelineRecordingStore { + let active = $state(false) + let startTime = 0 + let folder = '' + let graph: AssetGraphResponse | undefined = undefined + let timeline: PipelineTimelineFrame[] = [] + let jobs: Record = {} + let assetSamples: Record = {} + let codes: Record = {} + let watchedJobs = new Set() + let jobSources: EventSource[] = [] + + function closeSources() { + jobSources.forEach((es) => es.close()) + jobSources = [] + watchedJobs.clear() + } + + return { + get active() { + return active + }, + start(f: string, g: AssetGraphResponse) { + closeSources() + active = true + startTime = Date.now() + folder = f + // JSON round-trip to strip reactive proxies / non-serializable props. + graph = JSON.parse(JSON.stringify(g)) as AssetGraphResponse + timeline = [] + jobs = {} + assetSamples = {} + codes = {} + }, + /** Push a cascade status snapshot. Deep-cloned so a later mutation of the + * orchestrator's map can't rewrite an already-captured frame. */ + recordStatuses(statuses: Map) { + if (!active) return + const snapshot: Record = {} + for (const [path, st] of statuses) { + snapshot[path] = { status: st.status, jobId: st.jobId, error: st.error } + } + timeline.push({ t: Date.now() - startTime, statuses: snapshot }) + }, + /** Watch a launched node's SSE stream to capture its incremental + * logs/result. Mirrors flowRecording.watchSubJob's log-offset dedup. */ + watchJob(jobId: string, workspace: string) { + if (!active || watchedJobs.has(jobId)) return + watchedJobs.add(jobId) + + let logOffset = 0 + const params = new URLSearchParams({ + log_offset: '0', + running: 'true', + fast: 'true' + }) + const url = `/api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?${params}` + const es = new EventSource(url) + jobSources.push(es) + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'ping' || data.type === 'timeout') return + if (data.type === 'error' || data.type === 'not_found') { + es.close() + return + } + if (!active) { + es.close() + return + } + + // Deduplicate log data: SSE may resend full log dumps on reconnect. + if (data.new_logs != null && data.log_offset != null) { + if (logOffset > 0 && data.log_offset <= logOffset) { + delete data.new_logs + delete data.log_offset + } else { + logOffset = data.log_offset + } + } else if (data.log_offset != null && data.log_offset > logOffset) { + logOffset = data.log_offset + } + + if (!jobs[jobId]) { + jobs[jobId] = { + initial_job: data.job ? (data.job as Job) : ({ id: jobId } as Job), + events: [] + } + } + jobs[jobId].events.push({ + t: Date.now() - startTime, + data + }) + if (data.completed) { + es.close() + } + } catch { + // Ignore parse errors + } + } + es.onerror = () => { + es.close() + } + }, + /** Fill in a node's completed job (fallback for anything the SSE stream + * missed — e.g. a job that finished before its stream was opened). + * Callable after stop() so late-fetched completed jobs still attach. */ + addCompletedJob(jobId: string, completedJob: Job) { + const snapshotJob = $state.snapshot(completedJob) as Job + if (!jobs[jobId]) { + jobs[jobId] = { initial_job: snapshotJob, events: [] } + } else if (!jobs[jobId].initial_job?.id) { + jobs[jobId].initial_job = snapshotJob + } + const hasCompleted = jobs[jobId].events.some((e) => e.data.completed) + if (!hasCompleted) { + jobs[jobId].events.push({ + t: Date.now() - startTime, + data: { completed: true, job: snapshotJob } + }) + } + }, + /** Attach a captured asset data-sample (called during finalize, after + * the run, for each ducklake/datatable asset). Keyed by `${kind}:${path}`. + * Callable after stop() so late captures still attach to the returned + * recording (which references the same `assetSamples` object). */ + recordAssetSample(sample: PipelineAssetSample) { + assetSamples[`${sample.kind}:${sample.path}`] = sample + }, + /** Attach a runnable's source (called during finalize, per script path). + * Callable after stop() so late captures still attach to the returned + * recording (which references the same `codes` object). */ + recordCode(path: string, code: PipelineRecordedCode) { + codes[path] = code + }, + stop(): PipelineRecording { + active = false + closeSources() + return { + version: 1, + type: 'pipeline', + recorded_at: new Date().toISOString(), + folder, + total_duration_ms: Date.now() - startTime, + graph: graph ?? ({ assets: [], runnables: [], edges: [], triggers: [] } as any), + timeline, + jobs, + assetSamples, + codes + } + }, + download(recording: PipelineRecording) { + const blob = new Blob([truncateUuids(JSON.stringify(recording, null, 2))], { + type: 'application/json' + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `pipeline-recording-${(recording.folder || 'untitled').replace(/\//g, '-')}-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + } +} + +export type PipelineRecordingStore = { + readonly active: boolean + start(folder: string, graph: AssetGraphResponse): void + recordStatuses(statuses: Map): void + watchJob(jobId: string, workspace: string): void + addCompletedJob(jobId: string, completedJob: Job): void + recordAssetSample(sample: PipelineAssetSample): void + recordCode(path: string, code: PipelineRecordedCode): void + stop(): PipelineRecording + download(recording: PipelineRecording): void +} + +// Max asset samples in flight during finalize — each is several preview jobs. +const ASSET_SAMPLE_CONCURRENCY = 4 + +/** Run `fn` over `items` at most `limit` at a time (sequential batches). */ +async function forEachWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + for (let i = 0; i < items.length; i += limit) { + await Promise.all(items.slice(i, i + limit).map(fn)) + } +} + +/** + * Stop the recorder and enrich the recording with data the live SSE streams + * can't guarantee: each node's completed job (a fast job may finish before its + * stream opens), a data-sample per ducklake/datatable asset (offline table + * preview), and each step's source (by the exact hash that ran). Every fetch is + * best-effort — a step we can't resolve just replays with less detail. Shared by + * the pipeline editor's recorder and deploy-to-hub so both produce identical + * recordings. + */ +export async function finalizePipelineRecording( + store: PipelineRecordingStore, + workspace: string | undefined +): Promise { + const rec = store.stop() + if (!workspace) return rec + const ws = workspace + const jobIds = new Set() + for (const frame of rec.timeline) { + for (const st of Object.values(frame.statuses)) { + if (st.jobId) jobIds.add(st.jobId) + } + } + await Promise.all( + [...jobIds].map(async (jobId) => { + if (rec.jobs[jobId]?.events.some((e) => e.data.completed)) return + try { + const j = await JobService.getJob({ workspace: ws, id: jobId }) + store.addCompletedJob(jobId, j) + } catch { + // best-effort — a job we can't fetch just replays from its stream + } + }) + ) + // Each asset sample runs a metadata scan + a SELECT + a COUNT preview job, so a + // wide pipeline could fan out hundreds of jobs at once. Bound the concurrency + // to keep the recorder from saturating the worker pool. + const sampleTargets = (rec.graph.assets ?? []).filter( + (a) => a.kind === 'ducklake' || a.kind === 'datatable' + ) + await forEachWithConcurrency(sampleTargets, ASSET_SAMPLE_CONCURRENCY, async (a) => { + const sample = await capturePipelineAssetSample(ws, a.kind, a.path) + store.recordAssetSample(sample) + }) + const codeByPath = new Map() + for (const r of Object.values(rec.jobs)) { + const j = r.events.find((e) => e.data.completed)?.data.job as + | { job_kind?: string; script_path?: string; script_hash?: string } + | undefined + if (j?.job_kind === 'script' && j.script_path && j.script_hash) { + codeByPath.set(j.script_path, j.script_hash) + } + } + await Promise.all( + [...codeByPath].map(async ([path, hash]) => { + try { + const s = await ScriptService.getScriptByHash({ workspace: ws, hash }) + store.recordCode(path, { content: s.content, language: s.language }) + } catch { + // best-effort — a step we can't fetch just has no code in the player + } + }) + ) + return rec +} + +/** + * Run a folder's pipeline cascade end-to-end and capture it into a + * PipelineRecording — the self-contained path used by deploy-to-hub, where + * there is no editor page orchestrating the run. `launch`/`waitTerminal` are + * supplied by the caller (deployed-only launch, poll-based wait); this wires + * status/job capture around them and finalizes. + */ +export async function capturePipelineRecording(opts: { + workspace: string + folder: string + graph: AssetGraphResponse + scriptPaths: Set + launch: (path: string) => Promise + waitTerminal: (jobId: string) => Promise<'success' | 'failure'> + onUpdate?: (statuses: Map) => void +}): Promise<{ recording: PipelineRecording; result: CascadeRunResult & { cyclic: string[] } }> { + const store = createPipelineRecording() + store.start(opts.folder, opts.graph) + let result: CascadeRunResult & { cyclic: string[] } + try { + result = await runBoundedCascade({ + graph: opts.graph, + scripts: opts.scriptPaths, + launch: async (path) => { + const jobId = await opts.launch(path) + // No-op unless the store is active; captures the node's stream. + store.watchJob(jobId, opts.workspace) + return jobId + }, + waitTerminal: opts.waitTerminal, + onUpdate: (statuses) => { + store.recordStatuses(statuses) + opts.onUpdate?.(statuses) + } + }) + } catch (e) { + // The cascade threw before finalize could `stop()` the store: close the + // per-node SSE streams `watchJob` opened so they don't dangle. + store.stop() + throw e + } + const recording = await finalizePipelineRecording(store, opts.workspace) + return { recording, result } +} diff --git a/frontend/src/lib/components/recording/types.ts b/frontend/src/lib/components/recording/types.ts index 8d1dc8ca27..c47c9dc4e4 100644 --- a/frontend/src/lib/components/recording/types.ts +++ b/frontend/src/lib/components/recording/types.ts @@ -1,4 +1,5 @@ -import type { Job, OpenFlow } from '$lib/gen' +import type { AssetKind, Job, OpenFlow } from '$lib/gen' +import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types' export type RecordedEvent = { t: number @@ -33,6 +34,69 @@ export type ScriptRecording = { job: RecordedJob } +/** Per-node status inside a recorded cascade frame (mirror of + * cascadeOrchestrator.CascadeNodeState, kept structurally independent so the + * recording module doesn't depend on the orchestrator internals). */ +export type RecordedNodeState = { + status: 'pending' | 'running' | 'success' | 'failure' | 'skipped' + jobId?: string + error?: string +} + +/** One frame of the cascade timeline: the full per-path status snapshot at + * `t` ms since the run started. Replaying these in order reproduces the graph + * animation (nodes lighting up / turning green/red) a live run would show. */ +export type PipelineTimelineFrame = { + t: number + statuses: Record +} + +/** A captured data-sample of a pipeline asset (ducklake table / datatable), + * so the player can show what an asset held after the run — offline, without + * re-querying the backend. Keyed in `assetSamples` by `${kind}:${path}`. */ +export type PipelineAssetSample = { + kind: AssetKind + path: string + /** Full asset URI, e.g. `ducklake://main/orders`. */ + uri: string + /** Column names (in order) of the sampled table. */ + columns: { field: string; datatype?: string }[] + /** Sampled rows (capped), each a record keyed by column field. */ + rows: unknown[] + /** Total row count if it could be fetched. */ + rowCount?: number + /** Set when the sample couldn't be captured (table missing, unsupported…). */ + error?: string +} + +export type PipelineRecording = { + version: 1 + type: 'pipeline' + recorded_at: string + folder: string + total_duration_ms: number + /** The resolved asset graph rendered read-only by the player. */ + graph: AssetGraphResponse + /** Ordered cascade status snapshots driving the node animation. */ + timeline: PipelineTimelineFrame[] + /** Per-node job streams (initial job + SSE events), keyed by job id, so the + * player can replay each node's logs/result/args offline via JobLoader. */ + jobs: Record + /** Per-asset data samples captured after the run, keyed by `${kind}:${path}`, + * so asset nodes are inspectable offline in the player. */ + assetSamples?: Record + /** Source code of each runnable, keyed by script path, captured at record + * time so the player can show a step's code offline. Absent for recordings + * taken before code capture existed (the player degrades gracefully). */ + codes?: Record +} + +/** A pipeline step's captured source. */ +export type PipelineRecordedCode = { + content: string + language: string +} + /** Minimal interface that both flow and script recording stores implement */ export interface ActiveRecording { recordInitialJob(jobId: string, job: Job): void diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index cf2cd3ceb0..6b51b9a782 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -19,12 +19,14 @@ import { TRIGGER_KINDS, triggerDetails } from '$lib/components/triggers/workspaceTriggersList' import Toggle from '../Toggle.svelte' import MigrationSqlEditor from './MigrationSqlEditor.svelte' + import PipelineRecordingReplay from '$lib/components/recording/PipelineRecordingReplay.svelte' import { Check, Cloud, Code2, Copy, Database, + Eye, ExternalLink, Globe, Info, @@ -52,6 +54,7 @@ }) let recordDrawer = $state() + let pipelinePreviewDrawer = $state() let publishDrawer = $state() let resourceDrawer = $state() let triggerDrawer = $state() @@ -79,6 +82,9 @@ async function saveRecording() { if (await deployHub.session?.saveRecording()) recordDrawer?.closeDrawer() } + async function savePipelineRecording() { + await deployHub.session?.savePipelineRecording() + } function openPublish(it: DeployItem) { const s = deployHub.session if (!s) return @@ -140,7 +146,8 @@ > {stepNum > 2 ? '✓' : '2.'} Generate iframes & recordings — share - public apps as iframes and capture one execution per script/flow. + public apps as iframes, capture one execution per script/flow, and record the whole data-pipeline + cascade as one interactive replay.
  • 3 ? 'opacity-60' : 'opacity-40'} @@ -309,6 +316,73 @@ {/if} + {#if s.phase === 'draft' && s.isPipelineProject} +
    +
    + + Data pipeline recording + {#if s.pipelineRecorded} + + Recorded + + {/if} +
    + + {#if s.pipelineRecordingResult} + + + {/if} +
    +
    + + Runs this project's {s.selectedFolder}/ pipeline + cascade ({s.recordablePipelineScriptPaths.length} step{s + .recordablePipelineScriptPaths.length === 1 + ? '' + : 's'}) and captures the asset graph, per-step logs/results and table samples + into one interactive replay for the project page. + + {#if s.pipelineRunState === 'running'} +
    + Running the pipeline cascade… +
    + {:else if s.pipelineRunState === 'success'} +
    + Cascade succeeded — preview it, then save as the recording. +
    + {:else if s.pipelineRunState === 'failed'} +
    + + {s.pipelineRunError ?? 'Cascade failed'} +
    + {/if} +
    + {/if} {#if s.phase === 'predeploy'}
    @@ -632,6 +706,19 @@ + + pipelinePreviewDrawer?.closeDrawer()} + > + {#if s.pipelineRecordingResult} +
    + +
    + {/if} +
    +
    + = { const HIDDEN_RESOURCE_TYPES = new Set(['app_theme', 'state', 'cache']) +// Prune a folder's asset graph to a set of scripts so a pipeline recording only +// runs, renders and samples the project's included members — a deselected branch +// (its nodes, code, logs/results and table samples) never enters the recording. +// Assets kept are only those an included script touches; edges/triggers only +// those anchored on an included runnable. +function pruneGraphToScripts(graph: AssetGraphResponse, scripts: Set): AssetGraphResponse { + const runnables = graph.runnables.filter((r) => scripts.has(r.path)) + const edges = graph.edges.filter((e) => scripts.has(e.runnable_path)) + const keptAssets = new Set(edges.map((e) => `${e.asset_kind}:${e.asset_path}`)) + const assets = graph.assets.filter((a) => keptAssets.has(`${a.kind}:${a.path}`)) + const triggers = graph.triggers.filter((t) => scripts.has(t.runnable_path)) + const macro_edges = graph.macro_edges?.filter( + (m) => scripts.has(m.consumer_path) && scripts.has(m.lib_path) + ) + const test_edges = graph.test_edges?.filter( + (t) => scripts.has(t.runnable_path) && scripts.has(t.producer_path) + ) + return { assets, runnables, edges, triggers, macro_edges, test_edges } +} + function typesFromSchema(schema: any): string[] { const out = new Set() const props = schema?.properties @@ -190,6 +218,16 @@ export class DeployToHubSession { runError = $state(undefined) recordings = $state>({}) + // Project-level data-pipeline recording. Unlike script/flow recordings (one + // job per item) a pipeline is the whole folder cascade, so it gets a single + // recording: the resolved asset graph, per-node status timeline, per-node job + // streams and asset samples — replayed by PipelineRecordingReplay. + pipelineGraph = $state(undefined) + pipelineRunState = $state('idle') + pipelineRecordingResult = $state(undefined) + pipelineRunError = $state(undefined) + pipelineRecorded = $state(false) + publishTarget = $state() publishing = $state(false) @@ -221,6 +259,7 @@ export class DeployToHubSession { // Intra-session tokens: latest call wins among competing calls on this session. #triggerLoadTok = 0 #recordRunTok = 0 + #pipelineRunTok = 0 #migrationsTok = 0 #schedulePreviewsInFlight = new Set() // Preview-only cache: toggling checkboxes re-runs the closure walk, but item @@ -237,12 +276,16 @@ export class DeployToHubSession { dispose() { this.#disposed = true + // Invalidate any in-flight pipeline cascade poll so it stops on the next + // tick instead of polling to the timeout against a discarded session. + this.#pipelineRunTok++ } load() { void this.#loadWorkspace() void this.#loadTriggers() void this.rehydrateFromHub() + void this.#loadPipelineGraph() } filteredWorkspaceItems = $derived( @@ -268,6 +311,21 @@ export class DeployToHubSession { allRecorded = $derived( this.recordableItems.length > 0 && this.recordableItems.every((i) => i.rec === 'recorded') ) + // Pipeline members of this project's folder (`// pipeline` scripts). + pipelineScriptPaths = $derived( + (this.pipelineGraph?.runnables ?? []) + .filter((r) => r.usage_kind === 'script' && r.in_pipeline) + .map((r) => r.path) + ) + // The subset actually in the Hub project — so a member the user deselected from + // the bundle is neither executed nor embedded (with its code/logs/samples) in + // the recording. In the draft phase `items` is the project's membership. + recordablePipelineScriptPaths = $derived( + this.pipelineScriptPaths.filter((p) => + this.items.some((i) => i.kind === 'script' && i.path === p) + ) + ) + isPipelineProject = $derived(this.pipelineScriptPaths.length > 0) hubSlug = $derived(this.effectiveSlug || sanitizeSlug(this.hubName)) relevantTriggers = $derived.by(() => { @@ -1479,6 +1537,132 @@ export class DeployToHubSession { } } + /** Resolve the project folder's asset graph so a data-pipeline project can be + * detected and its whole-folder cascade recorded. Best-effort — a project + * with no pipeline just never shows the pipeline record card. */ + async #loadPipelineGraph() { + try { + const params = new URLSearchParams({ + folder: this.folder, + asset_kinds: DATA_ASSET_KINDS.join(',') + }) + const res = await fetch(`/api/w/${this.workspace}/assets/graph?${params}`, { + credentials: 'include' + }) + if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`) + const graph = (await res.json()) as AssetGraphResponse + if (this.#disposed) return + this.pipelineGraph = graph + } catch { + // No pipeline graph — the pipeline record card simply stays hidden. + } + } + + /** Run the whole-folder cascade and capture it into a single PipelineRecording. + * Deployed-only (no drafts) and arg-less — unlike the editor it seeds no + * per-node input, so a root that needs uploaded data or a schedule's static + * payload records a failure the user can see and fix rather than a green run. */ + runPipelineRecording = async () => { + const fullGraph = this.pipelineGraph + const scripts = this.recordablePipelineScriptPaths + if (!fullGraph || scripts.length === 0) return + const scriptSet = new Set(scripts) + // Scope the graph to the project's members so the run, the recorded graph + // (rendered by the player) and the asset samples all exclude deselected + // branches. + const graph = pruneGraphToScripts(fullGraph, scriptSet) + const tok = ++this.#pipelineRunTok + this.pipelineRunState = 'running' + this.pipelineRecordingResult = undefined + this.pipelineRunError = undefined + // A previous save's badge must not linger over a fresh, unsaved re-run. + this.pipelineRecorded = false + const workspace = this.workspace + try { + const { recording, result } = await capturePipelineRecording({ + workspace, + folder: this.folder, + graph, + scriptPaths: scriptSet, + launch: (path) => + JobService.runScriptByPath({ + workspace, + path, + // Skip the backend asset-trigger dispatcher: the cascade engine owns + // the whole closure (parity with the pipeline editor's bounded run). + requestBody: { _wmill_skip_asset_dispatch: true } + }), + waitTerminal: (jobId) => this.#waitJobTerminal(jobId, tok) + }) + if (tok !== this.#pipelineRunTok) return + this.pipelineRecordingResult = recording + // A dependency cycle drops its members from the schedule, so an all- or + // partially-cyclic run leaves the recording missing steps (and an empty + // schedule reports `ok`). Treat any dropped cyclic member as a failure so + // an incomplete pipeline can't be saved as a successful recording. + if (result.cyclic.length > 0) { + this.pipelineRunState = 'failed' + this.pipelineRunError = `Cannot record — ${result.cyclic.length} script(s) on a dependency cycle: ${result.cyclic.join(', ')}` + } else if (result.ok) { + this.pipelineRunState = 'success' + } else { + this.pipelineRunState = 'failed' + const failed = [...result.statuses.entries()] + .filter(([, s]) => s.status === 'failure') + .map(([p]) => p) + this.pipelineRunError = + failed.length > 0 ? `Failed at ${failed.join(', ')}` : 'Cascade did not complete' + } + } catch (e: any) { + if (tok !== this.#pipelineRunTok) return + this.pipelineRunState = 'failed' + this.pipelineRunError = `Failed to run pipeline: ${e?.message ?? e}` + } + } + + // Poll a launched step to terminal, matching the pipeline editor's cascade + // timeout (DuckLake/DuckDB steps routinely exceed a few minutes). Adds the + // `#pipelineRunTok` cancellation the shared `makeWaitJobTerminal` lacks. + async #waitJobTerminal(jobId: string, tok: number): Promise<'success' | 'failure'> { + const deadline = Date.now() + CASCADE_JOB_TIMEOUT_MS + while (Date.now() < deadline) { + if (tok !== this.#pipelineRunTok) throw new Error('cancelled') + try { + const r = await JobService.getCompletedJobResultMaybe({ + workspace: this.workspace, + id: jobId, + getStarted: false + }) + if (r.completed) return r.success ? 'success' : 'failure' + } catch { + // transient — retry on the next tick + } + await sleep(CASCADE_POLL_INTERVAL_MS) + } + throw new Error(`Timed out waiting for job ${jobId}`) + } + + /** Save the captured pipeline recording to the Hub, scoped to the project + * (a pipeline is the whole folder, not a single Hub item). Returns true on + * success. */ + async savePipelineRecording(): Promise { + const recording = this.pipelineRecordingResult + if (!recording || this.pipelineRunState !== 'success') return false + if (this.phase === 'predeploy') { + sendUserToast(`Push the project to the Hub first before saving its pipeline recording`, true) + return false + } + try { + await this.#postHub(`/hub/projects/${this.hubSlug}/pipeline_recording`, { recording }) + this.pipelineRecorded = true + sendUserToast(`Pipeline recording saved`) + return true + } catch (e: any) { + sendUserToast(`Failed to save pipeline recording: ${e?.message ?? e}`, true) + return false + } + } + // Set the Hub raw app's live-iframe URL (or clear it with null). The Hub renders // from external_embed_url; project_slug scopes ownership. async #pushRawAppEmbed(hubId: number, url: string | null) { diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index 0c1a6eeb8c..765c0d251e 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -43,6 +43,7 @@ assetProducers } from '$lib/components/assets/AssetGraph/graphTraversal' import { runCascade, runSelection } from '$lib/components/assets/AssetGraph/cascadeOrchestrator' + import { DATA_ASSET_KINDS } from '$lib/components/assets/AssetGraph/cascadeRun' import { boundedSet, buildLineageDag, @@ -72,6 +73,11 @@ type PipelineDraft } from '$lib/components/assets/AssetGraph/pipelineAiHelpers' import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte' + import { + createPipelineRecording, + finalizePipelineRecording + } from '$lib/components/recording/pipelineRecording.svelte' + import type { PipelineRecording } from '$lib/components/recording/types' import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { onMount, tick, untrack } from 'svelte' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' @@ -79,6 +85,8 @@ AlertTriangle, ArrowLeft, ChevronDown, + Circle, + Download, Folder, FolderSearch, History, @@ -113,7 +121,7 @@ // Variables and resources are declarative config, not pipeline assets — // they're hub-shaped (referenced by most runnables) and would swamp the // layout without adding lineage information. - const DATA_KINDS = ['s3object', 'ducklake', 'datatable', 'volume'] + const DATA_KINDS = DATA_ASSET_KINDS let folder = $derived(page.params.folder as string) @@ -1404,6 +1412,22 @@ // other's storage writes. let cascadeRunningRoot = $state(undefined) + // Recorder: when armed, the next cascade run captures the resolved graph, the + // per-node status timeline and each node's job stream into a downloadable + // recording that the /pipeline_replay player can rerun offline (parity with the + // flow/script recorders). Job capture (`watchJob`) and status capture + // (`recordStatuses`) no-op unless the store is active, so the cascade run + // paths call them unconditionally. + let pipelineRecording = createPipelineRecording() + let recordingMode = $state(false) + let lastPipelineRecording = $state(undefined) + + function downloadPipelineRecording() { + if (lastPipelineRecording) { + pipelineRecording.download(lastPipelineRecording) + } + } + // Script path → its schedule's configured args, so a manual "Run pipeline" // launches a schedule-triggered script with the same payload a real tick // would (rather than empty args). Schedule is the only trigger that stores a @@ -1711,6 +1735,10 @@ // Claim the running-guard BEFORE the first await so a rapid second click // (which reads `cascadeRunningRoot`) can't slip through and double-launch. cascadeRunningRoot = schedule.roots[0] ?? scripts[0] + if (recordingMode) { + lastPipelineRecording = undefined + pipelineRecording.start(folder, displayGraph) + } let firstJobId: string | undefined try { // Seed schedule-triggered roots with their configured payload. @@ -1720,6 +1748,8 @@ launch: async (path) => { const jobId = await launchCascadeScript(path) activeRunnables.arm(`script:${path}`) + // No-op unless a recording is active; captures the node's stream. + if ($workspaceStore) pipelineRecording.watchJob(jobId, $workspaceStore) if (firstJobId === undefined) { firstJobId = jobId runsPendingJobId = jobId @@ -1727,7 +1757,8 @@ } return jobId }, - waitTerminal: waitJobTerminal + waitTerminal: waitJobTerminal, + onUpdate: (statuses) => pipelineRecording.recordStatuses(statuses) }) const n = res.statuses.size if (res.ok) { @@ -1750,7 +1781,21 @@ ) } } finally { - cascadeRunningRoot = undefined + // Hold the run guard until finalization finishes: finalize keeps writing + // jobs/samples/code through the recorder store, and a second run's + // `start()` would reset those maps mid-write, corrupting both recordings. + // The nested finally still clears the guard if finalize ever rejects, so + // Run can't wedge permanently. + try { + if (pipelineRecording.active) { + lastPipelineRecording = await finalizePipelineRecording( + pipelineRecording, + $workspaceStore + ) + } + } finally { + cascadeRunningRoot = undefined + } } } @@ -2326,6 +2371,40 @@ {/if}
    {#if !isOperator && allPipelineScripts.length > 0} + + {#if lastPipelineRecording && !cascadeRunningRoot} + + {/if} + +{#snippet replayFailed()} +
    + +

    + This recording could not be replayed — it may be malformed or from an incompatible version. +

    + +
    +{/snippet} + + +
    + {#if flowRecording} +
    + +
    + setActiveReplay(undefined)}> + + {#snippet failed()}{@render replayFailed()}{/snippet} + + {:else if scriptRecording} +
    + +
    + setActiveReplay(undefined)}> + + {#snippet failed()}{@render replayFailed()}{/snippet} + + {:else if pipelineRecording} +
    + +
    +
    + setActiveReplay(undefined)}> + + {#snippet failed()}{@render replayFailed()}{/snippet} + +
    + {:else if downloading} +
    +
    + +

    Downloading recording…

    + {#if downloadPercent !== undefined} +
    +
    +
    +

    {downloadPercent}% · {fmtBytes(downloadedBytes)}

    + {:else} +

    {fmtBytes(downloadedBytes)}

    + {/if} +
    +
    + {:else} +
    +
    +

    Replay a recording

    +

    + Upload a recording JSON file to replay a flow, script or data-pipeline execution offline. +

    + {#if downloadError} +

    {downloadError}

    + {/if} + + Drag and drop a recording file + +
    +
    + {/if} +
    diff --git a/frontend/src/routes/(root)/(logged)/replay/+page.svelte b/frontend/src/routes/(root)/(logged)/replay/+page.svelte deleted file mode 100644 index d89bcbb9b0..0000000000 --- a/frontend/src/routes/(root)/(logged)/replay/+page.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
    - {#if flowRecording} -
    - -
    - - {:else if scriptRecording} -
    - -
    - - {:else} -
    -
    -

    Replay a recording

    -

    - Upload a recording JSON file to replay a flow or script execution offline. -

    - - Drag and drop a recording file - -
    -
    - {/if} -
    diff --git a/frontend/src/routes/(root)/(logged)/replay/+page.ts b/frontend/src/routes/(root)/(logged)/replay/+page.ts new file mode 100644 index 0000000000..811dac8e18 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/replay/+page.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit' +import { base } from '$app/paths' + +// The replay page moved to /pipeline_replay (it now replays data-pipeline +// recordings in addition to flow/script ones). Redirect the old path in `load` +// so existing /replay links and bookmarks still resolve instead of 404-ing. +export function load({ url }: { url: URL }) { + redirect(307, `${base}/pipeline_replay${url.search}`) +} From 68daed8501130456af3c7229ad18b1fb303887ae Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:57:31 +0200 Subject: [PATCH 49/66] refactor: custom-instance datatable connection handling (#10271) Attach custom-instance datatables in the DuckDB executor through a DuckDB secret instead of an inline connection string, and route postgres triggers on custom-instance datatables through a dedicated custom_instance_replication_user role (with its own auto-generated password in global_settings). Normalize custom_instance_user attributes on server boot. Claude-Session: https://claude.ai/code/session_01Tp6NNNinCB8dwWqGaFXDRF Co-authored-by: Claude Fable 5 --- ...f4367ef6b6cc3de20da6ef8c98f679b832240.json | 20 ++++ ..._custom_instance_replication_user.down.sql | 14 +++ ...46_custom_instance_replication_user.up.sql | 34 ++++++ backend/windmill-api-settings/src/lib.rs | 17 ++- backend/windmill-api/openapi.yaml | 2 +- backend/windmill-api/src/live_migrations.rs | 24 ++++ .../windmill-common/src/global_settings.rs | 7 ++ .../windmill-common/src/instance_config.rs | 99 ++++++++++------ backend/windmill-common/src/utils.rs | 86 ++++++++++++++ backend/windmill-common/src/workspaces.rs | 37 +++++- backend/windmill-trigger-postgres/src/lib.rs | 6 +- .../windmill-worker/src/duckdb_executor.rs | 107 +++++++++++++++++- .../CustomInstanceDbWizardModal.svelte | 15 ++- 13 files changed, 419 insertions(+), 49 deletions(-) create mode 100644 backend/.sqlx/query-ffbe9fe78a7fc0e95a5a29c8d17f4367ef6b6cc3de20da6ef8c98f679b832240.json create mode 100644 backend/migrations/20260716152346_custom_instance_replication_user.down.sql create mode 100644 backend/migrations/20260716152346_custom_instance_replication_user.up.sql diff --git a/backend/.sqlx/query-ffbe9fe78a7fc0e95a5a29c8d17f4367ef6b6cc3de20da6ef8c98f679b832240.json b/backend/.sqlx/query-ffbe9fe78a7fc0e95a5a29c8d17f4367ef6b6cc3de20da6ef8c98f679b832240.json new file mode 100644 index 0000000000..9320a9625f --- /dev/null +++ b/backend/.sqlx/query-ffbe9fe78a7fc0e95a5a29c8d17f4367ef6b6cc3de20da6ef8c98f679b832240.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value->>'replication_user_pwd' FROM global_settings WHERE name = 'custom_instance_pg_databases';", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "ffbe9fe78a7fc0e95a5a29c8d17f4367ef6b6cc3de20da6ef8c98f679b832240" +} diff --git a/backend/migrations/20260716152346_custom_instance_replication_user.down.sql b/backend/migrations/20260716152346_custom_instance_replication_user.down.sql new file mode 100644 index 0000000000..2350875885 --- /dev/null +++ b/backend/migrations/20260716152346_custom_instance_replication_user.down.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN + ALTER ROLE custom_instance_user REPLICATION; + END IF; + + DROP ROLE IF EXISTS custom_instance_replication_user; + + DELETE FROM global_settings WHERE name = 'custom_instance_replication_pwd'; +EXCEPTION + WHEN others THEN + RAISE NOTICE 'custom_instance_replication_user down-migration error, skipping: %', SQLERRM; +END +$$; diff --git a/backend/migrations/20260716152346_custom_instance_replication_user.up.sql b/backend/migrations/20260716152346_custom_instance_replication_user.up.sql new file mode 100644 index 0000000000..3fb0f0d9d0 --- /dev/null +++ b/backend/migrations/20260716152346_custom_instance_replication_user.up.sql @@ -0,0 +1,34 @@ +-- Dedicated logical-replication role used by postgres triggers on custom-instance +-- datatables. Its password is stored server-only in global_settings.custom_instance_replication_pwd +-- (hidden from the config surface); membership in custom_instance_user lets it manage +-- publications on the datatable tables. custom_instance_user itself must not hold REPLICATION. +DO $$ +DECLARE + pwd text; +BEGIN + SELECT gen_random_uuid()::text INTO pwd; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_replication_user') THEN + EXECUTE format('ALTER USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd); + ELSE + EXECUTE format('CREATE USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN + GRANT custom_instance_user TO custom_instance_replication_user; + ALTER ROLE custom_instance_user NOREPLICATION; + END IF; + + INSERT INTO global_settings (name, value) + VALUES ('custom_instance_replication_pwd', to_jsonb(pwd::text)) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value; + + -- Drop any replication password an earlier iteration stored in the operator-facing row. + UPDATE global_settings + SET value = value - 'replication_user_pwd' + WHERE name = 'custom_instance_pg_databases'; +EXCEPTION + WHEN others THEN + RAISE NOTICE 'custom_instance_replication_user migration error, skipping: %', SQLERRM; +END +$$; diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 1fb070c2c9..8351ec0d2f 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1578,6 +1578,7 @@ async fn refresh_custom_instance_user_pwd( ) -> JsonResult<()> { require_super_admin(&db, &authed.email).await?; windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?; + windmill_common::utils::refresh_custom_instance_replication_user_pwd(&db).await?; Ok(Json(())) } @@ -1704,11 +1705,23 @@ async fn setup_custom_instance_pg_database_inner( )) })?; + // The replication attribute lives on a dedicated role used by postgres trigger + // connections. The getter creates the role (with its stored password) when the + // migration couldn't. + if let Err(e) = windmill_common::utils::get_custom_pg_instance_replication_password(db).await { + tracing::error!("Failed to ensure custom_instance_replication_user exists: {e:#}"); + } if let Err(e) = client - .batch_execute(&format!("ALTER ROLE custom_instance_user REPLICATION;")) + .batch_execute( + "ALTER ROLE custom_instance_replication_user REPLICATION; + GRANT custom_instance_user TO custom_instance_replication_user; + ALTER ROLE custom_instance_user NOREPLICATION;", + ) .await { - tracing::error!("Failed to grant replication permission to custom_instance_user: {e:#}"); + tracing::error!( + "Failed to grant replication permission to custom_instance_replication_user: {e:#}" + ); } logs.grant_permissions = "OK".to_string(); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 12300d4a69..8c082f5052 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1330,7 +1330,7 @@ paths: /settings/refresh_custom_instance_user_pwd: post: - summary: Refreshes the password for the custom_instance_user + summary: Refreshes the passwords for the custom_instance_user and the custom_instance_replication_user (used by postgres triggers) operationId: refreshCustomInstanceUserPwd tags: - setting diff --git a/backend/windmill-api/src/live_migrations.rs b/backend/windmill-api/src/live_migrations.rs index d5c759fc18..7c359300c3 100644 --- a/backend/windmill-api/src/live_migrations.rs +++ b/backend/windmill-api/src/live_migrations.rs @@ -19,6 +19,30 @@ pub async fn custom_migrations(migrator: &mut CustomMigrator) -> Result<(), Erro tracing::error!("Could not apply flow versioning fix migration: {err:#}"); } + if let Err(err) = normalize_custom_instance_user_attributes(migrator).await { + tracing::error!("Could not normalize custom_instance_user attributes: {err:#}"); + } + + Ok(()) +} + +// Converged on every boot, not once: the one-shot migration swallows errors (it must not +// abort startup without superuser), and an older instance sharing the cluster can re-add +// the attribute. REPLICATION belongs only on custom_instance_replication_user. +async fn normalize_custom_instance_user_attributes( + migrator: &mut CustomMigrator, +) -> Result<(), Error> { + let has_replication = sqlx::query_scalar::<_, bool>( + "SELECT rolreplication FROM pg_roles WHERE rolname = 'custom_instance_user'", + ) + .fetch_optional(migrator.connection()) + .await?; + if has_replication == Some(true) { + sqlx::query("ALTER ROLE custom_instance_user NOREPLICATION") + .execute(migrator.connection()) + .await?; + tracing::info!("Normalized custom_instance_user attributes"); + } Ok(()) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 2714d31276..b91922dffb 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -165,6 +165,11 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[ INSTANCE_EVENTS_WEBHOOK_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, + // Custom-instance DB credentials: `custom_instance_pg_databases` holds `user_pwd`, + // `custom_instance_replication_pwd` holds the REPLICATION-role password. Agent workers + // resolve datatable connections through the dedicated datatable endpoints, never these. + "custom_instance_pg_databases", + "custom_instance_replication_pwd", ]; /// Whether an agent worker may read the given global setting over HTTP. @@ -381,6 +386,8 @@ mod tests { INSTANCE_EVENTS_WEBHOOK_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, + "custom_instance_pg_databases", + "custom_instance_replication_pwd", ] { assert!( !is_setting_readable_by_agent_worker(key), diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 74b0647d82..7e32fe6e56 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -776,7 +776,10 @@ pub enum DucklakeCatalogResourceType { // Custom instance PG databases // --------------------------------------------------------------------------- -/// Custom PostgreSQL databases managed by the instance. +/// Custom PostgreSQL databases managed by the instance. `user_pwd` is operator-configurable +/// (resolved from a Kubernetes secretKeyRef by the EE operator); `databases` is runtime +/// setup status. The replication-role password lives in a separate hidden setting +/// (`custom_instance_replication_pwd`), never in this operator-facing config row. #[derive(Deserialize, Serialize, Clone, Debug, Default)] #[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] pub struct CustomInstancePgDatabases { @@ -945,6 +948,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ "ducklake_user_pg_pwd", "ducklake_settings", "custom_instance_pg_databases", + "custom_instance_replication_pwd", "uid", "rsa_keys", "jwt_secret", @@ -966,6 +970,10 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ // every bulk InstanceSettings save via `GlobalSettings::extra`. Hiding it // on read + rejecting it in `diff_global_settings` breaks that loop. "worker_configs", + // Auto-generated password for the REPLICATION role used by postgres triggers. + // Server-only (written by setup/refresh via direct SQL), never operator-authored — + // hidden so the config machinery can't read, rewrite, or drop it. + "custom_instance_replication_pwd", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. @@ -976,6 +984,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "hub_api_secret", "license_key", "ducklake_user_pg_pwd", + "custom_instance_replication_pwd", "pip_index_url", "pip_extra_index_url", "npm_config_registry", @@ -1000,6 +1009,7 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ "object_store_cache_config", &["secret_key", "serviceAccountKey"], ), + ("custom_instance_pg_databases", &["user_pwd"]), ]; fn redact_json_value(value: &serde_json::Value) -> serde_json::Value { @@ -1024,10 +1034,7 @@ fn mask_nested_sensitive(key: &str, value: &serde_json::Value) -> serde_json::Va } } // Settings that are maps-of-objects where each child has a sensitive sub-field. - const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[ - ("oauths", "secret"), - ("custom_instance_pg_databases", "user_pwd"), - ]; + const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[("oauths", "secret")]; for &(parent_key, child_field) in NESTED_MAP_SENSITIVE { if key == parent_key { if let serde_json::Value::Object(entries) = value { @@ -1142,14 +1149,13 @@ pub fn diff_global_settings( let mut previous_values = BTreeMap::new(); let mut unchanged_count: usize = 0; for (key, desired_value) in desired { - // `worker_configs` is a legacy ghost: worker configs belong in the - // `config` table with a `worker__` prefix. If a client PUT carries a - // top-level `worker_configs` key (it flattens into - // `GlobalSettings::extra` on deserialize), drop it here instead of - // letting it resurrect a stale `global_settings` row. - if key == "worker_configs" { + // Hidden settings are server-managed and never driven by config: they are + // filtered out on read (`from_db`) and must be ignored on write too, so a client + // PUT that flattened one into `GlobalSettings::extra` can't resurrect or clobber + // the row (e.g. `worker_configs`, or the custom-instance credentials/status). + if HIDDEN_SETTINGS.contains(&key.as_str()) { tracing::warn!( - "Ignoring 'worker_configs' in global_settings diff: worker configs must be written to the config table (worker__ prefix), not global_settings" + "Ignoring hidden setting '{key}' in global_settings diff (server-managed, not configurable)" ); continue; } @@ -2367,34 +2373,63 @@ mod tests { } #[test] - fn custom_instance_pg_databases_roundtrips() { + fn custom_instance_replication_pwd_is_isolated_from_config() { + // The replication-role password is server-only: written by setup/refresh via direct + // SQL, never operator-authored. It must stay out of the declarative config surface + // (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop it. + assert!(HIDDEN_SETTINGS.contains(&"custom_instance_replication_pwd")); + assert!(PROTECTED_SETTINGS.contains(&"custom_instance_replication_pwd")); + assert!(SENSITIVE_SETTINGS.contains(&"custom_instance_replication_pwd")); + + // A stray desired value (e.g. flattened into `extra`) is ignored, not upserted. + let mut desired = BTreeMap::new(); + desired.insert( + "custom_instance_replication_pwd".to_string(), + serde_json::json!("attacker-set"), + ); + let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge); + assert!( + diff.upserts.is_empty(), + "hidden setting must not be upserted" + ); + + // A current value is never deleted by a Replace that omits it. + let mut current = BTreeMap::new(); + current.insert( + "custom_instance_replication_pwd".to_string(), + serde_json::json!("live"), + ); + let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace); + assert!( + !diff + .deletes + .contains(&"custom_instance_replication_pwd".to_string()), + "hidden setting must not be deleted" + ); + } + + #[test] + fn custom_instance_pg_databases_roundtrips_and_redacts_user_pwd() { + // user_pwd stays operator-configurable (EE secretKeyRef); databases is runtime status. let json = r#"{ "user_pwd": "secret123", - "databases": { - "mydb": { - "logs": { - "super_admin": "OK", - "database_credentials": "OK", - "valid_dbname": "OK", - "created_database": "OK", - "db_connect": "OK", - "grant_permissions": "OK" - }, - "success": true, - "tag": "production" - } - } + "databases": { "mydb": { "success": true, "tag": "production" } } }"#; let pg: CustomInstancePgDatabases = serde_json::from_str(json).unwrap(); assert_eq!( pg.user_pwd.as_ref().and_then(|v| v.as_literal()), Some("secret123") ); - let db = &pg.databases["mydb"]; - assert!(db.success); - assert_eq!(db.tag.as_deref(), Some("production")); - assert_eq!(db.logs.super_admin, "OK"); - assert_eq!(db.logs.grant_permissions, "OK"); + assert!(pg.databases["mydb"].success); + + let out = format_setting_value( + "custom_instance_pg_databases", + &serde_json::json!({ "user_pwd": "user-plaintext-password" }), + ); + assert!( + !out.contains("user-plaintext-password"), + "user_pwd leaked: {out}" + ); } #[test] diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index a6d1eeaeb0..efe6477ad3 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -1044,6 +1044,92 @@ pub async fn get_custom_pg_instance_password(db: &DB) -> Result { ) } +const REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL: &str = r#" + DO $$ + DECLARE + pwd text; + BEGIN + SELECT gen_random_uuid()::text INTO pwd; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_replication_user') THEN + EXECUTE format('ALTER USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd); + ELSE + EXECUTE format('CREATE USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN + GRANT custom_instance_user TO custom_instance_replication_user; + ALTER ROLE custom_instance_user NOREPLICATION; + END IF; + + INSERT INTO global_settings (name, value) + VALUES ('custom_instance_replication_pwd', to_jsonb(pwd::text)) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value; + END + $$; +"#; + +const REPLICATION_PWD_READ_SQL: &str = + "SELECT value #>> '{}' FROM global_settings WHERE name = 'custom_instance_replication_pwd'"; + +/// (Re)create `custom_instance_replication_user` with a fresh password. This role is +/// used by postgres trigger connections on custom-instance datatables; membership in +/// `custom_instance_user` lets it manage publications on the datatable tables. +/// +/// Authorization: rotates a stored database credential and performs no authorization +/// itself — callers MUST restrict this to superadmin or internal server paths. +pub async fn refresh_custom_instance_replication_user_pwd(db: &DB) -> Result<()> { + sqlx::query(REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL) + .execute(db) + .await?; + Ok(()) +} + +/// Authorization: returns a stored database credential and performs no authorization +/// itself — callers MUST restrict this to superadmin or internal server paths (mirrors +/// [`get_custom_pg_instance_password`]). +pub async fn get_custom_pg_instance_replication_password(db: &DB) -> Result { + // Fast path: already provisioned by the migration. + if let Some(pwd) = sqlx::query_scalar::<_, Option>(REPLICATION_PWD_READ_SQL) + .fetch_optional(db) + .await? + .flatten() + { + return Ok(pwd); + } + // Self-heal when the role-creating migration was swallowed. The advisory lock + re-check + // serialize concurrent workers: otherwise two callers both rotate, and the second + // rotation invalidates the password the first already returned. Rotating and reading in + // one locked transaction keeps the decision atomic. + let mut tx = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('custom_instance_replication_pwd'))") + .execute(&mut *tx) + .await?; + if let Some(pwd) = sqlx::query_scalar::<_, Option>(REPLICATION_PWD_READ_SQL) + .fetch_optional(&mut *tx) + .await? + .flatten() + { + tx.commit().await?; + return Ok(pwd); + } + sqlx::query(REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL) + .execute(&mut *tx) + .await?; + let pwd = sqlx::query_scalar::<_, Option>(REPLICATION_PWD_READ_SQL) + .fetch_optional(&mut *tx) + .await? + .flatten() + .ok_or_else(|| { + Error::BadRequest( + "Custom instance replication user password not found, did you run migrations ?" + .to_string(), + ) + })?; + tx.commit().await?; + Ok(pwd) +} + /// Convert a JSON string to a `Box` without validation. /// /// # Safety diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 5be60363b7..59d50ef44a 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -9,7 +9,7 @@ use crate::{ error::{self, to_anyhow, Error, Result}, get_database_url, secret_backend::{get_secret_value, is_external_stored_value}, - utils::get_custom_pg_instance_password, + utils::{get_custom_pg_instance_password, get_custom_pg_instance_replication_password}, variables::{build_crypt, decrypt}, PgDatabase, DB, }; @@ -1044,6 +1044,32 @@ pub async fn get_datatable_resource_from_db_unchecked( db: &DB, w_id: &str, name: &str, +) -> Result { + get_datatable_resource_inner(db, w_id, name, false).await +} + +/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger +/// connections: custom-instance datatables resolve to +/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres +/// datatables resolve to the user's own resource unchanged; configuring it for +/// replication there is the user's responsibility. +/// +/// Authorization: like its `_unchecked` sibling, returns resolved connection +/// credentials and performs no authorization — callers MUST have already authorized +/// access to the datatable (e.g. the trigger's own create-time check). +pub async fn get_datatable_replication_resource_from_db_unchecked( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + get_datatable_resource_inner(db, w_id, name, true).await +} + +async fn get_datatable_resource_inner( + db: &DB, + w_id: &str, + name: &str, + replication: bool, ) -> Result { let datatables = sqlx::query_scalar!( r#" @@ -1068,8 +1094,13 @@ pub async fn get_datatable_resource_from_db_unchecked( { let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; pg_creds.dbname = datatable.database.resource_path.clone(); - pg_creds.user = Some("custom_instance_user".to_string()); - pg_creds.password = Some(get_custom_pg_instance_password(&db).await?); + if replication { + pg_creds.user = Some("custom_instance_replication_user".to_string()); + pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?); + } else { + pg_creds.user = Some("custom_instance_user".to_string()); + pg_creds.password = Some(get_custom_pg_instance_password(&db).await?); + } serde_json::to_value(&pg_creds) .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? } else { diff --git a/backend/windmill-trigger-postgres/src/lib.rs b/backend/windmill-trigger-postgres/src/lib.rs index d6fcebfddb..a295183dfc 100644 --- a/backend/windmill-trigger-postgres/src/lib.rs +++ b/backend/windmill-trigger-postgres/src/lib.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::value::RawValue; use sqlx::FromRow; use windmill_api_auth::ApiAuthed; -use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; +use windmill_common::workspaces::get_datatable_replication_resource_from_db_unchecked; use windmill_common::{ db::UserDB, error::{to_anyhow, Error, Result}, @@ -382,8 +382,10 @@ pub async fn resolve_postgres_resource( w_id: &str, ) -> Result { if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") { + // Trigger connections (publication/slot management + logical replication) run + // as the dedicated replication user on custom-instance databases. let resource_value = - get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; + get_datatable_replication_resource_from_db_unchecked(db, w_id, datatable_name).await?; serde_json::from_value::(resource_value).map_err(|e| Error::SerdeJson { error: e, location: "resolve_postgres_resource".to_string(), diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index e4e24d6653..e2807547e7 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -2571,15 +2571,57 @@ async fn transform_attach_datatable( } Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?, }; - let db_type = "postgres"; if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) { hidden_passwords.lock().unwrap().push(pwd.to_string()); } - Ok(Some( - db_resource_to_attach_statements(db_resource, alias_name, db_type, None).await?, - )) + Ok(Some(pg_secret_attach_statements(db_resource, alias_name)?)) +} + +// Secret names must be plain identifiers; the hash keeps two aliases distinct even +// when sanitizing maps them to the same string. +fn datatable_secret_name(alias: &str) -> String { + use sha2::{Digest, Sha256}; + let sanitized: String = alias + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let hash = &Sha256::digest(alias.as_bytes())[..4]; + format!( + "__wm_datatable_{sanitized}_{:08x}", + u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]) + ) +} + +/// ATTACH a datatable's postgres database through a DuckDB TEMPORARY SECRET holding +/// the connection parameters; only sslmode rides in the ATTACH string. +fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result> { + let res: PgDatabase = serde_json::from_value(db_resource)?; + // Escape single quotes: each field is embedded in a single-quoted DuckDB literal, + // so an unescaped quote would break out of the CREATE SECRET statement. + let esc = |s: &str| s.replace('\'', "''"); + // The postgres secret type has no sslmode parameter, so it goes in the ATTACH + // string; only the libpq values PgDatabase::to_uri collapses to are forwarded. + let sslmode = match res.sslmode.as_deref() { + Some("disable") => "disable", + Some("require") | Some("verify-ca") | Some("verify-full") => "require", + _ => "prefer", + }; + let secret_name = datatable_secret_name(alias_name); + Ok(vec![ + "INSTALL postgres;".to_string(), + "LOAD postgres;".to_string(), + format!( + "CREATE OR REPLACE TEMPORARY SECRET {secret_name} (TYPE postgres, HOST '{}', PORT {}, DATABASE '{}', USER '{}', PASSWORD '{}');", + esc(&res.host), + res.port.unwrap_or(5432), + esc(&res.dbname), + esc(res.user.as_deref().unwrap_or("postgres")), + esc(res.password.as_deref().unwrap_or("")), + ), + format!("ATTACH 'sslmode={sslmode}' AS {alias_name} (TYPE postgres, SECRET {secret_name});"), + ]) } async fn transform_s3_uris(query: &str) -> Result { @@ -3716,6 +3758,63 @@ mod tests { assert!(result.contains("sslmode=prefer")); } + #[test] + fn test_pg_secret_attach_statements() { + let db_resource = json!({ + "host": "localhost", + "port": 5433, + "user": "custom_instance_user", + "password": "it's-secret", + "dbname": "wm_datatables", + "sslmode": "require" + }); + let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + assert_eq!(stmts[0], "INSTALL postgres;"); + assert_eq!(stmts[1], "LOAD postgres;"); + let secret_name = datatable_secret_name("dt"); + assert_eq!( + stmts[2], + format!( + "CREATE OR REPLACE TEMPORARY SECRET {secret_name} (TYPE postgres, HOST 'localhost', PORT 5433, DATABASE 'wm_datatables', USER 'custom_instance_user', PASSWORD 'it''s-secret');" + ) + ); + assert_eq!( + stmts[3], + format!("ATTACH 'sslmode=require' AS dt (TYPE postgres, SECRET {secret_name});") + ); + } + + #[test] + fn test_pg_secret_attach_statements_sslmode_whitelist() { + for (input, expected) in [ + (Some("allow"), "prefer"), + (Some("verify-full"), "require"), + (Some("disable"), "disable"), + (Some("unknown-value"), "prefer"), + (None, "prefer"), + ] { + let mut db_resource = json!({ "host": "h", "dbname": "d" }); + if let Some(s) = input { + db_resource["sslmode"] = json!(s); + } + let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + assert!( + stmts[3].starts_with(&format!("ATTACH 'sslmode={expected}'")), + "sslmode {input:?} → {}", + stmts[3] + ); + } + } + + #[test] + fn test_datatable_secret_name_sanitizes_and_disambiguates() { + let a = datatable_secret_name("a.b"); + let b = datatable_secret_name("a_b"); + assert!(a.starts_with("__wm_datatable_a_b_")); + assert_ne!(a, b); + assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')); + } + #[test] fn test_format_attach_db_conn_str_bigquery() { let db_resource = json!({ diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte index 156d11ce95..dcf41788ae 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte @@ -165,14 +165,17 @@ title: 'Grant permissions to custom_instance_user', status: status?.logs.grant_permissions, description: - 'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' + + 'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. Postgres triggers use custom_instance_replication_user (password in global_settings.custom_instance_replication_pwd). These are the commands : \n\n' + `GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` + 'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' + 'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' + `GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' + ' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' + - 'ALTER ROLE custom_instance_user CREATEROLE;' + 'ALTER ROLE custom_instance_user CREATEROLE;\n' + + 'ALTER ROLE custom_instance_replication_user REPLICATION;\n' + + 'GRANT custom_instance_user TO custom_instance_replication_user;\n' + + 'ALTER ROLE custom_instance_user NOREPLICATION;' } ], status?.error ?? undefined @@ -185,11 +188,13 @@ endIcon={{ icon: InfoIcon }} onClick={async () => { await SettingService.refreshCustomInstanceUserPwd() - sendUserToast('custom_instance_user password refreshed') - }}>Refresh custom_instance_user passwordRefresh custom instance passwords {#snippet text()} - Try this if there is an issue with your custom instance database password. + Try this if there is an issue with your custom instance database passwords. Rotates + both custom_instance_user and the custom_instance_replication_user used by postgres + triggers. {/snippet} {/if} From 8eb36ce008b4efe2be9a9bfebc91af68070f7a6c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 23 Jul 2026 19:05:09 +0200 Subject: [PATCH 50/66] fix: treat concurrent_limit/timeout <= 0 as unset instead of a zero cap (#10288) * fix: treat concurrent_limit/timeout <= 0 as unset instead of a zero cap Co-Authored-By: Claude Opus 4.8 (1M context) * fix: flow-step timeout <= 0 inherits the script timeout, not the global default Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api-flows/src/flows.rs | 9 +- backend/windmill-api-scripts/src/scripts.rs | 7 + backend/windmill-queue/src/jobs.rs | 42 ++-- .../tests/concurrency_limit_zero_test.rs | 21 ++ .../windmill-types/src/runnable_settings.rs | 190 +++++++++++++++++- backend/windmill-worker/src/common.rs | 4 +- backend/windmill-worker/src/worker.rs | 19 +- backend/windmill-worker/src/worker_flow.rs | 64 ++++-- cli/src/commands/script/script.ts | 51 ++++- 9 files changed, 355 insertions(+), 52 deletions(-) create mode 100644 backend/windmill-queue/tests/concurrency_limit_zero_test.rs diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 7f39e61f21..97e6999e23 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -527,6 +527,11 @@ async fn create_flow( } check_scopes(&authed, || format!("flows:write:{}", nf.path))?; + // A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly. + // (The concurrency settings inside the flow value are normalized on deserialization; see + // ConcurrencySettings.) Runtime guards also protect already-stored rows. + nf.timeout = windmill_common::runnable_settings::none_if_non_positive(nf.timeout); + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), @@ -1012,7 +1017,9 @@ async fn update_flow( } let flow_path = flow_path.to_path(); // The URL identifies the flow being updated; the body path is only needed to rename. - let nf = ef.into_new_flow(flow_path); + let mut nf = ef.into_new_flow(flow_path); + // A `<= 0` flow timeout is "unset", not a 0-second limit (see create_flow). + nf.timeout = windmill_common::runnable_settings::none_if_non_positive(nf.timeout); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 20aad98864..c2f023ce5a 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -914,6 +914,13 @@ async fn create_script_internal<'c>( } check_scopes(&authed, || format!("scripts:write:{}", ns.path))?; + // Normalize positive-only settings so a `<= 0` value (e.g. a CLI-pushed `0`) persists as + // disabled rather than as a zero-slot concurrency cap or a 0-second timeout. Deserialization + // already normalizes the concurrency fields; re-applying here also covers `timeout` and any + // NewScript built in-process rather than from a request body. + ns.timeout = windmill_common::runnable_settings::none_if_non_positive(ns.timeout); + ns.concurrency_settings = ns.concurrency_settings.normalized(); + guard_script_from_debounce_data(&ns).await?; let codebase = ns.codebase.as_ref(); diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 4911b08421..433d2e4ed5 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1107,15 +1107,16 @@ async fn commit_completed_job( // Resolve the concurrency-limit settings on the pool *before* opening the // completion transaction: doing it inside the tx would hold a second // simultaneous connection from the small per-worker pool. - let has_concurrent_limit = completed_job.concurrent_limit.is_some() - || windmill_common::runnable_settings::prefetch_cached_from_handle( - completed_job.runnable_settings_handle, - db, - ) - .await? - .1 - .concurrent_limit - .is_some(); + let has_concurrent_limit = has_active_concurrency_limit(completed_job.concurrent_limit) + || has_active_concurrency_limit( + windmill_common::runnable_settings::prefetch_cached_from_handle( + completed_job.runnable_settings_handle, + db, + ) + .await? + .1 + .concurrent_limit, + ); // A genuine NUL (U+0000) in the result serializes to a `\u0000` escape that // the jsonb `result` column rejects with 22P05 ("unsupported Unicode escape @@ -3921,7 +3922,7 @@ pub async fn pull( let pulled_job_result = match job { #[cfg(feature = "private")] Some(job) - if concurrency_settings.concurrent_limit.is_some() + if has_active_concurrency_limit(concurrency_settings.concurrent_limit) // Concurrency limit is available for either enterprise job or dependency job && (cfg!(feature = "enterprise") || (job.is_dependency() && !*WMDEBUG_NO_DEBOUNCING)) => { @@ -3985,7 +3986,8 @@ pub async fn pull( .1 .maybe_fallback(None, job.concurrent_limit, job.concurrency_time_window_s); - let has_concurent_limit = concurrency_settings.concurrent_limit.is_some(); + let has_concurent_limit = + has_active_concurrency_limit(concurrency_settings.concurrent_limit); #[cfg(not(feature = "enterprise"))] if has_concurent_limit && !job.is_dependency() { @@ -3994,7 +3996,7 @@ pub async fn pull( #[cfg(not(feature = "enterprise"))] let has_concurent_limit = job.is_dependency() - && job.concurrent_limit.is_some() + && has_active_concurrency_limit(job.concurrent_limit) && cfg!(feature = "private") && !*WMDEBUG_NO_DEBOUNCING; // if we don't have private flag, we don't have concurrency limit @@ -4162,6 +4164,13 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>( Ok(job_and_suspended) } +/// A concurrency limit is only active when it caps at 1+ slots. `Some(0)` (or negative) is +/// a disabled limit, not a zero-slot one — see [`ConcurrencySettings::normalized`]. The gate +/// checks must use this instead of `.is_some()` so a legacy stored `0` behaves as disabled. +pub fn has_active_concurrency_limit(concurrent_limit: Option) -> bool { + concurrent_limit.is_some_and(|n| n > 0) +} + pub async fn custom_concurrency_key( db: &Pool, job_id: &Uuid, @@ -6141,6 +6150,11 @@ async fn push_inner<'c, 'd>( }, }; + // Guard against an already-stored `concurrent_limit <= 0` reaching the queue: it would + // register a zero-slot concurrency key and permanently block the job. Coerce it to + // disabled before it is persisted onto the job row / concurrency key here. + concurrency_settings = concurrency_settings.normalized(); + // Enforce concurrency limit on all dependency jobs. // TODO: We can ignore this for scripts djobs. The main reason we need all djobs to be sequential is because we have // nodes_to_relock and we need all locks whose corresponding steps aren't in nodes_to_relock be already present. @@ -6328,7 +6342,7 @@ async fn push_inner<'c, 'd>( check_workspace_queue_cap(&mut *tx, workspace_id).await?; } - if concurrency_settings.concurrent_limit.is_some() { + if has_active_concurrency_limit(concurrency_settings.concurrent_limit) { let concurrency_key = resolve_concurrency_key( workspace_id, &args, @@ -6732,7 +6746,7 @@ pub async fn insert_concurrency_key_capped<'d, 'c, E: PgExecutor<'c> + Copy>( custom_concurrency_key, ); #[cfg(feature = "cloud")] - if *CLOUD_HOSTED && concurrent_limit.is_some() { + if *CLOUD_HOSTED && has_active_concurrency_limit(concurrent_limit) { check_concurrency_key_queue_cap(db, &concurrency_key).await?; } #[cfg(not(feature = "cloud"))] diff --git a/backend/windmill-queue/tests/concurrency_limit_zero_test.rs b/backend/windmill-queue/tests/concurrency_limit_zero_test.rs new file mode 100644 index 0000000000..bd72571bcd --- /dev/null +++ b/backend/windmill-queue/tests/concurrency_limit_zero_test.rs @@ -0,0 +1,21 @@ +//! Runtime gate for the `Some(0)` concurrency footgun: a stored `concurrent_limit <= 0` +//! must read as "disabled", never as a zero-slot cap that permanently blocks the job at the +//! concurrency gate (the re-queue storm the zombie monitor eventually fails as a fake OOM). +//! +//! Run with: +//! cargo test -p windmill-queue --test concurrency_limit_zero_test + +use windmill_queue::jobs::has_active_concurrency_limit; + +#[test] +fn zero_and_negative_are_not_active_limits() { + assert!(!has_active_concurrency_limit(None)); + assert!(!has_active_concurrency_limit(Some(0))); + assert!(!has_active_concurrency_limit(Some(-1))); +} + +#[test] +fn positive_limit_is_active() { + assert!(has_active_concurrency_limit(Some(1))); + assert!(has_active_concurrency_limit(Some(i32::MAX))); +} diff --git a/backend/windmill-types/src/runnable_settings.rs b/backend/windmill-types/src/runnable_settings.rs index ea0fd30dc0..038246cbe6 100644 --- a/backend/windmill-types/src/runnable_settings.rs +++ b/backend/windmill-types/src/runnable_settings.rs @@ -93,9 +93,7 @@ pub struct DebouncingSettings { pub debounce_args_to_accumulate: Option>, } -#[derive( - Debug, Default, Clone, Serialize, Deserialize, Hash, PartialEq, sqlx::FromRow, sqlx::Decode, -)] +#[derive(Debug, Default, Clone, Serialize, Hash, PartialEq, sqlx::FromRow, sqlx::Decode)] pub struct ConcurrencySettings { #[serde(skip_serializing_if = "Option::is_none")] pub concurrency_key: Option, @@ -105,7 +103,65 @@ pub struct ConcurrencySettings { pub concurrency_time_window_s: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, Default)] +/// Shared normalization for the positive-only `Option` runnable settings +/// (`concurrent_limit`, `timeout`, ...): a `<= 0` value is never meaningful — zero +/// concurrent slots permanently blocks a runnable at the concurrency gate (a re-queue +/// storm the zombie monitor eventually fails with a misleading OOM error), and a +/// 0-second timeout kills every job on the spot. The frontend already treats `0` as +/// "disabled", so `<= 0` maps to `None` (unset) everywhere. Idempotent. +pub fn none_if_non_positive(v: Option) -> Option { + v.filter(|n| *n > 0) +} + +/// Coerce a `concurrent_limit <= 0` to disabled, dropping the now-meaningless time window +/// alongside it. Idempotent. +fn normalize_concurrency( + concurrent_limit: &mut Option, + concurrency_time_window_s: &mut Option, +) { + if none_if_non_positive(*concurrent_limit).is_none() { + *concurrent_limit = None; + *concurrency_time_window_s = None; + } +} + +impl ConcurrencySettings { + pub fn normalized(mut self) -> Self { + normalize_concurrency( + &mut self.concurrent_limit, + &mut self.concurrency_time_window_s, + ); + self + } +} + +// Manual `Deserialize` so every ingestion path (script/flow create & update, app and +// http-trigger payloads, and read-back of already-stored settings) normalizes a `<= 0` +// limit uniformly, without each call site remembering to call `normalized()`. +impl<'de> Deserialize<'de> for ConcurrencySettings { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + concurrency_key: Option, + #[serde(default)] + concurrent_limit: Option, + #[serde(default)] + concurrency_time_window_s: Option, + } + let Raw { concurrency_key, concurrent_limit, concurrency_time_window_s } = + Raw::deserialize(deserializer)?; + Ok( + ConcurrencySettings { concurrency_key, concurrent_limit, concurrency_time_window_s } + .normalized(), + ) + } +} + +#[derive(Debug, Clone, Serialize, sqlx::FromRow, Default)] pub struct ConcurrencySettingsWithCustom { #[serde(skip_serializing_if = "Option::is_none")] pub custom_concurrency_key: Option, @@ -115,6 +171,41 @@ pub struct ConcurrencySettingsWithCustom { pub concurrency_time_window_s: Option, } +impl ConcurrencySettingsWithCustom { + pub fn normalized(mut self) -> Self { + normalize_concurrency( + &mut self.concurrent_limit, + &mut self.concurrency_time_window_s, + ); + self + } +} + +impl<'de> Deserialize<'de> for ConcurrencySettingsWithCustom { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + custom_concurrency_key: Option, + #[serde(default)] + concurrent_limit: Option, + #[serde(default)] + concurrency_time_window_s: Option, + } + let Raw { custom_concurrency_key, concurrent_limit, concurrency_time_window_s } = + Raw::deserialize(deserializer)?; + Ok(ConcurrencySettingsWithCustom { + custom_concurrency_key, + concurrent_limit, + concurrency_time_window_s, + } + .normalized()) + } +} + impl DebouncingSettings { pub fn maybe_fallback( self, @@ -142,11 +233,15 @@ impl ConcurrencySettings { concurrent_limit: Option, concurrency_time_window_s: Option, ) -> Self { + // Legacy columns can still hold a stored `0` that predates ingestion normalization, + // so re-normalize here: this is the single load boundary for every DB-backed read + // (script/schedule read, flow value, and the worker pull path). Self { concurrency_key: self.concurrency_key.or(concurrency_key), concurrent_limit: self.concurrent_limit.or(concurrent_limit), concurrency_time_window_s: self.concurrency_time_window_s.or(concurrency_time_window_s), } + .normalized() } } @@ -229,4 +324,91 @@ mod tests { assert_eq!(r, Retry::default()); assert_eq!(r.exponential.multiplier, 1); } + + // The positive-only settings share one rule: `<= 0` means "unset". This is what keeps a + // stored `0` from being enforced as a zero-slot cap or a 0-second timeout. + #[test] + fn none_if_non_positive_coerces_zero_and_negative() { + assert_eq!(none_if_non_positive(Some(0)), None); + assert_eq!(none_if_non_positive(Some(-3)), None); + assert_eq!(none_if_non_positive(Some(1)), Some(1)); + assert_eq!(none_if_non_positive(Some(i32::MAX)), Some(i32::MAX)); + assert_eq!(none_if_non_positive(None), None); + } + + // Ingestion path (scripts flatten this on `NewScript`, flows on `FlowModule`): a `0` + // concurrent_limit deserializes to disabled and drops the now-meaningless time window, + // while a real limit and its window survive untouched. + #[test] + fn concurrency_settings_deserialize_normalizes_non_positive_limit() { + let zero: ConcurrencySettings = serde_json::from_value( + serde_json::json!({"concurrent_limit": 0, "concurrency_time_window_s": 30}), + ) + .unwrap(); + assert_eq!(zero.concurrent_limit, None); + assert_eq!(zero.concurrency_time_window_s, None); + + let negative: ConcurrencySettings = + serde_json::from_value(serde_json::json!({"concurrent_limit": -1})).unwrap(); + assert_eq!(negative.concurrent_limit, None); + + let real: ConcurrencySettings = serde_json::from_value( + serde_json::json!({"concurrent_limit": 2, "concurrency_time_window_s": 30}), + ) + .unwrap(); + assert_eq!(real.concurrent_limit, Some(2)); + assert_eq!(real.concurrency_time_window_s, Some(30)); + } + + // Per-flow-step overrides use the `custom_concurrency_key` variant; same rule. + #[test] + fn concurrency_settings_with_custom_deserialize_normalizes() { + let zero: ConcurrencySettingsWithCustom = serde_json::from_value( + serde_json::json!({"concurrent_limit": 0, "concurrency_time_window_s": 5}), + ) + .unwrap(); + assert_eq!(zero.concurrent_limit, None); + assert_eq!(zero.concurrency_time_window_s, None); + } + + // A normalized value serializes with the limit omitted (skip_serializing_if), matching the + // frontend's "disabled" representation instead of re-emitting a `0`. + #[test] + fn normalized_disabled_limit_serializes_as_omitted() { + let s = + ConcurrencySettings { concurrent_limit: Some(0), ..Default::default() }.normalized(); + let json = serde_json::to_value(&s).unwrap(); + assert!(json.get("concurrent_limit").is_none()); + } + + // Runtime load boundary: legacy rows still hold a raw `0` in the fallback columns. The + // fallback must not resurrect it as an active limit. + #[test] + fn maybe_fallback_normalizes_legacy_zero_column() { + let merged = ConcurrencySettings::default().maybe_fallback(None, Some(0), Some(30)); + assert_eq!(merged.concurrent_limit, None); + assert_eq!(merged.concurrency_time_window_s, None); + } + + // `NewScript`/`FlowModule` embed the settings via `#[serde(flatten)]`, which drives the + // manual Deserialize through a content-buffer deserializer rather than a plain map. Guard + // that path: normalization must still fire and sibling fields must still parse. + #[test] + fn flattened_concurrency_normalizes_and_preserves_siblings() { + #[derive(Deserialize)] + struct Wrapper { + name: String, + #[serde(flatten)] + concurrency: ConcurrencySettings, + } + let w: Wrapper = serde_json::from_value(serde_json::json!({ + "name": "s", + "concurrent_limit": 0, + "concurrency_time_window_s": 42, + })) + .unwrap(); + assert_eq!(w.name, "s"); + assert_eq!(w.concurrency.concurrent_limit, None); + assert_eq!(w.concurrency.concurrency_time_window_s, None); + } } diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 6618ac7906..a406f3610c 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1081,7 +1081,9 @@ pub async fn resolve_job_timeout( *MAX_TIMEOUT_DURATION }; - match custom_timeout_secs { + // A `custom_timeout_secs <= 0` is not a 0-second limit but "unset": fall through to the + // default/global-max timeout instead of killing the job immediately. + match windmill_common::runnable_settings::none_if_non_positive(custom_timeout_secs) { Some(timeout_secs) if Duration::from_secs(timeout_secs as u64) < global_max_timeout_duration => { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 1b11d735c3..003ddd3876 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -3892,15 +3892,16 @@ pub async fn handle_queued_job( #[cfg(not(feature = "enterprise"))] if let Connection::Sql(db) = conn { - if (job.concurrent_limit.is_some() - || windmill_common::runnable_settings::prefetch_cached_from_handle( - job.runnable_settings_handle, - db, - ) - .await? - .1 - .concurrent_limit - .is_some()) + if (windmill_queue::jobs::has_active_concurrency_limit(job.concurrent_limit) + || windmill_queue::jobs::has_active_concurrency_limit( + windmill_common::runnable_settings::prefetch_cached_from_handle( + job.runnable_settings_handle, + db, + ) + .await? + .1 + .concurrent_limit, + )) && !job.kind.is_dependency() { logs.push_str("---\n"); diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index d2e5b20a1d..0793aff053 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -1525,9 +1525,14 @@ pub async fn update_flow_status_after_job_completion_internal( let concurrency_key = tag_and_concurrency_key .as_ref() .and_then(|x| x.concurrency_key.clone()); - let concurrent_limit = tag_and_concurrency_key - .as_ref() - .and_then(|x| x.concurrent_limit); + // `concurrent_limit` here can come straight from the raw flow JSON (see + // get_tag_and_concurrency), bypassing the ConcurrencySettings deserialization guard, + // so a stored `0` must still be coerced to disabled before we register a key for it. + let concurrent_limit = windmill_common::runnable_settings::none_if_non_positive( + tag_and_concurrency_key + .as_ref() + .and_then(|x| x.concurrent_limit), + ); let concurrency_time_window_s = tag_and_concurrency_key .as_ref() .and_then(|x| x.concurrency_time_window_s); @@ -4389,13 +4394,10 @@ async fn push_next_flow_job( ) .await?; - if timeout_value < 0 { - return Err(Error::ExecutionErr( - "Timeout value cannot be negative".to_string(), - )); - } - - Some(timeout_value) + // A `<= 0` step timeout (including a negative eval) means "no override": fall back + // to the referenced runnable's own timeout rather than a 0-second/negative timeout + // that would kill the step instantly. + effective_flow_step_timeout(Some(timeout_value), payload_tag.timeout) } else { payload_tag.timeout }; @@ -6048,6 +6050,18 @@ async fn flow_to_payload( }) } +/// Effective timeout for a flow step given the module's (already-evaluated) timeout override and +/// the timeout inherited from the referenced runnable. A `<= 0` override — or none — means "no +/// override": fall back to the inherited value (which is itself `None` when unset, i.e. the +/// instance default). A positive override wins. This keeps a step `timeout: 0` equivalent to an +/// omitted one rather than a 0-second, instant-kill timeout. +pub(crate) fn effective_flow_step_timeout( + module_override: Option, + inherited: Option, +) -> Option { + windmill_common::runnable_settings::none_if_non_positive(module_override).or(inherited) +} + pub async fn script_to_payload( script_hash: Option, script_path: String, @@ -6137,11 +6151,13 @@ pub async fn script_to_payload( module.delete_after_use.unwrap_or(false) || delete_after_use.unwrap_or(false); let final_delete_after_secs = module.delete_after_secs.or(delete_after_secs); - let flow_step_timeout = if module.timeout.is_some() { - None - } else { - script_timeout - }; + // Always carry the referenced script's own timeout as the inherited fallback. The module's + // timeout override (if any) is selected at the push site, where a `<= 0` override is treated + // as "no override" and falls back to this value — so `timeout: 0` on a step means "use the + // script's timeout", not a 0-second (immediate-kill) timeout. Normalize the inherited value + // too, so a legacy `0` script timeout resolves to the default rather than a zero-second kill. + let flow_step_timeout = + windmill_common::runnable_settings::none_if_non_positive(script_timeout); Ok(JobPayloadWithTag { payload, tag, @@ -6266,9 +6282,25 @@ pub async fn get_previous_job_result( #[cfg(test)] mod tests { - use super::extract_chat_message_from_flow_result; + use super::{effective_flow_step_timeout, extract_chat_message_from_flow_result}; use serde_json::{json, value::to_raw_value}; + // A `<= 0` step timeout override must behave as "no override" and inherit the referenced + // script's timeout, not collapse to a 0-second (instant-kill) timeout. A positive override + // still wins. Guards the flow-step timeout footgun. + #[test] + fn flow_step_timeout_zero_or_negative_inherits_script_timeout() { + // zero / negative override -> inherited script timeout + assert_eq!(effective_flow_step_timeout(Some(0), Some(300)), Some(300)); + assert_eq!(effective_flow_step_timeout(Some(-5), Some(300)), Some(300)); + // no inherited timeout either -> None (falls through to the instance default) + assert_eq!(effective_flow_step_timeout(Some(0), None), None); + // positive override wins over the inherited value + assert_eq!(effective_flow_step_timeout(Some(120), Some(300)), Some(120)); + // no override -> inherited + assert_eq!(effective_flow_step_timeout(None, Some(300)), Some(300)); + } + #[test] fn pretty_prints_full_result_when_no_override_is_present() { let value = json!({ diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 3db9c7b53e..82fe63719b 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -100,6 +100,30 @@ export function isRawAppBackendPath(filePath: string): boolean { return isRawAppBackendPathInternal(filePath); } +/** + * The positive-only runnable settings (concurrent_limit, timeout, ...) treat any `<= 0` + * value as "unset": the backend coerces it to null (a 0-slot concurrency limit bricks the + * runnable, a 0s timeout kills every run). Coerce to undefined so it is serialized as + * omitted, never as 0, and redeploys don't churn against the backend-normalized value. + */ +export function nonePositiveInt( + v: number | undefined | null +): number | undefined { + return v != null && v > 0 ? v : undefined; +} + +/** + * Normalize a concurrent_limit + its time window together: when the limit is disabled + * (<= 0) the window is dropped too. Returns [concurrent_limit, concurrency_time_window_s]. + */ +export function normalizeConcurrency( + concurrentLimit: number | undefined | null, + concurrencyTimeWindowS?: number | undefined | null +): [number | undefined, number | undefined] { + const limit = nonePositiveInt(concurrentLimit); + return limit === undefined ? [undefined, undefined] : [limit, concurrencyTimeWindowS ?? undefined]; +} + /** * Checks if a path is inside a normal app folder (inline script). * Matches patterns like: .../myApp.app/... or .../myApp__app/... @@ -469,6 +493,15 @@ export async function handleFile( const moduleFolderPath = scriptBasePath + getModuleFolderSuffix(); const modules = await readModulesFromDisk(moduleFolderPath, opts?.defaultTs, moduleEntryPoint); + // A concurrent_limit of <= 0 means "concurrency disabled", not "zero slots" (which + // would brick the runnable at the queue's concurrency gate). Emit it as omitted rather + // than 0 so a redeploy never re-persists a zero-slot limit, and drop the now-meaningless + // time window alongside it. Mirrors the backend's ConcurrencySettings::normalized. + const [normConcurrentLimit, normConcurrencyTimeWindowS] = normalizeConcurrency( + typed?.concurrent_limit, + typed?.concurrency_time_window_s + ); + const requestBodyCommon: NewScript = { content, description: typed?.description ?? "", @@ -482,8 +515,8 @@ export async function handleFile( ws_error_handler_muted: typed?.ws_error_handler_muted, dedicated_worker: typed?.dedicated_worker, cache_ttl: typed?.cache_ttl, - concurrency_time_window_s: typed?.concurrency_time_window_s, - concurrent_limit: typed?.concurrent_limit, + concurrency_time_window_s: normConcurrencyTimeWindowS, + concurrent_limit: normConcurrentLimit, deployment_message: message, restart_unless_cancelled: typed?.restart_unless_cancelled, visible_to_runner_only: typed?.visible_to_runner_only, @@ -493,7 +526,7 @@ export async function handleFile( debounce_key: typed?.debounce_key, debounce_delay_s: typed?.debounce_delay_s, codebase: await codebase?.getDigest(forceTar), - timeout: typed?.timeout, + timeout: nonePositiveInt(typed?.timeout), on_behalf_of_email: typed?.on_behalf_of_email, envs: typed?.envs, modules: modules, @@ -530,9 +563,13 @@ export async function handleFile( remote.ws_error_handler_muted && typed.dedicated_worker == remote.dedicated_worker && typed.cache_ttl == remote.cache_ttl && - typed.concurrency_time_window_s == - remote.concurrency_time_window_s && - typed.concurrent_limit == remote.concurrent_limit && + normConcurrencyTimeWindowS == + normalizeConcurrency( + remote.concurrent_limit, + remote.concurrency_time_window_s + )[1] && + normConcurrentLimit == + normalizeConcurrency(remote.concurrent_limit)[0] && Boolean(typed.restart_unless_cancelled) == Boolean(remote.restart_unless_cancelled) && Boolean(typed.visible_to_runner_only) == @@ -540,7 +577,7 @@ export async function handleFile( Boolean(typed.has_preprocessor) == Boolean(remote.has_preprocessor) && typed.priority == Boolean(remote.priority) && - typed.timeout == remote.timeout && + nonePositiveInt(typed.timeout) == nonePositiveInt(remote.timeout) && //@ts-ignore typed.concurrency_key == remote["concurrency_key"] && typed.debounce_key == remote["debounce_key"] && From f02df7fc454b0c2a2afa9ae9848e26af0e379246 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 23 Jul 2026 19:09:28 +0200 Subject: [PATCH 51/66] feat(monitor): make between-steps zombie flows hand-recoverable (#10287) * feat(monitor): make between-steps zombie flows hand-recoverable When a worker is OOM-killed mid state-transition, the flow is reaped as a between-steps zombie (children all success, module still InProgress). We do not auto-recover (a re-driven transition can OOM again), so instead: - Append actionable recovery guidance to the cancellation reason when the reaped step's state is derivable (every child a success completion): which step, iterations completed, raise memory then restart-from-step (UI + API). - Restart-from-step now reuses a zombie step verbatim (InProgress with all children successful) and restarts from the next step, so no completed child re-runs; downstream steps re-derive its result from flow_jobs on demand. - Cast flow_status ::text in the reaper query: reading the jsonb column as Box included the binary version byte and silently failed FlowStatus parsing (disabling the restart-not-yet-started branch since the v2 migration). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): only reuse a between-steps zombie step that provably finished Address review findings on the zombie-restart reuse path: - Require structural completeness (FlowStatusModule::is_between_steps_complete): a serial for-loop / branch-all reaped mid-fan-out has an all-success prefix but unrun remaining iterations, so the cursor must sit on the last element; while-loops are never derivable (continuation is a post-iteration condition). Parallel containers preallocate all children, so success alone is conclusive. Shared by the monitor guidance and the restart resolution. - Decline reuse when the step carries stop_after_if / stop_after_all_iters_if: those predicates decide whether downstream steps run, and reuse would bypass them; such a step re-runs instead. - Decline reuse when the zombie step is the last module (advancing past it lands on the failure step); it falls back to the existing re-run path. - Unit tests for is_between_steps_complete and an integration test asserting a mid-iteration serial-loop zombie is re-run, not reused. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): align zombie recovery guidance with restart eligibility Address CI review findings: - Exclude skip_if / suspend / sleep (not just stop predicates) from reuse via FlowModule::allows_zombie_reuse, so a skipped/suspend-armed step is never synthesized as Success (which would strand a restart waiting on an approval it never armed). - The reaper does not load the flow definition, so it cannot know whether restart will reuse or re-run a given step; reword the guidance to state both outcomes (reuse where derivable, re-run for the flow's last step or one carrying a stop/skip condition, approval, or sleep) instead of promising "no re-run". - Make the mid-iteration regression test exercise the cursor-completeness guard: a downstream step makes the loop non-final, so reuse is prevented only by the guard; a truncated loop result would then fail the assertion. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): never let zombie reuse swallow a nested restart request A nested restart (RestartedFrom.nested) descends into the restart step's child to re-run an inner step. For an eligible zombie BranchOne/Subflow the outer branch_or_iteration_n is None, so reuse fired, skipped the container, and the explicitly requested inner step never re-ran. Thread the presence of a nested chain into restarted_flows_resolution and decline reuse when set. Regression test added (RED without the guard: the nested target is reused instead of re-run). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): don't auto-requeue preprocessor zombies as unstarted flows The ::text parse fix re-activated the "hasn't started yet, restart it" branch, but its `modules[0] == WaitingForPriorSteps` check also matches a flow whose preprocessor is still InProgress (step == -1, first module waiting). Requeuing such a flow re-runs the preprocessor, duplicating side effects / repeating the OOM. Gate the branch on FlowStatus::is_not_yet_started, which also requires the preprocessor (if any) to be WaitingForPriorSteps. Unit-tested. Also drop the numbered procedural narration from the happy-path test comments. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): only emit restart guidance for restartable (deployed, top-level) flows The recovery guidance points operators at the run page's "Re-start from" button and the restart API, but both require a top-level deployed flow: a preview has no flow path (the button is hidden, the API 400s) and a subflow child restarts via its root, not itself. Gate the guidance on runnable_path IS NOT NULL AND parent_job IS NULL so previews/subflows keep the existing wording instead of being told to use a button/endpoint that isn't there. Verified end-to-end: a reaped preview gets no RECOVERY block, a reaped deployed flow does. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): gate recovery guidance on kind='flow' to match the restart surface Addresses review nit: a pathful editor preview (kind='flowpreview' with a runnable_path) satisfied the previous runnable_path check but the run page only renders the "Re-start from" button for kind='flow'. Match that condition exactly so previews/singlestepflow keep the plain wording. Verified end-to-end: a reaped pathful preview now gets no RECOVERY block. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): disable zombie reuse for raw-flow (editor preview) restarts A JobPayload::RawFlow restart queues the request's current, possibly EDITED, definition, but restarted_flows_resolution validates reuse against the completed job's STORED definition. For an eligible preview zombie, editing the restart step and restarting from it would synthesize Success from the old children and skip the edit. Thread allow_zombie_reuse into the resolver (true only for JobPayload::RestartedFlow, which queues the stored definition) and decline reuse for raw-flow restarts. Regression test added (RED without the guard: the edited step is skipped and the old result is reused). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(sqlx): add offline cache for zombie_flow_recovery test queries The integration test's UPDATE v2_job_completed queries had no .sqlx entry, so the CI SQLX_OFFLINE build of the test failed to compile. Regenerated with --all-targets --features deno_core,quickjs to capture the test-target queries. Co-Authored-By: Claude Opus 4.8 (1M context) * test(monitor): drop procedural narration from the raw-flow zombie test Per AGENTS.md (comments record constraints, not narration): remove the two step-describing comments the reviewer flagged; the test doc comment already carries the durable rationale. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): restrict zombie reuse to monitor-reaped flows The reuse predicate matched the InProgress/all-children-success shape without checking provenance, so an ordinary force-cancel at the same boundary (a child succeeded before its parent transition landed) would also be reused, dropping the usual restart-from-step re-run. Gate reuse on canceled_by = 'monitor' (the username the zombie reaper cancels with). Regression test added (RED without the guard: a user-cancelled flow reuses the child instead of re-running it). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): reuse zombie step on Some(0) too, so the run-page button works The run page's "Re-start from" button always sends branch_or_iteration_n = 0 (never omits it), but reuse only fired for None, so the exact UI path the recovery message points to would re-run the children instead of reusing them. Treat a whole-step restart (None or Some(0)) as reuse-eligible; Some(n>=1) keeps the explicit partial-container restart. Verified against the live EE restart API with branch_or_iteration_n=0: all loop-iteration child UUIDs are reused. Happy- path test now sends Some(0) to match the button. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...291f4ff5c979be956369fa4406d789cca92fc.json | 112 +++ ...9dd4864f4e0aa0093df47df7d444ee748dbb2.json | 23 + ...e8a4b96c9629cdc785edc7a389c3eb7269608.json | 22 + ...9d4c24699c2c7abad4086e0bb876c5c6b2c38.json | 15 + ...31fe94999ca4f11934fbab4638b2653a678dc.json | 15 + ...7e00942a8f4f5251502ef8efc0f510a857551.json | 15 + ...8e375d93caf76c4cec362c2b92d2a7b8704a2.json | 83 +++ backend/src/monitor.rs | 119 +++- backend/tests/zombie_flow_recovery.rs | 661 ++++++++++++++++++ backend/windmill-queue/src/jobs.rs | 130 +++- backend/windmill-types/src/flow_status.rs | 154 ++++ backend/windmill-types/src/flows.rs | 13 + 12 files changed, 1352 insertions(+), 10 deletions(-) create mode 100644 backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json create mode 100644 backend/.sqlx/query-387e135bf363533089493c35eb39dd4864f4e0aa0093df47df7d444ee748dbb2.json create mode 100644 backend/.sqlx/query-4371e59b85cc279dba03800aacde8a4b96c9629cdc785edc7a389c3eb7269608.json create mode 100644 backend/.sqlx/query-4a35f9a0201e283d854bd77f7f79d4c24699c2c7abad4086e0bb876c5c6b2c38.json create mode 100644 backend/.sqlx/query-60201b41a0bfea89a201a25ae2431fe94999ca4f11934fbab4638b2653a678dc.json create mode 100644 backend/.sqlx/query-7d2923f940cf5a8cbbdc51de91c7e00942a8f4f5251502ef8efc0f510a857551.json create mode 100644 backend/.sqlx/query-fa1b9fcdd344fa2c88a187f4a558e375d93caf76c4cec362c2b92d2a7b8704a2.json create mode 100644 backend/tests/zombie_flow_recovery.rs diff --git a/backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json b/backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json new file mode 100644 index 0000000000..1ee774a993 --- /dev/null +++ b/backend/.sqlx/query-1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status)::text AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "is_flow_step?", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "flow_status: Box", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "last_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "same_worker?", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "worker?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "worker_last_ping?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "worker_memory_usage?", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "worker_wm_memory_usage?", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "worker_memory_total?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "worker_group?", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "worker_version?", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "worker_current_job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 15, + "name": "worker_instance?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + null, + null, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false + ] + }, + "hash": "1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc" +} diff --git a/backend/.sqlx/query-387e135bf363533089493c35eb39dd4864f4e0aa0093df47df7d444ee748dbb2.json b/backend/.sqlx/query-387e135bf363533089493c35eb39dd4864f4e0aa0093df47df7d444ee748dbb2.json new file mode 100644 index 0000000000..50f0bf86e5 --- /dev/null +++ b/backend/.sqlx/query-387e135bf363533089493c35eb39dd4864f4e0aa0093df47df7d444ee748dbb2.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM v2_job_completed\n WHERE workspace_id = $1 AND id = ANY($2) AND status = 'success'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "387e135bf363533089493c35eb39dd4864f4e0aa0093df47df7d444ee748dbb2" +} diff --git a/backend/.sqlx/query-4371e59b85cc279dba03800aacde8a4b96c9629cdc785edc7a389c3eb7269608.json b/backend/.sqlx/query-4371e59b85cc279dba03800aacde8a4b96c9629cdc785edc7a389c3eb7269608.json new file mode 100644 index 0000000000..4bf6f3692b --- /dev/null +++ b/backend/.sqlx/query-4371e59b85cc279dba03800aacde8a4b96c9629cdc785edc7a389c3eb7269608.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (kind = 'flow' AND parent_job IS NULL) AS \"restartable!\"\n FROM v2_job WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "restartable!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4371e59b85cc279dba03800aacde8a4b96c9629cdc785edc7a389c3eb7269608" +} diff --git a/backend/.sqlx/query-4a35f9a0201e283d854bd77f7f79d4c24699c2c7abad4086e0bb876c5c6b2c38.json b/backend/.sqlx/query-4a35f9a0201e283d854bd77f7f79d4c24699c2c7abad4086e0bb876c5c6b2c38.json new file mode 100644 index 0000000000..6c5b364a5b --- /dev/null +++ b/backend/.sqlx/query-4a35f9a0201e283d854bd77f7f79d4c24699c2c7abad4086e0bb876c5c6b2c38.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'monitor',\n flow_status = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "4a35f9a0201e283d854bd77f7f79d4c24699c2c7abad4086e0bb876c5c6b2c38" +} diff --git a/backend/.sqlx/query-60201b41a0bfea89a201a25ae2431fe94999ca4f11934fbab4638b2653a678dc.json b/backend/.sqlx/query-60201b41a0bfea89a201a25ae2431fe94999ca4f11934fbab4638b2653a678dc.json new file mode 100644 index 0000000000..9f4c0f9bf6 --- /dev/null +++ b/backend/.sqlx/query-60201b41a0bfea89a201a25ae2431fe94999ca4f11934fbab4638b2653a678dc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'admin',\n flow_status = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "60201b41a0bfea89a201a25ae2431fe94999ca4f11934fbab4638b2653a678dc" +} diff --git a/backend/.sqlx/query-7d2923f940cf5a8cbbdc51de91c7e00942a8f4f5251502ef8efc0f510a857551.json b/backend/.sqlx/query-7d2923f940cf5a8cbbdc51de91c7e00942a8f4f5251502ef8efc0f510a857551.json new file mode 100644 index 0000000000..7967a56868 --- /dev/null +++ b/backend/.sqlx/query-7d2923f940cf5a8cbbdc51de91c7e00942a8f4f5251502ef8efc0f510a857551.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed\n SET status = 'canceled', canceled_by = 'monitor', canceled_reason = 'zombie flow',\n flow_status = $2\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "7d2923f940cf5a8cbbdc51de91c7e00942a8f4f5251502ef8efc0f510a857551" +} diff --git a/backend/.sqlx/query-fa1b9fcdd344fa2c88a187f4a558e375d93caf76c4cec362c2b92d2a7b8704a2.json b/backend/.sqlx/query-fa1b9fcdd344fa2c88a187f4a558e375d93caf76c4cec362c2b92d2a7b8704a2.json new file mode 100644 index 0000000000..0d9e4ac3ad --- /dev/null +++ b/backend/.sqlx/query-fa1b9fcdd344fa2c88a187f4a558e375d93caf76c4cec362c2b92d2a7b8704a2.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n j.runnable_path as script_path, j.runnable_id AS \"script_hash: ScriptHash\",\n j.kind AS \"job_kind!: JobKind\", c.canceled_by,\n COALESCE(c.flow_status, c.workflow_as_code_status) AS \"flow_status: Json>\",\n j.raw_flow AS \"raw_flow: Json>\"\n FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.id = $1 and j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "script_hash: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "job_kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "flow_status: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "raw_flow: Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + null, + true + ] + }, + "hash": "fa1b9fcdd344fa2c88a187f4a558e375d93caf76c4cec362c2b92d2a7b8704a2" +} diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 4947b9b211..d1e63958e2 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4946,11 +4946,13 @@ async fn find_zombie_flow_culprit_worker( } async fn handle_zombie_flows(db: &DB) -> error::Result<()> { + // flow_status is cast ::text on purpose: decoding the jsonb column directly as Box + // yields its binary form (leading version byte) and fails serde_json parsing at column 1. let flows = sqlx::query!( r#" SELECT j.id AS "id!", j.workspace_id AS "workspace_id!", j.parent_job, j.flow_step_id IS NOT NULL AS "is_flow_step?", - COALESCE(s.flow_status, s.workflow_as_code_status) AS "flow_status: Box", r.ping AS last_ping, j.same_worker AS "same_worker?", + COALESCE(s.flow_status, s.workflow_as_code_status)::text AS "flow_status: Box", r.ping AS last_ping, j.same_worker AS "same_worker?", q.worker AS "worker?", wp.ping_at AS "worker_last_ping?", wp.memory_usage AS "worker_memory_usage?", @@ -4979,11 +4981,7 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { .as_deref() .and_then(|x| serde_json::from_str::(x).ok()); if !flow.same_worker.unwrap_or(false) - && status.is_some_and(|s| { - s.modules - .get(0) - .is_some_and(|x| matches!(x, FlowStatusModule::WaitingForPriorSteps { .. })) - }) + && status.as_ref().is_some_and(|s| s.is_not_yet_started()) { let error_message = format!( "Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.", @@ -5294,6 +5292,18 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { format!("Flow {id} ({base_url}/run/{id}?workspace={workspace_id}) was cancelled because it") } ); + let reason = match between_steps_recovery_guidance( + db, + status.as_ref(), + id, + &workspace_id, + &base_url, + ) + .await + { + Some(guidance) => format!("{reason}\n\n{guidance}"), + None => reason, + }; report_critical_error(reason.clone(), db.clone(), Some(&flow.workspace_id), None).await; cancel_zombie_flow_job(db, flow.id, &flow.workspace_id, format!(r#"{reason} @@ -5340,6 +5350,103 @@ Please check your worker logs for more details and feel free to report it to the Ok(()) } +/// When a between-steps zombie's stuck step has every child recorded as a +/// `success` completion, the flow's state is fully derivable: only the final +/// state transition was lost to the worker failure, not any real work. In that +/// case return concrete restart-from-step recovery guidance to append to the +/// cancellation reason / critical alert. Returns `None` when the state isn't +/// derivable (some child missing or not successful), so the existing wording is +/// left untouched. Auto-recovery is deliberately not attempted (a re-driven +/// transition can OOM again on the same aggregated state; a human raises the +/// memory limit first, then restarts). +async fn between_steps_recovery_guidance( + db: &DB, + status: Option<&FlowStatus>, + flow_id: Uuid, + workspace_id: &str, + base_url: &str, +) -> Option { + // The stuck module is the current step, left InProgress because the + // transition that would have marked it Success was dropped. It is only + // derivable when its own cursor reached the end (a serial fan-out reaped + // mid-iteration has unrun work left; while-loops are never derivable). Whether + // restart reuses the children or re-runs the step (final step, or one carrying a + // stop/skip/approval/sleep) is decided by the restart path against the flow + // definition, which the reaper doesn't load; the guidance states both outcomes + // rather than promising reuse the restart might decline. + let status = status?; + let idx = usize::try_from(status.step).ok()?; + let module = status.modules.get(idx)?; + if !module.is_between_steps_complete() { + return None; + } + let step_id = module.id(); + + // Only a top-level deployed flow exposes a working restart-from-step: the run page's + // "Re-start from" button is rendered only for job_kind == 'flow' (a flowpreview, even a + // pathful editor preview, or a singlestepflow does not qualify), and a subflow child + // restarts via its root. Match that surface exactly so the guidance never points at a + // button / endpoint that isn't there; leave the existing wording otherwise. + let restartable = sqlx::query_scalar!( + r#"SELECT (kind = 'flow' AND parent_job IS NULL) AS "restartable!" + FROM v2_job WHERE id = $1"#, + flow_id, + ) + .fetch_one(db) + .await + .ok()?; + if !restartable { + return None; + } + + // Children whose completion the lost transition would have aggregated: the + // loop/branchall iterations, or the single leaf/subflow child. + let child_ids: Vec = module + .flow_jobs() + .filter(|v| !v.is_empty()) + .or_else(|| module.job().map(|j| vec![j]))?; + + // Derivable only when every child is recorded as a success completion. + let success_children = sqlx::query_scalar!( + "SELECT count(*) FROM v2_job_completed + WHERE workspace_id = $1 AND id = ANY($2) AND status = 'success'", + workspace_id, + &child_ids, + ) + .fetch_one(db) + .await + .ok()? + .unwrap_or(0); + if success_children != child_ids.len() as i64 { + return None; + } + let n = child_ids.len(); + + // For loop/branchall, name the completed iteration/branch count so the + // operator can confirm the whole fan-out is intact. + let iteration_hint = match module { + FlowStatusModule::InProgress { iterator: Some(_), .. } => { + format!(" (loop step, all {n} iterations completed)") + } + FlowStatusModule::InProgress { branchall: Some(_), .. } => { + format!(" (branchall step, all {n} branches completed)") + } + _ => String::new(), + }; + + Some(format!( + "RECOVERY: all {n} child job(s) of step `{step_id}`{iteration_hint} completed successfully; \ +only the flow's final state transition was lost to the worker failure above (not any genuine failure), so \ +the completed work is intact. To recover: first change the failure condition (raise the worker memory limit, \ +e.g. k8s `resources.limits.memory`, or move the flow to a larger worker group), then restart from step \ +`{step_id}`. Restart replays only the dropped transition and reuses the completed children where the step's \ +result is fully derivable; a step that is the flow's last, or carries a stop/skip condition, an approval, or a \ +sleep, is re-run instead (re-evaluating those on the larger worker).\n\ + UI: open {base_url}/run/{flow_id}?workspace={workspace_id} and use \"Re-start from {step_id}\".\n\ + API: POST {base_url}/api/w/{workspace_id}/jobs/restart/f/{flow_id} with body {{\"step_id\":\"{step_id}\"}}." + )) +} + async fn cancel_zombie_flow_job( db: &Pool, id: Uuid, diff --git a/backend/tests/zombie_flow_recovery.rs b/backend/tests/zombie_flow_recovery.rs new file mode 100644 index 0000000000..fb658dfb03 --- /dev/null +++ b/backend/tests/zombie_flow_recovery.rs @@ -0,0 +1,661 @@ +//! Regression test for hand-recovery of between-steps zombie flows. +//! +//! When a worker is OOM-killed mid state-transition, the zombie monitor +//! (`handle_zombie_flows` → `cancel_job` with force) reaps the flow: it lands in +//! `v2_job_completed` as `canceled`, with its `flow_status` preserved: the step +//! whose transition was lost stays `InProgress` even though all its children +//! completed successfully. This test reproduces that exact terminal state and +//! asserts that a hand-restart from the stuck step reuses every completed child +//! (no re-run) and the flow reaches success. +//! +//! The reaper itself lives in the `windmill` binary crate and is unreachable +//! from an integration test, so we reproduce the state `cancel_job(force)` +//! leaves behind directly; the fix under test is the restart-resolution path, +//! not the detection query. + +#![cfg(feature = "deno_core")] + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::flow_status::{BranchChosen, FlowStatus, RestartedFrom}; +use windmill_common::flows::FlowValue; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +/// Child job UUID for a top-level step in a completed flow's `flow_status` +/// (optionally the iteration index for a ForLoop / BranchAll container). +async fn child_job_id_for_step( + db: &Pool, + flow_job_id: uuid::Uuid, + step_id: &str, + iter: Option, +) -> uuid::Uuid { + let raw: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + flow_job_id + ) + .fetch_one(db) + .await + .unwrap() + .expect("flow_status missing"); + let status: FlowStatus = serde_json::from_value(raw).expect("parse flow_status"); + let module = status + .modules + .iter() + .find(|m| m.id() == step_id) + .expect("step in flow_status"); + match iter { + Some(i) => module.flow_jobs().expect("flow_jobs")[i], + None => module.job().expect("job"), + } +} + +/// A between-steps zombie whose fan-out completed but whose final transition was +/// lost can be hand-restarted from the stuck step, reusing every completed child +/// (including the last iteration) and reaching success. +#[sqlx::test(fixtures("base", "hello"))] +async fn test_between_steps_zombie_restart_reuses_all_children( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // A fan-out ForLoop `fanout` (2 iterations) followed by `after`, which + // consumes the loop's aggregated result. In the zombie scenario `fanout` + // finished all iterations but its final transition was lost, so `after` + // never ran. + let flow_value: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "fanout", + "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "['a', 'b']" }, + "skip_failures": false, + "parallel": false, + "modules": [{ + "id": "inner", + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": { + "v": { "type": "javascript", "expr": "flow_input.iter.value" } + }, + "content": "export function main(v: string) { return v }" + } + }] + } + }, + { + "id": "after", + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": { + "loop_res": { "type": "javascript", "expr": "results.fanout" } + }, + "content": "export function main(loop_res: string[]) { return loop_res.join(',') }" + } + } + ] + })) + .unwrap(); + + // Run to completion to obtain real, successful child jobs. + let full_run = RunJob::from(JobPayload::RawFlow { + value: flow_value.clone(), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, port) + .await; + assert!(full_run.success, "baseline run should succeed"); + assert_eq!(full_run.json_result().unwrap(), json!("a,b")); + + let orig_iter0 = child_job_id_for_step(&db, full_run.id, "fanout", Some(0)).await; + let orig_iter1 = child_job_id_for_step(&db, full_run.id, "fanout", Some(1)).await; + let orig_after = child_job_id_for_step(&db, full_run.id, "after", None).await; + + // Reproduce the zombie-reaper's terminal state: cancelled by `monitor` with + // `flow_status` frozen mid-transition: `fanout` still `InProgress` (all + // iterations done), `after` never reached. + let mut flow_status: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + full_run.id + ) + .fetch_one(&db) + .await? + .expect("flow_status"); + for m in flow_status["modules"].as_array_mut().unwrap() { + match m["id"].as_str() { + Some("fanout") => { + m["type"] = json!("InProgress"); + // A reaped loop keeps its cursor at the last iteration. + m["iterator"] = json!({ "index": 1, "itered_len": 2 }); + } + Some("after") => *m = json!({ "type": "WaitingForPriorSteps", "id": "after" }), + _ => {} + } + } + flow_status["step"] = json!(0); + sqlx::query!( + "UPDATE v2_job_completed + SET status = 'canceled', canceled_by = 'monitor', canceled_reason = 'zombie flow', + flow_status = $2 + WHERE id = $1", + full_run.id, + flow_status, + ) + .execute(&db) + .await?; + + // Hand-restart from the stuck step. `fanout` is recognised as a derivable + // between-steps zombie (all children succeeded), so it is reused verbatim and + // only the dropped transition onward is replayed. `Some(0)` is the exact value the + // run page's "Re-start from" button sends (a whole-step restart), not `None`. + let restarted = RunJob::from(JobPayload::RestartedFlow { + completed_job_id: full_run.id, + step_id: "fanout".into(), + branch_or_iteration_n: Some(0), + flow_version: None, + branch_chosen: None, + nested: None, + }) + .run_until_complete(&db, false, port) + .await; + + // Flow reaches success, reusing the loop's aggregated result. + assert!( + restarted.success, + "restarted zombie flow should succeed: {:?}", + restarted.json_result() + ); + assert_eq!(restarted.json_result().unwrap(), json!("a,b")); + + // Every completed loop iteration reuses its original child job (no re-run); + // only `after`, which never ran, executes fresh. + let new_iter0 = child_job_id_for_step(&db, restarted.id, "fanout", Some(0)).await; + let new_iter1 = child_job_id_for_step(&db, restarted.id, "fanout", Some(1)).await; + let new_after = child_job_id_for_step(&db, restarted.id, "after", None).await; + assert_eq!(new_iter0, orig_iter0, "loop iteration 0 must be reused"); + assert_eq!(new_iter1, orig_iter1, "loop iteration 1 must be reused"); + assert_ne!(new_after, orig_after, "`after` should run fresh"); + + Ok(()) +} + +/// A serial for-loop reaped *between* iterations (an all-success prefix, but the +/// cursor not yet at the last iteration) must NOT be treated as complete: reuse +/// would silently drop the remaining iterations. A downstream `after` step makes +/// the loop non-final, so the ONLY thing that can prevent reuse here is the +/// cursor-completeness guard; if it regresses, `after` would consume a truncated +/// loop result and this test fails. Restart must re-run the whole loop instead. +#[sqlx::test(fixtures("base", "hello"))] +async fn test_mid_iteration_zombie_not_reused(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let flow_value: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "fanout", + "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "['a', 'b', 'c']" }, + "skip_failures": false, + "parallel": false, + "modules": [{ + "id": "inner", + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": { + "v": { "type": "javascript", "expr": "flow_input.iter.value" } + }, + "content": "export function main(v: string) { return v }" + } + }] + } + }, + { + "id": "after", + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": { + "loop_res": { "type": "javascript", "expr": "results.fanout" } + }, + "content": "export function main(loop_res: string[]) { return loop_res.join(',') }" + } + } + ] + })) + .unwrap(); + + let full_run = RunJob::from(JobPayload::RawFlow { + value: flow_value.clone(), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, port) + .await; + assert!(full_run.success); + let orig_iter0 = child_job_id_for_step(&db, full_run.id, "fanout", Some(0)).await; + + // Reap after iteration 0: the loop is InProgress with the cursor still on + // iteration 0 (of 3), only iteration 0 recorded; `after` never reached. + let mut flow_status: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + full_run.id + ) + .fetch_one(&db) + .await? + .expect("flow_status"); + for m in flow_status["modules"].as_array_mut().unwrap() { + match m["id"].as_str() { + Some("fanout") => { + m["type"] = json!("InProgress"); + m["iterator"] = json!({ "index": 0, "itered_len": 3 }); + m["flow_jobs"] = json!([m["flow_jobs"][0]]); + m["flow_jobs_success"] = json!([true]); + } + Some("after") => *m = json!({ "type": "WaitingForPriorSteps", "id": "after" }), + _ => {} + } + } + flow_status["step"] = json!(0); + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'monitor', + flow_status = $2 WHERE id = $1", + full_run.id, + flow_status, + ) + .execute(&db) + .await?; + + let restarted = RunJob::from(JobPayload::RestartedFlow { + completed_job_id: full_run.id, + step_id: "fanout".into(), + branch_or_iteration_n: None, + flow_version: None, + branch_chosen: None, + nested: None, + }) + .run_until_complete(&db, false, port) + .await; + + // The loop re-runs from scratch: all three iterations execute (so `after` sees + // "a,b,c", not a truncated "a"), and iteration 0 is a fresh job. + assert!( + restarted.success, + "restart should re-run the loop and succeed: {:?}", + restarted.json_result() + ); + assert_eq!(restarted.json_result().unwrap(), json!("a,b,c")); + let new_iter0 = child_job_id_for_step(&db, restarted.id, "fanout", Some(0)).await; + assert_ne!( + new_iter0, orig_iter0, + "iteration 0 must re-run, not be reused" + ); + + Ok(()) +} + +/// A nested restart request targets an inner step of the restart-step container. +/// Even when that container is an eligible between-steps zombie, reuse must NOT +/// fire (it would skip the whole container and ignore the explicit nested target). +/// The inner step must re-run. +#[sqlx::test(fixtures("base", "hello"))] +async fn test_nested_restart_not_swallowed_by_zombie_reuse( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // `branch` is a BranchOne (single child, so branch_or_iteration_n is None on + // restart: the exact shape that would trip zombie reuse) with two inner steps, + // followed by a downstream `after`. + let flow_value: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "branch", + "value": { + "type": "branchone", + "default": [], + "branches": [{ + "expr": "true", + "modules": [ + { + "id": "inner_first", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": {}, + "content": "export function main() { return 'first' }" + } + }, + { + "id": "inner_second", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "first": { "type": "javascript", "expr": "results.inner_first" } + }, + "content": "export function main(first: string) { return `${first}|second` }" + } + } + ] + }] + } + }, + { + "id": "after", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "b": { "type": "javascript", "expr": "results.branch" } + }, + "content": "export function main(b: string) { return `after:${b}` }" + } + } + ] + })) + .unwrap(); + + let full_run = RunJob::from(JobPayload::RawFlow { + value: flow_value.clone(), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, port) + .await; + assert!(full_run.success); + assert_eq!(full_run.json_result().unwrap(), json!("after:first|second")); + let branch_child = child_job_id_for_step(&db, full_run.id, "branch", None).await; + let orig_inner_second = child_job_id_for_step(&db, branch_child, "inner_second", None).await; + + // Reap `branch` as a between-steps zombie (its child completed, transition lost); + // `after` never reached. + let mut flow_status: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + full_run.id + ) + .fetch_one(&db) + .await? + .expect("flow_status"); + for m in flow_status["modules"].as_array_mut().unwrap() { + match m["id"].as_str() { + Some("branch") => m["type"] = json!("InProgress"), + Some("after") => *m = json!({ "type": "WaitingForPriorSteps", "id": "after" }), + _ => {} + } + } + flow_status["step"] = json!(0); + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'monitor', + flow_status = $2 WHERE id = $1", + full_run.id, + flow_status, + ) + .execute(&db) + .await?; + + // Nested restart: re-run `inner_second` inside `branch`. Zombie reuse must step + // aside so the nested chain is honored. + let restarted = RunJob::from(JobPayload::RestartedFlow { + completed_job_id: full_run.id, + step_id: "branch".into(), + branch_or_iteration_n: None, + flow_version: None, + branch_chosen: Some(BranchChosen::Branch { branch: 0 }), + nested: Some(Box::new(RestartedFrom { + flow_job_id: branch_child, + step_id: "inner_second".into(), + branch_or_iteration_n: None, + flow_version: None, + branch_chosen: None, + nested: None, + })), + }) + .run_until_complete(&db, false, port) + .await; + + assert!( + restarted.success, + "nested restart of a zombie container should succeed: {:?}", + restarted.json_result() + ); + assert_eq!( + restarted.json_result().unwrap(), + json!("after:first|second") + ); + let new_branch_child = child_job_id_for_step(&db, restarted.id, "branch", None).await; + let new_inner_second = child_job_id_for_step(&db, new_branch_child, "inner_second", None).await; + assert_ne!( + new_inner_second, orig_inner_second, + "the nested target inner_second must re-run, not be skipped by zombie reuse" + ); + + Ok(()) +} + +/// A raw-flow (editor preview) restart queues the request's CURRENT definition, which the editor +/// allows to differ from the completed run. Zombie reuse must not fire there: it would validate the +/// stored step and synthesize Success from the old children, skipping the user's edit. The edited +/// step must re-run and downstream must observe its new result. +#[sqlx::test(fixtures("base", "hello"))] +async fn test_raw_flow_restart_does_not_reuse_edited_step( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let flow_of = |suffix: &str| -> FlowValue { + serde_json::from_value(json!({ + "modules": [ + { + "id": "fanout", + "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "['a', 'b']" }, + "skip_failures": false, + "parallel": false, + "modules": [{ + "id": "inner", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "v": { "type": "javascript", "expr": "flow_input.iter.value" } + }, + "content": format!("export function main(v: string) {{ return v + '{suffix}' }}") + } + }] + } + }, + { + "id": "after", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "loop_res": { "type": "javascript", "expr": "results.fanout" } + }, + "content": "export function main(loop_res: string[]) { return loop_res.join(',') }" + } + } + ] + })) + .unwrap() + }; + + let full_run = + RunJob::from(JobPayload::RawFlow { value: flow_of(""), path: None, restarted_from: None }) + .run_until_complete(&db, false, port) + .await; + assert!(full_run.success); + assert_eq!(full_run.json_result().unwrap(), json!("a,b")); + let orig_iter0 = child_job_id_for_step(&db, full_run.id, "fanout", Some(0)).await; + + let mut flow_status: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + full_run.id + ) + .fetch_one(&db) + .await? + .expect("flow_status"); + for m in flow_status["modules"].as_array_mut().unwrap() { + match m["id"].as_str() { + Some("fanout") => { + m["type"] = json!("InProgress"); + m["iterator"] = json!({ "index": 1, "itered_len": 2 }); + } + Some("after") => *m = json!({ "type": "WaitingForPriorSteps", "id": "after" }), + _ => {} + } + } + flow_status["step"] = json!(0); + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'monitor', + flow_status = $2 WHERE id = $1", + full_run.id, + flow_status, + ) + .execute(&db) + .await?; + + let restarted = RunJob::from(JobPayload::RawFlow { + value: flow_of("X"), + path: None, + restarted_from: Some(RestartedFrom { + flow_job_id: full_run.id, + step_id: "fanout".into(), + branch_or_iteration_n: None, + flow_version: None, + branch_chosen: None, + nested: None, + }), + }) + .run_until_complete(&db, false, port) + .await; + + // The edited step must run: results reflect the new definition, not the reused old children. + assert!( + restarted.success, + "edited raw-flow restart should succeed: {:?}", + restarted.json_result() + ); + assert_eq!(restarted.json_result().unwrap(), json!("aX,bX")); + let new_iter0 = child_job_id_for_step(&db, restarted.id, "fanout", Some(0)).await; + assert_ne!( + new_iter0, orig_iter0, + "the edited fanout step must re-run, not be reused" + ); + + Ok(()) +} + +/// Only a flow reaped by the zombie monitor (canceled_by = 'monitor') is eligible for reuse. A +/// plain force-cancel at the same boundary (a child succeeded, its parent transition not yet +/// landed) yields the identical InProgress/all-success shape but must keep restart-from-step +/// semantics: the selected step re-runs. +#[sqlx::test(fixtures("base", "hello"))] +async fn test_non_monitor_cancel_is_not_reused(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let flow_value: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "fanout", + "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "['a', 'b']" }, + "skip_failures": false, + "parallel": false, + "modules": [{ + "id": "inner", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "v": { "type": "javascript", "expr": "flow_input.iter.value" } + }, + "content": "export function main(v: string) { return v }" + } + }] + } + }, + { + "id": "after", + "value": { + "type": "rawscript", "language": "deno", + "input_transforms": { + "loop_res": { "type": "javascript", "expr": "results.fanout" } + }, + "content": "export function main(loop_res: string[]) { return loop_res.join(',') }" + } + } + ] + })) + .unwrap(); + + let full_run = RunJob::from(JobPayload::RawFlow { + value: flow_value.clone(), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, port) + .await; + assert!(full_run.success); + let orig_iter0 = child_job_id_for_step(&db, full_run.id, "fanout", Some(0)).await; + + // Same frozen-transition shape as a zombie, but canceled by a USER, not the monitor. + let mut flow_status: serde_json::Value = sqlx::query_scalar!( + "SELECT flow_status FROM v2_job_completed WHERE id = $1", + full_run.id + ) + .fetch_one(&db) + .await? + .expect("flow_status"); + for m in flow_status["modules"].as_array_mut().unwrap() { + match m["id"].as_str() { + Some("fanout") => { + m["type"] = json!("InProgress"); + m["iterator"] = json!({ "index": 1, "itered_len": 2 }); + } + Some("after") => *m = json!({ "type": "WaitingForPriorSteps", "id": "after" }), + _ => {} + } + } + flow_status["step"] = json!(0); + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled', canceled_by = 'admin', + flow_status = $2 WHERE id = $1", + full_run.id, + flow_status, + ) + .execute(&db) + .await?; + + let restarted = RunJob::from(JobPayload::RestartedFlow { + completed_job_id: full_run.id, + step_id: "fanout".into(), + branch_or_iteration_n: None, + flow_version: None, + branch_chosen: None, + nested: None, + }) + .run_until_complete(&db, false, port) + .await; + + assert!(restarted.success); + let new_iter0 = child_job_id_for_step(&db, restarted.id, "fanout", Some(0)).await; + assert_ne!( + new_iter0, orig_iter0, + "a non-monitor cancel must re-run the step, not reuse the child" + ); + + Ok(()) +} diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 433d2e4ed5..4b2196bf63 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5666,6 +5666,10 @@ async fn push_inner<'c, 'd>( restarted_from_val.step_id.as_str(), restarted_from_val.branch_or_iteration_n, restarted_from_val.flow_version, + restarted_from_val.nested.is_some(), + // RawFlow queues the request's (possibly edited) definition, not the + // stored one, so zombie reuse of the stored step is unsafe here. + false, ) .await?; FlowStatus { @@ -6055,6 +6059,10 @@ async fn push_inner<'c, 'd>( step_id.as_str(), branch_or_iteration_n, flow_version, + nested.is_some(), + // RestartedFlow resolves and queues the completed job's stored definition, so the + // step validated for reuse is the one that will run. + true, ) .await?; @@ -7063,6 +7071,78 @@ fn create_restarted_module( } } +/// A between-steps-zombie step: an `InProgress` module (in an otherwise terminal, +/// reaped flow) whose every child is recorded as a `success` completion. Only the +/// module's final state transition was lost, so the whole step is derivable and +/// safe to reuse on restart. Children incomplete/failed/cancelled ⟹ not a zombie. +async fn is_derivable_between_steps_zombie( + db: &Pool, + workspace_id: &str, + module: &FlowStatusModule, +) -> Result { + // The module's own cursor must prove it reached the end (a serial loop/branch-all reaped + // mid-fan-out has an all-success prefix but unrun remaining iterations); while-loops are + // never derivable. Children-success is verified below. + if !module.is_between_steps_complete() { + return Ok(false); + } + let child_ids: Vec = module + .flow_jobs() + .filter(|v| !v.is_empty()) + .or_else(|| module.job().map(|j| vec![j])) + .unwrap_or_default(); + if child_ids.is_empty() { + return Ok(false); + } + let success_children = sqlx::query_scalar!( + "SELECT count(*) FROM v2_job_completed + WHERE workspace_id = $1 AND id = ANY($2) AND status = 'success'", + workspace_id, + &child_ids, + ) + .fetch_one(db) + .await? + .unwrap_or(0); + Ok(success_children == child_ids.len() as i64) +} + +/// Convert a between-steps-zombie `InProgress` module (validated by +/// [`is_derivable_between_steps_zombie`]) into the `Success` it would have become +/// had its dropped transition landed, reusing all completed children. Downstream +/// steps re-derive this step's result from `flow_jobs`/`job` on demand +/// (`get_previous_job_result`), so no aggregate needs recomputing here. +fn reuse_completed_zombie_module(module: FlowStatusModule) -> FlowStatusModule { + match module { + FlowStatusModule::InProgress { + id, + job, + flow_jobs, + flow_jobs_success, + flow_jobs_duration, + branch_chosen, + agent_actions, + agent_actions_success, + .. + } => FlowStatusModule::Success { + id, + job, + // Every child was verified successful, so normalise the success + // vector (the dropped transition may have left the last entry unset). + flow_jobs_success: flow_jobs_success + .map(|v| v.into_iter().map(|_| Some(true)).collect()), + flow_jobs, + flow_jobs_duration, + branch_chosen, + approvers: vec![], + failed_retries: vec![], + skipped: false, + agent_actions, + agent_actions_success, + }, + other => other, + } +} + async fn restarted_flows_resolution( db: &Pool, workspace_id: &str, @@ -7070,6 +7150,15 @@ async fn restarted_flows_resolution( restart_step_id: &str, branch_or_iteration_n: Option, flow_version: Option, + // A nested restart chain (RestartedFrom.nested) descends into the restart step's child to + // re-run an inner step; zombie reuse would skip the whole container and ignore it. + nested_restart: bool, + // Zombie reuse validates the restart step against the completed job's STORED definition and + // synthesizes Success from its recorded children. That is only sound when the run being queued + // uses that same definition (JobPayload::RestartedFlow). A JobPayload::RawFlow restart queues + // the editor's current, possibly EDITED, definition instead, so reuse would skip the edited + // step and reuse the old child result; disable it there. + allow_zombie_reuse: bool, ) -> Result< ( Option, @@ -7086,7 +7175,7 @@ async fn restarted_flows_resolution( let row = sqlx::query!( "SELECT j.runnable_path as script_path, j.runnable_id AS \"script_hash: ScriptHash\", - j.kind AS \"job_kind!: JobKind\", + j.kind AS \"job_kind!: JobKind\", c.canceled_by, COALESCE(c.flow_status, c.workflow_as_code_status) AS \"flow_status: Json>\", j.raw_flow AS \"raw_flow: Json>\" FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.id = $1 and j.workspace_id = $2", @@ -7102,6 +7191,12 @@ async fn restarted_flows_resolution( )) })?; + // Zombie reuse must only apply to flows the zombie monitor reaped (canceled_by = 'monitor'). + // An ordinary force-cancel copies the same live flow_status, so a user canceling after a child + // succeeds but before the parent transition lands produces the identical InProgress/all-success + // shape; those must retain restart-from-step semantics (the step re-runs). + let reaped_by_monitor = row.canceled_by.as_deref() == Some("monitor"); + let current_flow_version = row.script_hash.map(|x| x.0); let is_version_change = flow_version.is_some() && current_flow_version.is_some() @@ -7192,9 +7287,36 @@ async fn restarted_flows_resolution( continue; }; if module.id() == restart_step_id { - // if the module ID is the one we want to restart the flow at, or if it's past it in the flow, - // set the module as WaitingForPriorSteps as it needs to be re-run - if branch_or_iteration_n.is_none() || branch_or_iteration_n.unwrap() == 0 { + // Reuse is only safe when there is a NEXT step to advance into (advancing past the + // last module lands on the failure step) and the step's definition carries no + // completion/arming semantics that reuse would skip (stop predicates, skip_if, + // suspend, sleep); such a step must re-run, not be synthesized as Success. + let has_next_step = flow_value + .modules + .last() + .is_none_or(|m| m.id != restart_step_id); + // A whole-step restart is `None` (restart API with the field omitted) or `Some(0)` + // (the run page's "Re-start from" button always sends 0); both mean "redo this step", + // which for a monitor-reaped zombie means reuse it. `Some(n>=1)` is an explicit + // partial container restart and keeps its existing reuse-0..n-1 / rerun-from-n path. + if allow_zombie_reuse + && reaped_by_monitor + && branch_or_iteration_n.unwrap_or(0) == 0 + && !nested_restart + && has_next_step + && module_definition.allows_zombie_reuse() + && is_derivable_between_steps_zombie(db, workspace_id, &module).await? + { + // Between-steps-zombie recovery: this step's children all + // completed but its final state transition was dropped (the + // flow was reaped by the zombie monitor). Reuse the completed + // step verbatim and restart from the NEXT step, so no child + // re-runs and only the dropped transition is replayed onward. + step_n += 1; + truncated_modules.push(reuse_completed_zombie_module(module)); + } else if branch_or_iteration_n.is_none() || branch_or_iteration_n.unwrap() == 0 { + // if the module ID is the one we want to restart the flow at, or if it's past it in the flow, + // set the module as WaitingForPriorSteps as it needs to be re-run // The module as WaitingForPriorSteps as the entire module (i.e. all the branches) need to be re-run truncated_modules .push(FlowStatusModule::WaitingForPriorSteps { id: module.id() }); diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 62da4d5517..79890e3c6e 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -475,6 +475,33 @@ impl FlowStatusModule { } } + /// For a still-`InProgress` module (a between-steps zombie), whether the module's own + /// iteration/branch cursor proves it actually reached the end, so the only thing left is + /// the final state transition (children-success is a separate, DB-side check). + /// + /// A serial for-loop / branch-all grows `flow_jobs` one entry at a time, so an all-success + /// prefix does NOT mean the module finished: the cursor must sit on the last element. Parallel + /// containers preallocate every child up front, so a full success set is conclusive. While-loops + /// are never derivable here (continuation depends on a condition evaluated after each iteration, + /// which a reaped zombie never persisted). Non-`InProgress` modules return false. + pub fn is_between_steps_complete(&self) -> bool { + match self { + FlowStatusModule::InProgress { while_loop: true, .. } => false, + // Parallel loop/branch-all: all children exist up front, so children-success suffices. + FlowStatusModule::InProgress { parallel: true, .. } => true, + FlowStatusModule::InProgress { iterator: Some(it), .. } => { + let total = it + .itered_len + .or_else(|| it.itered.as_ref().map(|v| v.len())); + total.is_some_and(|t| t > 0 && it.index + 1 == t) + } + FlowStatusModule::InProgress { branchall: Some(ba), .. } => ba.branch + 1 == ba.len, + // Single-child leaf / subflow / branch-one: the child ran, nothing else to advance. + FlowStatusModule::InProgress { .. } => true, + _ => false, + } + } + pub fn agent_actions(&self) -> Option> { match self { FlowStatusModule::InProgress { agent_actions, .. } => agent_actions.clone(), @@ -549,4 +576,131 @@ impl FlowStatus { let i = usize::try_from(self.step).ok()?; self.modules.get(i) } + + /// Whether no step has begun executing yet: the preprocessor (if any) and the first + /// module are both still `WaitingForPriorSteps`. A reaped flow in this state can be + /// safely re-queued because nothing ran. A preprocessor that is `InProgress` means its + /// child already ran (only the parent transition was lost), so re-queuing would + /// re-run the preprocessor and duplicate its side effects. + pub fn is_not_yet_started(&self) -> bool { + self.preprocessor_module + .as_ref() + .is_none_or(|p| matches!(p, FlowStatusModule::WaitingForPriorSteps { .. })) + && self + .modules + .first() + .is_some_and(|m| matches!(m, FlowStatusModule::WaitingForPriorSteps { .. })) + } +} + +#[cfg(test)] +mod tests { + use super::{FlowStatus, FlowStatusModule}; + + fn module(json: serde_json::Value) -> FlowStatusModule { + serde_json::from_value(json).unwrap() + } + + fn status(json: serde_json::Value) -> FlowStatus { + serde_json::from_value(json).unwrap() + } + + #[test] + fn is_not_yet_started_distinguishes_preprocessor_zombie() { + let nil = "00000000-0000-0000-0000-000000000000"; + let waiting = serde_json::json!({ "type": "WaitingForPriorSteps", "id": "a" }); + let failure = serde_json::json!({ "type": "WaitingForPriorSteps", "id": "failure" }); + // No preprocessor, first module waiting: genuinely unstarted. + assert!(status(serde_json::json!({ + "step": 0, "modules": [waiting], "failure_module": failure + })) + .is_not_yet_started()); + // First module already InProgress: started. + assert!(!status(serde_json::json!({ + "step": 0, + "modules": [{ "type": "InProgress", "id": "a", "job": nil }], + "failure_module": failure + })) + .is_not_yet_started()); + // Preprocessor still waiting, first module waiting: unstarted. + assert!(status(serde_json::json!({ + "step": -1, "modules": [waiting], "failure_module": failure, + "preprocessor_module": { "type": "WaitingForPriorSteps", "id": "pre" } + })) + .is_not_yet_started()); + // Preprocessor InProgress (its child ran) while modules[0] still waits: a + // preprocessor zombie, NOT unstarted, so it must not be auto-requeued. + assert!(!status(serde_json::json!({ + "step": -1, "modules": [waiting], "failure_module": failure, + "preprocessor_module": { "type": "InProgress", "id": "pre", "job": nil } + })) + .is_not_yet_started()); + } + + #[test] + fn between_steps_complete_serial_loop() { + // Cursor on the last iteration => complete. + assert!(module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "iterator": { "index": 1, "itered_len": 2 }, "flow_jobs": [] + })) + .is_between_steps_complete()); + // Reaped mid-iteration (iteration 1 of 2 never scheduled) => NOT complete. + assert!(!module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "iterator": { "index": 0, "itered_len": 2 }, "flow_jobs": [] + })) + .is_between_steps_complete()); + // Legacy shape: itered array present, itered_len absent. + assert!(module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "iterator": { "index": 1, "itered": ["x", "y"] } + })) + .is_between_steps_complete()); + } + + #[test] + fn between_steps_complete_while_loop_never() { + assert!(!module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "while_loop": true, "iterator": { "index": 1, "itered_len": 2 } + })) + .is_between_steps_complete()); + } + + #[test] + fn between_steps_complete_branchall_and_parallel() { + // Serial branch-all on the last branch => complete; earlier branch => not. + assert!(module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "branchall": { "branch": 1, "len": 2 } + })) + .is_between_steps_complete()); + assert!(!module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "branchall": { "branch": 0, "len": 2 } + })) + .is_between_steps_complete()); + // Parallel loop: children preallocated, so any cursor is fine (success is checked elsewhere). + assert!(module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "parallel": true, "iterator": { "index": 0, "itered_len": 3 } + })) + .is_between_steps_complete()); + } + + #[test] + fn between_steps_complete_leaf_and_non_inprogress() { + // Single-child leaf: the child ran, nothing to advance. + assert!(module(serde_json::json!({ + "type": "InProgress", "id": "a", "job": "00000000-0000-0000-0000-000000000000" + })) + .is_between_steps_complete()); + // A Success module is not a between-steps zombie. + assert!(!module(serde_json::json!({ + "type": "Success", "id": "a", "job": "00000000-0000-0000-0000-000000000000", + "skipped": false + })) + .is_between_steps_complete()); + } } diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 7e70dc5acd..b06e2af05e 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -664,6 +664,19 @@ impl FlowModule { .is_ok_and(|x| x == "script" || x == "rawscript" || x == "flowscript") } + /// Whether a between-steps-zombie step carrying this definition can be safely reused as + /// `Success` on restart (see restart-resolution reuse). Excludes steps whose completion + /// transition or arming carries semantics that reuse would silently skip: stop predicates + /// (`stop_after_if` / `stop_after_all_iters_if`, which decide whether downstream steps run), + /// `skip_if` (skipped-state and suspend arming), a `suspend` approval boundary, and `sleep`. + pub fn allows_zombie_reuse(&self) -> bool { + self.stop_after_if.is_none() + && self.stop_after_all_iters_if.is_none() + && self.skip_if.is_none() + && self.suspend.is_none() + && self.sleep.is_none() + } + pub fn get_type(&self) -> anyhow::Result<&str> { #[derive(Deserialize)] pub struct FlowModuleValueType<'a> { From a29e13fd18bcc09f43b32af80e9178801edf412e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 23 Jul 2026 23:03:25 +0200 Subject: [PATCH 52/66] reference the file-search worker by its packaged .js name (#10290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svelte-package does not rewrite the string literal inside new URL(), and ships only the compiled searchWorker.js — so the .ts URL is dangling for any downstream consumer of @windmill-labs/components (rollup: Could not resolve searchWorker.ts). Vite maps .js back to the .ts source in-repo, so both builds resolve. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/copilot/chat/files/fileEngine.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/copilot/chat/files/fileEngine.ts b/frontend/src/lib/components/copilot/chat/files/fileEngine.ts index 5dbceb77e2..5508ab8b68 100644 --- a/frontend/src/lib/components/copilot/chat/files/fileEngine.ts +++ b/frontend/src/lib/components/copilot/chat/files/fileEngine.ts @@ -294,7 +294,10 @@ export function searchFilesInWorker( ): Promise { let worker: Worker try { - worker = new Worker(new URL('./searchWorker.ts', import.meta.url), { type: 'module' }) + // Reference the worker by its .js name: svelte-package doesn't rewrite this + // string literal and ships only searchWorker.js, so a `.ts` URL is dangling + // when the package is consumed downstream (vite maps `.js`→`.ts` here in-repo). + worker = new Worker(new URL('./searchWorker.js', import.meta.url), { type: 'module' }) } catch { return searchFiles(entries, pattern, opts) } From bf16e7d49a7486d37cf9eb1907e80abae47a78a7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 23 Jul 2026 23:21:24 +0200 Subject: [PATCH 53/66] feat: surface workspace-script advanced settings in flow editor (#10289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow-editor): surface workspace-script advanced settings in flows Workspace-script steps in a flow could not view or edit script-level runtime settings (concurrency, cache, timeout, debounce, dedicated worker, priority, delete-after-use). The concurrency and cache tabs only showed a "set it on the script" warning with no value and no way to act on it. - Add ScriptAdvancedSettings, a reusable subset of the script editor's runtime settings, and two entry points that reuse it: - WorkspaceScriptSettingsDrawer: a mini settings drawer reachable from the flow step (header "Settings" button and the delegating tabs), saving a new script version with the code left unchanged. - an inner "Settings" drawer inside ScriptEditorDrawer, saved together with the code. - Replace the concurrency/cache delegation warnings with a box that fetches the referenced script's current value and offers an "Edit script settings" shortcut (useWorkspaceScriptSettings loader). - Add ScriptSettingsBadges showing active advanced settings, in the standalone script editor top bar, the edit-code drawer, and above the workspace-script step preview. Fixes WIN-2233 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): keep subflow concurrency note distinct from workspace-script The concurrency delegation box is workspace-script specific; subflow steps now keep a plain limitation note instead of the script settings shortcut. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): preserve all script fields when saving settings-only version Building the createScript body by hand dropped codebase/labels/envs and other fields on the new version. Spread the loaded script instead and override only lineage, matching ScriptEditorDrawer's save. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): address review — settings-save safety and stale display - WorkspaceScriptSettingsDrawer: keep settings-only saves from hijacking execution identity or discarding the author's draft (preserve_on_behalf_of + skip_draft_deletion), and normalize cleared concurrency/debounce keys to undefined so blanks don't become shared global keys. - ScriptEditorDrawer: normalize cleared keys in its save too (the inner settings drawer edits them). - FlowModuleComponent: reload the surfaced concurrency/cache values + badges after a header settings/code save; gate settings editing on customUi.scriptEdit. - useWorkspaceScriptSettings: sequence-guard load() against stale overwrites. - Add unit tests for getActiveScriptSettingsBadges. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-3 review — concurrency-safe save, load guards, UI gates - WorkspaceScriptSettingsDrawer: drop auto_parent so a settings-only save uses the loaded parent as an optimistic-concurrency guard (fails loudly instead of silently reverting a concurrent deploy); sequence-guard openDrawer so a slow load for a previous script can't clobber a reopened one. - useWorkspaceScriptSettings: clear loading in the superseded/early-return path so a hub/empty step can't spin forever. - ScriptBuilder: gate the clickable settings badges on customUi.topBar.settings and settingsPanel.disableRuntime. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-4 review — template, load-error, legacy-zero handling - WorkspaceScriptSettingsDrawer: stop forcing is_template=false so saving a setting on a template keeps its template status; show a recoverable error (with Retry) when the settings load fails instead of spinning forever. - scriptSettings/FlowModuleComponent: treat non-positive concurrent_limit and timeout as unset (legacy zero rows), so no "Max 0 executions"/"Timeout 0s". - Add badge tests for the non-positive cases. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-5 nits — neutral card wording, load-error surfacing, cache zero - WorkspaceScriptSettingInfo: neutral "managed on the referenced workspace script" header (no longer claims "configured" when unset) and a distinct error line so a failed load isn't misread as "not set". - useWorkspaceScriptSettings: expose an error state; thread it into the concurrency and cache cards. - Treat cache_ttl <= 0 as unset, matching concurrency/timeout; add test. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(flow-editor): icon-only script action buttons + gate settings in local-dev - Gate the workspace-script settings actions (header button, clickable badges, Concurrency/Cache shortcuts) on the settings drawer actually being mounted, so the local-dev flow editors (Dev.svelte / flows/dev) that provide the context store but never render the drawer keep the values read-only instead of showing no-op controls. - Make the script action buttons icon-only with clear hover popovers to save space in the crowded step/script-editor top bars: Edit, Settings and Fork in the step header, Settings in the edit-code drawer, and the settings badges (icon chip + label/value popover). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): round-6 nits — a11y names + accurate read-only reason - Add aria-label to the icon-only Edit/Settings/Fork buttons and the setting badges so keyboard/screen-reader users get an accessible name (the hover popover alone didn't expose it). - WorkspaceScriptSettingInfo takes a noEditReason so the read-only explanation matches the actual gate (hub / hash-pinned / unavailable-in-this-editor) instead of always blaming hub/pinned — fixes the wrong reason shown in the local-dev flow editors. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(flow-editor): drop narrating comment on the no-edit-reason derived Co-Authored-By: Claude Opus 4.8 (1M context) * fix(flow-editor): bind settings save completion to the drawer target The drawer is a singleton, so a save that outlived a reopen ran the new target's callback and closed its drawer, discarding edits in progress. Capture the target sequence and callback at save time: the captured callback still fires (it refreshes the script it belongs to) while the close, error toast and saving flag only apply if the target is unchanged. Reopening also resets the saving flag, which the seq-guarded save no longer clears for a superseded target. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/Dev.svelte | 2 + .../src/lib/components/FlowBuilder.svelte | 6 + .../components/ScriptAdvancedSettings.svelte | 286 ++++++++++++++++++ .../src/lib/components/ScriptBuilder.svelte | 15 + .../components/ScriptSettingsBadges.svelte | 42 +++ .../flows/content/FlowModuleCache.svelte | 37 ++- .../flows/content/FlowModuleComponent.svelte | 108 ++++++- .../flows/content/FlowModuleHeader.svelte | 93 ++++-- .../flows/content/ScriptEditorDrawer.svelte | 70 ++++- .../content/WorkspaceScriptSettingInfo.svelte | 61 ++++ .../WorkspaceScriptSettingsDrawer.svelte | 150 +++++++++ frontend/src/lib/components/flows/types.ts | 2 + .../useWorkspaceScriptSettings.svelte.ts | 72 +++++ .../src/lib/components/scriptSettings.test.ts | 53 ++++ frontend/src/lib/components/scriptSettings.ts | 137 +++++++++ frontend/src/routes/flows/dev/+page.svelte | 10 +- 16 files changed, 1101 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/ScriptAdvancedSettings.svelte create mode 100644 frontend/src/lib/components/ScriptSettingsBadges.svelte create mode 100644 frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte create mode 100644 frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte create mode 100644 frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts create mode 100644 frontend/src/lib/components/scriptSettings.test.ts create mode 100644 frontend/src/lib/components/scriptSettings.ts diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index ca8ecb8451..6d9b21aeb9 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -662,6 +662,7 @@ const previewArgsStore = $state({ val: {} }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() const selectionManager = new SelectionManager() @@ -687,6 +688,7 @@ selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer: writable(undefined), history, pathStore: pathStore, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index b30db19af9..3ce0a9fa03 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -40,6 +40,7 @@ import { Button } from './common' import FlowEditor from './flows/FlowEditor.svelte' import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte' + import WorkspaceScriptSettingsDrawer from './flows/content/WorkspaceScriptSettingsDrawer.svelte' import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte' import { dfs as dfsApply } from './flows/dfs' import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte' @@ -540,6 +541,9 @@ const previewArgsStore = $state({ val: untrack(() => initialArgs) }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable( + undefined + ) const flowEditorDrawer = writable(undefined) const history = initHistory(untrack(() => flowStore).val) const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) @@ -589,6 +593,7 @@ currentEditor: writable(undefined), previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer, history, flowStateStore: untrack(() => flowStateStore), @@ -1139,6 +1144,7 @@ +
    diff --git a/frontend/src/lib/components/ScriptAdvancedSettings.svelte b/frontend/src/lib/components/ScriptAdvancedSettings.svelte new file mode 100644 index 0000000000..67fadbe36a --- /dev/null +++ b/frontend/src/lib/components/ScriptAdvancedSettings.svelte @@ -0,0 +1,286 @@ + + +
    +
    + {#snippet header()} + + The script will be executed on a worker configured to listen to this worker group tag + (queue). For instance, you could setup an "highmem", or "gpu" tag. + + {/snippet} + +
    + +
    + {#snippet header()} + + Allowed concurrency within a given timeframe + + {/snippet} + { + if (script.concurrent_limit && script.concurrent_limit != undefined) { + script.concurrent_limit = undefined + script.concurrency_time_window_s = undefined + script.concurrency_key = undefined + } else { + script.concurrent_limit = 1 + } + }} + options={{ right: 'Concurrency limits' }} + /> + {#if Boolean(script.concurrent_limit)} +
    + + + +
    + {/if} +
    + +
    + {#snippet header()} + + Cache the results for each possible inputs + + {/snippet} +
    + !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)} + options={{ right: 'Cache the results for each possible inputs' }} + /> + {#if script.cache_ttl} +
    How long to keep the cache valid
    + + script.cache_ignore_s3_path, (v) => (script.cache_ignore_s3_path = v || undefined) + } + options={{ + right: 'Ignore S3 Object paths for caching purposes', + rightTooltip: + 'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.' + }} + /> + {/if} +
    +
    + +
    + {#snippet header()} + + Add a custom timeout for this script + + {/snippet} +
    + { + if (script.timeout && script.timeout != undefined) { + script.timeout = undefined + } else { + script.timeout = 300 + } + }} + options={{ right: 'Add a custom timeout for this script' }} + /> + {#if Boolean(script.timeout)} + Timeout duration + + {/if} +
    +
    + +
    + {#snippet header()} + + Debounce Jobs + + {/snippet} + +
    + +
    + {#snippet header()} + + Restart the script upon ending unless cancelled + + {/snippet} + { + script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true + }} + options={{ right: 'Restart upon ending unless cancelled' }} + /> +
    + +
    + {#snippet header()} + + In this mode, the script is meant to be run on dedicated workers that run the script at + native speed. Can reach >1500rps per dedicated worker. Only available on enterprise + edition and for Python3, Deno, Bun and Bunnative. + + {/snippet} + { + script.dedicated_worker = script.dedicated_worker ? undefined : true + }} + options={{ right: 'Script is run on dedicated workers' }} + /> + {#if script.dedicated_worker} +
    + + A worker group needs to be configured to listen to this script. Select it in the dedicated + workers section of the worker group configuration. + +
    + {/if} +
    + +
    + {#snippet header()} + + The logs, arguments and results of the job will be completely deleted from Windmill after + the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is + irreversible. This settings ONLY applies when the script is used within a flow or triggered + synchronously. + {#if !$enterpriseLicense} + This option is only available on Windmill Enterprise Edition. + {/if} + + {/snippet} +
    + { + script.delete_after_secs = script.delete_after_secs != null ? undefined : 0 + }} + options={{ right: 'Delete logs, arguments and results after completion' }} + /> + {#if script.delete_after_secs != null} + + {/if} +
    +
    + + {#if !isCloudHosted()} +
    + {#snippet header()} + + Jobs from script labeled as high priority take precedence over the other jobs when in the + jobs queue. + {#if !$enterpriseLicense}This is a feature only available on enterprise edition.{/if} + + {/snippet} + 0} + on:change={() => { + script.priority = script.priority ? undefined : 100 + }} + options={{ right: 'Label as high priority' }} + > + {#snippet right()} + { + if (script.priority && script.priority > 100) { + script.priority = 100 + } else if (script.priority && script.priority < 0) { + script.priority = 0 + } + }} + /> + {/snippet} + +
    + {/if} +
    diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 831cabb498..4007f3bf0f 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -87,6 +87,7 @@ import DefaultScripts from './DefaultScripts.svelte' import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' + import ScriptSettingsBadges from './ScriptSettingsBadges.svelte' import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' @@ -1971,6 +1972,20 @@ {onOpenOthersDrafts} /> {/if} + {#if !condensedHeader} + {@const canOpenRuntime = + customUi?.topBar?.settings != false && + customUi?.settingsPanel?.disableRuntime !== true} + { + selectedTab = 'runtime' + metadataOpen = true + } + : undefined} + /> + {/if}
    diff --git a/frontend/src/lib/components/ScriptSettingsBadges.svelte b/frontend/src/lib/components/ScriptSettingsBadges.svelte new file mode 100644 index 0000000000..8d73609891 --- /dev/null +++ b/frontend/src/lib/components/ScriptSettingsBadges.svelte @@ -0,0 +1,42 @@ + + +{#if badges.length > 0} +
    + {#each badges as badge (badge.key)} + + + onclick?.(badge.key) : undefined} + aria-label={`${badge.label}: ${badge.detail}`} + /> + {#snippet text()} + {badge.label} — {badge.detail} + {/snippet} + + {/each} +
    +{/if} diff --git a/frontend/src/lib/components/flows/content/FlowModuleCache.svelte b/frontend/src/lib/components/flows/content/FlowModuleCache.svelte index f9744af36c..99fc4c7b3a 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleCache.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleCache.svelte @@ -6,12 +6,29 @@ import type { FlowModule } from '$lib/gen' import { SecondsInput } from '../../common' + import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte' interface Props { flowModule: FlowModule + // For workspace-script steps: the cache_ttl currently set on the referenced + // script, and a shortcut to edit it. Undefined for inline/subflow steps. + workspaceScriptCacheTtl?: number | undefined + loadingWorkspaceScript?: boolean + workspaceScriptError?: string | undefined + canEditWorkspaceScript?: boolean + workspaceScriptNoEditReason?: string | undefined + onEditWorkspaceScript?: () => void } - let { flowModule = $bindable() }: Props = $props() + let { + flowModule = $bindable(), + workspaceScriptCacheTtl = undefined, + loadingWorkspaceScript = false, + workspaceScriptError = undefined, + canEditWorkspaceScript = false, + workspaceScriptNoEditReason = undefined, + onEditWorkspaceScript + }: Props = $props() let isCacheEnabled = $derived(Boolean(flowModule.cache_ttl)) @@ -25,10 +42,22 @@ {/snippet} - {#if flowModule.value.type != 'rawscript'} + {#if flowModule.value.type == 'script'} + + {:else if flowModule.value.type != 'rawscript'}

    - The cache settings need to be set in the referenced script/flow settings directly. Cache for - hub scripts is not available yet. + The cache settings need to be set in the referenced flow settings directly.

    {:else} ('FlowEditorContext') const selectedId = $derived(selectionManager.getSelectedId()) @@ -180,6 +185,59 @@ let assets = $derived((flowModule.value.type === 'rawscript' && flowModule.value.assets) || []) const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') + // For workspace-script steps, load the referenced script's advanced settings so + // the delegating settings tabs (concurrency, cache, ...) can show current values + // and offer an "Edit script settings" shortcut instead of a bare warning. + const referencedScriptSettings = useWorkspaceScriptSettings( + () => (flowModule.value.type === 'script' ? flowModule.value.path : undefined), + () => (flowModule.value.type === 'script' ? flowModule.value.hash : undefined), + () => opWs + ) + // Hub scripts, hash-pinned steps, and embeddings that disable script editing + // can't have their settings edited from here. The drawer must also be mounted: + // local-dev editors (Dev.svelte / flows/dev) provide the context store but never + // render the drawer, so editing there would be a no-op — keep values read-only. + let canEditWorkspaceScriptSettings = $derived( + flowModule.value.type === 'script' && + !flowModule.value.path?.startsWith('hub/') && + flowModule.value.hash == undefined && + customUi?.scriptEdit != false && + $workspaceScriptSettingsDrawer != undefined + ) + let workspaceScriptNoEditReason = $derived( + flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings + ? undefined + : flowModule.value.path?.startsWith('hub/') + ? 'Hub scripts cannot be edited from here.' + : flowModule.value.hash != undefined + ? 'Steps pinned to a specific version cannot be edited from here.' + : 'Editing script settings is not available in this editor.' + ) + // Non-positive concurrent_limit / cache_ttl are treated as unset by the runtime (legacy rows). + let referencedConcurrentLimit = $derived( + referencedScriptSettings.settings?.concurrent_limit != undefined && + referencedScriptSettings.settings.concurrent_limit > 0 + ? referencedScriptSettings.settings.concurrent_limit + : undefined + ) + let referencedCacheTtl = $derived( + referencedScriptSettings.settings?.cache_ttl != undefined && + referencedScriptSettings.settings.cache_ttl > 0 + ? referencedScriptSettings.settings.cache_ttl + : undefined + ) + function openWorkspaceScriptSettings() { + if (flowModule.value.type !== 'script') return + $workspaceScriptSettingsDrawer?.openDrawer( + flowModule.value.path, + flowModule.value.hash, + async () => { + await referencedScriptSettings.reload() + forceReload++ + } + ) + } + // UI Intent handling for AI tool control useUiIntent(`flow-${flowModule.id}`, { openTab: (tab) => { @@ -770,6 +828,9 @@ flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs) } forceReload++ + // Keep the surfaced concurrency/cache values and badges in sync after + // a settings/code save from the header (path/hash may be unchanged). + await referencedScriptSettings.reload() await reload(flowModule) } if (flowModule.value.type == 'flow') { @@ -991,6 +1052,16 @@ {:else if flowModule.value.type === 'script'} {#if !noEditor && (customUi?.hubCode != false || !flowModule?.value?.path?.startsWith('hub/'))}
    + {#if referencedScriptSettings.settings && getActiveScriptSettingsBadges(referencedScriptSettings.settings).length > 0} +
    + +
    + {/if} {#key forceReload} + {:else if flowModule.value.type == 'script'} + {:else} - The concurrency limit of a workspace script is only settable in the - script metadata itself. For hub scripts, this feature is non available - yet. + The concurrency limit of a referenced flow is only settable in the + flow settings directly. {/if} @@ -1322,7 +1412,15 @@
    {:else if advancedSelected === 'cache'}
    - +
    {:else if advancedSelected === 'early-stop'} diff --git a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte index c3d6742b54..b34a78e1db 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleHeader.svelte @@ -13,7 +13,8 @@ Repeat, Square, Pin, - Save + Save, + Settings } from 'lucide-svelte' import Popover from '../../Popover.svelte' import type { FlowEditorContext } from '../types' @@ -28,7 +29,7 @@ } let { module, tag }: Props = $props() - const { scriptEditorDrawer, flowEditorDrawer, opWorkspace } = + const { scriptEditorDrawer, workspaceScriptSettingsDrawer, flowEditorDrawer, opWorkspace } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() @@ -107,26 +108,54 @@ {/if} {#if module.value.type === 'script'} {#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false} - + + + + + {/snippet} + + diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte new file mode 100644 index 0000000000..505474c24c --- /dev/null +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingInfo.svelte @@ -0,0 +1,61 @@ + + +
    +
    + + {label} is managed on the referenced workspace script. + + {#if canEdit} + + {/if} +
    +
    + {#if loading} + + Loading current value… + + {:else if error} + Could not load the current value: {error} + {:else if active} + {valueText} + {:else} + Not set on the script. + {/if} +
    + {#if !canEdit && noEditReason} + {noEditReason} + {/if} +
    diff --git a/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte new file mode 100644 index 0000000000..3a88e6214a --- /dev/null +++ b/frontend/src/lib/components/flows/content/WorkspaceScriptSettingsDrawer.svelte @@ -0,0 +1,150 @@ + + + + drawer?.closeDrawer()}> + {#if loading} +
    + + Loading +
    + {:else if loadError || !script} +
    + + {loadError ?? 'Script not found.'} + + {#if current} + + {/if} +
    + {:else} +
    +
    + {script.path} + +
    +

    + Saving creates a new version of the workspace script with these runtime settings. The code + is left unchanged. +

    + +
    + {/if} + {#snippet actions()} + + {/snippet} +
    +
    diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 41165865ae..a527bd765d 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -2,6 +2,7 @@ import type { Job, OpenFlow } from '$lib/gen' import type { History } from '$lib/history.svelte' import type { Writable } from 'svelte/store' import type ScriptEditorDrawer from './content/ScriptEditorDrawer.svelte' +import type WorkspaceScriptSettingsDrawer from './content/WorkspaceScriptSettingsDrawer.svelte' import type FlowEditorDrawer from './content/FlowEditorDrawer.svelte' import type { FlowState } from './flowState' import type { FlowBuilderWhitelabelCustomUi } from '../custom_ui' @@ -76,6 +77,7 @@ export type FlowEditorContext = { currentEditor: Writable previewArgs: StateStore> scriptEditorDrawer: Writable + workspaceScriptSettingsDrawer: Writable flowEditorDrawer: Writable history: History pathStore: Writable diff --git a/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts b/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts new file mode 100644 index 0000000000..861f85e7bf --- /dev/null +++ b/frontend/src/lib/components/flows/useWorkspaceScriptSettings.svelte.ts @@ -0,0 +1,72 @@ +import { ScriptService } from '$lib/gen' +import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings' + +// Loads the advanced runtime settings (concurrency, cache, timeout, ...) of the +// workspace script referenced by a flow step, so the flow editor can surface the +// current values instead of only a "set it on the script" warning. Reactive to +// the path/hash/workspace getters; call reload() after saving new settings. +export function useWorkspaceScriptSettings( + pathGetter: () => string | undefined, + hashGetter: () => string | undefined, + workspaceGetter: () => string | undefined +) { + let settings = $state(undefined) + let loading = $state(false) + let error = $state(undefined) + // Guards against an older in-flight load resolving after a newer one and + // clobbering the displayed settings when path/hash change quickly. + let loadSeq = 0 + + async function load( + path: string | undefined, + hash: string | undefined, + workspace: string | undefined + ) { + const seq = ++loadSeq + if (!path || !workspace || path.startsWith('hub/')) { + settings = undefined + error = undefined + // Clear here too: this supersedes any in-flight load, whose guarded + // finally can no longer reset loading, else the card spins forever. + loading = false + return + } + loading = true + error = undefined + try { + const script = hash + ? await ScriptService.getScriptByHash({ workspace, hash }) + : await ScriptService.getScriptByPath({ workspace, path }) + if (seq !== loadSeq) return + settings = script as ScriptAdvancedSettingsFields + } catch (e) { + console.error('Could not load referenced script settings', e) + if (seq === loadSeq) { + settings = undefined + // Surface failure so cards distinguish "load failed" from "not set". + error = `${(e as { body?: string })?.body ?? e}` + } + } finally { + if (seq === loadSeq) loading = false + } + } + + $effect(() => { + load(pathGetter(), hashGetter(), workspaceGetter()) + }) + + return { + get settings() { + return settings + }, + get loading() { + return loading + }, + get error() { + return error + }, + reload() { + return load(pathGetter(), hashGetter(), workspaceGetter()) + } + } +} diff --git a/frontend/src/lib/components/scriptSettings.test.ts b/frontend/src/lib/components/scriptSettings.test.ts new file mode 100644 index 0000000000..f010e3863a --- /dev/null +++ b/frontend/src/lib/components/scriptSettings.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { getActiveScriptSettingsBadges } from './scriptSettings' + +describe('getActiveScriptSettingsBadges', () => { + it('returns no badges for undefined or empty settings', () => { + expect(getActiveScriptSettingsBadges(undefined)).toEqual([]) + expect(getActiveScriptSettingsBadges({})).toEqual([]) + }) + + it('only surfaces settings that are actually active', () => { + const keys = getActiveScriptSettingsBadges({ + concurrent_limit: 3, + concurrency_time_window_s: 60, + cache_ttl: 600, + timeout: 120, + priority: 50, + tag: 'gpu' + }).map((b) => b.key) + expect(keys).toEqual(['concurrency', 'cache', 'timeout', 'priority', 'tag']) + }) + + it('treats a zero/absent priority and non-positive debounce as inactive', () => { + const keys = getActiveScriptSettingsBadges({ + priority: 0, + debounce_delay_s: 0 + }).map((b) => b.key) + expect(keys).toEqual([]) + }) + + it('treats non-positive concurrency limits, timeouts and cache ttl as inactive (legacy zero rows)', () => { + const keys = getActiveScriptSettingsBadges({ + concurrent_limit: 0, + timeout: 0, + cache_ttl: 0 + }).map((b) => b.key) + expect(keys).toEqual([]) + }) + + it('keeps delete_after_secs of 0 active (immediate deletion is a real setting)', () => { + const badge = getActiveScriptSettingsBadges({ delete_after_secs: 0 }) + expect(badge.map((b) => b.key)).toEqual(['delete_after_use']) + expect(badge[0].detail).toContain('immediately') + }) + + it('pluralizes the concurrency detail correctly', () => { + expect(getActiveScriptSettingsBadges({ concurrent_limit: 1 })[0].detail).toContain( + 'Max 1 execution' + ) + expect(getActiveScriptSettingsBadges({ concurrent_limit: 2 })[0].detail).toContain( + 'Max 2 executions' + ) + }) +}) diff --git a/frontend/src/lib/components/scriptSettings.ts b/frontend/src/lib/components/scriptSettings.ts new file mode 100644 index 0000000000..4a3b3ad5b8 --- /dev/null +++ b/frontend/src/lib/components/scriptSettings.ts @@ -0,0 +1,137 @@ +import { + Gauge, + Database, + Timer, + Hourglass, + Repeat, + Cpu, + Trash2, + ChevronsUp, + Tag +} from 'lucide-svelte' +import type { ScriptLang } from '$lib/gen' + +// Subset of Script/NewScript fields that make up the "advanced runtime settings" +// surfaced both in the standalone script editor and, via the mini settings drawer, +// from within the flow editor for workspace-script steps. +export type ScriptAdvancedSettingsFields = { + path?: string + language?: ScriptLang + schema?: unknown + tag?: string + concurrent_limit?: number + concurrency_time_window_s?: number + concurrency_key?: string + cache_ttl?: number + cache_ignore_s3_path?: boolean + timeout?: number + debounce_delay_s?: number + debounce_key?: string + debounce_args_to_accumulate?: string[] + max_total_debouncing_time?: number + max_total_debounces_amount?: number + restart_unless_cancelled?: boolean + dedicated_worker?: boolean + delete_after_secs?: number + priority?: number +} + +export type ScriptSettingsBadge = { + key: string + label: string + icon: any + detail: string +} + +// Compute the list of active advanced settings for a script, used to render +// at-a-glance badges in the editor top bar and in the flow drawers. +export function getActiveScriptSettingsBadges( + settings: ScriptAdvancedSettingsFields | undefined +): ScriptSettingsBadge[] { + if (!settings) return [] + const badges: ScriptSettingsBadge[] = [] + // Non-positive concurrent_limit / timeout are treated as unset by the runtime + // (legacy zero rows), so don't surface them as active settings. + if (settings.concurrent_limit != undefined && settings.concurrent_limit > 0) { + badges.push({ + key: 'concurrency', + label: 'Concurrency', + icon: Gauge, + detail: `Max ${settings.concurrent_limit} execution${ + settings.concurrent_limit === 1 ? '' : 's' + }${ + settings.concurrency_time_window_s != undefined + ? ` / ${settings.concurrency_time_window_s}s` + : '' + }` + }) + } + if (settings.cache_ttl != undefined && settings.cache_ttl > 0) { + badges.push({ + key: 'cache', + label: 'Cache', + icon: Database, + detail: `Cached for ${settings.cache_ttl}s` + }) + } + if (settings.timeout != undefined && settings.timeout > 0) { + badges.push({ + key: 'timeout', + label: 'Timeout', + icon: Timer, + detail: `${settings.timeout}s` + }) + } + if (settings.debounce_delay_s != undefined && settings.debounce_delay_s > 0) { + badges.push({ + key: 'debounce', + label: 'Debounce', + icon: Hourglass, + detail: `Debounced by ${settings.debounce_delay_s}s` + }) + } + if (settings.restart_unless_cancelled) { + badges.push({ + key: 'perpetual', + label: 'Perpetual', + icon: Repeat, + detail: 'Restarts unless cancelled' + }) + } + if (settings.dedicated_worker) { + badges.push({ + key: 'dedicated', + label: 'Dedicated', + icon: Cpu, + detail: 'Runs on dedicated workers' + }) + } + if (settings.delete_after_secs != undefined) { + badges.push({ + key: 'delete_after_use', + label: 'Delete after use', + icon: Trash2, + detail: + settings.delete_after_secs === 0 + ? 'Deleted immediately after completion' + : `Deleted ${settings.delete_after_secs}s after completion` + }) + } + if (settings.priority != undefined && settings.priority > 0) { + badges.push({ + key: 'priority', + label: 'High priority', + icon: ChevronsUp, + detail: `Priority ${settings.priority}` + }) + } + if (settings.tag) { + badges.push({ + key: 'tag', + label: settings.tag, + icon: Tag, + detail: `Worker tag: ${settings.tag}` + }) + } + return badges +} diff --git a/frontend/src/routes/flows/dev/+page.svelte b/frontend/src/routes/flows/dev/+page.svelte index 620cd60523..fdb3c512f1 100644 --- a/frontend/src/routes/flows/dev/+page.svelte +++ b/frontend/src/routes/flows/dev/+page.svelte @@ -77,6 +77,7 @@ const previewArgsStore = $state({ val: {} }) const scriptEditorDrawer = writable(undefined) + const workspaceScriptSettingsDrawer = writable(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() @@ -94,6 +95,7 @@ selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, + workspaceScriptSettingsDrawer, flowEditorDrawer: writable(undefined), history, pathStore: writable(''), @@ -252,7 +254,7 @@ const selectedId = $derived(selectionManager.getSelectedId()) const selectedModule = $derived( selectedId && flowStore.val?.value - ? findModuleInFlow(flowStore.val.value, selectedId) ?? undefined + ? (findModuleInFlow(flowStore.val.value, selectedId) ?? undefined) : undefined ) @@ -293,7 +295,11 @@ {/if}
    -
    +
    From 9b182aaf3879d4d81b6a785222dca75f085e6fb7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 24 Jul 2026 00:20:38 +0200 Subject: [PATCH 54/66] fix: surface workspace ids on duplicate names and explain fork promotion (#10291) * fix: disambiguate same-named workspaces in the workspace menu Co-Authored-By: Claude Opus 4.8 (1M context) * fix: explain why git promotion is absent on a fork Co-Authored-By: Claude Opus 4.8 (1M context) * fix: link a fork to dev-workspace pairing from git sync settings Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/git_sync/GitSyncSection.svelte | 41 +++++++++++++++++++ .../components/sidebar/WorkspaceMenu.svelte | 26 +++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 3e5fe7c70d..7327912697 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -6,6 +6,7 @@ import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore, userWorkspaces } from '$lib/stores' + import { base } from '$lib/base' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' @@ -86,6 +87,22 @@ const devSingleRepo = $derived(isDevWorkspace && (gitSyncContext?.repositories?.length ?? 0) <= 1) const secondarySync = $derived(gitSyncContext?.getSecondarySyncRepositories() || []) const secondaryPromotion = $derived(gitSyncContext?.getSecondaryPromotionRepositories() || []) + // Fork creation keeps only sync-mode repositories and a fork is refused a + // promotion one, so the single way a fork holds one is a dev workspace + // detached back into a plain fork. Deploys still sync through it (only the + // promotion branching is dropped), so name it instead of showing nothing. + const promotionModeRepos = $derived( + showPromotion ? [] : [primaryPromotion, ...secondaryPromotion].filter((r) => r != null) + ) + // Promotion is what a dev workspace does, so a fork that wants it can be + // re-designated as one. Pairing is prod-scoped and admin-gated there, so this + // only links to the parent's screen, and only when the parent is a workspace + // the user actually has. + const devPairingHref = $derived.by(() => { + const parent = currentWorkspace?.parent_workspace_id + if (!parent || !$userWorkspaces?.some((w) => w.id === parent)) return undefined + return `${base}/workspace_settings?workspace=${parent}&tab=dev_workspace` + }) // State for collapsible sections let secondarySyncExpanded = $state(false) @@ -303,6 +320,30 @@ {/if} {/if}
    + {:else if !showPromotion} +
    + + Deploys in a fork always commit to the fork's own wm-fork/** branch, so a promotion + repository would never take effect here. Promote this fork's work by merging that + branch into the tracked branch instead. + {#if devPairingHref} +
    + To promote per item from this workspace, pair it with its parent as a + dev workspace. +
    + {/if} + {#if promotionModeRepos.length > 0} +
    + Still set to promotion mode here, and still syncing deploys to the fork's branch: + {promotionModeRepos.map((r) => r.repo.git_repo_resource_path).join(', ')} +
    + {/if} +
    +
    {/if} {/if}
    diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 38eddb2b95..2631af792f 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -165,6 +165,20 @@ // shown in the breadcrumb). const currentFamily = $derived(findRoot($workspaceStore ?? undefined)) + // 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 + // identical rows. Show the (unique) id alongside the name wherever a name is + // shared, so the rows stay tellable apart. + const ambiguousNames = $derived.by(() => { + const seen = new Set() + const ambiguous = new Set() + for (const w of $userWorkspaces ?? []) { + if (seen.has(w.name)) ambiguous.add(w.name) + seen.add(w.name) + } + return ambiguous + }) + // The active workspace itself (fork included) — names the settings entry. const activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( @@ -205,6 +219,9 @@ icon={Building} iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined} label={currentFamily?.name ?? $workspaceStore ?? ''} + sublabel={!isCollapsed && currentFamily && ambiguousNames.has(currentFamily.name) + ? currentFamily.id + : undefined} {isCollapsed} color={familyColor} showChevron @@ -293,6 +310,11 @@ > {/if} + {#if ambiguousNames.has(workspace.name)} +
    + {workspace.id} +
    + {/if} {#if isSelected} @@ -363,7 +385,9 @@ {item} > - {activeWorkspace?.name ?? $workspaceStore} settings + {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) + ? activeWorkspace.id + : activeWorkspace?.name) ?? $workspaceStore} settings {/if} From 717e38a0c6b5bb2340e236a9d49645a4cebf4849 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 24 Jul 2026 09:58:27 +0200 Subject: [PATCH 55/66] feat: let a workspace fall back to the instance critical alert channels (#10292) * feat(alerts): let a workspace fall back to the instance critical alert channels A workspace with no error handler had no way to surface failed jobs, and the instance critical alert channels a superadmin already configured (Slack, Teams, email) were unreachable from a workspace: the workspace Slack error handler posts with the workspace's own bot token, not the instance one. Adds an opt-in workspace setting that reports failed jobs to those channels when, and only when, no workspace error handler is configured. The report is send-only: it skips the `alerts` table so workspace job failures never flood the instance-wide feed superadmins triage. Rejected on cloud (the channels belong to the instance operator, who is not the tenant) and on fork workspaces (throwaway copies of a parent's runnables). Settable from workspace settings and from the new-workspace screen. The opt-in and the existing `mute_critical_alerts` flag are folded into the query already behind WORKSPACE_ERROR_HANDLER_CACHE, so a failed job costs no extra round trip, and workspaces with neither a handler nor the opt-in return before the per-runnable mute lookup. * chore(sqlx): add offline query cache entries for the new settings queries * refactor(alerts): make instance alerts a destination tab and address review Instance alerts are a fifth error-handler destination rather than a separate toggle: the backend already treats them as mutually exclusive with a handler script, so one "where do failures go?" control matches the semantics and drops the inert-while-a-handler-is-set state. The tab is offered on the workspace error handler only, not on schedules or triggers. Review fixes: - the fork boundary is enforced at dispatch (join on parent_workspace_id), so a workspace attached as a fork/dev after opting in stops reporting; attaching also clears the stored flag, and the settings page never selects a tab it does not render, which would have submitted a value the API rejects on a fork - mute_critical_alerts no longer gates this path: it is the UI-feed mute, and this path writes no feed entry - cancellations are not reported: they are a human action, and this destination has no per-workspace mute of its own - per-workspace throttle with a rollup count, so a flapping runnable cannot turn into unbounded Slack/SMTP traffic on channels shared by the whole instance - log the dispatch, audit the flag, name the columns in the rename INSERT, drop the generated migration placeholders * chore(alerts): state the fork/cloud invariant on canUseInstanceAlerts * chore(sqlx): cache the attach_dev_workspace settings update --- ...0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json | 15 ++ ...234f6a2f295a9c46897f5e9aab8f7298ff0d7.json | 15 ++ ...76705eeda7d17d49cafb3e9e6285dee6804c9.json | 15 ++ ...67ad00ff6c13918d46dd474cc48824b12ccaf.json | 208 ++++++++++++++++++ ...e63954f39b291f525e36e6ece916b98b4d2c9.json | 15 ++ ...d8a46dc7320e7dcdd64befe808dec8cbe2265.json | 16 ++ ...6a8e333912b6a5eba1c5d7b3a0ee75cc95b10.json | 20 ++ ...ad84b0a1bb18b35e0f505ab008bff1310528b.json | 12 + ...ndler_fallback_to_instance_alerts.down.sql | 2 + ...handler_fallback_to_instance_alerts.up.sql | 2 + .../tests/workspaces.rs | 58 +++++ .../windmill-api-workspaces/src/workspaces.rs | 78 ++++++- .../src/workspaces_extra.rs | 2 +- backend/windmill-api/openapi.yaml | 11 + backend/windmill-common/src/utils.rs | 14 ++ backend/windmill-queue/src/jobs.rs | 178 ++++++++++----- .../components/ErrorOrRecoveryHandler.svelte | 35 ++- .../CreateWorkspaceInner.svelte | 29 ++- .../(logged)/workspace_settings/+page.svelte | 65 ++++-- 19 files changed, 703 insertions(+), 87 deletions(-) create mode 100644 backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json create mode 100644 backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json create mode 100644 backend/.sqlx/query-40c591ab93adf3bb4f17598f78b76705eeda7d17d49cafb3e9e6285dee6804c9.json create mode 100644 backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json create mode 100644 backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json create mode 100644 backend/.sqlx/query-7da2e04f64cef6634256d7b3b12d8a46dc7320e7dcdd64befe808dec8cbe2265.json create mode 100644 backend/.sqlx/query-88943f52672bffc69c75bc6b06a6a8e333912b6a5eba1c5d7b3a0ee75cc95b10.json create mode 100644 backend/.sqlx/query-d6269058b3e5146de5b32ac2363ad84b0a1bb18b35e0f505ab008bff1310528b.json create mode 100644 backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.down.sql create mode 100644 backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.up.sql diff --git a/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json b/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json new file mode 100644 index 0000000000..9574582d5f --- /dev/null +++ b/backend/.sqlx/query-042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "042c42957221352891f7433f2ec0d6b7f686d9c7d505590eec7ba3bfeeb406ca" +} diff --git a/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json b/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json new file mode 100644 index 0000000000..8714711595 --- /dev/null +++ b/backend/.sqlx/query-2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2dc5a74c4e614b43148925cdbac234f6a2f295a9c46897f5e9aab8f7298ff0d7" +} diff --git a/backend/.sqlx/query-40c591ab93adf3bb4f17598f78b76705eeda7d17d49cafb3e9e6285dee6804c9.json b/backend/.sqlx/query-40c591ab93adf3bb4f17598f78b76705eeda7d17d49cafb3e9e6285dee6804c9.json new file mode 100644 index 0000000000..114712fc28 --- /dev/null +++ b/backend/.sqlx/query-40c591ab93adf3bb4f17598f78b76705eeda7d17d49cafb3e9e6285dee6804c9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "40c591ab93adf3bb4f17598f78b76705eeda7d17d49cafb3e9e6285dee6804c9" +} diff --git a/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json b/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json new file mode 100644 index 0000000000..353920fdeb --- /dev/null +++ b/backend/.sqlx/query-5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf.json @@ -0,0 +1,208 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_team_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "teams_team_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "teams_team_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "teams_team_guid", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "slack_name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "slack_command_script", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "teams_command_script", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "slack_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "plan", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "webhook", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "deploy_to", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "ai_config", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "datatable", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "ducklake", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "git_sync", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "deploy_ui", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "default_app", + "type_info": "Varchar" + }, + { + "ordinal": 22, + "name": "default_scripts", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "mute_critical_alerts", + "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "git_app_installations", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "auto_invite", + "type_info": "Jsonb" + }, + { + "ordinal": 28, + "name": "error_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 29, + "name": "success_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "public_app_execution_limit_per_minute", + "type_info": "Int4" + }, + { + "ordinal": 31, + "name": "error_handler_fallback_to_instance_alerts", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + false + ] + }, + "hash": "5ae9ad14effe923f1952d0e1d1f67ad00ff6c13918d46dd474cc48824b12ccaf" +} diff --git a/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json b/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json new file mode 100644 index 0000000000..243c3f5fa7 --- /dev/null +++ b/backend/.sqlx/query-620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET deploy_to = $1, error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "620c9efca071cb5fb8b33857129e63954f39b291f525e36e6ece916b98b4d2c9" +} diff --git a/backend/.sqlx/query-7da2e04f64cef6634256d7b3b12d8a46dc7320e7dcdd64befe808dec8cbe2265.json b/backend/.sqlx/query-7da2e04f64cef6634256d7b3b12d8a46dc7320e7dcdd64befe808dec8cbe2265.json new file mode 100644 index 0000000000..7eef899aa7 --- /dev/null +++ b/backend/.sqlx/query-7da2e04f64cef6634256d7b3b12d8a46dc7320e7dcdd64befe808dec8cbe2265.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings\n (workspace_id, color, error_handler_fallback_to_instance_alerts)\n VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "7da2e04f64cef6634256d7b3b12d8a46dc7320e7dcdd64befe808dec8cbe2265" +} diff --git a/backend/.sqlx/query-88943f52672bffc69c75bc6b06a6a8e333912b6a5eba1c5d7b3a0ee75cc95b10.json b/backend/.sqlx/query-88943f52672bffc69c75bc6b06a6a8e333912b6a5eba1c5d7b3a0ee75cc95b10.json new file mode 100644 index 0000000000..21679c4ec2 --- /dev/null +++ b/backend/.sqlx/query-88943f52672bffc69c75bc6b06a6a8e333912b6a5eba1c5d7b3a0ee75cc95b10.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "error_handler_fallback_to_instance_alerts", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "88943f52672bffc69c75bc6b06a6a8e333912b6a5eba1c5d7b3a0ee75cc95b10" +} diff --git a/backend/.sqlx/query-d6269058b3e5146de5b32ac2363ad84b0a1bb18b35e0f505ab008bff1310528b.json b/backend/.sqlx/query-d6269058b3e5146de5b32ac2363ad84b0a1bb18b35e0f505ab008bff1310528b.json new file mode 100644 index 0000000000..13e31508a0 --- /dev/null +++ b/backend/.sqlx/query-d6269058b3e5146de5b32ac2363ad84b0a1bb18b35e0f505ab008bff1310528b.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET parent_workspace_id = 'test-workspace' WHERE id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "d6269058b3e5146de5b32ac2363ad84b0a1bb18b35e0f505ab008bff1310528b" +} diff --git a/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.down.sql b/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.down.sql new file mode 100644 index 0000000000..a1e5abccc3 --- /dev/null +++ b/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspace_settings + DROP COLUMN IF EXISTS error_handler_fallback_to_instance_alerts; diff --git a/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.up.sql b/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.up.sql new file mode 100644 index 0000000000..2ab4b8b673 --- /dev/null +++ b/backend/migrations/20260723215437_error_handler_fallback_to_instance_alerts.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspace_settings + ADD COLUMN IF NOT EXISTS error_handler_fallback_to_instance_alerts BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index c319a1b9ec..a681fd982e 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -901,6 +901,64 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row( Ok(()) } +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_error_handler_instance_alerts_fallback(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let stored = || async { + sqlx::query_scalar!( + "SELECT error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = 'test-workspace'" + ) + .fetch_one(&db) + .await + }; + + let resp = authed(client().post(format!("{base}/edit_error_handler"))) + .json(&json!({"path": null, "fallback_to_instance_alerts": true})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "enable: {}", resp.text().await?); + assert!(stored().await?); + + // A client that predates the setting (the CLI pushing settings.yaml) omits the field and + // must not silently turn it back off. + let resp = authed(client().post(format!("{base}/edit_error_handler"))) + .json(&json!({"path": null, "extra_args": null})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "omitted: {}", resp.text().await?); + assert!(stored().await?); + + sqlx::query!( + "UPDATE workspace SET parent_workspace_id = 'test-workspace' WHERE id = 'test-workspace'" + ) + .execute(&db) + .await?; + let resp = authed(client().post(format!("{base}/edit_error_handler"))) + .json(&json!({"path": null, "fallback_to_instance_alerts": true})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400, "fork must be rejected"); + + // The settings page stops offering the option once the workspace is a fork, so its next save + // sends `false`: that must go through rather than lock the whole error handler behind a 400. + let resp = authed(client().post(format!("{base}/edit_error_handler"))) + .json(&json!({"path": null, "fallback_to_instance_alerts": false})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "disable on fork: {}", resp.text().await?); + assert!(!stored().await?); + + Ok(()) +} + #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_get_imports(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index b025261270..9cf80e5e39 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -302,6 +302,7 @@ pub struct WorkspaceSettings { pub success_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] pub public_app_execution_limit_per_minute: Option, + pub error_handler_fallback_to_instance_alerts: bool, } /// Subset of `WorkspaceSettings` that is safe to return to any workspace @@ -451,6 +452,8 @@ struct CreateWorkspace { name: String, username: Option, color: Option, + #[serde(default)] + error_handler_fallback_to_instance_alerts: bool, } #[derive(Deserialize)] @@ -558,6 +561,9 @@ pub struct EditErrorHandlerNew { pub muted_on_cancel: bool, #[serde(default)] pub muted_on_user_path: bool, + /// Left as `None` by clients that predate the setting (the CLI among them), which must + /// keep the stored value rather than silently reset it on every settings push. + pub fallback_to_instance_alerts: Option, } // Legacy format for error handler (flat fields from old CLI) @@ -586,6 +592,7 @@ impl EditErrorHandler { extra_args: legacy.error_handler_extra_args, muted_on_cancel: legacy.error_handler_muted_on_cancel, muted_on_user_path: false, // Old format doesn't have this field + fallback_to_instance_alerts: None, }, } } @@ -973,7 +980,8 @@ async fn get_settings( auto_invite, error_handler, success_handler, - public_app_execution_limit_per_minute + public_app_execution_limit_per_minute, + error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE @@ -4182,6 +4190,19 @@ async fn edit_error_handler( let mut tx = db.begin().await?; + if let Some(fallback_to_instance_alerts) = ee.fallback_to_instance_alerts { + if fallback_to_instance_alerts { + ensure_instance_alert_fallback_allowed(&mut tx, &w_id).await?; + } + sqlx::query!( + "UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = $1 WHERE workspace_id = $2", + fallback_to_instance_alerts, + &w_id + ) + .execute(&mut *tx) + .await?; + } + sqlx::query_as!( Group, "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", @@ -4260,7 +4281,16 @@ async fn edit_error_handler( ActionKind::Update, &w_id, Some(&authed.email), - Some([("error_handler", &format!("{:?}", ee.path)[..])].into()), + Some( + [ + ("error_handler", &format!("{:?}", ee.path)[..]), + ( + "fallback_to_instance_alerts", + &format!("{:?}", ee.fallback_to_instance_alerts)[..], + ), + ] + .into(), + ), ) .await?; tx.commit().await?; @@ -4737,6 +4767,36 @@ async fn session_workspace_status( Ok(Json(statuses)) } +/// The instance critical alert channels belong to the instance operator, who on cloud is +/// not the workspace owner and never opted into a tenant's job failures. Fork workspaces run +/// throwaway copies of their parent's runnables, so instance-wide operational alerting must +/// stay a property of the real workspace. +async fn ensure_instance_alert_fallback_allowed<'c>( + tx: &mut Transaction<'c, Postgres>, + w_id: &str, +) -> Result<()> { + if *CLOUD_HOSTED { + return Err(Error::BadRequest( + "Reporting to the instance critical alert channels is not available on cloud" + .to_string(), + )); + } + let is_fork = sqlx::query_scalar!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#, + w_id + ) + .fetch_optional(&mut **tx) + .await? + .unwrap_or(false); + if is_fork { + return Err(Error::BadRequest( + "Reporting to the instance critical alert channels cannot be enabled on a fork workspace" + .to_string(), + )); + } + Ok(()) +} + pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> { if w_id == "global" { return Err(windmill_common::error::Error::BadRequest( @@ -4913,12 +4973,16 @@ async fn create_workspace( ) .execute(&mut *tx) .await?; + if nw.error_handler_fallback_to_instance_alerts { + ensure_instance_alert_fallback_allowed(&mut tx, &nw.id).await?; + } sqlx::query!( "INSERT INTO workspace_settings - (workspace_id, color) - VALUES ($1, $2)", + (workspace_id, color, error_handler_fallback_to_instance_alerts) + VALUES ($1, $2, $3)", nw.id, nw.color, + nw.error_handler_fallback_to_instance_alerts, ) .execute(&mut *tx) .await?; @@ -6996,8 +7060,12 @@ async fn attach_dev_workspace( ) .execute(&mut *tx) .await?; + // Clearing the instance-alert opt-in here keeps the stored setting truthful for a workspace + // that becomes parent-managed: dispatch enforces the fork boundary on its own, but a lingering + // `true` would survive a later detach and would make the settings page submit a value the API + // rejects on a fork. sqlx::query!( - "UPDATE workspace_settings SET deploy_to = $1 WHERE workspace_id = $2", + "UPDATE workspace_settings SET deploy_to = $1, error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $2", &prod_w_id, &dev_w_id ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index aa89f1ffd5..7998a769f9 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -108,7 +108,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, deploy_to, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8c082f5052..b67461de43 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3617,6 +3617,9 @@ paths: public_app_execution_limit_per_minute: type: integer description: Rate limit for public app executions per minute per server. NULL or 0 means disabled. + error_handler_fallback_to_instance_alerts: + type: boolean + description: Report failed jobs to the instance critical alert channels when no workspace error handler is set. /w/{workspace}/workspaces/get_deploy_to: get: @@ -24232,6 +24235,9 @@ components: muted_on_user_path: type: boolean default: false + fallback_to_instance_alerts: + type: boolean + description: Report failed jobs to the instance critical alert channels when no workspace error handler is set. Omit to leave the stored value untouched. EditErrorHandlerLegacy: type: object @@ -26883,6 +26889,7 @@ components: - slack - teams - email + - instance_alerts NewSchedule: type: object @@ -29811,6 +29818,10 @@ components: type: string color: type: string + error_handler_fallback_to_instance_alerts: + type: boolean + default: false + description: Report failed jobs to the instance critical alert channels when no workspace error handler is set. Not available on cloud or on fork workspaces. required: - id - name diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index efe6477ad3..e2597fdfce 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -563,6 +563,20 @@ pub async fn report_critical_error( } } +/// Route a workspace-level failure to the instance critical alert channels without +/// recording an `alerts` row: job failures are workspace noise and would otherwise flood +/// the instance-wide feed superadmins triage. The channels belong to the instance operator, +/// who on cloud is not the workspace owner, hence the hard stop there. Callers own the +/// per-workspace opt-in. +pub async fn send_workspace_error_to_instance_channels(_error_message: String, _db: &DB) -> () { + if *CLOUD_HOSTED { + return; + } + + #[cfg(feature = "enterprise")] + send_critical_alert(_error_message, _db, CriticalAlertKind::CriticalError, None).await; +} + pub async fn report_recovered_critical_error( message: String, _db: DB, diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 4b2196bf63..f2d0425710 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -953,8 +953,13 @@ lazy_static::lazy_static! { static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option)> = Cache::new(10000); // Cache for workspace error handler settings with 60s TTL - // Key: workspace_id, Value: (error_handler, error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, expiry_timestamp) - static ref WORKSPACE_ERROR_HANDLER_CACHE: Cache, Option>>, bool, bool, i64)> = Cache::new(1000); + // Key: workspace_id, Value: (error_handler, error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, report_to_instance_alerts, expiry_timestamp) + static ref WORKSPACE_ERROR_HANDLER_CACHE: Cache, Option>>, bool, bool, bool, i64)> = Cache::new(1000); + + // Best-effort per-worker throttle for the instance-channel fallback: a flapping runnable + // would otherwise turn every failure into outbound Slack/SMTP traffic on channels shared by + // the whole instance. Key: workspace_id, Value: (last_sent_epoch, failures suppressed since) + static ref INSTANCE_ALERT_THROTTLE: Cache = Cache::new(1000); // Cache for workspace success handler settings with 60s TTL // Key: workspace_id, Value: (success_handler, success_handler_extra_args, expiry_timestamp) @@ -962,6 +967,7 @@ lazy_static::lazy_static! { } const WORKSPACE_HANDLER_CACHE_TTL_SECONDS: i64 = 60; +const INSTANCE_ALERT_COOLDOWN_SECONDS: i64 = 60; pub async fn add_completed_job( db: &Pool, @@ -2125,7 +2131,7 @@ pub async fn report_error_to_workspace_handler_or_critical_side_channel( async fn fetch_error_handler_from_db( db: &Pool, w_id: &str, -) -> Result<(Option, Option>>, bool, bool), Error> { +) -> Result<(Option, Option>>, bool, bool, bool), Error> { sqlx::query_as::< _, ( @@ -2133,6 +2139,7 @@ async fn fetch_error_handler_from_db( Option>>, Option, Option, + bool, ), >( r#" @@ -2140,23 +2147,28 @@ async fn fetch_error_handler_from_db( error_handler->>'path', (error_handler->'extra_args')::text::json, (error_handler->>'muted_on_cancel')::boolean, - (error_handler->>'muted_on_user_path')::boolean - FROM workspace_settings - WHERE workspace_id = $1 + (error_handler->>'muted_on_user_path')::boolean, + ws.error_handler_fallback_to_instance_alerts AND w.parent_workspace_id IS NULL + FROM workspace_settings ws + JOIN workspace w ON w.id = ws.workspace_id + WHERE ws.workspace_id = $1 "#, ) .bind(w_id) .fetch_optional(db) .await .context("fetching error handler info from workspace_settings")? - .map(|(path, extra_args, muted_on_cancel, muted_on_user_path)| { - ( - path, - extra_args, - muted_on_cancel.unwrap_or(false), - muted_on_user_path.unwrap_or(false), - ) - }) + .map( + |(path, extra_args, muted_on_cancel, muted_on_user_path, report_to_instance_alerts)| { + ( + path, + extra_args, + muted_on_cancel.unwrap_or(false), + muted_on_user_path.unwrap_or(false), + report_to_instance_alerts, + ) + }, + ) .ok_or_else(|| Error::internal_err(format!("no workspace settings for id {w_id}"))) } @@ -2174,15 +2186,22 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync> error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, + report_to_instance_alerts, ) = if let Some(cached) = WORKSPACE_ERROR_HANDLER_CACHE.get(w_id) { - if cached.4 > now { - (cached.0.clone(), cached.1.clone(), cached.2, cached.3) + if cached.5 > now { + ( + cached.0.clone(), + cached.1.clone(), + cached.2, + cached.3, + cached.4, + ) } else { let row = fetch_error_handler_from_db(db, w_id).await?; let expiry = now + WORKSPACE_HANDLER_CACHE_TTL_SECONDS; WORKSPACE_ERROR_HANDLER_CACHE.insert( w_id.clone(), - (row.0.clone(), row.1.clone(), row.2, row.3, expiry), + (row.0.clone(), row.1.clone(), row.2, row.3, row.4, expiry), ); row } @@ -2191,11 +2210,17 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync> let expiry = now + WORKSPACE_HANDLER_CACHE_TTL_SECONDS; WORKSPACE_ERROR_HANDLER_CACHE.insert( w_id.clone(), - (row.0.clone(), row.1.clone(), row.2, row.3, expiry), + (row.0.clone(), row.1.clone(), row.2, row.3, row.4, expiry), ); row }; + // Nothing to do for the vast majority of workspaces, and returning here keeps the + // per-runnable mute lookup below off the path of every failed job. + if error_handler.is_none() && !report_to_instance_alerts { + return Ok(()); + } + if is_canceled && error_handler_muted_on_cancel { return Ok(()); } @@ -2209,51 +2234,90 @@ pub async fn send_error_to_workspace_handler<'a, 'c, T: Serialize + Send + Sync> } } - if let Some(error_handler) = error_handler { - let ws_error_handler_muted: Option = match queued_job.kind { - JobKind::Script => { - sqlx::query_scalar!( + let ws_error_handler_muted: Option = match queued_job.kind { + JobKind::Script => { + sqlx::query_scalar!( "SELECT ws_error_handler_muted FROM script WHERE workspace_id = $1 AND hash = $2", queued_job.workspace_id, queued_job.runnable_id.map(|x| x.0), ) - .fetch_optional(db) - .await? - } - JobKind::Flow => { - sqlx::query_scalar!( - "SELECT ws_error_handler_muted FROM flow WHERE workspace_id = $1 AND path = $2", - queued_job.workspace_id, - queued_job.runnable_path.clone(), - ) - .fetch_optional(db) - .await? - } - _ => None, - }; - - let muted = ws_error_handler_muted.unwrap_or(false); - if !muted { - tracing::info!("workspace error handled for job {}", &queued_job.id); - - push_error_handler( - db, - queued_job.id, - queued_job.schedule_path(), + .fetch_optional(db) + .await? + } + JobKind::Flow => { + sqlx::query_scalar!( + "SELECT ws_error_handler_muted FROM flow WHERE workspace_id = $1 AND path = $2", + queued_job.workspace_id, queued_job.runnable_path.clone(), - queued_job.is_flow(), - &queued_job.workspace_id, - &error_handler, - result, - None, - queued_job.started_at, - error_handler_extra_args, - &queued_job.permissioned_as_email, - false, - false, - None, ) - .await?; + .fetch_optional(db) + .await? + } + _ => None, + }; + + if ws_error_handler_muted.unwrap_or(false) { + return Ok(()); + } + + if let Some(error_handler) = error_handler { + tracing::info!("workspace error handled for job {}", &queued_job.id); + + push_error_handler( + db, + queued_job.id, + queued_job.schedule_path(), + queued_job.runnable_path.clone(), + queued_job.is_flow(), + &queued_job.workspace_id, + &error_handler, + result, + None, + queued_job.started_at, + error_handler_extra_args, + &queued_job.permissioned_as_email, + false, + false, + None, + ) + .await?; + } else if !is_canceled { + // A cancellation is a human action rather than an operational failure, and unlike the + // handler path this one has no per-workspace toggle to opt out of reporting them. + let suppressed = match INSTANCE_ALERT_THROTTLE.get(w_id) { + Some((last_sent, suppressed)) + if now - last_sent < INSTANCE_ALERT_COOLDOWN_SECONDS => + { + INSTANCE_ALERT_THROTTLE.insert(w_id.clone(), (last_sent, suppressed + 1)); + None + } + entry => { + INSTANCE_ALERT_THROTTLE.insert(w_id.clone(), (now, 0)); + Some(entry.map(|(_, suppressed)| suppressed).unwrap_or(0)) + } + }; + if let Some(suppressed) = suppressed { + tracing::info!( + "reporting failed job {} to the instance critical alert channels", + &queued_job.id + ); + let base_url = windmill_common::BASE_URL.load(); + let rollup = if suppressed > 0 { + format!( + " (and {suppressed} more failure(s) in the preceding {INSTANCE_ALERT_COOLDOWN_SECONDS}s)" + ) + } else { + String::new() + }; + windmill_common::utils::send_workspace_error_to_instance_channels( + format!( + "Job {} failed in workspace {w_id} ({base_url}/run/{}?workspace={w_id}){rollup}", + queued_job.runnable_path.as_deref().unwrap_or("preview"), + queued_job.id + ), + db, + ) + .await; } } Ok(()) diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index 330275ee75..89e2554c01 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -85,6 +85,9 @@ * nav `$workspaceStore`; a trigger editor in a forked session passes its * acting workspace so the handler is resolved and saved there. */ workspace?: string + /** Offer the instance critical alert channels as a destination. Workspace-level + * error handling only: schedules and triggers have no such setting. */ + showInstanceAlerts?: boolean } let { @@ -99,7 +102,8 @@ customHandlerKind = $bindable('script'), customTabTooltip, noMargin = false, - workspace = undefined + workspace = undefined, + showInstanceAlerts = false }: Props = $props() let effectiveWorkspace = $derived(workspace ?? $workspaceStore) @@ -363,6 +367,14 @@ handlerPath = hubPaths.emailErrorHandler } }) + + // The instance channels are reached by having no workspace handler at all, so the tab + // owns an empty path rather than a handler script. + $effect(() => { + if (handlerSelected === 'instance_alerts') { + handlerPath = undefined + } + })
    @@ -378,6 +390,15 @@ disabled={!isEditable} tooltip={customTabTooltip ? 'Custom error handler with script or flow' : undefined} /> + {#if showInstanceAlerts} + + {/if} {/snippet} @@ -652,6 +673,18 @@ {/if}
    {/if} + {:else if handlerSelected === 'instance_alerts'} +
    + + Failed jobs are reported to the Slack, Teams and email channels configured at the instance + level. Those channels are managed in instance settings by a superadmin, not here, and the + report is sent without adding an entry to the instance critical alert feed. Canceled jobs + are not reported. + + + Configure the instance critical alert channels + +
    {/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index b085649b89..23ed37caad 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -14,7 +14,13 @@ import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' import { page } from '$app/state' - import { superadmin, usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { + enterpriseLicense, + superadmin, + usersWorkspaceStore, + userWorkspaces, + workspaceStore + } from '$lib/stores' import { workspaceIsFork, findWorkspaceRoot, @@ -204,6 +210,7 @@ let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) + let errorHandlerFallbackToInstanceAlerts = $state(false) function generateRandomColor() { const randomColor = @@ -452,7 +459,8 @@ id, name, color: colorEnabled && workspaceColor ? workspaceColor : undefined, - username: automateUsernameCreation ? undefined : username + username: automateUsernameCreation ? undefined : username, + error_handler_fallback_to_instance_alerts: errorHandlerFallbackToInstanceAlerts } }) if (auto_invite) { @@ -784,6 +792,23 @@ {/if} + {#if !isFork && !isCloudHosted() && $enterpriseLicense} + + {/if} {#if isFork && canDesignateDevWorkspace}