mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
Merge branch 'main' into feat/asset-graph-view
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
+14
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
Generated
+184
-183
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -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,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"
|
||||
|
||||
@@ -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 localStorage→DB 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?:
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-6
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 empty→non-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 (empty→non-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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.726.1"
|
||||
wmill = ">=1.728.1"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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,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,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
@@ -1 +1 @@
|
||||
1.726.1
|
||||
1.728.1
|
||||
|
||||
Reference in New Issue
Block a user