mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
Merge branch 'main' into hub-integrations-prominence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.796.0"
|
||||
".": "1.799.0"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+22
@@ -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
@@ -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" \
|
||||
|
||||
Generated
+257
-168
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -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 @@
|
||||
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;
|
||||
@@ -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
@@ -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
@@ -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]) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Generated
+60
-24
@@ -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
|
||||
|
||||
+40
-4
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
Generated
+6
-5
@@ -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,
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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/> "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/> "foo": "12"<br/>}`}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/> "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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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/> "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() {
|
||||
|
||||
@@ -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/> "foo": "12"<br/>}`}
|
||||
/>
|
||||
}}
|
||||
initialCode={onModuleArgs
|
||||
? argsToJsonPayload(testPanelSchema, testPanelArgs)
|
||||
: argsToJsonPayload(schema, args)}
|
||||
updateOnBlur={false}
|
||||
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/> "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
Reference in New Issue
Block a user