Merge branch 'main' into hub-integrations-prominence

This commit is contained in:
hugocasa
2026-08-28 17:37:48 +02:00
committed by GitHub
192 changed files with 9093 additions and 1721 deletions
+8 -9
View File
@@ -179,15 +179,14 @@ jobs:
# binary link spikes several hundred MB of transient I/O. Capping at
# 8 trades ~25% wall time for headroom on the ~75GB runner disk.
CARGO_BUILD_JOBS: 8
# backend/Cargo.toml leaves profile.dev at the default debug = 2 for
# the (large) windmill workspace crates; that debuginfo is emitted
# into every object file and embedded in each test binary, and on
# windows-msvc also spawns the mspdbsrv.exe PDB type server. Across a
# full --all --features build it is the dominant consumer of the
# ~63GB free on the runner disk (LNK1180 / disk-full during linking).
# CI needs no debug info, so drop it entirely for the dev/test
# profiles here. debug = 0 supersedes the previous split-debuginfo=off
# knob (no debuginfo => no .pdb and no LNK1318 type-server limit).
# backend/Cargo.toml keeps line tables on profile.dev for the (large)
# windmill workspace crates; that debuginfo is emitted into every
# object file and embedded in each test binary, and on windows-msvc
# also spawns the mspdbsrv.exe PDB type server. Across the worker
# crates' test build it drives the peak on the ~63GB free of the
# runner disk (LNK1180 / disk-full during linking). CI reads no
# backtraces, so drop it entirely for the dev/test profiles here:
# debug = 0 means no .pdb and no LNK1318 type-server limit.
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
# Tests' poll-time stack frames (deep nested async fn chains in
+7 -7
View File
@@ -268,13 +268,13 @@ jobs:
# overhead and extra disk. Off here (kept on for local dev via
# .cargo/config.toml). Matches backend-test-windows.yml.
CARGO_INCREMENTAL: "0"
# backend/Cargo.toml leaves profile.dev at the default debug = 2 for
# the (large) windmill workspace crates; that debug info is emitted
# into every object file and embedded in each test binary. Across the
# full --all --features build it is the dominant memory/disk consumer
# when mold links the windmill-api-integration-tests binary, tipping
# the runner over (lost runner reported as a canceled step). CI needs
# no debug info, so drop it entirely for the dev/test profiles here.
# backend/Cargo.toml keeps line tables on profile.dev for the (large)
# windmill workspace crates; that debug info is emitted into every
# object file and embedded in each test binary. Across the full
# --all --features build it drives the memory/disk peak when mold
# links the windmill-api-integration-tests binary, tipping the runner
# over (lost runner reported as a canceled step). CI reads no
# backtraces, so drop it entirely for the dev/test profiles here.
# (test profile inherits dev, but the workspace crates link in as
# dev-profile deps, so both must be set.) CI-only; local dev builds
# are unaffected.
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.796.0"
".": "1.799.0"
}
+61
View File
@@ -1,5 +1,66 @@
# Changelog
## [1.799.0](https://github.com/windmill-labs/windmill/compare/v1.798.1...v1.799.0) (2026-08-28)
### Features
* enable Anthropic prompt caching on Vertex AI agent steps ([#10876](https://github.com/windmill-labs/windmill/issues/10876)) ([320f400](https://github.com/windmill-labs/windmill/commit/320f4005124202852e6e9c70b394e7f87231d278))
* instrument AI fill/fix, evals, agents and the debugger ([#10853](https://github.com/windmill-labs/windmill/issues/10853)) ([0bbd559](https://github.com/windmill-labs/windmill/commit/0bbd559ac8a35dba04ba5e8d6f2fd8d1d1124891))
### Bug Fixes
* **datatables:** stop a fork's pg_dump restore from failing silently ([#10830](https://github.com/windmill-labs/windmill/issues/10830)) ([3ce9bbc](https://github.com/windmill-labs/windmill/commit/3ce9bbc7168b837cb2111aabd533bb67803502b8))
* key build artifact caches on a runnable's inline modules ([#10819](https://github.com/windmill-labs/windmill/issues/10819)) ([b72ccc3](https://github.com/windmill-labs/windmill/commit/b72ccc35934165b4bad112b947ca5af064aab26f))
* nested template literals in step inputs, and unresolvable $args tags ([#10856](https://github.com/windmill-labs/windmill/issues/10856)) ([8f349c0](https://github.com/windmill-labs/windmill/commit/8f349c032a0d75fc3350292075e5050a030f6166))
* pre-fill the test panel JSON args editor and align its placeholder ([#10871](https://github.com/windmill-labs/windmill/issues/10871)) ([fb82f36](https://github.com/windmill-labs/windmill/commit/fb82f36e6d6492dd0740984d8d78ea4eaa30361e))
* reject a prefixed error_handler_path on triggers ([#10847](https://github.com/windmill-labs/windmill/issues/10847)) ([d334831](https://github.com/windmill-labs/windmill/commit/d33483173526a3b352d2829ac8a2e1e229cc1127))
* unify billable seat counting and prevent fork subscriptions ([#10818](https://github.com/windmill-labs/windmill/issues/10818)) ([7dd88c4](https://github.com/windmill-labs/windmill/commit/7dd88c470caee5f095dc240667aa7550c55696bc))
## [1.798.1](https://github.com/windmill-labs/windmill/compare/v1.798.0...v1.798.1) (2026-08-27)
### Bug Fixes
* allow job tokens to read the automate_username_creation setting ([#10869](https://github.com/windmill-labs/windmill/issues/10869)) ([c2279db](https://github.com/windmill-labs/windmill/commit/c2279db8a96ac76382eafe254627dafd24d173fd))
## [1.798.0](https://github.com/windmill-labs/windmill/compare/v1.797.0...v1.798.0) (2026-08-27)
### Features
* a wizard for importing a hub project, and finishing what the import cannot ([#10729](https://github.com/windmill-labs/windmill/issues/10729)) ([2913339](https://github.com/windmill-labs/windmill/commit/29133398f99cd2dd5b33057ee9df4492d82e067a))
## [1.797.0](https://github.com/windmill-labs/windmill/compare/v1.796.0...v1.797.0) (2026-08-26)
### Features
* configurable expiry for presigned s3 public url signatures ([#10835](https://github.com/windmill-labs/windmill/issues/10835)) ([8a6dc27](https://github.com/windmill-labs/windmill/commit/8a6dc27236aca67f0efe941d9606b787c2305ea8))
* **frontend:** flag the fork-compare datatable schema diff as legacy ([#10829](https://github.com/windmill-labs/windmill/issues/10829)) ([07c77ea](https://github.com/windmill-labs/windmill/commit/07c77ead7425f1877372d358d867445a4c525c96))
* keep a Hub project live while an update is under review ([#10814](https://github.com/windmill-labs/windmill/issues/10814)) ([c04b570](https://github.com/windmill-labs/windmill/commit/c04b5705745c36ecbb3a551ac59459218d2e3807))
### Bug Fixes
* **cli:** keep svelte component styles in the raw-app bundle ([#10838](https://github.com/windmill-labs/windmill/issues/10838)) ([b8bf539](https://github.com/windmill-labs/windmill/commit/b8bf539c3fe2b4db9c74dd73f04b3029287acdc6))
* **debugger:** parse bun 1.4's UUID inspector token ([#10828](https://github.com/windmill-labs/windmill/issues/10828)) ([4658224](https://github.com/windmill-labs/windmill/commit/46582245926a7f8ea961bcd125a58fbfba3530cf))
* force HTTP router rebuild on trigger-change notification ([#10849](https://github.com/windmill-labs/windmill/issues/10849)) ([ffdf17e](https://github.com/windmill-labs/windmill/commit/ffdf17ef8dc5575dd92d62d0d0ba887c1e378576))
* **frontend:** follow the operating workspace in step input forms ([#10834](https://github.com/windmill-labs/windmill/issues/10834)) ([6b73145](https://github.com/windmill-labs/windmill/commit/6b73145e7220232601538b801ebc9dc73fe79bbb))
* **frontend:** key the GitHub App installation selector on installation_id ([#10831](https://github.com/windmill-labs/windmill/issues/10831)) ([78331fd](https://github.com/windmill-labs/windmill/commit/78331fda8b290a2d9a5dd92b8362ff32c8b39432))
* **frontend:** operator menu opens on hover, pins on click ([#10824](https://github.com/windmill-labs/windmill/issues/10824)) ([665f83e](https://github.com/windmill-labs/windmill/commit/665f83e1f438e34d006429889d51a5fb6a6b6176))
* keep connection string query parameters under token auth ([#10859](https://github.com/windmill-labs/windmill/issues/10859)) ([f131c39](https://github.com/windmill-labs/windmill/commit/f131c3920f50f9fa18cd637eac39609495999aef))
* migrate slack resource-connect oauth to v2 ([#10836](https://github.com/windmill-labs/windmill/issues/10836)) ([9fa8159](https://github.com/windmill-labs/windmill/commit/9fa8159ad16204cab52fd18a34a48ebf13f800f6))
* recover from unresolvable AI session links instead of a dead end ([#10854](https://github.com/windmill-labs/windmill/issues/10854)) ([e38c449](https://github.com/windmill-labs/windmill/commit/e38c449007f27b952808cba5aa812441f2ce5946))
* require admin on workspace tarball settings export ([#10817](https://github.com/windmill-labs/windmill/issues/10817)) ([46c363f](https://github.com/windmill-labs/windmill/commit/46c363ffa4bc72bef6b367ece4bdbeef5e0eadc9))
* restrict filesystem workspace storage to debug builds ([#10864](https://github.com/windmill-labs/windmill/issues/10864)) ([8b80b09](https://github.com/windmill-labs/windmill/commit/8b80b09f33d311f0881678577ca6004c12d97c22))
### Performance Improvements
* index the suspended-job resume test instead of filtering it ([#10863](https://github.com/windmill-labs/windmill/issues/10863)) ([69320b2](https://github.com/windmill-labs/windmill/commit/69320b28f615b897a92f580bd5961c41e5c29951))
## [1.796.0](https://github.com/windmill-labs/windmill/compare/v1.795.0...v1.796.0) (2026-08-24)
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT plan FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "plan",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583"
}
+5 -3
View File
@@ -131,9 +131,11 @@ minimal explicit set for dev.
## Workspace object storage in dev — use the local filesystem
For a dev workspace you don't need MinIO/S3: use the built-in **`FilesystemStorage`** large-file
storage (a root path on local disk). It is intentionally hidden from the settings-UI storage
dropdown (dev-only), so set it via the API. Requires the backend built with `parquet` (+ `private`
for the real S3 helpers, + `enterprise` if you want advanced permission rules enforced):
storage (a root path on local disk). It is a **debug-build affordance only** — every site that
builds a filesystem object store calls `ensure_filesystem_storage_allowed`, so release builds
refuse it, and the settings UI never offers it — so set it via the API on a `cargo run`/`cargo
test` binary. Requires the backend built with `parquet` (+ `private` for the real S3 helpers,
+ `enterprise` if you want advanced permission rules enforced):
```bash
curl -X POST "$BASE/api/w/<ws>/workspaces/edit_large_file_storage_config" \
+257 -168
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.796.0"
version = "1.799.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.796.0"
version = "1.799.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -100,6 +100,10 @@ path = "./src/main.rs"
opt-level = 0
incremental = true
split-debuginfo = "unpacked"
# Type and variable DWARF is the single largest thing in target/ and nothing in the dev loop
# reads it; backtraces only need the line tables, which this keeps. Raise to `true` when you
# actually need to inspect variables in gdb/lldb.
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
+1 -1
View File
@@ -1 +1 @@
d6aef91c0f7ba556befbf4addeb7674d4a9dd819
9ff97cd818e85940fec282c92161e98c1b8583e2
@@ -0,0 +1 @@
DROP INDEX IF EXISTS queue_suspended_v2;
@@ -0,0 +1,20 @@
-- Serves the suspended-job pull in windmill-common/src/worker.rs, whose resume test is the
-- indexed CASE expression. Two things about the shape are load-bearing:
-- * (priority DESC NULLS LAST, created_at) leads, so the scan yields that query's ORDER BY
-- and stops at the first match rather than sorting.
-- * the index is dropped before it is built rather than relying on IF NOT EXISTS. The
-- OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs runs these CONCURRENTLY, and an
-- interrupted concurrent build leaves the index present but invalid, which IF NOT EXISTS
-- would then skip rebuilding. Retiring the index this replaces is left to the migration
-- that follows, so this one can only ever be replayed while that index is still there to
-- cover the rebuild.
DROP INDEX IF EXISTS queue_suspended_v2;
CREATE INDEX IF NOT EXISTS queue_suspended_v2
ON v2_job_queue (
priority DESC NULLS LAST,
created_at,
(CASE WHEN suspend <= 0 THEN '-infinity'::timestamptz ELSE suspend_until END),
tag
)
WHERE suspend_until IS NOT NULL;
@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS queue_suspended
ON v2_job_queue (priority DESC NULLS LAST, created_at, suspend_until, suspend, tag)
WHERE suspend_until IS NOT NULL;
@@ -0,0 +1,6 @@
-- Retires the index queue_suspended_v2 replaces. Separate from the migration that builds it
-- so that one is only ever replayed while this index still exists: sqlx records a migration
-- only after all its statements run, so a process that dies before the record is written
-- replays the build, and its leading DROP would otherwise be destroying the sole usable
-- index rather than an interrupted build.
DROP INDEX IF EXISTS queue_suspended;
+3 -3
View File
@@ -16,9 +16,9 @@
"scopes": ["repository"]
},
"slack": {
"auth_url": "https://slack.com/oauth/authorize",
"token_url": "https://slack.com/api/oauth.access",
"scopes": ["chat:write:user", "users:read", "users:read.email"]
"auth_url": "https://slack.com/oauth/v2/authorize",
"token_url": "https://slack.com/api/oauth.v2.access",
"scopes": ["chat:write", "chat:write.public", "channels:join", "files:write"]
},
"supabase_wizard": {
"auth_url": "https://api.supabase.com/v1/oauth/authorize",
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.796.0"
version = "1.799.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.796.0"
version = "1.799.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+31 -20
View File
@@ -380,6 +380,7 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
"",
&mut None,
&None,
None,
)
.await
{
@@ -1434,21 +1435,30 @@ Windmill Community Edition {GIT_VERSION}
// Poll for new events from notify_event table
match windmill_common::notify_events::poll_notify_events(&db, last_event_id).await {
Ok(events) => {
let mut http_trigger_change_handled = false;
for event in events {
if !*windmill_common::QUIET_LOGS {
tracing::info!("Processing notify event: channel={}, payload={}", event.channel, event.payload);
}
process_notify_event(
&event.channel,
&event.payload,
&db,
&conn,
&tx,
server_mode,
worker_mode,
#[cfg(feature = "parquet")]
disable_s3_store,
).await;
let is_http_trigger_change = event.channel == "notify_http_trigger_change";
// Every changed http_trigger row emits its own event and each one forces
// a full router rebuild, but the batch's first successful rebuild already
// read every row the batch committed. A failed rebuild leaves the flag
// clear so the next event in the batch retries it.
if !(is_http_trigger_change && http_trigger_change_handled) {
let handled = process_notify_event(
&event.channel,
&event.payload,
&db,
&conn,
&tx,
server_mode,
worker_mode,
#[cfg(feature = "parquet")]
disable_s3_store,
).await;
http_trigger_change_handled |= is_http_trigger_change && handled;
}
last_event_id = last_event_id.max(event.id);
}
}
@@ -1670,6 +1680,9 @@ Windmill Community Edition {GIT_VERSION}
/// Process a single notify event from the polling-based event system.
/// This replaces the old PgListener notification handling.
///
/// Returns `false` when the event still needs handling. Only the HTTP router rebuild reports
/// that, because the poll loop coalesces those events and must not swallow the retry.
#[allow(unused_variables)]
async fn process_notify_event(
channel: &str,
@@ -1680,7 +1693,7 @@ async fn process_notify_event(
server_mode: bool,
worker_mode: bool,
#[cfg(feature = "parquet")] disable_s3_store: bool,
) {
) -> bool {
match channel {
"notify_config_change" => {
if payload == "server" && server_mode {
@@ -1825,17 +1838,14 @@ async fn process_notify_event(
#[cfg(feature = "http_trigger")]
"notify_http_trigger_change" => {
tracing::info!("HTTP trigger change detected: {}", payload);
match windmill_api::triggers::http::refresh_routers(db).await {
Ok((true, _)) => {
match windmill_api::triggers::http::refresh_routers(db, true).await {
Ok(_) => {
tracing::info!("Refreshed HTTP routers (trigger change)");
}
Ok((false, _)) => {
tracing::warn!(
"Should have refreshed HTTP routers (trigger change) but did not"
);
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
windmill_api::triggers::http::invalidate_routers();
return false;
}
};
}
@@ -2059,7 +2069,7 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db).await {
match windmill_api::triggers::http::refresh_routers(db, false).await {
Ok((true, _)) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
@@ -2179,6 +2189,7 @@ async fn process_notify_event(
tracing::warn!("Unknown notification channel: {}", channel);
}
}
true
}
fn display_config(envs: &[&str]) {
+90 -1
View File
@@ -17,7 +17,11 @@
//! so two test functions sharing the one fixture workspace serve each other's stale — by then
//! deleted — filesystem root.
//!
//! Advanced S3 permissions are an enterprise feature, so this test requires the
//! A second test pins the `expiry_secs` bounds: the signature's `exp` follows the caller's
//! request, defaults to 12h, and is clamped to [60s, 7d]. It only mints signatures and never
//! fetches through the proxy, so it never populates or reads that cache.
//!
//! Advanced S3 permissions are an enterprise feature, so these tests require the
//! `enterprise` + `private` + `parquet` features.
#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))]
@@ -175,3 +179,88 @@ async fn test_sign_s3_objects_enforces_read_authz(db: Pool<Postgres>) -> anyhow:
Ok(())
}
/// `exp` is signed into the HMAC message, so the only way a caller can influence
/// it is through `expiry_secs` — pin the default and both clamp bounds.
#[sqlx::test(fixtures("base"))]
async fn test_sign_s3_objects_expiry_secs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let storage_dir = tempfile::tempdir()?;
configure_lfs(&db, &storage_dir.path().to_string_lossy()).await?;
async fn signed_exp(base: &str, body: serde_json::Value) -> anyhow::Result<i64> {
let resp = authed(
client().post(format!("{base}/apps/sign_s3_objects")),
"SECRET_TOKEN",
)
.json(&body)
.send()
.await?;
let status = resp.status();
let signed: serde_json::Value = resp.json().await?;
assert!(status.is_success(), "sign must succeed: {status} {signed}");
let presigned = signed[0]["presigned"]
.as_str()
.expect("sign must return a presigned string");
let exp = presigned
.split('&')
.find_map(|kv| kv.strip_prefix("exp="))
.expect("presigned string must carry exp");
Ok(exp.parse::<i64>()?)
}
let key = json!([{ "s3": "allowed/file.txt" }]);
// The handler stamps `now` itself, so assert on a window rather than an exact value.
// Keep the window well under the 60s lower bound, or an unclamped 1s would pass.
let ttl_around = |exp: i64| exp - chrono::Utc::now().timestamp();
let tolerance = 30;
let default_ttl = ttl_around(signed_exp(&base, json!({ "s3_objects": key.clone() })).await?);
assert!(
(43200 - tolerance..=43200).contains(&default_ttl),
"omitting expiry_secs must keep the 12h default, got {default_ttl}s"
);
let honored = ttl_around(
signed_exp(
&base,
json!({ "s3_objects": key.clone(), "expiry_secs": 300 }),
)
.await?,
);
assert!(
(300 - tolerance..=300).contains(&honored),
"expiry_secs must be honored verbatim inside the bounds, got {honored}s"
);
let clamped_low = ttl_around(
signed_exp(
&base,
json!({ "s3_objects": key.clone(), "expiry_secs": 1 }),
)
.await?,
);
assert!(
(60 - tolerance..=60).contains(&clamped_low),
"expiry_secs below 60s must clamp up to 60s, got {clamped_low}s"
);
let clamped_high = ttl_around(
signed_exp(
&base,
json!({ "s3_objects": key.clone(), "expiry_secs": 99_999_999 }),
)
.await?,
);
assert!(
(604800 - tolerance..=604800).contains(&clamped_high),
"expiry_secs above 7d must clamp down to 7d, got {clamped_high}s"
);
Ok(())
}
+77
View File
@@ -0,0 +1,77 @@
//! Pins the plan of the suspended-job pull. Its resume test degrades silently: once the
//! query expression and `queue_suspended_v2` stop matching, Postgres still returns the right
//! job, just by falling back to a heap filter and fetching one tuple per suspended row on
//! every worker poll. No functional test can see that, so assert on the plan instead.
use serde_json::Value;
use sqlx::{Pool, Postgres};
use windmill_common::worker::make_suspended_pull_query;
/// Depth-first walk of an `EXPLAIN (FORMAT JSON)` plan tree.
fn nodes(plan: &Value, out: &mut Vec<Value>) {
out.push(plan.clone());
for child in plan["Plans"].as_array().unwrap_or(&vec![]) {
nodes(child, out);
}
}
#[sqlx::test(fixtures("base"))]
async fn suspended_pull_tests_resume_time_inside_the_index(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, created_at, scheduled_for, running, suspend, suspend_until, tag)
SELECT gen_random_uuid(), 'test-workspace', now() - make_interval(secs => i),
now(), true, 1 + (i % 3), now() + interval '7 day', 'flow'
FROM generate_series(1, 2000) i",
)
.execute(&db)
.await?;
sqlx::query("ANALYZE v2_job_queue").execute(&db).await?;
// Both plans are cheap on a 2000-row table, and which one wins there says nothing
// about a queue with a large suspended backlog. Force the index path, which is the
// one production takes, and assert on how it evaluates the resume test.
let mut conn = db.acquire().await?;
sqlx::query("SET enable_seqscan = off")
.execute(&mut *conn)
.await?;
let version: String = sqlx::query_scalar("SELECT version()")
.fetch_one(&mut *conn)
.await?;
// FORMAT JSON rather than the default: `Index Cond` and `Filter` are separate keys on the
// node, so this does not ride on EXPLAIN's line layout staying put across a major bump.
let explained: Value = sqlx::query_scalar(&format!(
"EXPLAIN (FORMAT JSON) {}",
make_suspended_pull_query(&["flow".to_string()])
))
.bind("test-worker")
.fetch_one(&mut *conn)
.await?;
let mut all = vec![];
nodes(&explained[0]["Plan"], &mut all);
let pretty = serde_json::to_string_pretty(&explained)?;
let scan = all
.iter()
.find(|n| n["Index Name"] == "queue_suspended_v2")
.unwrap_or_else(|| {
panic!("suspended pull did not scan queue_suspended_v2 on {version}:\n{pretty}")
});
// Only `Index Cond` is checked against the index tuple, so that is where the resume test
// has to land — as a `Filter` it would cost a heap fetch per suspended row. The residual
// `suspend_until IS NOT NULL` filter is not that: it is always true for rows the partial
// index holds, and only ever runs on the row LIMIT 1 already fetched.
let cond = scan["Index Cond"].as_str().unwrap_or_else(|| {
panic!("no Index Cond on the suspended pull scan on {version}:\n{pretty}")
});
assert!(
cond.contains("CASE WHEN"),
"resume test is not an index condition on {version}:\n{pretty}"
);
assert!(
!scan["Filter"].as_str().unwrap_or("").contains("CASE WHEN"),
"resume test fell back to a heap filter on {version}:\n{pretty}"
);
Ok(())
}
+27
View File
@@ -318,6 +318,33 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyho
resp.text().await?
);
}
// ...and the one `settings/global` key on the allowlist, which the CLI reads before
// creating a user on a git-sync push. `ws_base_url` is the control: the handler leaves
// it as ungated as `automate_username_creation`, so only the allowlist stops it.
let resp = authed(
client().get(format!("{api}/settings/global/automate_username_creation")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read automate_username_creation: {}",
resp.text().await?
);
let resp = authed(
client().get(format!("{api}/settings/global/ws_base_url")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"WM_TOKEN must not read any other global setting: {}",
resp.text().await?
);
let resp = authed(client().post(format!("{api}/schedules/preview")), &user_wm)
.json(&json!({ "schedule": "0 0 12 * * *", "timezone": "UTC" }))
.send()
+75 -1
View File
@@ -1,6 +1,6 @@
use sqlx::postgres::Postgres;
use sqlx::Pool;
use windmill_test_utils::{initialize_tracing, ApiServer};
use windmill_test_utils::{initialize_tracing, set_jwt_secret, ApiServer};
/// Integration test: exercises every explicit-column query in `tarball_workspace`.
///
@@ -287,3 +287,77 @@ async fn test_tarball_export_gates_values_on_item_scopes(db: Pool<Postgres>) ->
Ok(())
}
/// `settings.json` carries the admin-managed integration config that `get_settings`
/// is admin-only for (the webhook URL, ai_config, git_sync, handler extra_args), so
/// `include_settings` takes the same admin check as `get_settings` rather than
/// riding on the route's `workspaces:read`. Git sync exports settings through the
/// same route, so the gate must still admit its system identity.
#[sqlx::test(fixtures("base"))]
async fn test_tarball_export_settings_are_admin_only(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}", server.addr.port());
sqlx::query(
r#"UPDATE workspace_settings
SET webhook = 'https://hook.example/?token=WEBHOOK_SECRET',
ai_config = '{"providers":{"openai":{"api_key":"AI_CONFIG_SECRET"}}}'::jsonb
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
let export = async |token: &str| -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.get(format!(
"{base_url}/api/w/test-workspace/workspaces/tarball?include_settings=true&settings_version=v2"
))
.bearer_auth(token)
.send()
.await?;
let status = resp.status().as_u16();
// Lossy: a successful export is a tar, not UTF-8. Only the values matter here.
Ok((
status,
String::from_utf8_lossy(&resp.bytes().await?).into_owned(),
))
};
// SECRET_TOKEN_2 belongs to test-user-2, a non-admin member of test-workspace.
let (status, body) = export("SECRET_TOKEN_2").await?;
assert_eq!(status, 403, "non-admin exported settings: {body}");
let (status, body) = export("SECRET_TOKEN").await?;
assert_eq!(status, 200, "admin denied settings: {body}");
assert!(
body.contains("WEBHOOK_SECRET") && body.contains("AI_CONFIG_SECRET"),
"admin got no settings"
);
// Git sync pushes the workspace to the repo by exporting it under
// `superadmin_sync@windmill.dev`, which belongs to no workspace: the job token
// it runs with is the export's only admin claim.
let sync_email = windmill_common::users::SUPERADMIN_SYNC_EMAIL;
let sync_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
sync_email,
"git-sync",
300,
sync_email,
&uuid::Uuid::new_v4(),
None,
None,
)
.await?;
let (status, body) = export(&sync_token).await?;
assert_eq!(status, 200, "git-sync identity denied settings: {body}");
assert!(
body.contains("WEBHOOK_SECRET"),
"git-sync identity got no settings"
);
Ok(())
}
+10
View File
@@ -24,6 +24,16 @@ lazy_static::lazy_static! {
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
/// Drops the cache breakpoints from agent-step requests on every Anthropic platform,
/// not just the one that motivates it: a Google Cloud project can have explicit prompt
/// caching turned off (by request to Cloud support), and Vertex then rejects any request
/// carrying breakpoints. An instance that sets this to unblock such a project also gives
/// up caching on its direct-Anthropic and Foundry resources.
pub static ref DISABLE_ANTHROPIC_PROMPT_CACHING: bool =
std::env::var("DISABLE_ANTHROPIC_PROMPT_CACHING")
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
}
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
+47 -11
View File
@@ -1,7 +1,7 @@
use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
use crate::{
ai_google::parse_data_url,
ai_providers::{AIPlatform, AIProvider},
ai_providers::{AIPlatform, AIProvider, DISABLE_ANTHROPIC_PROMPT_CACHING},
image_handler::prepare_messages_for_api,
proxy::{
add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest,
@@ -632,15 +632,13 @@ impl AnthropicQueryBuilder {
}
}
let caching = !*DISABLE_ANTHROPIC_PROMPT_CACHING;
let system = collect_system_prompt(&prepared_messages, args.system_prompt).map(|text| {
vec![AnthropicSystemContent {
r#type: "text".to_string(),
text,
cache_control: if self.is_vertex() {
None
} else {
Some(CacheControl::ephemeral())
},
cache_control: caching.then(CacheControl::ephemeral),
}]
});
@@ -665,7 +663,7 @@ impl AnthropicQueryBuilder {
let max_tokens = Some(args.max_tokens.unwrap_or(64000));
// Apply cache_control on the last custom tool
if !self.is_vertex() {
if caching {
if let Some(ref mut tools_vec) = tools_option {
if let Some(AnthropicTool::Custom(ref mut custom)) = tools_vec.last_mut() {
custom.cache_control = Some(CacheControl::ephemeral());
@@ -674,7 +672,7 @@ impl AnthropicQueryBuilder {
}
// Apply cache_control on the last content block of the last message
if !self.is_vertex() {
if caching {
if let Some(last_msg) = anthropic_messages.last_mut() {
if let Some(last_block) = last_msg.content.last_mut() {
match last_block {
@@ -882,10 +880,15 @@ mod tests {
}
}
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
async fn build_text_body_on(
platform: AIPlatform,
messages: &[OpenAIMessage],
system_prompt: Option<&str>,
tools: Option<&[ToolDef]>,
) -> String {
let args = BuildRequestArgs {
messages,
tools: None,
tools,
model: "claude-sonnet-4",
temperature: None,
reasoning_effort: None,
@@ -899,12 +902,16 @@ mod tests {
prompt_cache_key: None,
};
AnthropicQueryBuilder::new(AIProvider::Anthropic, AIPlatform::Standard)
AnthropicQueryBuilder::new(AIProvider::Anthropic, platform)
.build_request(&args, &authed_client(), "test-workspace")
.await
.unwrap()
}
async fn build_text_body(messages: &[OpenAIMessage], system_prompt: Option<&str>) -> String {
build_text_body_on(AIPlatform::Standard, messages, system_prompt, None).await
}
/// The worker prepends the system prompt as a system message *and* passes it as
/// `system_prompt`; the request must still carry it exactly once.
#[tokio::test]
@@ -946,6 +953,35 @@ mod tests {
assert!(request.get("system").is_none());
}
/// Vertex serves the same Messages API and honours `cache_control` breakpoints, so
/// its requests must carry the same three the standard platform gets.
#[tokio::test]
async fn sets_cache_breakpoints_on_every_platform() {
let messages = vec![message("system", SYSTEM_PROMPT), message("user", "hi")];
let tools = vec![ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: "get_weather".to_string(),
description: None,
parameters: RawValue::from_string("{}".to_string()).unwrap(),
},
}];
let ephemeral = serde_json::json!({ "type": "ephemeral" });
for platform in [AIPlatform::Standard, AIPlatform::GoogleVertexAi] {
let body =
build_text_body_on(platform, &messages, Some(SYSTEM_PROMPT), Some(&tools)).await;
let request: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(request["system"][0]["cache_control"], ephemeral);
let sent_tools = request["tools"].as_array().unwrap();
assert_eq!(sent_tools.last().unwrap()["cache_control"], ephemeral);
let sent = request["messages"].as_array().unwrap();
let content = sent.last().unwrap()["content"].as_array().unwrap();
assert_eq!(content.last().unwrap()["cache_control"], ephemeral);
}
}
fn has_header(headers: &[(String, String)], name: &str, value: &str) -> bool {
headers
.iter()
+8
View File
@@ -996,6 +996,13 @@ fn scope_grants_access(
/// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the
/// token itself and touch no table.
///
/// `settings/global/automate_username_creation` is the one instance setting on the list.
/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one
/// among them, so the boolean is already readable by every authenticated user; it is here
/// because the CLI reads it before creating a user during a git-sync push, which runs as a
/// job. The other ungated keys have no such caller, so they stay confined — being ungated
/// earns a key nothing on its own.
///
/// Deliberately absent, as each crosses that line: `users/list_invites` (returns the
/// workspace ids the identity was invited to), `users/tokens/list` (credential metadata
/// of the borrowed identity), `users/exists/{email}` (an oracle over arbitrary
@@ -1011,6 +1018,7 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool {
| "/api/users/usage"
| "/api/users/tutorial_progress"
| "/api/workspaces/allowed_domain_auto_invite"
| "/api/settings/global/automate_username_creation"
| "/api/docs/search"
| "/api/docs/page"
| "/api/integrations/hub/list"
+5
View File
@@ -468,6 +468,9 @@ async fn sign_debug_request(
// Parse the language
let script_lang: ScriptLang = request.language.parse().unwrap_or(ScriptLang::Bun);
// Taken from the parsed language, not the request's string: the telemetry key vocabulary has
// to stay the closed set of languages rather than whatever a caller sent.
let lang_key = script_lang.as_str();
// Hash the code (we don't include full code in JWT to keep it small)
let mut hasher = Sha256::new();
@@ -578,6 +581,8 @@ async fn sign_debug_request(
tx.commit().await?;
windmill_common::feature_usage::log_feature_usage("debugger", "session", lang_key);
Ok(Json(SignedDebugPayload {
token,
code: request.code,
@@ -286,13 +286,24 @@ async fn test_jobs_authed_reachability(db: Pool<Postgres>) -> anyhow::Result<()>
"GET /jobs/result_by_id",
);
// Sent the way the generated client sends it. A handler whose `Path` tuple has drifted from
// the route is rejected by axum before it runs, which surfaces as a routing error rather
// than the handler's own answer, so reaching the handler is what this pins.
let resp = authed(client().post(format!("{base}/restart/f/{fake}")))
.json(&json!({ "step_id": "a" }))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/restart/f",
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_route_reachable(status, &body, "POST /jobs/restart/f");
assert!(
!body.contains("path arguments"),
"POST /jobs/restart/f never reached its handler: {status} {body}",
);
#[cfg(not(feature = "enterprise"))]
assert!(
body.contains("only available in enterprise version"),
"POST /jobs/restart/f must report the enterprise gate outside EE: {status} {body}",
);
let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main")))
@@ -10,7 +10,10 @@
//! management, and the workspace-merge diff helper. Split out of `workspaces.rs`
//! to keep that file focused on core workspace configuration.
use crate::workspaces::{pg_dump_database, ItemComparison};
use crate::workspaces::{
is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
PgDumpOptions,
};
use axum::{
extract::{Extension, Path, Query},
@@ -1408,18 +1411,25 @@ async fn generate_initial_datatable_migration(
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
// Snapshot the schema, excluding Windmill's own migration bookkeeping table.
let dump_file = pg_dump_database(&pg_db, true, &["_wm_migrations"]).await?;
// Snapshot the schema without `_wm_migrations`, Windmill's own bookkeeping table, and
// without what a replay elsewhere cannot run: the replaying user owns none of this
// database's objects, and the grants Windmill plants in an instance database (`ALTER
// DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server.
let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?;
let dump_file = pg_dump_database(
&pg_db,
PgDumpOptions {
schema_only: true,
exclude_tables: &["_wm_migrations"],
no_owner: true,
no_acl,
},
)
.await?;
let raw_dump = tokio::fs::read_to_string(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to read schema dump: {}", e)))?;
// pg_dump emits psql meta-commands (\restrict / \unrestrict) that aren't
// valid SQL; drop them so the migration body can run via a plain query.
let code_up: String = raw_dump
.lines()
.filter(|line| !line.trim_start().starts_with('\\'))
.collect::<Vec<_>>()
.join("\n");
let code_up = strip_unreplayable_dump_lines(&raw_dump);
// Record the definition first, then mark it installed. If marking fails we
// delete the definition, so a failure leaves no phantom "initial" (rather
+425 -45
View File
@@ -117,6 +117,7 @@ pub fn workspaced_service() -> Router {
get(get_secondary_storage_names),
)
.route("/is_premium", get(is_premium))
.route("/billable_seats", get(get_billable_seats))
.route("/edit_error_handler", post(edit_error_handler))
.route("/edit_success_handler", post(edit_success_handler))
.route(
@@ -686,6 +687,48 @@ async fn is_premium(
Ok(Json(premium))
}
#[derive(Serialize)]
struct BillableSeatsResponse {
/// Both omitted when the seats counted are another workspace's: a fork member need not be a
/// member of the billing root, so the root's headcount is not theirs to read. The total is,
/// since it is the divisor of the quota their own executions draw on.
#[serde(skip_serializing_if = "Option::is_none")]
developers: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
operators: Option<i64>,
seats: i64,
}
async fn get_billable_seats(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<BillableSeatsResponse> {
// Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter
// divides by, and that meter is shown to non-admin developers too.
//
// On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is
// measured against are the root's. Resolved here rather than by the caller: a fork member need
// not be a member of that root, and so cannot count its seats from the member list. Off cloud
// a fork is not billed through a root at all, so the workspace answers for itself.
#[cfg(feature = "cloud")]
let billing_w_id = if *CLOUD_HOSTED {
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
} else {
w_id.clone()
};
#[cfg(not(feature = "cloud"))]
let billing_w_id = w_id.clone();
let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?;
let own = billing_w_id == w_id;
Ok(Json(BillableSeatsResponse {
developers: own.then_some(counted.developers),
operators: own.then_some(counted.operators),
seats: counted.seats,
}))
}
async fn exists_workspace(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -1970,6 +2013,23 @@ async fn edit_large_file_storage_config(
)));
}
if !windmill_common::workspaces::filesystem_storage_allowed() {
let named = std::iter::once(("primary storage", &lfs_config.large_file_storage)).chain(
lfs_config
.secondary_storage
.iter()
.map(|(name, storage)| (name.as_str(), storage)),
);
for (name, storage) in named {
if matches!(storage, LargeFileStorage::FilesystemStorage(_)) {
return Err(Error::BadRequest(format!(
"{name}: {}",
windmill_common::workspaces::FILESYSTEM_STORAGE_DEV_ONLY_MSG
)));
}
}
}
let serialized_lfs_config =
serde_json::to_value::<LargeFileStorageWithSecondary>(lfs_config)
.map_err(|err| Error::internal_err(err.to_string()))?;
@@ -2594,6 +2654,66 @@ fn truncate_column_default(default: String) -> String {
mod tests {
use super::*;
/// The header of a pg_dump, followed by an object whose body also holds a `SET`.
const DUMP: &str = "--\n\
-- PostgreSQL database dump\n\
--\n\
\n\
\\restrict aBcD\n\
\n\
SET statement_timeout = 0;\n\
SET transaction_timeout = 0;\n\
SET client_encoding = 'UTF8';\n\
SELECT pg_catalog.set_config('search_path', '', false);\n\
\n\
SET default_table_access_method = heap;\n\
\n\
CREATE FUNCTION public.f() RETURNS void LANGUAGE plpgsql AS $$\n\
BEGIN\n\
SET transaction_timeout = 0;\n\
END;\n\
$$;\n";
#[test]
fn replayable_dump_keeps_everything_but_meta_commands_and_session_timeouts() {
let replayable = strip_unreplayable_dump_lines(DUMP);
assert!(!replayable.contains("\\restrict"));
assert!(!replayable.contains("SET statement_timeout"));
assert!(!replayable.contains("SET transaction_timeout = 0;\nSET client_encoding"));
assert!(replayable.contains("SET client_encoding = 'UTF8';"));
assert!(replayable.contains("SET default_table_access_method = heap;"));
// Past the preamble the dump is an object's own text: left exactly as it is.
assert!(replayable.contains("BEGIN\nSET transaction_timeout = 0;\nEND;"));
}
#[tokio::test]
async fn dump_preamble_only_drops_settings_the_server_lacks() {
let dump_file = DumpFile::new().unwrap();
tokio::fs::write(&dump_file.path, DUMP).await.unwrap();
let supported = [
"statement_timeout",
"client_encoding",
"default_table_access_method",
]
.map(String::from)
.into_iter()
.collect();
comment_out_unsupported_settings(&dump_file, &supported)
.await
.unwrap();
let patched = tokio::fs::read_to_string(&dump_file.path).await.unwrap();
// Rewriting the header must not shift the rest of the dump.
assert_eq!(patched.len(), DUMP.len());
assert!(patched.contains("-- transaction_timeout = 0;"));
assert!(patched.contains("SET statement_timeout = 0;"));
assert!(patched.contains("SET default_table_access_method = heap;"));
// The `SET` inside the function body is past the preamble: never touched.
assert!(patched.contains("BEGIN\nSET transaction_timeout = 0;\nEND;"));
}
#[test]
fn compact_column_type_truncates_multibyte_defaults_safely() {
let default = "é".repeat(31);
@@ -2696,6 +2816,35 @@ pub(crate) async fn resolve_pg_source_checked(
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
}
/// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL
/// rather than a user resource.
pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result<bool> {
let config = sqlx::query_scalar!(
"SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
w_id,
name
)
.fetch_optional(db)
.await?
.flatten();
Ok(config
.and_then(|v| {
v.get("database")
.and_then(|d| d.get("resource_type"))
.and_then(|r| r.as_str())
.map(|s| s == "instance")
})
.unwrap_or(false))
}
/// Same, for the `datatable://<name>` / `$res:<path>` form the import endpoints take.
async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result<bool> {
match source.strip_prefix("datatable://") {
Some(name) => is_instance_datatable(db, w_id, name).await,
None => Ok(false),
}
}
/// A temporary file for pg_dump output that is automatically deleted when dropped.
pub(crate) struct DumpFile {
pub(crate) path: std::path::PathBuf,
@@ -2743,12 +2892,21 @@ impl Drop for DumpFile {
}
}
#[derive(Default)]
pub(crate) struct PgDumpOptions<'a> {
pub(crate) schema_only: bool,
pub(crate) exclude_tables: &'a [&'a str],
/// Leave out `ALTER ... OWNER TO`.
pub(crate) no_owner: bool,
/// Leave out `GRANT`, `REVOKE` and `ALTER DEFAULT PRIVILEGES`.
pub(crate) no_acl: bool,
}
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
pub(crate) async fn pg_dump_database(
pg_db: &PgDatabase,
schema_only: bool,
exclude_tables: &[&str],
opts: PgDumpOptions<'_>,
) -> Result<DumpFile> {
let dump_file = DumpFile::new()?;
@@ -2759,10 +2917,16 @@ pub(crate) async fn pg_dump_database(
let mut cmd = tokio::process::Command::new("pg_dump");
cmd.arg("--format=plain").arg("--file").arg(&dump_file.path);
if schema_only {
if opts.schema_only {
cmd.arg("--schema-only");
}
for table in exclude_tables {
if opts.no_owner {
cmd.arg("--no-owner");
}
if opts.no_acl {
cmd.arg("--no-privileges");
}
for table in opts.exclude_tables {
cmd.arg(format!("--exclude-table={table}"));
}
cmd.arg("--host")
@@ -2794,37 +2958,179 @@ pub(crate) async fn pg_dump_database(
Ok(dump_file)
}
/// Import a pg_dump file into a target database using psql.
async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
let host = &target_db.host;
let port = target_db.port.unwrap_or(5432).to_string();
let user = target_db.login_name();
let dbname = &target_db.dbname;
/// Whether `line` still belongs to the preamble pg_dump emits before the first
/// dumped object: comments, blank lines, psql meta-commands and the session `SET`s.
fn is_dump_preamble_line(line: &[u8]) -> bool {
let line = line.trim_ascii_start();
line.is_empty()
|| line.starts_with(b"--")
|| line.starts_with(b"\\")
|| line.starts_with(b"SET ")
|| line.starts_with(b"SELECT pg_catalog.set_config(")
}
/// The GUCs pg_dump's preamble sets only to keep the dumping session out of the way.
/// They are also the ones that come and go across versions (`transaction_timeout` is
/// PG 17+), so they are what a dump replayed on an older server trips over first.
const DUMP_SESSION_TIMEOUTS: [&str; 4] = [
"statement_timeout",
"lock_timeout",
"idle_in_transaction_session_timeout",
"transaction_timeout",
];
/// Turn a dump into SQL that can be replayed on another database: drop pg_dump's psql
/// meta-commands (`\restrict` / `\unrestrict`, not valid SQL) and the session timeouts
/// its preamble sets, which the replaying server may not have as GUCs at all. Only the
/// preamble is filtered, so an object's body keeps whatever it holds.
pub(crate) fn strip_unreplayable_dump_lines(dump: &str) -> String {
let mut in_preamble = true;
dump.lines()
.filter(|line| {
in_preamble = in_preamble && is_dump_preamble_line(line.as_bytes());
if line.trim_start().starts_with('\\') {
return false;
}
!(in_preamble
&& preamble_setting_name(line.as_bytes())
.is_some_and(|name| DUMP_SESSION_TIMEOUTS.contains(&name)))
})
.collect::<Vec<_>>()
.join("\n")
}
/// The GUC a preamble `SET <name> = ...;` line assigns, if the line is one.
fn preamble_setting_name(line: &[u8]) -> Option<&str> {
let name = line.strip_prefix(b"SET ")?.split(|c| *c == b' ').next()?;
std::str::from_utf8(name).ok()
}
/// The preamble Windmill's postgres client writes can set GUCs an older server does not
/// have — harmless session tuning, but one failing statement aborts a restore that stops
/// on the first error. Comment those out in place, three bytes each, so the data
/// section's offsets stay put.
async fn comment_out_unsupported_settings(
dump_file: &DumpFile,
supported_settings: &HashSet<String>,
) -> Result<()> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let file = tokio::fs::File::open(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?;
let mut reader = tokio::io::BufReader::new(file);
let mut preamble: Vec<u8> = Vec::new();
let mut patched = false;
loop {
let start = preamble.len();
let read = reader
.read_until(b'\n', &mut preamble)
.await
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))?;
if read == 0 {
break;
}
let line = &preamble[start..];
if !is_dump_preamble_line(line) {
preamble.truncate(start);
break;
}
if preamble_setting_name(line).is_some_and(|name| !supported_settings.contains(name)) {
preamble[start..start + 3].copy_from_slice(b"-- ");
patched = true;
}
}
if !patched {
return Ok(());
}
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.open(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?;
file.write_all(&preamble)
.await
.map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?;
file.flush()
.await
.map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?;
Ok(())
}
/// A psql invocation against `pg_db`, carrying the connection settings the CLI reads
/// from the environment.
fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("psql");
cmd.arg("--host")
.arg(host)
.arg(&pg_db.host)
.arg("--port")
.arg(&port)
.arg(pg_db.port.unwrap_or(5432).to_string())
.arg("--username")
.arg(user)
.arg(pg_db.login_name())
.arg("--dbname")
.arg(dbname)
.arg(&pg_db.dbname)
.arg("--no-psqlrc")
.arg("--file")
.arg(&dump_file.path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(ref password) = target_db.password {
if let Some(ref password) = pg_db.password {
cmd.env("PGPASSWORD", password);
}
if let Some(ref sslmode) = target_db.sslmode {
if let Some(ref sslmode) = pg_db.sslmode {
cmd.env("PGSSLMODE", sslmode);
}
cmd
}
let output = cmd
/// GUC names the server backing `pg_db` knows about.
///
/// Asked through psql rather than a tokio-postgres connection so the lookup reaches
/// exactly the servers the restore itself can: libpq negotiates TLS for `sslmode=prefer`
/// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a
/// plaintext socket and fail before the import ever starts.
async fn server_setting_names(pg_db: &PgDatabase) -> Result<HashSet<String>> {
let output = psql_command(pg_db)
.arg("--tuples-only")
.arg("--no-align")
.arg("--command")
.arg("SELECT name FROM pg_settings")
.output()
.await
.map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::internal_err(format!(
"Failed to list the settings of the target server: {}",
stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect())
}
/// Import a pg_dump file into a target database using psql.
///
/// Left to its defaults psql reports a failed statement, carries on and still exits 0,
/// so a dump that breaks partway through imports partially and reads as a success.
/// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore
/// all-or-nothing, leaving the target as it was and the import retryable.
async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
let supported_settings = server_setting_names(target_db).await?;
comment_out_unsupported_settings(dump_file, &supported_settings).await?;
let output = psql_command(target_db)
.arg("--set")
.arg("ON_ERROR_STOP=1")
.arg("--single-transaction")
.arg("--file")
.arg(&dump_file.path)
.output()
.await
.map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?;
@@ -2869,29 +3175,7 @@ async fn create_pg_database(
}
}
// Determine if this is an instance or resource-backed datatable
let is_instance_datatable = if let Some(dt_name) = req.source.strip_prefix("datatable://") {
let config = sqlx::query_scalar!(
"SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
&w_id,
dt_name
)
.fetch_optional(&db)
.await?
.flatten();
config
.and_then(|v| {
v.get("database")
.and_then(|d| d.get("resource_type"))
.and_then(|r| r.as_str())
.map(|s| s == "instance")
})
.unwrap_or(false)
} else {
false
};
if is_instance_datatable {
if is_instance_datatable_source(&db, &w_id, &req.source).await? {
windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable")
.await?;
} else {
@@ -2989,7 +3273,18 @@ async fn import_pg_database(
}
windmill_common::validate_dbname(&target_pg.dbname)?;
let dump_file = pg_dump_database(&source_pg, schema_only, &[]).await?;
// Ownership never replays: the restore runs as the target's own connection user, and
// what it creates it owns. Grants do, except around an instance data table — Windmill
// plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere
// the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access.
let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await?
|| is_instance_datatable_source(&db, &w_id, &req.source).await?;
let dump_file = pg_dump_database(
&source_pg,
PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() },
)
.await?;
pg_import_dump(&target_pg, &dump_file).await?;
Ok(format!(
@@ -3011,7 +3306,11 @@ async fn export_pg_schema(
Json(req): Json<ExportPgSchemaRequest>,
) -> Result<String> {
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
let dump_file = pg_dump_database(&pg, true, &[]).await?;
let dump_file = pg_dump_database(
&pg,
PgDumpOptions { schema_only: true, ..Default::default() },
)
.await?;
tokio::fs::read_to_string(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))
@@ -7118,6 +7417,76 @@ async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()
enforce_cloud_fork_count(db, &root, 1).await
}
/// Cloud: refuse to attach a workspace that already has a paid plan of its own.
///
/// Once attached it draws the root's plan and meters its usage there, so a subscription of its own
/// bills a second time for one plan. Only an attach can reach this state: a fork is created as a
/// fresh workspace and never had a plan to keep.
///
/// Asked only of a candidate joining this family, never of one already under the same root: that
/// one is already in the double-billed state, where the settings page surfaces the leftover
/// subscription and the portal that cancels it, and refusing there would block re-designating a
/// renamed dev workspace over a billing problem the attach did not cause.
#[cfg(feature = "cloud")]
async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> {
let plan = sqlx::query_scalar!(
"SELECT plan FROM workspace_settings WHERE workspace_id = $1",
dev_w_id
)
.fetch_optional(db)
.await?
.flatten();
// Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan
// value it does not write yet would otherwise walk straight past this. An enterprise
// arrangement is deliberately not covered — it sets `premium` without a plan and has no
// self-serve portal, so refusing there would be a dead end rather than something to act on.
if plan.is_some() {
return Err(Error::BadRequest(format!(
"Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it."
)));
}
Ok(())
}
#[cfg(all(test, feature = "cloud"))]
mod attach_billing_guard_tests {
use super::reject_attach_of_subscribed_workspace;
use sqlx::{Pool, Postgres};
async fn workspace_on_plan(db: &Pool<Postgres>, id: &str, plan: Option<&str>) {
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')")
.bind(id)
.execute(db)
.await
.expect("insert workspace");
sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)")
.bind(id)
.bind(plan)
.execute(db)
.await
.expect("insert workspace_settings");
}
#[sqlx::test(migrations = "../migrations")]
async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool<Postgres>) {
workspace_on_plan(&db, "subscribed", Some("team")).await;
workspace_on_plan(&db, "cancelled", None).await;
let err = reject_attach_of_subscribed_workspace(&db, "subscribed")
.await
.expect_err("a workspace on a paid plan of its own must not be attachable");
assert!(err.to_string().contains("paid plan of its own"), "{err}");
// Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides.
reject_attach_of_subscribed_workspace(&db, "cancelled")
.await
.expect("a workspace with no plan is attachable");
reject_attach_of_subscribed_workspace(&db, "no-settings-row")
.await
.expect("a workspace with no settings row is attachable");
}
}
/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper
/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new
/// node — 0 for a plain fork, or the candidate's own subtree height for an attach.
@@ -7660,6 +8029,17 @@ async fn attach_dev_workspace(
)));
}
// Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the
// refusal names the candidate's plan, so running it earlier would tell any admin of any
// premium workspace whether an arbitrary workspace id is on a team plan.
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?;
if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root {
reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?;
}
}
let mut tx = db.begin().await?;
// Everything above ran outside a transaction, so prod's eligibility and the chain's labels could
// have changed under us: re-decide both here, under the pairing lock.
+175 -26
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.796.0
version: 1.799.0
title: Windmill API
contact:
@@ -3788,6 +3788,38 @@ paths:
schema:
type: boolean
/w/{workspace}/workspaces/billable_seats:
get:
summary: get the billable seats of the workspace the plan is billed on
operationId: getBillableSeats
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: billable seats
content:
application/json:
schema:
type: object
properties:
developers:
type: integer
description: >-
Omitted when the seats counted are another workspace's, as they are for a
fork resolving to its billing root.
operators:
type: integer
description: >-
Omitted when the seats counted are another workspace's, as they are for a
fork resolving to its billing root.
seats:
type: integer
required:
- seats
/w/{workspace}/workspaces/premium_info:
get:
summary: get premium info
@@ -13636,6 +13668,10 @@ paths:
type: array
items:
$ref: "#/components/schemas/S3Object"
expiry_secs:
type: integer
format: int64
description: how long the signature stays valid, in seconds. Defaults to 43200 (12h) and is clamped server-side to [60, 604800] (1 minute to 7 days).
required:
- s3_objects
responses:
@@ -25451,6 +25487,68 @@ paths:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/withdraw:
post:
summary: take a hub project submission back out of review
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body. Everything pushed for the
submission is kept, so it can be fixed and submitted again.
operationId: withdrawHubProject
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/discard_update:
post:
summary: discard the pending update to a published hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body. The published project is
left untouched.
operationId: discardHubProjectUpdate
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/project:
get:
summary: get the hub project linked to a workspace folder
@@ -28092,7 +28190,10 @@ components:
type: string
args:
$ref: "#/components/schemas/ScriptArgs"
result: {}
result:
description: |
For large results, this may be the placeholder string 'WINDMILL_TOO_BIG'.
Use the completed job result endpoint to retrieve the full result.
logs:
type: string
deleted:
@@ -30366,7 +30467,9 @@ components:
description: If true, passes the request body as a raw string instead of parsing as JSON
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -30455,7 +30558,9 @@ components:
description: If true, passes the request body as a raw string instead of parsing as JSON
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -30551,7 +30656,9 @@ components:
description: If true, passes the request body as a raw string instead of parsing as JSON
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -30743,7 +30850,9 @@ components:
description: Optional periodic heartbeat message configuration
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
description: Arguments to pass to the error handler
$ref: "#/components/schemas/ScriptArgs"
@@ -30808,7 +30917,9 @@ components:
description: Optional periodic heartbeat message configuration
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
description: Arguments to pass to the error handler
$ref: "#/components/schemas/ScriptArgs"
@@ -30884,7 +30995,9 @@ components:
description: Optional periodic heartbeat message configuration
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
description: Arguments to pass to the error handler
$ref: "#/components/schemas/ScriptArgs"
@@ -31012,7 +31125,9 @@ components:
description: Last error message if the trigger failed
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31063,7 +31178,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31127,7 +31244,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31210,7 +31329,9 @@ components:
description: Last error message if the trigger failed
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31251,7 +31372,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31305,7 +31428,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31703,7 +31828,9 @@ components:
description: Last error message if the trigger failed
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31805,7 +31932,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31861,7 +31990,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -31989,7 +32120,9 @@ components:
description: Timestamp of last server heartbeat (internal)
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32029,7 +32162,9 @@ components:
description: Configuration for creating/managing the publication (tables, operations)
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32081,7 +32216,9 @@ components:
description: Configuration for creating/managing the publication (tables, operations)
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32158,7 +32295,9 @@ components:
description: Last error message if the trigger failed
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32222,7 +32361,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32297,7 +32438,9 @@ components:
description: True if script_path points to a flow, false if it points to a script
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32360,7 +32503,9 @@ components:
description: Last error message if the trigger failed
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32408,7 +32553,9 @@ components:
$ref: "#/components/schemas/TriggerMode"
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -32467,7 +32614,9 @@ components:
description: True if script_path points to a flow, false if it points to a script
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare
path, without the script/ or flow/ prefix a schedule error handler takes;
it cannot be a flow.
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
description: Arguments to pass to the error handler
@@ -190,6 +190,8 @@ pub async fn create_dataset(
}
tx.commit().await?;
windmill_common::feature_usage::log_feature_usage("ai_agent_eval", "dataset_created", "");
Ok(format!("Created eval dataset {}", payload.path))
}
+11
View File
@@ -707,6 +707,17 @@ pub async fn run_experiment(
.await?;
return Err(e);
}
// Which state of the agent was measured is the whole key vocabulary: it is what separates
// running what is deployed from measuring edits or an older version.
windmill_common::feature_usage::log_feature_usage(
"ai_agent_eval",
"run",
match subject.kind {
EvalSubjectKind::Agent => "agent",
EvalSubjectKind::AgentDraft => "agent_draft",
EvalSubjectKind::AgentVersion => "agent_version",
},
);
Ok(experiment_id.to_string())
}
+14 -1
View File
@@ -4112,10 +4112,18 @@ struct S3DeleteTokenClaims {
pub exp: usize,
}
#[cfg(feature = "parquet")]
const SIGN_S3_DEFAULT_EXPIRY_SECS: i64 = 12 * 60 * 60;
#[cfg(feature = "parquet")]
const SIGN_S3_MIN_EXPIRY_SECS: i64 = 60;
#[cfg(feature = "parquet")]
const SIGN_S3_MAX_EXPIRY_SECS: i64 = 7 * 24 * 60 * 60;
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
struct S3TokenRequestBody {
s3_objects: Vec<S3Object>,
expiry_secs: Option<i64>,
}
#[cfg(feature = "parquet")]
async fn sign_s3_objects(
@@ -4126,6 +4134,12 @@ async fn sign_s3_objects(
) -> Result<Json<Vec<S3Object>>> {
let workspace_key = get_workspace_key(&w_id, &db).await?;
let expiry_secs = body
.expiry_secs
.unwrap_or(SIGN_S3_DEFAULT_EXPIRY_SECS)
.clamp(SIGN_S3_MIN_EXPIRY_SECS, SIGN_S3_MAX_EXPIRY_SECS);
let exp = (chrono::Utc::now() + chrono::Duration::seconds(expiry_secs)).timestamp();
let futures = body.s3_objects.into_iter().map(|s3_object| async {
// The signature this mints is a transferable bearer capability: `validate_s3_signature`
// only checks the HMAC and expiry, so anyone who obtains the string can read this key.
@@ -4156,7 +4170,6 @@ async fn sign_s3_objects(
)
.await?;
let exp = (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp();
let message = format!(
"file_key={}&exp={}{}",
s3_object.s3.clone(),
+8
View File
@@ -102,6 +102,12 @@ lazy_static::lazy_static! {
(20260727151319, include_str!(
"../../migrations/20260727151319_draft_only_listing_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
(20260826202939, include_str!(
"../../migrations/20260826202939_queue_suspended_resume_at_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
(20260826214706, include_str!(
"../../migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql"
).replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
].into_iter().collect();
}
@@ -228,6 +234,8 @@ impl Migrate for CustomMigrator {
// CONCURRENTLY operations cannot run inside a transaction block
// or a multi-statement query (PostgreSQL requires top-level execution).
// Split into individual statements and execute each separately.
// The split is naive, so a `;` anywhere in an overridden migration —
// inside a comment or a string literal included — splits mid-statement.
for stmt in migration_sql.split(';') {
let stmt = stmt.trim();
if !stmt.is_empty()
+34 -3
View File
@@ -42,6 +42,11 @@ pub fn workspaced_service() -> Router {
.route("/migrations", post(publish_migrations))
.route("/projects/{slug}/export", get(get_project_export))
.route("/projects/{slug}/submit", post(submit_project))
.route("/projects/{slug}/withdraw", post(withdraw_project))
.route(
"/projects/{slug}/discard_update",
post(discard_project_update),
)
.route("/project", get(get_project_by_source))
}
@@ -554,9 +559,35 @@ async fn submit_project(
.await
}
// The Hub has no auth of its own: it validates bearer tokens by calling this
// instance's /api/users/whoami. Forwarding the caller's own token logs them in
// on the Hub as themselves (account auto-created on first use).
// Take a submission back out of review, keeping what was pushed for it.
async fn withdraw_project(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/withdraw", slug),
&serde_json::json!({}),
)
.await
}
// Throw away the pending update to an already-published project. The published
// version is untouched — it never saw the update.
async fn discard_project_update(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/discard_update", slug),
&serde_json::json!({}),
)
.await
}
// The Hub has no auth of its own: it validates bearer tokens by calling
// /api/users/whoami — on app.windmill.dev for the public Hub, on the paired
// instance for a private one. Forwarding the caller's own token logs them in on
// the Hub as themselves (account auto-created on first use).
async fn get_from_hub(
path: &str,
source_id: &str,
+8 -7
View File
@@ -6559,17 +6559,18 @@ pub async fn run_flow_by_version_inner(
Ok((uuid, early_return, has_failure_module))
}
/// Path parameters of `POST /w/{workspace}/jobs/restart/f/{job_id}`, shared by the CE and EE
/// handlers. Axum only checks the tuple against the route at request time and rejects a
/// mismatch with an opaque 500 before the handler runs, so both must be declared from here:
/// an arity that drifts from the route hides the handler behind what reads as a broken route.
type RestartFlowPath = Path<(String, Uuid)>;
#[cfg(not(feature = "enterprise"))]
pub async fn restart_flow(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path((_w_id, _job_id, _step_id, _branch_or_iteration_n)): Path<(
String,
Uuid,
String,
Option<usize>,
)>,
Path((_w_id, _job_id)): RestartFlowPath,
Query(_run_query): Query<RunJobQuery>,
) -> error::Result<(StatusCode, String)> {
return Err(Error::BadRequest(
@@ -6798,7 +6799,7 @@ pub async fn restart_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Path((w_id, job_id)): RestartFlowPath,
Query(run_query): Query<RunJobQuery>,
Json(RestartFlowRequestBody {
step_id,
@@ -123,7 +123,9 @@ async fn get_http_route_trigger(
let routers_cache = if routers_cache.routers.is_empty() {
tracing::warn!("HTTP routers are not loaded, loading from db");
let (_, routers_cache) = refresh_routers(db).await?;
// refresh_routers takes the write lock, so holding this read guard across it deadlocks.
drop(routers_cache);
let (_, routers_cache) = refresh_routers(db, false).await?;
routers_cache
} else {
routers_cache
+11 -7
View File
@@ -643,6 +643,16 @@ pub(crate) async fn tarball_workspace(
windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?;
}
// settings.json carries the admin-managed integration config that `get_settings`
// is admin-only for (ai_config, the webhook URL, git_sync, handler extra_args),
// so it takes the same check. Not a per-field redaction: fields silently dropped
// from settings.json come back as null on the next `wmill sync push`.
if include_settings.unwrap_or(false) && !authed.is_admin {
return Err(Error::PermissionDenied(
"include_settings requires workspace admin".to_string(),
));
}
// The route is gated by workspaces:read, but the tarball also carries the item
// values that the per-item routes gate on their own domain (get_resource_value,
// get_variable). A whole-workspace export cannot be confined to a path, so it
@@ -1626,13 +1636,7 @@ pub(crate) async fn tarball_workspace(
slack_name: row.slack_name.clone(),
slack_command_script: row.slack_command_script.clone(),
slack_oauth_client_id: row.slack_oauth_client_id.clone(),
// Mirror the non-admin redaction in `get_settings`: the OAuth
// client secret is admin-only and must not leak via tarball.
slack_oauth_client_secret: if authed.is_admin {
row.slack_oauth_client_secret.clone()
} else {
None
},
slack_oauth_client_secret: row.slack_oauth_client_secret.clone(),
};
serde_json::to_value(settings)
.map(|v| serde_json::to_string_pretty(&v).ok())
-45
View File
@@ -1,45 +0,0 @@
use anyhow::Result;
/// Parsed database connection parameters, shared across DB auth providers (IAM RDS, Entra ID, etc.)
#[derive(Debug, Clone)]
pub struct DatabaseParams {
pub hostname: String,
pub port: u64,
pub username: String,
pub database: String,
}
/// Extract database connection parameters from a PostgreSQL URL
pub fn extract_database_params(database_url: &str) -> Result<DatabaseParams> {
let url = url::Url::parse(database_url)
.map_err(|e| anyhow::anyhow!("Failed to parse database URL: {}", e))?;
let hostname = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("Database URL missing hostname"))?
.to_string();
let port = url.port().unwrap_or(5432) as u64;
let username = if url.username().is_empty() {
return Err(anyhow::anyhow!("Database URL missing username"));
} else {
urlencoding::decode(url.username())?.to_string()
};
let database = url
.path()
.trim_start_matches('/')
.split('/')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Database URL missing database name"))?
.to_string();
Ok(DatabaseParams {
hostname,
port,
username,
database: urlencoding::decode(&database)?.to_string(),
})
}
+14 -5
View File
@@ -42,7 +42,6 @@ pub mod db;
mod db_entra_ee;
#[cfg(all(feature = "enterprise", feature = "private"))]
mod db_iam_ee;
pub mod db_params;
pub mod dbt_manifest;
pub mod deploy_origin;
#[cfg(feature = "private")]
@@ -1479,6 +1478,17 @@ pub async fn create_custom_instance_database(
Ok(())
}
/// Connection options parsed from a database URL.
///
/// The only place a database URL becomes `PgConnectOptions`. Providers that mint the password
/// themselves override it on these and keep the rest: options assembled field by field instead
/// would drop every query parameter, `sslmode` and `sslrootcert` above all, leaving the
/// connection on sqlx's default TLS policy rather than the operator's.
pub fn base_connect_options(database_url: &str) -> Result<sqlx::postgres::PgConnectOptions, Error> {
sqlx::postgres::PgConnectOptions::from_str(database_url)
.map_err(|e| Error::InternalErr(format!("Failed to parse database URL: {}", e)))
}
#[derive(Clone)]
pub enum DatabaseUrl {
#[cfg(all(feature = "enterprise", feature = "private"))]
@@ -1509,8 +1519,8 @@ impl DatabaseUrl {
}
/// Get PgConnectOptions for this database URL.
/// For token-based auth (IAM RDS, Entra ID), this returns options built directly from the
/// token to avoid double-encoding issues with temporary credentials.
/// For token-based auth (IAM RDS, Entra ID), this returns options carrying the current
/// token, set on the builder to avoid double-encoding temporary credentials.
/// For static URLs, this parses the URL string.
pub async fn connect_options(&self) -> Result<sqlx::postgres::PgConnectOptions, Error> {
match self {
@@ -1524,8 +1534,7 @@ impl DatabaseUrl {
let guard = entra_url.read().await;
Ok(guard.connect_options())
}
DatabaseUrl::Static(url) => sqlx::postgres::PgConnectOptions::from_str(url)
.map_err(|e| Error::InternalErr(format!("Failed to parse database URL: {}", e))),
DatabaseUrl::Static(url) => base_connect_options(url),
}
}
+7 -1
View File
@@ -734,11 +734,17 @@ fn format_pull_query(peek: String) -> String {
r
}
// The `CASE` is `suspend <= 0 OR suspend_until <= now()` written as one indexable
// expression, equivalent only under the `suspend_until IS NOT NULL` guard. It must stay in
// sync with `queue_suspended_v2` (migration 20260826202939): if it no longer matches, the
// test silently reverts to a heap filter over every suspended row on every worker poll.
pub fn make_suspended_pull_query(tags: &[String]) -> String {
format_pull_query(format!(
"SELECT id
FROM v2_job_queue
WHERE suspend_until IS NOT NULL AND (suspend <= 0 OR suspend_until <= now()) AND tag IN ({})
WHERE suspend_until IS NOT NULL
AND (CASE WHEN suspend <= 0 THEN '-infinity'::timestamptz ELSE suspend_until END) <= now()
AND tag IN ({})
ORDER BY priority DESC NULLS LAST, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1",
+53 -8
View File
@@ -759,15 +759,24 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result<i64> {
Ok(count)
}
/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and
/// service-account members. Reuses billing's author/operator weighting, but counts provisioned
/// members rather than the active-user population billing meters, so it only ever loosens the fork
/// cap (never blocks a paid seat) — good enough for a soft guardrail.
/// The billable members of a workspace and the seats they add up to.
#[derive(Clone, Debug, Serialize)]
pub struct BillableSeats {
pub developers: i64,
pub operators: i64,
pub seats: i64,
}
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
///
/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own
/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a
/// workspace for seats the product never credits it for.
///
/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be
/// authorized for that workspace (or run in trusted server-side code).
#[cfg(feature = "cloud")]
pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats> {
let row = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!",
@@ -777,8 +786,18 @@ pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
)
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?;
Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64)
.map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?;
Ok(BillableSeats {
developers: row.developers,
operators: row.operators,
seats: ((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64,
})
}
/// Seats only, for the fork cap. See [`billable_seats`].
#[cfg(feature = "cloud")]
pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
Ok(billable_seats(db, w_id).await?.seats)
}
#[cfg(feature = "cloud")]
@@ -2193,6 +2212,32 @@ pub fn lfs_entry_storage_ref(entry: &serde_json::Value) -> Option<String> {
Some(format!("{typ}:{path}"))
}
pub const FILESYSTEM_STORAGE_DEV_ONLY_MSG: &str =
"Filesystem storage is only available in development builds of Windmill: it points the \
workspace at a directory on the server's own disk rather than at a resource. Use an S3, \
Azure Blob or Google Cloud Storage backend instead.";
/// A filesystem workspace storage names a directory on the server's own disk, so it hands whoever
/// configures it — a workspace admin, or any member who can write a `filesystem` resource —
/// whatever the server process can reach, and it only resolves when server and workers share that
/// disk. It is there so local development can skip MinIO, hence debug builds only. Instance object
/// storage on local disk is a separate, superadmin-only setting and stays allowed everywhere.
pub fn filesystem_storage_allowed() -> bool {
cfg!(debug_assertions)
}
/// Guards every site that builds an `ObjectStoreResource::Filesystem`, so nothing downstream can
/// reach a local-disk store: a stored config outlives the build that accepted it, and the resource
/// route never passes through the workspace-storage settings at all.
pub fn ensure_filesystem_storage_allowed() -> Result<()> {
if !filesystem_storage_allowed() {
return Err(Error::BadRequest(
FILESYSTEM_STORAGE_DEV_ONLY_MSG.to_string(),
));
}
Ok(())
}
/// Resolve a `$res:`/`$var:` reference tree to its concrete value (recursively, secrets
/// decrypted). No permission checks — trusted server-side callers only; never echo the result
/// to a user.
@@ -3,7 +3,7 @@
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::{
count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
billable_seats, count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
get_billing_workspace_id, invalidate_billing_workspace_cache, list_fork_descendants,
};
@@ -106,11 +106,17 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await;
insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await;
insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await;
// These must NOT count towards seats.
// These must NOT count towards seats. The service account is a non-operator, so counting it
// would inflate the developer tally the invoice line is written from, not the operator one.
insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await;
insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await;
assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3);
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(2, 2, 3)
);
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
insert_ws(&db, "seat-fork2", Some("seat-root"), false).await;
+1
View File
@@ -1171,6 +1171,7 @@ pub fn lfs_to_object_store_resource(
Ok(ObjectStoreResource::Gcs(gcs_resource))
}
LargeFileStorage::FilesystemStorage(fs) => {
windmill_common::workspaces::ensure_filesystem_storage_allowed()?;
Ok(ObjectStoreResource::Filesystem(FilesystemSettings {
root_path: fs.root_path.clone(),
}))
+6
View File
@@ -4671,6 +4671,12 @@ pub async fn check_debouncing_within_limits(
}
}
/// Whether the tag's queue name is computed from the job's arguments, so that a caller holding
/// arguments it could not build knows the tag cannot be built either.
pub fn tag_reads_args(tag: &str) -> bool {
RE_ARG_TAG.is_match(tag)
}
pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> String {
// Save this value to avoid parsing twice
let workspaced = x.as_str().replace("$workspace", workspace_id).to_string();
+13 -4
View File
@@ -268,6 +268,15 @@ pub async fn create_many_http_triggers(
format!("http_triggers:write:{}", &new_http_trigger.base.path)
})?;
// This route inserts directly, bypassing the shared create handler.
// `error_wrapper` would turn the rejection into a 500.
new_http_trigger.error_handling.validate().map_err(|err| {
Error::BadRequest(format!(
"Error occurred for HTTP route at route path: {}, error: {}",
new_http_trigger.config.route_path, err
))
})?;
handler
.validate_new(&db, &w_id, &new_http_trigger.config)
.await
@@ -570,8 +579,8 @@ impl TriggerCrud for HttpTrigger {
route_path,
&route_path_key,
Some(effective_workspaced),
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.wrap_body.unwrap_or(false),
trigger.config.raw_string.unwrap_or(false),
trigger.config.authentication_resource_path,
trigger.base.script_path,
trigger.base.path,
@@ -626,8 +635,8 @@ impl TriggerCrud for HttpTrigger {
workspace_id = $20 AND
path = $21
"#,
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.wrap_body.unwrap_or(false),
trigger.config.raw_string.unwrap_or(false),
trigger.config.authentication_resource_path,
trigger.base.script_path,
trigger.base.path,
+35 -6
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
@@ -27,9 +28,12 @@ lazy_static::lazy_static! {
pub static ref HTTP_ROUTERS_CACHE: RwLock<RoutersCache> = RwLock::new(RoutersCache {
routers: HashMap::new(),
version: 0,
invalidations: 0,
});
}
static HTTP_ROUTERS_INVALIDATIONS: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Deserialize, Clone)]
pub struct TriggerRoute {
pub path: String,
@@ -56,6 +60,10 @@ pub struct TriggerRoute {
pub struct RoutersCache {
pub routers: HashMap<HttpMethod, matchit::Router<TriggerRoute>>,
pub version: i64,
/// `HTTP_ROUTERS_INVALIDATIONS` as of the moment these rows were read. A rebuild that
/// started before an invalidation publishes a count behind the current one, which is what
/// stops it from passing its own stale rows off as covering that invalidation.
invalidations: u64,
}
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, Hash, Eq, PartialEq)]
@@ -223,12 +231,24 @@ pub fn validate_authentication_method(
}
}
pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> {
/// `force` rebuilds unconditionally. `nextval` on `http_trigger_version_seq` runs inside the
/// writing transaction and sequences are non-transactional, so another session can cache the
/// bumped version against still-uncommitted rows, after which every version-gated refresh is a
/// no-op. Force when reacting to a bump that could have been observed before its own rows were.
pub async fn refresh_routers(
db: &DB,
force: bool,
) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> {
let invalidations = HTTP_ROUTERS_INVALIDATIONS.load(Ordering::Relaxed);
let version = sqlx::query_scalar!("SELECT last_value FROM http_trigger_version_seq",)
.fetch_one(db)
.await?;
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
if routers_cache.version == 0 || version > routers_cache.version {
if force
|| routers_cache.version == 0
|| version > routers_cache.version
|| invalidations != routers_cache.invalidations
{
drop(routers_cache);
let mut routers = HashMap::new();
@@ -274,7 +294,8 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
.await?;
let mut router = matchit::Router::new();
let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let http_route_workspaced =
HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
for trigger in triggers {
let full_path =
@@ -306,7 +327,7 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
}
let mut routers_cache = HTTP_ROUTERS_CACHE.write().await;
*routers_cache = RoutersCache { routers, version };
*routers_cache = RoutersCache { routers, version, invalidations };
Ok((true, routers_cache.downgrade()))
} else {
@@ -315,11 +336,19 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
}
}
/// Record that the cache no longer covers everything committed, so the next refresh rebuilds
/// whatever the version says. The routes already loaded keep being served in the meantime. Use
/// after a forced refresh fails: its change is inside the cached version, so nothing else would
/// retry it.
pub fn invalidate_routers() {
HTTP_ROUTERS_INVALIDATIONS.fetch_add(1, Ordering::Relaxed);
}
pub async fn refresh_routers_loop(
db: &DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match refresh_routers(db).await {
match refresh_routers(db, false).await {
Ok(_) => {
tracing::info!("Loaded HTTP routers");
}
@@ -335,7 +364,7 @@ pub async fn refresh_routers_loop(
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
match refresh_routers(&db).await {
match refresh_routers(&db, false).await {
Ok((true, _)) => {
tracing::info!("Refreshed HTTP routers");
}
@@ -0,0 +1,62 @@
use sqlx::{Pool, Postgres};
use windmill_trigger_http::{invalidate_routers, refresh_routers, HttpMethod, RoutersCache};
async fn insert_trigger(db: &Pool<Postgres>, path: &str, route_path: &str) {
sqlx::query(
"INSERT INTO http_trigger (
path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by,
permissioned_as, http_method, authentication_method, request_type, is_static_website,
workspaced_route, wrap_body, raw_string, mode
) VALUES ($1, $2, $2, 'f/test/handler', false, 'test-workspace', 'test-user',
'u/test-user', 'get', 'none', 'async', false, false, false, false, 'enabled')",
)
.bind(path)
.bind(route_path)
.execute(db)
.await
.expect("insert http_trigger");
}
fn routes(cache: &RoutersCache, path: &str) -> bool {
cache.routers[&HttpMethod::Get].at(path).is_ok()
}
// A trigger row can commit without advancing http_trigger_version_seq past what the cache
// already holds, because `nextval` runs ahead of the commit it belongs to. The version gate
// cannot see such a row; only forcing, or an invalidation, recovers the route.
#[sqlx::test(migrations = "../migrations")]
async fn rebuilds_a_change_the_cached_version_does_not_cover(db: Pool<Postgres>) {
insert_trigger(&db, "f/test/first", "first").await;
let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap();
assert!(rebuilt);
assert!(routes(&cache, "/first"));
drop(cache);
insert_trigger(&db, "f/test/second", "second").await;
let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap();
assert!(!rebuilt, "an unchanged version must not rebuild");
assert!(!routes(&cache, "/second"));
drop(cache);
let (rebuilt, cache) = refresh_routers(&db, true).await.unwrap();
assert!(rebuilt, "force must rebuild whatever the version says");
assert!(routes(&cache, "/second"));
drop(cache);
// A forced refresh that failed leaves its change inside the cached version, so the periodic
// version-gated refresh has to rebuild on the invalidation alone.
insert_trigger(&db, "f/test/third", "third").await;
invalidate_routers();
let (rebuilt, cache) = refresh_routers(&db, false).await.unwrap();
assert!(
rebuilt,
"an invalidation must rebuild through the version gate"
);
assert!(routes(&cache, "/third"));
drop(cache);
let (rebuilt, _) = refresh_routers(&db, false).await.unwrap();
assert!(!rebuilt, "a served invalidation must not rebuild forever");
}
+4
View File
@@ -554,6 +554,8 @@ async fn create_trigger<T: TriggerCrud>(
)));
}
new_trigger.error_handling.validate()?;
handler
.validate_new(&db, &workspace_id, &new_trigger.config)
.await?;
@@ -815,6 +817,8 @@ async fn update_trigger<T: TriggerCrud>(
)
})?;
edit_trigger.error_handling.validate()?;
handler
.validate_edit(&db, &workspace_id, &edit_trigger.config, path)
.await?;
+62
View File
@@ -84,6 +84,31 @@ pub struct TriggerErrorHandling {
pub retry: Option<sqlx::types::Json<windmill_common::flows::Retry>>,
}
impl TriggerErrorHandling {
/// Schedule and workspace error handlers encode script-vs-flow as a
/// `script/`/`flow/` prefix; a trigger's handler is always a script, so a
/// prefixed path here would be looked up verbatim as a script name and fail
/// only once the trigger errors, which is when the handler is needed.
pub fn validate(&self) -> windmill_common::error::Result<()> {
let Some(path) = self.error_handler_path.as_deref() else {
return Ok(());
};
if let Some(bare) = path.strip_prefix("script/") {
return Err(windmill_common::error::Error::BadRequest(format!(
"error_handler_path is a plain script path, not the prefixed form a schedule \
error handler takes: got '{path}', use '{bare}'"
)));
}
if path.starts_with("flow/") {
return Err(windmill_common::error::Error::BadRequest(format!(
"error_handler_path must be a script: a trigger error handler cannot be a flow \
(got '{path}')"
)));
}
Ok(())
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Trigger<T>
where
@@ -253,6 +278,43 @@ mod tests {
use super::*;
use serde_json::json;
// --- TriggerErrorHandling::validate ---
fn error_handling(path: Option<&str>) -> TriggerErrorHandling {
TriggerErrorHandling {
error_handler_path: path.map(str::to_string),
error_handler_args: None,
retry: None,
}
}
#[test]
fn test_error_handler_path_accepts_bare_and_hub_paths() {
for path in [None, Some("f/team/handler"), Some("u/admin/handler")] {
assert!(error_handling(path).validate().is_ok(), "{path:?}");
}
assert!(error_handling(Some("hub/13953/windmill/handler"))
.validate()
.is_ok());
}
#[test]
fn test_error_handler_path_rejects_prefixed_paths() {
// The rejection names the bare path to use, so the caller can fix it
// without knowing which of the two conventions a trigger follows.
let err = error_handling(Some("script/f/team/handler"))
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("f/team/handler"), "{err}");
let err = error_handling(Some("flow/f/team/handler"))
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("cannot be a flow"), "{err}");
}
// --- TriggerMode serde ---
#[test]
+22
View File
@@ -632,6 +632,28 @@ pub enum JobPayload {
},
}
impl JobPayload {
/// Whether the payload itself declares a dedicated worker, in which case `push` replaces
/// whatever tag it is handed and the caller's tag never reaches the queue.
///
/// This reads what the payload carries, not what `push` will conclude: a `SingleStepFlow`
/// loads the flag from the script row at push time and reports `false` here. That only
/// matters to a caller reasoning about the tag, and for those the answer is the same either
/// way, since `push` replaces the tag in exactly the case this misses.
pub fn is_dedicated_worker(&self) -> bool {
let dedicated_worker = match self {
JobPayload::ScriptHash { dedicated_worker, .. }
| JobPayload::FlowScript { dedicated_worker, .. }
| JobPayload::Dependencies { dedicated_worker, .. }
| JobPayload::FlowDependencies { dedicated_worker, .. }
| JobPayload::Flow { dedicated_worker, .. } => dedicated_worker,
JobPayload::Code(raw) => &raw.dedicated_worker,
_ => &None,
};
dedicated_worker.is_some_and(|x| x)
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct SkipHandler {
pub path: String,
+55 -4
View File
@@ -1256,6 +1256,7 @@ pub async fn prebundle_bun_script(
token: &str,
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
temp_script_refs: &Option<HashMap<String, String>>,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<()> {
let (local_path, remote_path) = compute_bundle_local_and_remote_path(
inner_content,
@@ -1264,6 +1265,7 @@ pub async fn prebundle_bun_script(
db,
w_id,
temp_script_refs,
modules,
)
.await;
if exists_in_cache(&local_path, &remote_path).await {
@@ -1442,6 +1444,7 @@ pub async fn compute_bundle_local_and_remote_path(
db: Option<&DB>,
w_id: &str,
temp_script_refs: &Option<HashMap<String, String>>,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> (String, String) {
let mut input_src = format!("{inner_content}{lock}",);
@@ -1470,7 +1473,10 @@ pub async fn compute_bundle_local_and_remote_path(
let ws_suffix = crate::workspace_registry_cache_suffix(w_id).await;
input_src.push_str(&ws_suffix);
let hash = windmill_common::utils::calculate_hash(&input_src);
// The loader resolves relative imports against the module files in the job dir, so
// their content is inlined into the bundle this name covers.
let hash = crate::worker::artifact_cache_name(input_src, modules);
let local_path = format!("{}/{hash}", *BUN_BUNDLE_CACHE_DIR);
#[cfg(windows)]
@@ -1569,6 +1575,7 @@ pub async fn handle_bun_job(
Some(db),
&job.workspace_id,
&temp_script_refs,
modules.as_ref(),
)
.await
}
@@ -2962,9 +2969,8 @@ pub async fn handle_wac_v2_output(
version: flow_info.version,
labels: flow_info.labels.clone(),
};
let on_behalf_of = flow_info
.on_behalf_of(&job.workspace_id, db)
.await?;
let on_behalf_of =
flow_info.on_behalf_of(&job.workspace_id, db).await?;
let step_args: HashMap<String, Box<RawValue>> = step
.args
.iter()
@@ -4382,4 +4388,49 @@ export function main(x: number) { return x; }"#;
assert!(wrapper.contains(r#"line.startsWith("exec_preprocess:")"#));
assert!(wrapper.contains(r#"line.startsWith("exec:")"#));
}
/// The bundle cache is global and content-keyed, so a key that ignores the inline
/// modules hands one workspace's bundle — attacker helper code and all — to the next
/// job whose main content and lockfile happen to match.
#[tokio::test]
async fn bundle_cache_key_separates_inline_module_content() {
use windmill_common::scripts::ScriptModule;
async fn key_for(modules: Option<&HashMap<String, ScriptModule>>) -> String {
compute_bundle_local_and_remote_path(
"import { h } from './helper.ts';\nexport async function main() { return h(); }",
"{}\n//bun.lock\n<empty>",
"u/alice/script",
None,
"w1",
&None,
modules,
)
.await
.1
}
fn modules(content: &str) -> HashMap<String, ScriptModule> {
HashMap::from([(
"helper.ts".to_string(),
ScriptModule {
content: content.to_string(),
language: ScriptLang::Bun,
lock: None,
},
)])
}
let attacker = key_for(Some(&modules("export const h = () => 'attacker'"))).await;
let victim = key_for(Some(&modules("export const h = () => 'victim'"))).await;
assert_ne!(attacker, victim);
assert_eq!(
attacker,
key_for(Some(&modules("export const h = () => 'attacker'"))).await,
"same modules must still share a cache slot"
);
// An absent map and an empty one are the same script, so they share a slot.
assert_eq!(key_for(None).await, key_for(Some(&HashMap::new())).await);
assert_ne!(key_for(None).await, attacker);
}
}
+1
View File
@@ -1640,6 +1640,7 @@ pub(crate) async fn get_workspace_s3_resource_path(
)
}
Some(LargeFileStorage::FilesystemStorage(fs)) => {
windmill_common::workspaces::ensure_filesystem_storage_allowed()?;
return Ok(Some(
windmill_object_store::ObjectStoreResource::Filesystem(
windmill_object_store::FilesystemSettings { root_path: fs.root_path.clone() },
+17 -5
View File
@@ -13,7 +13,7 @@ use itertools::Itertools;
#[cfg(feature = "csharp")]
use tokio::{fs::File, io::AsyncReadExt, process::Command};
#[cfg(feature = "csharp")]
use windmill_common::{utils::calculate_hash, worker::write_file};
use windmill_common::worker::write_file;
#[cfg(feature = "csharp")]
use crate::global_cache::save_cache;
@@ -72,13 +72,21 @@ const CSHARP_OBJECT_STORE_PREFIX: &str =
/// Cache key of a C# build. The run path and the deploy-time prebuild must derive it the
/// same way or the prebuilt binary is never found and gets rebuilt on first run.
#[cfg(feature = "csharp")]
async fn csharp_cache_key(code: &str, requirements_o: Option<&str>, w_id: &str) -> String {
let mut hash = calculate_hash(&format!(
async fn csharp_cache_key(
code: &str,
requirements_o: Option<&str>,
w_id: &str,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> String {
// The SDK project globs every `.cs` under the job dir, so companion modules are
// compiled into the binary this key names and have to be part of it.
let base = format!(
"{}{}{}",
code,
requirements_o.unwrap_or(""),
DOTNET_TARGET_FRAMEWORK.as_str()
));
);
let mut hash = crate::worker::artifact_cache_name(base, modules);
hash.push_str(&crate::workspace_registry_cache_suffix(w_id).await);
hash
}
@@ -487,10 +495,11 @@ pub async fn prebuild_csharp_binary(
worker_name: &str,
base_internal_url: &str,
occupancy_metrics: &mut OccupancyMetrics,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> error::Result<Option<String>> {
check_executor_binary_exists("dotnet", DOTNET_PATH.as_str(), "C#")?;
let hash = csharp_cache_key(code, Some(lock), &job.workspace_id).await;
let hash = csharp_cache_key(code, Some(lock), &job.workspace_id, modules).await;
let remote_path = format!("{CSHARP_OBJECT_STORE_PREFIX}{hash}");
if crate::global_cache::exists_in_object_store(&remote_path).await {
return Ok(None);
@@ -549,6 +558,7 @@ pub async fn handle_csharp_job(
_worker_name: &str,
_envs: HashMap<String, String>,
_occupancy_metrics: &mut OccupancyMetrics,
_modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<Box<RawValue>, Error> {
Err(anyhow!("C# is not available because the feature is not enabled").into())
}
@@ -568,6 +578,7 @@ pub async fn handle_csharp_job(
worker_name: &str,
envs: HashMap<String, String>,
occupancy_metrics: &mut OccupancyMetrics,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<Box<RawValue>, Error> {
check_executor_binary_exists("dotnet", DOTNET_PATH.as_str(), "C#")?;
@@ -575,6 +586,7 @@ pub async fn handle_csharp_job(
inner_content,
requirements_o.map(|x| x.as_str()),
&job.workspace_id,
modules,
)
.await;
let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR);
+13 -4
View File
@@ -230,8 +230,15 @@ fn go_runtime_int32(v: &str) -> Option<i32> {
/// Cache key of a Go build. The run path and the deploy-time prebuild must derive it the
/// same way or the prebuilt binary is never found and gets rebuilt on first run.
fn go_cache_key(code: &str, maybe_lock: &MaybeLock) -> String {
calculate_hash(&format!("{}{:?}v2", code, maybe_lock))
fn go_cache_key(
code: &str,
maybe_lock: &MaybeLock,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> String {
// A module whose path starts with `go/` lands inside the module dir this builds, so
// its content ends up in the binary the key names.
let base = format!("{}{:?}v2", code, maybe_lock);
crate::worker::artifact_cache_name(base, modules)
}
/// Install the deps, generate the entrypoint wrapper, `go build`, and push the binary to
@@ -459,9 +466,10 @@ pub async fn prebuild_go_binary(
worker_name: &str,
base_internal_url: &str,
occupancy_metrics: &mut OccupancyMetrics,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<Option<String>, Error> {
let maybe_lock = MaybeLock::Resolved { lock: lock.to_string() };
let hash = go_cache_key(code, &maybe_lock);
let hash = go_cache_key(code, &maybe_lock, modules);
let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}");
if crate::global_cache::exists_in_object_store(&remote_path).await {
return Ok(None);
@@ -512,6 +520,7 @@ pub async fn handle_go_job(
envs: HashMap<String, String>,
occupation_metrics: &mut OccupancyMetrics,
maybe_lock: MaybeLock,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<Box<RawValue>, Error> {
//go does not like executing modules at temp root
let job_dir = &format!("{job_dir}/go");
@@ -520,7 +529,7 @@ pub async fn handle_go_job(
.create(&job_dir)
.expect("could not create go job dir");
let hash = go_cache_key(inner_content, &maybe_lock);
let hash = go_cache_key(inner_content, &maybe_lock, modules);
let bin_path = format!("{}/{hash}", *GO_BIN_CACHE_DIR);
let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}");
let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
+13 -4
View File
@@ -66,6 +66,7 @@ pub(crate) struct JobHandlerInput<'a> {
pub requirements_o: Option<&'a String>,
pub shared_mount: &'a str,
pub worker_name: &'a str,
pub modules: Option<&'a HashMap<String, windmill_common::scripts::ScriptModule>>,
}
pub async fn handle_java_job<'a>(mut args: JobHandlerInput<'a>) -> Result<Box<RawValue>, Error> {
@@ -612,25 +613,33 @@ async fn compile<'a>(
inner_content,
requirements_o,
parent_runnable_path,
modules,
..
}: &mut JobHandlerInput<'a>,
classpath: &'a str,
// plugins: Vec<&'a str>,
) -> Result<(), Error> {
fn compute_hash(code: &str, requirements_o: Option<&String>) -> String {
calculate_hash(&format!(
// The cached artifact is the whole `target/` dir, and companion modules are written
// into the job dir before this runs, so their content can land in it.
fn compute_hash(
code: &str,
requirements_o: Option<&String>,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> String {
let base = format!(
"{}{}",
code,
requirements_o
.as_ref()
.map(|x| x.to_string())
.unwrap_or_default()
))
);
crate::worker::artifact_cache_name(base, modules)
}
let reserved_variables =
get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?;
let ws_suffix = crate::workspace_registry_cache_suffix(&job.workspace_id).await;
let mut hash = compute_hash(inner_content, *requirements_o);
let mut hash = compute_hash(inner_content, *requirements_o, *modules);
hash.push_str(&ws_suffix);
let bin_path = format!("{}/{hash}", *JAVA_CACHE_DIR);
let remote_path = format!("java_jar/{hash}");
+21 -8
View File
@@ -14,7 +14,6 @@ use tokio::{
};
use windmill_common::{
error::{self, Error},
utils::calculate_hash,
worker::{write_file, Connection},
};
use windmill_queue::MiniPulledJob;
@@ -602,8 +601,13 @@ pub async fn build_rust_crate(
/// Cache key of a Rust build. The run path and the deploy-time prebuild must derive it
/// the same way or the prebuilt binary is never found and gets rebuilt on first run.
async fn rust_cache_key(code: &str, requirements_o: Option<&String>, w_id: &str) -> String {
let mut hash = compute_rust_hash(code, requirements_o);
async fn rust_cache_key(
code: &str,
requirements_o: Option<&String>,
w_id: &str,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> String {
let mut hash = compute_rust_hash(code, requirements_o, modules);
hash.push_str(&crate::workspace_registry_cache_suffix(w_id).await);
hash
}
@@ -621,11 +625,12 @@ pub async fn prebuild_rust_binary(
worker_name: &str,
base_internal_url: &str,
occupancy_metrics: &mut OccupancyMetrics,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> error::Result<Option<String>> {
ensure_rust_runtime_dirs();
check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?;
let hash = rust_cache_key(code, Some(&lock.to_string()), &job.workspace_id).await;
let hash = rust_cache_key(code, Some(&lock.to_string()), &job.workspace_id, modules).await;
let remote_path = format!("{RUST_OBJECT_STORE_PREFIX}{hash}");
if crate::global_cache::exists_in_object_store(&remote_path).await {
return Ok(None);
@@ -652,15 +657,22 @@ pub async fn prebuild_rust_binary(
Ok(Some(logs))
}
pub fn compute_rust_hash(code: &str, requirements_o: Option<&String>) -> String {
calculate_hash(&format!(
pub fn compute_rust_hash(
code: &str,
requirements_o: Option<&String>,
// Companion modules are written into the crate dir and compiled into the binary this
// key names, so leaving them out shares one script's binary with another.
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> String {
let base = format!(
"{}{}",
code,
requirements_o
.as_ref()
.map(|x| x.to_string())
.unwrap_or_default()
))
);
crate::worker::artifact_cache_name(base, modules)
}
#[tracing::instrument(level = "trace", skip_all)]
@@ -679,11 +691,12 @@ pub async fn handle_rust_job(
worker_name: &str,
envs: HashMap<String, String>,
occupancy_metrics: &mut OccupancyMetrics,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Result<Box<RawValue>, Error> {
ensure_rust_runtime_dirs();
check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?;
let hash = rust_cache_key(inner_content, requirements_o, &job.workspace_id).await;
let hash = rust_cache_key(inner_content, requirements_o, &job.workspace_id, modules).await;
let bin_path = format!("{}/{hash}", *RUST_CACHE_DIR);
let remote_path = format!("{RUST_OBJECT_STORE_PREFIX}{hash}");
+155 -3
View File
@@ -950,6 +950,51 @@ pub async fn workspace_registry_cache_suffix(w_id: &str) -> String {
}
}
/// The name a build artifact is cached under, derived from `base` — the runnable's own
/// cache-key input — and its inline modules.
///
/// `write_module_files` puts module content in the job dir where the build inlines it into
/// the artifact, so a name without it serves one runnable's modules to another whose main
/// content and lockfile match — across workspaces, the cache being global.
///
/// Only the path and content may name the artifact, because they are all the build reads.
/// `ScriptModule::lock` especially must stay out: deploy regenerates it *after* the parent
/// has prebuilt, so naming it would strand every prebuilt artifact.
pub(crate) fn artifact_cache_name(
base: String,
modules: Option<&std::collections::HashMap<String, ScriptModule>>,
) -> String {
let Some(modules) = modules.filter(|m| !m.is_empty()) else {
// Byte-identical to the name a module-free runnable had before modules entered this,
// so its cached artifacts stay reachable. A pre-fix multi-file runnable also stored
// here, so one of those stays reachable too — accepted over invalidating every cache,
// and once this ships nothing can be stored here with module content in it again.
return windmill_common::utils::calculate_hash(&base);
};
let mut entries: Vec<(&String, &ScriptModule)> = modules.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
// `base` ends in caller-supplied bytes (a preview brings its own lockfile), so it is
// sealed to a fixed width before the module block is appended — raw, a crafted lockfile
// could spell out another runnable's block and reach its slot.
let mut keyed = format!(
"{}:modules:{}",
windmill_common::utils::calculate_hash(&base),
entries.len()
);
for (path, module) in entries {
// Both length-prefixed, else `{"a": "bc"}` and `{"ab": "c"}` encode alike.
keyed.push_str(&format!(
":{}:{path}:{}:{}",
path.len(),
module.content.len(),
module.content,
));
}
// Its own namespace: `calculate_hash` emits hex, so however a module-free runnable
// crafts its content and lockfile it can never land on a module-bearing name.
format!("mod-{}", windmill_common::utils::calculate_hash(&keyed))
}
pub fn is_sandboxing_enabled() -> bool {
if !*DISABLE_NSJAIL {
return true;
@@ -3532,7 +3577,13 @@ pub async fn run_worker(
job.kind,
JobKind::Script | JobKind::Preview | JobKind::FlowScript
) {
if !dedicated_workers.is_empty() {
// A job carrying a pre-run error never runs its code: it only has to be
// pulled so `handle_queued_job` can fail it. Both hand-off paths below
// dispatch by path and return before that check, so a job sent down them
// would run with whatever arguments survived the failure.
let fails_before_running = job.pre_run_error.is_some();
if !dedicated_workers.is_empty() && !fails_before_running {
let dedicated_worker_tx = job.runnable_path.as_ref().and_then(|path| {
// For flow steps inside branches/loops, runnable_path includes
// nesting segments (e.g. f/flow/branchone-0/a) but the dedicated
@@ -3577,7 +3628,7 @@ pub async fn run_worker(
NextJob::Http(_) => None,
};
if let Some(flow_runners) = flow_runners {
if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) {
let key_o = job.flow_step_id.as_ref().map(|x| x.to_string());
if let Some(key) = key_o {
if let Some(flow_runner_tx) = flow_runners.runners.get(&key) {
@@ -5453,7 +5504,9 @@ async fn handle_code_execution_job(
None => job,
};
// For preview jobs, extract modules from args._MODULES if not already set
// Any job kind, not just previews: whatever is here is what gets written to the job dir
// and built in, so the agent-worker server precomputing a cache name has to resolve
// modules the same way (`windmill-api-agent-workers`, `get_code_and_lock`).
let modules = modules_from_data.clone().or_else(|| {
job.args.as_ref().and_then(|args| {
args.get("_MODULES").and_then(|raw| {
@@ -5646,11 +5699,106 @@ mod write_module_files_tests {
use super::*;
use std::collections::HashMap;
use windmill_common::scripts::ScriptLang;
use windmill_common::utils::calculate_hash;
fn module(content: &str) -> ScriptModule {
ScriptModule { content: content.to_string(), language: ScriptLang::Python3, lock: None }
}
/// Every language's artifact cache name funnels module content through this, so an
/// ambiguous encoding puts two different runnables back on one name.
#[test]
fn artifact_name_cannot_be_re_cut_into_another_module_map() {
fn name(entries: &[(&str, &str)]) -> String {
let map: HashMap<String, ScriptModule> = entries
.iter()
.map(|(p, c)| (p.to_string(), module(c)))
.collect();
artifact_cache_name("base".to_string(), Some(&map))
}
// Naive `path + content` concatenation renders both of these as "abc".
assert_ne!(name(&[("a", "bc")]), name(&[("ab", "c")]));
// Splitting one module into two must not read back as the joined one.
assert_ne!(name(&[("a", "b"), ("c", "d")]), name(&[("ac", "bd")]));
// Iteration order of the map must not move the name.
assert_eq!(
name(&[("a", "1"), ("b", "2")]),
name(&[("b", "2"), ("a", "1")])
);
}
/// A module-free runnable must keep the exact name it had before modules entered the
/// derivation, or upgrading strands every artifact already in the cache.
#[test]
fn artifact_name_is_unchanged_without_modules() {
assert_eq!(
artifact_cache_name("code+lock".to_string(), None),
calculate_hash("code+lock")
);
assert_eq!(
artifact_cache_name("code+lock".to_string(), Some(&HashMap::new())),
calculate_hash("code+lock")
);
}
/// A preview brings its own source and lockfile, so a module-free runnable picks its
/// whole `base`. Module-bearing names live in their own namespace precisely so that no
/// crafted `base` can be made to land on one.
#[test]
fn a_module_free_runnable_cannot_forge_a_module_bearing_name() {
let modules = HashMap::from([("h.ts".to_string(), module("evil"))]);
let victim = artifact_cache_name("code+lock".to_string(), Some(&modules));
// `calculate_hash` emits hex, so the namespace is unreachable however `base` is
// chosen — including by feeding it the victim's own name.
assert!(victim.starts_with("mod-"));
assert_ne!(artifact_cache_name(victim.clone(), None), victim);
assert!(!artifact_cache_name("anything".to_string(), None).starts_with("mod-"));
}
/// The `mod-` namespace separates module-free from module-bearing, and nothing separates
/// two module-bearing runnables — only the seal does. Unsealed, `base` is variable-width,
/// so the split between it and the module block is ambiguous and a preview (which brings
/// its own source *and* lockfile) can absorb part of another runnable's block.
#[test]
fn a_module_bearing_runnable_cannot_absorb_another_ones_block() {
let victim = artifact_cache_name(
"V".to_string(),
Some(&HashMap::from([(
"h.ts".to_string(),
module(":modules:1:1:a:1:b"),
)])),
);
// Byte-identical to the victim's without the seal: the forger's `base` spells out the
// victim's leading block, leaving its own single module to supply the tail.
let forged = artifact_cache_name(
"V:modules:1:4:h.ts:18:".to_string(),
Some(&HashMap::from([("a".to_string(), module("b"))])),
);
assert_ne!(victim, forged);
}
/// Deploy fills a module's lock in after the parent has prebuilt, so a name that moved
/// with it would leave every prebuilt artifact unreachable by the runs it was built for.
#[test]
fn artifact_name_ignores_the_lock_deploy_fills_in_later() {
let prebuild = artifact_cache_name(
"base".to_string(),
Some(&HashMap::from([("h.ts".to_string(), module("x"))])),
);
let mut locked = module("x");
locked.lock = Some("{}\n//bun.lock\n<empty>".to_string());
let after_deploy = artifact_cache_name(
"base".to_string(),
Some(&HashMap::from([("h.ts".to_string(), locked)])),
);
assert_eq!(prebuild, after_deploy);
}
#[test]
fn contained_relative_path_rejects_traversal_and_absolute() {
assert!(is_contained_relative_path("u/admin/pkg"));
@@ -6380,6 +6528,7 @@ mount {{
envs,
occupancy_metrics,
maybe_lock,
modules.as_ref(),
))
.await
}
@@ -6509,6 +6658,7 @@ mount {{
worker_name,
envs,
occupancy_metrics,
modules.as_ref(),
))
.await
}
@@ -6567,6 +6717,7 @@ mount {{
worker_name,
envs,
occupancy_metrics,
modules.as_ref(),
))
.await
}
@@ -6631,6 +6782,7 @@ mount {{
worker_name,
envs,
occupancy_metrics,
modules: modules.as_ref(),
}))
.await
}
+22 -8
View File
@@ -70,9 +70,9 @@ use windmill_queue::schedule::get_schedule_opt;
use windmill_queue::{
add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job,
insert_concurrency_key_capped, interpolate_args,
report_error_to_workspace_handler_or_critical_side_channel, try_schedule_next_job, CanceledBy,
FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload,
WrappedError,
report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args,
try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs,
PushIsolationLevel, SameWorkerPayload, WrappedError,
};
use windmill_audit::audit_oss::audit_log;
@@ -4403,6 +4403,23 @@ async fn push_next_flow_job(
payload_tag.tag.as_deref(),
);
// `push_args` is empty once the input transforms failed, so a tag reading `$args[...]`
// interpolates to a queue nobody serves and the step sits there instead of reporting
// the error. Send it to the flow's tag, which a worker is provably serving right now.
//
// A step handed over by id, or one whose tag `push` replaces, never reaches a worker
// through its tag, so rewriting theirs would be noise.
let step_is_pulled_by_tag = !continue_on_same_worker
&& !continue_with_runners
&& !payload_tag.payload.is_dedicated_worker();
let reroute_to_flow_tag =
err.is_some() && step_is_pulled_by_tag && tag.as_deref().is_some_and(tag_reads_args);
let tag = if reroute_to_flow_tag {
Some(flow_job.tag.clone())
} else {
tag
};
let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref()
{
(&on_behalf_of.email, on_behalf_of.permissioned_as.clone())
@@ -4421,8 +4438,7 @@ async fn push_next_flow_job(
.as_deref()
.filter(|t| !t.is_empty() && *t != flow_job.tag.as_str())
{
let is_super_admin =
windmill_common::auth::is_super_admin_email(db, email).await?;
let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?;
check_tag_available_for_workspace_internal(
db,
&flow_job.workspace_id,
@@ -6155,9 +6171,7 @@ pub async fn script_to_payload(
.await?
.prefetch_cached(&db)
.await?;
let on_behalf_of = script_info
.on_behalf_of(&flow_job.workspace_id, db)
.await?;
let on_behalf_of = script_info.on_behalf_of(&flow_job.workspace_id, db).await?;
let ScriptHashInfo {
tag,
cache_ttl,
@@ -199,6 +199,7 @@ async fn handle_build_binary_job(
worker_name,
base_internal_url,
occupancy_metrics,
script_data.modules.as_ref(),
)
.await?
}
@@ -214,6 +215,7 @@ async fn handle_build_binary_job(
worker_name,
base_internal_url,
occupancy_metrics,
script_data.modules.as_ref(),
)
.await?
}
@@ -235,6 +237,7 @@ async fn handle_build_binary_job(
worker_name,
base_internal_url,
occupancy_metrics,
script_data.modules.as_ref(),
)
.await?
}
@@ -3155,7 +3158,11 @@ async fn capture_dependency_job(
)
.await?
{
if !wd_exist {
// Nothing here writes the module files, so a bundle built now resolves a
// multi-file script's relative imports remotely rather than from its
// modules; caching that under a key naming them would serve the wrong
// code to every run. Leave it to the first run instead.
if !wd_exist && modules.map_or(true, |m| m.is_empty()) {
crate::bun_executor::prebundle_bun_script(
job_raw_code,
&lock,
@@ -3169,6 +3176,7 @@ async fn capture_dependency_job(
&token,
&mut Some(occupancy_metrics),
temp_script_refs,
modules,
)
.await?;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.796.0";
export const VERSION = "v1.799.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+8 -1
View File
@@ -191,7 +191,14 @@ function createSveltePlugin(appDir: string): any {
// Convert Svelte syntax to JavaScript
try {
const { js, warnings } = svelte.compile(source, { filename });
// The raw-app editor's in-browser bundler compiles with
// `css: "injected"`, so this must too, or the same app renders
// styled there and unstyled once the CLI builds it: Svelte's default
// ("external") hands the <style> back on a `css` field nothing emits.
const { js, warnings } = svelte.compile(source, {
filename,
css: "injected",
});
const contents = js.code + `//# sourceMappingURL=` + js.map.toUrl();
return { contents, warnings: warnings.map(convertMessage) };
} catch (e: any) {
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.796.0";
export const VERSION = "1.799.0";
+60 -24
View File
@@ -848,31 +848,35 @@ async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined
/**
* Sign S3 objects to be used by anonymous users in public apps
* @param s3objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 objects
*/
async signS3Objects(s3objects: S3Object[]): Promise<S3Object[]>
async signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>
/**
* Sign S3 object to be used by anonymous users in public apps
* @param s3object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 object
*/
async signS3Object(s3object: S3Object): Promise<S3Object>
async signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>
/**
* Generate a presigned public URL for an array of S3 objects.
* If an S3 object is not signed yet, it will be signed first.
* @param s3Objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns list of signed public URLs
*/
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise<string[]>
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>
/**
* Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.
* @param s3Object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed public URL
*/
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise<string>
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>
/**
* Get URLs needed for resuming a flow after this step
@@ -1631,31 +1635,35 @@ async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined
/**
* Sign S3 objects to be used by anonymous users in public apps
* @param s3objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 objects
*/
async signS3Objects(s3objects: S3Object[]): Promise<S3Object[]>
async signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>
/**
* Sign S3 object to be used by anonymous users in public apps
* @param s3object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 object
*/
async signS3Object(s3object: S3Object): Promise<S3Object>
async signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>
/**
* Generate a presigned public URL for an array of S3 objects.
* If an S3 object is not signed yet, it will be signed first.
* @param s3Objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns list of signed public URLs
*/
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise<string[]>
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>
/**
* Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.
* @param s3Object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed public URL
*/
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise<string>
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>
/**
* Get URLs needed for resuming a flow after this step
@@ -2508,31 +2516,35 @@ async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined
/**
* Sign S3 objects to be used by anonymous users in public apps
* @param s3objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 objects
*/
async signS3Objects(s3objects: S3Object[]): Promise<S3Object[]>
async signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>
/**
* Sign S3 object to be used by anonymous users in public apps
* @param s3object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed s3 object
*/
async signS3Object(s3object: S3Object): Promise<S3Object>
async signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>
/**
* Generate a presigned public URL for an array of S3 objects.
* If an S3 object is not signed yet, it will be signed first.
* @param s3Objects s3 objects to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns list of signed public URLs
*/
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise<string[]>
async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>
/**
* Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.
* @param s3Object s3 object to sign
* @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])
* @returns signed public URL
*/
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise<string>
async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>
/**
* Get URLs needed for resuming a flow after this step
@@ -4225,19 +4237,23 @@ def delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = No
#
# Args:
# s3_objects: List of S3 objects to sign
# expiry_secs: How long the signature stays valid, in seconds
# (defaults to 43200 = 12h, clamped to [60, 604800])
#
# Returns:
# List of signed S3 objects
def sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]
def sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]
# Sign a single S3 object for use by anonymous users in public apps.
#
# Args:
# s3_object: S3 object to sign
# expiry_secs: How long the signature stays valid, in seconds
# (defaults to 43200 = 12h, clamped to [60, 604800])
#
# Returns:
# Signed S3 object
def sign_s3_object(s3_object: S3Object | str) -> S3Object
def sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object
# Generate presigned public URLs for an array of S3 objects.
# If an S3 object is not signed yet, it will be signed first.
@@ -4245,6 +4261,8 @@ def sign_s3_object(s3_object: S3Object | str) -> S3Object
# Args:
# s3_objects: List of S3 objects to sign
# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)
# expiry_secs: How long the signatures stay valid, in seconds
# (defaults to 43200 = 12h, clamped to [60, 604800])
#
# Returns:
# List of signed public URLs
@@ -4252,7 +4270,7 @@ def sign_s3_object(s3_object: S3Object | str) -> S3Object
# Example:
# >>> s3_objs = [S3Object(s3="/path/to/file1.txt"), S3Object(s3="/path/to/file2.txt")]
# >>> urls = client.get_presigned_s3_public_urls(s3_objs)
def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None) -> list[str]
def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]
# Generate a presigned public URL for an S3 object.
# If the S3 object is not signed yet, it will be signed first.
@@ -4260,6 +4278,8 @@ def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str
# Args:
# s3_object: S3 object to sign
# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)
# expiry_secs: How long the signature stays valid, in seconds
# (defaults to 43200 = 12h, clamped to [60, 604800])
#
# Returns:
# Signed public URL
@@ -4267,7 +4287,7 @@ def get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str
# Example:
# >>> s3_obj = S3Object(s3="/path/to/file.txt")
# >>> url = client.get_presigned_s3_public_url(s3_obj)
def get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None) -> str
def get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str
# Get the current user information.
#
@@ -7915,7 +7935,9 @@ properties:
at once (1-65535)
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8365,7 +8387,9 @@ properties:
as JSON
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8532,7 +8556,9 @@ properties:
endpoint.
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8656,7 +8682,9 @@ properties:
- v5
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8752,7 +8780,9 @@ properties:
description: Array of NATS subjects to subscribe to
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8842,7 +8872,9 @@ properties:
description: Name of the PostgreSQL logical replication slot to use
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -9089,7 +9121,9 @@ properties:
description: Array of SQS message attribute names to include with each message
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -9288,7 +9322,9 @@ properties:
The extracted value replaces {{state}} in the heartbeat message.
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -1,8 +1,5 @@
/**
* `lib.svelte.ts` / `lib.svelte.js` modules are plain modules that may use
* runes. They need `svelte.compileModule`; without it esbuild happily bundles
* `$state(...)` as an ordinary call and the app dies at runtime with
* "ReferenceError: $state is not defined".
* The svelte esbuild plugin, driven through `createBundle`.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
@@ -59,6 +56,12 @@ afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
/**
* `lib.svelte.ts` / `lib.svelte.js` modules are plain modules that may use
* runes. They need `svelte.compileModule`; without it esbuild happily bundles
* `$state(...)` as an ordinary call and the app dies at runtime with
* "ReferenceError: $state is not defined".
*/
describe("svelte plugin: .svelte.ts modules", () => {
test("compiles runes in a TypeScript rune module and the bundle runs", async () => {
writeApp({
@@ -116,3 +119,36 @@ bump();
expect((globalThis as any).__counterResult).toBe(2);
});
});
/**
* Svelte's default `css: "external"` hands a component's <style> back on a
* field the plugin never emits, so the markup keeps its `svelte-<hash>` class
* while the rule matching it disappears no build error, just an app that
* renders unstyled from the CLI and styled in the editor.
*/
describe("svelte plugin: component styles", () => {
test("a <style> block reaches the bundle under the class its markup carries", async () => {
writeApp({
"Styled.svelte": `<main>
<h1>Hello</h1>
</main>
<style>
h1 {
font-size: 1.5rem;
}
</style>
`,
"styles_entry.ts": `import Styled from './Styled.svelte';
export default Styled;
`,
});
const js = await bundle("styles_entry.ts");
const scopeClass = js.match(/<h1 class="(svelte-[a-z0-9]+)"/)?.[1];
expect(scopeClass).toBeDefined();
expect(js).toContain(`h1.${scopeClass}`);
expect(js).toContain("font-size");
});
});
+6
View File
@@ -10,6 +10,12 @@
* bear the startup cost inside their per-test timeout window.
*/
// esbuild's node API pins the cwd it spawns its service with to process.cwd() at
// module import, and test files chdir into temp dirs they later delete. Import it
// here, from a cwd that outlives the run, or the first file to bundle pins the
// service to a directory that stops existing and every later build fails ENOENT.
import "esbuild";
if (process.env["UNIT_ONLY"]) {
// Nothing to do — unit tests don't need backend setup
} else {
+45 -5
View File
@@ -1651,8 +1651,17 @@ export class DebugSession {
try {
await this.startBunProcess(cwd)
} catch (error) {
this.sendEvent('output', { category: 'stderr', output: `Failed to start Bun: ${error}\n` })
this.sendEvent('terminated', { error: String(error) })
// A launch failure is reported here, a finished script from onclose; whichever gets
// there first owns the terminated event, so a client that already has a result is
// never told afterwards that the launch failed.
if (!this.terminatedSent) {
this.terminatedSent = true
this.sendEvent('output', { category: 'stderr', output: `Failed to start Bun: ${error}\n` })
this.sendEvent('terminated', { error: String(error) })
}
// --inspect-wait blocks until a debugger attaches, so a bun we failed to attach to
// waits forever unless it is reaped here.
await this.cleanup()
}
}
@@ -1956,9 +1965,12 @@ export class DebugSession {
const text = decoder.decode(value)
buffer += text
// Look for the WebSocket URL in Bun's inspector output
// Format: "ws://127.0.0.1:9229/xxxxx"
const wsMatch = buffer.match(/ws:\/\/[\d.]+:\d+\/[a-z0-9]+/i)
// Look for the WebSocket URL in Bun's inspector banner, e.g.
// " ws://127.0.0.1:9229/848c719d-a52e-4610-8e94-99cd60f34af9".
// The token's alphabet is Bun's to change (it became a hyphenated UUID in 1.4), so
// take the whole path, and only once whitespace proves it complete: a stderr chunk
// can end mid-URL, and connecting to a truncated path gets a 404 from the inspector.
const wsMatch = buffer.match(/ws:\/\/[\d.]+:\d+\/\S+(?=\s)/)
if (wsMatch && this.inspectorWsUrlPromise) {
const wsUrl = wsMatch[0]
logger.info(`Found inspector WebSocket URL in stderr: ${wsUrl}`)
@@ -1988,12 +2000,22 @@ export class DebugSession {
return new Promise((resolve, reject) => {
this.inspectorWs = new WebSocket(wsUrl)
// A close before the script is running is a failed connection, not a finished script,
// and the two are reported to the client in opposite ways. The socket opening is not
// the line: the setup commands below run over an open socket and none of them reject
// when it drops (sendInspectorCommand only has its own timer), so a drop mid-setup
// would otherwise be indistinguishable from a clean exit.
let opened = false
let executionStarted = false
let handshakeError: string | null = null
const timeout = setTimeout(() => {
reject(new Error('Inspector connection timeout'))
}, 5000)
this.inspectorWs.onopen = async () => {
clearTimeout(timeout)
opened = true
logger.info('Connected to inspector')
try {
@@ -2030,6 +2052,11 @@ export class DebugSession {
logger.info('Starting script execution with Inspector.initialized...')
await this.sendInspectorCommand('Inspector.initialized', {})
// Only past its reply is a later close a finished script rather than a lost
// connection. The reply precedes any close on this socket, so the continuation
// runs first and a real run is never misread as a failure.
executionStarted = true
resolve()
} catch (error) {
reject(error)
@@ -2042,12 +2069,25 @@ export class DebugSession {
this.inspectorWs.onerror = (error) => {
logger.error('Inspector WebSocket error:', error)
if (!opened) {
handshakeError = (error as ErrorEvent)?.message || String(error)
}
}
this.inspectorWs.onclose = () => {
logger.info('Inspector WebSocket closed')
this.inspectorWs = null
if (!executionStarted) {
clearTimeout(timeout)
reject(
new Error(
`Inspector connection failed: ${handshakeError ?? (opened ? 'closed before setup completed' : 'closed before the handshake completed')}`
)
)
return
}
// When inspector closes, the script has ended - send terminated event
if (!this.terminatedSent) {
this.terminatedSent = true
+15 -2
View File
@@ -8,15 +8,28 @@ x-logging: &default-logging
compress: "true"
services:
## UPGRADING FROM POSTGRES 16: db_data holds a cluster 18 cannot read, so the
## container exits with an explanatory error rather than coming up blank. Migrating
## means dumping the WHOLE cluster (pg_dumpall), never just the windmill database:
## Windmill keeps datatable, DuckLake and wm_fork_* databases beside it and grants
## its RLS policies to cluster-level roles, and a single-database dump loses both
## silently. Full procedure, and why 16 is still a valid choice until Nov 2028:
## https://www.windmill.dev/docs/advanced/self_host#upgrade-postgresql-to-18
db:
deploy:
# To use an external database, set replicas to 0 and set DATABASE_URL to the external database url in the .env file
replicas: 1
image: postgres:16
image: postgres:18
shm_size: 1g
restart: unless-stopped
volumes:
- db_data:/var/lib/postgresql/data
# From 18 on the official image keeps the cluster in a major-version
# subdirectory (/var/lib/postgresql/18/docker), so the mount has to be the
# parent directory: that is what lets pg_upgrade see an old and a new
# cluster inside a single mount point. Mounting the pre-18 .../data path
# instead makes the image exit rather than start, which is what turns a
# stale 16 cluster into a loud failure instead of an empty instance.
- db_data:/var/lib/postgresql
expose:
- 5432
environment:
+24 -11
View File
@@ -206,7 +206,11 @@ class DAPTestClient {
private events: DAPMessage[] = []
private output: string[] = []
private result: unknown = undefined
private eventHandlers = new Map<string, ((event: DAPMessage) => void)[]>()
private eventWaiters = new Map<string, ((event: DAPMessage) => void)[]>()
// An event that arrives before its waiter is registered is queued rather than dropped: the
// server sends 'initialized' right behind the 'initialize' response, and 'terminated' can
// land before the launch call the test awaits has even returned.
private bufferedEvents = new Map<string, DAPMessage[]>()
async connect(endpoint: string): Promise<void> {
const url = `ws://${HOST}:${DEBUGGER_PORT}${endpoint}`
@@ -273,9 +277,13 @@ class DAPTestClient {
this.result = msg.body.result
}
const handlers = this.eventHandlers.get(msg.event!) || []
for (const handler of handlers) {
handler(msg)
const waiters = this.eventWaiters.get(msg.event!)
if (waiters && waiters.length > 0) {
waiters.shift()!(msg)
} else {
const buffered = this.bufferedEvents.get(msg.event!) || []
buffered.push(msg)
this.bufferedEvents.set(msg.event!, buffered)
}
}
} catch {
@@ -313,23 +321,27 @@ class DAPTestClient {
}
waitForEvent(eventName: string, timeout = 10000): Promise<DAPMessage> {
const buffered = this.bufferedEvents.get(eventName)
if (buffered && buffered.length > 0) {
return Promise.resolve(buffered.shift()!)
}
return new Promise((resolve, reject) => {
const waiters = this.eventWaiters.get(eventName) || []
this.eventWaiters.set(eventName, waiters)
const timer = setTimeout(() => {
const idx = waiters.indexOf(handler)
if (idx >= 0) waiters.splice(idx, 1)
reject(new Error(`Timeout waiting for event: ${eventName}`))
}, timeout)
const handler = (event: DAPMessage) => {
clearTimeout(timer)
const handlers = this.eventHandlers.get(eventName) || []
const idx = handlers.indexOf(handler)
if (idx >= 0) handlers.splice(idx, 1)
resolve(event)
}
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, [])
}
this.eventHandlers.get(eventName)!.push(handler)
waiters.push(handler)
})
}
@@ -379,6 +391,7 @@ class DAPTestClient {
this.output = []
this.result = undefined
this.events = []
this.bufferedEvents.clear()
}
}
+4 -4
View File
@@ -4,10 +4,10 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`,
`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`,
`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the
opportunity to change that.
It currently carries 28 registered actions across fourteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `flow_editor`, `flow_run`, `flow_step`, `run_form`,
`debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+6 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.796.0",
"version": "1.799.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.796.0",
"version": "1.799.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -29,6 +29,7 @@
"@windmill-labs/svelte-dnd-action": "^0.9.44",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^1.0.0",
"acorn": "^8.15.0",
"ag-charts-community": "^9.0.1",
"ag-charts-enterprise": "^9.0.1",
"ag-grid-community": "^31.3.4",
@@ -13195,9 +13196,9 @@
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"optional": true,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.796.0",
"version": "1.799.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -105,6 +105,7 @@
"@windmill-labs/svelte-dnd-action": "^0.9.44",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/svelte": "^1.0.0",
"acorn": "^8.15.0",
"ag-charts-community": "^9.0.1",
"ag-charts-enterprise": "^9.0.1",
"ag-grid-community": "^31.3.4",
@@ -6,7 +6,7 @@
import DrawerContent from './common/drawer/DrawerContent.svelte'
import AppConnectInner from './AppConnectInner.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import GoogleSigninButton from './GoogleSigninButton.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import { addResourceTitle } from './resourceTypeDisplay'
@@ -22,6 +22,10 @@
disableChatOffset = false
}: Props = $props()
/** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a
* property of the click, and a prop would go stale between two different rows. */
let fillPath: string | undefined = $state(undefined)
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
let step = $state(1)
@@ -32,29 +36,46 @@
let appConnectInner: AppConnectInner | undefined = $state(undefined)
let rtToLoad: string | undefined = $state('')
export async function open(rt?: string) {
/** `fill` connects into a resource that already exists, instead of creating one. */
export async function open(rt?: string, fill?: string) {
fillPath = fill
handedOff = false
rtToLoad = rt
drawer?.openDrawer?.()
}
/**
* Hand off to the inner component exactly once per opening. The reactive statement below
* re-runs both when `rtToLoad` changes and when `appConnectInner` binds — and it binds
* afresh on every opening, since the drawer destroys its content on close. A second
* `open()` runs `next()` a second time, which walks a drawer opened on a resource type
* straight past the Connect button and into `window.open`; a popup opened from a reactive
* effect rather than from the click is blocked, leaving "Finish connection in popup
* window" with no popup behind it.
*
* A flag rather than the last resource type: `open()` with no argument leaves `rtToLoad`
* undefined, which compares equal to the initial state and would skip the hand-off
* entirely — the resources page opens it that way.
*/
let handedOff = false
function onRtToLoadChange(rtToLoad: string | undefined) {
if (handedOff) return
handedOff = true
appConnectInner?.open(rtToLoad)
}
const dispatch = createEventDispatcher()
let darkMode: boolean = $state(false)
run(() => {
appConnectInner && onRtToLoadChange(rtToLoad)
})
</script>
<DarkModeObserver bind:darkMode />
<Drawer
bind:this={drawer}
on:close={() => {
step = 1
handedOff = false
dispatch('close')
}}
size="700px"
@@ -83,22 +104,26 @@
on:refresh
express={expressOAuthSetup}
{workspace}
{fillPath}
/>
{#snippet actions()}
<div class="flex gap-1">
{#if step > 1}
<Button variant="default" on:click={appConnectInner?.back ?? (() => {})}>Back</Button>
<!-- Only when the user came through the type picker: opening the drawer for one
resource type skips step 1, so Back would land on a list they never chose from. -->
{#if step > 1 && !rtToLoad}
<Button variant="default" unifiedSize="md" onClick={() => appConnectInner?.back()}>
Back
</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} onclick={appConnectInner?.next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
alt="Google sign-in"
/>
</button>
<GoogleSigninButton {disabled} onClick={() => appConnectInner?.next()} />
{:else}
<Button variant="accent" {disabled} on:click={appConnectInner?.next ?? (() => {})}>
<Button
variant="accent"
unifiedSize="md"
{disabled}
onClick={() => appConnectInner?.next()}
>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
@@ -20,7 +20,7 @@
type ResourceType
} from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils'
import oauthConnectRegistry from '$oauth_connect_registry'
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
import Path from './Path.svelte'
import { Button, RadioCard, Skeleton } from './common'
@@ -52,6 +52,15 @@
manual?: boolean
express?: boolean
workspace?: string
/**
* Fill an existing resource instead of creating one. The path is fixed to it and the
* "already exists" guard becomes an update, so a caller holding a resource that is
* already there — the import wizard's empty stubs — can connect into it rather than
* making the user delete it first and retype the path.
*
* Opt-in: without it this flow still refuses to write over anything.
*/
fillPath?: string
}
let {
@@ -61,7 +70,8 @@
disabled = $bindable(false),
manual = $bindable(true),
express = false,
workspace = undefined
workspace = undefined,
fillPath = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
@@ -98,10 +108,6 @@
return connectsInfo[key]?.has_shared_credentials ?? false
}
const SANDBOX_SUFFIX = '_sandbox'
function stripSandboxSuffix(name: string): string {
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
}
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
// rows are uniform. `connectClient` carries the suffixed OAuth client name
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
@@ -195,16 +201,16 @@
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
let resourceTypeNotFound = $state(false)
// Both resolve `_sandbox` clients to their parent entry (e.g. salesforce_sandbox ->
// salesforce) so sandbox connections see the same metadata. Shared with callers that
// decide whether to open this dialog at all, so the two cannot disagree.
function registryEntry(): any {
const reg = oauthConnectRegistry as Record<string, any>
// Resolve `_sandbox` clients to their parent registry entry (e.g.
// salesforce_sandbox -> salesforce) so sandbox connections see CC metadata.
return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)]
return registryEntryFor(connectClient, resourceType)
}
/** The static registry declares this provider supports client credentials */
function registryCcCapable(): boolean {
return registryEntry()?.grant_types?.includes('client_credentials') ?? false
return registryCcCapableFor(connectClient, resourceType)
}
/** Instance-name metadata for providers whose token URL is instance-templated
@@ -289,11 +295,7 @@
/** Static registry declares client-credentials support for `key`. */
function isCcCapable(key: string): boolean {
return (
(oauthConnectRegistry as Record<string, any>)[stripSandboxSuffix(key)]?.grant_types?.includes(
'client_credentials'
) ?? false
)
return registryCcCapableFor(key)
}
/** Step-1 "Others" selection: CC-capable resource types open the client-
@@ -553,8 +555,9 @@
valueToken = data.res
responseExtra = data.extra ?? {}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
// `fillPath` decides the path as surely as express does, so neither stops here.
if (fillPath || express) {
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
}
@@ -689,8 +692,8 @@
grant_type: 'client_credentials' // Mark this token as client_credentials
}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
if (fillPath || express) {
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
} catch (error) {
@@ -749,7 +752,41 @@
path
})
if (exists) {
// Filling one names its path up front; anything else reaching an occupied path got
// there by the user typing it, which is the case worth refusing.
//
// The type is checked here and not only by the caller: `fillPath` says "write into
// this path", and a path says nothing about what lives at it. A workspace resource
// of another type sitting where the project wanted one of ours would otherwise have
// its value replaced with credentials for a different provider, while keeping its
// own type — destroying a working resource that has nothing to do with the import.
const filling = exists && !!fillPath && path === fillPath
if (filling) {
// Fails closed. Only a read that succeeds and answers with exactly this type
// permits the write — a failed read, a missing type, or any other type all
// refuse. Letting "could not tell" through is how the overwrite this guard
// exists to stop would happen anyway, on the one occasion the check was needed
// and could not run.
let occupantType: string | undefined
try {
occupantType = (
await ResourceService.getResource({ workspace: effectiveWorkspace, path })
)?.resource_type
} catch (e: any) {
throw Error(
`Could not read what is already at ${path} (${e?.body ?? e?.message ?? e}), ` +
`so it will not be written over. Try again.`
)
}
if (occupantType !== resourceType) {
throw Error(
`Resource at path ${path} is ${
occupantType ? `a ${occupantType} resource` : 'of an unknown type'
}, not ${resourceType}. Move or rename it, then import again.`
)
}
}
if (exists && !filling) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
@@ -760,8 +797,7 @@
// the user entered in `ccInstance` (raw, possibly a full host); the shared
// path carries it (already normalized) in the connect entry's extra_params.
// Prefer the user-entered one so the saved resource matches the exchange.
const connectTemplate = (oauthConnectRegistry as Record<string, any>)[resourceType]
?.connect_config_template
const connectTemplate = registryEntryFor(resourceType)?.connect_config_template
if (connectTemplate?.resource_mapping) {
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
@@ -895,17 +931,27 @@
}
}
await ResourceService.createResource({
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
if (filling) {
// The stub the import made carries no description, so this is the one chance to
// give it one; its resource_type and path are already what we want.
await ResourceService.updateResource({
workspace: effectiveWorkspace,
path,
value: resourceValue,
description,
labels,
ws_specific: wsSpecific
}
})
requestBody: { value: resourceValue, description }
})
} else {
await ResourceService.createResource({
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
value: resourceValue,
description,
labels,
ws_specific: wsSpecific
}
})
}
dispatch('refresh', path)
dispatch('close')
sendUserToast(
@@ -1395,7 +1441,13 @@
{/if}
</div>
{:else}
<div class="flex flex-col gap-2 mb-2">
<!-- role=radiogroup: the cards below carry `role="radio"`, which a screen
reader can only place ("2 of 2") inside a named group. -->
<div
class="flex flex-col gap-2 mb-2"
role="radiogroup"
aria-label="How to authenticate"
>
<RadioCard
label={`Sign in through ${resourceType}`}
description="Opens a browser window to log in and authorize. Connects as you."
@@ -2,6 +2,7 @@
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import GoogleSigninButton from '$lib/components/GoogleSigninButton.svelte'
import { workspaceStore } from '$lib/stores'
import { onMount, untrack } from 'svelte'
@@ -16,6 +17,7 @@
let step = $state(1)
let disabled = $state(false)
let manual = $state(true)
let isGoogleSignin = $state(false)
let appConnect: AppConnectInner | undefined = $state(undefined)
@@ -41,18 +43,24 @@
<div class="flex flex-row-reverse w-full pb-2 shrink-0">
<div class="flex gap-2">
{#if step > 2}
<Button variant="default" on:click={appConnect?.back ?? (() => {})}>Back</Button>
<Button variant="default" unifiedSize="md" onClick={() => appConnect?.back()}>
Back
</Button>
{/if}
<Button variant="accent" {disabled} on:click={appConnect?.next ?? (() => {})}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{#if isGoogleSignin}
<GoogleSigninButton {disabled} onClick={() => appConnect?.next()} />
{:else}
<Button variant="accent" unifiedSize="md" {disabled} onClick={() => appConnect?.next()}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{/if}
</div>
</div>
{/if}
@@ -64,6 +72,7 @@
bind:resourceType
bind:disabled
bind:manual
bind:isGoogleSignin
on:error
on:refresh
/>
@@ -6,6 +6,9 @@
interface Props {
subtitle?: string | undefined
/** Rendered under the title, for a subtitle that needs markup (a link, say).
* Sits below `subtitle` when both are given. */
subtitleSnippet?: import('svelte').Snippet
title?: string
large?: boolean
centerVertically?: boolean
@@ -16,6 +19,7 @@
let {
subtitle = undefined,
subtitleSnippet = undefined,
title = 'Windmill',
large = false,
centerVertically = true,
@@ -60,6 +64,9 @@
{subtitle}
</p>
{/if}
{#if subtitleSnippet}
<div class="text-center mt-2">{@render subtitleSnippet()}</div>
{/if}
</div>
{#if children}
@@ -48,6 +48,9 @@
// migrations, its changes flow through the normal item diff instead, so it is
// excluded here and the whole section hides once none remain.
let applicableCount = $state(0)
let applicableLabel = $derived(
applicableCount === 1 ? 'this data table has' : 'these data tables have'
)
let expandedDatatables: Set<string> = $state(new Set())
// Drawer state
@@ -293,6 +296,12 @@
{#if applicableCount > 0}
<div class="bg-surface-tertiary p-4 rounded-md border">
<h3 class="text-sm font-semibold">Datatable schema changes</h3>
<Alert type="warning" size="xs" title="Legacy schema comparison" class="mt-2">
This section diffs data table schemas directly because {applicableLabel} not opted in to migrations.
With migrations enabled, schema changes are tracked as migrations and deployed like any other item.
Opt in from the parent workspace's data table settings — a fork only inherits the flag when it
is created, so this fork keeps using the legacy diff.
</Alert>
{#if loading}
<div class="flex items-center gap-2 text-xs text-tertiary py-2">
<Loader2 class="w-4 h-4 animate-spin" /> Loading datatable diffs...
+2 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import SchemaForm from '$lib/components/SchemaForm.svelte'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import JobLoader from '$lib/components/JobLoader.svelte'
import { Button } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
@@ -161,7 +162,6 @@
let args: Record<string, any> = $state({})
let isValid: boolean = $state(true)
let jsonView: boolean = $state(false)
let jsonEditor: JsonInputs | undefined = $state(undefined)
let schemaHeight = $state(0)
// Test
@@ -1171,20 +1171,17 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={() => {
jsonEditor?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 300)}px">
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
args = e.detail
}
}}
initialCode={argsToJsonPayload(schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
@@ -15,6 +15,7 @@
import PropertyEditor from './schema/PropertyEditor.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { watch } from 'runed'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Label from './Label.svelte'
@@ -173,6 +174,11 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
watch(
() => ws,
() => itemPicker?.reloadItems()
)
let keys: string[] = $state(
(Array.isArray(schema?.order)
? [...schema.order]
@@ -34,6 +34,8 @@
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import Toggle from './Toggle.svelte'
import JsonInputs from './JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import type { Schema } from '$lib/common'
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
import type { DurationStatus, GraphModuleState } from './graph'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
@@ -264,8 +266,12 @@
previewArgs.val = input
inputSelected = type
preventEscape = true
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
}
// Deselecting restores the args the same way selecting replaced them, so both branches
// owe the editor an overwrite — it holds a payload for the input being left behind.
jsonEditor?.setCode(
argsToJsonPayload(flowStore.val.schema as Schema | undefined, previewArgs.val)
)
}
export function refresh() {
@@ -510,8 +516,7 @@
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
jsonEditor?.setCode(JSON.stringify(previewArgs.val ?? {}, null, '\t'))
on:change={() => {
refresh()
}}
/>
@@ -526,6 +531,10 @@
previewArgs.val = e.detail
}
}}
initialCode={argsToJsonPayload(
flowStore.val.schema as Schema | undefined,
previewArgs.val
)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
+51 -18
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FolderService } from '$lib/gen'
import { FolderService, UserService, type User } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
@@ -14,10 +14,6 @@
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
const restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let folders: { name: string; write: boolean }[] = $state([])
let filterText: string = $state('')
let selectOpen: boolean = $state(false)
@@ -38,6 +34,10 @@
size?: 'sm' | 'md'
drawerOffset?: number
selectInputClass?: string
/** List and create folders in this workspace instead of the active one. For a
* screen that targets a workspace it has not switched to — the project import
* wizard picks a destination and only enters it when the import runs. */
workspace?: string
}
let {
@@ -47,9 +47,24 @@
disableEditing = $bindable(undefined),
size = 'md',
drawerOffset = 0,
selectInputClass
selectInputClass,
workspace
}: Props = $props()
const targetWorkspace = $derived(workspace ?? $workspaceStore ?? '')
// `$userStore` describes the workspace the app is *in*. When this picker is aimed
// somewhere else, those memberships answer the wrong question — and since a folder
// without write access renders disabled, a stale answer makes the real folders
// unpickable. Resolve the membership for the workspace actually being listed.
let targetUser: User | undefined = $state(undefined)
const aimedElsewhere = $derived(!!workspace && workspace !== $workspaceStore)
const membership = $derived(aimedElsewhere ? targetUser : ($userStore ?? undefined))
const restricted = $derived(
isDemoWorkspaceRestricted(targetWorkspace, membership?.is_admin, membership?.is_super_admin)
)
async function loadFolders(): Promise<void> {
loadingFolders = true
try {
@@ -65,16 +80,16 @@
folders = initialFolders.concat(
(
await FolderService.listFolderNames({
workspace: $workspaceStore!
workspace: targetWorkspace
})
)
.filter((x) => !excludedFolders.includes(x))
.map((x) => ({
name: x,
write:
$userStore?.folders?.includes(x) == true ||
($userStore?.is_admin ?? false) ||
($userStore?.is_super_admin ?? false)
membership?.folders?.includes(x) == true ||
(membership?.is_admin ?? false) ||
(membership?.is_super_admin ?? false)
}))
)
} catch (e) {
@@ -97,20 +112,27 @@
creating = true
try {
await FolderService.createFolder({
workspace: $workspaceStore ?? '',
workspace: targetWorkspace,
requestBody: { name: newFolderName }
})
folderCreated = newFolderName
await loadFolders()
folderName = newFolderName
// Writing $userStore.folders = [...] would call userStore.set(),
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
// switching the owner toggle from "Folder" back to "User".
if ($userStore) {
// The creator owns what they just created. Recorded on whichever membership
// this picker is reading, and *before* reloading, so the new folder comes
// back selectable rather than `(read-only)` — `loadFolders` derives `write`
// from exactly this.
if (aimedElsewhere) {
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), newFolderName]
} else if ($userStore) {
// Writing $userStore.folders = [...] would call userStore.set(),
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
// switching the owner toggle from "Folder" back to "User".
if (!$userStore.folders) $userStore.folders = []
$userStore.folders.push(newFolderName)
}
await loadFolders()
folderName = newFolderName
} catch (e) {
sendUserToast(`Could not create folder: ${e}`, true)
} finally {
@@ -149,7 +171,18 @@
}
}
loadFolders()
async function loadTargetUser(): Promise<void> {
if (!workspace || workspace === $workspaceStore) return
try {
targetUser = await UserService.whoami({ workspace })
} catch {
// Not a member, or the call failed: every folder stays read-only, which is
// the safe reading — the import would be refused anyway.
targetUser = undefined
}
}
loadTargetUser().then(loadFolders)
</script>
<Drawer bind:this={newFolder} name="newFolder" offset={drawerOffset}>
@@ -55,6 +55,19 @@
)
)
// Org names that appear on more than one installation, so the dropdown can
// tell those entries apart.
let duplicatedAccountIds = $derived(
new Set(
githubState.workspaceGithubInstallations
.filter(
(installation, _, array) =>
array.filter((other) => other.account_id === installation.account_id).length > 1
)
.map((installation) => installation.account_id)
)
)
let showGitHubApp = $derived(
resourceType === 'git_repository' &&
$workspaceStore &&
@@ -205,29 +218,47 @@
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">GitHub Account ID</p>
<select bind:value={githubState.selectedGHAppAccountId}>
<option value="" disabled>Select GitHub Account ID</option>
<select
bind:value={githubState.selectedGHAppInstallationId}
onchange={() => (githubState.selectedGHAppRepository = undefined)}
>
<option value={undefined} disabled>Select GitHub Account ID</option>
{#each githubState.workspaceGithubInstallations as installation (`select-${installation.installation_id}-${installation.workspace_id}`)}
<option value={installation.account_id} disabled={!!installation.error}>
{installation.account_id}{installation.error ? ' (token error)' : ''}
{@const details = [
duplicatedAccountIds.has(installation.account_id)
? `${installation.installation_id}`
: undefined,
installation.error ? 'token error' : undefined
].filter(Boolean)}
<option
value={installation.installation_id}
disabled={!!installation.error}
>
{installation.account_id}{details.length
? ` (${details.join(', ')})`
: ''}
</option>
{/each}
</select>
</div>
{#if githubState.selectedGHAppAccountId}
{#if githubState.selectedGHAppInstallationId !== undefined}
{@const selectedInstallation = githubState.workspaceGithubInstallations.find(
(inst) => inst.account_id === githubState.selectedGHAppAccountId
(inst) => inst.installation_id === githubState.selectedGHAppInstallationId
)}
{#if selectedInstallation}
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">Repository</p>
<RepositorySelector
bind:selectedRepository={githubState.selectedGHAppRepository}
accountId={githubState.selectedGHAppAccountId}
initialRepositories={selectedInstallation.repositories}
totalCount={selectedInstallation.total_count}
perPage={selectedInstallation.per_page}
/>
<!-- RepositorySelector snapshots its repositories and page cursor at
mount, so switching installation has to remount it. -->
{#key selectedInstallation.installation_id}
<RepositorySelector
bind:selectedRepository={githubState.selectedGHAppRepository}
installationId={selectedInstallation.installation_id}
initialRepositories={selectedInstallation.repositories}
totalCount={selectedInstallation.total_count}
perPage={selectedInstallation.per_page}
/>
{/key}
</div>
{/if}
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { Button, type ButtonType } from '$lib/components/common'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
interface Props {
disabled?: boolean
unifiedSize?: ButtonType.UnifiedSize
onClick?: ((e?: Event) => void) | undefined
}
let { disabled = false, unifiedSize = 'md', onClick = undefined }: Props = $props()
</script>
<!-- developers.google.com/identity/branding-guidelines requires Google's own branding on the
control that starts a Google sign-in, and allows a custom button for it as long as the G
mark is unaltered and the label is one of theirs. So this is a design-system Button rather
than the PNG Google ships, which carried no hover, focus or disabled state and stood at a
different height than the buttons beside it. -->
<Button variant="default" {unifiedSize} {disabled} startIcon={{ icon: GoogleIcon }} {onClick}>
Sign in with Google
</Button>
@@ -0,0 +1,115 @@
<script lang="ts" module>
export interface ImportProjectSummary {
slug: string
name: string
summary: string
author: string
/** Integration slugs, for the chips and the fallback icons. */
apps: string[]
/** The project's uploaded logo, when it has one. */
logoUrl?: string
/** Integration slugs to draw, most representative first. */
iconApps: string[]
counts: { apps: number; flows: number; scripts: number; resources: number }
}
</script>
<script lang="ts">
import { ExternalLink, LayoutGrid } from 'lucide-svelte'
import ProjectContentBadges from '$lib/components/ProjectContentBadges.svelte'
import { hubAppIcon } from '$lib/hubProject'
interface Props {
project: ImportProjectSummary
/** Where the project is coming from, shown next to the author. */
hubHost?: string
}
let { project, hubHost = 'hub.windmill.dev' }: Props = $props()
// Protocol-relative on purpose: the same hub is https in production and plain
// http when it's a local dev instance, and this way the link follows whichever
// scheme the page itself was served over.
const hubProjectUrl = $derived(`//${hubHost}/projects/${project.slug}`)
// Resolved locally rather than fetched: these are Windmill's own bundled icons, so the
// card draws them synchronously instead of waiting on the hub — and keeps working on a
// hub that refuses uncredentialed reads.
const icons = $derived(
project.iconApps
.slice(0, 4)
.map(hubAppIcon)
.filter((c): c is NonNullable<typeof c> => !!c)
)
// The icon row shows the integrations the tile is not already showing: with an
// uploaded logo the tile shows none of them, so the row shows them all.
const restIcons = $derived(project.logoUrl ? icons : icons.slice(1))
</script>
<div class="mb-4 w-full">
<!-- No border: the subject of the page, not one card among the choices below,
which are the things with edges because they are selectable. -->
<div class="w-full">
<div class="flex items-start gap-3">
<!-- The project's own logo when it has one, otherwise the icon of the
integration it is filed under, otherwise a neutral placeholder. -->
<div class="flex h-12 w-12 shrink-0 items-center justify-center">
{#if project.logoUrl}
<img src={project.logoUrl} alt="" class="max-h-10 max-w-10 object-contain" />
{:else if icons[0]}
{@const Icon = icons[0]}
<span class="inline-flex h-7 w-7 text-primary [&>svg]:h-full [&>svg]:w-full">
<Icon size={28} />
</span>
{:else}
<LayoutGrid size={22} class="text-secondary" />
{/if}
</div>
<div class="min-w-0 flex-1">
<!-- The title carries the link back to the hub page, so the card can state
where the project comes from without spending a line on the URL. The
arrow only appears on hover — at rest the title reads as a title. -->
<a
href={hubProjectUrl}
target="_blank"
rel="noopener noreferrer"
class="group inline-flex max-w-full items-center gap-1 text-xs font-semibold text-emphasis hover:underline"
>
<span class="truncate">{project.name}</span>
<ExternalLink
size={13}
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
/>
</a>
<p class="mt-0.5 line-clamp-2 text-xs text-secondary">{project.summary}</p>
<p class="mt-1 text-xs text-tertiary">
by <span class="font-medium text-secondary">{project.author}</span>
· <span class="font-mono">{project.slug}</span>
</p>
<!-- What the import will create, aligned under the title rather than in a
band of its own: the counts belong to the project above them. -->
<div class="mt-3">
<ProjectContentBadges counts={project.counts} />
</div>
</div>
<!-- The integrations, minus whichever one is already standing in as the logo. -->
{#if restIcons.length > 0}
<div class="flex shrink-0 items-center gap-1.5 pt-0.5">
{#each restIcons as Icon, i (i)}
<span class="inline-flex h-4 w-4 text-primary opacity-80 [&>svg]:h-full [&>svg]:w-full">
<Icon size={16} />
</span>
{/each}
</div>
{/if}
</div>
</div>
<!-- The connector to what follows lives in the page, not here: what comes next is
either the new-workspace offer or the workspace list, and only the page knows
which. -->
</div>
@@ -0,0 +1,524 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import FolderPicker from '$lib/components/FolderPicker.svelte'
import { WorkspaceService } from '$lib/gen'
import { contentSummary } from '$lib/components/ProjectContentBadges.svelte'
import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle'
import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { ImportExecution, plannedTasks } from '$lib/importWizard/execution.svelte'
import SetupChecklist, { type SetupStep } from '$lib/components/wizards/SetupChecklist.svelte'
import { beforeNavigate, goto } from '$app/navigation'
import { untrack } from 'svelte'
import { FOLDER_NAME_RE, planProblem, type ImportPlan } from '$lib/importWizard/plan'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
import { ArrowLeft, Download, Loader2 } from 'lucide-svelte'
// The last step: it shows the plan, and it is the only step that can act on it.
// All the acting lives in ImportExecution — this file decides what the user sees
// and supplies the one interaction the run needs (the migration review).
interface Props {
plan: ImportPlan
/** From the hub, for the counts — the export is only fetched during the run. */
project?: ImportProjectSummary
onFolderChange: (folder: string) => void
onFinish: () => void
/** True once the run reveals data tables the destination has yet to configure. */
setupPending?: boolean
/** The page has not settled whether a setup step follows. Finishing now would skip it. */
setupUndecided?: boolean
/** Hands the run to the page, which needs the export's data tables to know
* whether a setup step follows this one. */
onExecution?: (execution: ImportExecution | undefined) => void
/**
* The run this step already made, handed back when it is remounted. Step 4 unmounts
* this component, so returning from it would otherwise arrive at a fresh step with no
* run — offering Import again over a bundle that is already in, and on a new
* workspace failing at create because the finished run cleared its parking.
*/
resume?: ImportExecution | undefined
onBack: () => void
}
let {
plan,
project,
onFolderChange,
onFinish,
onBack,
setupPending = false,
setupUndecided = false,
onExecution,
resume
}: Props = $props()
let folder = $state(plan.folder ?? plan.slug)
// The workspace the import will land in, when it is one that already exists. A
// `new` destination has no workspace to list folders from until the run creates it.
const existingWorkspace = $derived(
plan.destination?.kind === 'existing' ? plan.destination.workspaceId : undefined
)
// The picker binds `folder`, so there is no blur event to report on. Mirror every
// settled change into the plan. The page replaces rather than pushes for this
// (`go(..., { replace: true })`), because a mirrored field is not a step the Back
// button should have to walk through; a value equal to what the plan already holds
// is skipped so this cannot loop.
$effect(() => {
const next = folder.trim()
if (next && next !== (plan.folder ?? '')) onFolderChange(next)
})
const folderValid = $derived(FOLDER_NAME_RE.test(folder.trim()))
const problem = $derived(planProblem({ ...plan, folder: folder.trim() }))
// --- the run ---------------------------------------------------------------
let reviewDrawer = $state<Drawer | undefined>()
let reviewList = $state<
{ datatable_name: string; sql: string; sql_down: string; run: boolean }[]
>([])
// Bumped per review session so the Monaco editors re-mount with the new SQL.
let reviewGeneration = $state(0)
/** `abort` stops the whole import; `false` only skips the migrations. */
let reviewResolve: ((run: boolean | 'abort') => void) | undefined
function openMigrationReview(migs: ProjectMigration[]): Promise<boolean | 'abort'> {
reviewList = migs.map((m) => ({
datatable_name: m.datatable_name,
sql: m.sql,
sql_down: m.sql_down ?? '',
run: true
}))
reviewGeneration++
reviewDrawer?.openDrawer()
return new Promise<boolean | 'abort'>((resolve) => (reviewResolve = resolve))
}
function closeMigrationReview(run: boolean) {
// Capture + clear first so the `on:close` fired by closeDrawer() (which would
// call this again with run=false) can't override an explicit Run/Skip choice.
const resolve = reviewResolve
reviewResolve = undefined
reviewDrawer?.closeDrawer()
resolve?.(run)
}
/**
* Migrations are keyed by data table name and only apply to a target table of
* the same name. Returns what to run, or null to abort the import.
*/
async function reviewMigrations(
workspace: string,
migrations: ProjectMigration[]
): Promise<ProjectMigration[] | null> {
const enabled = migrations.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
if (enabled.length === 0) return []
let present: Set<string>
try {
present = new Set((await WorkspaceService.listDataTables({ workspace })).map((d) => d.name))
} catch {
// Can't read the target's data tables — skip migrations rather than guess.
return []
}
// A migration whose data table does not exist here is not a reason to stop: the
// setup step after this one configures those tables and runs them. Only the
// ones that can run now are worth reviewing.
const runnable = enabled.filter((m) => present.has(m.datatable_name))
if (runnable.length === 0) return []
const run = await openMigrationReview(runnable)
// `abort` is the teardown case: the step is gone, so stop rather than import the
// items without the tables the review was about.
if (run === 'abort') return null
if (!run) return []
return reviewList
.filter((r) => r.run && r.sql.trim() !== '')
.map((r) => ({
datatable_name: r.datatable_name,
sql: r.sql,
sql_down: r.sql_down,
enabled: true
}))
}
// One execution per plan, tagged with the plan it belongs to. Going back and
// choosing a different destination leaves the old run behind the tag rather than
// clearing it from an effect, so a previous run's outcome can never be shown
// against another plan. The folder is deliberately not part of the tag: it is
// pushed onto the existing run instead (see `start`).
// Seeded from the handed-back run under *its own* tag, so the `planKey` guard below
// still rejects it when the destination changed while this component was unmounted —
// tagging it with the current plan would make that check pass by construction and show
// one destination's finished checklist against another's plan.
// `untrack`, because this is a mount-time snapshot on purpose.
let run = $state<{ key: string; execution: ImportExecution } | undefined>(
untrack(() => (resume ? { key: resume.planTag, execution: resume } : undefined))
)
const planKey = $derived(JSON.stringify(plan.destination) + plan.slug)
const execution = $derived(run?.key === planKey ? run.execution : undefined)
$effect(() => onExecution?.(execution))
// What the import will bring, named on the row that brings it. Triggers and
// migrations only become known once the export is fetched, so the phrase grows
// mid-run rather than starting complete.
const importSummary = $derived(
project ? contentSummary({ ...project.counts, ...(execution?.extraCounts ?? {}) }) : ''
)
// The same rows before and during the run: the step states what it is about to do,
// and the run fills those rows in rather than replacing a paragraph with a list.
const tasks = $derived(execution?.tasks ?? plannedTasks(plan))
// `SetupStep` carries no detail field, so what the row reports goes in the title
// beside the label. The import row says what it is importing; every other row keeps
// whatever the run reported. The breakdown supersedes the run's own "N items" here,
// being the same total said in a more useful way.
const checklist = $derived<SetupStep[]>(
tasks.map((task) => {
// The breakdown says what the import *will* bring, so it belongs to the row only
// until the run has an outcome of its own. Left in place it would go on claiming
// "2 apps, 4 scripts" over a run that wrote none of them because they were
// already there.
const detail = task.key === 'import' ? task.detail || importSummary : task.detail
return {
title: detail ? `${task.label} — ${detail}` : task.label,
status: task.status,
// Only under the row that wrote them. A failed item carries its error as the
// description, which the checklist opens by itself.
substeps:
task.key === 'import'
? execution?.itemResults.map((r) => ({
title: r.path,
// `skipped`, not `done`: nothing was written, and a green tick over an
// item this run left alone claims an import that did not happen.
status: !r.ok
? ('failed' as const)
: r.skipped
? ('skipped' as const)
: ('done' as const),
description: r.skipped ? 'Already in the workspace — left as it is.' : r.error
}))
: undefined
}
})
)
/** A run that has been attempted — what makes the button read Retry rather than Import. */
const attempted = $derived(!!execution)
function start() {
const current =
execution ??
new ImportExecution(
{ ...plan, folder: folder.trim() },
{ reviewMigrations, hasEeLicense: !!$enterpriseLicense }
)
// A retry reuses the execution — that is what keeps a created workspace and a
// fetched export from being redone — so the folder, the one field still
// editable after a failure, has to be pushed onto it before running again.
current.setFolder(folder.trim())
run = { key: planKey, execution: current }
void current.run()
}
const leaveModal = createAsyncConfirmationModal()
/** The question is on screen; a second attempt must not stack another one. */
let askingToLeave = false
/** The navigation the question approved, which has to get past this guard. */
let leaveApproved = false
// The browser's own back/forward, which the stepper's guard cannot see. Leaving
// mid-run unmounts the migration review the executor may be awaiting, so it is
// worth stopping for — but silently refusing reads as a broken back button, so
// cancel, ask, and re-navigate if the answer is yes.
beforeNavigate((nav) => {
if (leaveApproved) return
// Nothing in flight has anything to lose.
if (!execution?.running) return
if (askingToLeave) {
nav.cancel()
return
}
// Leaving the app entirely cannot be resumed from here — the browser owns that
// prompt — so there is nothing to ask and nowhere to navigate back to.
const to = nav.to?.url
if (!to) {
nav.cancel()
return
}
nav.cancel()
void confirmLeave(to)
})
async function confirmLeave(to: URL): Promise<void> {
askingToLeave = true
// `finally`, because this flag is what blocks a second attempt: an `ask` that threw
// would otherwise leave the step permanently unleavable, since every path above
// returns early on it.
try {
const landed = execution?.itemResults.length ?? 0
const confirmed = await leaveModal.ask({
title: 'Leave while the import is running?',
confirmationText: 'Leave',
type: 'danger',
// A run that has already written items leaves them behind, so promising
// otherwise would be a lie exactly when it matters most.
children:
(landed === 0
? 'Nothing has been imported into the workspace yet.'
: `${landed} item${landed === 1 ? '' : 's'} already imported into the workspace will stay there.`) +
'<br /><br />The import stops where it is. Coming back to this link picks it up ' +
'again without redoing what finished.'
})
if (!confirmed) return
// Deliberately not re-read against `running`: the answer was about leaving, and a
// run that finished in the meantime only makes leaving safer.
leaveApproved = true
// Stop the run before navigating. Nothing can abort a request already in flight,
// so this stops it at the next phase boundary and keeps the workspace parked, so
// the link the message promises actually resumes instead of failing on create.
execution?.abandon()
await goto(to)
} finally {
askingToLeave = false
}
}
// Torn down with the review drawer open, the executor is still awaiting an answer.
// Abort rather than resolve: resolving to `false` means "skip the migrations", which
// would let the orphaned run import every item *without* the tables they need — the
// opposite of leaving it where it was.
$effect(() => () => reviewResolve?.('abort'))
const deleteModal = createAsyncConfirmationModal()
async function deleteWorkspace() {
const id = execution?.workspaceId
if (!id) return
const ok = await deleteModal.ask({
title: `Delete workspace ${id}?`,
confirmationText: 'Delete it',
children: 'It was created for this import. Deleting it cannot be undone.'
})
if (!ok) return
try {
await execution?.deleteCreatedWorkspace()
sendUserToast(`Deleted workspace ${id}`)
onBack()
} catch (e: any) {
sendUserToast(`Could not delete ${id}: ${e?.body ?? e}`, true)
}
}
</script>
<div class="flex flex-col gap-4">
<!-- Only when the destination already exists. A workspace created by this run is
empty, so there is nothing for the project to sit next to and nothing to
choose between — asking would be a question with one answer. It lands in
f/<slug>/ either way; `installProject` creates the folder as it imports. -->
{#if existingWorkspace}
<div class="max-w-sm">
<!-- The workspace rides on the field label rather than getting a line of its own:
the folder is the only thing being chosen, and naming its container is what
the label is for. Step 2 chose the workspace a screen ago, so this is a
reminder, not a control. -->
<span class="block text-xs font-semibold text-emphasis">
Folder inside <span class="font-mono">{existingWorkspace}</span>
</span>
<!-- Says where the items land, now that the path hint under the picker is gone. -->
<p class="mb-1 text-xs font-normal text-secondary">
Everything the project ships is imported into this folder.
</p>
<!-- Pointed at the destination rather than the active workspace: the run is
what enters it, and that has not happened yet on this step. -->
<FolderPicker
bind:folderName={folder}
workspace={existingWorkspace}
disabled={execution?.running || execution?.done}
size="sm"
/>
{#if folder.trim() && !folderValid}
<p class="mt-1 text-2xs font-normal text-red-500"
>Letters, digits, dashes and underscores only.</p
>
{/if}
</div>
{/if}
<!-- Heads the step the way the others do ("Where should it go?", "Name the new
workspace"), and in the same voice: a sentence, not a label. -->
<h2 class="text-sm font-semibold text-emphasis">What this will do</h2>
<!-- The run, task by task, so a failure says which part failed. Shown before the
run too, as the plan: every row starts pending and turns green in place. The
paths the import writes hang off the import task rather than forming a second
list: they are that task's output, not a parallel account of the same run.
`substepsClass` caps that list: a project ships tens of items where a data
table wizard step has a handful of checks. -->
<SetupChecklist steps={checklist} substepsClass="max-h-52 overflow-y-auto" />
{#if execution?.error}
<Alert type="error" title="The import did not finish cleanly" size="xs">
{execution.error}
</Alert>
{/if}
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
so the collapsed row sits under the checklist as a note rather than competing with it
— `bgClass` is the only lever, the border is baked into each type's classes. -->
<Alert
type="info"
title="What import does to resources and triggers"
size="xs"
bgClass="border-0"
collapsible
>
Resources are imported as empty stubs — set their values after import; one whose path is
already in the workspace is left exactly as it is and reported as already there, so a value
you have since filled in is never overwritten. Trigger kinds are
recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation
and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure
triggers all require Enterprise. Triggers that reference a resource depend on stubs imported
empty, so fill in the resource value before re-enabling the trigger.
</Alert>
<div class="mt-2 flex items-center justify-between gap-2">
<!-- Back is disabled mid-run, and gone once the import has landed: at that point
the plan has already happened and re-answering it would say nothing. -->
{#if !execution?.done}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArrowLeft }}
disabled={execution?.running}
onClick={onBack}
>
Back
</Button>
{:else}
<span></span>
{/if}
<div class="flex items-center gap-2">
<!-- Deleting is offered only while the run has not finished: once the items are
in, removing the workspace is not a cancel, it is a different decision. -->
{#if execution?.createdWorkspace && !execution.done}
<Button
variant="subtle"
unifiedSize="sm"
disabled={execution.running}
onClick={deleteWorkspace}
>
Delete workspace
</Button>
{/if}
{#if execution?.done}
<!-- A finished run that reports failures is still finished — what landed is
real — but it must stay actionable: without this the only way out of a
failed migration or a failed item is to leave, and nothing downstream
can run the SQL. Offered beside Finish rather than instead of it, so a
migration that fails every time cannot trap the user short of step 4. -->
{#if execution.error}
<Button
variant="subtle"
unifiedSize="sm"
disabled={execution.running}
startIcon={{ icon: execution.running ? Loader2 : Download }}
onClick={start}
>
Retry
</Button>
{/if}
<!-- Disabled while the page is still deciding whether a setup step follows:
finishing in that window leaves for the workspace and skips a step that
the answer, a moment later, says was needed. -->
<Button
variant="accent"
unifiedSize="sm"
disabled={setupUndecided}
startIcon={setupUndecided ? { icon: Loader2 } : undefined}
onClick={onFinish}
>
{setupUndecided ? 'Checking…' : setupPending ? 'Continue →' : 'Finish setup →'}
</Button>
{:else}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: execution?.running ? Loader2 : Download }}
disabled={!!problem || !folderValid || execution?.running}
title={problem}
onClick={start}
>
{#if execution?.running}
Importing…
{:else if attempted}
Retry
{:else if plan.destination?.kind === 'new'}
Create workspace and import
{:else}
Import
{/if}
</Button>
{/if}
</div>
</div>
</div>
<Portal>
<ConfirmationModal {...deleteModal.props} />
<ConfirmationModal {...leaveModal.props} />
</Portal>
<Drawer bind:this={reviewDrawer} size="700px" on:close={() => closeMigrationReview(false)}>
<DrawerContent title="Data table migrations" on:close={() => closeMigrationReview(false)}>
<div class="flex flex-col gap-4">
<!-- Unconditional, because this drawer cannot open for anything else: `reviewMigrations`
keeps only migrations whose data table is already present in the destination, and a
workspace this run just created has none. Everything listed here therefore targets a
table that already exists and may already hold rows. -->
<Alert type="warning" title="These run against data tables that already exist" size="xs">
{reviewList.length === 1 ? 'This data table is' : 'These data tables are'} already set up{existingWorkspace
? ` in ${existingWorkspace}`
: ''} and may already hold data. These migrations were written to create the project's tables,
so running them here can alter or drop what is in them. Read the SQL before you run it, and skip
anything you are unsure of.
</Alert>
<p class="text-xs text-secondary">
Review and edit the SQL, then choose which to run. A migration runs against the data table
of the same name in the destination workspace; if that data table has migrations enabled it
is recorded, otherwise it runs once as a preview job.
</p>
{#each reviewList as m (m.datatable_name)}
<div class="flex flex-col gap-1.5 rounded border bg-surface-secondary p-2 text-xs">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-primary">{m.datatable_name}</span>
<Toggle bind:checked={m.run} size="xs" options={{ right: 'Run' }} />
</div>
{#if m.run}
<MigrationSqlEditor
bind:up={m.sql}
bind:down={m.sql_down}
generation={reviewGeneration}
/>
{/if}
</div>
{/each}
</div>
{#snippet actions()}
<Button variant="subtle" unifiedSize="sm" onClick={() => closeMigrationReview(false)}>
Skip migrations
</Button>
<Button
variant="accent"
unifiedSize="sm"
disabled={!reviewList.some((m) => m.run && m.sql.trim() !== '')}
onClick={() => closeMigrationReview(true)}
>
Run selected
</Button>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,48 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import { CheckCircle2 } from 'lucide-svelte'
interface Props {
/** Sized by the row, not the caller: 20px is what an integration logo needs to stay legible. */
icon: import('svelte').Snippet
title: import('svelte').Snippet
detail?: import('svelte').Snippet
action: import('svelte').Snippet
/** Rendered full-width under the row, for detail that does not fit on one line. */
extra?: import('svelte').Snippet
/** Plays the confirmation flash over the action once. */
flash?: boolean
}
let { icon, title, detail, action, extra, flash = false }: Props = $props()
</script>
<!-- One row for both lists on the setup step. Data tables and credentials are the same
thing to the reader — something the import could not configure, with an action that
configures it — so they get the same icon size, spacing and text block. -->
<li class="flex flex-col rounded-md border border-border-light px-3 py-2 text-xs">
<div class="flex items-center gap-3">
<div class="shrink-0">{@render icon()}</div>
<!-- No gap and no leading override: `text-xs` already carries `leading-4`, and the two
lines are one block of text, not two stacked items. -->
<div class="flex min-w-0 flex-1 flex-col">
{@render title()}
{@render detail?.()}
</div>
<!-- The confirmation flash is the one from SaveButton: the work itself happens
elsewhere — a drawer, a wizard — so only the overlay is reused here. The button
stays live underneath either way; being configured is a state, not a dead end. -->
<div class="relative shrink-0 overflow-hidden rounded-md">
{@render action()}
{#if flash}
<div
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
transition:fly={{ y: -10, duration: 300 }}
>
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
</div>
{/if}
</div>
</div>
{@render extra?.()}
</li>
@@ -0,0 +1,861 @@
<script lang="ts">
import { ResourceService, WorkspaceService } from '$lib/gen'
import { ArrowLeft, Check, Database, Loader2, TriangleAlert, X } from 'lucide-svelte'
import { tick } from 'svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Button } from '$lib/components/common'
import AddDataTableWizard from '$lib/components/workspaceSettings/AddDataTableWizard.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
import { SettingService } from '$lib/gen'
import { resource } from 'runed'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
import ImportSetupRow from '$lib/components/ImportSetupRow.svelte'
import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte'
import { OauthService } from '$lib/gen'
import { registryCcCapableFor } from '$lib/components/oauthRegistry'
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
import { probeMigrationsApplied } from '$lib/importWizard/probe'
import {
retargetProjectExport,
type ProjectExport,
type ProjectMigration
} from '$lib/components/workspaceSettings/projectBundle'
import { sendUserToast } from '$lib/toast'
import { escapeHtml } from '$lib/utils'
// The last step, and the only optional one: it exists when the project's data
// tables are not configured in the destination. The import has already run —
// everything here is the part it could not do, because a data table is a named
// database connection the workspace owns, not something an import can invent.
//
// Self-sufficient from `workspace` + `slug`: it re-fetches the export rather than
// reading the executor, so reloading the page on this step still works. The plan
// in the URL stays the whole state.
interface Props {
workspace: string
slug: string
/** The folder the import wrote into. The export names resources under the project's
* own slug and `installProject` retargets them, so reading the raw paths here would
* look for stubs that are not where they landed. */
folder?: string
onSkip: () => void
onFinish: () => void
onBack?: () => void
}
let { workspace, slug, folder, onSkip, onFinish, onBack }: Props = $props()
type Row = {
name: string
migrations: ProjectMigration[]
status: 'unconfigured' | 'running' | 'done' | 'failed' | 'unknown'
error?: string
/** Plays the confirmation flash once, right after the run that configured it. */
justSaved: boolean
}
/** A resource the project shipped that needed filling in. */
type Blank = {
path: string
resourceType: string
/** Required fields the type declares and the value does not have yet. */
missing: string[]
/** Filled in since this step opened. The row stays — it is a checklist, and a
* line that vanishes when you complete it reads as something going wrong. */
done: boolean
/** Plays the confirmation flash once, right after the save that flipped it. */
justSaved: boolean
/**
* Something of another type already holds this path, named here. The import skipped it
* on the path alone, so this row exists to say the project did not get the resource it
* shipped — and to make sure nothing offers to write over what is there.
*/
occupiedBy?: string
/**
* The resource could not be read, so nothing here knows whether it needs filling. Kept
* on the checklist rather than dropped: a read that fails is not evidence the resource
* is absent, and removing the row reports "all set" over a credential nobody filled.
*/
unreadable?: boolean
}
let loading = $state(true)
let loadError = $state<string | undefined>(undefined)
let rows = $state<Row[]>([])
let blanks = $state<Blank[]>([])
let projectResources: { path: string; resource_type: string }[] = []
let working = $state(false)
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
const pendingTables = $derived(rows.filter((r) => r.status !== 'done'))
// Split because the two say different things to the user: one data table was never
// created, the other exists and could not be read. Telling someone to set up what they
// have already set up is how a warning stops being believed.
// Grouped by what it costs the project, not by row status. A row whose migrations failed is
// configured, but its tables are as absent as one that was never created and the remedy is
// the same — so they share a message. An `unknown` row is the odd one: it is set up, and
// whether its tables are there is precisely what could not be established.
const missingTables = $derived(
rows.filter((r) => r.status === 'unconfigured' || r.status === 'failed')
)
const uncheckedTables = $derived(rows.filter((r) => r.status === 'unknown'))
/** Rows the user has not dealt with, of either kind. */
const outstanding = $derived(pendingTables.length + blanks.filter((b) => !b.done).length)
// The wizard needs the instance-database pool and a confirmation host; the settings
// page owns them there, so this step owns them here.
// Which resource types this instance has an OAuth client for. A resource whose type is
// in here can be connected instead of hand-filled, which for an OAuth type is the
// difference between clicking Connect and pasting a token that expires in an hour.
// Empty when no superadmin has configured any client — then every row falls back to
// the editor, which is the only thing that would work anyway.
const oauthConnects = resource(
() => workspace,
async () => {
try {
return (await OauthService.listOauthConnects()).map((c) => c.name)
} catch {
return []
}
}
)
const instanceConnects = $derived(new Set(oauthConnects.current ?? []))
/**
* Matches what the dialog itself decides (`AppConnectInner.open`: `manual = !inConnects &&
* !registryCcCapable()`). An instance client is the usual route, but a provider the
* registry marks client-credentials-capable is connectable without one, because those
* credentials are entered per resource rather than held by a superadmin. Test only the
* first half and Connect disappears on the eight such providers, where it would work.
*/
const canConnectType = (rt: string) => instanceConnects.has(rt) || registryCcCapableFor(rt)
let appConnect: AppConnectDrawer | undefined = $state(undefined)
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
const confirmationModal = createAsyncConfirmationModal()
let wizardOpen = $state(false)
let wizard = $state<AddDataTableWizard | undefined>(undefined)
let wizardFor = $state<string | undefined>(undefined)
let configuredNames = $state<{ name: string; resourcePath: string | undefined }[]>([])
/**
* Which row the open dialog runs migrations for. Separate from `wizardFor`, which
* `afterWizard()` clears as soon as the run reports — while the dialog stays up offering
* "Try again" for exactly that row. Read by `onFinishAlso`, so it has to outlive the run
* rather than the opening.
*/
let retryTarget = $state<string | undefined>(undefined)
/**
* A dialog is being opened. `open()` resolves the destination membership before it shows
* anything, so the button stays clickable during that wait — and a second click starts a
* second lookup whose `reset()` lands on the dialog the first one opened, wiping fields
* the user has already filled.
*/
let opening = $state(false)
function openWizard(name: string) {
wizardFor = name
retryTarget = name
// `open()`, not `opened = true`: only the method runs the wizard's own reset, which
// is what applies `initialName` and clears whatever a previous run left behind.
// `wizardOpen` is deliberately not set here. `open()` sets it once it has resolved the
// destination's membership, and it is bound to the dialog's `opened` — so setting it
// now shows a live, clickable dialog while that lookup is still in flight, with the
// username unresolved. Setup reached in that window writes the credential path this
// whole chain exists to get right. `wizardFor` alone mounts the component, which is
// all `wizard?.open()` needs to exist.
opening = true
void tick()
.then(() => wizard?.open())
.finally(() => (opening = false))
}
function defaultInstanceDbName(): string {
const used = Object.keys(customInstanceDbs.current ?? {})
let n = 1
while (used.includes(`dt${n}`)) n++
return `dt${n}`
}
/**
* What a row's state actually is, asked of the destination rather than inferred.
*
* The data table existing is not the question — the wizard creates it and the migrations
* run afterwards, so a table can be there with none of the project's tables inside it.
* That gap is invisible in memory after a reload, which rebuilds every row from scratch;
* reading it as "done" would let the step say "You're all set" over a project whose apps
* all fail on open.
*
* `probeMigrationsApplied` answers `undefined` when it cannot tell — an unreadable schema
* (the data table's database is down, or its credentials have gone bad) or SQL naming no
* tables it can resolve. That is neither done nor missing, and claiming either goes beyond
* what the code knows: reading it as done is how this step ends up reporting "You're all
* set" over a project whose apps fail on open. `unknown` says what is true, and still
* counts as outstanding so nothing is finished on top of it.
*/
async function settle(
name: string,
ms: ProjectMigration[],
absent: boolean,
prev: Row | undefined
): Promise<Row['status']> {
if (absent) return 'unconfigured'
const applied = await probeMigrationsApplied(workspace, name, ms)
if (applied === true) return 'done'
if (applied === false) return prev?.status === 'failed' ? 'failed' : 'unconfigured'
return prev?.status === 'failed' ? 'failed' : 'unknown'
}
/** Which data tables the project needs that the destination does not have yet. */
async function load() {
loading = true
loadError = undefined
try {
const res = await fetch(
`/api/w/${encodeURIComponent(workspace)}/hub/projects/${encodeURIComponent(slug)}/export`
)
if (!res.ok) throw new Error(`the hub proxy answered ${res.status}`)
const exportData = (await res.json()) as ProjectExport
const enabled = (exportData.migrations ?? []).filter(
(m) => m.enabled && (m.sql ?? '').trim() !== ''
)
// Kept, not just counted: which data tables the destination has decides whether a
// row can retry its migrations or has to go back through the wizard, and after a
// reload this call is the only thing that knows. Drop it and such a row offers the
// wizard, which then refuses the name it created itself.
const tables = await WorkspaceService.listDataTables({ workspace })
configuredNames = tables.map((t) => ({ name: t.name, resourcePath: t.resource_path }))
const present = new Set(tables.map((d) => d.name))
const missing = [...new Set(enabled.map((m) => m.datatable_name))].filter(
(n) => !present.has(n)
)
const previous = new Map(rows.map((r) => [r.name, r]))
rows = await Promise.all(
[...new Set(enabled.map((m) => m.datatable_name))].map(async (name) => {
const ms = enabled.filter((m) => m.datatable_name === name)
const prev = previous.get(name)
const status = await settle(name, ms, missing.includes(name), prev)
return prev
? { ...prev, migrations: ms, status, error: status === 'done' ? undefined : prev.error }
: { name, migrations: ms, status, justSaved: false }
})
)
// Retargeted the same way the import was, so these are where the stubs actually
// landed. `retargetProjectExport` is a no-op when the folder is the slug, which is
// every new-workspace import.
const target = folder?.trim() || slug
const retargeted = retargetProjectExport(exportData, exportData.project?.slug ?? slug, target)
// Contained for the same reason the import contains: a crafted export can name a
// path outside the folder, and offering that for editing would reach a resource
// this import was never allowed to create.
projectResources = (retargeted.resources ?? [])
.map((r) => ({ path: String(r.path), resource_type: String((r as any).resource_type) }))
.filter((r) => r.path.startsWith(`f/${target}/`))
await refreshBlanks()
} catch (e: any) {
loadError = e?.body ?? e?.message ?? String(e)
} finally {
loading = false
}
}
/**
* Resources the import created but could not fill. Every shipped resource arrives
* as a stub — the hub never publishes resource values, they are credentials — so
* this is not "which ones are empty" but "which ones are *still* empty": a
* re-import leaves an already-filled resource alone.
*
* The type's schema names the required fields, so the row can say what is missing
* rather than just that something is. A type we cannot read still counts as blank
* when the value is empty; it just lists no field names.
*/
async function findBlankResources(
resources: { path: string; resource_type: string }[]
): Promise<Blank[]> {
const out: Blank[] = []
for (const r of resources) {
let value: any
let occupiedBy: string | undefined
try {
const found = await ResourceService.getResource({ workspace, path: r.path })
value = found?.value
// The presence probe matches on path, and a path says nothing about type. A
// resource of another kind sitting here is not this project's stub, however
// empty it looks — treating it as one offers to fill somebody else's resource.
if (found?.resource_type && found.resource_type !== r.resource_type) {
occupiedBy = found.resource_type
}
} catch (e: any) {
// A 404 is the import having failed to create it, which it reported itself.
// Any other failure is a read this could not complete, which says nothing about
// whether the resource is there or needs filling — so the row stays.
if (e?.status === 404) continue
out.push({
path: r.path,
resourceType: r.resource_type,
missing: [],
done: false,
justSaved: false,
unreadable: true
})
continue
}
const filled = new Set(
value && typeof value === 'object'
? Object.entries(value)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k]) => k)
: []
)
let required: string[] = []
// A type whose schema will not load leaves `required` empty, which reads as "nothing
// missing" — and a half-filled resource would drop off the checklist as done. The
// row is kept instead; it just cannot name which fields are short.
let requirementsUnknown = false
try {
const schema = (await ResourceService.getResourceType({ workspace, path: r.resource_type }))
?.schema as { required?: string[] } | undefined
required = schema?.required ?? []
} catch {
requirementsUnknown = true
}
const missing = required.filter((k) => !filled.has(k))
// A conflicting occupant is always listed, however full its value looks: the row is
// what tells the user the project is missing a resource it shipped.
if (occupiedBy || requirementsUnknown || missing.length > 0 || filled.size === 0) {
out.push({
path: r.path,
resourceType: r.resource_type,
missing,
done: false,
justSaved: false,
occupiedBy
})
}
}
return out
}
/**
* Re-read the resources and settle each row's state. Rows are never dropped once
* listed: the first pass decides what the checklist contains, and every pass after
* it only moves a row from outstanding to done.
*/
async function refreshBlanks(): Promise<void> {
const fresh = await findBlankResources(projectResources)
const stillBlank = new Map(fresh.map((b) => [b.path, b]))
if (blanks.length === 0) {
blanks = fresh
return
}
blanks = blanks.map((b) => {
const f = stillBlank.get(b.path)
// Every field the fresh read decides is taken from it, not merged selectively: these
// describe what is at the path *now*. Keeping a stale `unreadable` leaves a resource
// that has since come back blocked until a reload, and keeping a stale absence hides
// one that has just become unreadable.
if (f) {
return {
...b,
missing: f.missing,
unreadable: f.unreadable,
occupiedBy: f.occupiedBy,
done: false,
justSaved: false
}
}
// Gone from the blank list entirely: it was read, and it is filled.
return {
...b,
missing: [],
unreadable: undefined,
occupiedBy: undefined,
done: true,
justSaved: !b.done
}
})
// The flash is a one-shot; clear it so a later refresh does not replay it.
for (const b of blanks) {
if (!b.justSaved) continue
setTimeout(() => {
const row = blanks.find((x) => x.path === b.path)
if (row) row.justSaved = false
}, 1500)
}
}
$effect(() => {
void load()
})
/**
* The data table now exists — run the migrations that were skipped for it during the
* import, which is the whole reason this step waits for the configuration.
*/
async function runMigrationsFor(name: string): Promise<void> {
const row = rows.find((r) => r.name === name)
// Thrown, not returned: this also runs as the wizard's appended step, which reads a
// resolved promise as "the migrations ran". Resolving for a name that matches no row
// would report success over SQL that never executed.
if (!row) throw new Error(`No data table named '${name}' in this project`)
working = true
row.status = 'running'
try {
for (const m of row.migrations) await applyOneMigration(workspace, slug, m)
row.status = 'done'
row.error = undefined
// One-shot, cleared by name rather than by reference: `load()` rebuilds the row
// objects, so the one holding the flag when it fires may not be this one.
row.justSaved = true
setTimeout(() => {
const current = rows.find((r) => r.name === name)
if (current) current.justSaved = false
}, 1500)
} catch (e: any) {
row.status = 'failed'
row.error = e?.body ?? e?.message ?? String(e)
sendUserToast(`Could not run the migrations for ${name}: ${row.error}`, true)
// Rethrown, because this also runs as the wizard's last checklist step
// (`onFinishAlso`). Swallowing it there makes the wizard report a clean finish
// over a failed migration, and close — leaving the data table name taken and no
// way back to retry it.
throw e
} finally {
working = false
}
}
/**
* Leaving a credential unfilled costs the project the parts that read it. Leaving a
* data table unconfigured costs it everything: the apps query tables that do not
* exist, so they fail on open rather than degrading. Only the second is worth
* stopping for, and the wizard is the only place that can still run the migration —
* nothing in the workspace knows the project shipped one.
*/
async function skip(): Promise<void> {
if (pendingTables.length > 0) {
// Escaped: `confirmationModal.ask` renders `children` through `createRawSnippet`,
// so this string is HTML, and the name is a `datatable_name` straight out of the
// hub export. A hub is not ours — `hub_base_url` is an instance setting and the
// wizard can be pointed at any of them — so a name carrying an event-bearing
// element would otherwise run script in this authenticated origin.
// One block per outcome, the way the footer alert does it. A single sentence over a
// mixed list has to be wrong about half of it: an `unknown` data table is set up —
// only its schema could not be read — so naming it under "not set up" tells the user
// to do something they have already done.
const missing = missingTables
const unverified = uncheckedTables
const listOf = (rs: Row[]) => rs.map((r) => escapeHtml(r.name)).join(', ')
const blocks: string[] = []
if (missing.length > 0) {
const one = missing.length === 1
blocks.push(
`The tables ${one ? 'the data table' : 'the data tables'} <b>${listOf(missing)}</b> ` +
`${one ? 'holds' : 'hold'} do not exist, and this project's apps and flows read ` +
`them. Every one of those fails as soon as it opens.<br /><br />` +
`Setting ${one ? 'it' : 'them'} up later from workspace settings creates the ` +
`connection but not the tables — only this step runs the project's migration.`
)
}
if (unverified.length > 0) {
const one = unverified.length === 1
blocks.push(
`${one ? 'The data table' : 'The data tables'} <b>${listOf(unverified)}</b> ` +
`${one ? 'is' : 'are'} set up, but ${one ? 'its' : 'their'} schema could not be ` +
`read, so whether this project's tables exist is unknown. Its apps and flows will ` +
`fail wherever they query a table that is missing.`
)
}
const confirmed = await confirmationModal.ask({
title: missing.length > 0 ? 'The project will not run' : 'This has not been verified',
confirmationText: 'Skip anyway',
type: missing.length > 0 ? 'danger' : 'info',
children: blocks.join('<br /><br />')
})
if (!confirmed) return
}
onSkip()
}
/**
* After the wizard closes. The migrations already ran inside its checklist, via
* `onFinishAlso`, so this only re-reads what exists now — including the case where
* the wizard was cancelled, or made a table under a different name than the row
* asked for, which leaves the row outstanding rather than falsely done.
*/
async function afterWizard(): Promise<void> {
const name = wizardFor
wizardFor = undefined
try {
const tables = await WorkspaceService.listDataTables({ workspace })
configuredNames = tables.map((t) => ({ name: t.name, resourcePath: t.resource_path }))
const present = new Set(tables.map((t) => t.name))
const row = name ? rows.find((r) => r.name === name) : undefined
if (row && row.status !== 'done' && row.status !== 'failed' && !present.has(name!)) {
row.status = 'unconfigured'
}
} catch {
// Nothing to correct with; the row keeps whatever the run left it saying.
}
}
</script>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
<!-- Reads as what the user gets out of it, not as what the import failed to do:
the step is skippable, so it has to say why finishing is worth their time. -->
<p class="mt-0.5 text-xs text-secondary">
Your project is imported. For its apps and flows to actually run, they need a place to store
data and credentials for the services they use — the import can't supply those for you.
</p>
</div>
{#if loading}
<div class="flex items-center gap-2 text-xs text-secondary">
<Loader2 size={14} class="animate-spin" /> Checking what this project needs…
</div>
{:else if loadError}
<Alert type="warning" title="Could not check the project's data tables" size="xs">
{loadError}. You can finish and configure them later in Workspace settings → Data tables.
</Alert>
{:else}
{#if rows.length > 0}
<!-- Named and explained: the row underneath is a table called `main`, which
says nothing to someone meeting the concept for the first time. -->
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">
Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length})
</span>
<p class="text-xs font-normal text-secondary">
Where apps and flows keep the data they read and write.
</p>
</div>
{/if}
<ul class="flex flex-col gap-1.5">
{#each rows as row (row.name)}
{@const sql = row.migrations
.map((m) => m.sql)
.filter(Boolean)
.join('\n\n')}
{@const hasTable = configuredNames.some((c) => c.name === row.name)}
<ImportSetupRow flash={row.justSaved}>
{#snippet icon()}
{#if row.status === 'done'}
<Check size={20} class="text-emerald-600" />
{:else if row.status === 'running'}
<Loader2 size={20} class="animate-spin text-blue-500" />
{:else if row.status === 'failed'}
<X size={20} class="text-red-500" />
{:else if row.status === 'unknown'}
<TriangleAlert size={20} class="text-yellow-600" />
{:else}
<Database size={20} class="text-secondary" />
{/if}
{/snippet}
{#snippet title()}
<span class="min-w-0 truncate font-mono text-emphasis">{row.name}</span>
{/snippet}
{#snippet detail()}
<span class="truncate text-secondary">
{#if row.status === 'done'}
{row.migrations.length} migration{row.migrations.length === 1 ? '' : 's'} run
{:else if row.status === 'running'}
running migrations…
{:else if row.status === 'failed'}
<span class="text-red-500">{row.error}</span>
{:else if row.status === 'unknown'}
set up, but its tables could not be read — the database may be unreachable
{:else}
not configured yet
{/if}
</span>
{/snippet}
{#snippet extra()}
<!-- The SQL, before anything runs it. Step 3 reviews the migrations it can
run there; the ones deferred to here were never shown, and "Set up"
executes them against whatever database the wizard is pointed at —
which can be one that already holds unrelated objects.
On an `unknown` row nothing here will run — its only action re-reads —
so the summary says what the SQL is rather than promising to run it. -->
{#if sql && row.status !== 'done'}
<details class="mt-1.5">
<summary class="cursor-pointer text-2xs text-secondary hover:text-primary">
{row.status === 'unknown'
? 'Show the SQL this project ships'
: 'Show the SQL this will run'}
</summary>
<pre
class="mt-1.5 max-h-52 overflow-auto whitespace-pre-wrap rounded border border-border-light bg-surface-secondary p-2 font-mono text-2xs text-secondary"
>{sql}</pre
>
</details>
{/if}
{/snippet}
{#snippet action()}
<!-- The wizard owns creating a data table: picking or provisioning the
database, writing the config, and reporting the connection. This step
only says which name it needs and runs the migrations afterwards. -->
{#if row.status === 'unknown'}
<!-- Never runs the SQL. `unknown` covers two different unknowns — the schema
could not be read, or the SQL names no table this can resolve — and the
second is arbitrary published SQL that may carry a non-idempotent INSERT
or ALTER. Applying it a second time on the chance it never applied once
is a worse outcome than saying so. Reading again is free and settles the
case that actually recovers: a database that was briefly unreachable. -->
<Button
variant="subtle"
unifiedSize="sm"
disabled={working || loading}
onClick={() => void load()}
>
Check again
</Button>
{:else if hasTable && row.status !== 'done' && row.status !== 'running'}
<!-- The data table is there and its tables are not, so the thing left
to do is run the migrations. Reopening the wizard would ask for a
name it now holds itself, which it rejects as taken — leaving no
way back to the step that actually failed.
Keyed on the data table existing rather than on the row saying
`failed`, because a reload rebuilds every row from scratch: the
same situation then reads as `unconfigured`, with nothing left in
memory to say a migration was ever attempted. -->
<Button
variant="accent"
unifiedSize="sm"
disabled={working}
onClick={() => void runMigrationsFor(row.name).catch(() => {})}
>
{row.status === 'failed' ? 'Run migrations again' : 'Run migrations'}
</Button>
{:else}
<!-- Everything the branches above do not claim: a configured row, one whose
migrations are running, and one whose data table does not exist yet. -->
<Button
variant={row.status === 'done' ? 'subtle' : 'accent'}
unifiedSize="sm"
disabled={working || opening}
onClick={() => openWizard(row.name)}
>
{#if row.status === 'done'}
Configured
{:else if row.status === 'running'}
Setting up…
{:else}
Set up
{/if}
</Button>
{/if}
{/snippet}
</ImportSetupRow>
{/each}
</ul>
{#if blanks.length > 0}
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-emphasis"
>Credentials to fill ({blanks.length})</span
>
<ul class="flex flex-col gap-1.5">
{#each blanks as b (b.path)}
{@const blocked = !!b.occupiedBy || !!b.unreadable}
{@const canConnect = !b.done && !blocked && canConnectType(b.resourceType)}
<!-- Laid out like the resource type rows in the Add-a-resource drawer: the
integration's own icon, its product name, and the raw identifier demoted
beside it. The path only matters when two resources share a type, so it
stops being the thing the eye lands on. -->
<ImportSetupRow flash={b.justSaved}>
{#snippet icon()}
{#if b.done}
<Check size={20} class="text-emerald-600" />
{:else if blocked}
<TriangleAlert size={20} class="text-yellow-600" />
{:else}
<IconedResourceType name={b.resourceType} silent width="20px" height="20px" />
{/if}
{/snippet}
{#snippet title()}
<div class="flex min-w-0 flex-row items-baseline gap-2">
<span class="min-w-0 truncate text-emphasis">
{resourceTypeDisplayName(b.resourceType)}
</span>
<span class="min-w-0 truncate font-mono text-2xs font-normal text-hint">
{b.path}
</span>
</div>
{/snippet}
{#snippet detail()}
{#if b.occupiedBy}
<span class="truncate text-secondary">
a {resourceTypeDisplayName(b.occupiedBy)} resource already holds this path — the
project did not get this one
</span>
{:else if b.unreadable}
<span class="truncate text-secondary">
could not be read, so whether it needs filling is unknown
</span>
{:else if !b.done && b.missing.length > 0}
<span class="truncate text-secondary">
Missing {b.missing.join(', ')}
</span>
{/if}
{/snippet}
{#snippet action()}
<!-- Connect where the instance has a client for this type: asking for an
OAuth resource by hand means pasting an access token that dies within
the hour, since only a token Windmill obtained itself gets refreshed. -->
{#if blocked}
<!-- No action: every one here writes to the path, and this code does not
know what is at it — either something of another type, or a read that
failed. Opening the editor would invite exactly the overwrite these
rows exist to prevent. -->
<span class="whitespace-nowrap text-2xs text-hint">
{b.occupiedBy ? 'Resolve in the workspace' : 'Check the workspace'}
</span>
{:else}
<Button
variant={b.done ? 'subtle' : 'accent'}
unifiedSize="sm"
disabled={working}
onClick={() =>
canConnect
? appConnect?.open(b.resourceType, b.path)
: resourceEditor?.initEdit(b.path)}
>
{b.done ? 'Saved' : canConnect ? 'Connect' : 'Fill in'}
</Button>
{/if}
{/snippet}
</ImportSetupRow>
{/each}
</ul>
</div>
{/if}
<!-- Three different things to say, and which one depends on what is left. A missing
credential degrades the project; a missing data table ends it, because every app
queries tables that do not exist. Only the credential case is offered as
skippable — saying "you can skip this" above a missing data table would be
telling the user something that is not true. -->
{#if outstanding === 0}
<Alert type="success" title="You're all set" size="xs">
Everything this project needs is configured. Finish, and it is ready to run.
</Alert>
{:else if pendingTables.length > 0}
<Alert
type="warning"
title={missingTables.length > 0
? 'The project will not run without this'
: 'This could not be checked'}
size="xs"
>
{#if missingTables.length > 0}
The tables {missingTables.length === 1 ? 'this data table holds' : 'these data tables hold'}
do not exist, and the project's apps and flows read them. Every one of those fails as soon
as it opens.
{/if}
{#if uncheckedTables.length > 0}
{#if missingTables.length > 0}<br /><br />{/if}
{uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but
{uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the
project's tables are there is unknown. Check again once the database is reachable.
{/if}
</Alert>
{:else}
<Alert type="info" title="You can skip this" size="xs" collapsible>
The project's apps and flows will fail wherever they read a credential that is still
missing. Everything else it imported works either way, and you can fill these in from the
workspace at any time.
</Alert>
{/if}
{/if}
<div class="mt-2 flex items-center justify-between">
{#if onBack}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArrowLeft }}
disabled={working}
onClick={onBack}
>
Back
</Button>
{:else}
<span></span>
{/if}
<div class="flex items-center gap-2">
<!-- Every row carries its own action, so the footer only offers the way out —
twice, because leaving work undone is a different decision from having
finished it. Finish stays disabled until nothing is outstanding, and Skip
is the subtle escape beside it. A load that failed cannot tell what is
outstanding, so it offers Finish rather than blocking on an unknown — but a
load still *running* has the same empty lists as a step with nothing to do,
so Finish waits for it rather than reading that emptiness as "all done". -->
{#if outstanding > 0 && !loading && !loadError}
<Button variant="subtle" unifiedSize="sm" disabled={working} onClick={skip}>
Skip for now
</Button>
{/if}
<Button
variant="accent"
unifiedSize="sm"
disabled={working || loading || (outstanding > 0 && !loadError)}
onClick={onFinish}
>
Finish setup →
</Button>
</div>
</div>
</div>
{#if wizardOpen || wizardFor}
<AddDataTableWizard
bind:this={wizard}
bind:opened={wizardOpen}
initialName={wizardFor}
modalTarget="body"
{workspace}
finishAlso="run migrations"
onFinishAlso={() => runMigrationsFor(retryTarget ?? '')}
existingNames={configuredNames.map((c) => c.name)}
existingDataTables={configuredNames}
onDone={() => void afterWizard()}
{customInstanceDbs}
{confirmationModal}
{defaultInstanceDbName}
/>
{/if}
<!-- Portalled to the body, not left in place: this step renders inside the wizard page's
CenteredModal, which is its own stacking context, while the data table wizard it shares
this handle with portals to the body. In place, the confirmation's z-index is capped by
that context and the wizard paints over it — leaving its backdrop swallowing every click
with nothing visible to answer. -->
<Portal>
<ConfirmationModal {...confirmationModal.props} />
</Portal>
<!-- The destination is not the workspace the app is in until the run switches to it,
so the editor is told which one explicitly.
Saving re-reads only the resources, never `load()`: a credential cannot change which
data tables the project ships or which ones the workspace has, and `load()` raises
`loading`, which replaces both lists with the spinner — so every save looked like the
whole step had reloaded. -->
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
onSaved={() => void refreshBlanks()}
onRestored={() => void refreshBlanks()}
/>
<!-- `on:refresh` fires once the connection has been written into the stub — the same moment
a save is — so the rows settle the same way either route was taken. -->
<AppConnectDrawer bind:this={appConnect} {workspace} on:refresh={() => void refreshBlanks()} />
@@ -0,0 +1,71 @@
<script lang="ts" module>
export const IMPORT_WIZARD_LABELS = ['Destination', 'Workspace', 'Import']
/** The optional fourth: shown only for a project whose data tables need configuring. */
export const IMPORT_WIZARD_SETUP_LABEL = 'Set up'
</script>
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$lib/navigation'
import Stepper from '$lib/components/common/stepper/Stepper.svelte'
import { importIsRunning } from '$lib/importWizard/execution.svelte'
import type { WizardStep } from '$lib/importWizard/plan'
import { sendUserToast } from '$lib/toast'
interface Props {
/** 1-based; Stepper is 0-based, hence the -1 below. */
step: WizardStep
/** Whether this import has a setup step at all — most projects do not. */
hasSetup?: boolean
/**
* The lowest step still worth returning to. Defaults to the first. The page raises it
* past the import once that import has landed and the run behind it can no longer be
* recovered — after a reload on the setup step, where the executor was in memory and
* the parking a clean finish cleared. Step 3 would otherwise mount with nothing to
* resume and offer to run the whole bundle again.
*/
lowestStep?: number
}
let { step, hasSetup = false, lowestStep = 1 }: Props = $props()
// Most projects ship no data table migrations, so the wizard is three steps and
// says so. A fourth appears only once there is something to configure.
const tabs = $derived(
hasSetup ? [...IMPORT_WIZARD_LABELS, IMPORT_WIZARD_SETUP_LABEL] : IMPORT_WIZARD_LABELS
)
// `maxReachedIndex` is the current step, so Stepper renders everything past it as
// unreachable and only the steps behind it as clickable — the wizard has no way to
// skip ahead, since each step decides what the next one asks.
function onStepClick(index: number) {
if (index >= step - 1) return
if (index + 1 < lowestStep) {
sendUserToast('The project is already imported. There is nothing to go back to.', true)
return
}
// An import in flight owns the page: stepping back unmounts the step that is
// awaiting the migration review, which would leave the run with no controls
// and no way to resolve.
if (importIsRunning()) {
sendUserToast('Wait for the import to finish before going back.', true)
return
}
// Every step shares one route, so going back is a `step` rewrite that leaves
// the rest of the wizard's state in the URL alone.
const params = new URLSearchParams($page.url.search)
params.set('step', String(index + 1))
goto(`/projects/import?${params}`)
}
</script>
<div class="mb-5 flex justify-center">
<!-- `small`: this steers a dialog, not a page. -->
<Stepper
{tabs}
small
selectedIndex={step - 1}
maxReachedIndex={step - 1}
on:click={(e) => onStepClick(e.detail.index)}
/>
</div>
@@ -30,6 +30,7 @@
import type { InputTransform } from '$lib/gen'
import TemplateEditor from './TemplateEditor.svelte'
import { setInputCat as computeInputCat, isCodeInjection } from '$lib/utils'
import { escapeTemplateBackticks } from '$lib/utils/templateLiteral'
import { FunctionSquare, InfoIcon } from 'lucide-svelte'
import { getResourceTypes } from './resourceTypesStore'
import type { FlowCopilotContext } from './copilot/flow'
@@ -253,7 +254,7 @@
arg.expr = getDefaultExpr(
argName,
previousModuleId,
`\`${rawValue.toString().replaceAll('`', '\\`')}\``
`\`${escapeTemplateBackticks(rawValue.toString())}\``
)
arg.type = 'javascript'
propertyType = 'static'
@@ -687,7 +688,7 @@
argName,
previousModuleId,
staticTemplate
? `\`${arg?.value?.toString().replaceAll('`', '\\`') ?? ''}\``
? `\`${escapeTemplateBackticks(arg?.value?.toString() ?? '')}\``
: arg.value
? '(' + JSON.stringify(arg?.value, null, 4) + ')'
: ''
@@ -1,9 +1,10 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { VariableService, WorkspaceService, type InputTransform } from '$lib/gen'
import { CancelError, VariableService, WorkspaceService, type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
import { untrack } from 'svelte'
import { resource, watch } from 'runed'
import { Button } from './common'
import StepInputsGen from './copilot/StepInputsGen.svelte'
import type { PickableProperties } from './flows/previousResults'
@@ -81,21 +82,37 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
let s3StorageConfigured = $state(true)
async function checkS3Storage() {
try {
if (ws) {
const settings = await WorkspaceService.getPublicSettings({ workspace: ws })
s3StorageConfigured = settings.large_file_storage?.s3_resource_path !== undefined
const settings = resource(
() => ws,
async (ws, _previousWs, { onCleanup }) => {
if (!ws) return undefined
const req = WorkspaceService.getPublicSettings({ workspace: ws })
// `resource` keeps whatever lands last: cancel a superseded request so a slow
// reply for a workspace we have left cannot overwrite the current one.
onCleanup(() => req.cancel())
try {
return { ws, settings: await req }
} catch (err) {
if (!(err instanceof CancelError)) {
console.error('Failed to fetch workspace settings:', err)
}
return undefined
}
} catch (error) {
console.error('Failed to fetch workspace settings:', error)
s3StorageConfigured = true
}
}
)
// Assume configured until this workspace's own answer lands: the warning must not
// linger from the previous workspace, nor appear merely because the fetch failed.
let s3StorageConfigured = $derived.by(() => {
const loaded = settings.current
return loaded && loaded.ws === ws
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
: true
})
checkS3Storage()
watch(
() => ws,
() => itemPicker?.reloadItems()
)
let keys: string[] = $state([])
$effect(() => {
@@ -1069,8 +1069,9 @@
<li>git sync repo count (sync vs promotion mode)</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1121,8 +1122,9 @@
<li>development instance status</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
+54 -14
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { RotateCw } from 'lucide-svelte'
import { Button, Drawer, Skeleton } from './common'
import { Alert, Button, Drawer, Skeleton } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import NoItemFound from './home/NoItemFound.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import SearchItems from './SearchItems.svelte'
import { sendUserToast } from '$lib/toast'
type Item = Record<string, any>
@@ -39,22 +40,62 @@
}: Props = $props()
let loading = $state(false)
let loadError: string | undefined = $state(undefined)
let items: Item[] | undefined = $state([])
let filteredItems: Item[] | undefined = $state([])
let filter = $state('')
export function openDrawer() {
loading = true
loadItems()
// Only the newest load may write `items`: a slower earlier request can resolve last.
// Skeletons replace a list that is known-stale; the refresh button omits them so a
// known-good list does not flicker.
let loadSeq = 0
function load(showSkeleton = false): Promise<void> {
const seq = ++loadSeq
if (showSkeleton) {
loading = true
}
return loadItems()
.then((v) => {
items = v
if (seq === loadSeq) {
items = v
loadError = undefined
}
})
.catch((err) => {
if (seq !== loadSeq) return
// Drop the list rather than keep offering entries the failed load may have
// superseded. `loadError` then has to carry the reason, or an empty list reads
// as an empty workspace. An empty body must not win over the message, or the
// error state is skipped for a falsy `loadError` — hence `||`, not `??`.
items = []
loadError = err.body || err.message || String(err)
// 401/403 are handled globally by onunhandledrejection (logout, privilege
// toast). No caller awaits load(), so rethrowing still reaches it.
if (err?.status === 401 || err?.status === 403) {
throw err
}
sendUserToast(`Failed to load ${itemName.toLowerCase()}s: ${loadError}`, true)
})
.finally(() => {
loading = false
if (seq === loadSeq) {
loading = false
}
})
}
export function openDrawer() {
load(true)
drawer?.openDrawer?.()
}
/** Re-runs `loadItems` against what it closes over now. No-op while closed —
* opening reloads anyway. */
export function reloadItems() {
if (drawer?.isOpen()) {
load(true)
}
}
let drawer: Drawer | undefined = $state()
let refreshing = $state(false)
@@ -94,14 +135,9 @@
<Button
on:click={() => {
refreshing = true
loadItems()
.then((v) => {
items = v
})
.finally(() => {
loading = false
refreshing = false
})
load().finally(() => {
refreshing = false
})
}}
iconOnly
startIcon={{ icon: RotateCw, classes: loading || refreshing ? 'animate-spin' : '' }}
@@ -111,6 +147,10 @@
{#each new Array(3) as _}
<Skeleton layout={[[5], 0.2]} />
{/each}
{:else if loadError}
<Alert type="error" size="xs" title="Failed to load {itemName.toLowerCase()}s">
{loadError}
</Alert>
{:else if !items?.length}
<div class="text-center text-sm text-primary mt-2">
{@html noItemMessage}
+37 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
const dispatch = createEventDispatcher()
@@ -8,18 +8,48 @@
updateOnBlur?: boolean
placeholder?: string
selected?: boolean
/** Content the editor opens with, and keeps following while the buffer is untouched — so a
* payload nobody has typed into tracks the schema instead of going stale. The first edit
* hands the buffer to the user and later changes stop overwriting it. */
initialCode?: string
}
let {
updateOnBlur = true,
placeholder = 'Write a JSON payload. The input schema will be inferred.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}',
selected = false
selected = false,
initialCode = ''
}: Props = $props()
let pendingJson = $state('')
let pendingJson = $state(untrack(() => initialCode))
// The last content this component wrote, kept only to skip a reseed that would replace the
// buffer with what it already holds — `setValue` resets the cursor and the undo stack.
let seededCode = untrack(() => initialCode)
// Latched from Monaco's own change event, never from `pendingJson`: that trails the buffer by
// SimpleEditor's debounce, a window in which typed text still looks like the seeded payload
// and a reseed lands on top of it.
let userEdited = false
let simpleEditor: SimpleEditor | undefined = $state(undefined)
let focusTrap: HTMLElement | undefined = $state()
$effect(() => {
const next = initialCode
untrack(() => {
if (next !== seededCode && !userEdited) {
seed(next)
}
})
})
// `SimpleEditor.setCode` cancels the change burst its own `setValue` opens, so reseeding
// never dispatches `select` — the payload reaches `args` only when the user edits it.
function seed(code: string) {
seededCode = code
userEdited = false
pendingJson = code
simpleEditor?.setCode(code)
}
function updatePayloadFromJson(jsonInput: string) {
if (jsonInput === undefined || jsonInput === null || jsonInput.trim() === '') {
dispatch('select', undefined)
@@ -33,8 +63,10 @@
}
}
/** Authoritative overwrite: replaces the buffer whether or not it has been typed into, and
* re-establishes it as the content to keep following. */
export function setCode(code: string) {
simpleEditor?.setCode(code)
seed(code)
}
export function resetSelected(dispatchEvent?: boolean) {
@@ -59,6 +91,7 @@
<div class="h-full rounded-md border">
<SimpleEditor
bind:this={simpleEditor}
on:input={() => (userEdited = true)}
on:focus={() => {
if (updateOnBlur) {
dispatch('focus')
+6 -2
View File
@@ -329,7 +329,11 @@
} else {
goto(resolvedRd ?? '/')
}
} else if (resolvedRd?.startsWith('/user/workspaces')) {
// See (root)/+layout.svelte for why /projects/import skips the picker.
} else if (
resolvedRd?.startsWith('/user/workspaces') ||
resolvedRd?.startsWith(`${base}/projects/import`)
) {
goto(resolvedRd)
} else if (resolvedRd == '/#user-settings') {
goto(`/user/workspaces#user-settings`)
@@ -732,7 +736,7 @@
contact@windmill.dev
</p>
{/if}
<div bind:this={fieldsEl} class="space-y-6 {shake ? 'motion-safe:animate-shake' : ''}">
<div bind:this={fieldsEl} class="space-y-2 {shake ? 'motion-safe:animate-shake' : ''}">
<div class="space-y-1">
<label for={emailId} class="block text-xs font-semibold text-emphasis"> Email </label>
<div>
+12 -7
View File
@@ -50,14 +50,19 @@
let jobProgressReset: () => void = () => {}
let stepHistoryLoader = getStepHistoryLoaderContext()
// Every explicit run re-evaluates the args with errors surfaced. The reactive evaluations
// that follow each flow edit stay quiet, so without this a failing expression is silently
// `undefined` in what the run is built from. Manually edited args are preserved across the
// refresh by `initializeFromSchema`.
export function runTestWithStepArgs() {
const args = stepsInputArgs.getStepArgs(mod.id)
runTest(args)
}
export function loadArgsAndRunTest() {
stepsInputArgs?.updateStepArgs(mod.id, flowStateStore.val, flowStore?.val, previewArgs?.val)
runTestWithStepArgs()
stepsInputArgs?.updateStepArgs(
mod.id,
flowStateStore.val,
flowStore?.val,
previewArgs?.val,
true
)
runTest(stepsInputArgs.getStepArgs(mod.id))
}
// A step's timeout is an InputTransform. Only a static numeric value can be applied
@@ -0,0 +1,73 @@
<script lang="ts" module>
export interface ProjectContentCounts {
apps: number
flows: number
scripts: number
resources: number
/** Only the import step counts these. */
triggers?: number
migrations?: number
}
/** The kinds, in the order a project is read. Shared by the badges and the sentence
* below so the same project can never be counted two ways. */
function kinds(counts: ProjectContentCounts) {
return [
{ label: 'app', count: counts.apps },
{ label: 'flow', count: counts.flows },
{ label: 'script', count: counts.scripts },
{ label: 'resource', count: counts.resources },
{ label: 'trigger', count: counts.triggers ?? 0 },
{ label: 'data table migration', count: counts.migrations ?? 0 }
].filter((c) => c.count > 0)
}
/**
* The same counts as one line of text, for callers with a row to sit on rather than
* a space for chips — the import step names them beside the task that imports them.
* Empty when a project has nothing in it, so a caller can drop the whole phrase.
*/
export function contentSummary(counts: ProjectContentCounts): string {
return kinds(counts)
.map((c) => `${c.count} ${c.label}${c.count === 1 ? '' : 's'}`)
.join(', ')
}
</script>
<script lang="ts">
import { Code2, Database, LayoutDashboard, Table2, Zap } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
// What a project contains, as one row of badges. Shared by the wizard's project
// card and its import step so the same project never gets counted two ways.
interface Props {
counts: ProjectContentCounts
}
let { counts }: Props = $props()
// Transparent badges throughout: six kinds in six colours turned a summary into
// a paint chart. The icon carries the kind, the colour carries nothing.
//
// Zero counts are dropped rather than shown: a project with no apps should read
// as "no apps", not as a "0 apps" chip the eye has to discount.
const ICONS: Record<string, any> = {
app: LayoutDashboard,
flow: BarsStaggered,
script: Code2,
resource: Database,
trigger: Zap,
'data table migration': Table2
}
const shown = $derived(kinds(counts).map((c) => ({ ...c, icon: ICONS[c.label] })))
</script>
<div class="flex flex-wrap items-center gap-1.5">
{#each shown as c (c.label)}
<Badge color="transparent" small icon={{ icon: c.icon, position: 'left' }}>
{c.count}
{c.label}{c.count === 1 ? '' : 's'}
</Badge>
{/each}
</div>
@@ -11,7 +11,7 @@
interface Props {
disabled?: boolean
selectedRepository?: string | undefined
accountId: string
installationId: number
initialRepositories: Repository[]
totalCount: number
perPage: number
@@ -23,7 +23,7 @@
let {
disabled = false,
selectedRepository = $bindable(),
accountId,
installationId,
initialRepositories,
totalCount,
perPage,
@@ -67,8 +67,8 @@
page: nextPage
})
// Find the matching installation and get its repositories
const installation = installations.find((inst) => inst.account_id === accountId)
// Match on installation_id: several installations can share one account_id
const installation = installations.find((inst) => inst.installation_id === installationId)
if (installation?.repositories) {
// Append new repos to existing ones
@@ -95,8 +95,8 @@
}))}
placeholder="Select repository..."
clearable
disabled={disabled}
bind:filterText={filterText}
{disabled}
bind:filterText
bind:value={selectedRepository}
/>
{#if hasMoreRepos}
@@ -22,8 +22,16 @@
let {
workspace = undefined,
disableChatOffset = false,
onRestored = undefined
}: { workspace?: string; disableChatOffset?: boolean; onRestored?: () => void } = $props()
onRestored = undefined,
onSaved = undefined
}: {
workspace?: string
disableChatOffset?: boolean
onRestored?: () => void
/** Fires after Save has written, for a caller showing state derived from the
* resource — `onRestored` only covers restoring an old version. */
onSaved?: () => void
} = $props()
let drawer: Drawer | undefined = $state()
let historyDrawer: Drawer | undefined = $state()
@@ -149,9 +157,14 @@
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
on:click={() => {
resourceEditor?.save()
on:click={async () => {
// Closed before the write is awaited, the way it always was: `save()` toasts its
// own failures and never rejects, so waiting would only add visible lag to every
// caller of this drawer. `onSaved` still fires after the write lands.
const saved = resourceEditor?.save()
drawer?.closeDrawer()
await saved
onSaved?.()
}}
disabled={!canSave}
>
+8 -2
View File
@@ -19,6 +19,7 @@
import { page } from '$app/state'
import { replaceState } from '$app/navigation'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
@@ -53,6 +54,8 @@
args = scriptArgs
psCommonParams = commonParams
reloadArgs++
// `reloadArgs` only keys the form; the JSON editor reads its payload once, at mount.
syncJsonEditor()
}
export async function run(overrideScheduledForStr?: string | undefined | null) {
@@ -199,8 +202,10 @@
return result
}
export function setCode(code: string) {
jsonEditor?.setCode(code)
/** Rewrite the open JSON editor from the current args. Only for args replaced from outside
* the editor: entering the JSON view already starts from whatever `args` holds. */
export function syncJsonEditor() {
jsonEditor?.setCode(argsToJsonPayload(runnable?.schema, args))
}
$effect(() => {
overrideTag
@@ -320,6 +325,7 @@
args = enforceDisabledDefaults(e.detail)
}
}}
initialCode={argsToJsonPayload(runnable.schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
@@ -15,6 +15,7 @@
import { Plus } from 'lucide-svelte'
import ArgInput from './ArgInput.svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { watch } from 'runed'
import { deepEqual } from 'fast-equals'
import {
dragHandleZone,
@@ -155,6 +156,11 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
watch(
() => ws,
() => itemPicker?.reloadItems()
)
let resourceTypes: string[] | undefined = $state(undefined)
async function loadResourceTypes() {
+56 -16
View File
@@ -122,6 +122,7 @@
import { updateDelegateToGitRepoConfig, insertAdditionalInventories } from '$lib/ansibleUtils'
import { copilotInfo } from '$lib/aiStore'
import JsonInputs from '$lib/components/JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import Toggle from './Toggle.svelte'
import { deepEqual } from 'fast-equals'
import { usePreparedAssetSqlQueries } from '$lib/infer.svelte'
@@ -310,6 +311,10 @@
let moduleTestState: Record<string, { args: Record<string, any>; schema: Schema }> = $state({})
let testPanelArgs: Record<string, any> = $state({})
let testPanelSchema: Schema = $state(emptySchema())
// Bumped whenever the args under test are replaced from outside the arg panel. Both arg
// views key off it: without a bump the JSON editor keeps showing, and on the next
// keystroke commits, the payload it was seeded with for the previous args.
let argsRender = $state(0)
// editorCode is what the editor shows; code always holds the main script content
let editorCode: string = $state(code)
// Sync editorCode when code changes externally (template reset, copilot,
@@ -329,7 +334,13 @@
})
function switchToModule(modulePath: string) {
if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) {
// Re-clicking the tab you are already on is a no-op. Re-running the body would reset this
// module's test state whenever its inference is still pending or has failed (the catch
// leaves `moduleTestState` unwritten), losing both the filled-in args and the arg views.
if (activeModuleTab === modulePath) {
return
}
if (activeModuleTab !== null && modules) {
// Switching from another module: save its content and test state
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
@@ -345,13 +356,20 @@
} else {
testPanelArgs = {}
testPanelSchema = emptySchema()
// Inference lands after the bump below, so the editor opens on `{}` and the arg
// views follow the schema in once it arrives. Remounting them again on arrival
// instead would discard anything typed while it was in flight.
inferModuleSchema()
}
argsRender++
}
}
function switchToMain() {
if (activeModuleTab !== null && modules) {
if (activeModuleTab === null) {
return
}
if (modules) {
// Save current module content and test state
modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode }
moduleTestState[activeModuleTab] = { args: testPanelArgs, schema: testPanelSchema }
@@ -360,6 +378,7 @@
editorCode = code
lastSyncedCode = code
editor?.setCode(editorCode)
argsRender++
}
// Whether the open file is tested as a runnable of its own. A `__mod` helper
@@ -854,6 +873,7 @@
export function setArgs(nargs: Record<string, any>) {
args = nargs
argsRender++
}
export async function runTest(opts?: { cascade?: boolean; skipDdlGuard?: boolean }) {
@@ -1654,15 +1674,30 @@
$effect(() => {
!hasPreprocessor && (selectedTab = 'main')
})
// `main` and `preprocessor` describe the same args under different schemas; every other tab
// (`diagram`) runs against main's schema, so it collapses into `main` here.
let lastSchemaTab = untrack(() => (selectedTab === 'preprocessor' ? 'preprocessor' : 'main'))
$effect(() => {
// Only depend on selectedTab (preprocessor ↔ main toggle).
// Code changes are handled by the editor on:change handler and
// explicit inferSchema calls (initContent, onMount), so we read
// `code` inside untrack to avoid a redundant double-inference race.
selectedTab && untrack(() => code && inferSchema(code))
selectedTab &&
untrack(() => {
const schemaTab = selectedTab === 'preprocessor' ? 'preprocessor' : 'main'
const switched = schemaTab !== lastSchemaTab
lastSchemaTab = schemaTab
if (!code) return
// Bump on the switch itself, not on the inference it starts: the other tab's schema
// only lands once that resolves, and remounting the arg views then would discard
// anything typed while it was in flight. An untouched editor follows the schema in.
if (switched) {
argsRender++
}
inferSchema(code)
})
})
let argsRender = $state(0)
export async function updateArgs(newArgs: Record<string, any>) {
if (Object.keys(newArgs).length > 0) {
args = { ...newArgs }
@@ -2299,19 +2334,24 @@
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (onModuleArgs) {
testPanelArgs = e.detail
} else {
args = e.detail
{#key argsRender}
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (onModuleArgs) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
}}
initialCode={onModuleArgs
? argsToJsonPayload(testPanelSchema, testPanelArgs)
: argsToJsonPayload(schema, args)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
{/key}
</div>
{:else}
<div class="px-4">

Some files were not shown because too many files have changed in this diff Show More