Merge branch 'main' into feat/asset-graph-view

This commit is contained in:
Ruben Fiszel
2026-06-17 09:17:16 +00:00
100 changed files with 3783 additions and 989 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## [1.728.1](https://github.com/windmill-labs/windmill/compare/v1.728.0...v1.728.1) (2026-06-17)
### Bug Fixes
* **backend:** purge workspace_diff cache on workspace delete ([#9627](https://github.com/windmill-labs/windmill/issues/9627)) ([8a3f69d](https://github.com/windmill-labs/windmill/commit/8a3f69dda8f2088fb859ed8ed6e54458940423d0))
* **cli:** fall back to esbuild-wasm on native host/binary mismatch ([#9629](https://github.com/windmill-labs/windmill/issues/9629)) ([86d1d16](https://github.com/windmill-labs/windmill/commit/86d1d160f0d3bd9faabdafada07e2956dd98445d))
* **frontend:** persist session-editor draft path/summary edits + per-line diff tooltips ([#9622](https://github.com/windmill-labs/windmill/issues/9622)) ([e4bfeb2](https://github.com/windmill-labs/windmill/commit/e4bfeb29bc4e89669863b5f6396904a331167658))
## [1.728.0](https://github.com/windmill-labs/windmill/compare/v1.727.0...v1.728.0) (2026-06-16)
### Features
* **frontend:** adapt AI-chat/sessions drafts to DB-backed model ([#9601](https://github.com/windmill-labs/windmill/issues/9601)) ([611c70a](https://github.com/windmill-labs/windmill/commit/611c70acd211cf4b8f8308da4a264c670a2f5f43))
* **frontend:** consolidate draft-migration errors into a single toast + modal ([#9612](https://github.com/windmill-labs/windmill/issues/9612)) ([bc0d5bf](https://github.com/windmill-labs/windmill/commit/bc0d5bf241df3633921bd9d43d171e91034fbfcf))
* **frontend:** dedup user drafts against the deployed baseline ([#9618](https://github.com/windmill-labs/windmill/issues/9618)) ([a2ce446](https://github.com/windmill-labs/windmill/commit/a2ce44645fdbfa98bf250fac2d15d2b5b26c4b47))
### Bug Fixes
* **frontend:** reset deleteWorkspaceForkModal on confirm in SidebarContent ([#9619](https://github.com/windmill-labs/windmill/issues/9619)) ([7cb5c6e](https://github.com/windmill-labs/windmill/commit/7cb5c6e749b2020dee5ee1499f0dc69c5109a6d8))
* **frontend:** session Drafts drawer uses raw_app kind for the raw-app diff ([#9617](https://github.com/windmill-labs/windmill/issues/9617)) ([46288b6](https://github.com/windmill-labs/windmill/commit/46288b6143efae4dfdf6fe068b97a1e8831fce6a))
* **nativets:** respect custom CA certs in in-process fetch runtime ([#9615](https://github.com/windmill-labs/windmill/issues/9615)) ([41562c7](https://github.com/windmill-labs/windmill/commit/41562c7d7c708d7d056d9b3d0c39b994a6f4a016))
* **ResourceForm:** initialize JSON editor when resource type schema is unavailable ([#9611](https://github.com/windmill-labs/windmill/issues/9611)) ([5a24057](https://github.com/windmill-labs/windmill/commit/5a2405743b4622fc1021109114d007057abd5dfd))
* show folder labels in the folder list table ([#9620](https://github.com/windmill-labs/windmill/issues/9620)) ([651fa13](https://github.com/windmill-labs/windmill/commit/651fa13ee80ff76e5a53ef1ed545b03ce6792294))
* show last updated date per user in other-users-drafts modal ([#9614](https://github.com/windmill-labs/windmill/issues/9614)) ([f6104ce](https://github.com/windmill-labs/windmill/commit/f6104ce05c4005ffb9fe8112782d1ef6d3065300))
## [1.727.0](https://github.com/windmill-labs/windmill/compare/v1.726.1...v1.727.0) (2026-06-16)
### Features
* support temp_script_refs in wmill dev for local relative imports ([#9554](https://github.com/windmill-labs/windmill/issues/9554)) ([33ac287](https://github.com/windmill-labs/windmill/commit/33ac287065742df53f363a5fe09f54f5584a85a6))
### Bug Fixes
* **cli:** harden legacy flow lock migration ordering and collision guard ([#9557](https://github.com/windmill-labs/windmill/issues/9557)) ([cd09870](https://github.com/windmill-labs/windmill/commit/cd098700c2cd8d7e9150f760938f4eaf34d188ec))
* **cli:** include __mod/ folder in gitSyncIncludePattern for scripts ([#9606](https://github.com/windmill-labs/windmill/issues/9606)) ([252c1b3](https://github.com/windmill-labs/windmill/commit/252c1b35fc716c3486109d89615127c588bbe90a))
* **frontend:** allow same-origin redirects in isValidLogoutRedirect ([#9568](https://github.com/windmill-labs/windmill/issues/9568)) ([8500435](https://github.com/windmill-labs/windmill/commit/8500435e82231e13a1b8a874fd0545f0a0a73fee))
* **frontend:** make UserDraft read-after-write work without live entry ([#9609](https://github.com/windmill-labs/windmill/issues/9609)) ([51e82d7](https://github.com/windmill-labs/windmill/commit/51e82d7c6d30c66c84236feb743c09929934e564))
* **frontend:** seed detached user-draft handles so new-item drawers render ([#9608](https://github.com/windmill-labs/windmill/issues/9608)) ([9e3c0de](https://github.com/windmill-labs/windmill/commit/9e3c0decf95378c66055d82215c15cd3bf4a69cb))
* **frontend:** strip server-managed fields from value diffs ([#9599](https://github.com/windmill-labs/windmill/issues/9599)) ([c213801](https://github.com/windmill-labs/windmill/commit/c213801b5aee54d801c14b9eb31422f2a312ef7e))
## [1.726.1](https://github.com/windmill-labs/windmill/compare/v1.726.0...v1.726.1) (2026-06-15)
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "03c8a797ae734ff76e227259ae011ef6d35f892fe95448c58ec13dea58eee3fa"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_diff\n WHERE source_workspace_id = 'wm-fork-test-workspace'\n OR fork_workspace_id = 'wm-fork-test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "126cf6d54f8d2c916cd799f6663892119a94d66637062d1bf5a5fe97d89f8096"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM skip_workspace_diff_tally WHERE workspace_id = 'wm-fork-test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "353c6a648720e118c0d82ef055c60033f1969fb39155e7e07bb6730505e254b7"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO skip_workspace_diff_tally (workspace_id) VALUES ('wm-fork-test-workspace')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "5c2d1ae706e997bbca27dadb99cc0d55ffba53bc23ae69181a0d671c640b9ba7"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "5eaf2e0bbede9dd80a70f2b37538239273443c7d56a1f7732988208fe3c9c58f"
}
@@ -1,12 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
"query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username?",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "draft_saved_at!",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -50,8 +55,9 @@
]
},
"nullable": [
null
null,
false
]
},
"hash": "590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d"
"hash": "6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('wm-fork-test-workspace', 'test-workspace', 'f/shared/other', 'script', 0, 1, true)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "ba7d01509662be936c000aab4ff60f57be839ae365e5cbbb4838a7b622d1771a"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)\n VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/leaky', 'script', 1, 0, true, true, true)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "ca1a39a56d36802418048ddad1301f16394c719ef55494b5f286bc402292c420"
}
+184 -183
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.726.1"
version = "1.728.1"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.726.1"
version = "1.728.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -0,0 +1,3 @@
-- Irreversible data backfill: once an email is attached, the row is
-- indistinguishable from a draft that was always per-user owned, so the
-- original NULL state cannot be reconstructed. No-op on revert.
@@ -0,0 +1,28 @@
-- Backfill the owner `email` on legacy drafts (rows persisted before per-user
-- sync, hence `email IS NULL`). A user-owned draft path is `u/<username>/...`,
-- so resolve `<username>` against `usr` for the same workspace and adopt that
-- user's email.
--
-- Guards:
-- - the resolved email must exist in `password` (the `draft_password_fkey`
-- target), or the UPDATE would violate the FK;
-- - skip rows that would collide with an existing per-user draft at the same
-- (workspace_id, path, typ, email) under the `draft_pkey_with_user` partial
-- unique index — the per-user row is authoritative, so the legacy row is
-- left untouched.
UPDATE draft d
SET email = u.email
FROM usr u
WHERE d.email IS NULL
AND split_part(d.path, '/', 1) = 'u'
AND split_part(d.path, '/', 2) <> ''
AND u.workspace_id = d.workspace_id
AND u.username = split_part(d.path, '/', 2)
AND EXISTS (SELECT 1 FROM password p WHERE p.email = u.email)
AND NOT EXISTS (
SELECT 1 FROM draft d2
WHERE d2.workspace_id = d.workspace_id
AND d2.path = d.path
AND d2.typ = d.typ
AND d2.email = u.email
);
@@ -0,0 +1,3 @@
-- Irreversible data cleanup: deleted orphaned rows and reset cached verdicts
-- cannot be reconstructed. No-op on rollback.
SELECT 1;
@@ -0,0 +1,31 @@
-- workspace_diff / skip_workspace_diff_tally are keyed by workspace id with no FK
-- cascade, so until the matching delete_workspace cleanup landed, deleting a fork
-- left its cached diff rows behind. Workspace ids are reused (recreating a fork
-- under the same name), so those orphaned rows leaked onto the new fork and
-- produced a spurious "changes not visible" warning that hid the deploy button.
-- Drop rows referencing workspaces that no longer exist (leftovers from past deletes).
DELETE FROM workspace_diff
WHERE source_workspace_id NOT IN (SELECT id FROM workspace)
OR fork_workspace_id NOT IN (SELECT id FROM workspace);
DELETE FROM skip_workspace_diff_tally
WHERE workspace_id NOT IN (SELECT id FROM workspace);
-- Reused-id victims keep a skip-tally row pointing at a LIVE workspace, so the
-- orphan cleanup above can't reach them. compare_workspaces consults
-- skip_workspace_diff_tally first and short-circuits to an empty comparison, so a
-- stale skip row would also defeat the has_changes reset below. A genuinely
-- skipped workspace is excluded from tallying and therefore never has
-- workspace_diff rows — so a skip row that coexists with workspace_diff rows for
-- that id is provably leaked from a previous occupant of a reused id. Drop those.
DELETE FROM skip_workspace_diff_tally s
WHERE EXISTS (
SELECT 1 FROM workspace_diff d WHERE d.fork_workspace_id = s.workspace_id
);
-- Remaining reused-id victims keep workspace_diff rows pointing at live
-- workspaces, so they can't be told apart from valid cache. Reset the cached
-- verdict to force a recompute on the next compare; compare_workspaces
-- re-evaluates NULL rows and corrects or deletes them.
UPDATE workspace_diff SET has_changes = NULL;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.726.1"
version = "1.728.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.726.1"
version = "1.728.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -1336,3 +1336,188 @@ async fn test_compare_workspaces_fork_only_folder_visibility(
Ok(())
}
/// Regression test: deleting a fork must purge its `workspace_diff` and
/// `skip_workspace_diff_tally` rows. These tables are keyed by workspace id with
/// no FK cascade, and a fork id is reused when a fork is deleted and recreated
/// under the same name. If the cached diff rows survive the delete, they leak
/// onto the next fork sharing that id and produce a spurious "changes not
/// visible" warning that hides the deploy button (WIN-2066).
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_delete_fork_purges_workspace_diff(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let base_url = format!("http://localhost:{port}/api");
// Create the fork so the caller owns it (delete is authorized for fork owners).
let fork_response = client
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-test-workspace",
"name": "Test Fork",
"color": "#0000ff"
}))
.send()
.await?;
assert!(
fork_response.status().is_success(),
"Fork creation should succeed: {}",
fork_response.status()
);
// Seed cached diff state for the fork: as the fork side of a pair, as the
// source side of a pair, and a skip-tally row.
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/leaky', 'script', 1, 0, true, true, true)"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
VALUES ('wm-fork-test-workspace', 'test-workspace', 'f/shared/other', 'script', 0, 1, true)"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO skip_workspace_diff_tally (workspace_id) VALUES ('wm-fork-test-workspace')"
)
.execute(&db)
.await?;
// Delete the fork through the real handler.
let delete_response = client
.client()
.delete(&format!("{base_url}/workspaces/delete/wm-fork-test-workspace"))
.send()
.await?;
assert!(
delete_response.status().is_success(),
"Fork deletion should succeed: {}",
delete_response.status()
);
let leftover_diffs = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_diff
WHERE source_workspace_id = 'wm-fork-test-workspace'
OR fork_workspace_id = 'wm-fork-test-workspace'"
)
.fetch_one(&db)
.await?;
assert_eq!(
leftover_diffs,
Some(0),
"workspace_diff rows referencing the deleted fork must be purged"
);
let leftover_skip = sqlx::query_scalar!(
"SELECT COUNT(*) FROM skip_workspace_diff_tally WHERE workspace_id = 'wm-fork-test-workspace'"
)
.fetch_one(&db)
.await?;
assert_eq!(
leftover_skip,
Some(0),
"skip_workspace_diff_tally row for the deleted fork must be purged"
);
Ok(())
}
/// Regression test: creating a fork must start with clean diff state even when
/// the (reusable) fork id was previously occupied by a deleted fork. Stale
/// `workspace_diff` / `skip_workspace_diff_tally` rows left behind by an earlier
/// occupant would otherwise leak onto the new fork — a stale skip row suppresses
/// comparison entirely, and stale diff rows produce a spurious "changes not
/// visible" warning that hides the deploy button (WIN-2066).
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_create_fork_purges_stale_diff_state(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let base_url = format!("http://localhost:{port}/api");
// Simulate leftovers from a previously deleted fork that reused this id:
// diff rows on both sides plus a skip-tally row, with no workspace yet.
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/shared/leaky', 'script', 1, 0, true, true, true)"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
VALUES ('wm-fork-test-workspace', 'test-workspace', 'f/shared/other', 'script', 0, 1, true)"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO skip_workspace_diff_tally (workspace_id) VALUES ('wm-fork-test-workspace')"
)
.execute(&db)
.await?;
// Create the fork reusing that id; the conflict check passes because no
// workspace row exists for it.
let fork_response = client
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-test-workspace",
"name": "Test Fork",
"color": "#0000ff"
}))
.send()
.await?;
assert!(
fork_response.status().is_success(),
"Fork creation should succeed: {}",
fork_response.status()
);
let leftover_diffs = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_diff
WHERE source_workspace_id = 'wm-fork-test-workspace'
OR fork_workspace_id = 'wm-fork-test-workspace'"
)
.fetch_one(&db)
.await?;
assert_eq!(
leftover_diffs,
Some(0),
"stale workspace_diff rows must be purged on fork creation"
);
let leftover_skip = sqlx::query_scalar!(
"SELECT COUNT(*) FROM skip_workspace_diff_tally WHERE workspace_id = 'wm-fork-test-workspace'"
)
.fetch_one(&db)
.await?;
assert_eq!(
leftover_skip,
Some(0),
"stale skip_workspace_diff_tally row must be purged on fork creation"
);
Ok(())
}
@@ -3661,6 +3661,30 @@ async fn check_fork_w_id_conflict(db: &DB, w_id: &str) -> Result<()> {
}
}
/// A fork id is reusable: it is freed when a fork is deleted and can be claimed
/// again under the same name. `workspace_diff` and `skip_workspace_diff_tally`
/// are keyed by workspace id with no FK cascade, so a freshly created fork could
/// inherit cached diff state from a previous occupant of its id — a stale skip
/// row suppresses comparison entirely, and stale workspace_diff rows produce a
/// spurious "changes not visible" warning that hides the deploy button. Clear
/// both so a new fork always starts with clean diff state, regardless of how the
/// id was freed.
async fn purge_stale_fork_diff_state(db: &DB, fork_id: &str) -> Result<()> {
sqlx::query!(
"DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
fork_id
)
.execute(db)
.await?;
sqlx::query!(
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1",
fork_id
)
.execute(db)
.await?;
Ok(())
}
lazy_static::lazy_static! {
pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = {
@@ -4864,6 +4888,7 @@ async fn create_workspace_fork_branch(
// Fail before creating any git branch so a name conflict doesn't leave a
// dangling branch on the synced repos.
check_fork_w_id_conflict(&db, &nw.id).await?;
purge_stale_fork_diff_state(&db, &nw.id).await?;
Ok(Json(
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
@@ -5008,6 +5033,7 @@ async fn create_workspace_fork(
// re-using a taken (possibly archived) fork id reports the actual
// conflict instead of a misleading "maximum number of workspaces" error.
check_fork_w_id_conflict(&db, &nw.id).await?;
purge_stale_fork_diff_state(&db, &nw.id).await?;
#[cfg(not(feature = "enterprise"))]
_check_nb_of_workspaces(&db).await?;
@@ -868,6 +868,25 @@ pub(crate) async fn delete_workspace(
.execute(&mut *tx)
.await?;
// workspace_diff and skip_workspace_diff_tally are keyed by workspace id with no
// FK cascade. A fork id is reused when a fork is deleted and recreated under the
// same name, so leaving these rows behind leaks the previous fork's cached diff
// verdicts onto the new fork — causing a spurious "changes not visible" warning
// that hides the deploy button.
sqlx::query!(
"DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1",
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
.execute(&mut *tx)
.await?;
+8 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.726.1
version: 1.728.1
title: Windmill API
contact:
@@ -21533,6 +21533,13 @@ components:
Workspace username of the draft owner. `null` represents
the legacy workspace-level (NULL-email) row. Emails never
leave the server.
draft_saved_at:
type: string
format: date-time
description: |
When this user's draft was last saved (`draft.created_at`),
surfaced in the fork modal as "Last updated".
required: [draft_saved_at]
required: [is_draft]
UserDraftItemKind:
type: string
+5 -1
View File
@@ -188,6 +188,9 @@ pub struct WithDraftQuery {
pub struct OtherDraftUser {
/// `None` represents a legacy workspace-level draft (no owner).
pub username: Option<String>,
/// When this user's draft was last saved (the `draft.created_at` upsert
/// timestamp), surfaced in the fork modal as "Last updated".
pub draft_saved_at: DateTime<Utc>,
}
/// Response wrapper: the deployed entity untouched plus the authed user's
@@ -240,7 +243,8 @@ async fn fetch_other_drafts_users(
// row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL).
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?"
r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?",
d.created_at as "draft_saved_at!"
FROM draft d
LEFT JOIN usr u
ON u.workspace_id = d.workspace_id
@@ -48,6 +48,9 @@ futures.workspace = true
sqlx.workspace = true
rustls.workspace = true
[dev-dependencies]
rcgen = "0.13.2"
[build-dependencies]
deno_fetch.workspace = true
deno_webidl.workspace = true
@@ -0,0 +1,184 @@
//! Regression tests for custom CA support in the in-process nativets fetch
//! runtime (WIN-2055).
//!
//! `deno_fetch` with `root_cert_store_provider: None` trusts only the Mozilla
//! webpki roots, so scripts calling internal APIs fronted by a corporate CA
//! failed with `invalid peer certificate: UnknownIssuer`. The provider built by
//! `build_native_root_cert_store_provider` merges CAs from `DENO_CERT` /
//! `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS` / `DENO_TLS_CA_STORE=system` into the
//! default store. These tests pin that behaviour.
use crate::{build_native_root_cert_store_provider, load_pem_certs_from_path};
/// The CA-related env vars the provider inspects. Cleared around each test so a
/// CI runner that happens to set one of them can't perturb the result.
const CA_ENV_VARS: &[&str] = &[
"DENO_CERT",
"SSL_CERT_FILE",
"NODE_EXTRA_CA_CERTS",
"DENO_TLS_CA_STORE",
];
fn write_test_ca(suffix: &str) -> std::path::PathBuf {
let cert = rcgen::generate_simple_self_signed(vec!["windmill-test-ca".to_string()])
.expect("generate self-signed cert");
let pem = cert.cert.pem();
let path = std::env::temp_dir().join(format!(
"windmill-nativets-ca-{}-{}.pem",
std::process::id(),
suffix
));
std::fs::write(&path, pem).expect("write cert");
path
}
/// Serializes the env-mutating tests against each other — env is process-global,
/// so concurrent `set_var`/`remove_var` would otherwise interleave.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_cleared_ca_env<T>(f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let saved: Vec<(&str, Option<String>)> = CA_ENV_VARS
.iter()
.map(|k| (*k, std::env::var(k).ok()))
.collect();
for k in CA_ENV_VARS {
std::env::remove_var(k);
}
let out = f();
for (k, v) in saved {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
out
}
#[test]
fn load_pem_certs_parses_self_signed_cert() {
let path = write_test_ca("load");
let certs = load_pem_certs_from_path(path.to_str().unwrap()).expect("load certs");
assert_eq!(certs.len(), 1, "expected exactly one cert in the bundle");
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_pem_certs_errors_on_missing_file() {
let missing = std::env::temp_dir().join("windmill-nativets-does-not-exist.pem");
assert!(load_pem_certs_from_path(missing.to_str().unwrap()).is_err());
}
#[test]
fn no_ca_env_yields_no_provider() {
// serialize against the env-mutating tests via the shared guard
with_cleared_ca_env(|| {
assert!(
build_native_root_cert_store_provider().is_none(),
"without any CA env var the provider must stay None (default-only behaviour)"
);
});
}
#[test]
fn ssl_cert_file_adds_custom_root() {
let path = write_test_ca("ssl");
with_cleared_ca_env(|| {
std::env::set_var("SSL_CERT_FILE", &path);
let provider = build_native_root_cert_store_provider()
.expect("a custom CA was configured, provider must be Some");
let store = provider.get_or_try_init().expect("store init");
let default_len = deno_tls::create_default_root_cert_store().len();
assert_eq!(
store.len(),
default_len + 1,
"custom CA should be added on top of the Mozilla defaults"
);
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn node_extra_ca_certs_adds_custom_root() {
let path = write_test_ca("node");
with_cleared_ca_env(|| {
std::env::set_var("NODE_EXTRA_CA_CERTS", &path);
let provider = build_native_root_cert_store_provider()
.expect("provider must be Some for NODE_EXTRA_CA_CERTS");
let store = provider.get_or_try_init().expect("store init");
assert_eq!(
store.len(),
deno_tls::create_default_root_cert_store().len() + 1
);
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn multiple_ca_env_vars_pointing_at_same_file_dedupe_to_one_root() {
// The tracing proxy points SSL_CERT_FILE, NODE_EXTRA_CA_CERTS and DENO_CERT at
// the same bundle; the path dedupe in build_native_root_cert_store_provider
// loads it once, so the store grows by exactly one (rustls' add does not dedupe).
let path = write_test_ca("dupe");
with_cleared_ca_env(|| {
std::env::set_var("SSL_CERT_FILE", &path);
std::env::set_var("NODE_EXTRA_CA_CERTS", &path);
std::env::set_var("DENO_CERT", &path);
let provider = build_native_root_cert_store_provider().expect("provider must be Some");
let store = provider.get_or_try_init().expect("store init");
assert_eq!(
store.len(),
deno_tls::create_default_root_cert_store().len() + 1
);
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn worker_config_env_var_adds_custom_root() {
// A CA configured only through the worker-group config (DB `env_vars_static`
// / allowlisted forwarded vars) lands in `WORKER_CONFIG.env_vars`, not the
// worker's own process env. Child Deno/Bun jobs receive it via `.envs(...)`;
// nativets must pick it up from the same place. Regression for the in-process
// path missing that source.
use windmill_common::worker::WORKER_CONFIG;
let path = write_test_ca("workercfg");
with_cleared_ca_env(|| {
let prev = WORKER_CONFIG.load_full();
let mut cfg = (*prev).clone();
cfg.env_vars.insert(
"SSL_CERT_FILE".to_string(),
path.to_string_lossy().into_owned(),
);
WORKER_CONFIG.store(std::sync::Arc::new(cfg));
let provider = build_native_root_cert_store_provider();
// restore before asserting so a failure can't leak the mutated global
WORKER_CONFIG.store(prev);
let provider = provider.expect("worker-config CA must produce a provider");
let store = provider.get_or_try_init().expect("store init");
assert_eq!(
store.len(),
deno_tls::create_default_root_cert_store().len() + 1
);
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn deno_cert_adds_custom_root() {
let path = write_test_ca("deno");
with_cleared_ca_env(|| {
std::env::set_var("DENO_CERT", &path);
let provider =
build_native_root_cert_store_provider().expect("provider must be Some for DENO_CERT");
let store = provider.get_or_try_init().expect("store init");
assert_eq!(
store.len(),
deno_tls::create_default_root_cert_store().len() + 1
);
});
let _ = std::fs::remove_file(&path);
}
+123 -1
View File
@@ -18,6 +18,9 @@ pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult};
#[cfg(test)]
mod smoke_tests;
#[cfg(test)]
mod cert_tests;
use std::{
borrow::Cow,
cell::RefCell,
@@ -35,8 +38,10 @@ use deno_core::{
v8::{self, IsolateHandle},
Extension, JsRuntime, OpState, PollEventLoopOptions, RuntimeOptions,
};
use deno_error::JsErrorBox;
use deno_fetch::FetchPermissions;
use deno_net::NetPermissions;
use deno_tls::{rustls::pki_types::CertificateDer, rustls::RootCertStore, RootCertStoreProvider};
use deno_web::{BlobStore, TimersPermission};
use itertools::Itertools;
use lazy_static::lazy_static;
@@ -214,6 +219,123 @@ lazy_static! {
Regex::new(r"^(https?)://(([^:@\s]+):([^:@\s]+)@)?([^:@\s]+)(:(\d+))?$").unwrap();
}
lazy_static! {
/// Root cert store for the in-process nativets fetch runtime.
///
/// Unlike the Deno/Bun executors, nativets never spawns a child process, so
/// the CA env vars those executors forward (`DENO_CERT`, `DENO_TLS_CA_STORE`,
/// `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS`) are never consumed by deno's CLI
/// layer. `deno_fetch` with `root_cert_store_provider: None` falls back to
/// the Mozilla webpki roots only, so corporate CAs fail with `UnknownIssuer`.
/// We read those env vars here and merge the certs into the default store.
///
/// Snapshotted once for the process lifetime, like the Deno executor's
/// `DENO_CERT`/`DENO_TLS_CA_STORE` lazy statics (`deno_executor.rs`): the
/// fetch root store is shared across all (potentially prewarmed) isolates, so
/// per-job CA reconfiguration is out of scope. A later `WORKER_CONFIG` reload
/// is not picked up until the process restarts.
static ref NATIVE_ROOT_CERT_STORE_PROVIDER: Option<Arc<dyn RootCertStoreProvider>> =
build_native_root_cert_store_provider();
}
struct NativeRootCertStoreProvider {
store: RootCertStore,
}
impl RootCertStoreProvider for NativeRootCertStoreProvider {
fn get_or_try_init(&self) -> Result<&RootCertStore, JsErrorBox> {
Ok(&self.store)
}
}
/// Resolve a CA-related env var the same way the child executors see it: the
/// worker's own process env, then the worker-group config (`env_vars_allowlist`
/// forwarded values + DB `env_vars_static` literals, resolved into
/// `WORKER_CONFIG.env_vars`). Child Deno/Bun jobs receive that config map via
/// `.envs(...)`, so nativets must consult it too or a CA set only through worker
/// config would silently not apply in-process.
fn resolve_ca_env_var(name: &str) -> Option<String> {
if let Ok(v) = std::env::var(name) {
if !v.is_empty() {
return Some(v);
}
}
windmill_common::worker::WORKER_CONFIG
.load()
.env_vars
.get(name)
.filter(|v| !v.is_empty())
.cloned()
}
/// Build a root cert store seeded with the Mozilla webpki roots plus any custom
/// CAs configured via env. Returns `None` when no custom CA is configured, which
/// preserves the previous default-only behaviour.
fn build_native_root_cert_store_provider() -> Option<Arc<dyn RootCertStoreProvider>> {
let mut store = deno_tls::create_default_root_cert_store();
let mut added = 0usize;
// File-path env vars, each pointing at a PEM bundle of one or more certs.
// `DENO_CERT` mirrors the Deno CLI; `SSL_CERT_FILE` is the OpenSSL standard
// also honoured by Bun/Node (via NODE_EXTRA_CA_CERTS). Dedupe by path because
// the tracing proxy points several of these at the same bundle, and rustls'
// RootCertStore::add does not dedupe — we'd otherwise trust the same root N times.
let mut seen_paths = std::collections::HashSet::new();
for var in ["DENO_CERT", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"] {
let Some(path) = resolve_ca_env_var(var).filter(|p| !p.is_empty()) else {
continue;
};
if !seen_paths.insert(path.clone()) {
continue;
}
match load_pem_certs_from_path(&path) {
Ok(certs) => {
for cert in certs {
if let Err(e) = store.add(cert) {
tracing::warn!("nativets: failed to add cert from {var}={path}: {e}");
} else {
added += 1;
}
}
}
Err(e) => tracing::warn!("nativets: failed to read CA file {var}={path}: {e}"),
}
}
// `DENO_TLS_CA_STORE=system` (comma-separated, may also contain `mozilla`)
// pulls in the OS trust store. Unlike the Deno CLI — where the list selects
// and orders the stores — this is purely additive: the Mozilla defaults are
// always seeded above, and `system` augments them. That is a strict superset
// of the public roots, which is what the corporate-CA use case needs.
if resolve_ca_env_var("DENO_TLS_CA_STORE")
.map(|v| v.split(',').any(|s| s.trim() == "system"))
.unwrap_or(false)
{
match deno_tls::deno_native_certs::load_native_certs() {
Ok(certs) => {
for cert in certs {
if store.add(CertificateDer::from(cert.0)).is_ok() {
added += 1;
}
}
}
Err(e) => tracing::warn!("nativets: failed to load system CA store: {e}"),
}
}
if added == 0 {
return None;
}
tracing::info!("nativets: loaded {added} custom CA cert(s) into fetch root store");
Some(Arc::new(NativeRootCertStoreProvider { store }))
}
fn load_pem_certs_from_path(path: &str) -> anyhow::Result<Vec<CertificateDer<'static>>> {
let file = std::fs::File::open(path)?;
let mut reader = std::io::BufReader::new(file);
deno_tls::load_certs(&mut reader).map_err(|e| anyhow::anyhow!(e))
}
// ── Public interface ─────────────────────────────────────────────────
/// Set up the deno_core/V8 runtime. Idempotent — safe to call multiple times.
@@ -433,7 +555,7 @@ pub(crate) fn create_nativets_runtime(
let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() };
let fetch_options = deno_fetch::Options {
root_cert_store_provider: None,
root_cert_store_provider: NATIVE_ROOT_CERT_STORE_PROVIDER.clone(),
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
proxy: ann.proxy.map(|x| deno_tls::Proxy::Http {
url: x.0,
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.726.1";
export const VERSION = "v1.728.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+3
View File
@@ -28,6 +28,9 @@ rmSync(outDir, { recursive: true, force: true });
// Build with bun — bundle everything except esbuild (platform-specific binary),
// svelte (optional, only needed for `wmill app bundle/dev`), and parser packages
// (loaded at runtime via init() with readFileSync for the .wasm binary).
// esbuild-wasm is not a dependency at all: the host/binary-mismatch fallback in
// esbuild_loader.ts downloads and caches the whole esbuild-wasm package at
// runtime, so it stays out of the bundle and the published dependencies.
console.log("Bundling with bun build...");
const buildResult = Bun.spawnSync([
"bun", "build", "src/main.ts",
+17 -16
View File
@@ -6,6 +6,7 @@ import * as log from "../../core/log.ts";
import { colors } from "@cliffy/ansi/colors";
import * as windmillUtils from "@windmill-labs/shared-utils";
import { readTextFile, readTextFileSync } from "../../utils/utils.ts";
import { getEsbuild, stopEsbuild } from "../../utils/esbuild_loader.ts";
export interface BundleOptions {
entryPoint?: string;
outDir?: string;
@@ -170,8 +171,9 @@ export async function ensureNodeModules(appDir?: string): Promise<void> {
export async function createBundle(
options: BundleOptions = {}
): Promise<BundleResult> {
// Dynamically import esbuild
const esbuild = await import("esbuild");
// Native esbuild with a transparent esbuild-wasm fallback on host/binary
// version mismatch (see esbuild_loader.ts).
const esbuild = await getEsbuild();
// Detect frameworks to determine default entry point.
// Use the entryPoint's directory if provided, otherwise fall back to cwd.
@@ -286,6 +288,10 @@ export async function createBundle(
outfile,
sourcemap,
minify,
// Keep outputs in memory: esbuild-wasm cannot write to the filesystem
// ("write" option unavailable), and the dist files were discarded after the
// read anyway. Native esbuild supports write:false + outputFiles too.
write: false as const,
define: {
"process.env.NODE_ENV": production ? '"production"' : '"development"',
},
@@ -307,29 +313,24 @@ export async function createBundle(
log.info(colors.green("✅ Bundle created successfully"));
// Read the generated files
const jsPath = path.join(process.cwd(), outfile);
const cssPath = path.join(process.cwd(), outDir, "bundle.css");
const outputFiles = result.outputFiles ?? [];
const jsFile = outputFiles.find((f) => f.path.endsWith(".js"));
const cssFile = outputFiles.find((f) => f.path.endsWith(".css"));
if (!fs.existsSync(jsPath)) {
throw new Error(`Expected JS bundle at ${jsPath} but file not found`);
if (!jsFile) {
throw new Error("Expected a JS bundle in esbuild output but none found");
}
const jsContent = readTextFileSync(jsPath);
const cssContent = fs.existsSync(cssPath)
? readTextFileSync(cssPath)
: "";
try {
fs.rmSync(distDir, { recursive: true });
} catch {
//ignore
}
return { js: jsContent, css: cssContent };
return { js: jsFile.text, css: cssFile?.text ?? "" };
} finally {
// Stop esbuild
await esbuild.stop();
// Stop the native esbuild service so the process can exit (no-op for wasm).
await stopEsbuild();
}
}
+5 -1
View File
@@ -437,7 +437,11 @@ async function dev(opts: DevOptions, appFolder?: string) {
const rawApp = (await yamlParseFile(rawAppPath)) as any;
const appPath = rawApp?.custom_path ?? "u/unknown/newapp";
// Dynamically import esbuild only when the dev command is called
// Dynamically import esbuild only when the dev command is called.
// Native-only here (no esbuild-wasm fallback via getEsbuild): dev is a local
// interactive command that relies on context()/watch, whose semantics under
// wasm are untested. The host/binary-mismatch fallback covers the bundling
// paths that run on workers/CI via `wmill sync push`.
const esbuild = await import("esbuild");
const host = opts.host ?? DEFAULT_HOST;
+3 -2
View File
@@ -58,6 +58,7 @@ import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts";
import { pollJobWithQueueLogging } from "../../utils/job_polling.ts";
import fs from "node:fs";
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
import { getEsbuild } from "../../utils/esbuild_loader.ts";
import { execSync } from "node:child_process";
import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts";
@@ -328,7 +329,7 @@ export async function handleFile(
}).toString();
log.info("Custom bundler executed for " + path);
} else {
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
log.info(`Started bundling ${path} ...`);
const startTime = performance.now();
@@ -1565,7 +1566,7 @@ async function preview(
maxBuffer: 1024 * 1024 * 50,
}).toString();
} else {
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
if (!opts.silent) {
log.info(`Bundling ${filePath} for preview...`);
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.726.1";
export const VERSION = "1.728.1";
+228
View File
@@ -0,0 +1,228 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import process from "node:process";
import { createGunzip } from "node:zlib";
import { Readable } from "node:stream";
import { pathToFileURL } from "node:url";
import * as tar from "tar-stream";
import * as log from "../core/log.ts";
// esbuild splits into a JS host package and a per-platform native binary
// (@esbuild/<platform>). They must be the same version. A broken or incremental
// install can leave the on-disk binary at a different version than the pinned
// host, which crashes service start with
// Cannot start service: Host version "X" does not match binary version "Y"
// The running code can't fix what npm/bun put on disk, so when that happens we
// fall back to esbuild-wasm, whose binary is a single version-pinned .wasm. To
// keep that 14MB out of every CLI install, the esbuild-wasm package is not a
// dependency: it is downloaded once and cached on disk, on the fallback path
// only. We download the whole package (not just the .wasm) because esbuild-wasm
// reads the app's files from disk by spawning `node bin/esbuild`, which needs
// bin/esbuild + esbuild.wasm + wasm_exec*.js co-located on disk.
type Esbuild = typeof import("esbuild");
// Version to fall back to if the native host's version can't be read. Keep in
// sync with the "esbuild" pin in cli/package.json.
const FALLBACK_VERSION = "0.28.0";
let cached: Esbuild | undefined;
let inFlight: Promise<Esbuild> | undefined;
// Distinguishes concurrent extraction temp dirs within a process.
let extractCounter = 0;
/**
* Returns a working esbuild module, preferring the native binary and falling
* back to esbuild-wasm only when the native host/binary versions don't match.
* Memoized for the process: concurrent first callers (e.g. a parallel
* `wmill sync push`) share one probe/download instead of each running their own.
*/
export function getEsbuild(): Promise<Esbuild> {
if (cached) return Promise.resolve(cached);
if (inFlight) return inFlight;
inFlight = acquireEsbuild()
.then((esbuild) => {
cached = esbuild;
return esbuild;
})
.finally(() => {
inFlight = undefined;
});
return inFlight;
}
async function acquireEsbuild(): Promise<Esbuild> {
// Escape hatch: skip native entirely (e.g. a host known to have a broken
// install, or to exercise the fallback path).
if (process.env.WINDMILL_FORCE_ESBUILD_WASM) {
return loadWasmEsbuild(await nativeHostVersion());
}
try {
const esbuild = await import("esbuild");
// The native service only starts on the first call; force it with the most
// trivial op so any breakage (host/binary version mismatch, a dead service)
// surfaces now rather than mid-build. The mismatch detail is printed to the
// child's stderr while the thrown error is generic ("service was stopped"),
// so we fall back on ANY smoke-test failure rather than matching a string.
await esbuild.transform("");
return esbuild;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.warn(
`native esbuild is not usable; falling back to esbuild-wasm (${msg.trim()})`
);
}
return loadWasmEsbuild(await nativeHostVersion());
}
/**
* Stops the esbuild service (native or wasm both spawn a child process) so the
* process can exit. Safe to call repeatedly; the service restarts lazily on the
* next build.
*/
export async function stopEsbuild(): Promise<void> {
await cached?.stop();
}
async function nativeHostVersion(): Promise<string> {
try {
return (await import("esbuild")).version ?? FALLBACK_VERSION;
} catch {
return FALLBACK_VERSION;
}
}
async function loadWasmEsbuild(version: string): Promise<Esbuild> {
const pkgDir = await ensureWasmPackage(version);
const mainJs = path.join(pkgDir, "lib", "main.js");
// The Node build (lib/main.js) reads app files from disk by spawning
// `node bin/esbuild`, so it works with on-disk entry points and node_modules,
// unlike the browser build.
return (await import(pathToFileURL(mainJs).href)) as unknown as Esbuild;
}
/**
* Returns a directory containing an extracted esbuild-wasm package (with
* lib/main.js). Uses an explicit override, then an on-disk cache, then downloads
* and extracts the npm tarball.
*/
async function ensureWasmPackage(version: string): Promise<string> {
// Explicit local override wins (air-gapped / self-hosted workers): a path to
// an already-extracted esbuild-wasm package directory.
const override = process.env.WINDMILL_ESBUILD_WASM_PATH;
if (override) return override;
const destDir = path.join(cacheDir(), `esbuild-wasm-${version}`);
if (fs.existsSync(path.join(destDir, "lib", "main.js"))) {
return destDir;
}
const url = process.env.WINDMILL_ESBUILD_WASM_URL ??
`https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-${version}.tgz`;
log.info(`Downloading esbuild-wasm@${version} from ${url} ...`);
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(
`Failed to download esbuild-wasm@${version} (${res.status} ${res.statusText}). ` +
`Set WINDMILL_ESBUILD_WASM_PATH to an extracted esbuild-wasm package dir, ` +
`point WINDMILL_ESBUILD_WASM_URL at a reachable tarball, or repair the native esbuild install.`
);
}
// Extract to a unique temp dir and rename into place so a crash or a
// concurrent writer can't leave a half-extracted package behind, and so two
// extractions never share an in-progress directory.
const tmpDir = `${destDir}.${process.pid}.${extractCounter++}.tmp`;
fs.rmSync(tmpDir, { recursive: true, force: true });
await extractTarball(res.body, tmpDir);
if (!fs.existsSync(path.join(tmpDir, "lib", "main.js"))) {
fs.rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`esbuild-wasm@${version} tarball did not contain lib/main.js`);
}
try {
fs.renameSync(tmpDir, destDir);
} catch {
// Another process won the race, or rename across devices failed; clean up
// and let the existsSync check below decide whether the cache is usable.
fs.rmSync(tmpDir, { recursive: true, force: true });
}
if (!fs.existsSync(path.join(destDir, "lib", "main.js"))) {
throw new Error(`Failed to cache esbuild-wasm@${version} at ${destDir}`);
}
return destDir;
}
/**
* Resolves a tar entry to an absolute path inside destDir, stripping the leading
* "package/" component that npm tarballs use. Returns null if the entry would
* escape destDir (tar-slip), since WINDMILL_ESBUILD_WASM_URL allows untrusted
* tarball sources.
*/
export function resolveTarEntryPath(
destDir: string,
entryName: string
): string | null {
const rel = entryName.replace(/^[^/]+\//, "");
const root = path.resolve(destDir);
const outPath = path.resolve(root, rel);
if (outPath !== root && !outPath.startsWith(root + path.sep)) {
return null;
}
return outPath;
}
// Extracts an npm tarball (gzipped tar) into destDir, stripping the leading
// "package/" path component that npm tarballs use.
async function extractTarball(
body: ReadableStream<Uint8Array>,
destDir: string
): Promise<void> {
const extract = tar.extract();
extract.on("entry", (header, stream, next) => {
if (header.type !== "file") {
stream.resume();
stream.on("end", next);
return;
}
const outPath = resolveTarEntryPath(destDir, header.name);
if (!outPath) {
// Reject tar-slip entries that would write outside the cache dir.
stream.resume();
stream.on("end", () =>
next(new Error(`unsafe path in esbuild-wasm tarball: ${header.name}`))
);
return;
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const ws = fs.createWriteStream(outPath, { mode: header.mode ?? 0o644 });
stream.pipe(ws);
ws.on("finish", next);
ws.on("error", next);
stream.on("error", next);
});
await new Promise<void>((resolve, reject) => {
extract.on("finish", resolve);
extract.on("error", reject);
Readable.fromWeb(body as unknown as Parameters<typeof Readable.fromWeb>[0])
.pipe(createGunzip())
.on("error", reject)
.pipe(extract)
.on("error", reject);
});
}
function cacheDir(): string {
const explicit = process.env.WINDMILL_CACHE_DIR;
if (explicit) return explicit;
const xdg = process.env.XDG_CACHE_HOME;
if (xdg) return path.join(xdg, "windmill");
try {
return path.join(os.homedir(), ".cache", "windmill");
} catch {
return path.join(os.tmpdir(), "windmill");
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { stat } from "node:fs/promises";
import { readTextFile } from "./utils.ts";
import { getEsbuild } from "./esbuild_loader.ts";
import type { SyncCodebase } from "./codebase.ts";
import { parseMetadataFileIfExists } from "./metadata.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
@@ -43,7 +44,7 @@ async function bundleSingleFileCodebaseScript(
).toString();
}
const esbuild = await import("esbuild");
const esbuild = await getEsbuild();
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
+31
View File
@@ -0,0 +1,31 @@
/**
* Unit tests for esbuild_loader pure logic (no backend, no network).
*/
import { expect, test, describe } from "bun:test";
import { resolveTarEntryPath } from "../src/utils/esbuild_loader.ts";
import { sep, resolve } from "node:path";
describe("resolveTarEntryPath", () => {
const dest = resolve("/tmp/cache/esbuild-wasm-0.28.0");
test("strips the leading package/ component and resolves inside dest", () => {
expect(resolveTarEntryPath(dest, "package/lib/main.js")).toBe(
dest + sep + "lib" + sep + "main.js"
);
expect(resolveTarEntryPath(dest, "package/esbuild.wasm")).toBe(
dest + sep + "esbuild.wasm"
);
});
test("rejects tar-slip entries that escape the dest dir", () => {
expect(resolveTarEntryPath(dest, "package/../../etc/passwd")).toBeNull();
expect(resolveTarEntryPath(dest, "package/../../../outside")).toBeNull();
});
test("rejects entries that only share a prefix with dest", () => {
// ".../esbuild-wasm-0.28.0-evil" must not be treated as inside dest
expect(resolveTarEntryPath(dest, "evil/../../esbuild-wasm-0.28.0-evil/x"))
.toBeNull();
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.726.1",
"version": "1.728.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.726.1",
"version": "1.728.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.726.1",
"version": "1.728.1",
"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",
@@ -216,7 +216,7 @@
</script>
<div
class="autosave-indicator-wrap relative flex items-center gap-1.5 text-primary min-w-[5.2rem] rounded-md"
class="autosave-indicator-wrap relative flex items-center gap-1.5 text-primary shrink-0 rounded-md"
aria-label="Autosave status"
>
{#if flashActive}
@@ -0,0 +1,124 @@
<script lang="ts">
/**
* Lists drafts that `migrateUserDraftsToDb` failed to push to the server.
* Opened from the single "Resolve issues" toast (or stays empty/closed when
* nothing failed). Reads the registry live, so failures that surface while
* the modal is already open appear in place. Mount once, app-wide.
*/
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Braces, Trash2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import {
draftMigrationErrors,
deleteDraftMigrationError,
deleteAllDraftMigrationErrors,
type DraftMigrationError
} from '$lib/userDraftMigrationErrors.svelte'
const DRAFT_DOCS_URL = 'https://www.windmill.dev/docs/core_concepts/draft_and_deploy'
let jsonView = $state<DraftMigrationError | undefined>(undefined)
let jsonOpen = $state(false)
function viewJson(error: DraftMigrationError) {
jsonView = error
jsonOpen = true
}
</script>
<Modal2
bind:isOpen={draftMigrationErrors.modalOpen}
title="Resolve draft migration issues"
fixedWidth="md"
fixedHeight="adaptive"
closeOnOutsideClick={!jsonOpen}
>
<div class="flex flex-col w-full gap-4">
<p class="text-sm text-secondary">
Drafts are now user-scoped and synced to the database.
{#if draftMigrationErrors.list.length}
These local storage drafts could not be
migrated to the server — view their contents before deciding, then delete the ones you no
longer need.
{/if}
<a href={DRAFT_DOCS_URL} target="_blank" rel="noopener noreferrer">Learn more</a>.
</p>
{#if draftMigrationErrors.list.length === 0}
<p class="text-sm text-tertiary italic">All issues resolved.</p>
{:else}
<div class="flex justify-end">
<Button
color="red"
variant="default"
size="xs"
startIcon={{ icon: Trash2 }}
on:click={() => deleteAllDraftMigrationErrors()}
>
Remove all
</Button>
</div>
<ul class="divide-y border-t border-b flex-1 overflow-y-auto max-h-72">
{#each draftMigrationErrors.list as error (error.key)}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-primary truncate">{error.path}</div>
<div class="text-xs text-tertiary truncate">
{error.itemKind} · {error.workspace}
</div>
</div>
<Button
variant="default"
size="xs"
startIcon={{ icon: Braces }}
on:click={() => viewJson(error)}
>
View JSON
</Button>
<Button
color="red"
variant="default"
size="xs"
startIcon={{ icon: Trash2 }}
on:click={() => deleteDraftMigrationError(error.key)}
>
Delete draft
</Button>
</li>
{/each}
</ul>
{/if}
<div class="flex justify-end">
<Button variant="default" size="sm" on:click={() => (draftMigrationErrors.modalOpen = false)}>
Close
</Button>
</div>
</div>
</Modal2>
<Modal2
bind:isOpen={jsonOpen}
title="Draft JSON — {jsonView?.path ?? ''}"
fixedWidth="lg"
fixedHeight="lg"
>
{#snippet headerRight()}
<Button
variant="default"
size="xs"
on:click={() => {
navigator.clipboard?.writeText(JSON.stringify(jsonView?.value ?? {}, null, 2))
sendUserToast('Copied to clipboard')
}}
>
Copy
</Button>
{/snippet}
<div class="w-full overflow-auto">
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
>{JSON.stringify(jsonView?.value ?? {}, null, 2)}</pre
>
</div>
</Modal2>
@@ -237,6 +237,7 @@
value={summary ?? ''}
placeholder="Add a summary..."
editable={summaryEditable}
commitOnInput
size="sm"
onSave={handleSummarySave}
textClass="text-xs font-semibold text-emphasis leading-tight"
+32 -14
View File
@@ -119,6 +119,8 @@
loadedFromHistoryFromUrl,
noInitial = false,
liveEditorDraftStoragePath = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
onDeploy,
onDeployError,
onDetails,
@@ -131,6 +133,12 @@
onTestJob
}: FlowBuilderProps = $props()
// Key the AutosaveIndicator watches. Falls back to this component's own
// draft key, so the full-page editor is unchanged; the sessions preview
// overrides both to the (forked) workspace + path its autosave saves under.
const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
let initialPathStore = writable(initialPath)
// For preserve_on_behalf_of feature
@@ -1084,11 +1092,11 @@
onBehalfOfEmail={$savedOnBehalfOfEmail}
onNavigate={(item) => onNavigate?.(item)}
/>
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
{#if indicatorWorkspace && indicatorPath !== undefined}
<AutosaveIndicator
workspace={$workspaceStore}
workspace={indicatorWorkspace}
itemKind="flow"
path={liveEditorDraftStoragePath}
path={indicatorPath}
draftOnly={newFlow}
{onResetToDeployed}
{loadedFromDraft}
@@ -1110,19 +1118,29 @@
{/if}
</div>
{#if customUi?.topBar?.diff != false}
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedFlow || newFlow}
iconOnly={compactTopbar}
title={newFlow
{@const isDraftOnly = savedFlow?.no_deployed === true}
{@const diffDisabled = !savedFlow || newFlow || isDraftOnly}
{@const diffTitle =
newFlow || isDraftOnly
? 'Deploy this flow once to compare against the deployed version'
: 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
it never shows on hover. pointer-events-none on the button lets the
hover reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{#if !compactTopbar}
{@render previewButtons()}
@@ -101,6 +101,14 @@
}
})
// Seed the JSON editor when the resource type schema is missing
// (restores the old ResourceEditor's catch-block behavior)
$effect(() => {
if (resource_type && !loadingSchema && !resourceSchema && rawCode === undefined) {
rawCode = JSON.stringify(args, null, 2)
}
})
$effect(() => {
if (textFileContent) parseTextFileContent()
})
@@ -109,6 +109,8 @@
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
autosaveWorkspace = undefined,
autosavePath = undefined,
template = $bindable('script'),
initialArgs = {},
lockedLanguage = false,
@@ -159,9 +161,19 @@
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
// Top-bar responsive collapse — container width, not viewport.
// Top-bar responsive collapse — container width, not viewport. Collapse the
// right group (hide the ~200px tag select, icon-only Diff/Settings) before the
// full group crowds the path into heavy truncation; ~900 is where the path
// keeps a usable width given the right group's natural ~440px.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900)
// AutosaveIndicator watch key. Falls back to the full-page editor's
// global store + URL draft path; the sessions preview overrides both so the
// icon tracks the session's (forked) workspace + target path where autosave
// actually happens.
const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? userDraftPath)
function getCompactMenuItems(): Item[] {
const hasTags = ($workerTags?.length ?? 0) > 0
@@ -728,7 +740,10 @@
})
}
function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) {
function computeDropdownItems(
initialPath: string,
savedScript: ((Script | NewScript) & { no_deployed?: boolean }) | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
@@ -759,7 +774,7 @@
]
: []),
...(!inSessionPane &&
(savedScript as any)?.no_deployed !== true &&
savedScript?.no_deployed !== true &&
script.kind === 'script' &&
!script.auto_kind
? [
@@ -1831,7 +1846,7 @@
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(script.language)}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && (savedScript as any)?.no_deployed !== true}
isDeployed={savedScript && savedScript?.no_deployed !== true}
schema={script.schema}
runnableVersion={script.parent_hash}
onDeployTrigger={handleDeployTrigger}
@@ -1849,7 +1864,7 @@
<div class="flex flex-col h-screen">
<div bind:clientWidth={topbarWidth} class="flex h-12 items-center px-4">
<div class="flex gap-2 lg:gap-2 w-full items-center">
<div class="flex flex-row items-center gap-2 min-w-[200px] max-w-full">
<div class="flex flex-row items-center gap-2 min-w-0 shrink">
<button
disabled={customUi?.topBar?.settings == false}
class="shrink-0"
@@ -1860,22 +1875,24 @@
<LanguageIcon lang={script.language} size={24} />
</button>
{#if customUi?.topBar?.path != false}
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
onNavigate={(item) => onNavigate?.(item)}
/>
<div class="min-w-0 overflow-hidden">
<EditorHeader
bind:summary={script.summary}
bind:path={script.path}
savedPath={initialPath}
kind="script"
summaryEditable={customUi?.topBar?.editableSummary != false}
pathEditable={customUi?.topBar?.editablePath != false}
onNavigate={(item) => onNavigate?.(item)}
/>
</div>
{/if}
{#if $workspaceStore}
{#if indicatorWorkspace}
<AutosaveIndicator
workspace={$workspaceStore}
workspace={indicatorWorkspace}
itemKind="script"
path={userDraftPath}
draftOnly={(savedScript as any)?.no_deployed === true}
path={indicatorPath}
draftOnly={savedScript?.no_deployed === true}
{onResetToDeployed}
{loadedFromDraft}
{othersDraftsCount}
@@ -1912,20 +1929,28 @@
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = (savedScript as any)?.no_deployed === true}
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedScript || !diffDrawer || isDraftOnly}
iconOnly={compactTopbar}
title={isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
{@const isDraftOnly = savedScript?.no_deployed === true}
{@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly}
{@const diffTitle = isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
never shows on hover. pointer-events-none on the button lets the hover
reach this titled wrapper instead. -->
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
{/snippet}
{#if compactTopbar}
@@ -23,6 +23,7 @@ would be surprising.
import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { resource } from 'runed'
type Kind = WorkspaceItemKind
type ScopeKind = Kind | 'all'
@@ -82,11 +83,15 @@ would be surprising.
// be surprising (they'd appear as navigable items that 404 on the backend
// draft fetch).
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
// `listGlobalDrafts` is backend-backed (async); fetch once and derive the
// per-kind lists synchronously from the resolved snapshot.
const globalDraftsResource = resource(
() => ({ ws: $workspaceStore, enabled: isGlobalAiEnabled() }),
async ({ ws, enabled }) => (enabled && ws ? await listGlobalDrafts(ws) : [])
)
function aiDraftsForKind(k: Kind): WorkspaceItem[] {
if (!isGlobalAiEnabled()) return []
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return listGlobalDrafts($workspaceStore)
return (globalDraftsResource.current ?? [])
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
@@ -44,10 +44,15 @@ doesn't steal focus from a sibling search input (matches the picker).
navKey?: string
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
baseClass?: string
/** Reserve two lines of height and vertically center the content so
* summary and summary-less rows are the same height (diff viewer). */
uniformHeight?: boolean
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
/** Title tooltip shown on hover; defaults to the secondary text. */
/** Full path tooltip for the secondary line; defaults to the secondary
* text. The summary line gets its own tooltip (the full summary) so each
* truncated line reveals its own content on hover. */
title?: string
/** When set, the row renders as an `<a href target="_blank">` link
* instead of a `<button>`. Used by callers that want native
@@ -76,12 +81,19 @@ doesn't steal focus from a sibling search input (matches the picker).
href,
onclick,
onmouseenter,
extras
extras,
uniformHeight = false
}: Props = $props()
const rootClass = $derived(
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
)
// Same min-height + centering for both branches so a row with a summary
// (two lines) and one without (one line) end up identical in height.
const contentClass = $derived(
`min-w-0 flex-1${uniformHeight ? ' flex flex-col justify-center min-h-[2.25rem]' : ''}`
)
</script>
{#if href}
@@ -94,19 +106,25 @@ doesn't steal focus from a sibling search input (matches the picker).
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
<div class={contentClass}>
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
<div
class="text-2xs text-secondary font-normal font-mono truncate"
title={title ?? secondary}
>
{secondary}
</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
{secondary}
</div>
{/if}
</div>
{#if extras}
@@ -123,7 +141,6 @@ doesn't steal focus from a sibling search input (matches the picker).
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
onmousedown={(e) => e.preventDefault()}
@@ -131,12 +148,19 @@ doesn't steal focus from a sibling search input (matches the picker).
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
<div class={contentClass}>
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
<div class="text-xs text-primary truncate" title={summary}>{summary}</div>
<div
class="text-2xs text-secondary font-normal font-mono truncate"
title={title ?? secondary}
>
{secondary}
</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
<div class="text-xs text-primary font-mono truncate" title={title ?? secondary}>
{secondary}
</div>
{/if}
</div>
{#if extras}
@@ -34,7 +34,7 @@
sendUserToast,
urlParamsToObject
} from '$lib/utils'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import AppPreview from './AppPreview.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
@@ -69,6 +69,7 @@
path,
policy,
summary,
deployedBaseline = undefined,
fromHub = false,
diffDrawer = undefined,
savedApp = $bindable(undefined),
@@ -87,6 +88,17 @@
migrateApp(untrack(() => app))
// Migrated clone of the deployed baseline for the autosave `discardIf`. The
// live `stateApp` is `migrateApp`'d on mount, so the baseline must be too or
// an unedited draft would never compare equal. Captured once per mount (the
// route remounts AppEditor on path change), `undefined` for draft-only paths.
const migratedDeployedBaseline = untrack(() => {
if (!deployedBaseline) return undefined
const clone = structuredClone($state.snapshot(deployedBaseline)) as App
migrateApp(clone)
return clone
})
// Inside a session pane the AIChatManager is injected via context. Sessions
// have their own state machinery (sessionRuntime + per-fork backend), and
// the user-facing $workspaceStore stays on the main workspace even when
@@ -101,7 +113,13 @@
const appDraftHandle = inSessionPane
? undefined
: // `canBeDisabled`: page editor's AutosaveIndicator carries the toggle.
UserDraft.use<App>('app', appDraftPath, { canBeDisabled: true })
// `discardIf`: an autosave reverting to the deployed app deletes the
// draft instead of persisting a no-op copy.
UserDraft.use<App>('app', appDraftPath, {
canBeDisabled: true,
discardIf: (val) =>
migratedDeployedBaseline !== undefined && draftValuesEqual(val, migratedDeployedBaseline)
})
// Suspend autosave around mount so the `firstMirror` seed write isn't POSTed
// as the user's first edit; `onMount`-then-`tick` resumes once effects settle.
if (appDraftHandle) UserDraft.stopSync('app', appDraftPath)
@@ -0,0 +1,32 @@
import type { App } from './types'
import { gridColumns } from './gridUtils'
import { allItems } from './editor/appUtilsCore'
/**
* Normalize an `App` in place to the current schema: default `hiddenInlineScripts`
* type, migrate the legacy `doNotRecomputeOnInputChanged` flag, and default
* `fullHeight` on every grid item. Lives in its own light module (no app-editor
* component imports) so non-editor callers e.g. the localStorageDB draft
* migration can reuse it without pulling the whole `apps/utils` graph.
*/
export function migrateApp(app: App) {
;(app?.hiddenInlineScripts ?? []).forEach((x) => {
if (x.type == undefined) {
//@ts-ignore
x.type = 'inline'
}
//TODO: remove after migration is done
if (x.doNotRecomputeOnInputChanged != undefined) {
x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged
x.doNotRecomputeOnInputChanged = undefined
}
})
allItems(app.grid, app.subgrids).forEach((x) => {
gridColumns.forEach((column: number) => {
if (x?.[column]?.fullHeight === undefined) {
x[column].fullHeight = false
}
})
})
}
@@ -144,6 +144,10 @@ export interface AppEditorProps {
path: string
policy: Policy
summary: string
/** Deployed app value the autosave `discardIf` compares against, so an
* edit reverting to deployed clears the draft instead of leaving a no-op.
* `undefined` for draft-only paths (no deployed baseline). */
deployedBaseline?: App | undefined
fromHub?: boolean
diffDrawer?: DiffDrawerI | undefined
savedApp?:
+14 -28
View File
@@ -2,7 +2,14 @@ import type { Schema } from '$lib/common'
import { twMerge } from 'tailwind-merge'
import { type AppComponent } from './editor/component'
import { isRunnableByName, isRunnableByPath, type AppInput, type InputType, type ResultAppInput, type StaticAppInput } from './inputType'
import {
isRunnableByName,
isRunnableByPath,
type AppInput,
type InputType,
type ResultAppInput,
type StaticAppInput
} from './inputType'
import type { Output } from './rx'
import type {
App,
@@ -11,30 +18,12 @@ import type {
HorizontalAlignment,
VerticalAlignment
} from './types'
import { gridColumns } from './gridUtils'
import { allItems, BG_PREFIX } from './editor/appUtilsCore'
export function migrateApp(app: App) {
;(app?.hiddenInlineScripts ?? []).forEach((x) => {
if (x.type == undefined) {
//@ts-ignore
x.type = 'inline'
}
//TODO: remove after migration is done
if (x.doNotRecomputeOnInputChanged != undefined) {
x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged
x.doNotRecomputeOnInputChanged = undefined
}
})
allItems(app.grid, app.subgrids).forEach((x) => {
gridColumns.forEach((column: number) => {
if (x?.[column]?.fullHeight === undefined) {
x[column].fullHeight = false
}
})
})
}
// `migrateApp` moved to its own light module so non-editor callers can reuse it
// without pulling the whole `apps/utils` graph; re-exported here for existing
// `from '../utils'` importers.
export { migrateApp } from './migrateApp'
export function processSubcomponents(data: AppComponent, fn: (data: AppComponent) => void) {
if (data.type == 'tablecomponent' && Array.isArray(data.actionButtons)) {
@@ -133,7 +122,7 @@ export function isScriptByNameDefined(appInput: AppInput | undefined): boolean {
return false
}
if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) {
if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) {
return appInput.runnable?.name != undefined
}
@@ -402,10 +391,7 @@ export function getAllScriptNames(app: App): string[] {
const names = (allItems(app.grid, app?.subgrids) ?? []).reduce((acc, gridItem: GridItem) => {
const { componentInput } = gridItem.data
if (
componentInput?.type === 'runnable' &&
isRunnableByName(componentInput.runnable)
) {
if (componentInput?.type === 'runnable' && isRunnableByName(componentInput.runnable)) {
acc.push(componentInput.runnable.name)
}
@@ -31,12 +31,21 @@ this component just proposes new values.
/** Shown when `value` is empty, in both idle and editing modes. */
placeholder?: string
/**
* Called when the user commits a changed value (Enter or blur). Fires
* with the trimmed draft, including `''` if the user cleared the field.
* Not called on Escape, or on blur when the trimmed draft matches the
* prior `value`. Guard against empty in your handler if needed.
* Called when the user commits a changed value (Enter or blur, or every
* keystroke when {@link commitOnInput} is set). Fires with the trimmed
* draft, including `''` if the user cleared the field. Not called on
* Escape, or when the trimmed draft matches the prior `value`. Guard
* against empty in your handler if needed.
*/
onSave?: (newValue: string) => void
/**
* Fire `onSave` on every keystroke (still trimmed, still de-duped against
* the prior `value`) instead of only on Enter/blur. Use when the parent
* autosaves the field and the user expects edits to land live rather than
* on focus-out. Escape no longer discards (live commits already
* propagated). Off by default to preserve the commit-on-blur contract.
*/
commitOnInput?: boolean
/** When false, the component renders as plain text (not clickable). Default true. */
editable?: boolean
/** TextInput size in editing mode. Idle mode is unaffected (text only). */
@@ -57,6 +66,7 @@ this component just proposes new values.
value,
placeholder = '',
onSave,
commitOnInput = false,
editable = true,
size = 'sm',
class: className = '',
@@ -104,6 +114,15 @@ this component just proposes new values.
if (e.key === 'Enter') save()
else if (e.key === 'Escape') editing = false
}
// Live commit: propagate each keystroke to the parent (trimmed, de-duped)
// so an autosaving field updates as you type instead of only on focus-out.
// Reads the DOM value directly so it's correct regardless of the `bind:value`
// update order.
function handleLiveInput(e: Event) {
const next = (e.currentTarget as HTMLInputElement).value.trim()
if (next !== (value ?? '')) onSave?.(next)
}
</script>
{#if editing}
@@ -120,6 +139,7 @@ this component just proposes new values.
placeholder,
onblur: save,
onkeydown: handleKeydown,
oninput: commitOnInput ? handleLiveInput : undefined,
spellcheck: false,
size: 1,
style: 'padding: 2px !important; grid-area: 1 / 1'
@@ -12,8 +12,9 @@
import Button from '$lib/components/common/button/Button.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { forkDraftToImport } from '$lib/components/forkDraftToImport'
import { displayDate } from '$lib/utils'
export type OtherDraftUser = { username?: string | null }
export type OtherDraftUser = { username?: string | null; draft_saved_at?: string }
type Props = {
workspace: string
@@ -102,15 +103,26 @@
<ul class="divide-y border-t border-b flex-1 overflow-y-auto">
{#each otherDraftsUsers as owner (ownerKey(owner))}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0 flex items-center gap-2">
<span class="text-sm font-medium text-primary truncate" class:italic={!owner.username}>
{ownerLabel(owner)}
</span>
{#if !owner.username}
<Tooltip>
Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user
— kept around so you can recover the content, but no current user owns it.
</Tooltip>
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary truncate"
class:italic={!owner.username}
>
{ownerLabel(owner)}
</span>
{#if !owner.username}
<Tooltip>
Pre-migration workspace-scoped draft (no owner). Saved before drafts became
per-user — kept around so you can recover the content, but no current user owns
it.
</Tooltip>
{/if}
</div>
{#if owner.draft_saved_at}
<span class="text-2xs text-hint truncate">
Last updated: {displayDate(owner.draft_saved_at)}
</span>
{/if}
</div>
<Button
@@ -18,6 +18,14 @@
triggerOnSearchParamsChange?: boolean
onDiscardChanges?: () => void
tabMode?: boolean
/** Alternative dirty check. When provided it REPLACES the value diff:
* the modal engages whenever it returns true (the value-diff props can
* be omitted). The full-page editors pass the auto-save-off "parked
* unsaved changes" signal here. */
hasUnsavedChanges?: () => boolean
/** Adds a line to the confirmation telling the user they can enable
* auto-save to persist a draft automatically. */
showAutosaveTips?: boolean
}
let {
@@ -25,7 +33,9 @@
additionalExitAction = () => {},
triggerOnSearchParamsChange = false,
onDiscardChanges = undefined,
tabMode = false
tabMode = false,
hasUnsavedChanges = undefined,
showAutosaveTips = false
}: Props = $props()
let savedValue: Value | undefined = $state(undefined)
let modifiedValue: Value | undefined = $state(undefined)
@@ -34,10 +44,15 @@
let open = $state(false)
let goingTo: URL | undefined = $state(undefined)
// The modal is wired up when either dirty-detection mode is configured.
let dirtyDetectionActive = $derived(!!getInitialAndModifiedValues || !!hasUnsavedChanges)
// Mirrors the modal condition: dirty when values differ, or when either
// value is missing (e.g. a never-saved draft). Also refreshes
// savedValue/modifiedValue for the diff drawer.
function hasUnsavedChanges(): boolean {
// savedValue/modifiedValue for the diff drawer. `hasUnsavedChanges`, when
// passed, short-circuits the value diff with the caller's own predicate.
function checkUnsavedChanges(): boolean {
if (hasUnsavedChanges) return hasUnsavedChanges()
const state = getInitialAndModifiedValues?.()
savedValue = state?.savedValue
modifiedValue = state?.modifiedValue
@@ -57,7 +72,7 @@
beforeNavigate(async (newNavigationState) => {
if (
!bypassBeforeNavigate &&
getInitialAndModifiedValues &&
dirtyDetectionActive &&
newNavigationState.to &&
((newNavigationState.to.url != page.url &&
newNavigationState.to.url.pathname !== newNavigationState.from?.url.pathname) ||
@@ -65,7 +80,7 @@
) {
goingTo = newNavigationState.to.url
if (hasUnsavedChanges()) {
if (checkUnsavedChanges()) {
newNavigationState.cancel()
open = true
} else {
@@ -80,7 +95,7 @@
})
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!bypassBeforeNavigate && getInitialAndModifiedValues && hasUnsavedChanges()) {
if (!bypassBeforeNavigate && dirtyDetectionActive && checkUnsavedChanges()) {
// Triggers the browser's native "leave site?" confirmation
event.preventDefault()
// Required by some browsers (legacy mechanism)
@@ -121,5 +136,12 @@
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to discard the changes you have made? </span>
{#if showAutosaveTips}
<span class="text-xs text-tertiary">
Auto-save is off, so these changes are not saved as a draft. Enable auto-save (the cloud
icon in the editor toolbar) to persist your changes automatically, or press Ctrl/Cmd+S to
save the current draft before leaving.
</span>
{/if}
</div>
</ConfirmationModal>
@@ -102,7 +102,9 @@
{/if}
<Row
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
href={app.draft_only
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
kind="app"
{marked}
path={(app as any).draft_path ?? app.path}
@@ -26,6 +26,22 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
vi.mock('$lib/components/vscode', () => ({}))
// In-memory stand-in for the per-user draft backend. The chat now persists/reads
// drafts through DraftService (no in-tab cell in unit tests), so this Map is the
// source of truth the write/read tools round-trip against. `vi.hoisted` makes it
// available inside the hoisted `vi.mock` factory and the test body alike.
const { backendDrafts, serverTimestamps, failingWrites, failingReads } = vi.hoisted(() => ({
backendDrafts: new Map<string, unknown>(),
// Per-row server timestamp, only set by tests that want to simulate a
// concurrent writer advancing the row; otherwise empty, so the conflict
// branch in `updateDraft` stays inert for every pre-existing test.
serverTimestamps: new Map<string, string>(),
// Keys whose `updateDraft` / `getDraftForUser` throw a non-404 (network/5xx);
// only set by the error-handling tests, empty otherwise.
failingWrites: new Set<string>(),
failingReads: new Set<string>()
}))
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
@@ -140,6 +156,48 @@ vi.mock('$lib/gen', async () => {
}),
createVariable: vi.fn(async () => 'created'),
updateVariable: vi.fn(async () => 'updated')
}),
DraftService: wrapService(actual.DraftService, {
updateDraft: vi.fn(async ({ kind, path, requestBody }: any) => {
const key = `${kind}:${path}`
if (failingWrites.has(key)) throw Object.assign(new Error('server error'), { status: 500 })
// A non-force save whose last_sync no longer matches the row's
// server timestamp is rejected (optimistic concurrency). Inert
// unless a test set serverTimestamps for this key.
const serverTs = serverTimestamps.get(key)
if (
!requestBody?.force &&
requestBody?.last_sync != null &&
serverTs != null &&
requestBody.last_sync !== serverTs
) {
return { status: 'conflict', current_timestamp: serverTs }
}
if (requestBody?.value == null) backendDrafts.delete(key)
else backendDrafts.set(key, requestBody.value)
return { status: 'saved', current_timestamp: '2026-06-15T00:00:00Z' }
}),
getDraftForUser: vi.fn(async ({ kind, path }: any) => {
const key = `${kind}:${path}`
if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 })
// 404-shaped (status) like the real ApiError, so the adapter's
// narrowed catch treats it as "no draft" rather than re-throwing.
if (!backendDrafts.has(key))
throw Object.assign(new Error('no draft for that owner at that path'), { status: 404 })
return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' }
}),
listDrafts: vi.fn(async () =>
Array.from(backendDrafts.entries()).map(([key, value]) => {
const idx = key.indexOf(':')
return {
kind: key.slice(0, idx),
path: key.slice(idx + 1),
summary: (value as any)?.summary,
draft_only: true,
created_at: '2026-06-15T00:00:00Z'
}
})
)
})
}
})
@@ -163,7 +221,14 @@ import {
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
clearGlobalDrafts,
deleteGlobalDraft,
persistGlobalDraft,
readGlobalDraftValue,
saveGlobalAppDraft
} from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
AppService,
@@ -179,6 +244,17 @@ import type { Tool, ToolCallbacks } from '../shared'
const WORKSPACE = 'global-core-test'
// Seed/read the backend draft store directly (keyed exactly like the syncer:
// `${itemKind}:${storagePath}`). Drop-in replacements for the old in-tab
// `UserDraft.save`/`UserDraft.get` round-trip the tests used before the drafts
// moved to the backend. Extra opts arg is ignored (kept for call-site parity).
function seedBackendDraft(kind: string, path: string, value: unknown, _opts?: unknown): void {
backendDrafts.set(`${kind}:${path}`, value)
}
function getBackendDraft<V = any>(kind: string, path: string, _opts?: unknown): V | undefined {
return backendDrafts.get(`${kind}:${path}`) as V | undefined
}
const toolCallbacks: ToolCallbacks = {
setToolStatus: vi.fn(),
removeToolStatus: vi.fn()
@@ -231,6 +307,10 @@ describe('global AI tools', () => {
beforeEach(() => {
__resetUserDraftForTesting()
localStorage.clear()
backendDrafts.clear()
serverTimestamps.clear()
failingWrites.clear()
failingReads.clear()
clearGlobalDrafts(WORKSPACE)
vi.clearAllMocks()
})
@@ -406,7 +486,7 @@ describe('global AI tools', () => {
resource_type: 'postgresql'
})
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
expect(getBackendDraft<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
path: 'f/resources/db',
description: 'existing database',
args: { host: 'new.example.com', port: 5432 },
@@ -438,19 +518,21 @@ describe('global AI tools', () => {
description: 'new description'
})
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: ['prod'],
wsSpecific: true,
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z'
})
expect(getBackendDraft<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual(
{
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: ['prod'],
wsSpecific: true,
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z'
}
)
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
@@ -463,7 +545,7 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
getBackendDraft<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/secrets/api_key',
variable: {
@@ -490,12 +572,14 @@ describe('global AI tools', () => {
ws_specific: false
})
})
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
expect(
getBackendDraft('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
).toBeUndefined()
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
UserDraft.save(
seedBackendDraft(
'variable',
'f/secrets/api_key',
{
@@ -531,17 +615,17 @@ describe('global AI tools', () => {
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
}
)
expect(
getBackendDraft<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
})
})
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
it('applies path_prefix to drafts before enforcing the result limit', async () => {
await callGlobalTool('write_script', {
path: 'f/other/outside',
summary: 'Outside draft',
@@ -571,7 +655,7 @@ describe('global AI tools', () => {
})
it('lists and edits the live script editor draft through its effective path', async () => {
UserDraft.save(
seedBackendDraft(
'script',
'',
{
@@ -609,17 +693,17 @@ describe('global AI tools', () => {
new_string: 'return a * b'
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
expect(getBackendDraft<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/amazed_script',
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
})
expect(
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
getBackendDraft('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
).toBeUndefined()
})
it('lists and writes the live flow editor draft through its effective path', async () => {
UserDraft.save(
seedBackendDraft(
'flow',
'',
{
@@ -657,16 +741,16 @@ describe('global AI tools', () => {
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
expect(getBackendDraft<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/live_flow',
summary: 'Updated live flow',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
})
it('writes the live raw app editor draft through its effective path', async () => {
UserDraft.save(
seedBackendDraft(
'raw_app',
'',
{
@@ -690,16 +774,16 @@ describe('global AI tools', () => {
content: 'export default function New() { return null }'
})
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
expect(getBackendDraft<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
files: {
'/src/App.tsx': 'export default function App() { return null }',
'/src/New.tsx': 'export default function New() { return null }'
}
})
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
})
it('discards a local draft without deleting the workspace item', async () => {
it('discards a draft without deleting the workspace item', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/discard-me',
summary: 'Temporary draft',
@@ -707,7 +791,9 @@ describe('global AI tools', () => {
content: 'export async function main() { return 1 }'
})
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
expect(
getBackendDraft('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeDefined()
const raw = await callGlobalTool('discard_local_draft', {
type: 'script',
@@ -721,10 +807,134 @@ describe('global AI tools', () => {
})
expect(raw).toContain('The deployed workspace item was not changed')
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
getBackendDraft('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeUndefined()
})
// Covers the conflict-on-save / override branch of `persistGlobalDraft`
// directly: a non-force save whose recorded baseline is older than the
// server row is rejected with `status:'conflict'`, and `override` (force)
// pushes our version through. NB: this targets persistGlobalDraft, not the
// write_* tools — those re-read the backend first (readGlobalDraftValue ->
// recordRemoteSync), which re-seeds the baseline and so can only surface a
// conflict when a live editor cell is mounted (not the case in unit tests).
it('persistGlobalDraft surfaces a conflict on a stale baseline and override forces it', async () => {
const path = 'f/scripts/conflicted'
const key = `script:${path}`
const v1 = {
path,
summary: 'v1',
description: '',
content: 'export function main() {}',
language: 'bun'
}
seedBackendDraft('script', path, v1)
// A concurrent writer advanced the row past the baseline we recorded.
serverTimestamps.set(key, '2026-06-15T00:01:00Z')
UserDraftDbSyncer.recordRemoteSync(
{ workspace: WORKSPACE, itemKind: 'script', path },
'2026-06-15T00:00:00Z'
)
const v2 = { ...v1, summary: 'v2', content: 'export function main() { return 1 }' }
const conflict = await persistGlobalDraft(WORKSPACE, 'script', path, v2)
expect(conflict.status).toBe('conflict')
if (conflict.status === 'conflict') {
expect(conflict.serverTimestamp).toBe('2026-06-15T00:01:00Z')
}
// The rejected write left the stored draft untouched.
expect(getBackendDraft<any>('script', path, { workspace: WORKSPACE })).toMatchObject({
summary: 'v1'
})
// override:true bypasses the check and persists our version.
const forced = await persistGlobalDraft(WORKSPACE, 'script', path, v2, { force: true })
expect(forced.status).toBe('saved')
expect(getBackendDraft<any>('script', path, { workspace: WORKSPACE })).toMatchObject({
summary: 'v2',
content: 'export function main() { return 1 }'
})
})
// A backend save failure (network/5xx) is recorded in the syncer's failure
// map, not thrown — persistGlobalDraft must report 'error', never 'saved'.
it('persistGlobalDraft reports an error (not saved) when the backend save fails', async () => {
const path = 'f/scripts/savefail'
failingWrites.add(`script:${path}`)
const v = {
path,
summary: 's',
description: '',
content: 'export function main() {}',
language: 'bun'
}
const res = await persistGlobalDraft(WORKSPACE, 'script', path, v)
expect(res.status).toBe('error')
if (res.status === 'error') expect(res.message).toBeTruthy()
// Nothing was persisted.
expect(getBackendDraft('script', path, { workspace: WORKSPACE })).toBeUndefined()
})
// A non-404 read failure must propagate, not collapse to "no draft" — else
// the write merge falls through to the deployed item, losing draft edits.
it('a non-404 backend read failure propagates instead of returning undefined', async () => {
const path = 'f/scripts/readfail'
failingReads.add(`script:${path}`)
await expect(readGlobalDraftValue(WORKSPACE, 'script', path)).rejects.toThrow()
})
// Raw-app writes go through saveGlobalAppDraft, which must carry the conflict
// status so write_app_* tools don't report a stale write as saved.
it('saveGlobalAppDraft surfaces a conflict on a stale baseline', async () => {
const path = 'u/admin/conflictedapp'
const key = `raw_app:${path}`
seedBackendDraft('raw_app', path, { summary: 'v1', files: {}, runnables: {} })
serverTimestamps.set(key, '2026-06-15T00:01:00Z')
UserDraftDbSyncer.recordRemoteSync(
{ workspace: WORKSPACE, itemKind: 'raw_app', path },
'2026-06-15T00:00:00Z'
)
const res = await saveGlobalAppDraft(WORKSPACE, path, {
summary: 'v2',
files: {},
runnables: {}
} as any)
expect(res.status).toBe('conflict')
})
// A failed server delete must surface (throw), not silently report removed —
// the same guard the write path got, applied to the delete path.
it('deleteGlobalDraft throws when the server delete fails', async () => {
const path = 'f/scripts/delfail'
seedBackendDraft('script', path, {
path,
summary: 's',
content: 'export function main() {}',
language: 'bun'
})
failingWrites.add(`script:${path}`)
await expect(deleteGlobalDraft(WORKSPACE, 'script', path)).rejects.toThrow()
})
// `override` is a tool-only conflict flag and must not leak into the persisted
// schedule draft value.
it('does not persist the tool-only override flag into a schedule draft', async () => {
await callGlobalTool('write_schedule', {
path: 'f/schedules/ov',
schedule: '0 0 9 * * *',
timezone: 'UTC',
script_path: 'f/scripts/run',
is_flow: false,
args: {},
override: true
})
const draft = getBackendDraft<any>('trigger_schedule', 'f/schedules/ov', {
workspace: WORKSPACE
})
expect(draft).toBeTruthy()
expect(draft).not.toHaveProperty('override')
})
it('requires trigger_kind when discarding a trigger draft', async () => {
await expect(
callGlobalTool('discard_local_draft', {
@@ -754,7 +964,7 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
getBackendDraft<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
@@ -786,7 +996,9 @@ describe('global AI tools', () => {
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
expect(
getBackendDraft<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'deployed description',
@@ -825,7 +1037,7 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
getBackendDraft<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
@@ -840,7 +1052,7 @@ describe('global AI tools', () => {
no_flow_overlap: true
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
getBackendDraft<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).not.toMatchObject({
edited_by: expect.anything()
})
@@ -883,7 +1095,7 @@ describe('global AI tools', () => {
}
})
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
const draft = getBackendDraft<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
expect(draft).toMatchObject({
path: 'f/routes/api',
script_path: 'f/flows/new',
@@ -927,7 +1139,7 @@ describe('global AI tools', () => {
content: 'export default function New() { return null }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
const draft = getBackendDraft<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'deployed app',
files: {
@@ -947,7 +1159,7 @@ describe('global AI tools', () => {
})
it('summarizes local raw app drafts in read_workspace_item', async () => {
UserDraft.save(
seedBackendDraft(
'raw_app',
'f/apps/local',
{
@@ -1058,10 +1270,10 @@ describe('global AI tools', () => {
file_path: '/src/Helper.tsx'
})
).resolves.toBe('helper content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('reads raw app files without creating a local draft', async () => {
it('reads raw app files without creating a draft', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
@@ -1079,7 +1291,7 @@ describe('global AI tools', () => {
file_path: '/src/App.tsx'
})
).resolves.toBe('deployed content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
@@ -1103,7 +1315,7 @@ describe('global AI tools', () => {
replace_all: false
})
).rejects.toThrow()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
@@ -1124,7 +1336,7 @@ describe('global AI tools', () => {
file_path: '/src/Missing.tsx'
})
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
@@ -1150,11 +1362,11 @@ describe('global AI tools', () => {
key: 'missing'
})
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('deploys a new raw app draft by bundling files and creating a raw app', async () => {
UserDraft.save(
seedBackendDraft(
'raw_app',
'f/apps/report',
{
@@ -1206,7 +1418,7 @@ describe('global AI tools', () => {
}
})
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(JSON.parse(raw)).toMatchObject({
success: true,
type: 'app',
@@ -1216,7 +1428,7 @@ describe('global AI tools', () => {
it('deploys an existing raw app draft by bundling files and updating the raw app', async () => {
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
UserDraft.save(
seedBackendDraft(
'raw_app',
'f/apps/report',
{
@@ -1255,14 +1467,14 @@ describe('global AI tools', () => {
}
})
expect(AppService.createAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
const onDeployed = vi.fn()
setDeployedInSessionHandler(onDeployed)
try {
UserDraft.save(
seedBackendDraft(
'raw_app',
'f/apps/report',
{
@@ -1388,7 +1600,7 @@ describe('global AI tools', () => {
expect(item.value.value).toBeUndefined()
})
it('test_run_script previews local draft script content by path', async () => {
it('test_run_script previews draft script content by path', async () => {
const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/draft-test',
@@ -1418,7 +1630,7 @@ describe('global AI tools', () => {
expect(result).toContain('test logs')
})
it('test_run_script previews deployed script content when no local draft exists', async () => {
it('test_run_script previews deployed script content when no draft exists', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/deployed-test',
summary: 'Deployed test script',
@@ -1448,7 +1660,7 @@ describe('global AI tools', () => {
})
})
it('test_run_flow previews local draft flow content by path', async () => {
it('test_run_flow previews draft flow content by path', async () => {
const modules = [{ id: 'start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/draft-test',
@@ -1474,7 +1686,7 @@ describe('global AI tools', () => {
})
})
it('test_run_flow previews deployed flow content when no local draft exists', async () => {
it('test_run_flow previews deployed flow content when no draft exists', async () => {
const modules = [{ id: 'deployed_start', value: { type: 'identity' } }]
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/deployed-test',
@@ -1505,7 +1717,7 @@ describe('global AI tools', () => {
})
it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => {
UserDraft.save(
seedBackendDraft(
'flow',
'',
{
@@ -1547,7 +1759,7 @@ describe('global AI tools', () => {
})
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
UserDraft.save(
seedBackendDraft(
'flow',
'',
{
@@ -1594,7 +1806,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step previews rawscript steps from the local draft flow', async () => {
it('test_run_step previews rawscript steps from the draft flow', async () => {
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
await callGlobalTool('write_flow', {
path: 'f/flows/rawscript-step',
@@ -1673,7 +1885,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step previews local draft subflows for flow steps', async () => {
it('test_run_step previews draft subflows for flow steps', async () => {
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/nested-draft',
@@ -1864,9 +2076,9 @@ describe('prepareGlobalSystemMessage', () => {
const message = prepareGlobalSystemMessage()
const content = message.content
expect(content).toContain('Draft tools create or update local drafts only')
expect(content).toContain('Draft tools create or update drafts only')
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
'Use discard_local_draft to remove a draft, including the matching open editor draft'
)
expect(content).toContain(
'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step'
@@ -1883,7 +2095,7 @@ describe('prepareGlobalSystemMessage', () => {
const deleteItem = getGlobalTool('delete_workspace_item')
expect(discard.def.function.description).toBe(
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
)
expect(deleteItem.def.function.description).toBe(
'Delete a deployed workspace item. Mutates the workspace.'
@@ -1984,7 +2196,8 @@ describe('prepareGlobalSystemMessage', () => {
const handler = vi.fn(() => ({
aiResult: 'runs output. Next step: call get_job_logs.',
uiMessage: 'Listed 1 app run',
toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
toolResult:
'[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
}))
setListAppRunsHandler(handler)
const result = await callGlobalTool('list_app_runs', {}, callbacks, {
@@ -2003,7 +2216,8 @@ describe('prepareGlobalSystemMessage', () => {
const handler = vi.fn(() => ({
aiResult: 'runs output',
uiMessage: 'Listed app runs',
toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
toolResult:
'[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
}))
setListAppRunsHandler(handler)
await callGlobalTool('list_app_runs', { limit: 5 }, toolCallbacks, {
File diff suppressed because it is too large Load Diff
@@ -143,6 +143,31 @@ describe('global AI deploy request builders', () => {
expect(requestBody.value.groups).toEqual(draftValue.groups)
})
it('deploys a draft-set flow description, overriding the existing one', () => {
const existing = {
path: 'f/demo/flow',
summary: 'existing summary',
description: 'existing description',
value: { modules: [] },
schema: {}
} as unknown as Flow
const requestBody = buildFlowDeployRequestBody(
'f/demo/flow',
undefined,
{
value: { modules: [] },
schema: null,
groups: null,
description: 'draft-set description'
} as any,
existing,
undefined
)
expect(requestBody.description).toBe('draft-set description')
})
it('falls back to existing flow schema when the draft has no schema', () => {
const existing = {
path: 'f/demo/flow',
@@ -84,7 +84,7 @@ export function buildFlowDeployRequestBody(
return {
path,
summary: draftSummary ?? existing?.summary ?? '',
description: existing?.description ?? '',
description: flowDraft.description ?? existing?.description ?? '',
value: flowValueWithDraftGroups(flowDraft),
schema: flowDraft.schema ?? existing?.schema ?? {},
tag: existing?.tag,
@@ -1,4 +1,8 @@
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { DraftService } from '$lib/gen'
import { get } from 'svelte/store'
import { userStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte'
import {
@@ -143,7 +147,8 @@ function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
value: {
value: draft.value,
schema: draft.schema ?? null,
groups: draft.value.groups ?? null
groups: draft.value.groups ?? null,
description: draft.description ?? null
},
isDraft: true
}
@@ -321,17 +326,178 @@ function getGlobalDraftSlot(
return { itemKind, storagePath, displayPath, item }
}
export function getGlobalDraft(
// Current user's persisted draft value (+ records the sync baseline so a later
// save detects external conflicts). undefined on 404 (no draft at that path).
async function fetchBackendDraftValue(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): Promise<unknown | undefined> {
try {
const resp = await DraftService.getDraftForUser({
workspace,
kind: itemKind as any,
path: storagePath,
username: get(userStore)?.username
})
UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at)
return resp.value ?? undefined
} catch (e) {
// 404 = no draft for this owner at that path (the intended empty case).
// Anything else (403/500/network) MUST propagate: swallowing it would make
// the write merge fall through to the deployed item instead of the user's
// in-progress draft, silently overwriting their draft-only changes.
if ((e as { status?: number } | null | undefined)?.status === 404) return undefined
throw e
}
}
// Draft VALUE for a write merge: cell-if-present (the user's freshest in-tab
// edits) else the current user's backend draft.
export async function readGlobalDraftValue<V>(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
): Promise<V | undefined> {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const cell = UserDraft.get<V>(itemKind, storagePath, { workspace })
if (cell !== undefined) return cell
return (await fetchBackendDraftValue(workspace, itemKind, storagePath)) as V | undefined
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
export type DraftPersistResult =
| { status: 'saved'; item: WorkspaceItem }
| { status: 'conflict'; item: WorkspaceItem; serverTimestamp?: string }
| { status: 'error'; item: WorkspaceItem; message: string }
// Persist a built draft value. `UserDraft.seed` reflects it into an open editor's
// cell WITHOUT a double-POST (no-ops if no cell; its seedNextWrite suppresses the
// cell's autosave mirror), then the awaited immediate save is the single source of
// persistence + conflict detection against the shared baseline. force overwrites.
export async function persistGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
value: unknown,
opts: { triggerKind?: TriggerKind; force?: boolean } = {}
): Promise<DraftPersistResult> {
const itemKind = itemKindFor(type, opts.triggerKind)
if (!itemKind) throw new Error(`Unsupported draft type "${type}".`)
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
UserDraft.seed(itemKind, storagePath, value, { workspace })
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value,
immediate: true,
force: opts.force
})
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const item = userDraftEntryToWorkspaceItem(
{ workspace, itemKind, path: storagePath, value },
displayPath,
isLiveDraft
)
if (!item) throw new Error(`Could not synthesize ${type} draft "${path}".`)
// A failed save (network/5xx) is recorded in the syncer's failure map, not
// thrown — so check it before reporting success, else a write tool would tell
// the chat "saved" while the DB-backed source of truth was never updated.
const saveState = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath })
if (saveState.state === 'failed') {
return { status: 'error', item, message: saveState.failureMessage ?? 'Draft save failed' }
}
const conflict = opts.force
? undefined
: UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict
return conflict
? { status: 'conflict', item, serverTimestamp: conflict.serverTimestamp }
: { status: 'saved', item }
}
export async function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): Promise<WorkspaceItem | undefined> {
const slot = getGlobalDraftSlot(workspace, type, path, triggerKind)
if (slot) return slot.item
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const value = await fetchBackendDraftValue(workspace, itemKind, storagePath)
if (value === undefined || value === null) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
return userDraftEntryToWorkspaceItem(
{ workspace, itemKind, path: storagePath, value },
displayPath,
isLiveDraft
)
}
// Maps a backend `listDrafts` metadata row (no value) to a lightweight item.
// The row's `path` is the storage path; remap it to the live editor's effective
// path (and flag it) when one is open on this key, matching the cell path.
function backendDraftRowToWorkspaceItem(
workspace: string,
row: {
kind: string
path: string
summary?: string
}
): WorkspaceItem | undefined {
if (!(GLOBAL_DRAFT_KINDS as readonly string[]).includes(row.kind)) return undefined
let type: WorkspaceItemType
let triggerKind: TriggerKind | undefined
switch (row.kind) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
type = row.kind
break
case 'raw_app':
type = 'app'
break
case 'trigger_schedule':
type = 'schedule'
break
default: {
const tk = TRIGGER_KIND_BY_DRAFT_KIND[row.kind as UserDraftItemKind]
if (!tk) return undefined
type = 'trigger'
triggerKind = tk
}
}
const { displayPath, isLiveDraft } = liveDisplayPath(
workspace,
row.kind as UserDraftItemKind,
row.path
)
return {
type,
path: displayPath,
summary: row.summary,
value: undefined,
isDraft: true,
triggerKind,
...(isLiveDraft ? { isLiveDraft: true } : {})
}
}
export async function listGlobalDrafts(workspace: string): Promise<WorkspaceItem[]> {
const drafts = new Map<string, WorkspaceItem>()
const rows = await DraftService.listDrafts({ workspace })
for (const row of rows) {
const item = backendDraftRowToWorkspaceItem(workspace, row)
if (!item) continue
drafts.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
}
// Overlay live in-tab cells (full values + the user's live edits); cell wins.
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
@@ -341,30 +507,27 @@ export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
return Array.from(drafts.values())
}
export function saveGlobalAppDraft(
export async function saveGlobalAppDraft(
workspace: string,
path: string,
value: AppDraftValue
): WorkspaceItem {
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
const normalized = normalizeAppDraftValue(value)
UserDraft.save('raw_app', storagePath, normalized, { workspace })
const stored = getGlobalDraft(workspace, 'app', path)
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
return stored
): Promise<DraftPersistResult> {
// Return the full result (not just the item) so app write tools surface a
// conflict / save failure instead of reporting every stale write as saved.
return persistGlobalDraft(workspace, 'app', path, normalizeAppDraftValue(value), {})
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
export function deleteGlobalDraft(
export async function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
options: DeleteGlobalDraftOptions = {}
): void {
): Promise<void> {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
@@ -374,6 +537,27 @@ export function deleteGlobalDraft(
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
}
// `remove`/`clear` only debounce the delete; persist it now so a deploy/discard
// that the caller awaits has actually cleared the server draft on return.
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value: null,
immediate: true
})
// A failed (network/5xx) or conflicted delete is recorded in the syncer state,
// not thrown — surface it so callers don't report the draft as removed while
// the DB-backed source of truth still has it (same guard as the write path).
const state = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath })
if (state.state === 'failed') {
throw new Error(state.failureMessage ?? `Failed to delete draft "${path}".`)
}
if (UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict) {
throw new Error(
`Draft "${path}" changed externally since you last read it; it was not removed. Re-read and retry.`
)
}
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
}
@@ -28,6 +28,7 @@ export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
description?: string | null
}
export const TRIGGER_KINDS = [
+11 -1
View File
@@ -17,7 +17,7 @@ export type FlowBuilderProps = {
loading?: boolean
flowStore: StateStore<OpenFlow>
flowStateStore: StateStore<FlowState>
savedFlow?: Flow | undefined
savedFlow?: Flow & { no_deployed?: boolean }
diffDrawer?: DiffDrawerI | undefined
customUi?: FlowBuilderWhitelabelCustomUi
disableAi?: boolean
@@ -33,6 +33,16 @@ export type FlowBuilderProps = {
}
noInitial?: boolean
liveEditorDraftStoragePath?: string
// Indicator-only draft key overrides. When the flow editor is embedded
// (e.g. the sessions preview) its autosave runs under a different
// (workspace, path) than `$workspaceStore`/`liveEditorDraftStoragePath`
// (a forked workspace, and a path this component doesn't own). These let
// the host point the `AutosaveIndicator` at the key its own autosave uses,
// WITHOUT repurposing `liveEditorDraftStoragePath` (which still drives this
// component's setLiveEditorDraft/flush). Undefined → fall back, so the
// full-page editor is unaffected.
autosaveWorkspace?: string
autosavePath?: string
onDeploy?: ({ path }: { path: string }) => void
onDeployError?: ({ error }: { error: any }) => void
onDetails?: ({ path }: { path: string }) => void
@@ -72,6 +72,8 @@
summary: string
policy: any
draft_only?: boolean
/** No deployed counterpart exists (draft-only); disables Diff. */
no_deployed?: boolean
custom_path?: string
}
| undefined
@@ -88,6 +90,11 @@
* preference. */
sidebarStorageKey?: string
liveEditorDraftStoragePath?: string
/** Indicator-only overrides forwarded to RawAppEditorHeader so the
* sessions preview's AutosaveIndicator watches the session's
* (workspace, path). Undefined on the full-page editor. */
autosaveWorkspace?: string
autosavePath?: string
/** Initial value for the "Split with Preview" tab-bar toggle. Defaults
* to `true` (split mode, preview always pinned to the right). Set
* `false` when the editor mounts inside a context that wants single-
@@ -125,6 +132,8 @@
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
defaultSplitWithPreview = true,
pendingDraftPath = $bindable(undefined),
onResetToDeployed,
@@ -1503,6 +1512,8 @@
{newPath}
appPath={path}
{liveEditorDraftStoragePath}
{autosaveWorkspace}
{autosavePath}
{files}
{data}
{runnables}
@@ -101,6 +101,8 @@
summary: string
policy: any
custom_path?: string
/** No deployed counterpart exists (draft-only); disables Diff. */
no_deployed?: boolean
}
| undefined
version?: number | undefined
@@ -126,6 +128,12 @@
onToggleSidebar?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
liveEditorDraftStoragePath?: string
/** Indicator-only overrides for the sessions preview: the AutosaveIndicator
* watches the session's (workspace, path) so it renders + animates on the
* key SessionEditorTarget saves under. Undefined on the full-page editor →
* falls back to `$workspaceStore`/`liveEditorDraftStoragePath`. */
autosaveWorkspace?: string
autosavePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
/** Surfaces the user-typed path (`newEditedPath`) up to the route
@@ -168,6 +176,8 @@
onToggleSidebar = undefined,
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
onDeploy = undefined,
pendingDraftPath = $bindable(undefined),
onResetToDeployed,
@@ -176,6 +186,11 @@
onOpenOthersDrafts
}: Props = $props()
// The AutosaveIndicator watches these; in the sessions preview they're the
// session's (workspace, path), else the full-page editor's own values.
const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
@@ -184,12 +199,19 @@
})
})
// `newApp` is true both for a brand-new app AND (in the session preview) for a
// draft-only one that already has a real path — so prefer the real `appPath`,
// but NOT a `draft_{uuid}` storage placeholder (a brand-new app is parked at
// `u/{user}/draft_{uuid}`). A real named path is kept (else its breadcrumb shows
// a random name and deploy createApps under it); a placeholder still falls
// through to the friendly generated suggestion.
let newEditedPath = $state(
untrack(() =>
newApp
? newPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
untrack(() => {
const realAppPath = appPath && !appPath.split('/').pop()?.startsWith('draft_') ? appPath : ''
return newApp
? newPath || realAppPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
: newPath || appPath || ''
)
})
)
$effect(() => {
@@ -752,11 +774,11 @@
raw_app
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
{#if indicatorWorkspace && indicatorPath !== undefined}
<AutosaveIndicator
workspace={$workspaceStore}
workspace={indicatorWorkspace}
itemKind="raw_app"
path={liveEditorDraftStoragePath}
path={indicatorPath}
draftOnly={newApp}
{onResetToDeployed}
{loadedFromDraft}
@@ -782,17 +804,34 @@
{/snippet}
</DropdownV2>
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedApp || newApp}
iconOnly={compactTopbar}
title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'}
startIcon={{ icon: DiffIcon }}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
never shows on hover. pointer-events-none on the button lets the hover
reach this titled wrapper instead. -->
<div
title={newApp || savedApp?.no_deployed === true
? 'Deploy this app once to compare against the deployed version'
: 'Diff'}
class={!savedApp || newApp || savedApp?.no_deployed === true
? 'flex cursor-not-allowed'
: 'flex'}
>
Diff
</Button>
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedApp || newApp || savedApp?.no_deployed === true}
btnClasses={!savedApp || newApp || savedApp?.no_deployed === true
? 'pointer-events-none'
: undefined}
iconOnly={compactTopbar}
title={newApp || savedApp?.no_deployed === true
? 'Deploy this app once to compare against the deployed version'
: 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible">
<Button
+11 -1
View File
@@ -27,6 +27,16 @@ export interface ScriptBuilderProps {
* stop/restart pair is a no-op on a non-live entry.
*/
userDraftPath?: string
/**
* Workspace + path the AutosaveIndicator watches for sync state. Default
* (undefined) falls back to `$workspaceStore` / `userDraftPath` the
* full-page editor. The sessions preview sets these to the session's
* (possibly forked) workspace and target path, where autosave is owned by
* `SessionEditorTarget`/`useUserDraftSync`, so the indicator must watch that
* key rather than the global store + the unset `userDraftPath`.
*/
autosaveWorkspace?: string
autosavePath?: string
template?:
| 'docker'
| 'bunnative'
@@ -41,7 +51,7 @@ export interface ScriptBuilderProps {
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: Script | NewScript | undefined
savedScript?: (Script | NewScript) & { no_deployed?: boolean }
searchParams?: URLSearchParams
disableHistoryChange?: boolean
customUi?: ScriptBuilderWhitelabelCustomUi
@@ -7,6 +7,27 @@
import { getDraftDiffValues, type DraftKind } from '$lib/utils_draft_deploy'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
// Draft rows carry the user-draft itemKind (`trigger_schedule`, `trigger_http`…),
// but the shared row icon/label and the edit-link builder speak the deploy-style
// kinds (`schedule`, `http_trigger`…) the fork drawer and compare page use. Map
// to deploy-style for display, and back for the draft-value getter.
const DEPLOY_KIND_BY_DRAFT_KIND: Partial<Record<DraftKind, string>> = {
trigger_schedule: 'schedule',
trigger_http: 'http_trigger',
trigger_websocket: 'websocket_trigger',
trigger_kafka: 'kafka_trigger',
trigger_nats: 'nats_trigger',
trigger_postgres: 'postgres_trigger',
trigger_mqtt: 'mqtt_trigger',
trigger_sqs: 'sqs_trigger',
trigger_gcp: 'gcp_trigger',
trigger_azure: 'azure_trigger',
trigger_email: 'email_trigger'
}
const DRAFT_KIND_BY_DEPLOY_KIND = Object.fromEntries(
Object.entries(DEPLOY_KIND_BY_DRAFT_KIND).map(([d, p]) => [p, d])
) as Record<string, DraftKind>
// Thin wrapper: supplies the deployed ↔ draft data source (server `draft`
// table, same as the compare page) to the generic WorkspaceDiffDrawer.
// Read-only, mirroring ForkDiffDrawer; deploy/discard live on the Review page.
@@ -37,9 +58,21 @@
rows = items.map((it) => {
// Raw apps must surface as `raw_app` so the row's edit link points at the
// raw-app editor (mirrors CompareDrafts); `getDraftItems` carries the flag.
const kind = it.raw_app ? 'raw_app' : it.kind
const baseKind = it.raw_app ? 'raw_app' : it.kind
const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind
donly[`${kind}/${it.path}`] = it.draft_only
return { kind, path: it.path, status: it.draft_only ? 'added' : 'modified' }
// A never-deployed app/raw_app is parked at a synthetic `…/draft_<uuid>`
// storage path with the user's typed name in `draft_path`; show that
// (matches the home list) while `path` stays the storage key for loading.
// `summary` comes straight from the draft row, so it shows for every kind
// up front instead of only after the diff value loads.
return {
kind,
path: it.path,
displayPath: it.draft_path ?? it.path,
summary: it.summary,
status: it.draft_only ? 'added' : 'modified'
}
})
draftOnlyByKey = donly
} catch (e) {
@@ -53,8 +86,11 @@
async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> {
const draftOnly = draftOnlyByKey[`${d.kind}/${d.path}`] ?? false
// getDraftDiffValues works on the draft_type kind ('app' for raw apps too).
const kind: DraftKind = d.kind === 'raw_app' ? 'app' : (d.kind as DraftKind)
// getDraftDiffValues keys on the draft itemKind: `raw_app` must stay
// `raw_app` (the helper sends rawApp:true only for that exact kind, which a
// never-deployed raw app needs, else it hits the normal app endpoint and
// 404s). Only the trigger display kinds map back from their deploy-style names.
const kind = (DRAFT_KIND_BY_DEPLOY_KIND[d.kind] ?? d.kind) as DraftKind
const { deployed, draft } = await getDraftDiffValues(kind, d.path, workspaceId, draftOnly)
// draft_only items have never been deployed → render as "added" (empty
// before), matching how the fork drawer renders added items.
@@ -75,12 +75,21 @@
>
{#snippet editor()}
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
has its own AI chat in the left pane, so the toggle is redundant here. -->
has its own AI chat in the left pane, so the toggle is redundant here.
newFlow: a draft-only flow has a synthesized `savedFlow` (no_deployed=true)
but no deployed row, so it must deploy via createFlow — treating it as
!newFlow would updateFlow the draft_<uuid> path and 404 "Flow not found".
initialPath: a brand-new flow is stored under a `draft_<uuid>` path with
its intended name in `draft_path`; seed the builder from `draft_path`
(as the full-page editor does) so the Path widget and deploy use the
friendly name rather than creating a flow literally named draft_<uuid>. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={path}
newFlow={!runtime.savedFlow.val}
initialPath={(runtime.savedFlow.val as any)?.draft_path ?? path}
autosaveWorkspace={workspaceId}
autosavePath={path}
newFlow={!runtime.savedFlow.val || runtime.savedFlow.val.no_deployed === true}
{selectedId}
loading={false}
bind:savedFlow={runtime.savedFlow.val}
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
@@ -29,6 +30,32 @@
let diffDrawer: DiffDrawer | undefined = $state()
// Path typed in the editor header, surfaced when it differs from the stored
// path. Mirror it into the runtime draft as `draft_path` so the rename
// mutates runtime.rawApp.val → the autosave sig changes → the draft is saved
// (and the home/review/Drafts lists show the friendly name). Mirrors the
// full-page /apps_raw/edit route.
let pendingDraftPath = $state<string | undefined>(undefined)
// The header collapses both "not yet bound" and "reverted to baseline" to
// `undefined`. `surfacedDraftPath` tells them apart: the initial undefined
// (before the header binds) must not clobber the `draft_path` seeded by
// loadRawApp, but a revert/clear after a real typed path must drop the stale
// friendly name — mirroring the script codec's `else delete draft_path`.
let surfacedDraftPath = false
$effect(() => {
const dp = pendingDraftPath
untrack(() => {
const val = runtime.rawApp.val
if (!val) return
if (dp !== undefined) {
surfacedDraftPath = true
if (val.draft_path !== dp) val.draft_path = dp
} else if (surfacedDraftPath && val.draft_path !== undefined) {
val.draft_path = undefined
}
})
})
async function reloadDeployed() {
await runtime.loadRawApp(workspaceId, path, true, true)
}
@@ -67,16 +94,23 @@
>
{#snippet editor()}
{#if runtime.rawApp.val}
<!-- newApp: a draft-only app (no_deployed=true) has a truthy synthesized
savedApp but no deployed row, so it must deploy via createApp — keying
on !savedApp alone would updateApp a never-deployed path and 404
"not found". -->
<RawAppEditor
bind:files={runtime.rawApp.val.files}
bind:runnables={runtime.rawApp.val.runnables}
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
newPath={runtime.rawApp.val.path}
bind:pendingDraftPath
newPath={runtime.rawApp.val.draft_path ?? runtime.rawApp.val.path}
{path}
autosaveWorkspace={workspaceId}
autosavePath={path}
policy={runtime.rawApp.val.policy}
bind:savedApp={runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val}
newApp={!runtime.savedRawApp.val || runtime.savedRawApp.val.no_deployed === true}
{diffDrawer}
{onNavigate}
onResetToDeployed={reloadDeployed}
@@ -97,6 +97,8 @@
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={runtime.savedScript.val ? path : ''}
autosaveWorkspace={workspaceId}
autosavePath={path}
initialPathChosen={true}
neverShowMeta={true}
fullyLoaded={!runtime.slot('script').loading}
@@ -49,7 +49,7 @@
function buildCodec(): DraftSyncCodec<any> {
if (kind === 'flow') return makeFlowCodec(runtime)
if (kind === 'script') return makeScriptCodec(runtime)
if (kind === 'script') return makeScriptCodec(runtime, () => path)
return makeRawAppCodec(runtime)
}
@@ -295,8 +295,13 @@
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
<!-- Undefined pane sizes (not an explicit `size`): Splitpanes auto-distributes —
a lone chat pane fills 100%, and when the editor pane mounts the two split
50/50. A reactive `size={hasEditor ? 50 : 100}` here instead races the
sibling pane appearing on reload → "Could not resize panes due to constraints"
and a wrong split. -->
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane size={hasEditor ? 50 : 100} minSize={25} class="flex flex-col min-h-0 pb-2">
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
<header class="flex flex-row items-center gap-1 pl-4 pr-4 py-2 shrink-0">
<EditableInput
bind:this={summaryInput}
@@ -408,7 +413,7 @@
</div>
</Pane>
{#if hasEditor && session.target}
<Pane size={50} minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
<Pane minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
<div
transition:slide={{ axis: 'x', duration: 200 }}
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative"
@@ -8,6 +8,13 @@
status: DiffStatus
ahead?: number
behind?: number
/** Human-facing path; defaults to `path`. Lets a draft parked at a
* synthetic storage path (`…/draft_<uuid>`) show its friendly typed path
* while keys, value-loading and edit links stay keyed on `path`. */
displayPath?: string
/** Summary supplied by the data source. Preferred over the one derived
* from the loaded diff value, and shown before that value loads. */
summary?: string
}
</script>
@@ -88,6 +95,12 @@
return `${d.kind}/${d.path}`
}
// Friendly path for display only; `path` stays the storage key everywhere
// keys/loads happen, so a never-deployed draft still loads from `…/draft_<uuid>`.
function displayPathOf(d: DiffRow): string {
return d.displayPath ?? d.path
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
@@ -183,9 +196,9 @@
}
const folderCache = new Map<string, FolderNode>()
for (const d of rows) {
const parts = d.path.split('/')
const parts = displayPathOf(d).split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
root.children.push({ type: 'file', name: displayPathOf(d), diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
@@ -226,8 +239,8 @@
}
function searchableText(d: DiffRow): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
const parts = [displayPathOf(d), KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)] ?? d.summary
if (s) parts.push(s)
return parts.join(' ')
}
@@ -432,12 +445,13 @@
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind as any}
summary={summaries[key]}
uniformHeight
summary={summaries[key] ?? node.diff.summary}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
title={displayPathOf(node.diff)}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
@@ -555,6 +569,7 @@
{@const StatusIcon = statusIcons[status]}
{@const loaded = loadedDiffs[key]}
{@const editUrl = editUrlFor?.(d)}
{@const dpath = displayPathOf(d)}
<details
open
id={rowId(d)}
@@ -572,14 +587,14 @@
{#if editUrl}
<ExternalEditLink
href={editUrl}
title={d.path}
title={dpath}
class="text-xs text-primary font-mono truncate"
>
<span class="truncate">{d.path}</span>
<span class="truncate">{dpath}</span>
</ExternalEditLink>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
<div class="text-xs text-primary font-mono truncate" title={dpath}>
{dpath}
</div>
{/if}
</div>
@@ -59,3 +59,22 @@ describe('appDraftCodec — custom_path round-trip', () => {
expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing')
})
})
describe('appDraftCodec — draft_path round-trip', () => {
it('serializes draft_path so a path edit changes the draft (and its sig)', () => {
const draft = runtimeRawAppToDraft(runtime({ draft_path: 'u/admin/friendly' }))
expect(draft.draft_path).toBe('u/admin/friendly')
// The autosave keys on JSON.stringify(draft); without draft_path a rename
// would be invisible and never persist.
expect(JSON.stringify(draft)).toContain('u/admin/friendly')
})
it('survives a full runtime → draft → runtime round-trip', () => {
const original = runtime({ draft_path: 'u/admin/renamed' })
const back = applyDraftToRuntimeRawApp(
runtime({ draft_path: undefined }),
runtimeRawAppToDraft(original)
)
expect(back.draft_path).toBe('u/admin/renamed')
})
})
@@ -11,6 +11,11 @@ export type RawAppDraft = {
summary: string
policy?: any
custom_path?: string
// User-typed path while the app is parked at a `…/draft_<uuid>` storage path.
// Must round-trip through the draft so the home/review/Drafts lists render the
// friendly name (they read `value->>'draft_path'`) — and so editing the path
// in the editor changes the persisted draft and triggers an autosave.
draft_path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
@@ -24,6 +29,7 @@ export type RuntimeRawApp = {
data: RawAppData
policy: any
custom_path?: string
draft_path?: string
}
// Strip runtime-only metadata (just `path`, the storage key) when persisting
@@ -36,7 +42,8 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
runnables: raw.runnables,
data: raw.data,
policy: raw.policy,
custom_path: raw.custom_path
custom_path: raw.custom_path,
draft_path: raw.draft_path
}
}
@@ -50,6 +57,7 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft):
runnables: dv.runnables,
data: dv.data,
policy: dv.policy ?? raw.policy,
custom_path: dv.custom_path ?? raw.custom_path
custom_path: dv.custom_path ?? raw.custom_path,
draft_path: dv.draft_path ?? raw.draft_path
}
}
@@ -19,4 +19,15 @@ describe('flowDraftSig', () => {
flowDraftSig({ value: { modules: [1] }, summary: 's' })
)
})
it('changes when only draft_path changes (path rename triggers a save)', () => {
const base = { value: { modules: [] }, summary: 's', draft_path: 'u/admin/draft_abc' }
const renamed = { ...base, draft_path: 'u/admin/friendly' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed))
})
it('changes when only path changes', () => {
const base = { value: { modules: [] }, summary: 's', path: 'u/admin/a' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/b' }))
})
})
@@ -1,9 +1,27 @@
// Dedup signature for the session flow preview's two-way sync (FlowEditorView).
//
// The inbound (draft → editor) and outbound (editor → draft) effects compare
// this signature to skip no-op work. It MUST include `summary` — otherwise a
// summary-only change produces an identical signature and never propagates or
// persists.
export function flowDraftSig(x: { value?: unknown; schema?: unknown; summary?: unknown }): string {
return JSON.stringify({ value: x.value, schema: x.schema, summary: x.summary })
// this signature to skip no-op work. It MUST include every top-level field the
// editor can change on its own — `summary`, `description`, and the path —
// otherwise a change to only that field produces an identical signature and
// never propagates or persists. The Path widget writes a rename into
// `draft_path` (FlowBuilder mirrors the typed path there while the flow is
// parked at its `…/draft_<uuid>` storage `path`); without it here the rename
// moves no signature and the draft is never saved.
export function flowDraftSig(x: {
value?: unknown
schema?: unknown
summary?: unknown
description?: unknown
path?: unknown
draft_path?: unknown
}): string {
return JSON.stringify({
value: x.value,
schema: x.schema,
summary: x.summary,
description: x.description,
path: x.path,
draft_path: x.draft_path
})
}
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest'
// `sessionDraftCodecs` statically imports `initFlowState`, which pulls the flow
// editor's Monaco chain (unloadable under vitest). The script codec never calls
// it; stub the module so the import resolves.
vi.mock('$lib/components/flows/flowState', () => ({ initFlowState: () => Promise.resolve() }))
import { makeScriptCodec } from './sessionDraftCodecs'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { NewScript } from '$lib/gen'
// Minimal runtime stub: the script codec only touches `runtime.scriptStore.val`.
function runtimeWith(script: Partial<NewScript> & { path: string }): SessionRuntime {
return { scriptStore: { val: script as NewScript } } as unknown as SessionRuntime
}
const STORAGE = 'u/admin/draft_abc'
describe('makeScriptCodec — draft_path (path rename)', () => {
it('writes draft_path when the typed path differs from the storage key', () => {
const codec = makeScriptCodec(
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }),
() => STORAGE
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
expect(draft?.draft_path).toBe('u/admin/friendly')
})
it('drops draft_path when the typed path equals the storage key', () => {
const codec = makeScriptCodec(
runtimeWith({ path: STORAGE, content: 'c', summary: 's' }),
() => STORAGE
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
expect(draft?.draft_path).toBeUndefined()
})
it('signature changes on a rename, so the outbound sync persists it', () => {
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }), () => STORAGE)
const after = makeScriptCodec(
runtimeWith({ path: 'u/admin/renamed', content: 'c' }),
() => STORAGE
)
expect(before.sig(before.storeToDraft(undefined)!)).not.toBe(
after.sig(after.storeToDraft(undefined)!)
)
})
})
@@ -21,7 +21,8 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec<Flow> {
...current,
value: incoming.value,
schema: incoming.schema ?? current.schema,
summary: incoming.summary ?? current.summary
summary: incoming.summary ?? current.summary,
description: incoming.description ?? current.description
}
// flowStateStore is keyed by module_id; after an AI write the set of
// module ids may differ, so rebuild the UI state. This wipes per-module
@@ -34,10 +35,32 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec<Flow> {
}
}
export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec<NewScript> {
// `NewScript` has no `draft_path` of its own; the session editor parks a rename
// there so the home/Drafts lists (which read `draft_path`) show the typed name.
type ScriptDraft = NewScript & { draft_path?: string }
export function makeScriptCodec(
runtime: SessionRuntime,
// The draft's storage key (the URL path). A never-deployed script is parked
// here at `…/draft_<uuid>` while the user's typed name lives in `script.path`.
storagePath: () => string
): DraftSyncCodec<ScriptDraft> {
return {
itemKind: 'script',
sig: (d) => d.content ?? '',
// Must include every field write_script can set — not just content —
// else a summary-only/language-only change yields an identical signature
// and the inbound/outbound sync skips it (the chat's change is then
// invisible in the open editor and clobbered by the next content save).
// `draft_path` carries a rename: ScriptBuilder binds the Path widget
// straight to `script.path` (no separate draft field like flow/raw_app),
// so without it here a rename moves no signature and never autosaves.
sig: (d) =>
JSON.stringify({
content: d.content ?? '',
summary: d.summary,
language: d.language,
draft_path: d.draft_path
}),
debounceMs: DEBOUNCE_MS,
applyDraftToStore(incoming) {
const script = runtime.scriptStore.val
@@ -52,7 +75,16 @@ export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec<NewScri
if (!script) return undefined
// Merge over the existing entry so fields the preview doesn't edit
// (set by the chat) survive a content-only save.
return { ...(current ?? script), ...script }
const merged: ScriptDraft = { ...(current ?? script), ...script }
// Surface a rename to the home/Drafts lists, which read `draft_path`
// (the typed `script.path` is the draft *value*'s path, not its storage
// key). Mirror flow/raw_app: set it only when the typed path differs
// from the storage key, and drop it once it matches again so a revert
// doesn't leave a stale friendly name behind.
const typed = script.path
if (typed && typed !== storagePath()) merged.draft_path = typed
else delete merged.draft_path
return merged
}
}
}
@@ -41,6 +41,7 @@ import {
} from './sessionState.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { armRestartOnFirstInteraction } from '$lib/userDraftToast'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec'
import {
setDeployedInSessionHandler,
@@ -104,6 +105,7 @@ export interface SessionRuntime {
summary: string
path: string
custom_path?: string
draft_path?: string
}
| undefined
}
@@ -119,6 +121,8 @@ export interface SessionRuntime {
summary: string
policy: any
draft_only?: boolean
/** No deployed counterpart (draft-only); disables the topbar Diff. */
no_deployed?: boolean
custom_path?: string
}
| undefined
@@ -447,7 +451,15 @@ function createRuntime(session: Session): SessionRuntime {
)
) as NewScript)
: {
path,
// Seed from the draft's own path (a rename lives in `draft_path`,
// else `path`), not the storage key. Otherwise re-seeding a renamed
// never-deployed draft (e.g. a script→script switch re-runs loadScript
// with the draft still in memory) resets the path to `draft_<uuid>`,
// and the next autosave drops `draft_path` — clobbering the rename.
path:
(aiDraft as NewScript & { draft_path?: string }).draft_path ??
aiDraft.path ??
path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
@@ -532,7 +544,8 @@ function createRuntime(session: Session): SessionRuntime {
value: result.value as any,
path: result.path,
policy: result.policy,
custom_path: result.custom_path
custom_path: result.custom_path,
no_deployed: result.no_deployed
}
} catch {
savedRawApp.val = undefined
@@ -566,7 +579,8 @@ function createRuntime(session: Session): SessionRuntime {
value: result.value as any,
path: result.path,
policy: result.policy,
custom_path: result.custom_path
custom_path: result.custom_path,
no_deployed: result.no_deployed
}
// Prefer the server draft over the deployed value (mirrors the
// flow/script `result.draft ?? result`). A raw-app draft is already
@@ -595,7 +609,8 @@ function createRuntime(session: Session): SessionRuntime {
policy: draftValue?.policy ?? result.policy,
summary: draftValue?.summary ?? result.summary ?? '',
path: result.path,
custom_path: draftValue?.custom_path ?? result.custom_path
custom_path: draftValue?.custom_path ?? result.custom_path,
draft_path: draftValue?.draft_path
}
// Seed the per-tab last_sync from the server draft's timestamp so
// later saves attach a matching last_sync and the server can reject
@@ -618,10 +633,18 @@ function createRuntime(session: Session): SessionRuntime {
syncPreviewWithDeployed(workspace, kind, path) {
this.scheduleForkComparisonRefresh()
// After deploy the editor state equals the deployed value; the reload
// below re-seeds the cell from it, which must NOT POST as a fresh draft.
// The full-page editor guards this with discardDraftAfterDeploy, but the
// shared header skips that in a session pane (inSessionPane) and routes
// post-deploy cleanup here — so apply the same stopSync + arm-restart
// bracket. Covers all three kinds since they all funnel through this.
UserDraft.stopSync(kind, path, { workspace })
UserDraft.discard(kind, path, undefined, { workspace })
if (kind === 'script') void this.loadScript(workspace, path, true)
else if (kind === 'flow') void this.loadFlow(workspace, path, true)
else void this.loadRawApp(workspace, path, true)
armRestartOnFirstInteraction(workspace, kind, path)
},
setRuntimeLogRequester(requester) {
@@ -44,7 +44,10 @@ export interface UserDraftSyncOptions<Draft> {
* Bidirectional sync between a session editor's runtime store and the shared
* `UserDraft` cell for `(workspace, kind, path)`. Holding a *live* handle
* (`useMany`) is what lets the chat's writes (`write_script`, `patch_flow_json`,
* ) reach the open preview a plain `UserDraft.get` would only see localStorage.
* ) reach the open preview: the chat persists through the backend syncer and
* reflects into this in-tab cell, and the inbound effect below mirrors that into
* the editor. Without a mounted handle there is no cell, so the chat falls back
* to reading/writing the backend directly.
*
* - **inbound** (`handle.draft → store`): reflects external writes into the editor.
* - **outbound** (`store → handle`, debounced): persists editor edits.
@@ -881,6 +881,7 @@
deleteWorkspaceForkModal = false
}}
on:confirmed={() => {
deleteWorkspaceForkModal = false
deleteFork()
}}
>
@@ -11,7 +11,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
* overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`.
* The entity-specific backend load and new-draft template stay in the page.
*/
export interface PageDraftSyncOptions {
export interface PageDraftSyncOptions<V = unknown> {
itemKind: UserDraftItemKind
/** Reactive draft storage path. `''` (e.g. viewing a historical hash)
* releases the handle and skips registry/sync work. */
@@ -22,6 +22,14 @@ export interface PageDraftSyncOptions {
* draft's own `path`, used by home-page deep links). Omit to skip
* registry registration entirely (e.g. read-only hash views). */
effectivePath?: () => string | undefined
/** Predicate: is the value about to autosave back at the deployed
* baseline? When true the syncer POSTs a delete instead of a
* baseline-equal draft, so editing back to deployed clears the draft
* instead of leaving a no-op behind. MUST read the deployed baseline
* reactively (it's captured once per re-keyed acquire) and use
* `draftValuesEqual` so it can't disagree with the "unsaved changes"
* banner. Return false for draft-only items (no deployed baseline). */
discardIf?: (val: V) => boolean
}
export interface PageDraftSync<V> {
@@ -41,14 +49,15 @@ export interface PageDraftSync<V> {
remove(): void
}
export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions): PageDraftSync<V> {
export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): PageDraftSync<V> {
// One handle, re-keyed on (workspace, path); `''` path releases it.
// `canBeDisabled` because these editors carry the "Enable auto-save" toggle.
const handle = UserDraft.useReactive<V>(() => ({
itemKind: opts.itemKind,
path: opts.path(),
workspace: opts.workspace(),
canBeDisabled: true
canBeDisabled: true,
discardIf: opts.discardIf
}))
// Live-editor-draft registry: lets the home-page "edit draft" link resolve
+133 -16
View File
@@ -121,6 +121,35 @@ const liveEditorDrafts = new Map<string, LiveEditorDraft>()
* Consumed by `acquireEntry`; cleared by the matching `restartSync`. */
const pendingSuspensions = new Set<string>()
/**
* Synchronous read-through cache for values written via `save` while no live
* editor entry exists. That branch persists through the debounced
* `UserDraftDbSyncer` (async, fire-and-forget), so without this a same-tab
* `save(...)` followed by `get(...)` would miss its own write: the global AI
* chat writes a draft then immediately reads it back to return the result and
* would otherwise throw "Could not read written draft". A live entry shadows
* the cache (the entry is authoritative) and a release drops the key; a delete
* (`remove`/`discard`) evicts it.
*/
const writtenCache = new Map<
string,
{ workspace: string; itemKind: UserDraftItemKind; path: string; val: unknown }
>()
function rememberWrite(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
val: unknown
): void {
const mk = mapKey(workspace, itemKind, path)
if (val === undefined) {
writtenCache.delete(mk)
} else {
writtenCache.set(mk, { workspace, itemKind, path, val: snapshotDraftValue(val) })
}
}
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
if (!ws) {
@@ -158,11 +187,24 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
* run-as directives, not draft content, and the editor round-trips them
* asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load
* but `|| undefined` on build) keeping them produces a phantom banner.
*
* The rest are server-managed read-time metadata that ride along on the
* loaded deployed payload but never appear in the editor's draft content, so
* comparing them would mask a true baseline match:
* `draft_saved_at` (the draft's own save time), `edited_at` (deploy time),
* `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version),
* and `is_draft` (backend presence flag).
*/
const DRAFT_COMPARE_IGNORED_FIELDS = [
'permissioned_as',
'preserve_permissioned_as',
'extra_perms'
'extra_perms',
'draft_saved_at',
'edited_at',
'edited_by',
'workspace_id',
'version_id',
'is_draft'
] as const
/**
@@ -208,8 +250,10 @@ export const UserDraft = {
// and POSTs it.
entry.state.val = value
} else {
// No live handle: push straight to the syncer. The next editor
// mount re-fetches the draft from the backend.
// No live handle: remember the value so a same-tab read-after-write
// observes it synchronously (the syncer POST below is debounced),
// then persist. The next editor mount re-fetches from the backend.
rememberWrite(ws, itemKind, path, value)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
}
},
@@ -226,8 +270,10 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (!entry) return undefined
return snapshotDraftValue(entry.state.val as V | undefined)
if (entry) return snapshotDraftValue(entry.state.val as V | undefined)
const cached = writtenCache.get(mk)
if (cached) return snapshotDraftValue(cached.val as V | undefined)
return undefined
},
/**
@@ -238,8 +284,8 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (!entry) return false
return entry.state.val !== undefined
if (entry) return entry.state.val !== undefined
return writtenCache.get(mk)?.val !== undefined
},
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
@@ -252,6 +298,7 @@ export const UserDraft = {
entry.skipNextSync = true
entry.state.val = undefined
}
writtenCache.delete(mk)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
},
@@ -320,10 +367,12 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out: UserDraftEntry<V>[] = []
const seen = new Set<string>()
for (const entry of entries.values()) {
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
const val = untrack(() => entry.state.val as V | undefined)
if (val === undefined) continue
seen.add(mapKey(entry.workspace, entry.itemKind, entry.path))
out.push({
workspace: entry.workspace,
itemKind: entry.itemKind,
@@ -331,6 +380,19 @@ export const UserDraft = {
value: snapshotDraftValue(val)
})
}
// Drafts written without a live entry (e.g. global AI chat) live only in
// `writtenCache`; surface them too so the list matches what `get` returns.
for (const cached of writtenCache.values()) {
if (cached.workspace !== ws || !itemKinds.includes(cached.itemKind)) continue
const mk = mapKey(cached.workspace, cached.itemKind, cached.path)
if (seen.has(mk)) continue
out.push({
workspace: cached.workspace,
itemKind: cached.itemKind,
path: cached.path,
value: snapshotDraftValue(cached.val as V | undefined)
})
}
return out
},
@@ -392,6 +454,10 @@ export const UserDraft = {
entry.skipNextSync = true
entry.state.val = safeFallback
}
// The draft is deleted server-side (the `null` POST below); the fallback
// only resets the live handle's UI. Drop the cache so a no-entry read
// reports "no draft" rather than the discarded value.
writtenCache.delete(mk)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null, auto: opts?.auto })
},
@@ -401,6 +467,8 @@ export const UserDraft = {
opts?: UserDraftOptions & {
/** See the `useMany` spec field. Default `false`. */
canBeDisabled?: boolean
/** See the `useMany` spec field. Captured once on first acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
// Single-spec wrapper around `useMany`. `untrack` captures reactive
@@ -408,7 +476,13 @@ export const UserDraft = {
// workspace until unmount. For reactive `(kind, path)` use `useReactive`.
const handles = UserDraft.useMany<V>(() =>
untrack(() => [
{ itemKind, path, workspace: opts?.workspace, canBeDisabled: opts?.canBeDisabled }
{
itemKind,
path,
workspace: opts?.workspace,
canBeDisabled: opts?.canBeDisabled,
discardIf: opts?.discardIf
}
])
)
return handles[0]
@@ -427,6 +501,8 @@ export const UserDraft = {
path: string
workspace?: string
canBeDisabled?: boolean
/** See the `useMany` spec field. Captured per re-keyed acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
const handles = UserDraft.useMany<V>(() => [getSpec()])
@@ -478,6 +554,11 @@ export const UserDraft = {
const handles = $state<UserDraftHandle<V>[]>([])
const acquired = new Set<string>()
const handleCache = new Map<string, UserDraftHandle<V>>()
// `defaultValue` reference last used to seed each detached (empty-path)
// handle. The reference is stable within an editing session but swapped
// for a fresh clone each time the caller restarts (e.g. reopening the
// new-item drawer) — so a change here means "re-seed", not "live edit".
const detachedSeeds = new Map<string, unknown>()
function reconcile() {
const specs = getSpecs()
@@ -494,10 +575,24 @@ export const UserDraft = {
// `POST /drafts/update/kind/` (permanent "Save failed").
// Hand out a detached, local-only handle instead.
if (!spec.path) {
seen.add(mk)
let handle = handleCache.get(mk)
// Drop the cached handle when the caller hands in a fresh
// `defaultValue` reference (reopening the new-item drawer seeds a
// new clone) so the rebuilt handle re-seeds instead of replaying
// the previous session's edits. Stable reference within a session
// means live edits are never clobbered.
if (handle && detachedSeeds.get(mk) !== spec.defaultValue) {
handleCache.delete(mk)
handle = undefined
}
if (!handle) {
handle = makeDetachedHandle<V>()
// Seed with `defaultValue` so consumers (e.g. the new-variable
// drawer, whose path is empty until the user types one) get a
// populated cell to bind their form to instead of `undefined`.
handle = makeDetachedHandle<V>(spec.defaultValue)
handleCache.set(mk, handle)
detachedSeeds.set(mk, spec.defaultValue)
}
next.push(handle)
continue
@@ -531,6 +626,16 @@ export const UserDraft = {
}
}
// Detached handles (empty-path) live only in `handleCache` — they're
// never in `acquired`. Drop any that fell out of the specs so they
// don't leak and a later reappearance rebuilds from scratch.
for (const mk of [...handleCache.keys()]) {
if (!acquired.has(mk) && !seen.has(mk)) {
handleCache.delete(mk)
detachedSeeds.delete(mk)
}
}
// Skip no-op mutations (cached handles → reference-equal arrays).
// `untrack` so this effect doesn't subscribe to its own `handles`
// write — otherwise it self-loops (`effect_update_depth_exceeded`).
@@ -551,14 +656,21 @@ export const UserDraft = {
// vanish when the editor unmounts mid-typing. Fire-and-forget —
// the POST rides the runner's own lifetime, which outlives this
// component, so destroying the cell here doesn't cancel it.
//
// `honorAutosaveToggle`: this unmount flush is an implicit autosave,
// so a toggle-aware handle whose auto-save is off must NOT persist on
// leave — the editor's UnsavedConfirmationModal warns the user instead.
for (const mk of acquired) {
const entry = entries.get(mk)
if (!entry) continue
void UserDraftDbSyncer.flush({
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path
})
void UserDraftDbSyncer.flush(
{
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path
},
{ honorAutosaveToggle: true }
)
}
for (const mk of acquired) releaseEntry(mk)
acquired.clear()
@@ -696,6 +808,10 @@ function releaseEntry(mk: string): void {
if (!entry) return
entry.count--
if (entry.count <= 0) {
// The live entry was authoritative while mounted; once gone, drop any
// cached write for this key so a later read falls back to the server
// rather than a value the editor may have changed in the meantime.
writtenCache.delete(mk)
entry.destroyRoot?.()
entries.delete(mk)
}
@@ -706,8 +822,8 @@ function releaseEntry(mk: string): void {
* `bind:` but is wired to nothing (no entry, no sync, no POSTs). For views
* that bind an editor value with no draftable item behind it.
*/
function makeDetachedHandle<V>(): UserDraftHandle<V> {
let val = $state<V | undefined>(undefined)
function makeDetachedHandle<V>(defaultValue?: V): UserDraftHandle<V> {
let val = $state<V | undefined>(snapshotDraftValue(defaultValue))
return {
get draft(): V | undefined {
return val
@@ -743,4 +859,5 @@ function makeHandle<V>(
export function __resetUserDraftForTesting(): void {
entries.clear()
liveEditorDrafts.clear()
writtenCache.clear()
}
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Service layer is mocked: the migration must dedup against the deployed value
// without making real network calls.
const updateDraft = vi.fn(async (..._args: any[]) => ({ status: 'created' as const }))
const getScriptByPath = vi.fn()
const getFlowByPath = vi.fn()
const getAppByPath = vi.fn()
vi.mock('./gen', () => ({
DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) },
ScriptService: { getScriptByPath: (...a: unknown[]) => getScriptByPath(...(a as [])) },
FlowService: { getFlowByPath: (...a: unknown[]) => getFlowByPath(...(a as [])) },
AppService: { getAppByPath: (...a: unknown[]) => getAppByPath(...(a as [])) }
}))
// `migrateApp` mutates an App in place; the deployed fixtures below are already
// in migrated shape, so a no-op keeps the dedup comparison exact.
vi.mock('./components/apps/migrateApp', () => ({ migrateApp: vi.fn() }))
vi.mock('./toast', () => ({ sendUserToast: vi.fn() }))
vi.mock('./userNamespace', () => ({ getUsernameForNamespace: () => 'me' }))
vi.mock('./utils/uuid', () => ({ randomUUID: () => 'fixed-uuid' }))
import { migrateUserDraftsToDb } from './userDraftDbMigration'
function lsKey(kind: string, path: string): string {
return `userdraft/w/main/${kind}/${path}`
}
function setDraft(kind: string, path: string, value: unknown): string {
const key = lsKey(kind, path)
localStorage.setItem(key, JSON.stringify({ value, lastWrittenAt: 123 }))
return key
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
updateDraft.mockResolvedValue({ status: 'created' })
})
describe('migrateUserDraftsToDb dedup', () => {
it('drops a draft deep-equal to the deployed script without uploading it', async () => {
const deployed = { path: 'u/me/s', summary: 'hi', content: 'x', language: 'bun' }
getScriptByPath.mockResolvedValue(deployed)
const key = setDraft('script', 'u/me/s', { ...deployed })
await migrateUserDraftsToDb()
expect(getScriptByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/s',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads a draft that differs from the deployed script', async () => {
getScriptByPath.mockResolvedValue({
path: 'u/me/s',
summary: 'hi',
content: 'x',
language: 'bun'
})
const key = setDraft('script', 'u/me/s', {
path: 'u/me/s',
summary: 'hi',
content: 'EDITED',
language: 'bun'
})
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('treats `{ field: undefined }` and an absent field as equal (json normalization)', async () => {
// The draft table stores JSON, which strips `undefined` keys — the
// comparison must too, or a draft that only differs by an undefined key
// would never dedup.
getFlowByPath.mockResolvedValue({ summary: 'f', value: { modules: [] } })
const key = setDraft('flow', 'u/me/f', {
summary: 'f',
value: { modules: [] },
labels: undefined
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('ignores server-managed metadata fields on the deployed flow payload', async () => {
// The deployed flow carries read-time metadata (workspace_id, edited_by,
// version_id, is_draft, timestamps) that the editor's draft content never
// holds — they must not block the dedup.
getFlowByPath.mockResolvedValue({
workspace_id: 'admins',
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
edited_by: 'admin@windmill.dev',
edited_at: '2026-01-01T00:00:00Z',
archived: false,
schema: {},
extra_perms: {},
version_id: 2,
is_draft: false,
draft_saved_at: '2026-01-01T00:00:01Z'
})
const key = setDraft('flow', 'u/me/f', {
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
archived: false,
schema: {}
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('compares app drafts against the deployed `.value`', async () => {
const appValue = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
getAppByPath.mockResolvedValue({ value: { ...appValue } })
const key = setDraft('app', 'u/me/a', { ...appValue })
await migrateUserDraftsToDb()
expect(getAppByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/a',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads when there is no deployed item (fetch rejects)', async () => {
getScriptByPath.mockRejectedValue(new Error('404'))
const key = setDraft('script', 'u/me/new', { path: 'u/me/new', content: 'x' })
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('skips the deployed fetch for a pathless /add draft and uploads at a minted path', async () => {
// A legacy `/add` autosave has an empty path; there is no deployed item to
// dedup against, so it uploads to a freshly minted `u/{user}/draft_{uuid}`.
setDraft('script', '', { path: '', content: 'x' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(updateDraft.mock.calls[0][0]).toMatchObject({
kind: 'script',
// `mintDraftAddPath` dashes→underscores (path segments are word chars).
path: 'u/me/draft_fixed_uuid'
})
})
it('does not dedup unsupported kinds (e.g. variable) — uploads as before', async () => {
const key = setDraft('variable', 'u/me/v', { value: 'secret' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(getAppByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
})
+79 -17
View File
@@ -6,13 +6,22 @@
* success so it's idempotent without a sentinel; failed entries retry next
* mount. Not workspace-gated: keys embed their own workspace and the token
* covers all of them, so gating would orphan other-workspace entries.
* Deliberately self-contained (no `userDraft.svelte.ts` import) so the
* runtime module stays free of legacy decoders.
*
* Before uploading, each draft is compared against its deployed version
* (script / flow / app); a draft that's deep-equal to what's deployed carries
* no changes, so it's dropped instead of migrated (no error).
*/
import { DraftService } from './gen'
import { AppService, DraftService, FlowService, ScriptService } from './gen'
import type { UserDraftItemKind } from './gen'
import type { App } from './components/apps/types'
import { migrateApp } from './components/apps/migrateApp'
import { sendUserToast } from './toast'
import { draftValuesEqual } from './userDraft.svelte'
import {
openDraftMigrationErrorModal,
reportDraftMigrationError
} from './userDraftMigrationErrors.svelte'
import { getUsernameForNamespace } from './userNamespace'
import { randomUUID } from './utils/uuid'
@@ -118,6 +127,48 @@ function readPayload(key: string): { value: unknown; lastWrittenAt?: number } |
}
}
/**
* Fetch the deployed value for a draft so the migration can drop a draft that
* carries no changes (deep-equal to what's already deployed) instead of
* uploading a no-op that would light up the "unsaved" badge. Returns the
* comparable deployed payload, or `undefined` when there's nothing to compare
* against: an unsupported kind, a pathless (minted `/add`) draft, or a fetch
* miss (404 the path is draft-only, so the draft is genuinely new). `getDraft`
* is forced off so we compare against the deployed baseline, not our own draft.
*/
async function fetchDeployedValue(
workspace: string,
kind: UserDraftItemKind,
path: string
): Promise<unknown | undefined> {
if (!path) return undefined
try {
switch (kind) {
case 'script':
return await ScriptService.getScriptByPath({ workspace, path, getDraft: false })
case 'flow':
return await FlowService.getFlowByPath({ workspace, path, getDraft: false })
case 'app': {
// The app autosave stores the inner `App`, not the `AppWithLastVersion`
// wrapper getAppByPath returns — compare against `.value`. Run
// `migrateApp` so the deployed value matches the editor-migrated draft
// (AppEditor `migrateApp`s `stateApp` on mount); without this an app
// whose deployed row predates those field migrations never dedups.
const app = await AppService.getAppByPath({ workspace, path, getDraft: false })
const value = (app as { value?: App }).value
if (value) migrateApp(value)
return value
}
default:
return undefined
}
} catch {
// No deployed item at this path (or the fetch failed) — nothing to dedup
// against, so the caller proceeds to upload the draft.
return undefined
}
}
function collectKeys(): string[] {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
@@ -177,11 +228,27 @@ export async function migrateUserDraftsToDb(): Promise<void> {
}
if (toMigrate.length === 0) return
// Legacy drafts detected — tell the user the one-off upload is running.
sendUserToast('Migrating local storage drafts ...', 'info')
// Legacy drafts detected — tell the user the one-off upload is running, with
// an escape hatch to the modal where any failures show up as they happen.
sendUserToast('Migrating local storage drafts ...', 'info', [
{ label: 'See more', callback: openDraftMigrationErrorModal }
])
for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) {
try {
// Dedup: if the draft is deep-equal to the deployed version it carries
// no changes — drop it (no error) instead of uploading a no-op draft.
// Fetches against `parsed.path` (the real item path); minted `/add`
// drafts have `parsed.path === ''` and so are never deduped.
const deployed = await fetchDeployedValue(parsed.workspace, parsed.itemKind, parsed.path)
if (deployed !== undefined && draftValuesEqual(value, deployed)) {
try {
localStorage.removeItem(key)
} catch {
// Best-effort; a stale LS entry is harmless — it re-dedups next mount.
}
continue
}
const res = await DraftService.updateDraft({
workspace: parsed.workspace,
kind: parsed.itemKind,
@@ -204,18 +271,13 @@ export async function migrateUserDraftsToDb(): Promise<void> {
// surface it so the user isn't silently stuck, with an escape
// hatch to drop the un-migratable draft.
console.error('UserDraft LS→DB migration: failed for', key, e)
sendUserToast(`Could not migrate draft ${path} in workspace ${parsed.workspace}`, 'error', [
{
label: 'Delete draft',
callback: () => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
}
])
reportDraftMigrationError({
key,
workspace: parsed.workspace,
itemKind: parsed.itemKind,
path,
value
})
}
}
}
+43 -4
View File
@@ -459,17 +459,56 @@ export const UserDraftDbSyncer = {
* save" Monaco may hold unmaterialized text; flush the editor
* (`Editor.flushPendingChanges()`) and await `tick()` first so its
* bind:code reaches our save() before this.
*
* `honorAutosaveToggle` makes the flush respect the "Enable auto-save"
* preference: a toggle-aware autosave (`auto` + `canBeDisabled`) stays
* parked while auto-save is off, so the edit is NOT persisted. The editor's
* unmount uses it (leaving with auto-save off must not silently save
* the UnsavedConfirmationModal warns instead); explicit Ctrl/Cmd+S omits it
* and always saves.
*/
async flush(query: UserDraftLastSyncQuery): Promise<void> {
async flush(
query: UserDraftLastSyncQuery,
opts?: { honorAutosaveToggle?: boolean }
): Promise<void> {
const key = draftKey(query.workspace, query.itemKind, query.path)
try {
const opts = pendingSaveOpts.get(key)
if (!opts) return
await this.save({ ...opts, immediate: true })
const parked = pendingSaveOpts.get(key)
if (!parked) return
if (opts?.honorAutosaveToggle && !autosaveEnabledState && parked.auto && parked.canBeDisabled)
return
await this.save({ ...parked, immediate: true })
} finally {
// Signal the indicator even on the no-op path so Ctrl/Cmd+S
// shows "Saved" even when the autosave already landed.
flushes.set(key, (flushes.get(key) ?? 0) + 1)
}
},
/**
* Whether this draft has content edits parked but unsaved because auto-save
* is off the signal the full-page editors' UnsavedConfirmationModal uses
* to warn before leaving. True only when auto-save is disabled AND a
* toggle-aware (`auto` + `canBeDisabled`) write carrying content is parked;
* a parked delete (`value: null` from deploy / discard / reset) is not
* unsaved content. Read imperatively (e.g. in `beforeNavigate`), not
* reactively.
*/
hasUnsavedDisabledChanges(query: UserDraftLastSyncQuery): boolean {
if (autosaveEnabledState) return false
const parked = pendingSaveOpts.get(draftKey(query.workspace, query.itemKind, query.path))
return !!parked && parked.auto === true && parked.canBeDisabled === true && parked.value != null
},
/**
* Drop a draft's parked-but-unsaved autosave WITHOUT POSTing the user
* chose to discard the auto-save-off edits on leave. Also cancels any
* queued debounce so turning auto-save back on can't resurrect them
* (the `autosaveEnabled` setter re-schedules every parked entry).
*/
dropPending(query: UserDraftLastSyncQuery): void {
const key = draftKey(query.workspace, query.itemKind, query.path)
pendingSaveOpts.delete(key)
debouncer.cancel(key)
}
}
@@ -0,0 +1,75 @@
/**
* Reactive registry of drafts that `migrateUserDraftsToDb` could not push to
* the server. The migration runs on every layout mount, so a persistently
* un-migratable draft would re-fail (and re-report) each time keying by the
* LS key dedupes those repeats. A SINGLE toast fires on the emptynon-empty
* transition (never per-failure, never when there's nothing wrong); its action
* opens `DraftMigrationErrorModal`, which reads `list` live so failures that
* surface while the modal is already open just appear in place.
*/
import { SvelteMap } from 'svelte/reactivity'
import type { UserDraftItemKind } from '$lib/gen'
import { sendUserToast } from './toast'
export type DraftMigrationError = {
/** The source `userdraft/...` localStorage key — identity and delete target. */
key: string
workspace: string
itemKind: UserDraftItemKind
path: string
/** The draft payload, surfaced verbatim by the modal's "View JSON". */
value: unknown
}
const errors = new SvelteMap<string, DraftMigrationError>()
let modalOpen = $state(false)
export const draftMigrationErrors = {
get list(): DraftMigrationError[] {
return [...errors.values()]
},
get modalOpen(): boolean {
return modalOpen
},
set modalOpen(open: boolean) {
modalOpen = open
}
}
/** Open the modal listing the failed migrations. */
export function openDraftMigrationErrorModal(): void {
modalOpen = true
}
/**
* Record a failed draft migration. Idempotent per `key`; the toast only fires
* on the first failure of a batch (emptynon-empty) and is suppressed when the
* modal is already open, since the user is already resolving issues there.
*/
export function reportDraftMigrationError(error: DraftMigrationError): void {
if (errors.has(error.key)) return
const wasEmpty = errors.size === 0
errors.set(error.key, error)
if (wasEmpty && !modalOpen) {
sendUserToast('Some local storage drafts could not be migrated', 'error', [
{ label: 'Resolve issues', callback: openDraftMigrationErrorModal }
])
}
}
/** Drop the un-migratable draft from localStorage and clear its error entry. */
export function deleteDraftMigrationError(key: string): void {
try {
localStorage.removeItem(key)
} catch {
// Best-effort; the entry leaves the list regardless.
}
errors.delete(key)
}
/** Drop every un-migratable draft at once. */
export function deleteAllDraftMigrationErrors(): void {
for (const key of [...errors.keys()]) {
deleteDraftMigrationError(key)
}
}
+1 -1
View File
@@ -337,7 +337,7 @@ export async function getTriggersDeployData(
* `TRIGGER_COMPARE_IGNORE` and `stripTriggerOrScheduleRuntimeFields` in the
* shared deploy module.
*/
const TRIGGER_RUNTIME_IGNORE = new Set([
export const TRIGGER_RUNTIME_IGNORE = new Set([
'workspace_id',
'edited_by',
'edited_at',
+57 -4
View File
@@ -35,6 +35,7 @@ import {
} from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import type { DeployResult } from '$lib/utils_workspace_deploy'
import { TRIGGER_RUNTIME_IGNORE } from '$lib/utils_deployable'
import { deployRawAppDraft } from '$lib/rawAppDeploy'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
@@ -114,6 +115,53 @@ const EMPTY_DEPLOYED: Partial<Record<DraftKind, (draft: any) => unknown>> = {
app: () => ({ summary: '', value: {}, policy: {} })
}
// Schedule & trigger rows drop the same runtime/server-managed fields as the
// fork/compare path so the diff shows only config changes — reuse that set
// (the authoritative mirror of the backend `TRIGGER_COMPARE_IGNORE`).
function stripScheduleTriggerRuntime(row: any): Record<string, unknown> {
if (!row || typeof row !== 'object') return {}
return Object.fromEntries(Object.entries(row).filter(([k]) => !TRIGGER_RUNTIME_IGNORE.has(k)))
}
/**
* Project a variable/resource/schedule/trigger value given in either its
* deployed backend-row shape or its draft editor-state shape onto one
* canonical field set, so the deployed and draft sides of a diff are comparable
* and read as labeled rows instead of structural noise (variable `variable.value`
* vs `value`, resource `args` vs `value`, schedule/trigger runtime fields). This
* matches the shaping the compare page applies via `getItemValue`. `isDraft`
* selects the editor-state field names; secret variable values are masked.
*/
function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean): unknown {
if (!raw || typeof raw !== 'object') return raw ?? {}
if (kind === 'variable') {
// draft: { variable: { value, is_secret, description }, labels, wsSpecific }
// deployed row: { value, is_secret, description, labels, ws_specific }
const v = isDraft ? (raw.variable ?? {}) : raw
const is_secret = !!v.is_secret
return {
value: is_secret ? '<secret>' : (v.value ?? ''),
is_secret,
description: v.description ?? '',
labels: raw.labels ?? undefined,
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
}
}
if (kind === 'resource') {
// draft: { args, description, resource_type, labels, wsSpecific }
// deployed row: { value, description, resource_type, labels, ws_specific }
return {
value: (isDraft ? raw.args : raw.value) ?? {},
description: raw.description ?? '',
resource_type: raw.resource_type ?? undefined,
labels: raw.labels ?? undefined,
ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined
}
}
// schedule + triggers: same field names on both sides — drop runtime noise.
return stripScheduleTriggerRuntime(raw)
}
/**
* Fetch the deployed value and the draft value for an item, for the DiffDrawer
* (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only`
@@ -181,15 +229,20 @@ export async function getDraftDiffValues(
const draftValue = r.draft ?? deployed
return { deployed: draftOnly ? EMPTY_DEPLOYED.app!(draftValue) : deployed, draft: draftValue }
} else {
// Variables / resources / schedules / triggers: the draft is the
// editor's flat config object and the deployed shape diffs cleanly
// as a plain object — one overlay GET covers both sides.
// Variables / resources / schedules / triggers: one overlay GET yields
// both sides, but the draft side is the editor's state shape while the
// deployed side is the backend row — they diverge enough to make a raw
// diff pure noise. Canonicalize both onto a shared field set (same shaping
// the compare page's `getItemValue` applies) so only real changes show.
const getter = OVERLAY_GETTERS[kind]
if (!getter) {
throw new Error(`Draft diff not supported for kind ${kind}`)
}
const { deployed, draft } = splitOverlay(await getter(workspace, path))
return { deployed: draftOnly ? {} : deployed, draft }
return {
deployed: draftOnly ? {} : canonicalizeDraftDiffValue(kind, deployed, false),
draft: canonicalizeDraftDiffValue(kind, draft, true)
}
}
}
@@ -60,6 +60,7 @@
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration'
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
import { setContext, untrack } from 'svelte'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
@@ -441,7 +442,7 @@
// on success. The order matters — the second step only sees what
// the first one normalized.
$effect(() => {
if ($workspaceStore) {
if ($workspaceStore && $userStore) {
untrack(() => {
migrateLegacyUserDrafts($workspaceStore!)
void migrateUserDraftsToDb()
@@ -501,6 +502,7 @@
<svelte:window bind:innerWidth />
<UserSettings bind:this={userSettings} showMcpMode={true} />
<DraftMigrationErrorModal />
{#if page.status == 404}
<CenteredModal title="Page not found, redirecting you to login" loading={true}></CenteredModal>
{:else if $userStore}
@@ -8,6 +8,7 @@
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { App } from '$lib/components/apps/types'
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { type OtherDraftUser } from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
@@ -36,6 +37,10 @@
/** No deployed app at the URL path. Drives the editor's deploy:
* `createApp` vs `updateApp`. Flips false once a deploy lands here. */
let isNewApp = $state(false)
/** Deployed app value this load, the baseline AppEditor's autosave
* `discardIf` compares against. `undefined` for draft-only paths so they
* never self-destruct by matching a non-existent baseline. */
let deployedBaseline = $state<App | undefined>(undefined)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
let loadedFromDraft = $state(false)
let othersModalOpen = $state(false)
@@ -65,6 +70,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Capture every seeding param BEFORE stripping the URL flag.
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
@@ -185,6 +192,12 @@
getDraft
})
if (tok !== loadAppToken) return
// Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE
// the draft swap below replaces `backendApp.value`. `undefined` when
// there's no deployed row (draft-only path).
deployedBaseline = backendApp.no_deployed
? undefined
: (structuredClone(stateSnapshot(backendApp.value)) as App)
// `other_drafts_users` only computed when `getDraft`; don't clobber the
// known list on a `getDraft:false` reload. See /scripts/edit's loader.
if (getDraft) {
@@ -302,6 +315,23 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<!-- Auto-save off: edits aren't persisted on leave, so warn before navigating
away (and on tab close). Inert while auto-save is on. -->
<UnsavedConfirmationModal
showAutosaveTips
hasUnsavedChanges={() =>
UserDraftDbSyncer.hasUnsavedDisabledChanges({
workspace: $workspaceStore ?? '',
itemKind: 'app',
path
})}
onDiscardChanges={() =>
UserDraftDbSyncer.dropPending({
workspace: $workspaceStore ?? '',
itemKind: 'app',
path
})}
/>
<DraftEditorModals
workspace={$workspaceStore ?? ''}
itemKind="app"
@@ -343,6 +373,7 @@
on:restore={onRestore}
summary={app.summary}
app={app.value}
{deployedBaseline}
newPath={app.value?.draft_path ?? app.path}
path={page.params.path ?? ''}
policy={app.policy}
@@ -13,10 +13,12 @@
import { page } from '$app/state'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { importStore } from '$lib/components/apps/store'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
import { armRestartOnFirstInteraction, runResetToDeployed } from '$lib/userDraftToast'
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { type OtherDraftUser } from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
import RawAppTemplatePicker, {
type RawAppTemplatePickerResult
@@ -63,6 +65,7 @@
summary: string
policy: any
custom_path?: string
no_deployed?: boolean
}
| undefined = $state(undefined)
let redraw = $state(0)
@@ -71,13 +74,21 @@
* React/Svelte + data config + optional AI prompt before the editor goes live. */
let templatePicker = $state(false)
/** Deployed raw-app bundle this load, the baseline the autosave `discardIf`
* compares against. `undefined` for draft-only paths so they never
* self-destruct by matching a non-existent baseline. */
let deployedBaseline = $state<RawAppDraft | undefined>(undefined)
// Page-level draft orchestration. `path` is a mount-scoped plain `let` (the
// editor remounts per path), so this re-keys only on workspace change.
// effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor.
const draftSync = usePageDraftSync<RawAppDraft>({
itemKind: 'raw_app',
path: () => path,
workspace: () => $workspaceStore
workspace: () => $workspaceStore,
// Autosaves landing back on the deployed raw app become deletes, so
// reverting edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
// Persist the bundle whenever any of the four pieces of state changes.
@@ -97,30 +108,42 @@
summary,
policy,
custom_path: savedApp?.custom_path,
// Only persist when set, so the field disappears from the saved JSON
// once the typed path matches the baseline again (or on deploy).
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
// Persist the typed path as `draft_path` only when it actually differs
// from the current path — a `draft_path` equal to the baseline is a
// no-op that would block the draft from deduping against the deployed
// app (which carries none). Drops back out on a revert or deploy.
...(pendingDraftPath && pendingDraftPath !== (savedApp?.path ?? '')
? { draft_path: pendingDraftPath }
: {})
} as RawAppDraft
})
function extractRawApp(app: any) {
runnables = app.value.runnables
// Support old formats and new format
if (app.value.data) {
const d = app.value.data
/** Normalize a raw-app `value` into the editor's `data` config, supporting
* the old nested `creation` / `datatables` shapes. `undefined` when the
* value carries no data config (caller keeps the current/default `data`). */
function extractDataConfig(value: any): RawAppData | undefined {
if (value?.data) {
const d = value.data
// Handle old nested creation format
if (d.creation) {
data = {
return {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (app.value.datatables) {
data = { ...DEFAULT_DATA, tables: app.value.datatables }
return d
} else if (value?.datatables) {
return { ...DEFAULT_DATA, tables: value.datatables }
}
return undefined
}
function extractRawApp(app: any) {
runnables = app.value.runnables
// Support old formats and new format
const extractedData = extractDataConfig(app.value)
if (extractedData) data = extractedData
files = app.value.files
summary = app.summary
// lastVersion = app.version
@@ -157,6 +180,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new raw app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Suspend autosave across the bootstrap: the seed template and the
// picker's `onStart` are programmatic writes that must not POST as the
// first edit. Resume on first interaction (the template-card click or
@@ -258,6 +283,22 @@
// See /apps/edit's loader.
draftSavedAt = backendApp.draft_saved_at as string | undefined
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// Deployed baseline for the autosave `discardIf`, captured BEFORE the swap
// below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus
// the edit-only `draft_path`) so an unedited draft compares equal.
// `undefined` when there's no deployed row.
deployedBaseline = backendApp.no_deployed
? undefined
: (structuredClone(
stateSnapshot({
files: backendApp.value?.files ?? {},
runnables: backendApp.value?.runnables ?? {},
data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA },
summary: backendApp.summary ?? '',
policy: backendApp.policy,
custom_path: backendApp.custom_path
})
) as RawAppDraft)
// The raw-app autosave stores a flat `RawAppDraft`, but this loader (and
// `extractRawApp`) needs the deployed shape with `files`/`runnables`/`data`
// under `.value` and the rest top-level. Re-wrap the saved draft (`.draft`):
@@ -308,7 +349,8 @@
value: backendApp_.value as any,
path: backendApp_.path,
policy: backendApp_.policy,
custom_path: backendApp_.custom_path
custom_path: backendApp_.custom_path,
no_deployed: backendApp_.no_deployed
}
// Extract the effective raw app into the editor's local pieces. The bundle
// $effect re-mirrors them into `draftSync.draft`; the first write is
@@ -401,6 +443,23 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<!-- Auto-save off: edits aren't persisted on leave, so warn before navigating
away (and on tab close). Inert while auto-save is on. -->
<UnsavedConfirmationModal
showAutosaveTips
hasUnsavedChanges={() =>
UserDraftDbSyncer.hasUnsavedDisabledChanges({
workspace: $workspaceStore ?? '',
itemKind: 'raw_app',
path
})}
onDiscardChanges={() =>
UserDraftDbSyncer.dropPending({
workspace: $workspaceStore ?? '',
itemKind: 'raw_app',
path
})}
/>
<DraftEditorModals
workspace={$workspaceStore ?? ''}
itemKind="raw_app"
@@ -19,7 +19,9 @@
import { tick, untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import {
armRestartOnFirstInteraction,
discardDraftAfterDeploy,
@@ -43,6 +45,10 @@
}
let savedFlow: Flow | undefined = $state(undefined)
/** Deployed flow this load, the baseline the autosave `discardIf` compares
* against. `undefined` for draft-only paths (no deployed) so a draft-only
* item never self-destructs by "matching" a non-existent baseline. */
let deployedBaseline = $state<Flow | undefined>(undefined)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
let loadedFromDraft = $state(false)
let othersModalOpen = $state(false)
@@ -65,7 +71,10 @@
const draftSync = usePageDraftSync<Flow>({
itemKind: 'flow',
path: () => flowDraftPath,
workspace: () => $workspaceStore
workspace: () => $workspaceStore,
// Autosaves landing back on the deployed flow become deletes, so reverting
// edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
function emptyFlow(): Flow {
@@ -136,6 +145,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new flow: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Suspend autosave around the bootstrap cascade: the Path widget's
// `initPath → reset → bind:path` chain seeds a friendly auto-name that
// FlowBuilder mirrors into `flow.draft_path` — a programmatic write that
@@ -336,6 +347,11 @@
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
: (deployedFlow as Flow)
savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow
// Baseline for the autosave `discardIf`: the deployed flow WITHOUT the
// draft overlay (matches the unedited seed when no draft exists).
deployedBaseline = backendFlow.no_deployed
? undefined
: (structuredClone($state.snapshot(deployedFlow)) as Flow)
// Surface the saved `draft_path` to the Path widget so the topbar shows the
// pending name, not the `draft_{uuid}` URL. Else the widget seeds from the
// URL, the first edit clobbers `draft_path`, and the friendly name is lost.
@@ -409,6 +425,23 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} isFlow />
<!-- Auto-save off: edits aren't persisted on leave, so warn before navigating
away (and on tab close). Inert while auto-save is on. -->
<UnsavedConfirmationModal
showAutosaveTips
hasUnsavedChanges={() =>
UserDraftDbSyncer.hasUnsavedDisabledChanges({
workspace: $workspaceStore ?? '',
itemKind: 'flow',
path: flowDraftPath
})}
onDiscardChanges={() =>
UserDraftDbSyncer.dropPending({
workspace: $workspaceStore ?? '',
itemKind: 'flow',
path: flowDraftPath
})}
/>
<DraftEditorModals
workspace={$workspaceStore ?? ''}
itemKind="flow"
@@ -17,6 +17,7 @@
import { Pen, Trash, Plus } from 'lucide-svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { untrack } from 'svelte'
type FolderW = Folder & { canWrite: boolean }
@@ -135,6 +136,7 @@
<Head>
<tr>
<Cell head first>Name</Cell>
<Cell head>Labels</Cell>
<Cell head class="w-20">Scripts</Cell>
<Cell head class="w-20">Flows</Cell>
<Cell head class="w-20">Apps</Cell>
@@ -149,7 +151,7 @@
{#if folders === undefined}
{#each new Array(4) as _}
<tr>
<td colspan="9">
<td colspan="10">
<Skeleton layout={[[2]]} />
</td>
</tr>
@@ -157,7 +159,7 @@
{:else}
{#if folders.length === 0}
<tr>
<Cell colspan="9">
<Cell colspan="10">
<div class="text-xs text-primary py-2 text-center">
No folders yet, create one
</div>
@@ -165,7 +167,7 @@
</tr>
{/if}
{#each folders as { name, extra_perms, owners, canWrite, summary } (name)}
{#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)}
<Row
hoverable
on:click={() => {
@@ -180,6 +182,27 @@
<span class="text-2xs font-normal text-secondary">{summary}</span>
{/if}
</Cell>
<Cell>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge
>
{/each}
{#if labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{labels.length - 3}</Badge
>
{/if}
</div>
{/if}
</Cell>
<FolderUsageInfo {name} tabular />
<Cell><FolderInfo members={computeMembers(owners, extra_perms)} /></Cell>
@@ -11,6 +11,7 @@
import { workspaceStore } from '$lib/stores'
import { Trash2 } from 'lucide-svelte'
import { onMount } from 'svelte'
import { resource } from 'runed'
let enabled = $state(false)
let refreshToken = $state(0)
@@ -39,23 +40,34 @@
}
})
let drafts = $derived.by(() => {
refreshToken
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
})
const draftsResource = resource(
() => ({ ws: $workspaceStore, token: refreshToken }),
async ({ ws }) => (ws ? await listGlobalDrafts(ws) : [])
)
let drafts = $derived(draftsResource.current ?? [])
function draftKey(item: WorkspaceItem): string {
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
}
function deleteDraft(item: WorkspaceItem) {
async function deleteDraft(item: WorkspaceItem) {
if (!$workspaceStore) return
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
await deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
refreshDrafts()
}
function clearAll() {
async function clearAll() {
if (!$workspaceStore) return
// Delete each listed draft from the backend (the source of truth) — the
// local clearGlobalDrafts only clears in-tab cells, leaving persisted rows.
// Continue past a per-row failure so one bad delete doesn't strand the rest.
for (const item of [...drafts]) {
try {
await deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
} catch (e) {
console.error('Failed to clear draft', item.path, e)
}
}
clearGlobalDrafts($workspaceStore)
refreshDrafts()
}
@@ -66,9 +78,7 @@
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Global local drafts</h1>
<p class="text-sm text-tertiary">
Dev-only inspector for global local drafts.
</p>
<p class="text-sm text-tertiary"> Dev-only inspector for global local drafts. </p>
</div>
<Button
variant="default"
@@ -17,9 +17,11 @@
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast'
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -65,11 +67,19 @@
// Page-level draft orchestration: autosave handle (re-keyed on nav via
// `draftPath`), live-editor-draft registry, `recordRemoteSync`, removal.
// `draftSync.draft` stays a stable lvalue for `bind:script`.
/** Deployed script this load (with `parent_hash` grafted to match the
* unedited draft seed), the baseline the autosave `discardIf` compares
* against. `undefined` for draft-only paths so they never self-destruct. */
let deployedBaseline = $state<EditableScript | undefined>(undefined)
const draftSync = usePageDraftSync<EditableScript>({
itemKind: 'script',
path: () => draftPath,
workspace: () => $workspaceStore,
effectivePath: () => draftSync.draft?.path ?? draftPath
effectivePath: () => draftSync.draft?.path ?? draftPath,
// Autosaves landing back on the deployed script become deletes, so
// reverting edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
@@ -129,6 +139,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new script: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Capture every seeding param BEFORE stripping the URL flag.
const templatePath = page.url.searchParams.get('template')
const hubPath = page.url.searchParams.get('hub')
@@ -259,6 +271,9 @@
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash))
// Historical-hash view is read-only relative to drafts (`draftPath` is
// '' → detached handle), so no baseline is needed.
deployedBaseline = undefined
draftSync.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const backendScript = await ScriptService.getScriptByPath({
@@ -292,6 +307,17 @@
? { ...deployedScript, ...draftFromBackend }
: (deployedScript as EditableScript)
savedScript = structuredClone($state.snapshot(effectiveScript))
// Baseline for the autosave `discardIf`: the deployed script with the
// same `parent_hash` graft the seed below applies, so the unedited
// draft compares equal. `undefined` when there's no deployed row.
deployedBaseline = backendScript.no_deployed
? undefined
: structuredClone(
$state.snapshot({
...deployedScript,
parent_hash: topHash ?? backendScript.hash
})
)
// `parent_hash` is grafted on so the editor's compile reuses the
// deployed lock. The first cell write after `acquireEntry` is swallowed
// by the syncer's seed guard, so this load doesn't POST.
@@ -355,6 +381,24 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
<!-- Auto-save off: edits aren't persisted on leave, so warn before navigating
away (and on tab close). When auto-save is on the predicate returns false and
this modal stays inert — the draft is saved automatically. -->
<UnsavedConfirmationModal
showAutosaveTips
hasUnsavedChanges={() =>
UserDraftDbSyncer.hasUnsavedDisabledChanges({
workspace: $workspaceStore ?? '',
itemKind: 'script',
path: draftPath
})}
onDiscardChanges={() =>
UserDraftDbSyncer.dropPending({
workspace: $workspaceStore ?? '',
itemKind: 'script',
path: draftPath
})}
/>
<DraftEditorModals
enabled={!hash}
workspace={$workspaceStore ?? ''}
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.726.1"
wmill = ">=1.728.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.726.1
version: 1.728.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.726.1'
ModuleVersion = '1.728.1'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.726.1"
version = "1.728.1"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.726.1",
"version": "1.728.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.726.1",
"version": "1.728.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.726.1
1.728.1