From bb78b1c06de5b73b951691460f81a3a2ec6e7f80 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 20 May 2026 13:59:39 +0000 Subject: [PATCH 1/7] fix(s3): sandbox stored XSS via download response headers (#9263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] fix(s3): sandbox stored XSS via download response headers Reported chain: a workspace user uploads xss.html via apps_u/upload_s3_file with content_type=text/html&content_disposition=inline; when an admin clicks the resulting download URL the browser renders the attacker page in Windmill's origin and can escalate via the SameSite=Lax session cookie. Fix on the download side only — leaves upload semantics unchanged so existing integrations are not affected: - download_s3_file_internal (used by apps_u/download_s3_file and job_helpers/download_s3_file) emits X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox on every response (EE). - The HTTP static-asset trigger emits the same headers on single-file responses. Static-website responses keep their existing semantics (CSP sandbox would break a legitimate static site); restricting write access to those buckets remains the documented mitigation. Sandbox loads any HTML/SVG into an opaque origin so the page cannot reach the viewer's cookie or /api/*. Images, PDFs, and fetch-driven previews are unaffected (browsers ignore CSP for / and for fetch responses). Companion: windmill-ee-private fix/s3-content-type-xss. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to daffe7bb81cfcaca666c61de1ee838a44d60ebc2 This commit updates the EE repository reference after PR #585 was merged in windmill-ee-private. Previous ee-repo-ref: e889b86ee1c68c2f7cf9b07ec4b8ba6e6b66a169 New ee-repo-ref: daffe7bb81cfcaca666c61de1ee838a44d60ebc2 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/src/triggers/http/handler.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index cff094ca35..142ca727a7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3489c243b0e5a8eb0dbc86e90917fbe72843573b +daffe7bb81cfcaca666c61de1ee838a44d60ebc2 diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index ccde55bab1..ffb61e31fa 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -424,6 +424,7 @@ async fn route_job( .flatten() .unwrap_or("application/octet-stream".parse().unwrap()), ); + response_headers.insert("x-content-type-options", "nosniff".parse().unwrap()); if !trigger.is_static_website { response_headers.insert( "content-disposition", @@ -443,6 +444,19 @@ async fn route_job( }, ), ); + // For single-file triggers, sandbox any HTML/SVG so it can't + // reach the viewer's session cookie. Allow-scripts/forms/etc. + // keep the opaque origin (cookies still blocked) while + // preserving JS for legitimate HTML payloads. Static-website + // triggers intentionally serve a live web app and cannot be + // sandboxed; restrict write access to those buckets at the + // workspace level. + response_headers.insert( + "content-security-policy", + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + .parse() + .unwrap(), + ); } let body_stream = axum::body::Body::from_stream(s3_object.into_stream()); From 22ec4da5f03b136aa84f7c371900898ef0cc5f57 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 20 May 2026 14:02:50 +0000 Subject: [PATCH 2/7] tighten security from vuln report (#9264) * fix: harden app preview S3, WM_ env reservation, set_progress scoping * fixup: minimize #1 fix to single SQL-level filter * fixup: apply WM_* filter to HTTP agent-worker branch + normalize app S3 scope path --- ...5f584c8b114ef24fdb67a5eefb40ce17acdef.json | 16 ++++++++++ backend/windmill-api-jobs/src/job_metrics.rs | 17 ++++++++--- backend/windmill-api/src/apps.rs | 29 +++++++++++++++++++ backend/windmill-common/src/variables.rs | 10 ++++++- 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 backend/.sqlx/query-d4af472614e1b6af8defa40a79d5f584c8b114ef24fdb67a5eefb40ce17acdef.json diff --git a/backend/.sqlx/query-d4af472614e1b6af8defa40a79d5f584c8b114ef24fdb67a5eefb40ce17acdef.json b/backend/.sqlx/query-d4af472614e1b6af8defa40a79d5f584c8b114ef24fdb67a5eefb40ce17acdef.json new file mode 100644 index 0000000000..23087064d3 --- /dev/null +++ b/backend/.sqlx/query-d4af472614e1b6af8defa40a79d5f584c8b114ef24fdb67a5eefb40ce17acdef.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status s\n SET flow_status = JSONB_SET(s.flow_status, ARRAY['modules', s.flow_status->>'step', 'progress'], $1)\n FROM v2_job j\n WHERE s.id = $2 AND j.id = s.id AND j.workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d4af472614e1b6af8defa40a79d5f584c8b114ef24fdb67a5eefb40ce17acdef" +} diff --git a/backend/windmill-api-jobs/src/job_metrics.rs b/backend/windmill-api-jobs/src/job_metrics.rs index 59cb23deff..abb95693f5 100644 --- a/backend/windmill-api-jobs/src/job_metrics.rs +++ b/backend/windmill-api-jobs/src/job_metrics.rs @@ -180,13 +180,22 @@ async fn set_job_progress( // If flow_job_id exists, than we should modify flow_status of corresponding module // Individual jobs and flows are handled differently if let Some(flow_job_id) = flow_job_id { + // `v2_job_status` has no workspace_id column and the root db handle + // bypasses RLS (the per-row policy on the table is also inert today — + // `ENABLE ROW LEVEL SECURITY` was never set). Scope the update by + // joining `v2_job` so the URL's workspace_id confines tampering to the + // caller's workspace; without this, an authed member of any workspace + // could overwrite the flow `progress` UI field of a flow in another + // workspace given just the flow UUID. // TODO: Return error if trying to set completed job? sqlx::query!( - "UPDATE v2_job_status - SET flow_status = JSONB_SET(flow_status, ARRAY['modules', flow_status->>'step', 'progress'], $1) - WHERE id = $2", + "UPDATE v2_job_status s + SET flow_status = JSONB_SET(s.flow_status, ARRAY['modules', s.flow_status->>'step', 'progress'], $1) + FROM v2_job j + WHERE s.id = $2 AND j.id = s.id AND j.workspace_id = $3", serde_json::json!(percent.clamp(0, 99)), - flow_job_id + flow_job_id, + w_id, ) .execute(&db) .await?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ab36b8cb89..272c23353a 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2589,6 +2589,21 @@ async fn upload_s3_file_from_app( request: axum::extract::Request, ) -> JsonResult { let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex { + // `force_viewer_*` lets the caller supply a synthetic upload policy that + // bypasses the deployed app's file_key_regex / resource restrictions. + // It is intended for the app editor's preview path, so it must enforce + // the same guards as `execute_component`'s preview mode (PR #9235): + // authed caller, not an operator, and `apps:write` scope to make sure + // an `apps:run`-scoped token cannot pick its own policy. + let authed = opt_authed.as_ref().ok_or_else(|| { + Error::NotAuthorized("App S3 preview upload requires authentication".to_string()) + })?; + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot run app S3 previews for security reasons".to_string(), + )); + } + check_scopes(authed, || format!("apps:write:{}", path.to_path()))?; Some(Policy { execution_mode: ExecutionMode::Viewer, triggerables: None, @@ -3100,6 +3115,20 @@ async fn download_s3_file_from_app( let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) = query.force_viewer_allowed_s3_keys.clone() { + // `force_viewer_allowed_s3_keys` lets the caller supply a synthetic + // allowlist that bypasses the deployed app policy. Apply the same + // preview-mode guard as `execute_component` (PR #9235): authed, not an + // operator, `apps:write` scope so an `apps:run`-scoped token cannot + // pick its own allowlist. + let authed = opt_authed.as_ref().ok_or_else(|| { + Error::NotAuthorized("App S3 preview download requires authentication".to_string()) + })?; + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot run app S3 previews for security reasons".to_string(), + )); + } + check_scopes(authed, || format!("apps:write:{}", path))?; Some(serde_json::from_str::>(&force_viewer_allowed_s3_keys).unwrap_or_default()) } else { None diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 57e41bb487..bbde2e9152 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize}; lazy_static::lazy_static! { pub static ref SECRET_SALT: Option = std::env::var("SECRET_SALT").ok(); + static ref RESERVED_WM_VAR_NAME: regex::Regex = regex::Regex::new(r"^WM_[A-Z_]+$").unwrap(); } #[derive(Serialize, Clone)] @@ -452,7 +453,7 @@ async fn get_cached_workspace_envs(conn: &Connection, w_id: &str) -> Vec<(String let custom_envs = if let Some(cached_envs) = cached_envs_o { cached_envs } else { - let custom_envs = match conn { + let raw_envs = match conn { Connection::Sql(db) => sqlx::query_as::<_, (String, String)>( "SELECT name, value FROM workspace_env WHERE workspace_id = $1", ) @@ -465,6 +466,13 @@ async fn get_cached_workspace_envs(conn: &Connection, w_id: &str) -> Vec<(String .await .unwrap_or_default(), }; + // Applied here (not in the SQL branch alone) so agent workers going + // through `Connection::Http` are covered too — drop any name that + // would shadow a built-in `%%WM_*%%` contextual var. + let custom_envs: Vec<(String, String)> = raw_envs + .into_iter() + .filter(|(name, _)| !RESERVED_WM_VAR_NAME.is_match(name)) + .collect(); CUSTOM_ENVS_CACHE.insert( w_id.to_string(), (chrono::Utc::now().timestamp(), custom_envs.clone()), From c4a86838fb474c0388435ebf3c7feb356fad56fb Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 20 May 2026 19:34:10 +0530 Subject: [PATCH 3/7] set explicit cursor color in light editor theme (#9134) The light Monaco theme ('myTheme') did not define editorCursor.foreground, causing the cursor to be invisible on white backgrounds. The dark theme ('nord') already sets this explicitly. Fixes #8876 --- frontend/src/lib/components/vscode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/vscode.ts b/frontend/src/lib/components/vscode.ts index d01afd4861..1277ac1b4a 100644 --- a/frontend/src/lib/components/vscode.ts +++ b/frontend/src/lib/components/vscode.ts @@ -255,6 +255,7 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem colors: { 'editor.background': '#FFFFFF', 'editor.foreground': '#2d3748', + 'editorCursor.foreground': '#2d3748', 'editorLineNumber.foreground': '#C2C9D1', 'editorLineNumber.activeForeground': '#989DA5', 'editorGutter.background': '#FFFFFF00' From 00221128cbf0801a45bad40246e50beceaba0a7e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 20 May 2026 14:05:16 +0000 Subject: [PATCH 4/7] fix: cgroup-aware DuckDB memory_limit + allocator memory release (#9245) --- .../windmill-duckdb-ffi-internal/build_dev.sh | 2 +- .../windmill-duckdb-ffi-internal/src/lib.rs | 95 +++++++++++++++++-- .../windmill-worker/src/duckdb_executor.rs | 91 +++++++++++++++++- backend/windmill-worker/src/worker.rs | 1 + 4 files changed, 179 insertions(+), 10 deletions(-) diff --git a/backend/windmill-duckdb-ffi-internal/build_dev.sh b/backend/windmill-duckdb-ffi-internal/build_dev.sh index 1fb13057a2..d4ae2b7583 100755 --- a/backend/windmill-duckdb-ffi-internal/build_dev.sh +++ b/backend/windmill-duckdb-ffi-internal/build_dev.sh @@ -1,3 +1,3 @@ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p windmill_duckdb_ffi_internal mkdir -p ../target/debug/ -cp target/release/libwindmill_duckdb_ffi_internal.* ../target/debug/ \ No newline at end of file +cp target/release/libwindmill_duckdb_ffi_internal.* ../target/debug/ diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index 2701319d6e..594b0f6df4 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -11,6 +11,22 @@ use rust_decimal::{prelude::FromPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; +// Worker passes "" for "no override" — saves an extra C string nullability dance. +// Returns an owned String so the value outlives the raw pointer's lifetime. +fn ptr_to_opt_str(ptr: *const c_char) -> Result, String> { + if ptr.is_null() { + return Ok(None); + } + let s = unsafe { CStr::from_ptr(ptr) } + .to_str() + .map_err(|e| format!("Invalid string in duckdb ffi: {}", e))?; + Ok(if s.is_empty() { + None + } else { + Some(s.to_owned()) + }) +} + #[derive(Deserialize, Clone, Debug, PartialEq, Default)] pub struct Arg { pub name: String, @@ -34,7 +50,7 @@ pub extern "C" fn get_version() -> c_uint { // Increment when making breaking changes to the FFI interface. // The windmill worker will check that the version matches or else refuse to call // the FFI functions to avoid undefined behavior. - return 1; + return 2; } #[unsafe(no_mangle)] @@ -45,10 +61,16 @@ pub extern "C" fn run_duckdb_ffi( token: *const c_char, base_internal_url: *const c_char, w_id: *const c_char, + memory_limit: *const c_char, + temp_directory: *const c_char, column_order_ptr: *mut *mut c_char, collect_last_only: bool, collect_first_row_only: bool, ) -> *mut c_char { + let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { + (Ok(m), Ok(t)) => Ok(ResourceLimits { memory_limit: m, temp_directory: t }), + (Err(e), _) | (_, Err(e)) => Err(e), + }; let (r, column_order) = match convert_args( query_block_list, query_block_list_count, @@ -57,8 +79,9 @@ pub extern "C" fn run_duckdb_ffi( base_internal_url, w_id, ) + .and_then(|args| resource_limits.map(|r| (args, r))) .and_then( - |(query_block_list, job_args, token, base_internal_url, w_id)| { + |((query_block_list, job_args, token, base_internal_url, w_id), limits)| { run_duckdb_internal( query_block_list, query_block_list_count, @@ -66,6 +89,7 @@ pub extern "C" fn run_duckdb_ffi( token, base_internal_url, w_id, + limits, collect_last_only, collect_first_row_only, ) @@ -150,7 +174,13 @@ pub extern "C" fn prepare_duckdb_ffi( token: *const c_char, base_internal_url: *const c_char, w_id: *const c_char, + memory_limit: *const c_char, + temp_directory: *const c_char, ) -> *mut c_char { + let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { + (Ok(m), Ok(t)) => Ok(ResourceLimits { memory_limit: m, temp_directory: t }), + (Err(e), _) | (_, Err(e)) => Err(e), + }; let r = match convert_prepare_args( query_block_list, query_block_list_count, @@ -158,9 +188,12 @@ pub extern "C" fn prepare_duckdb_ffi( base_internal_url, w_id, ) - .and_then(|(query_block_list, token, base_internal_url, w_id)| { - prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id) - }) { + .and_then(|args| resource_limits.map(|r| (args, r))) + .and_then( + |((query_block_list, token, base_internal_url, w_id), limits)| { + prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id, limits) + }, + ) { Ok(result) => result, Err(err) => { let err = serde_json::to_string(&err) @@ -175,12 +208,58 @@ pub extern "C" fn prepare_duckdb_ffi( }) } +#[derive(Clone, Default)] +struct ResourceLimits { + memory_limit: Option, + temp_directory: Option, +} + +fn sql_single_quote(s: &str) -> String { + s.replace('\'', "''") +} + +// Bounds memory so DuckDB spills to disk before blowing the cgroup cap and +// getting the worker SIGKILLed. Spill goes to the job dir (when set) so it is +// cleaned up with the job, otherwise DuckDB's default temp_directory is kept. +fn configure_duckdb_resource_limits( + conn: &duckdb::Connection, + limits: &ResourceLimits, +) -> Result<(), String> { + let mut config_sql = String::new(); + // jemalloc-specific setting bundled with the Linux DuckDB build. macOS and + // Windows builds may not accept it; gated to avoid breaking those workers. + if cfg!(target_os = "linux") { + config_sql.push_str("SET allocator_background_threads=true;\n"); + } + if let Some(mem) = limits.memory_limit.as_deref() { + config_sql.push_str(&format!("SET memory_limit='{}';\n", sql_single_quote(mem))); + } + if let Some(tmp) = limits.temp_directory.as_deref() { + config_sql.push_str(&format!( + "SET temp_directory='{}';\n", + sql_single_quote(tmp) + )); + } + if config_sql.is_empty() { + return Ok(()); + } + conn.execute_batch(&config_sql).map_err(|e| { + format!( + "Error configuring DuckDB resource limits: {}", + e.to_string() + ) + }) +} + fn setup_duckdb_connection( conn: &duckdb::Connection, token: &str, base_internal_url: &str, w_id: &str, + limits: &ResourceLimits, ) -> Result<(), String> { + configure_duckdb_resource_limits(conn, limits)?; + let (s3_access_key, s3_secret_key) = token.rsplit_once('.').unwrap_or(("", token)); let (s3_endpoint_ssl, s3_endpoint) = base_internal_url .split_once("://") @@ -249,10 +328,11 @@ fn prepare_duckdb_internal( token: &str, base_internal_url: &str, w_id: &str, + limits: ResourceLimits, ) -> Result { let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; - setup_duckdb_connection(&conn, token, base_internal_url, w_id)?; + setup_duckdb_connection(&conn, token, base_internal_url, w_id, &limits)?; let mut results: Vec = vec![]; @@ -379,12 +459,13 @@ fn run_duckdb_internal<'a>( token: &str, base_internal_url: &str, w_id: &str, + limits: ResourceLimits, collect_last_only: bool, collect_first_row_only: bool, ) -> Result<(String, Option>), String> { let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; - setup_duckdb_connection(&conn, token, base_internal_url, w_id)?; + setup_duckdb_connection(&conn, token, base_internal_url, w_id, &limits)?; let mut results: Vec>> = vec![]; let mut column_order = None; diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index d8c40f6c4d..4a019be9ab 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -11,7 +11,7 @@ use serde_json::{json, Value}; use uuid::Uuid; use windmill_common::error::{to_anyhow, Error, Result}; use windmill_common::utils::sanitize_string_from_password; -use windmill_common::worker::{Connection, SqlResultCollectionStrategy}; +use windmill_common::worker::{get_memory, Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked, DucklakeCatalogResourceType, @@ -44,6 +44,7 @@ pub async fn do_duckdb( #[allow(unused_variables)] column_order_ref: &mut Option>, occupancy_metrics: &mut OccupancyMetrics, parent_runnable_path: Option, + job_dir: &str, run_inline: bool, ) -> Result> { let annotations = windmill_common::worker::SqlAnnotations::parse(query); @@ -160,6 +161,7 @@ pub async fn do_duckdb( let base_internal_url = client.base_internal_url.clone(); let w_id = job.workspace_id.clone(); + let job_dir = job_dir.to_string(); if annotations.prepare { let result = tokio::task::spawn_blocking(move || { @@ -168,6 +170,7 @@ pub async fn do_duckdb( &token, &base_internal_url, &w_id, + &job_dir, ) }) .await @@ -185,6 +188,7 @@ pub async fn do_duckdb( &token, &base_internal_url, &w_id, + &job_dir, collection_strategy, ) }) @@ -259,6 +263,8 @@ struct DuckDbFfiLib { token: *const c_char, base_internal_url: *const c_char, w_id: *const c_char, + memory_limit: *const c_char, + temp_directory: *const c_char, column_order_ptr: *mut *mut c_char, collect_last_only: bool, collect_first_row_only: bool, @@ -273,6 +279,8 @@ struct DuckDbFfiLib { token: *const c_char, base_internal_url: *const c_char, w_id: *const c_char, + memory_limit: *const c_char, + temp_directory: *const c_char, ) -> *mut c_char, >, >, @@ -319,7 +327,7 @@ impl DuckDbFfiLib { // Version mismatch should only be possible on Windows agent workers // We check for it because FFI interface mismatch will cause undefined behavior / crashes unsafe { - let expected_version: c_uint = 1; + let expected_version: c_uint = 2; let get_version: Symbol<'static, unsafe extern "C" fn() -> c_uint> = lib.get(b"get_version") .map_err(|e| return Error::ExecutionErr(format!("Could not find get_version in the duckdb ffi library. If you are not using docker, consider manually upgrading windmill_duckdb_ffi_lib. {}", e.to_string())))?; @@ -345,6 +353,35 @@ impl DuckDbFfiLib { } } +// 20% headroom for Rust runtime + DuckDB's untracked allocations. Mirrors +// DuckDB's own default ratio, but applied to the worker's cgroup budget +// instead of host RAM. +const DUCKDB_MEMORY_FRACTION: f64 = 0.8; +// Treat cgroup values above 1 PiB as "unlimited" (kernels report page-aligned +// huge numbers when uncapped). get_memory() falls back to host RAM in that +// case, which is exactly what we want to leave to DuckDB's own default. +const CGROUP_UNLIMITED_THRESHOLD: i64 = 1024 * 1024 * 1024 * 1024 * 1024; + +// `DUCKDB_MEMORY_LIMIT` env override, else fraction of the worker's cgroup +// memory (as reported by windmill-common), else None (keep DuckDB's default). +fn resolve_duckdb_memory_limit() -> Option { + if let Ok(v) = env::var("DUCKDB_MEMORY_LIMIT") { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + cgroup_bytes_to_duckdb_memory_limit(get_memory()?) +} + +fn cgroup_bytes_to_duckdb_memory_limit(bytes: i64) -> Option { + if bytes <= 0 || bytes >= CGROUP_UNLIMITED_THRESHOLD { + return None; + } + let mib = ((bytes as f64 * DUCKDB_MEMORY_FRACTION) as i64) / (1024 * 1024); + Some(format!("{}MiB", mib.max(64))) +} + // Read backend/windmill-duckdb-ffi-internal/README_DEV.md for details about why we use FFI fn run_duckdb_ffi_safe<'a>( query_block_list: impl Iterator, @@ -353,6 +390,7 @@ fn run_duckdb_ffi_safe<'a>( token: &str, base_internal_url: &str, w_id: &str, + job_dir: &str, collection_strategy: SqlResultCollectionStrategy, ) -> Result<(Box, Option>)> { let query_block_list = query_block_list @@ -372,6 +410,9 @@ fn run_duckdb_ffi_safe<'a>( let token = CString::new(token).map_err(to_anyhow)?; let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; let w_id = CString::new(w_id).map_err(to_anyhow)?; + let memory_limit = + CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; + let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; let run_duckdb_ffi = &DuckDbFfiLib::get_singleton()?.run_duckdb_ffi; let free_cstr = &DuckDbFfiLib::get_singleton()?.free_cstr; @@ -384,6 +425,8 @@ fn run_duckdb_ffi_safe<'a>( token.as_ptr(), base_internal_url.as_ptr(), w_id.as_ptr(), + memory_limit.as_ptr(), + temp_directory.as_ptr(), &mut column_order, collection_strategy.collect_last_statement_only(query_block_list_count), collection_strategy.collect_first_row_only(), @@ -424,6 +467,7 @@ fn prepare_duckdb_ffi_safe<'a>( token: &str, base_internal_url: &str, w_id: &str, + job_dir: &str, ) -> Result> { let query_block_list = query_block_list .map(|s| { @@ -440,6 +484,9 @@ fn prepare_duckdb_ffi_safe<'a>( let token = CString::new(token).map_err(to_anyhow)?; let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; let w_id = CString::new(w_id).map_err(to_anyhow)?; + let memory_limit = + CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; + let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; let lib = DuckDbFfiLib::get_singleton()?; let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| { @@ -456,6 +503,8 @@ fn prepare_duckdb_ffi_safe<'a>( token.as_ptr(), base_internal_url.as_ptr(), w_id.as_ptr(), + memory_limit.as_ptr(), + temp_directory.as_ptr(), ); let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); free_cstr(ptr); @@ -783,6 +832,44 @@ pub struct Arg { mod tests { use super::*; + #[test] + fn cgroup_bytes_unlimited_or_invalid_returns_none() { + assert_eq!(cgroup_bytes_to_duckdb_memory_limit(0), None); + assert_eq!(cgroup_bytes_to_duckdb_memory_limit(-1), None); + // 1 PiB sentinel: cgroup v1 reports ~i64::MAX when uncapped. + assert_eq!( + cgroup_bytes_to_duckdb_memory_limit(CGROUP_UNLIMITED_THRESHOLD), + None + ); + } + + #[test] + fn cgroup_bytes_real_values_take_80_percent() { + // 1 GiB -> 80% -> 819 MiB (floored to MiB) + assert_eq!( + cgroup_bytes_to_duckdb_memory_limit(1024 * 1024 * 1024), + Some("819MiB".to_string()) + ); + // 4 GiB -> 3276 MiB + assert_eq!( + cgroup_bytes_to_duckdb_memory_limit(4 * 1024 * 1024 * 1024), + Some("3276MiB".to_string()) + ); + } + + #[test] + fn cgroup_bytes_tiny_values_floored_to_64mib() { + // Tiny cgroup must not produce a 0/unusable limit. + assert_eq!( + cgroup_bytes_to_duckdb_memory_limit(1024 * 1024), + Some("64MiB".to_string()) + ); + assert_eq!( + cgroup_bytes_to_duckdb_memory_limit(1), + Some("64MiB".to_string()) + ); + } + // Tests for parse_attach_db_resource function #[test] fn test_parse_attach_db_resource_postgres_res_prefix() { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7798e73c98..ff5a06a022 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -4700,6 +4700,7 @@ pub async fn run_language_executor( column_order, occupancy_metrics, parent_runnable_path, + job_dir, run_inline, )) .await; From 413404a788bbe6b5c9df387a2db3000ffec74083 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 20 May 2026 16:55:50 +0200 Subject: [PATCH 5/7] fix: collapse successful ai tool details (#9265) --- .../copilot/chat/ToolExecutionDisplay.svelte | 10 +++++++++- .../lib/components/copilot/chat/anthropic.ts | 3 ++- .../lib/components/copilot/chat/flow/core.ts | 6 ++++-- .../components/copilot/chat/openai-responses.ts | 3 ++- .../lib/components/copilot/chat/script/core.ts | 3 ++- .../lib/components/copilot/chat/shared.test.ts | 17 +++++++++++++++++ .../src/lib/components/copilot/chat/shared.ts | 14 +++++++++++--- frontend/src/lib/components/copilot/lib.ts | 1 + 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 2c09b1cacd..f6e5950ac6 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -18,8 +18,16 @@ message.parameters !== undefined && Object.keys(message.parameters).length > 0 ) + const isSuccessful = $derived( + !message.isLoading && + !message.error && + !message.needsConfirmation && + !message.isStreamingArguments + ) + const autoCollapseDetails = $derived(message.autoCollapseDetails !== false) + let isExpanded = $derived( - message.showDetails || + (message.showDetails && (!isSuccessful || !autoCollapseDetails)) || (message.isStreamingArguments && hasParameters) || (message.isLoading && message.needsConfirmation) ) diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 4bc581db88..489d700ace 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -107,7 +107,8 @@ export async function parseAnthropicCompletion( toolName, isStreamingArguments: shouldStream, showFade: tool?.showFade, - showDetails: tool?.showDetails + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails }) } } diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index c54c652247..9de82b4132 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -420,7 +420,8 @@ export const flowTools: Tool[] = [ }, requiresConfirmation: true, confirmationMessage: 'Run flow test', - showDetails: true + showDetails: true, + autoCollapseDetails: false }, { // set strict to false to avoid issues with open ai models @@ -537,7 +538,8 @@ export const flowTools: Tool[] = [ }, requiresConfirmation: true, confirmationMessage: 'Run flow step test', - showDetails: true + showDetails: true, + autoCollapseDetails: false }, { def: inspectInlineScriptToolDef, diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index c557132417..378e1073f7 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -270,7 +270,8 @@ export async function parseOpenAIResponsesCompletion( toolName: item.name, isStreamingArguments: shouldStream, showFade: tool?.showFade, - showDetails: tool?.showDetails + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails }) } }) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index b236235527..1e30bf50d9 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -905,7 +905,8 @@ export const testRunScriptTool: Tool = { }, requiresConfirmation: true, confirmationMessage: 'Run script test', - showDetails: true + showDetails: true, + autoCollapseDetails: false } export const getLintErrorsTool: Tool = { diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index b15fddcb94..d26f5cbb54 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -188,6 +188,7 @@ describe('processToolCall', () => { content: error, error, isLoading: false, + isStreamingArguments: false, needsConfirmation: false, showDetails: true }) @@ -207,6 +208,8 @@ describe('processToolCall', () => { def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'), requiresConfirmation: true, confirmationMessage: 'Create schedule', + showDetails: true, + autoCollapseDetails: false, validateBeforeConfirmation: () => undefined, fn } @@ -227,6 +230,20 @@ describe('processToolCall', () => { expect(requestConfirmation).toHaveBeenCalledWith('call_2') expect(fn).toHaveBeenCalled() + expect(setToolStatus).toHaveBeenCalledWith( + 'call_2', + expect.objectContaining({ + autoCollapseDetails: false, + showDetails: true + }) + ) + expect(setToolStatus).toHaveBeenLastCalledWith( + 'call_2', + expect.objectContaining({ + isLoading: false, + isStreamingArguments: false + }) + ) expect(result.content).toBe('ok') }) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 1836267cd3..0723cc3eb3 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -498,6 +498,7 @@ export type ToolDisplayMessage = { error?: string needsConfirmation?: boolean showDetails?: boolean + autoCollapseDetails?: boolean isStreamingArguments?: boolean toolName?: string showFade?: boolean @@ -567,9 +568,11 @@ export async function processToolCall({ content: validationError, parameters: args, isLoading: false, + isStreamingArguments: false, error: validationError, needsConfirmation: false, - showDetails: tool?.showDetails + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails }) return { role: 'tool' as const, @@ -588,7 +591,8 @@ export async function processToolCall({ parameters: args, isLoading: true, needsConfirmation: needsConfirmation, - showDetails: tool?.showDetails + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails }) // If confirmation is needed and we have the callback, wait for it @@ -599,6 +603,7 @@ export async function processToolCall({ toolCallbacks.setToolStatus(toolCall.id, { content: 'Cancelled by user', isLoading: false, + isStreamingArguments: false, error: 'Tool execution was cancelled by user', needsConfirmation: false }) @@ -628,12 +633,14 @@ export async function processToolCall({ toolId: toolCall.id }) toolCallbacks.setToolStatus(toolCall.id, { - isLoading: false + isLoading: false, + isStreamingArguments: false }) } catch (err) { console.error(err) toolCallbacks.setToolStatus(toolCall.id, { isLoading: false, + isStreamingArguments: false, error: 'An error occurred while calling the tool' }) const errorMessage = @@ -679,6 +686,7 @@ export interface Tool { requiresConfirmation?: boolean confirmationMessage?: string showDetails?: boolean + autoCollapseDetails?: boolean streamArguments?: boolean showFade?: boolean } diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index f919103400..de87579a23 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -1069,6 +1069,7 @@ export async function parseOpenAICompletion( isStreamingArguments: shouldStream, showFade: tool?.showFade, showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails, parameters: parameters }) } From 0f7dd86e5c3a43bc62c4c0501efec34226b6e279 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 20 May 2026 16:58:26 +0200 Subject: [PATCH 6/7] feat: persistent in-editor drafts via UserDraft (#9121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(frontend): remove localStorage-backed autosave drafts Strip the per-editor localStorage autosave for flows, apps and raw apps, along with the associated restore toasts and diff actions, so we can replace them with a unified UserDraft service in a follow-up. The backend DraftService (DB-backed drafts) is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): add UserDraft service for per-workspace local drafts Introduces UserDraft, a key-value store keyed by `{workspace}/{itemKind}/{path}` and backed by localStorage. Supports save/get/remove plus a reactive use() handle so multiple component instances observing the same draft stay in sync via a shared $state loaded through useLocalStorageValue. Designed to host drafts for scripts, flows, apps, raw apps, resources, variables, and all trigger kinds. Co-Authored-By: Claude Opus 4.7 (1M context) * tests * nit schedule_ prefix * feat(frontend): persist deep mutations in useLocalStorageValue Track the serialized value alongside the $state and add an $effect that deep-reads it (via readFieldsRecursively). When a deep mutation produces a serialization that differs from the last persisted blob, write it to localStorage. The setter keeps writing synchronously so callers reading localStorage right after assignment still see the new value; the effect no-ops on those because lastSerialized was already updated by the setter. Undefined values are persisted as a removal. UserDraft no longer needs its own removeItem workarounds for undefined values — useLocalStorageValue handles that uniformly now. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): add defaultValue + empty-path handling to UserDraft UserDraft.use() accepts an opts.defaultValue used when no localStorage entry exists yet. It is not persisted on first read — only an actual mutation writes through. Empty paths (new items) bypass localStorage entirely. The entry still lives in the in-memory Map so multiple components on the same /add page share state, but save/get/remove/use never read or write localStorage with an empty path. Once the item is saved and the route navigates to its new URL, a fresh use() on the non-empty path takes over. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire script editor to UserDraft The script editor's top-level state now lives in UserDraft.use(), keyed on the route's path (page.params.path on /scripts/edit, '' on /scripts/add). Deep edits inside ScriptBuilder persist automatically; deploy and draft restore now call UserDraft.remove to clear the local autosave alongside the backend draft. Replaces the URL-hash autosave that ScriptBuilder used to write via replaceStateFn — that prop is now gone, the encodeScriptState debounce is gone, and Triggers no longer takes a saveSessionDraft callback. Viewing a specific historical hash (?hash=...) is kept draft-free by passing '' as the path. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire flow editor to UserDraft flows/add and flows/edit drive the flow value through a StateStore adapter backed by UserDraft.use, so every edit auto-persists at userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal .val convention. On returning visits the local autosave wins and a toast offers a diff against the latest backend draft/deployed version; on a fresh visit the backend value is written into the handle. Deploy, save-as-draft rename, restore-draft and restore-deployed each call UserDraft.remove on the route path so the local autosave doesn't outlive the action. Adds UserDraft.has() for "is there already a local draft?" detection in the load path. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire app editor to UserDraft AppEditor registers a UserDraft.use handle for its current path (empty path for /apps/add stays in-memory) and a single $effect deep-tracks the internal stateApp and forwards every mutation to the handle. useLocalStorageValue's lastSerialized check then dedupes the actual localStorage writes per tick, so even fast drag/resize loops only persist when the JSON output really changes. /apps/edit overlays a local autosave from UserDraft.get on top of the backend value when one exists, with the existing "Discard / Show diff" toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft and restore-deployed all call UserDraft.remove on the relevant path, including the JSON editor save paths. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire raw app editor to UserDraft /apps_raw/edit owns the canonical raw-app state (files, runnables, data, summary) in four $state vars; a single $effect deep-tracks them and forwards the bundle to a UserDraft.use handle so each mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by useLocalStorageValue's serialized check). On load the route overlays the local autosave on top of backend.draft/deployed and offers a "Discard / Show diff" toast when they diverge; matching local entries are silently dropped. Deploy, save-as-draft rename, restore-draft and restore-deployed each call UserDraft.remove on the route path. /apps_raw/add keeps the same shape (UserDraft.use with empty path) so the draft is in-memory only and we drop it explicitly when the initial save creates the real path. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire resource editor to UserDraft ResourceEditor registers a UserDraft.use handle keyed on the initialPath (empty for new resources, in-memory only). A $effect deep-tracks the current workspace's edit state and forwards mutations to the handle; on bootstrap and lazy backend-fetch the local autosave wins over the backend value when they diverge. After a successful save() we call UserDraft.remove so the local autosave doesn't outlive the deploy. Cross-workspace deploys always start from the live backend value rather than the local draft. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire variable editor to UserDraft VariableEditor persists the current workspace's edit state via UserDraft.save on every mutation, keyed on editPath ('' for new variables → in-memory only). Backend fetches now overlay a matching local autosave when one exists, and initNew() rehydrates from the in-memory empty-path entry so opening a fresh "Add variable" drawer keeps any unsaved work from the previous open. After a successful save we drop the corresponding entry. Co-Authored-By: Claude Opus 4.7 (1M context) * editor external changes sync * fix(frontend): don't UserDraft.remove flows while route is still mounted The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore whose getter reads flowHandle.draft directly. Calling UserDraft.remove synchronously before goto() therefore wiped the in-memory entry, made flowStore.val collapse to emptyFlow(), and tripped UnsavedConfirmationModal against the just-saved value — even though the deploy/save-draft itself succeeded. Drop those explicit removes in onSaveInitial, /add onDeploy, and /edit onDeploy. The empty-path entry self-cleans on unmount via onDestroy ref counting; for the non-empty edit path the next visit's load-time diff will silently overwrite localStorage when the local autosave matches the deployed value. Restore-draft/restore-deployed keep their explicit remove because they navigate to the same route (no modal) and loadFlow immediately rehydrates the handle. Co-Authored-By: Claude Opus 4.7 (1M context) * Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted" This reverts commit 079ebef72b3b8cad799e54fb88fc4f8fa1863d0d. * Only remove from localStorage * feat(frontend): saveInitialValue option on useLocalStorageValue The first time a value flows into a UserDraft.use() handle — typically the editor route loading the backend value via flowHandle.draft = backendFlow — is the baseline, not a user edit. Persisting it on the spot puts a copy of the backend into localStorage on every page open and produces spurious "local autosave" toasts on next visit when the serialization round-trips differently. useLocalStorageValue now takes options.saveInitialValue (default true, backward compatible). When false, the first time the serialised form of the state changes — via the setter or via a deep mutation — the lastSerialized cache is updated but localStorage is not touched. Every write after that persists normally. UserDraft.use() passes false. Tests updated to reflect the new contract (first write is the baseline) and a regression test added for the second-write-persists behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): persist full multi-workspace bundle for resources/variables ResourceEditor and VariableEditor can stage edits for several target workspaces in a single drawer session (see deployTo / states[ws] map). The previous UserDraft wiring only persisted states[$workspaceStore] — the user's session workspace — so any edit made under a different target workspace tab disappeared on refresh. Persist the entire `states: Record` bundle as the draft value instead. On lazy-fetch we pick the local state for that ws if present and divergent from the backend; on bootstrap for new resources/variables we restore states for every workspace the user had staged. The localStorage key still lives under the user's session workspace via UserDraft, but its contents now cover all target workspaces from that session. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): bake parent_hash into the initial script load loadScript() assigned the backend value to scriptHandle.draft and then deep-mutated parent_hash on the next line. Under useLocalStorageValue's saveInitialValue=false contract only the very first write is the baseline — the parent_hash mutation right after counted as a second write and was persisted to localStorage, so opening an existing script would silently write a draft entry even though the user hadn't touched anything. Combine `parent_hash` (and the topHash override) into a single bakedBaseline so each branch of loadScript performs exactly one assignment to scriptHandle.draft. Mirrored across the local-autosave branch's discard callbacks too. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire SqsTrigger editor to UserDraft Persist the trigger's getSaveCfg() output to userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any existing local autosave on top of the backend value when openEdit loads the trigger, and clear the entry on successful update. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire KafkaTrigger editor to UserDraft Same pattern as the Sqs trigger: persist getSaveCfg() on every edit, overlay any local autosave on top of the backend value when openEdit loads the trigger, drop the entry on successful update. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire NatsTrigger editor to UserDraft Same pattern as the Kafka trigger: persist getSaveCfg() on every edit, overlay any local autosave on top of the backend value when openEdit loads the trigger (with initialConfig/originalConfig snapshotted from backend first so hasChanged correctly reports the overlay as unsaved), drop the entry on successful update. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire MqttTrigger editor to UserDraft Same pattern: persist getSaveCfg() on edits, overlay local autosave in openEdit (with initialConfig/originalConfig snapshotted from backend first), drop the entry on successful update. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire GcpTrigger editor to UserDraft Same pattern as the other triggers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire AzureTrigger editor to UserDraft Same pattern as the other triggers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire WebsocketTrigger editor to UserDraft Same pattern as the other triggers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire PostgresTrigger editor to UserDraft Same pattern as the other triggers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire EmailTrigger editor to UserDraft Same pattern as the other triggers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire HTTP RouteEditor to UserDraft Same pattern as the other triggers, keyed on schedule_http. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): wire ScheduleEditor to UserDraft Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track an originalConfig (its saveDisabled doesn't compare against a baseline) so ordering is simpler — initialConfig snapshotted from backend, local autosave overlaid after. This completes UserDraft wiring across all 11 trigger editors. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(frontend): rename schedule_* UserDraft kinds to trigger_* The schedule_ prefix grouped all the trigger editors under what looked like a "scheduler" namespace; trigger_ is what these actually are (triggers — including the cron-style schedule). Mechanical rename across UserDraftItemKind, every trigger editor's UserDraft.save/get/ remove calls, and the one test that asserted on the localStorage key. Behaviour-only impact: existing localStorage keys under userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be ignored on next open (no schema migration). Users will lose any unsaved trigger drafts persisted before this change. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(frontend): wrap UserDraft localStorage payload as { value } localStorage entries now look like {"value": } instead of just . The wrapping is invisible at the API boundary — UserDraft.use, .save, .get, .remove all still operate on the unwrapped draft value — but it leaves room to add metadata (timestamps, originating user, schema version, ...) later without breaking existing entries. Internals: - StoredDraft = { value: V } is what we serialise to localStorage and what useLocalStorageValue's $state holds. - wrap()/unwrap() helpers gate the boundary; the handle returned by use() unwraps on get and wraps on set. - readPersisted() defensively drops entries whose payload isn't a { value: ... } object, so pre-migration drafts written by earlier commits on this branch are simply ignored (has() returns false, get() returns undefined) rather than confusingly surfacing as undefined-shaped drafts. Test data switched from { value: X } (which collides confusingly with the wrapper shape) to plain primitives / objects, plus a regression test for the pre-migration ignore behaviour. 28 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(backend): expose freshness for UserDraft staleness check Variable - Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE. - Surface them on `ListableVariable` so `getVariable` / `listVariable` return them. DB drafts (script, flow, app/raw_app) - The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal. - Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match. The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): track remote rev metadata on UserDraft entries Extends StoredDraft with two optional rev fields used by the forthcoming staleness modal: - remoteRev — the deployed version's id/hash/timestamp at the moment the local draft was created. Compared against the latest deployed rev on reload. - remoteDraftRev — the DB-draft created_at at the moment the local draft was created. Only meaningful for kinds that have a DB draft (script, flow, app, raw_app). Checked first so a teammate's draft push is detected before the "deployed version moved" case. API additions on the handle returned by UserDraft.use(): - handle.meta — read the rev metadata currently stored. - handle.setDraftAndMeta(value, meta) — atomic write of value + meta in a single state.val assignment. Editor routes use this on load so the baseline rev rides along with the value without consuming the saveInitialValue=false dedup slot twice. - handle.setMeta(meta) — update just the rev metadata after the user picks "Keep current draft" in the staleness modal. - handle.draft = X — unchanged surface; now preserves existing rev metadata across user edits. Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted rev metadata when called without a live handle. 7 new tests cover the metadata surface; all 35 pass. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(frontend): staleness modal for the script editor's local autosave Replace the script editor's toast-based "Discard / Show diff" pattern with a dedicated modal that surfaces *why* the local autosave is out of date: a new DB draft on the server, or a new deployed version. Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev priority) and a `setMeta({ force: true })` mode so the "Keep current draft" acknowledgement persists even when it happens to be the entry's first state mutation — under `saveInitialValue: false` an ack-only setMeta would otherwise be skipped and the modal would re-fire on next mount. The modal lives at LocalDraftStaleModal.svelte; the script editor wires it as a template for the remaining editors. Other editors (flows, apps, raw_apps, resources, variables, triggers) still use the previous toast pattern and will be migrated in follow-up commits. * feat(frontend): staleness modal for flow, app, and raw-app editors Migrates the flow, app, and raw_app editor routes to the same `LocalDraftStaleModal` flow already used by scripts: compare the recorded meta against the current `version` / `versions[last]` and `draft_created_at`; on mismatch, surface the choice in a modal. Adds `UserDraft.saveMeta` for routes that don't hold a live handle (the app editor reads via `UserDraft.get` and the handle lives in the child `AppEditor` component). It writes meta directly to localStorage and tolerates the no-entry case. * feat(frontend): migrate legacy localStorage autosave entries Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`, `app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded state envelope. This adds a one-off migration that rewrites surviving legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` keys with the new `{ value }` wrapper, transforms the payload where the shape differs (drops the flow view-state envelope, defaults the new raw-app `summary` field), and drops the source key. The migration lives in its own file (`userDraftLegacyMigration.ts`) so the new UserDraft service stays free of legacy decoders. Idempotent via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in root layout once a workspace is known. Defensive shape checks avoid clobbering co-resident apps that happen to use the same key prefixes. * nit remove comments * refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors Earlier commits in this PR wired the resource and variable editors to a single multi-workspace bundle stored under the user's session workspace key — which mixed workspaces in one localStorage entry and required a custom multi-key fix-up pass to persist edits for other workspaces. Reset both editors to their pre-PR shape and apply the minimal change: the per-workspace `Record` (resp. `VariableState`) becomes `Record>`, with one handle per workspace created via `UserDraft.use(…, { workspace: ws })`. The handle keys its own localStorage entry under that workspace, so cross-workspace edits stay cleanly separated and reactivity flows through the handle's `draft` accessor — `bind:` on form fields just works. Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so the editors can register handles lazily inside an effect (Svelte 5 forbids `onDestroy` outside component init). The editors register a single top-level `onDestroy` that releases every collected handle. After a successful save, the per-workspace autosave is cleared via `UserDraft.remove(itemKind, path, { workspace })`. * refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`, which relies on the saveInitialValue=false skip to swallow that seeding write. Hand the baseline to `UserDraft.use({ defaultValue })` instead — useLocalStorageValue uses it as the initial $state value when localStorage is empty, so lastSerialized is correct out of the gate and no setter call is needed. * feat(frontend): persist empty-path drafts across reloads Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit) because we worried about collisions between concurrent /add tabs. The user asked for the trade-off to flip: a /flows/add or /scripts/add reload should restore the user's work, while explicitly clicking "+ Flow / + Script / …" should always open a clean editor. - Drop `isLocalOnly` from UserDraft so empty-path entries persist under `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind refcounting and saveInitialValue=false behavior already handle them correctly — the change is just lifting the bypass. - Each /add page now calls `UserDraft.remove(kind, '')` synchronously when `?nodraft=true` is present in the URL, before the handle is created. - The two "+" entry points that lacked the `?nodraft=true` flag (CreateActionsScript's plain `` and CreateActionsFlow's YAML/JSON import paths) now include it, so every fresh-start path goes through the wipe. - Tests updated: the "empty path (in-memory only)" block becomes "empty path (persists across reloads)" and asserts the new behavior. * refactor(frontend): drop legacy-migration shape guard We assume Windmill is the only app on the origin, so the isPlausibleLegacyValue per-kind shape check was just dead weight. Keep the cheap "decoded is an object" guard for malformed payloads. * docs(frontend): refresh stale "in-memory only" comments around empty paths Empty-path UserDraft entries persist now. Drop the leftover "in-memory only" comments on the /add pages' handle creation, and rewrite the EditorHeader save-initial-draft comments to describe why the UserDraft.remove call is still needed: the draft was promoted to a real path on the backend, so the prior-path autosave must not shadow a future "+ App" / "+ Flow" / … visit. * fix(frontend): strip ?nodraft=true from /add URLs synchronously The previous cleanup ran in afterNavigate, which (a) fires asynchronously — a quick reload between mount and the callback would re-wipe the freshly-started draft — and (b) did `url.search = ''`, nuking sibling params like ?template, ?hub, and ?wac. Move the URL cleanup to the same synchronous block that calls UserDraft.remove on nodraft, using `window.history.replaceState` so it lands before paint. Only the `nodraft` key is removed — other params survive. * feat(frontend): toast when editor opens on a local autosave When a route loads its local autosave (differs from backend, no staleness alarm), surface "Restored from local storage" with up to two reset actions: - "Reset to saved draft": drop the autosave, reapply the backend DB draft. Only shown when the backend has a DB draft. - "Reset to deployed": drop the autosave, delete the DB draft on the backend (if any), reload from the deployed version. Only shown when the item has a deployed version. The toast title + label wording + per-state inclusion live in a single helper (`$lib/userDraftToast`). Each editor passes its own reset callbacks since the side effects differ per route (handle vs UserDraft.get/save, redraw counters, loadXxx helpers). Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource and variable editors don't have DB drafts and use per-workspace handles — a follow-up will tailor a single-action version. * feat(frontend): load URL-encoded scripts on /scripts/add The "Fork" action on run/[...run] and several workspace-settings helper-script templates base64-JSON-encode a NewScript into the URL hash on `/scripts/add#...`. Until now /scripts/add silently dropped that payload — both call sites landed on a blank editor. Decode `page.url.hash` at module top, and if it parses to an object, apply it as `scriptHandle.draft` and surface "Loaded from URL". The URL value wins over local autosave, ?template, ?hub, and YAML imports because the hash represents an explicit "open this script" intent. Parsing is inlined rather than reusing `decodeState` so an unrelated hash (e.g. a future route anchor) doesn't fire its default "Impossible to parse state" error toast. * feat(frontend): strip URL hash from /scripts/add after consumption The URL-encoded script is a one-shot seed (Fork preview, workspace handler templates, hub publish) — keeping the hash in the bar after loading meant a reload would re-apply the original payload and wipe whatever the user edited since landing. After applying `urlScript` and firing the "Loaded from URL" toast, clear `location.hash` via `window.history.replaceState`. The user's edits then flow into the normal autosave path (UserDraft empty-path entry), and a reload restores those edits instead of the seed. * feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit for parity: decode the base64-JSON-encoded NewScript payload from the URL hash, apply it over the bakedBaseline as the editor's initial state, send "Loaded from URL", and strip the hash immediately via window.history.replaceState so a reload restores the user's autosave rather than re-injecting the seed. The seed wins over local autosave + backend draft + deployed — UserDraft.remove(script, draftPath) drops the stale autosave on disk before setDraftAndMeta writes the seeded value, so the user's subsequent edits will overwrite cleanly. Skipped when ?hash= is in the URL (historical-version view, which is read-only relative to drafts) and when the hash fragment isn't a parseable encoded payload. No callers build /scripts/edit# URLs today — this lands the mechanism for future symmetry with /scripts/add. * fix(frontend): "Reset to deployed" loop on Restored-from-local toast UserDraft.remove only clears localStorage — the entry's reactive cell stays alive as long as some component holds a handle. The toast callback was relying on remove+loadXxx to reset state, but loadXxx then read the *in-memory* autosave through the still-alive entry, matched it against the now-deployed reference, and re-fired the same toast. Forever. Drop the in-memory state explicitly before the load: - scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})` - apps (handle lives in the AppEditor child): set `app = undefined` to unmount AppEditor — its onDestroy releases the handle and the entry's refcount drops to 0, destroying the entry. ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the reload fetches; the flash is the user-visible "loading" cue. * fix(backend): convert draft.created_at to TIMESTAMPTZ The new `*WithDraft` endpoints surface `draft.created_at` as `Option>` for the frontend's staleness check, which requires `TIMESTAMPTZ`. The column was originally created as plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a non-null draft and the handler returns HTTP 400 instead of 200 — caught by `test_draft_endpoints` in the integration tests. Migrate the column to `TIMESTAMPTZ`, interpreting existing values as UTC (matching `now()`'s behaviour on a UTC server). No compile-time sqlx queries reference the column, so the offline cache stays valid. * fix(frontend): settings drawer auto-opening on /scripts/edit ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the heuristic for "new script, expected on /scripts/add"). The route's `let initialPath = $state('')` left it empty until applyBaseline ran later inside loadScript. Pre-PR, the editor was gated on a route-level `script` $state that started undefined, so ScriptBuilder didn't mount until loadScript's synchronous block set both `script` and `initialPath` in the same tick. With UserDraft.use reading localStorage synchronously, the gate (`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder mounts with the still-empty initialPath, popping the drawer open. Seed initialPath from page.params.path synchronously so ScriptBuilder sees the path on its first render. Falls back to '' for the historical `?hash=` view to preserve the existing behaviour there. * fix(backend): refresh draft.created_at on every upsert The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`, so subsequent draft writes left `created_at` frozen at the first INSERT. The frontend's UserDraft staleness check reads that timestamp as `remoteDraftRev`; with it frozen, an updated remote draft looked identical to the originally-baselined one and the "newer draft was saved on the server" modal never fired. Touch `created_at` on conflict too. The column's semantic widens from "first write time" to "last write time", which is what every reader of the field actually wants — the staleness signal is the only consumer. SQLx offline cache regenerated to match the new query text. * fix(frontend): persist trigger drafts in script-editor autosave The triggers in ScriptBuilder live in a dedicated `triggersState` $state, separate from the `script` object that the UserDraft handle deep-tracks. Pre-PR the per-builder localStorage autosave bridged the two by snapshotting `triggersState.getDraftTriggersSnapshot()` into the payload on every write — that bridge was dropped when we removed the per-builder autosave in favour of UserDraft. Add an $effect that deep-reads triggersState and mirrors the snapshot back into `script.draft_triggers`. The UserDraft handle (already deep-tracking `script`) then persists the trigger drafts as part of the script autosave, restoring the prior behaviour. * feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use Adds `debounce: number` to `useLocalStorageValue`'s options. When set, repeated mutations within the window collapse into a single localStorage write fired by a plain `setTimeout`. The in-memory `$state` is updated on every change so readers of `.val` always see the latest value; only the persistence side-effect is deferred. No `onDestroy` flush — the timer is independent of the Svelte lifecycle, so SPA route teardown doesn't drop the pending write (the callback still fires later as long as the JS context is alive). A hard browser tab close within the window does drop it; that's an acceptable trade-off vs the complexity of `beforeunload` listeners and the leak/refcount issues they create alongside `useLocalStorageValue`'s keyed instances. `UserDraft.use` opts in with `debounce: 500` so a typing storm in the script/flow/app editor produces one localStorage write per 500 ms instead of one per keystroke. Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to keep the synchronous `expect(localStorage…)` assertions working. New test verifies the coalescing behaviour end-to-end. * fix(frontend): tighten legacy-migration key matching The legacy migration was consuming any localStorage key starting with `app-`, `flow-`, or `rawapp-`, with no constraint on what followed and no shape check on the decoded payload. Two failure modes called out in review: 1. A future feature (or third-party extension) picking a name like `app-recent` would silently lose data on first migration run. 2. A stray key that happened to base64-decode to valid JSON but wasn't a real legacy draft would still get promoted to the new format, surfacing later as a phantom "Restored from local storage" toast on the next edit. Two guards: - `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `-` match, the remainder must look like a Windmill item path (`u/owner/name` or `f/folder/name`, possibly with deeper segments). Bare-prefix empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves) still match the exact branch and don't go through the shape gate. - `isPlausibleLegacyValue`: after decode, require the payload to carry the field the legacy writers actually produced (`flow.flow` for flows, any of `summary|value|policy|path` for apps, any of `files|runnables|data` for raw apps). Both are belt-and-suspenders: nothing else currently uses these key prefixes, but enforcing the shape locally keeps the migration safe against future namespace collisions. * fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration The original migration forced `USING created_at AT TIME ZONE 'UTC'`, which tags every existing wall-clock value as UTC. That matches the common case (Postgres on a UTC server, which the Docker image and most managed offerings default to), but on a non-UTC operator's deployment it shifts all pre-migration timestamps by the server's tz offset. Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ` cast reinterprets each existing value in the session's current timezone — which is the same timezone under which the original `INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so the conversion correctly recovers the original instant regardless of the operator's timezone. Same semantics on UTC servers, correct semantics on non-UTC servers. Down migration updated symmetrically. * docs(frontend): clarify staleness modal copy The four route-level editors (scripts/flows/apps/apps_raw) keep the user's local draft visible behind the modal so they can glance at it before choosing. The old body text described the situation (server has moved on, local autosave is behind) but didn't say what's actually on screen or how each action maps to it. New body leads with "The editor is showing your local autosave" and spells out each action: "Load latest replaces what's on screen; Keep current leaves it alone." Same copy for both `cause = 'draft'` and `cause = 'version'`, branching only on what the user is "behind" relative to. * refactor(frontend): drop dead updateDraftCallback from Triggers constructor None of the eight `new Triggers(...)` call sites pass an update callback any more — the bridge was a leftover from the pre-UserDraft era when ScriptBuilder ran its own localStorage autosave and had to be notified on every triggers mutation. The unified UserDraft handle now deep-tracks `script.draft_triggers` via the $effect in ScriptBuilder, so the callback channel is dead weight. Removes the third constructor parameter, the private field, and the six `this.#updateDraftCallback?.()` invocations across setters and mutators. * docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers Three low-priority callouts: - Document the variable.edited_at backfill in the migration. All existing rows get a single `now()` timestamp from the column DEFAULT; the staleness check only consumes the field as an opaque rev string and never displays/sorts on it, so the collision is harmless — but worth saying out loud. - Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte explaining how this layer sits above the per-browser UserDraft autosave and is distinct from the backend DraftService (the server-side "Save as draft" feature surfaced as `*.draft`). * refactor(frontend): replace UserDraft.release() with useMany() Public surface change: - New `UserDraft.useMany(getSpecs: () => UserDraftSpec[])` returns a reactive array of handles. The reconcile loop acquires entries for added specs, releases entries for removed specs, and re-uses cached handles for unchanged keys so caller-captured references stay stable. - `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around `useMany`. The spec getter is `untrack`ed so reactive opts (`$workspaceStore` etc.) are still captured-once — current `use()` semantics unchanged. - `UserDraftHandle.release()` and the `manualRelease` option are gone. Component teardown is handled by a single internal `onDestroy` that releases every entry `useMany` acquired. ResourceEditor + VariableEditor migrated: - Replaced `Record` + manual `ensureHandle`/`release` with a `workspaceSpecs: $state>` plus a derived `Record` that pairs each ws with its parallel handle from `useMany`. `ensureHandle(ws)` is now just a push to the specs array; `VariableEditor.reset()` clears it. The reconcile loop handles acquisition/release end-to-end. Tests: - Dropped the `manualRelease`/`release` test; the option no longer exists. - Added a `useMany` test asserting per-spec entries, isolated workspace-scoped localStorage keys, and a single onDestroy registration covering every acquired entry. Implementation note: I tried wrapping `useLocalStorageValue` in `$effect.root` to give the entry's `$state`/`$effect` an independent scope (in case `useMany`'s reconcile effect tore down nested effects across cycles). But `$effect.root`'s callback wasn't running synchronously in the test runtime (vitest + svelte-vite plugin), and the original `use()` implementation called `useLocalStorageValue` directly without issue. Reverted to the direct call; the nested-scope concern stays theoretical. * fix(frontend): isolate UserDraft entries via $effect.root The previous commit landed `useMany` calling `useLocalStorageValue` directly. That works for the `use()` 1-spec wrapper (whose getter is untracked, so the reconcile `$effect` never re-runs), but for dynamic specs (ResourceEditor / VariableEditor) it leaks the persist `$effect` into the reconcile `$effect`'s scope — meaning the second spec change would destroy the first entry's deep-mutation persist loop. Wrap the `useLocalStorageValue` creation in `$effect.root` so the entry's reactivity lives in its own scope. Stash the returned disposer on the entry and invoke it when the refcount hits 0. The vitest runtime's `$effect.root` returns its disposer but never runs the callback (a test-env quirk, not a production behaviour). Kept a documented fallback that calls `useLocalStorageValue` directly when the callback doesn't populate `stateRef`. In tests that path parents the persist `$effect` to the test scope and lives long enough; in production `$effect.root` runs the callback synchronously per the Svelte 5 spec and the fallback is unreachable. * chore(frontend): drop leftover console.log in setDraftConfig Co-authored-by: Diego Imbert Co-Authored-By: Claude Opus 4.7 * fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler that just stripped the param from the URL via `afterNavigate` — nothing behind it. The original pre-PR semantics (and what every caller assumes) was "skip the localStorage autosave on this load." Mirror the synchronous wipe pattern already in /add: when nodraft is present, call `UserDraft.remove(kind, path)` and strip the flag from the URL via `window.history.replaceState`, before the UserDraft handle is created. The handle then reads an empty entry and the editor opens on the backend version. A plain reload (no nodraft) restores the autosave normally. Removed the redundant `afterNavigate` blocks. Dropped the now-unused `afterNavigate` import in all three; apps/edit still imports `replaceState` (used downstream), so only that name stayed. * feat(frontend): GC UserDraft entries older than 30 days Without a sweep, a heavy user accumulates one localStorage entry per (workspace, kind, path) they ever touched. The pre-PR single-key autosave self-capped at one entry per editor; this one needs an explicit GC pass. Mechanism: - Stamp every persist with `lastWrittenAt: Date.now()`. Added at four sites: `useLocalStorageValue`'s new `transformBeforePersist` option (covers both setter and deep-mutation persists), `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta writes), and the legacy migration. Done at persist time, not in `wrap()`, so deep mutations bump the clock too — `wrap()` runs only on `.draft =` assignments, which would leave the timestamp stale for bind-mutated editor sessions. - `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key, removes the ones older than the cutoff. Entries written before this field existed (pre-PR or pre-this-commit) get backfilled with the current time on first sweep so a 30-day clock starts fresh; the alternative — sweeping on sight — would wipe work that the legacy migration just rescued. - Wired into the logged-in layout: runs once on mount and every 30 min via `setInterval` (cleaned up in the effect's return). Tests use `vi.setSystemTime` to drive the clock; assertions on the stored payload now go through a `storedShape` helper that strips `lastWrittenAt` before string-comparing, so the existing `expect(...).toBe(wrapped(...))` style still reads cleanly. New tests cover the sweep, the backfill behaviour, the default 30d window, and a custom `maxAgeMs`. * fix(frontend): break useMany reconcile feedback loop The reconcile effect read `handles.length` / `handles[i]` for the "unchanged?" early-exit optimisation and then `handles.splice(...)` to publish the new array. Reading `handles` inside the effect registered it as a dependency; the subsequent splice re-fired the effect; ad infinitum (Svelte threw `effect_update_depth_exceeded`). Wrap the comparison reads in `untrack` so the effect's only tracked dependency stays `getSpecs()`. The splice still fires the downstream readers of `handles` (the whole point of `useMany`'s reactivity); it just doesn't re-enter its own producer. * fix(frontend): untrack the splice's own .length read in useMany reconcile The previous fix wrapped only the comparison reads in `untrack`, but `handles.splice(0, handles.length, ...next)` still reads `.length` under the effect's tracking scope — same feedback loop, same `effect_update_depth_exceeded`. Move the whole "compare + splice" block inside `untrack`. The downstream notification on splice still fires (untrack suppresses dependency subscriptions on the producer side, not write notifications), so consumers of `handles` still re-render. * nit * fix(frontend): drop in-memory handle before reloading after DB-draft discard When the "Script/flow loaded from latest saved draft" toast's "Reset to deployed" action ran, it: 1. Deleted the DB draft via DraftService.deleteDraft. 2. Called UserDraft.remove (clears localStorage only). 3. Called goto + loadScript / loadFlow. But the handle's in-memory state still held the now-deleted DB draft and its meta (remoteDraftRev pointing at the gone draft's created_at). On the reload, the editor's loadScript/loadFlow saw `localDraft != undefined` and ran the staleness check, which compared `meta.remoteDraftRev = ` against `currentDraftRev = undefined`. Verdict: "version" stale → spurious "A newer version was deployed on the server" modal, even though nothing on the server actually moved. The editor visibly froze behind the modal because the in-memory state wasn't refreshed. Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})` before the reload — same fix already applied to the "Restored from local storage > Reset to deployed" toast action. apps/edit and apps_raw/edit's "discard draft" actions don't call DraftService.deleteDraft (they just swap the in-memory view to the deployed branch), so they don't hit this codepath. * fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed Same UserDraft.remove-without-clearing-in-memory bug as the previous two commits, this time in the DiffDrawer's "Restore to draft" / "Restore to deployed" buttons on all four /edit routes. The handler deletes the DB draft (in the deployed case), wipes the localStorage entry, navigates, and reloads — but the route's UserDraft handle still holds the old draft + meta in memory, so the reload's staleness check compares the stale meta against the freshly fetched backend and surfaces a spurious "newer version was deployed" modal. - scripts/edit, flows/edit, apps_raw/edit: route-level handle — `handle.setDraftAndMeta(undefined, {})` before the reload. - apps/edit: the handle lives in the AppEditor child, so force a remount by setting `app = undefined; redraw++` before goto/loadApp (matches the existing pattern from the toast's onResetToDeployed). * fix(frontend): legacy app migration matches actual stored shape Legacy AppEditor wrote `encodeState($appStore)` — the inner App value (grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the wrapping AppWithLastVersion. The plausibility check was matching the wrapping fields, so real legacy app entries were filtered out and never migrated to the new userdraft/w/{ws}/app/{path} keys. * fix(frontend): untrack meta-preservation reads in UserDraft setters `set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all read `state.val` before writing it (to preserve existing rev metadata). When called from inside a `$effect` — as AppEditor does to mirror its reactive `$state` into the handle — the read subscribes the effect to the entry's `$state` cell that the write then mutates, producing an `effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so mirrors don't self-trigger. * fix(frontend): apps detect drift + restore on /apps/add reload Two related issues in the app editor's UserDraft wiring: 1. Drift wasn't detected on first deploy/draft after starting an autosave. The route only backfilled meta on a reload that found a local diff — so the first external change after editing slipped through with empty `previousMeta`. AppEditor now receives the load-time revs as `initialRevs` and seeds them into the handle's meta on the first mirror, capturing the rev at autosave-creation time. 2. /apps/add didn't restore from LS on plain reload. The route always initialised `value` to `emptyApp()` and the AppEditor's `stateApp` captured the prop unconditionally, so the LS autosave was shadowed. `stateApp` now falls back to `appDraftHandle.draft` when present; the template/hub/import branches explicitly `UserDraft.remove('app', '')` to keep "start fresh from this content" semantics. Also work around `useLocalStorageValue`'s `saveInitialValue: false` skip slot — in the mirror pattern the slot survived past mount and swallowed the user's first edit. Consume it up-front with a wipe-then-restore pair so subsequent edits persist normally. * feat(frontend): restored-from-local toast in resource/variable editors Resource and variable editors silently loaded LS autosaves over the backend value, leaving users with no signal that the form wasn't reflecting deployed state. Both now fire the standard `notifyRestoredFromLocal` toast (with a "Reset to deployed" action that re-seeds the handle from the just-fetched backend) the first time a lazy-fetch finds the local draft diverging from the remote. * fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist The "Reset to deployed" toast action in resource/variable editors called UserDraft.save with the backend value to repaint the form. That left a duplicate-of-backend autosave in localStorage which would silently restore on every subsequent reload, defeating the reset. New UserDraft.discard(itemKind, path, fallback) clears LS AND resets any live handle's in-memory state to the fallback, skipping the next persist so the fallback doesn't round-trip back into storage. Backed by a new `skipNextWriteOnce()` method on useLocalStorageValue's return. * fix(frontend): use UserDraft.discard in apps reset flows The apps editor route doesn't hold the UserDraft handle — AppEditor (the child remounted by {#key redraw}) does. When a reset action ran `UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor before the old one's onDestroy released its handle, leaving the entry's in-memory state.val populated with the stale autosave. The new AppEditor would then re-acquire that entry and shadow the just-emptied localStorage. Switch every reset path (stale modal Load latest, restored-from-local toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard` so the in-memory cell is cleared synchronously alongside LS. Also plumb `currentRevs` updates so the next mount's initialRevs reflects the acked state. * fix(frontend): /flows/add restores autosave on plain reload `loadFlow()` initialised the local `flow` variable to `emptyFlow()`, then passed it to `initFlow` which writes it to `flowStore.val` (= `flowHandle.draft = flow`). On a bare /flows/add reload (no template/hub/import/fork/urlHash) the assignment overwrote the persisted autosave with the empty baseline. Seed `flow` from `flowHandle.draft` instead, and keep `emptyFlow()` as the explicit "start fresh" baseline for template/hub branches. * nit rename * fix(frontend): snapshot UserDraft proxy before structuredClone in resource save `states[ws].draft` is now a Svelte $state proxy (it flows through UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a proxy and threw "Failed to execute 'structuredClone' on 'Window'", blocking resource saves. Snapshot to a plain object via `$state.snapshot` before assigning the dirty baseline. * fix(frontend): raw app deploy toast crash + harden Toast against bad type RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`, passing an Error as the `_type` arg. `classes[]` is undefined so `color.descriptionClass` threw — and because the toast renders in the root layout, it crashed the whole page on raw app deploy/create. Fixed both call sites to the proper `(msg, true)` error form. Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error' so a future miscall degrades to a plain error toast instead of taking down the page. * fix(frontend): /apps_raw/add restores autosave on plain reload The route initialised files/runnables/data/summary to hardcoded defaults, and the $effect mirror then wrote those defaults over the persisted empty-path autosave. Seed the $state from `draftHandle.draft` instead; import/template/hub branches `UserDraft.remove('raw_app', '')` for explicit "start fresh" semantics. Also consume useLocalStorageValue's saveInitialValue=false skip slot (wipe-then-restore) so the user's first edit isn't dropped. * feat(frontend): staleness modal in resource/variable editors Resource/variable editors only showed the restored-from-local toast; they never surfaced the staleness modal when the backend item moved on since the local autosave was written. Wire LocalDraftStaleModal + checkStaleness using the backend `edited_at` as `remoteRev` (these items have no DB-draft concept). Meta is backfilled on reload for legacy autosaves and seeded on the first real edit via a guarded effect, so an external edit is detectable as drift. Per-workspace detection; the modal is a singleton driven by `pendingStale`. * feat(frontend): restored-from-local toast in standalone trigger editors The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/ mqtt editors silently overlaid the local UserDraft autosave on top of the backend config in `openEdit`, with no signal that the form wasn't showing deployed state. Each now snapshots the just-loaded backend config, then fires `notifyRestoredFromLocal` with a "Reset to deployed" action that drops the LS entry and re-applies the snapshot. * fix(frontend): trigger autosave no longer false-restores on plain open Co-Authored-By: Claude Opus 4.7 * refactor(frontend): live UserDraft handle for trigger editors Co-Authored-By: Claude Opus 4.7 * refactor(frontend): live UserDraft sync for raw app editors Co-Authored-By: Claude Opus 4.7 * refactor(frontend): extract useTriggerDraftSync composable Co-Authored-By: Claude Opus 4.7 * docs(frontend): trim rot-prone comments in UserDraft Co-Authored-By: Claude Opus 4.7 * in /script, put code state in URL --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Diego Imbert --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +- ...e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json | 23 + ...fb5c2f6fd58e518085caf8e8d189951f7c4d8.json | 28 + ...ba3d8bbf1608519b08f2544c64ecfe130c537.json | 17 + ...2200642_add_edited_at_to_variable.down.sql | 3 + ...512200642_add_edited_at_to_variable.up.sql | 15 + ...t_draft_created_at_to_timestamptz.down.sql | 5 + ...ert_draft_created_at_to_timestamptz.up.sql | 12 + backend/windmill-api-flows/src/flows.rs | 4 + backend/windmill-api-scripts/src/scripts.rs | 6 +- backend/windmill-api/openapi.yaml | 17 + backend/windmill-api/src/apps.rs | 30 +- backend/windmill-api/src/drafts.rs | 3 +- backend/windmill-common/src/variables.rs | 4 + backend/windmill-store/src/variables.rs | 17 +- cli/package-lock.json | 36 +- frontend/src/lib/components/Editor.svelte | 19 + .../src/lib/components/FlowBuilder.svelte | 51 +- .../src/lib/components/ResourceEditor.svelte | 159 +++- .../src/lib/components/ScriptBuilder.svelte | 59 +- .../src/lib/components/ScriptEditor.svelte | 5 - frontend/src/lib/components/Toast.svelte | 14 +- .../src/lib/components/VariableEditor.svelte | 140 +++- .../components/apps/editor/AppEditor.svelte | 67 +- .../apps/editor/AppEditorHeader.svelte | 23 +- .../apps/editor/AppJsonEditor.svelte | 13 +- frontend/src/lib/components/apps/types.ts | 10 + .../LocalDraftStaleModal.svelte | 125 +++ .../components/flows/CreateActionsFlow.svelte | 4 +- .../components/raw_apps/RawAppEditor.svelte | 28 - .../raw_apps/RawAppEditorHeader.svelte | 27 +- frontend/src/lib/components/script_builder.ts | 1 - .../scripts/CreateActionsScript.svelte | 2 +- .../azure/AzureTriggerEditorInner.svelte | 20 +- .../email/EmailTriggerEditorInner.svelte | 22 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 20 +- .../triggers/http/RouteEditorInner.svelte | 22 +- .../kafka/KafkaTriggerEditorInner.svelte | 22 +- .../mqtt/MqttTriggerEditorInner.svelte | 20 +- .../nats/NatsTriggerEditorInner.svelte | 22 +- .../PostgresTriggerEditorInner.svelte | 22 +- .../schedules/ScheduleEditorInner.svelte | 16 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 30 +- .../components/triggers/triggers.svelte.ts | 15 +- .../triggers/useTriggerDraftSync.svelte.ts | 121 +++ .../WebsocketTriggerEditorInner.svelte | 22 +- .../tutorials/FlowBuilderLiveTutorial.svelte | 92 ++- frontend/src/lib/storeUtils.ts | 8 - frontend/src/lib/svelte5Utils.svelte.ts | 120 ++- frontend/src/lib/test-setup.ts | 10 + frontend/src/lib/userDraft.svelte.ts | 677 ++++++++++++++++ frontend/src/lib/userDraft.test.ts | 721 ++++++++++++++++++ .../src/lib/userDraftLegacyMigration.test.ts | 223 ++++++ frontend/src/lib/userDraftLegacyMigration.ts | 192 +++++ frontend/src/lib/userDraftToast.ts | 31 + .../src/routes/(root)/(logged)/+layout.svelte | 19 +- .../(root)/(logged)/apps/add/+page.svelte | 50 +- .../(logged)/apps/edit/[...path]/+page.svelte | 242 +++--- .../(root)/(logged)/apps_raw/add/+page.svelte | 121 ++- .../apps_raw/edit/[...path]/+page.svelte | 231 ++++-- .../(root)/(logged)/flows/add/+page.svelte | 87 +-- .../flows/edit/[...path]/+page.svelte | 358 +++++---- .../(root)/(logged)/scripts/add/+page.svelte | 155 +++- .../scripts/edit/[...path]/+page.svelte | 435 ++++++++--- 64 files changed, 4203 insertions(+), 920 deletions(-) create mode 100644 backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json create mode 100644 backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json create mode 100644 backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json create mode 100644 backend/migrations/20260512200642_add_edited_at_to_variable.down.sql create mode 100644 backend/migrations/20260512200642_add_edited_at_to_variable.up.sql create mode 100644 backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.down.sql create mode 100644 backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.up.sql create mode 100644 frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte create mode 100644 frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts create mode 100644 frontend/src/lib/userDraft.svelte.ts create mode 100644 frontend/src/lib/userDraft.test.ts create mode 100644 frontend/src/lib/userDraftLegacyMigration.test.ts create mode 100644 frontend/src/lib/userDraftLegacyMigration.ts create mode 100644 frontend/src/lib/userDraftToast.ts diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index e7ed0aee65..d29a18c691 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true, true ] diff --git a/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json b/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json new file mode 100644 index 0000000000..3e47b7b034 --- /dev/null +++ b/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Int4", + "Bool", + "Timestamptz", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622" +} diff --git a/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json b/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json new file mode 100644 index 0000000000..46025de086 --- /dev/null +++ b/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + { + "Custom": { + "name": "draft_type", + "kind": { + "Enum": [ + "script", + "flow", + "app" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8" +} diff --git a/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json b/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json new file mode 100644 index 0000000000..cd81ba0052 --- /dev/null +++ b/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537" +} diff --git a/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql b/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql new file mode 100644 index 0000000000..efacb3376e --- /dev/null +++ b/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE variable + DROP COLUMN IF EXISTS edited_by, + DROP COLUMN IF EXISTS edited_at; diff --git a/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql b/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql new file mode 100644 index 0000000000..46fc8ad8be --- /dev/null +++ b/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql @@ -0,0 +1,15 @@ +-- Add `edited_at` and `edited_by` so the UI can detect when a variable has +-- been modified remotely while a local autosave was in flight (see the +-- UserDraft staleness check). Mirrors what `resource` already has. +-- +-- Backfill: existing rows get `edited_at = now()` via the column's DEFAULT. +-- All pre-migration variables therefore appear to share a single edit +-- timestamp (the migration time). The staleness check only consumes +-- `edited_at` as an opaque rev string — it doesn't display or sort on it — +-- and only after the user edits a variable forward at least once. So the +-- collision is harmless: no UI flow looks at the pre-migration timestamp +-- before it gets overwritten by a real edit. + +ALTER TABLE variable + ADD COLUMN edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + ADD COLUMN edited_by VARCHAR(50); diff --git a/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.down.sql b/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.down.sql new file mode 100644 index 0000000000..31a1f44859 --- /dev/null +++ b/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.down.sql @@ -0,0 +1,5 @@ +-- Symmetric to the up migration: Postgres's default `TIMESTAMPTZ -> TIMESTAMP` +-- cast strips the timezone by representing the instant in the session's +-- current timezone, mirroring how the original `now()` values were +-- truncated on insert. +ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMP; diff --git a/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.up.sql b/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.up.sql new file mode 100644 index 0000000000..79f20611d4 --- /dev/null +++ b/backend/migrations/20260514233244_convert_draft_created_at_to_timestamptz.up.sql @@ -0,0 +1,12 @@ +-- `draft.created_at` was originally created as `TIMESTAMP` (no timezone). The +-- new `*WithDraft` API responses surface it as `chrono::DateTime` for the +-- frontend's staleness check, which requires `TIMESTAMPTZ`. +-- +-- We rely on Postgres's default `TIMESTAMP -> TIMESTAMPTZ` cast (no explicit +-- USING), which interprets each existing wall-clock value in the session's +-- current timezone. That's the exact semantics under which the original +-- `INSERT ... DEFAULT now()` values were truncated to TIMESTAMP — so the +-- conversion is a no-op on UTC servers (the common case) and correctly +-- recovers the original instant on non-UTC servers, instead of shifting all +-- pre-migration timestamps by the server's tz offset. +ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMPTZ; diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index f20511ab14..8a42bce88e 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1480,6 +1480,9 @@ pub struct FlowWDraft { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option>>, + /// Timestamp at which the most recent DB draft was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_created_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1516,6 +1519,7 @@ async fn get_flow_by_path_w_draft( flow.ws_error_handler_muted, flow.dedicated_worker, draft.value AS draft, + draft.created_at AS draft_created_at, flow.tag, flow.visible_to_runner_only, flow.on_behalf_of_email, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index c99880bbd8..19fbaa2148 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -93,6 +93,9 @@ pub struct ScriptWDraft { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option>>, + /// Timestamp at which the most recent DB draft was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_created_at: Option>, pub schema: Option, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, @@ -170,6 +173,7 @@ impl ScriptWDraft { kind: self.kind, tag: self.tag, draft: self.draft, + draft_created_at: self.draft_created_at, schema: self.schema, draft_only: self.draft_only, envs: self.envs, @@ -1821,7 +1825,7 @@ async fn get_script_by_path_w_draft( let mut tx = user_db.begin(&authed).await?; let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON + "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' WHERE script.path = $1 AND script.workspace_id = $2 ORDER BY script.created_at DESC LIMIT 1", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2adbbcbcf7..e25bb381de 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9588,6 +9588,10 @@ paths: properties: draft: $ref: "#/components/schemas/Flow" + draft_created_at: + type: string + format: date-time + description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. /w/{workspace}/flows/exists/{path}: get: @@ -21754,6 +21758,10 @@ components: properties: draft: $ref: "#/components/schemas/NewScript" + draft_created_at: + type: string + format: date-time + description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. hash: type: string required: @@ -22821,6 +22829,11 @@ components: type: string ws_specific: type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string required: - workspace_id - path @@ -26829,6 +26842,10 @@ components: draft_only: type: boolean draft: {} + draft_created_at: + type: string + format: date-time + description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. AppHistory: type: object diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 272c23353a..c377203f38 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -217,6 +217,9 @@ pub struct AppWithLastVersionAndDraft { pub draft: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, + /// Timestamp at which the most recent DB draft was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_created_at: Option>, } #[derive(Serialize)] @@ -642,29 +645,30 @@ async fn get_app_w_draft( let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>( r#" - SELECT - app.id, - app.path, - app.summary, - app.versions, - app.policy, + SELECT + app.id, + app.path, + app.summary, + app.versions, + app.policy, app.custom_path, - app.extra_perms, + app.extra_perms, app_version.value, - app_version.created_at, + app_version.created_at, app_version.created_by, app.draft_only, draft.value AS "draft", + draft.created_at AS "draft_created_at", app_version.raw_app, app.labels FROM app - INNER JOIN app_version + INNER JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - LEFT JOIN draft - ON app.path = draft.path - AND draft.workspace_id = $2 + LEFT JOIN draft + ON app.path = draft.path + AND draft.workspace_id = $2 AND draft.typ = 'app' - WHERE app.path = $1 + WHERE app.path = $1 AND app.workspace_id = $2 "#, ) diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 39a68d8f9a..b0cd5f61b0 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -79,7 +79,8 @@ async fn create_draft( "INSERT INTO draft (workspace_id, path, value, typ) VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = EXCLUDED.value", + ON CONFLICT (workspace_id, path, typ) + DO UPDATE SET value = EXCLUDED.value, created_at = now()", &w_id, draft.path, //to preserve key orders diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index bbde2e9152..07ac44b755 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -51,6 +51,10 @@ pub struct ListableVariable { pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_by: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index c50049fcd6..4c2a0ed670 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -134,6 +134,8 @@ async fn list_variables( "variable.expires_at", "variable.labels", "ws_specific.path IS NOT NULL as ws_specific", + "variable.edited_at", + "variable.edited_by", ]) .left() .join("account") @@ -216,6 +218,7 @@ async fn get_variable( "SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret, variable.description, variable.extra_perms, variable.account, variable.is_oauth, variable.expires_at, variable.labels, + variable.edited_at, variable.edited_by, (now() > account.expires_at) as is_expired, account.refresh_error, resource.path IS NOT NULL as is_linked, account.refresh_token != '' as is_refreshed, @@ -441,8 +444,8 @@ async fn create_variable( sqlx::query!( "INSERT INTO variable - (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &w_id, variable.path, value, @@ -451,7 +454,8 @@ async fn create_variable( variable.account, variable.is_oauth.unwrap_or(false), variable.expires_at, - variable.labels.as_deref() as Option<&[String]> + variable.labels.as_deref() as Option<&[String]>, + &authed.username ) .execute(&mut *tx) .await?; @@ -1048,6 +1052,8 @@ async fn update_variable( } let npath = if has_sql_updates { + sqlb.set("edited_at", "now()"); + sqlb.set_str("edited_by", &authed.username); sqlb.returning("path"); let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let npath_o: Option = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?; @@ -1078,10 +1084,11 @@ async fn update_variable( if let Some(nlabels) = &ns.labels { sqlx::query!( - "UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3", + "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3", nlabels as &[String], &npath, - &w_id + &w_id, + &authed.username ) .execute(&mut *tx) .await?; diff --git a/cli/package-lock.json b/cli/package-lock.json index 2033d89833..f14fd32f8d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -19,18 +19,18 @@ "open": "^10.0.0", "svelte": "^5.45.2", "tar-stream": "^3.1.7", - "windmill-parser-wasm-csharp": "*", - "windmill-parser-wasm-go": "*", - "windmill-parser-wasm-java": "*", - "windmill-parser-wasm-nu": "*", - "windmill-parser-wasm-php": "*", - "windmill-parser-wasm-py": "^1.693.1", - "windmill-parser-wasm-py-imports": "^1.693.1", - "windmill-parser-wasm-regex": "*", - "windmill-parser-wasm-ruby": "*", - "windmill-parser-wasm-rust": "*", - "windmill-parser-wasm-ts": "^1.693.1", - "windmill-parser-wasm-yaml": "*", + "windmill-parser-wasm-csharp": "1.510.1", + "windmill-parser-wasm-go": "1.510.1", + "windmill-parser-wasm-java": "1.510.1", + "windmill-parser-wasm-nu": "1.510.1", + "windmill-parser-wasm-php": "1.647.1", + "windmill-parser-wasm-py": "1.693.1", + "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-regex": "1.692.0", + "windmill-parser-wasm-ruby": "1.526.1", + "windmill-parser-wasm-rust": "1.647.1", + "windmill-parser-wasm-ts": "1.695.0", + "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", "yaml": "^2.7.0" @@ -1438,9 +1438,9 @@ "integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g==" }, "node_modules/windmill-parser-wasm-regex": { - "version": "1.639.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", - "integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ==" + "version": "1.692.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.692.0.tgz", + "integrity": "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw==" }, "node_modules/windmill-parser-wasm-ruby": { "version": "1.526.1", @@ -1453,9 +1453,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.693.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz", - "integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ==" + "version": "1.695.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz", + "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 5f2d65ab2d..03f4c04642 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1916,6 +1916,25 @@ }) }) + // External `code` prop changes should flow into the Monaco editor. The + // `untrack` block reads/writes Monaco without subscribing — only the + // prop read above is tracked — so the editor's own change handler + // (`updateCode`) re-running with the same value short-circuits and we + // don't loop. + $effect(() => { + const next = code ?? '' + const ed = editor + if (!ed) return + untrack(() => { + if (ed.getValue() === next) return + const model = ed.getModel() + if (!model) return + ed.pushUndoStop() + ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }]) + ed.pushUndoStop() + }) + }) + let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => { if (lang !== 'typescript' || !initialized) return false // Use the stable model URI (computed once at mount), not filePath which changes on rename diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 3558e5020e..2dbdec772e 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -14,7 +14,6 @@ import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores' import { cleanValueProperties, - encodeState, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -298,12 +297,6 @@ loadingDraft = true try { const flow = cleanFlow(flowStore.val) - try { - localStorage.removeItem('flow') - localStorage.removeItem(`flow-${$pathStore}`) - } catch (e) { - console.error('error interacting with local storage', e) - } if (newFlow || savedFlow?.draft_only) { if (savedFlow?.draft_only) { await FlowService.deleteFlowByPath({ @@ -487,12 +480,6 @@ // return if (newFlow) { - try { - localStorage.removeItem('flow') - localStorage.removeItem(`flow-${$pathStore}`) - } catch (e) { - console.error('error interacting with local storage', e) - } await FlowService.createFlow({ workspace: $workspaceStore!, requestBody: { @@ -530,12 +517,6 @@ ) } } else { - try { - localStorage.removeItem(`flow-${initialPath}`) - } catch (e) { - console.error('error interacting with local storage', e) - } - if (triggersToDeploy) { await deployTriggers( triggersToDeploy, @@ -589,32 +570,6 @@ } } - let timeout: number | undefined = undefined - - function saveSessionDraft() { - timeout && clearTimeout(timeout) - timeout = window.setTimeout(() => { - try { - localStorage.setItem( - initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow', - encodeState({ - flow: flowStore.val, - path: $pathStore, - selectedId: selectedIdStore, - draft_triggers: triggersState.getDraftTriggersSnapshot(), - selected_trigger: triggersState.getSelectedTriggerSnapshot(), - loadedFromHistory: { - flowJobInitial: stepHistoryLoader.flowJobInitial, - stepsState: stepHistoryLoader.stepStates - } - }) - ) - } catch (err) { - console.error(err) - } - }, 500) - } - const selectionManager = new SelectionManager() const selectedIdStore = $derived(selectionManager.getSelectedId()) // Initialize with selected id if provided @@ -705,8 +660,7 @@ { type: 'default_email', path: '', isDraft: false }, ...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? []) ], - untrack(() => selectedTriggerIndexFromUrl), - saveSessionDraft + untrack(() => selectedTriggerIndexFromUrl) ) ) @@ -1069,7 +1023,6 @@ $effect.pre(() => { if (flowStore.val || selectedIdStore) { readFieldsRecursively(flowStore.val) - untrack(() => saveSessionDraft()) } }) // Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates @@ -1110,7 +1063,7 @@ let stepHistoryLoader = new StepHistoryLoader( untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {}, untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial, - saveSessionDraft, + undefined, untrack(() => noInitial) ) setStepHistoryLoaderContext(stepHistoryLoader) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index a9ad6faaec..0b573d723c 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -13,6 +13,9 @@ import { deepEqual } from 'fast-equals' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' + import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' + import { notifyRestoredFromLocal } from '$lib/userDraftToast' + import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' interface Props { canSave?: boolean @@ -49,11 +52,82 @@ let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) let initialPath = path - let states: Record = $state({}) + // Per-workspace handles are driven by `useMany`. We track the workspace + // IDs (and their seeded defaults) in a parallel `$state` array; on every + // mutation `useMany` reconciles, acquiring entries for new workspaces and + // releasing them on component teardown. `states` indexes the resulting + // handles by workspace ID for ergonomic lookup downstream. + let workspaceSpecs = $state>([]) let initialStates: Record = $state({}) let existedInitially: Record = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $state({}) + // Backend `edited_at` per workspace — the rev the staleness check + // compares the local autosave's recorded rev against. Resources have + // no DB-draft concept, so only `remoteRev` is ever populated. + let fetchedRev: Record = $state({}) + + // Local-draft staleness modal: opened when the backend resource moved + // on (someone else edited it) since the local autosave was written. + let staleModalOpen = $state(false) + let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined + + function onStaleLoadLatest(): void { + if (!pendingStale) { + staleModalOpen = false + return + } + const { ws, backend } = pendingStale + // Drop the divergent autosave and reset the handle to the freshly + // fetched backend state. A later edit re-creates the autosave and + // the seeding effect records the new rev. + UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws }) + initialStates[ws] = $state.snapshot(backend) as ResourceState + pendingStale = undefined + staleModalOpen = false + } + + function onStaleKeepDraft(): void { + if (pendingStale) { + const { ws } = pendingStale + // Ack the new backend rev so the modal doesn't fire again until + // the backend moves once more. Keeps the local autosave intact. + UserDraft.saveMeta( + 'resource', + initialPath ?? '', + { remoteRev: fetchedRev[ws] }, + { workspace: ws } + ) + } + pendingStale = undefined + staleModalOpen = false + } + + const handlesArray = UserDraft.useMany(() => + workspaceSpecs.map((s) => ({ + itemKind: 'resource' as const, + path: initialPath ?? '', + workspace: s.ws, + defaultValue: s.defaultValue + })) + ) + const states = $derived.by(() => { + const out: Record> = {} + for (let i = 0; i < workspaceSpecs.length; i++) { + const handle = handlesArray[i] + if (handle) out[workspaceSpecs[i].ws] = handle + } + return out + }) + + /** Register a workspace so `useMany` acquires (or reuses) its handle. + * `defaultValue` is what the handle reports when no autosave is persisted; + * an existing autosave always wins. The default itself never round-trips + * to localStorage — only the user's first real edit triggers a write. */ + function ensureHandle(ws: string, defaultValue: ResourceState): void { + if (workspaceSpecs.some((s) => s.ws === ws)) return + workspaceSpecs.push({ ws, defaultValue }) + } let isValid = $state(true) let jsonError = $state('') @@ -86,7 +160,7 @@ }) let loadingSchema = $derived(resourceTypeResource.loading) - let current = $derived(selected ? states[selected] : undefined) + let current = $derived(selected ? states[selected]?.draft : undefined) let resourceToEdit: Resource | undefined = $derived( selected ? fetchedResources[selected] : undefined ) @@ -108,7 +182,7 @@ ) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws])) + Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) const otherDirty = $derived( @@ -122,7 +196,11 @@ const r = fetchedResources[ws] return ( !r || - canWrite(states[ws]?.path ?? initialPath, r.extra_perms ?? {}, perWsUser[ws] ?? $userStore) + canWrite( + states[ws]?.draft?.path ?? initialPath, + r.extra_perms ?? {}, + perWsUser[ws] ?? $userStore + ) ) }) ) @@ -144,7 +222,7 @@ labels: undefined, wsSpecific: false } - states[effectiveWorkspace] = s + ensureHandle(effectiveWorkspace, s) initialStates[effectiveWorkspace] = structuredClone(s) existedInitially[effectiveWorkspace] = false } @@ -162,6 +240,7 @@ getUserExt(ws) ]).then(([r, user]) => { fetchedResources[ws] = r + fetchedRev[ws] = r.edited_at const s: ResourceState = { path: r.path, description: r.description ?? '', @@ -169,7 +248,40 @@ labels: r.labels ?? undefined, wsSpecific: r.ws_specific ?? false } - states[ws] = s + // Reconcile the local autosave with the backend before the + // handle is registered. If the backend moved on since the + // autosave was written (recorded rev != current rev) surface + // the staleness modal; otherwise the form is just showing the + // user's unsaved work — a toast with a "Reset to deployed" + // escape is enough. + const persisted = UserDraft.get('resource', initialPath ?? '', { + workspace: ws + }) + const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws }) + if (persisted !== undefined && !deepEqual(persisted, s)) { + const cause = checkStaleness(previousMeta, r.edited_at) + if (cause) { + pendingStale = { ws, backend: s } + staleModalOpen = true + } else { + if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { + // Legacy autosave (no rev recorded) — backfill so the + // next backend change is detectable as drift. + UserDraft.saveMeta( + 'resource', + initialPath ?? '', + { remoteRev: r.edited_at }, + { workspace: ws } + ) + } + notifyRestoredFromLocal(false, true, { + onResetToDeployed: () => { + UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) + } + }) + } + } + ensureHandle(ws, s) initialStates[ws] = structuredClone(s) existedInitially[ws] = true perWsUser[ws] = user @@ -181,6 +293,25 @@ }) }) + // Seed the staleness rev the moment a real autosave appears. Until the + // user's first edit diverges the handle's draft from the backend + // baseline there's no autosave to attach a rev to; once it does, record + // the backend rev captured at fetch time so a later external edit is + // detectable as drift on the next open. Self-limiting: after the write + // `meta.remoteRev` is set so the guard fails on the re-run. + $effect(() => { + for (const ws of Object.keys(states)) { + const h = states[ws] + const rev = fetchedRev[ws] + const baseline = initialStates[ws] + if (!h || rev === undefined || baseline === undefined) continue + const draft = h.draft + if (draft === undefined || deepEqual(draft, baseline)) continue + if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue + untrack(() => h.setMeta({ remoteRev: rev })) + } + }) + // Keep current.path bound to the outer `path` prop for consumers $effect(() => { if (current) path = current.path @@ -216,7 +347,7 @@ const dirty = dirtyWorkspaces try { for (const ws of dirty) { - const s = states[ws] + const s = states[ws].draft! const ini = initialStates[ws] if (existedInitially[ws]) { await ResourceService.updateResource({ @@ -247,6 +378,13 @@ } }) } + // Saved on the backend — drop the local autosave for this + // workspace and refresh the dirty baseline. `s` is the + // UserDraft handle's draft, a Svelte $state proxy; + // `structuredClone` can't clone a proxy, so snapshot it to a + // plain object first. + initialStates[ws] = $state.snapshot(s) as ResourceState + UserDraft.remove('resource', initialPath ?? '', { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -261,6 +399,13 @@ } + +
{#if otherDirty.length > 0} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 286598cf85..710ce9bd5d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -4,7 +4,6 @@ const bubble = createBubbler() import { DraftService, - type NewScript, ScriptService, type NewScriptWithDraft, type Script, @@ -35,7 +34,6 @@ cleanValueProperties, emptySchema, emptyString, - encodeState, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -125,7 +123,6 @@ savedScript = $bindable(undefined), searchParams = new URLSearchParams(), disableHistoryChange = false, - replaceStateFn = (url) => window.history.replaceState(null, '', url), customUi = {}, savedPrimarySchedule = undefined, functionExports = undefined, @@ -303,15 +300,11 @@ // Add triggers context store const triggersState = $state( - new Triggers( - [ - { type: 'webhook', path: '', isDraft: false }, - { type: 'default_email', path: '', isDraft: false }, - ...(script.draft_triggers ?? []) - ], - undefined, - saveSessionDraft - ) + new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'default_email', path: '', isDraft: false }, + ...(script.draft_triggers ?? []) + ]) ) const captureOn = writable(undefined) @@ -375,28 +368,6 @@ let loadingSave = $state(false) let loadingDraft = $state(false) - let timeout2: number | undefined = undefined - function encodeScriptState(script: NewScript) { - untrack(() => timeout2 && clearTimeout(timeout2)) - timeout2 = setTimeout(() => { - replaceStateFn( - '#' + - encodeState({ - ...script, - draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) - }) - ) - }, 500) - } - - let timeout: number | undefined = undefined - function saveSessionDraft() { - timeout && clearTimeout(timeout) - timeout = setTimeout(() => { - encodeScriptState(script) - }, 500) - } - if (script.content == '') { if (template === 'wac_python') { script.modules = { @@ -559,11 +530,6 @@ loadingSave = true try { - try { - localStorage.removeItem(script.path) - } catch (e) { - console.error('error interacting with local storage', e) - } script.schema = script.schema ?? emptySchema() try { const result = await inferArgs( @@ -703,11 +669,6 @@ loadingDraft = true try { - try { - localStorage.removeItem(script.path) - } catch (e) { - console.error('error interacting with local storage', e) - } script.schema = script.schema ?? emptySchema() try { const result = await inferArgs( @@ -1086,7 +1047,15 @@ }) $effect(() => { readFieldsRecursively(script) - !disableHistoryChange && encodeScriptState(script) + }) + // Mirror the draft triggers (held in a separate `triggersState` $state) + // back into `script.draft_triggers` so the UserDraft autosave — which + // deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own + // localStorage autosave that explicitly snapshotted triggersState; the + // switch to a unified UserDraft handle dropped that bridge. + $effect(() => { + readFieldsRecursively(triggersState.triggers) + script.draft_triggers = triggersState.getDraftTriggersSnapshot() }) loadWorkerTags() diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 159291b250..7e8269913d 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2159,11 +2159,6 @@ if (activeModuleTab === null) { await inferSchema(editorCode) } - try { - localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code) - } catch (e) { - console.error('Could not save last_save to local storage', e) - } dispatch('format') }} class="flex flex-1 h-full !overflow-visible" diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 034887235e..0dd1b91026 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -82,16 +82,24 @@ } }) - let color = classes[untrack(() => type)] + // Defensive: a miscall like `sendUserToast(msg, err)` passes a non- + // AlertType as `type`. Without a fallback the `classes[type]` lookup + // returns undefined and `color.descriptionClass` throws — and because + // the toast renders inside the root layout, that crashes the whole + // page instead of just dropping one toast. Coerce anything unknown to + // 'error' (a bad type almost always accompanies an error path). + const safeType: ToastType = untrack(() => (type in classes ? type : 'error')) + + let color = classes[safeType] let containerClass = { success: 'toast-success', error: 'toast-error', info: 'toast-info', warning: 'toast-warning' - }[untrack(() => type)] + }[safeType] - let Icon = $derived(icons[type]) + let Icon = icons[safeType] let showMore = $state(false) const MAX_MSG_LEN = 160 diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 54ff250c63..962009e63d 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -16,6 +16,9 @@ import { deepEqual } from 'fast-equals' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' + import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' + import { notifyRestoredFromLocal } from '$lib/userDraftToast' + import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' const dispatch = createEventDispatcher() @@ -28,13 +31,79 @@ let editPath: string | undefined = $state(undefined) - let states: Record = $state({}) + // Per-workspace handles are driven by `useMany`. We track the workspace + // IDs (and their seeded defaults) in a parallel `$state` array; on every + // mutation `useMany` reconciles, acquiring entries for new workspaces and + // releasing them on component teardown. `states` indexes the resulting + // handles by workspace ID for ergonomic lookup downstream. + let workspaceSpecs = $state>([]) let initialStates: Record = $state({}) let existedInitially: Record = $state({}) let extraPerms: Record> = $state({}) let perWsUser: Record = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') + // Backend `edited_at` per workspace — the rev the staleness check + // compares the local autosave's recorded rev against. Variables have + // no DB-draft concept, so only `remoteRev` is ever populated. + let fetchedRev: Record = $state({}) + + // Local-draft staleness modal: opened when the backend variable moved + // on (someone else edited it) since the local autosave was written. + let staleModalOpen = $state(false) + let pendingStale: { ws: string; backend: VariableState } | undefined = undefined + + function onStaleLoadLatest(): void { + if (!pendingStale) { + staleModalOpen = false + return + } + const { ws, backend } = pendingStale + UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws }) + initialStates[ws] = $state.snapshot(backend) as VariableState + pendingStale = undefined + staleModalOpen = false + } + + function onStaleKeepDraft(): void { + if (pendingStale) { + const { ws } = pendingStale + UserDraft.saveMeta( + 'variable', + editPath ?? '', + { remoteRev: fetchedRev[ws] }, + { workspace: ws } + ) + } + pendingStale = undefined + staleModalOpen = false + } + + const handlesArray = UserDraft.useMany(() => + workspaceSpecs.map((s) => ({ + itemKind: 'variable' as const, + path: editPath ?? '', + workspace: s.ws, + defaultValue: s.defaultValue + })) + ) + const states = $derived.by(() => { + const out: Record> = {} + for (let i = 0; i < workspaceSpecs.length; i++) { + const handle = handlesArray[i] + if (handle) out[workspaceSpecs[i].ws] = handle + } + return out + }) + + /** Register a workspace so `useMany` acquires (or reuses) its handle. + * `defaultValue` is what the handle reports when no autosave is persisted; + * an existing autosave always wins. The default itself never round-trips + * to localStorage — only the user's first real edit triggers a write. */ + function ensureHandle(ws: string, defaultValue: VariableState): void { + if (workspaceSpecs.some((s) => s.ws === ws)) return + workspaceSpecs.push({ ws, defaultValue }) + } let drawer: Drawer | undefined = $state() let form: VariableForm | undefined = $state() @@ -48,7 +117,7 @@ const MAX_VARIABLE_LENGTH = 10000 const edit = $derived(editPath !== undefined) const initialPath = $derived(editPath ?? '') - const current = $derived(selected ? states[selected] : undefined) + const current = $derived(selected ? states[selected]?.draft : undefined) const can_write = $derived.by(() => { if (!selected || !edit) return true const perms = extraPerms[selected] @@ -56,7 +125,7 @@ return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore) }) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws])) + Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) const otherDirty = $derived( @@ -65,7 +134,10 @@ : dirtyWorkspaces ) const dirtyValid = $derived( - dirtyWorkspaces.every((ws) => states[ws].variable.value.length <= MAX_VARIABLE_LENGTH) + dirtyWorkspaces.every((ws) => { + const v = states[ws].draft + return !!v && v.variable.value.length <= MAX_VARIABLE_LENGTH + }) ) const dirtyCanWrite = $derived( dirtyWorkspaces.every((ws) => { @@ -85,6 +157,7 @@ VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }), getUserExt(ws) ]).then(([v, user]) => { + fetchedRev[ws] = v.edited_at const s: VariableState = { path: v.path, variable: { @@ -95,7 +168,29 @@ labels: v.labels ?? undefined, wsSpecific: v.ws_specific ?? false } - states[ws] = s + // See ResourceEditor for the same pattern: a backend that + // moved on since the autosave was written → staleness modal; + // otherwise just a "showing your local autosave" toast with + // a "Reset to deployed" escape. + const persisted = UserDraft.get('variable', p, { workspace: ws }) + const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws }) + if (persisted !== undefined && !deepEqual(persisted, s)) { + const cause = checkStaleness(previousMeta, v.edited_at) + if (cause) { + pendingStale = { ws, backend: s } + staleModalOpen = true + } else { + if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { + UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) + } + notifyRestoredFromLocal(false, true, { + onResetToDeployed: () => { + UserDraft.discard('variable', p, s, { workspace: ws }) + } + }) + } + } + ensureHandle(ws, s) initialStates[ws] = structuredClone(s) existedInitially[ws] = true extraPerms[ws] = v.extra_perms ?? {} @@ -104,8 +199,26 @@ }) }) + // Seed the staleness rev once a real autosave appears (see + // ResourceEditor for the rationale). Self-limiting via the + // meta-already-set guard. + $effect(() => { + for (const ws of Object.keys(states)) { + const h = states[ws] + const rev = fetchedRev[ws] + const baseline = initialStates[ws] + if (!h || rev === undefined || baseline === undefined) continue + const draft = h.draft + if (draft === undefined || deepEqual(draft, baseline)) continue + if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue + untrack(() => h.setMeta({ remoteRev: rev })) + } + }) + function reset() { - states = {} + // Clearing workspaceSpecs triggers useMany's reconcile to release + // every acquired entry. The $derived `states` then collapses to {}. + workspaceSpecs = [] initialStates = {} existedInitially = {} extraPerms = {} @@ -123,7 +236,7 @@ labels: undefined, wsSpecific: false } - states[ws] = s + ensureHandle(ws, s) initialStates[ws] = structuredClone(s) existedInitially[ws] = false selected = ws @@ -144,7 +257,7 @@ path: editPath, decryptSecret: true }) - const s = states[selected] + const s = states[selected]?.draft const ini = initialStates[selected] if (s) s.variable.value = getV.value ?? '' if (ini) ini.variable.value = getV.value ?? '' @@ -155,7 +268,7 @@ const dirty = dirtyWorkspaces try { for (const ws of dirty) { - const s = states[ws] + const s = states[ws].draft! const ini = initialStates[ws] if (existedInitially[ws]) { await VariableService.updateVariable({ @@ -187,6 +300,8 @@ } }) } + // Saved on the backend — drop the local autosave for this workspace. + UserDraft.remove('variable', editPath ?? '', { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -200,6 +315,13 @@ } + + window.history.replaceState(null, '', path), gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, - onSavedNewAppPath + onSavedNewAppPath, + initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) - const stateApp = $state(untrack(() => app)) + const appDraftPath = newApp ? '' : (path ?? '') + const appDraftHandle = UserDraft.use('app', appDraftPath) + // Prefer the persisted autosave over the prop when both exist (e.g. + // /apps/add reload: the route always initializes `app` to an empty + // template, but the user's last session is sitting in LS under the + // empty-path entry). The route is responsible for wiping the entry + // (`UserDraft.remove`) when it wants to force a fresh start — + // `?nodraft=true`, template/hub loads, etc. + const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) const appStore = writable(stateApp) + // Captured once on mount: the load-time revs are only used as the + // seed meta on the very first persist of this entry. After that the + // handle's own meta wins. + const capturedInitialRevs = untrack(() => initialRevs) + // `useLocalStorageValue`'s `saveInitialValue: false` skips the first + // `set val` that DIFFERS from the loaded LS state — meant to absorb a + // route's "load baseline" write. In AppEditor's $effect-mirror pattern + // the loaded baseline always matches LS (stateApp is initialised from + // the handle's draft), so the skip slot survives until the user's + // FIRST edit and silently swallows it. Consume the slot up-front with + // a wipe-then-restore pair: the wipe sets state.val = undefined + // in-memory (the consumption side-effect of skipNextWrite, which + // suppresses the localStorage delete the wipe would otherwise schedule), + // and the restore immediately puts the value+meta back. Net effect: LS + // gets re-written once on mount and user edits persist normally. + let firstMirror = true + $effect(() => { + readFieldsRecursively(stateApp) + untrack(() => { + // Resolve the meta to attach BEFORE the wipe — the wipe clears + // in-memory meta and would otherwise force-seed `initialRevs` + // even when the handle had real meta. + const currentMeta = appDraftHandle.meta + const hasMeta = + currentMeta.remoteRev !== undefined || currentMeta.remoteDraftRev !== undefined + const meta: UserDraftMeta = hasMeta ? currentMeta : (capturedInitialRevs ?? {}) + if (firstMirror) { + firstMirror = false + appDraftHandle.setDraftAndMeta(undefined, {}) + } + appDraftHandle.setDraftAndMeta(stateApp, meta) + }) + }) const selectedComponent = writable(undefined) // $: selectedComponent.subscribe((s) => { @@ -166,7 +209,7 @@ runnableComponents: writable({}), appPath: writablePath, workspace: $workspaceStore ?? '', - onchange: () => saveFrontendDraft(), + onchange: undefined, isEditor: true, jobs: writable([]), staticExporter: writable({}), @@ -219,19 +262,6 @@ stylePanel: () => StylePanel }) - let timeout: number | undefined = undefined - - function saveFrontendDraft() { - timeout && clearTimeout(timeout) - timeout = setTimeout(() => { - try { - localStorage.setItem(path != '' ? `app-${path}` : 'app', encodeState($appStore)) - } catch (err) { - console.error('Error storing frontend draft in localStorage', err) - } - }, 500) - } - function hashchange(e: HashChangeEvent) { context.hash = e.newURL.split('#')[1] context = context @@ -754,9 +784,6 @@ $effect(() => { path && untrack(() => onPathChange()) }) - $effect(() => { - $appStore && untrack(() => saveFrontendDraft()) - }) $effect(() => { context.mode = $mode == 'dnd' ? 'editor' : 'viewer' }) diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 96bf0f5186..7a55f28860 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -5,6 +5,7 @@ import Toggle from '$lib/components/Toggle.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' import { redo, undo } from '$lib/history.svelte' + import { UserDraft } from '$lib/userDraft.svelte' import { enterpriseLicense, tutorialsToDo, userStore, workspaceStore } from '$lib/stores' import { isMac, type Item, userPathPrefix } from '$lib/utils' import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils' @@ -228,11 +229,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - try { - localStorage.removeItem(`app-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -333,12 +330,8 @@ closeSaveDrawer() sendUserToast('App deployed successfully') + UserDraft.remove('app', $appPath) if ($appPath !== npath) { - try { - localStorage.removeItem(`app-${appPath}`) - } catch (e) { - console.error('error interacting with local storage', e) - } onSavedNewAppPath?.(npath) } } @@ -410,6 +403,10 @@ } draftDrawerOpen = false + // The initial draft was promoted to a real path on the backend — + // drop the autosave keyed on the prior (possibly empty) path so + // a future "+ App" click opens on a clean slate. + UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -500,11 +497,7 @@ } sendUserToast('Draft saved') - try { - localStorage.removeItem(`app-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) diff --git a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte index 5a955a8ce6..e7a35ded20 100644 --- a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte @@ -5,6 +5,7 @@ import JsonEditor from '../../JsonEditor.svelte' import { AppService, DraftService } from '$lib/gen' + import { UserDraft } from '$lib/userDraft.svelte' import { sendUserToast } from '$lib/toast' import { userStore, workspaceStore } from '$lib/stores' import { createEventDispatcher } from 'svelte' @@ -45,11 +46,7 @@ requestBody: { ...app, value: JSON.parse(code) } }) dispatch('change') - try { - localStorage.removeItem(`app-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('app', path) sendUserToast('App deployed') } @@ -63,11 +60,7 @@ } }) dispatch('change') - try { - localStorage.removeItem(`app-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('app', path) sendUserToast('Draft saved') } diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 7e9ccd46c6..2fe6536aa8 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,16 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** + * Backend revs at the load that produced `app`. Used as the seed + * `UserDraft` meta on the first local autosave: until the handle has + * its own meta (set on a previous reload, or by route backfill), the + * mirror `$effect` injects these revs so the next reload's staleness + * check has something to compare the current backend rev against. + * Without this, the first deploy-after-edit can't be detected as + * drift — `previousMeta` would be empty and the modal wouldn't fire. + */ + initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta } export type App = { diff --git a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte new file mode 100644 index 0000000000..94782f176e --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte @@ -0,0 +1,125 @@ + + + + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index fca04512de..dd975fcc72 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -33,7 +33,7 @@ async function importRaw() { $importFlowStore = importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '') - await goto('/flows/add') + await goto('/flows/add?nodraft=true') drawer?.closeDrawer?.() } @@ -41,7 +41,7 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true`) + await goto(`${base}/scripts/add?import=true&nodraft=true`) wacDrawer?.closeDrawer?.() } diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index ffa6037e44..46edb5d230 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -7,7 +7,6 @@ import type Drawer from '../common/drawer/Drawer.svelte' import { type Policy, WorkspaceService } from '$lib/gen' import DiffDrawer from '../DiffDrawer.svelte' - import { encodeState } from '$lib/utils' import { deepEqual } from 'fast-equals' // import { addWmillClient } from './utils' @@ -180,25 +179,6 @@ }) historyManager.manualSnapshot(files ?? {}, runnables, summary, data) - let draftTimeout: number | undefined = undefined - function saveFrontendDraft() { - draftTimeout && clearTimeout(draftTimeout) - draftTimeout = setTimeout(() => { - try { - localStorage.setItem( - path != '' ? `rawapp-${path}` : 'rawapp', - encodeState({ - files, - runnables: runnables, - data: data - }) - ) - } catch (err) { - console.error(err) - } - }, 500) - } - let iframe: HTMLIFrameElement | undefined = $state(undefined) let yamlEditorDrawer: Drawer | undefined = $state(undefined) @@ -414,7 +394,6 @@ if (data.datatable !== policy.datatable || data.schema !== policy.schema) { data.datatable = policy.datatable data.schema = policy.schema - saveFrontendDraft() } }) @@ -655,7 +634,6 @@ // Only add if not already present if (!data.tables.includes(newRef)) { data.tables = [...data.tables, newRef] - saveFrontendDraft() // Clear the cached schema so it gets refreshed with the new table const resourcePath = `datatable://${datatableName}` delete $dbSchemas[resourcePath] @@ -685,7 +663,6 @@ // Only add if not already present if (!data.tables.includes(newRef)) { data.tables = [...data.tables, newRef] - saveFrontendDraft() void aiChatManager.refreshDatatables() } } @@ -773,9 +750,6 @@ } let darkMode: boolean = $state(false) - $effect(() => { - runnables && files && saveFrontendDraft() - }) $effect(() => { iframe?.addEventListener('load', () => { iframeLoaded = true @@ -999,7 +973,6 @@ dataTableRefs={dataTableRefsObjects} onDataTableRefsChange={(newRefs) => { data.tables = newRefs.map(formatDataTableRef) - saveFrontendDraft() }} defaultDatatable={data.datatable} defaultSchema={data.schema} @@ -1012,7 +985,6 @@ datatable, schema } - saveFrontendDraft() }} {runnables} {modules} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 076a831deb..b60327896f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -6,6 +6,7 @@ import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' + import { UserDraft } from '$lib/userDraft.svelte' import { rawAppToHubUrl } from '$lib/hub' import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import YAML from 'yaml' @@ -266,14 +267,10 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - try { - localStorage.removeItem(`rawapp-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('raw_app', path) dispatch('savedNewAppPath', path) } catch (e) { - sendUserToast('Error creating app', e) + sendUserToast(`Error creating app: ${e.body ?? e.message}`, true) } } @@ -382,12 +379,8 @@ closeSaveDrawer() sendUserToast('App deployed successfully') + UserDraft.remove('raw_app', appPath) if (appPath !== npath) { - try { - localStorage.removeItem(`rawapp-${appPath}`) - } catch (e) { - console.error('error interacting with local storage', e) - } dispatch('savedNewAppPath', npath) } } @@ -465,9 +458,13 @@ } draftDrawerOpen = false + // The initial draft was promoted to a real path on the backend — + // drop the autosave keyed on the prior (possibly empty) path so + // a future "+ App" click opens on a clean slate. + UserDraft.remove('raw_app', appPath) dispatch('savedNewAppPath', newEditedPath) } catch (e) { - sendUserToast('Error saving initial draft', e) + sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) } draftDrawerOpen = false } @@ -565,11 +562,7 @@ } sendUserToast('Draft saved') - try { - localStorage.removeItem(`rawapp-${path}`) - } catch (e) { - console.error('error interacting with local storage', e) - } + UserDraft.remove('raw_app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { dispatch('savedNewAppPath', newEditedPath || path) diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 378e562e98..c810003b38 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -32,7 +32,6 @@ export interface ScriptBuilderProps { savedScript?: NewScriptWithDraftAndDraftTriggers | undefined searchParams?: URLSearchParams disableHistoryChange?: boolean - replaceStateFn?: (url: string) => void customUi?: ScriptBuilderWhitelabelCustomUi savedPrimarySchedule?: ScheduleTrigger | undefined functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 48480918e0..5abd8235f2 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -14,7 +14,7 @@ unifiedSize="lg" variant="accent" startIcon={{ icon: Plus }} - href="{base}/scripts/add" + href="{base}/scripts/add?nodraft=true" endIcon={{ icon: Code2 }} > Script diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index de8ac92ee1..e29ab8703c 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -25,6 +25,7 @@ import { saveAzureTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { base } from '$lib/base' @@ -106,6 +107,16 @@ let hasChanged = $derived(!deepEqual(getAzureConfig(), originalConfig ?? {})) const azureConfig = $derived.by(getAzureConfig) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_azure', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => azureConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const saveDisabled = $derived( pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged ) @@ -124,14 +135,15 @@ edit = true dirtyPath = false await loadTrigger(defaultValues) + if (!defaultValues) { + initialConfig = structuredClone($state.snapshot(getAzureConfig())) + } originalConfig = structuredClone($state.snapshot(getAzureConfig())) + await draftSync.maybeRestore(ePath) } catch (err) { sendUserToast(`Could not load Azure trigger: ${err.body}`, true) } finally { drawerLoading = false - if (!defaultValues) { - initialConfig = structuredClone($state.snapshot(getAzureConfig())) - } } } @@ -210,6 +222,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = azureConfig if (!cfg) return const isSaved = await saveAzureTriggerFromCfg( @@ -220,6 +233,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getAzureConfig()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getAzureConfig())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index a1f467001d..486dc64832 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -29,6 +29,7 @@ import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { saveEmailTriggerFromCfg } from './utils' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -88,6 +89,16 @@ let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {})) const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const emailConfig = $derived.by(getEmailTriggerConfig) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_email', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => emailConfig, + applyCfg: (c) => loadTriggerConfig(c as Partial), + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) const saveDisabled = $derived( drawerLoading || @@ -120,14 +131,15 @@ dirtyPath = false dirtyLocalPart = false await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) - } catch (err) { - sendUserToast(`Could not load email trigger: ${err}`, true) - } finally { if (!defaultConfig) { // If the email trigger is loaded from the backend, we to set the initial config initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) } + originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load email trigger: ${err}`, true) + } finally { clearTimeout(loader) drawerLoading = false showLoader = false @@ -213,6 +225,7 @@ drawer?.closeDrawer() } else { deploymentLoading = true + const previousPath = initialPath const saveCfg = emailConfig const isSaved = await saveEmailTriggerFromCfg( initialPath, @@ -223,6 +236,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getEmailTriggerConfig()) onUpdate(saveCfg.path) originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) initialPath = saveCfg.path diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index cea627776d..f0c3f9150a 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -27,6 +27,7 @@ import { saveGcpTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { base } from '$lib/base' @@ -108,6 +109,16 @@ let hasChanged = $derived(!deepEqual(getGcpConfig(), originalConfig ?? {})) const gcpConfig = $derived.by(getGcpConfig) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_gcp', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => gcpConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const saveDisabled = $derived( pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged ) @@ -126,14 +137,15 @@ edit = true dirtyPath = false await loadTrigger(defaultValues) + if (!defaultValues) { + initialConfig = structuredClone($state.snapshot(getGcpConfig())) + } originalConfig = structuredClone($state.snapshot(getGcpConfig())) + await draftSync.maybeRestore(ePath) } catch (err) { sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true) } finally { drawerLoading = false - if (!defaultValues) { - initialConfig = structuredClone($state.snapshot(getGcpConfig())) - } } } @@ -217,6 +229,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = gcpConfig if (!cfg) { return @@ -229,6 +242,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getGcpConfig()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getGcpConfig())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 457091d422..fef0cdb11a 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -55,6 +55,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import UserSettings from '$lib/components/UserSettings.svelte' @@ -137,6 +138,16 @@ let scopes = $derived(['http_triggers:read:' + path]) const routeConfig = $derived.by(getRouteConfig) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_http', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => routeConfig, + applyCfg: (c) => loadTriggerConfig(c as Partial), + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) const saveDisabled = $derived( drawerLoading || @@ -219,14 +230,15 @@ dirtyPath = false dirtyRoutePath = false await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getRouteConfig())) - } catch (err) { - sendUserToast(`Could not load route: ${err}`, true) - } finally { if (!defaultConfig) { // If the route is loaded from the backend, we to set the initial config initialConfig = structuredClone($state.snapshot(getRouteConfig())) } + originalConfig = structuredClone($state.snapshot(getRouteConfig())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load route: ${err}`, true) + } finally { clearTimeout(loader) drawerLoading = false showLoader = false @@ -346,6 +358,7 @@ drawer?.closeDrawer() } else { deploymentLoading = true + const previousPath = initialPath const saveCfg = routeConfig const isSaved = await saveHttpRouteFromCfg( initialPath, @@ -356,6 +369,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getRouteConfig()) onUpdate(saveCfg.path) originalConfig = structuredClone($state.snapshot(getRouteConfig())) initialPath = saveCfg.path diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index ce972f6420..c38ef2e84e 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -24,6 +24,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import TriggerFilters from '../TriggerFilters.svelte' @@ -126,6 +127,16 @@ !hasChanged ) const kafkaConfig = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_kafka', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => kafkaConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) $effect(() => { @@ -148,13 +159,14 @@ edit = true dirtyPath = false await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getSaveCfg())) - } catch (err) { - sendUserToast(`Could not load Kafka trigger: ${err}`, true) - } finally { if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load Kafka trigger: ${err}`, true) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -269,6 +281,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = getSaveCfg() const isSaved = await saveKafkaTriggerFromCfg( initialPath, @@ -278,6 +291,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index e925082b1e..d8aeba8edc 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -39,6 +39,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' interface Props { useDrawer?: boolean @@ -115,6 +116,16 @@ let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {})) const mqttConfig = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_mqtt', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => mqttConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) const saveDisabled = $derived( pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged @@ -144,13 +155,14 @@ edit = true dirtyPath = false await loadTrigger(defaultConfig) - } catch (err) { - sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) - } finally { if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -282,6 +294,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = getSaveCfg() const isSaved = await saveMqttTriggerFromCfg( initialPath, @@ -291,6 +304,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index f2c2688009..3646da068a 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -23,6 +23,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' @@ -110,6 +111,16 @@ pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged ) const natsConfig = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_nats', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => natsConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) $effect(() => { @@ -132,16 +143,17 @@ edit = true dirtyPath = false await loadTrigger(defaultConfig) + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) } catch (err) { sendUserToast(`Could not load nats trigger: ${err}`, true) } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false - if (!defaultConfig) { - initialConfig = structuredClone($state.snapshot(getSaveCfg())) - } - originalConfig = structuredClone($state.snapshot(getSaveCfg())) } } @@ -248,6 +260,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = natsConfig const isSaved = await saveNatsTriggerFromCfg( initialPath, @@ -257,6 +270,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index a46d4a976d..2ca55c4da8 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -45,6 +45,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import { capitalize } from '$lib/utils' interface Props { @@ -162,6 +163,16 @@ ) const postgresConfig = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_postgres', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => postgresConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) const saveDisabled = $derived( @@ -239,13 +250,14 @@ transaction_to_track = [] tab = 'basic' await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getSaveCfg())) - } catch (err) { - sendUserToast(`Could not load postgres trigger: ${err.body}`, true) - } finally { if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load postgres trigger: ${err.body}`, true) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -400,6 +412,7 @@ if (!cfg) { return } + const previousPath = initialPath deploymentLoading = true const isSaved = await savePostgresTriggerFromCfg( initialPath, @@ -409,6 +422,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = cfg.path diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 24a7fdb26d..b7652da98f 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -38,6 +38,7 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' import PermissionedAsLine from '../PermissionedAsLine.svelte' @@ -130,6 +131,16 @@ ) const scheduleCfg = $derived.by(getScheduleCfg) + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_schedule', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => scheduleCfg, + applyCfg: loadScheduleCfg, + deployed: () => initialConfig + }) + export async function openEdit(ePath: string, isFlow: boolean, defaultCfg?: Record) { let loadingTimeout = setTimeout(() => { showLoading = true @@ -142,10 +153,11 @@ path = defaultCfg?.path ?? ePath await loadSchedule(defaultCfg) edit = true - } finally { if (!defaultCfg) { initialConfig = structuredClone($state.snapshot(getScheduleCfg())) } + await draftSync.maybeRestore(ePath) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -527,10 +539,12 @@ } async function scheduleScript(): Promise { + const previousPath = initialPath const scheduleCfg = getScheduleCfg() deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, $workspaceStore!) if (isSaved) { + draftSync.discard(previousPath, scheduleCfg) onUpdate?.(scheduleCfg.path) drawer?.closeDrawer() } diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index e4d823582c..205db6f925 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -31,6 +31,7 @@ import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' interface Props { useDrawer?: boolean @@ -102,6 +103,16 @@ let originalConfig = $state | undefined>(undefined) const sqsConfig = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_sqs', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => sqsConfig, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(getCaptureConfig) const hasChanged = $derived(!deepEqual(sqsConfig, originalConfig ?? {})) const saveDisabled = $derived( @@ -127,13 +138,17 @@ edit = true dirtyPath = false await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getSaveCfg())) - } catch (err) { - sendUserToast(`Could not load sqs trigger: ${err.body}`, true) - } finally { + // Snapshot the *backend* config as the baseline before overlaying + // any local autosave, so hasChanged / onConfigChange correctly + // flag the local edits as unsaved changes. if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load sqs trigger: ${err.body}`, true) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -267,6 +282,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const cfg = getSaveCfg() const isSaved = await saveSqsTriggerFromCfg( initialPath, @@ -276,6 +292,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(cfg.path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = cfg.path @@ -307,6 +324,11 @@ handleConfigChange(sqsConfig, initialConfig, saveDisabled, edit, onConfigChange) } }) + + // Persist edits to UserDraft so an accidental drawer close doesn't lose + // in-progress work. Skipped while the drawer is loading (the freshly + // loaded backend value isn't a user edit) and for new triggers without + // a path (no localStorage write would happen anyway). {#if mode === 'suspended'} diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index c10907b8d3..c849b24c79 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -39,16 +39,10 @@ export class Triggers { ? this.#triggers[this.#selectedTriggerIndex] : undefined ) - #updateDraftCallback: (() => void) | undefined = undefined - constructor( - triggers: Trigger[] = [], - selectedIndex?: number, - updateDraftCallback?: (() => void) | undefined - ) { + constructor(triggers: Trigger[] = [], selectedIndex?: number) { this.#triggers = triggers this.#selectedTriggerIndex = selectedIndex - this.#updateDraftCallback = updateDraftCallback } get selectedTrigger(): Trigger | undefined { @@ -65,7 +59,6 @@ export class Triggers { } else { this.#selectedTriggerIndex = index } - this.#updateDraftCallback?.() } get triggers(): Trigger[] { @@ -74,16 +67,13 @@ export class Triggers { setTriggers(triggers: Trigger[]) { this.#triggers = triggers - this.#updateDraftCallback?.() } setDraftConfig(triggerIndex: number, draftConfig: Record | undefined) { - console.log('setDraftConfig', triggerIndex, draftConfig) if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { return } this.#triggers[triggerIndex].draftConfig = draftConfig - this.#updateDraftCallback?.() } getDraftTriggersSnapshot(): Trigger[] | undefined { @@ -116,7 +106,6 @@ export class Triggers { } this.#triggers.push(newTrigger) - this.#updateDraftCallback?.() updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig) @@ -135,7 +124,6 @@ export class Triggers { this.#triggers = this.#triggers.filter((_, index) => index !== triggerIndex) updateTriggersCount(triggersCountStore, type, 'remove') - this.#updateDraftCallback?.() } updateTriggers( @@ -172,7 +160,6 @@ export class Triggers { const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers]) this.#triggers = newTriggers - this.#updateDraftCallback?.() return newTriggers.filter((t) => t.type === type).length } diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts new file mode 100644 index 0000000000..d8e868559f --- /dev/null +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -0,0 +1,121 @@ +import { untrack } from 'svelte' +import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { notifyRestoredFromLocal } from '$lib/userDraftToast' + +type Cfg = Record + +export interface TriggerDraftSyncOptions { + /** UserDraft item kind for this trigger, e.g. `'trigger_postgres'`. */ + itemKind: UserDraftItemKind + /** Reactive editor path (the trigger being edited). */ + path: () => string + /** Reactive workspace ($workspaceStore). */ + workspace: () => string | undefined + /** Reactive loading flag — both effects are inert while true. */ + drawerLoading: () => boolean + /** form → config: the editor's `getXCfg()` (or its `$derived`). */ + getCfg: () => Cfg | undefined + /** config → form: the editor's `loadXConfig` / `loadScheduleCfg`. May be async. */ + applyCfg: (cfg: Cfg) => void | Promise + /** + * The deployed baseline the editor's dirty check compares against — + * `originalConfig` for TriggerCrud editors, `initialConfig` for Schedule. + */ + deployed: () => Cfg | undefined +} + +export interface TriggerDraftSync { + /** The local autosave for the active (workspace, path), if any. */ + readonly draft: Cfg | undefined + /** + * Restore-on-open: if a local autosave diverges from the just-loaded + * backend config, overlay it and toast a "Reset to deployed" action. + * Call right after the backend load, before clearing `drawerLoading`. + */ + maybeRestore(path: string): Promise + /** + * Clear the draft for `path` and reset the handle's in-memory cell to + * `fallback`. Use after a successful deploy, passing the just-saved cfg — + * `discard` (not `UserDraft.remove`) so the apply-effect doesn't bounce + * the form back to the now-stale draft. + */ + discard(path: string, fallback: Cfg | undefined): void +} + +/** + * Shared local-autosave wiring for the trigger editors. Holding a live + * `UserDraft` handle is what makes an external `UserDraft.save('trigger_x', + * …)` (another tab, a programmatic write) propagate into the open editor. + * + * - **apply-effect**: reflects external `handle.draft` changes into the form. + * - **persist-effect**: writes form edits back through the handle, dropping + * the draft when the form is back at the deployed baseline. + * + * Both effect bodies are `untrack`ed and gated by `localDraftDiffers` + * idempotence so they can't feed back into each other. Must be called once + * during component init (it registers `useMany` + two `$effect`s). + */ +export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraftSync { + const handles = UserDraft.useMany(() => { + const p = opts.path() + const ws = opts.workspace() + return p && ws ? [{ itemKind: opts.itemKind, path: p, workspace: ws }] : [] + }) + const handle = $derived(handles[0]) + + function discard(path: string, fallback: Cfg | undefined): void { + UserDraft.discard(opts.itemKind, path, fallback, { + workspace: opts.workspace() ?? undefined + }) + } + + // apply-effect: external handle.draft → form. + $effect(() => { + const d = handle?.draft + if (opts.drawerLoading() || d == null) return + untrack(() => { + if (localDraftDiffers(d, opts.getCfg() as Cfg)) { + void opts.applyCfg(d) + } + }) + }) + + // persist-effect: form edits → handle; drop the draft when back at the + // deployed baseline. + $effect(() => { + if (opts.drawerLoading() || !opts.path()) return + const cfg = opts.getCfg() + if (cfg == null) return + untrack(() => { + const h = handle + if (!h) return + const deployed = opts.deployed() + if (localDraftDiffers(cfg, deployed)) { + if (localDraftDiffers(cfg, h.draft)) h.draft = cfg + } else { + discard(opts.path(), deployed) + } + }) + }) + + return { + get draft() { + return handle?.draft + }, + async maybeRestore(path: string) { + const d = handle?.draft + if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return + // Snapshot the just-loaded backend config so "Reset to deployed" + // can re-apply it, then overlay the local autosave. + const deployedCfg = structuredClone($state.snapshot(opts.getCfg())) as Cfg + await opts.applyCfg(d) + notifyRestoredFromLocal(false, true, { + onResetToDeployed: async () => { + discard(path, deployedCfg) + await opts.applyCfg(deployedCfg) + } + }) + }, + discard + } +} diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index a7d2e807a8..181e6ff2b3 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -42,6 +42,7 @@ import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte' import { deepEqual } from 'fast-equals' + import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' import { capitalize } from '$lib/utils' @@ -130,6 +131,16 @@ let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {})) const websocketCfg = $derived.by(getSaveCfg) + + const draftSync = useTriggerDraftSync({ + itemKind: 'trigger_websocket', + path: () => initialPath, + workspace: () => $workspaceStore, + drawerLoading: () => drawerLoading, + getCfg: () => websocketCfg, + applyCfg: loadTriggerConfig, + deployed: () => originalConfig + }) const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({})) const saveDisabled = $derived.by(() => { const invalidInitialMessages = initial_messages.some((v) => { @@ -176,13 +187,14 @@ dirtyPath = false dirtyUrl = false await loadTrigger(defaultConfig) - originalConfig = structuredClone($state.snapshot(getSaveCfg())) - } catch (err) { - sendUserToast(`Could not load websocket trigger: ${err}`, true) - } finally { if (!defaultConfig) { initialConfig = structuredClone($state.snapshot(getSaveCfg())) } + originalConfig = structuredClone($state.snapshot(getSaveCfg())) + await draftSync.maybeRestore(ePath) + } catch (err) { + sendUserToast(`Could not load websocket trigger: ${err}`, true) + } finally { clearTimeout(loadingTimeout) drawerLoading = false showLoading = false @@ -342,6 +354,7 @@ async function updateTrigger(): Promise { deploymentLoading = true + const previousPath = initialPath const saveCfg = getSaveCfg() const isSaved = await saveWebsocketTriggerFromCfg( initialPath, @@ -351,6 +364,7 @@ usedTriggerKinds ) if (isSaved) { + draftSync.discard(previousPath, getSaveCfg()) onUpdate?.(saveCfg.path) originalConfig = structuredClone($state.snapshot(getSaveCfg())) initialPath = saveCfg.path diff --git a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte index f6c1fc559b..343deb917c 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte @@ -25,7 +25,8 @@ import { get } from 'svelte/store' import { sendUserToast } from '$lib/toast' import { updateProgress } from '$lib/tutorialUtils' - const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext('FlowEditorContext') + const { flowStore, flowStateStore, selectionManager, currentEditor } = + getContext('FlowEditorContext') interface Props { index: number @@ -48,7 +49,11 @@ } // Helper function to type text character by character - async function typeText(input: HTMLInputElement, text: string, delay: number = DELAY_TYPING): Promise { + async function typeText( + input: HTMLInputElement, + text: string, + delay: number = DELAY_TYPING + ): Promise { input.value = '' input.focus() for (let i = 0; i < text.length; i++) { @@ -60,7 +65,7 @@ // Helper function to update module summary in flowStore function updateModuleSummary(moduleId: string, summary: string): void { - const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === moduleId) + const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === moduleId) if (moduleIndex !== -1) { flowStore.val.value.modules[moduleIndex].summary = summary flowStore.val = { ...flowStore.val } @@ -78,9 +83,9 @@ // Helper function to find button by text and classes function findButtonByText(text: string, classes: string[] = []): HTMLElement | null { const buttons = Array.from(document.querySelectorAll('button')) - return buttons.find(btn => { + return buttons.find((btn) => { const hasText = btn.textContent?.includes(text) ?? false - const hasClasses = classes.every(cls => btn.classList.contains(cls)) + const hasClasses = classes.every((cls) => btn.classList.contains(cls)) return hasText && (classes.length === 0 || hasClasses) }) as HTMLElement | null } @@ -127,11 +132,6 @@ } export function runTutorial() { - try { - localStorage.removeItem('flow') - } catch (e) { - console.error('Error clearing localStorage', e) - } tutorial?.runTutorial() } @@ -196,7 +196,7 @@ celsius: { type: 'number', description: 'Temperature in Celsius', - default: "" + default: '' } }, required: ['celsius'], @@ -212,7 +212,7 @@ span.textContent?.includes('TypeScript (Bun)')) as HTMLElement + const bunSpan = spans.find((span) => + span.textContent?.includes('TypeScript (Bun)') + ) as HTMLElement if (bunSpan) { // Animate cursor from add step button to TypeScript (Bun) span @@ -355,7 +359,13 @@ side: 'top', onNextClick: () => { if (!step3Complete) { - sendUserToast('Please wait for the script to be created...', false, [], undefined, 3000) + sendUserToast( + 'Please wait for the script to be created...', + false, + [], + undefined, + 3000 + ) return } driver.moveNext() @@ -383,7 +393,9 @@ // First, type the summary await wait(DELAY_MEDIUM) - const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + const summaryInput = document.querySelector( + 'input[placeholder="Summary"]' + ) as HTMLInputElement if (summaryInput) { const summaryText = 'Validate temperature input' await typeText(summaryInput, summaryText) @@ -405,8 +417,9 @@ if (editorState && editorState.type === 'script') { const editor = editorState.editor - const moduleA = flowJson.value.modules.find(m => m.id === 'a') - const codeToType = (moduleA?.value && 'content' in moduleA.value) ? moduleA.value.content : '' + const moduleA = flowJson.value.modules.find((m) => m.id === 'a') + const codeToType = + moduleA?.value && 'content' in moduleA.value ? moduleA.value.content : '' if (codeToType) { editor.setCode('', true) @@ -422,8 +435,11 @@ } // Update the flow store with the typed code - const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === 'a') - if (moduleIndex !== -1 && 'content' in flowStore.val.value.modules[moduleIndex].value) { + const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === 'a') + if ( + moduleIndex !== -1 && + 'content' in flowStore.val.value.modules[moduleIndex].value + ) { flowStore.val.value.modules[moduleIndex].value = { ...flowStore.val.value.modules[moduleIndex].value, content: codeToType @@ -445,13 +461,18 @@ }, popover: { title: 'Add validation logic', - description: - "Watch as we write code to validate the temperature input.", + description: 'Watch as we write code to validate the temperature input.', side: 'bottom', onNextClick: () => { // Only proceed if code writing is complete if (!step4Complete) { - sendUserToast('Please wait for the code to finish typing...', false, [], undefined, 3000) + sendUserToast( + 'Please wait for the code to finish typing...', + false, + [], + undefined, + 3000 + ) return } @@ -524,7 +545,9 @@ await wait(DELAY_MEDIUM) // Step 2: Move to and click flow_input.celsius - const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement + const targetButton = document.querySelector( + 'button[title="flow_input.celsius"]' + ) as HTMLElement if (targetButton) { await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG) await wait(DELAY_MEDIUM) @@ -636,7 +659,9 @@ await wait(DELAY_LONG) // Type summary for script 'b' - const summaryInputB = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + const summaryInputB = document.querySelector( + 'input[placeholder="Summary"]' + ) as HTMLInputElement if (summaryInputB) { const summaryTextB = 'Convert to Fahrenheit' await typeText(summaryInputB, summaryTextB) @@ -655,7 +680,9 @@ await wait(DELAY_LONG) // Type summary for script 'c' - const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + const summaryInputC = document.querySelector( + 'input[placeholder="Summary"]' + ) as HTMLInputElement if (summaryInputC) { const summaryTextC = 'Categorize temperature' await typeText(summaryInputC, summaryTextC) @@ -680,7 +707,13 @@ description: 'Two more scripts to convert and categorize the temperature.', onNextClick: () => { if (!step6Complete) { - sendUserToast('Please wait for the summaries to be added...', false, [], undefined, 3000) + sendUserToast( + 'Please wait for the summaries to be added...', + false, + [], + undefined, + 3000 + ) return } @@ -703,7 +736,8 @@ element: '#flow-editor-test-flow', popover: { title: 'Ready to test!', - description: 'Run the complete flow and see your temperature converter in action.

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', + description: + 'Run the complete flow and see your temperature converter in action.

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', onNextClick: () => { updateProgress(index) driver.destroy() @@ -712,7 +746,7 @@ sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } - }, + } ] return steps diff --git a/frontend/src/lib/storeUtils.ts b/frontend/src/lib/storeUtils.ts index 863898a77b..93680b24eb 100644 --- a/frontend/src/lib/storeUtils.ts +++ b/frontend/src/lib/storeUtils.ts @@ -11,12 +11,6 @@ import { import { resetProtectionRules, loadProtectionRules } from './workspaceProtectionRules.svelte' export function switchWorkspace(workspace: string | undefined) { - try { - localStorage.removeItem('flow') - localStorage.removeItem('app') - } catch (e) { - console.error('error interacting with local storage', e) - } resourceTypesStore.set(undefined) // Clear protection rules state @@ -32,8 +26,6 @@ export function switchWorkspace(workspace: string | undefined) { export function clearStores(): void { try { - localStorage.removeItem('flow') - localStorage.removeItem('app') clearWorkspaceFromStorage() } catch (e) { console.error('error interacting with local storage', e) diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index cacac174b9..e3a927281a 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -2,7 +2,7 @@ import { untrack } from 'svelte' import { deepEqual } from 'fast-equals' -import type { StateStore } from './utils' +import { readFieldsRecursively, type StateStore } from './utils' import { resource, watch, type ResourceReturn } from 'runed' export function withProps(component: Component, props: Props) { @@ -575,8 +575,36 @@ export class DebouncedTempValue { export function useLocalStorageValue( key: string, defaultValue: T, - typ?: 'string' | 'number' | 'boolean' -): { val: T } { + typ?: 'string' | 'number' | 'boolean', + options?: { + saveInitialValue?: boolean + /** + * Coalesce localStorage writes within a sliding window. When set to a + * positive number, repeated mutations within `debounce` ms produce a + * single localStorage write at the end of the window. The in-memory + * `$state` is updated immediately on each change — only the persistence + * side-effect is deferred. Readers of `.val` always see the latest + * value; readers of `localStorage` may see a stale value during the + * window. A pending write fires from a plain `setTimeout`, which + * keeps running across SPA route teardown — only a hard browser tab + * close drops it. + */ + debounce?: number + /** + * Transform applied to the value just before serialisation, on every + * persist (both setter-driven and deep-mutation-driven). The in-memory + * `$state` is left as-is. Useful for injecting per-write metadata + * (timestamps, counters) that must reflect the actual write time, not + * the last `.val =` assignment — deep mutations don't re-run the + * setter, so a timestamp set via `.val =` would otherwise grow stale + * across long editing sessions. + */ + transformBeforePersist?: (val: T) => T + } +): { val: T; skipNextWriteOnce(): void } { + const saveInitialValue = options?.saveInitialValue ?? true + const debounceMs = options?.debounce ?? 0 + const transformBeforePersist = options?.transformBeforePersist const serialize = (val: T) => typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val) const deserialize = (val: string): T => { @@ -585,17 +613,97 @@ export function useLocalStorageValue( if (typ === 'boolean') return (val === 'true') as any return JSON.parse(val) as T } + const persist = (val: T | undefined) => { + try { + if (val === undefined) { + localStorage.removeItem(key) + } else { + const toStore = transformBeforePersist ? transformBeforePersist(val as T) : (val as T) + localStorage.setItem(key, serialize(toStore)) + } + } catch (e) { + console.error('useLocalStorageValue: localStorage write failed', e) + } + } - if (typeof window === 'undefined') return { val: defaultValue } + if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} } const savedValue = localStorage.getItem(key) - let s = $state(savedValue ? (deserialize(savedValue) as T) : defaultValue) + let s = $state( + savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue + ) + + // Track the serialized form last written so we can detect deep mutations + // (changes that didn't go through the setter) without double-writing on + // every setter call. The first effect run sees identical serialized output + // and is a no-op (avoids persisting the default value on mount). + let lastSerialized: string | undefined = untrack(() => + s === undefined ? undefined : serialize(s) + ) + // When saveInitialValue=false, the first time the value actually changes + // (either via the setter or a deep mutation) is treated as "loading the + // initial value" rather than a user edit — we update lastSerialized so + // future writes are detected, but we don't persist. + let skipNextWrite = !saveInitialValue + + // Debounce wrapper. Captures the latest pending value; a follow-up call + // within the window replaces the queued payload and resets the timer. + let debounceTimer: ReturnType | undefined + let pendingValue: T | undefined + const schedulePersist = (val: T | undefined) => { + if (debounceMs <= 0) { + persist(val) + return + } + pendingValue = val + if (debounceTimer != null) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = undefined + persist(pendingValue) + pendingValue = undefined + }, debounceMs) + } + + $effect(() => { + readFieldsRecursively(s) + const next = s === undefined ? undefined : serialize(s) + if (next === lastSerialized) return + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + return + } + schedulePersist(s) + }) + return { get val() { return s }, set val(newVal: T) { - localStorage.setItem(key, serialize(newVal)) + // In-memory state is updated synchronously; the localStorage write + // is debounced when `options.debounce` is set. Callers that read + // `.val` get the latest value either way; only direct localStorage + // reads see the stale value during the debounce window. + const next = newVal === undefined ? undefined : serialize(newVal as T) + if (next !== lastSerialized) { + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + } else { + schedulePersist(newVal) + } + } s = newVal + }, + /** + * Arm the persist skip so the next `set val` (or deep-mutation flush) + * updates only the in-memory cell and leaves localStorage untouched. + * Used by `UserDraft.discard` to reset the in-memory state to a + * fallback without re-persisting it — pairs with an explicit LS + * delete to leave the slot empty. + */ + skipNextWriteOnce(): void { + skipNextWrite = true } } } diff --git a/frontend/src/lib/test-setup.ts b/frontend/src/lib/test-setup.ts index 8966a34f96..6b82fcefb6 100644 --- a/frontend/src/lib/test-setup.ts +++ b/frontend/src/lib/test-setup.ts @@ -61,6 +61,16 @@ Object.defineProperty(globalThis, 'sessionStorage', { writable: true }) +// Some modules (e.g. svelte5Utils.useLocalStorageValue) gate browser-only +// behavior on `typeof window`. Provide a minimal window so they don't +// short-circuit during tests. +if (typeof (globalThis as any).window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + value: globalThis, + writable: true + }) +} + vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features/worker', () => ({ TypeScriptWorker: class TypeScriptWorker { private _mockScriptSnapshot?: { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts new file mode 100644 index 0000000000..923b87fec5 --- /dev/null +++ b/frontend/src/lib/userDraft.svelte.ts @@ -0,0 +1,677 @@ +import { get } from 'svelte/store' +import { onDestroy, untrack } from 'svelte' +import { deepEqual } from 'fast-equals' +import { workspaceStore } from './stores' +import { useLocalStorageValue } from './svelte5Utils.svelte' + +export type UserDraftItemKind = + | 'script' + | 'flow' + | 'app' + | 'raw_app' + | 'resource' + | 'variable' + | 'trigger_schedule' + | 'trigger_webhook' + | 'trigger_default_email' + | 'trigger_email' + | 'trigger_http' + | 'trigger_websocket' + | 'trigger_postgres' + | 'trigger_kafka' + | 'trigger_nats' + | 'trigger_mqtt' + | 'trigger_sqs' + | 'trigger_gcp' + | 'trigger_azure' + | 'trigger_poll' + | 'trigger_cli' + | 'trigger_nextcloud' + | 'trigger_google' + | 'trigger_github' + +export type UserDraftOptions = { + workspace?: string +} + +export type UserDraftUseOptions = UserDraftOptions & { + /** + * Initial value used when localStorage holds no draft for this + * (workspace, itemKind, path). It is *not* eagerly persisted — the first + * actual mutation is what writes to localStorage. + */ + defaultValue?: V +} + +/** + * A single (kind, path, workspace) tuple that `useMany` should hold a handle + * for. The shape mirrors `use()`'s arguments, just bundled into one object + * so a getter can return a list of them. + */ +export type UserDraftSpec = { + itemKind: UserDraftItemKind + path: string + workspace?: string + defaultValue?: V +} + +/** + * Snapshot of the remote item's freshness at the moment the local draft was + * written. Used by editor routes to detect that the remote has moved on + * (someone else deployed, or saved a DB draft) so we can warn the user + * before they push stale changes. + * + * - `remoteRev`: the deployed version's id/hash/timestamp at draft creation. + * - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set + * for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`). + */ +export type UserDraftMeta = { + remoteRev?: string | number + remoteDraftRev?: string | number +} + +/** + * The shape of what we actually persist. Wrapping the value lets us add + * metadata (timestamps, originating user, schema version, ...) later + * without breaking existing entries. + * + * `lastWrittenAt` is the unix-ms timestamp of the most recent write + * (setter call or deep mutation flush). It's the GC signal — + * `gcUserDrafts` sweeps entries that haven't been touched in N days. + * Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`, + * `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing + * (undefined) on entries written before this field was introduced; + * `gcUserDrafts` backfills them on first sighting. + */ +type StoredDraft = { value: V; lastWrittenAt?: number } & UserDraftMeta + +function stamp(stored: StoredDraft | undefined): StoredDraft | undefined { + if (stored === undefined) return undefined + return { ...stored, lastWrittenAt: Date.now() } +} + +type DraftState = { + val: StoredDraft | undefined + skipNextWriteOnce(): void +} + +type DraftEntry = { + count: number + state: DraftState + /** + * Tears down the `$effect.root` scope that owns the entry's + * `useLocalStorageValue` reactivity — its `$state` cell and the persist + * `$effect` deep-mutation loop. Called when the refcount hits 0. + * + * `undefined` only when the test runtime's broken `$effect.root` forced + * us through the fallback path (see `acquireEntry`). + */ + destroyRoot?: () => void +} + +const entries = new Map() + +function resolveWorkspace(opts?: UserDraftOptions): string { + const ws = opts?.workspace ?? get(workspaceStore) + if (!ws) { + throw new Error( + 'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)' + ) + } + return ws +} + +function wrap(value: V | undefined, meta?: UserDraftMeta): StoredDraft | undefined { + if (value === undefined) return undefined + const out: StoredDraft = { value } + if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev + if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev + return out +} + +function unwrap(stored: StoredDraft | undefined): V | undefined { + return stored?.value +} + +function extractMeta(stored: StoredDraft | undefined): UserDraftMeta { + if (!stored) return {} + const meta: UserDraftMeta = {} + if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev + if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev + return meta +} + +/** + * Compares the rev metadata recorded against the local draft to the current + * backend revs. Returns the staleness cause, or `null` when the local draft + * is still based on the latest backend state we know about. + * + * - Entries with no recorded meta (legacy entries written before this field + * existed) report `null` — we can't tell if they're stale, and we'd rather + * trust the local autosave than spam the user with false positives. + * - DB-draft staleness wins over deployed-version staleness: a remote DB + * draft is the more recent state to reconcile against. + * - If a DB draft existed when the local autosave was created but now no + * longer exists on the remote (someone discarded it), we report `version` + * because the deployed version is now the canonical "latest saved". + */ +export type UserDraftStalenessCause = 'draft' | 'version' + +export function checkStaleness( + meta: UserDraftMeta, + currentRev: string | number | undefined, + currentDraftRev?: string | number | undefined +): UserDraftStalenessCause | null { + if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null + if (meta.remoteDraftRev !== currentDraftRev) { + return currentDraftRev !== undefined ? 'draft' : 'version' + } + if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version' + return null +} + +/** + * Synchronous localStorage write, bypassing the entry's debounced setter + * and its first-write skip. See `setMeta({ force: true })`. + */ +function persistDirect(key: string, value: V | undefined, meta: UserDraftMeta): void { + try { + const next = stamp(wrap(value, meta)) + if (next === undefined) { + localStorage.removeItem(key) + } else { + localStorage.setItem(key, JSON.stringify(next)) + } + } catch (e) { + console.error('UserDraft: localStorage write failed', e) + } +} + +function readPersisted(key: string): StoredDraft | undefined { + try { + const raw = localStorage.getItem(key) + if (raw == null || raw === 'undefined') return undefined + const parsed = JSON.parse(raw) + // Defensive: ignore pre-wrapping payloads (no `.value`). + if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined + return parsed as StoredDraft + } catch (e) { + console.error('UserDraft: localStorage read failed', e) + return undefined + } +} + +function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { + return `${workspace}/${itemKind}/${path}` +} + +function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string { + return `userdraft/w/${workspace}/${itemKind}/${path}` +} + +export type UserDraftHandle = { + get draft(): V | undefined + set draft(value: V | undefined) + /** + * Read the rev metadata stored alongside the current draft. Empty object + * if the entry has no draft or no rev was ever recorded. + */ + get meta(): UserDraftMeta + /** + * Set value AND rev metadata in one write (no extra persist). Later + * `draft = X` writes preserve the rev metadata. + */ + setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void + /** + * Update rev metadata without touching the value. `{ force: true }` also + * persists synchronously — use when this may be the entry's first write, + * else the ack is lost on remount. + */ + setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void +} + +/** + * JSON round-trip normalization. localStorage persistence stringifies the + * draft, which silently drops keys whose value is `undefined`, turns `Date` + * into a string, etc. A freshly-built config object (e.g. a trigger editor's + * `getXConfig()`) keeps those `undefined`-valued keys, so a raw + * `deepEqual(persistedDraft, freshConfig)` reports spurious differences + * (`{ a: undefined }` ≠ `{}`). Normalize BOTH sides through the same + * round-trip before comparing. Returns the input unchanged if it can't be + * serialized (e.g. a cyclic structure) — better a false "differs" than a + * throw inside a load/effect path. + */ +export function normalizeForCompare(value: V | undefined): V | undefined { + if (value === undefined) return undefined + try { + return JSON.parse(JSON.stringify(value)) as V + } catch { + return value + } +} + +/** + * Whether the persisted local autosave (`localDraft`, as returned by + * `UserDraft.get`) meaningfully differs from the freshly-built + * `currentConfig`. Editor restore guards use this to decide whether to + * overlay the local autosave and toast. + * + * Returns `false` when there is no local draft. Normalizes both sides (see + * `normalizeForCompare`) so a draft that round-trips equal to the deployed + * config — e.g. one written by merely opening then closing the editor with + * no edits — is correctly treated as "no meaningful draft" instead of + * spuriously triggering a restore on every reopen. + * + * Typed as a guard: a `true` result narrows `localDraft` to non-nullish + * `V`, mirroring the `localCfg && …` narrowing it replaces so call sites + * can pass the draft straight into `loadXConfig(...)` without re-checking. + */ +export function localDraftDiffers( + localDraft: V | undefined | null, + currentConfig: V +): localDraft is V { + if (localDraft === undefined || localDraft === null) return false + return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig)) +} + +export const UserDraft = { + save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + // Notify observers; preserve existing rev metadata. `untrack`ed + // read — see `set draft` below for why. + const current = untrack(() => entry.state.val as StoredDraft | undefined) + entry.state.val = wrap(value, extractMeta(current)) + return + } + // No live handle: preserve any persisted meta so the staleness + // signal survives a write while the editor is closed. + const existing = readPersisted(localStorageKey(ws, itemKind, path)) + try { + localStorage.setItem( + localStorageKey(ws, itemKind, path), + JSON.stringify(stamp(wrap(value, extractMeta(existing)))) + ) + } catch (e) { + console.error('UserDraft.save: localStorage write failed', e) + } + }, + + /** + * Autosave gate: persist `value` only when it differs (after + * `normalizeForCompare`) from the `deployed` baseline; otherwise remove + * any draft. Without this, opening and closing an editor with no edits + * would leave a no-op draft that `has()` / restore guards treat as + * unsaved work. + */ + saveIfChanged( + itemKind: UserDraftItemKind, + path: string, + value: V, + deployed: V | undefined, + opts?: UserDraftOptions + ): void { + if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) { + UserDraft.remove(itemKind, path, opts) + } else { + UserDraft.save(itemKind, path, value, opts) + } + }, + + get( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions + ): V | undefined { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + return unwrap(entry.state.val as StoredDraft | undefined) + } + return unwrap(readPersisted(localStorageKey(ws, itemKind, path))) + }, + + /** + * Update the rev metadata for an entry without touching the value, and + * persist immediately. Used by editor routes that don't hold a live + * handle (apps, raw apps) — they read the local draft via `UserDraft.get` + * and the handle is created later inside the child editor. + * + * No-op when the entry has no draft to attach meta to. + */ + saveMeta( + itemKind: UserDraftItemKind, + path: string, + meta: UserDraftMeta, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + const current = untrack(() => entry.state.val as StoredDraft | undefined) + if (current === undefined) return + entry.state.val = wrap(current.value, meta) + } + const existing = readPersisted(localStorageKey(ws, itemKind, path)) + if (existing === undefined) return + persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta) + }, + + /** + * Read the rev metadata for the entry. Returns an empty object if there + * is no entry. Useful for staleness checks before reading the draft. + */ + getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) return extractMeta(entry.state.val as StoredDraft | undefined) + return extractMeta(readPersisted(localStorageKey(ws, itemKind, path))) + }, + + /** + * Whether a draft currently exists for (workspace, itemKind, path). + * Falls back to the persisted localStorage entry when no live handle is + * registered. Useful for distinguishing "first visit" from "returning + * visit with unsaved local changes". + */ + has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) return entry.state.val !== undefined + return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined + }, + + remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + try { + localStorage.removeItem(localStorageKey(ws, itemKind, path)) + } catch (e) { + console.error('UserDraft.remove: localStorage remove failed', e) + } + }, + + /** + * Like `remove`, but also resets any live handle's `draft` to + * `fallback` in-memory (so reactive readers see it immediately) and + * skips re-persisting it, leaving the LS slot empty until the next real + * edit. Pass the deployed baseline as `fallback`. + */ + discard( + itemKind: UserDraftItemKind, + path: string, + fallback: V | undefined, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + // Arm the skip before the cell write so the setter suppresses + // the persist; the removeItem below actually clears the slot. + entry.state.skipNextWriteOnce() + entry.state.val = wrap(fallback) as StoredDraft | undefined + } + try { + localStorage.removeItem(localStorageKey(ws, itemKind, path)) + } catch (e) { + console.error('UserDraft.discard: localStorage remove failed', e) + } + }, + + use( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftUseOptions + ): UserDraftHandle { + // `use()` is a single-spec wrapper around `useMany`. We untrack the + // getter so that reactive opts (e.g. `$workspaceStore`) are captured + // once at call time — the current `use()` contract is "the handle + // stays bound to this workspace until the component unmounts." Use + // `useMany` directly if you want spec changes to release/acquire + // entries as you go. + const handles = UserDraft.useMany(() => + untrack(() => [ + { + itemKind, + path, + workspace: opts?.workspace, + defaultValue: opts?.defaultValue + } + ]) + ) + return handles[0] + }, + + useMany(getSpecs: () => UserDraftSpec[]): UserDraftHandle[] { + // Reactive handles array, reconciled against the latest `getSpecs()` + // output. Indices line up with the spec array. Handles for the same + // (workspace, kind, path) tuple are reused across reconciles so + // callers can capture a reference and keep it alive — only the + // underlying entry's refcount moves. + const handles = $state[]>([]) + const acquired = new Set() + const handleCache = new Map>() + + function reconcile() { + const specs = getSpecs() + const seen = new Set() + const next: UserDraftHandle[] = [] + + for (const spec of specs) { + const ws = spec.workspace ?? resolveWorkspace() + const mk = mapKey(ws, spec.itemKind, spec.path) + seen.add(mk) + + if (!acquired.has(mk)) { + acquireEntry(ws, spec.itemKind, spec.path, spec.defaultValue) + acquired.add(mk) + } + let handle = handleCache.get(mk) + if (!handle) { + handle = makeHandle(ws, spec.itemKind, spec.path) + handleCache.set(mk, handle) + } + next.push(handle) + } + + for (const mk of [...acquired]) { + if (!seen.has(mk)) { + releaseEntry(mk) + acquired.delete(mk) + handleCache.delete(mk) + } + } + + // Skip no-op mutations (handles are cached by mapKey, so an + // unchanged spec set yields reference-equal arrays). `untrack` so + // this effect doesn't subscribe to its own `handles` write — + // otherwise it self-loops (`effect_update_depth_exceeded`). + // Downstream notification still propagates. + untrack(() => { + const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i]) + if (!unchanged) handles.splice(0, handles.length, ...next) + }) + } + + // Synchronous initial reconcile so single-spec callers (`use()`) get a + // populated `handles[0]` before the function returns. Reactive reads + // inside `getSpecs()` here are intentionally not tracked — the + // `$effect` below picks up any subsequent dependency changes. + untrack(reconcile) + $effect(reconcile) + onDestroy(() => { + for (const mk of acquired) releaseEntry(mk) + acquired.clear() + handleCache.clear() + }) + + return handles + } +} + +function acquireEntry( + workspace: string, + itemKind: UserDraftItemKind, + path: string, + defaultValue: unknown +): void { + const mk = mapKey(workspace, itemKind, path) + const existing = entries.get(mk) + if (existing) { + existing.count++ + return + } + // `useLocalStorageValue`'s internal persist `$effect` would otherwise + // parent to `useMany`'s reconcile effect and be torn down on the next + // reconcile. `$effect.root` gives the entry its own scope, disposed only + // by `releaseEntry`. + const useLocalStorageOptions = { + // First value is the baseline (don't persist it); coalesce edits. + saveInitialValue: false, + debounce: 500, + // Stamp `lastWrittenAt` at persist time so deep mutations also bump + // the GC clock (the setter doesn't re-run for those). + transformBeforePersist: stamp + } as const + let stateRef: DraftState | undefined + const destroyRoot = $effect.root(() => { + stateRef = useLocalStorageValue | undefined>( + localStorageKey(workspace, itemKind, path), + wrap(defaultValue), + undefined, + useLocalStorageOptions + ) + }) + if (stateRef) { + entries.set(mk, { count: 1, state: stateRef, destroyRoot }) + return + } + // Fallback for the vitest runtime where `$effect.root`'s callback isn't + // invoked. Unreachable in production (Svelte runs it synchronously). + const state = useLocalStorageValue | undefined>( + localStorageKey(workspace, itemKind, path), + wrap(defaultValue), + undefined, + useLocalStorageOptions + ) + entries.set(mk, { count: 1, state }) +} + +function releaseEntry(mk: string): void { + const entry = entries.get(mk) + if (!entry) return + entry.count-- + if (entry.count <= 0) { + entry.destroyRoot?.() + entries.delete(mk) + } +} + +function makeHandle( + workspace: string, + itemKind: UserDraftItemKind, + path: string +): UserDraftHandle { + // The handle reads `entries.get(mk)` on every access. The entry it points + // at is stable as long as the refcount stays > 0 (which `useMany` keeps + // the case for as long as a spec references it). If the refcount drops to + // 0 and the entry is destroyed, reads return `undefined` rather than + // throwing — the consumer should already have been torn down by that point. + const mk = mapKey(workspace, itemKind, path) + const stateOf = (): DraftState | undefined => entries.get(mk)?.state + return { + get draft(): V | undefined { + return unwrap(stateOf()?.val as StoredDraft | undefined) + }, + set draft(value: V | undefined) { + // Preserve existing rev metadata on a value edit. `untrack` the + // read: callers often set this from inside a `$effect` mirroring + // `$state` into the handle; a tracked read would subscribe that + // effect to the cell it's about to write (self-loop → + // effect_update_depth_exceeded). + const state = stateOf() + if (!state) return + const current = untrack(() => state.val as StoredDraft | undefined) + state.val = wrap(value, extractMeta(current)) + }, + get meta(): UserDraftMeta { + return extractMeta(stateOf()?.val as StoredDraft | undefined) + }, + setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void { + const state = stateOf() + if (!state) return + state.val = wrap(value, meta) + }, + setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void { + // Read under `untrack` for the same reason as `set draft` above — + // avoid making any surrounding effect re-fire on the write below. + const state = stateOf() + if (!state) return + const current = untrack(() => state.val as StoredDraft | undefined) + if (current === undefined) return + state.val = wrap(current.value, meta) + if (opts?.force) { + persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta) + } + } + } +} + +/** + * Default GC retention window: 30 days. Entries that haven't been touched + * (no setter call, no deep-mutation persist) for this long are swept on + * the next `gcUserDrafts` invocation. + */ +export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 + +/** + * Sweep stale UserDraft entries from localStorage. Walks every + * `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes + * any entry older than `maxAgeMs`. + * + * Entries written before `lastWrittenAt` was introduced lack the field; + * we backfill them to `now()` on first sighting so they participate in + * the next sweep cycle rather than getting wiped immediately. + * + * Safe to call on every load and on a timer (e.g. every 30 min) — live + * entries get their stamp refreshed on every persist, so the sweep only + * touches truly stale records. + */ +export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void { + if (typeof localStorage === 'undefined') return + const now = Date.now() + const cutoff = now - maxAgeMs + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null && k.startsWith('userdraft/w/')) keys.push(k) + } + for (const key of keys) { + try { + const raw = localStorage.getItem(key) + if (raw == null) continue + const parsed = JSON.parse(raw) + if (parsed == null || typeof parsed !== 'object') continue + if (typeof parsed.lastWrittenAt !== 'number') { + // Pre-GC-feature entry. Backfill so the next sweep can decide. + parsed.lastWrittenAt = now + localStorage.setItem(key, JSON.stringify(parsed)) + continue + } + if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key) + } catch (e) { + console.error('UserDraft GC: failed to inspect', key, e) + } + } +} + +/** Test-only: clear all in-memory entries. */ +export function __resetUserDraftForTesting(): void { + entries.clear() +} diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts new file mode 100644 index 0000000000..90d25000e9 --- /dev/null +++ b/frontend/src/lib/userDraft.test.ts @@ -0,0 +1,721 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Capture onDestroy callbacks so we can simulate component teardown without +// a real component context. +const onDestroyCallbacks: Array<() => void> = [] + +vi.mock('svelte', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + onDestroy: (fn: () => void) => { + onDestroyCallbacks.push(fn) + } + } +}) + +// Imported AFTER vi.mock so the module sees the mocked onDestroy. +const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } = + await import('./userDraft.svelte') +const { workspaceStore } = await import('./stores') + +function flushDestroyCallbacks(): void { + const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length) + for (const cb of callbacks) cb() +} + +// UserDraft.use debounces localStorage writes by 500 ms via +// useLocalStorageValue. Tests assert localStorage state synchronously after +// writes, so we use fake timers and call this helper to fast-forward past +// the debounce window before each assertion. +function flushPersist(): void { + vi.runAllTimers() +} + +// Helper: localStorage payloads are always wrapped as { value: } so +// future metadata fields can be added without breaking existing entries. +function wrapped(value: V): string { + return JSON.stringify({ value }) +} + +// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so +// assertions can stay focused on value + rev metadata. Real entries always +// carry `lastWrittenAt` once written; the GC tests below assert on it +// directly via `localStorage.getItem`. +function storedShape(key: string): string | null { + const raw = localStorage.getItem(key) + if (raw == null) return null + const parsed = JSON.parse(raw) + delete parsed.lastWrittenAt + return JSON.stringify(parsed) +} + +beforeEach(() => { + __resetUserDraftForTesting() + onDestroyCallbacks.length = 0 + localStorage.clear() + workspaceStore.set('test_ws') + vi.useFakeTimers() +}) + +describe('UserDraft.save / get / remove (no observers)', () => { + it('save writes a wrapped { value } payload under the workspace-scoped key', () => { + UserDraft.save('flow', 'u/me/myflow', { hello: 'world' }) + + expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' })) + }) + + it('get reads from a wrapped localStorage payload when no observer is registered', () => { + localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code')) + + expect(UserDraft.get('script', 'u/me/script1')).toBe('code') + }) + + it('get returns undefined when nothing is stored', () => { + expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined() + }) + + it('get returns undefined when the stored payload is malformed', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json') + expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined() + }) + + it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => { + // Drafts written before the wrapping was introduced look like the raw + // value rather than { value: ... }. They must be ignored rather than + // surface as undefined-shaped drafts. + localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' })) + expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined() + expect(UserDraft.has('flow', 'u/me/raw')).toBe(false) + }) + + it('remove clears the localStorage entry', () => { + UserDraft.save('app', 'u/me/app1', { grid: [] }) + expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull() + + UserDraft.remove('app', 'u/me/app1') + expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull() + }) + + it('uses the workspace from opts when provided', () => { + UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' }) + + expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1)) + // Default workspace key must remain empty. + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull() + }) + + it('supports trigger kinds as item kinds', () => { + UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] }) + + expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe( + wrapped({ brokers: ['localhost:9092'] }) + ) + }) + + it('throws when neither opts.workspace nor $workspaceStore is set', () => { + workspaceStore.set(undefined) + expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/) + }) +}) + +describe('UserDraft.use() — observer sync', () => { + it('loads the existing localStorage value on first use', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded')) + + const handle = UserDraft.use('flow', 'u/me/loaded') + expect(handle.draft).toBe('preloaded') + }) + + it('two handles on the same key share the same underlying state', () => { + const a = UserDraft.use('flow', 'u/me/shared') + const b = UserDraft.use('flow', 'u/me/shared') + + a.draft = 42 + expect(b.draft).toBe(42) + + b.draft = 99 + expect(a.draft).toBe(99) + }) + + it('save() propagates to live use() handles (in-memory)', () => { + const handle = UserDraft.use('flow', 'u/me/observed') + expect(handle.draft).toBeUndefined() + + // First write through a live entry is treated as the "initial value" + // (saveInitialValue=false) and is NOT persisted — observers still see it. + UserDraft.save('flow', 'u/me/observed', 7) + expect(handle.draft).toBe(7) + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull() + + // Subsequent writes persist. + UserDraft.save('flow', 'u/me/observed', 9) + expect(handle.draft).toBe(9) + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) + }) + + it('remove() clears localStorage without touching the in-memory handle', () => { + // Seed localStorage so the live handle initialises from it. + localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1)) + const handle = UserDraft.use('flow', 'u/me/removed') + expect(handle.draft).toBe(1) + + UserDraft.remove('flow', 'u/me/removed') + // Live handle keeps its current value — remove() only wipes the + // persisted side. This is what lets callers run UserDraft.remove + // during navigation without flickering the editor UI. + expect(handle.draft).toBe(1) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull() + }) + + it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => { + // Seed: handle holds a divergent local autosave. + localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit')) + const handle = UserDraft.use('flow', 'u/me/discard') + expect(handle.draft).toBe('local-edit') + + // Reset to a known backend baseline. + UserDraft.discard('flow', 'u/me/discard', 'backend-baseline') + flushPersist() + + // In-memory handle reflects the fallback immediately. + expect(handle.draft).toBe('backend-baseline') + // LS is cleared and stays cleared — the fallback must NOT round-trip + // back into storage (that would make the next reload "restore" the + // fallback as if it were a real autosave). + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull() + }) + + it('discard() with undefined fallback clears both LS and in-memory state', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit')) + const handle = UserDraft.use('flow', 'u/me/wipe') + expect(handle.draft).toBe('local-edit') + + UserDraft.discard('flow', 'u/me/wipe', undefined) + flushPersist() + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull() + }) + + it('the second write through the handle setter persists to localStorage', () => { + const handle = UserDraft.use('flow', 'u/me/setter') + + // First write is the baseline — not persisted. + handle.draft = 'initial' + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull() + + // Second (and onwards) persists. + handle.draft = 'persisted' + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted')) + }) + + it('setting handle.draft = undefined after edits removes the localStorage entry', () => { + const handle = UserDraft.use('flow', 'u/me/clear') + handle.draft = 'initial' // baseline, not persisted + handle.draft = 'edited' // persisted + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull() + + handle.draft = undefined + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull() + expect(handle.draft).toBeUndefined() + }) + + it('two handles in different workspaces are isolated', () => { + const a = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_a' }) + const b = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_b' }) + + a.draft = 1 + b.draft = 2 + + expect(a.draft).toBe(1) + expect(b.draft).toBe(2) + }) + + it('save() falls back to localStorage when no handle is registered', () => { + UserDraft.save('flow', 'u/me/noobs', 'fallback') + // First use() afterwards loads the persisted value. + const handle = UserDraft.use('flow', 'u/me/noobs') + expect(handle.draft).toBe('fallback') + }) +}) + +describe('UserDraft.use() — defaultValue', () => { + it('returns defaultValue when localStorage has no entry', () => { + const handle = UserDraft.use('flow', 'u/me/withdefault', { defaultValue: 'fallback' }) + + expect(handle.draft).toBe('fallback') + }) + + it('does not persist the defaultValue on first read', () => { + UserDraft.use('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' }) + + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull() + }) + + it('localStorage value wins over defaultValue', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted')) + + const handle = UserDraft.use('flow', 'u/me/overridden', { + defaultValue: 'fallback' + }) + + expect(handle.draft).toBe('persisted') + }) + + it('second write through the setter persists even though defaultValue was set', () => { + const handle = UserDraft.use('flow', 'u/me/writeDefault', { + defaultValue: 'fallback' + }) + + // First write is the initial-value baseline. + handle.draft = 'initial' + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull() + + handle.draft = 'modified' + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified')) + }) +}) + +describe('UserDraft — empty path (new-item drafts persist across reloads)', () => { + it('use() with empty path persists subsequent edits to localStorage', () => { + const handle = UserDraft.use('flow', '', { defaultValue: 0 }) + + // First write under saveInitialValue=false counts as the baseline and + // is skipped — only the user's subsequent edits persist. + handle.draft = 99 + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() + handle.draft = 100 + flushPersist() + // The "+ Flow / + Script / …" buttons are expected to call + // `UserDraft.remove(kind, '')` to wipe before navigating; an + // unguarded /add reload therefore restores the previous session. + expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100)) + }) + + it('two handles with empty path share state per workspace', () => { + const a = UserDraft.use('flow', '') + const b = UserDraft.use('flow', '') + + a.draft = 1 + expect(b.draft).toBe(1) + + b.draft = 2 + expect(a.draft).toBe(2) + }) + + it('save() with empty path writes to localStorage when no handle is live', () => { + UserDraft.save('flow', '', 5) + expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5)) + }) + + it('get() with empty path falls back to localStorage when no handle is live', () => { + localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11)) + expect(UserDraft.get('flow', '')).toBe(11) + }) + + it('remove() with empty path clears localStorage', () => { + localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1)) + UserDraft.remove('flow', '') + expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() + }) +}) + +describe('UserDraft — rev metadata for staleness checks', () => { + it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => { + const handle = UserDraft.use('flow', 'u/me/atomic') + + // Single atomic write — under saveInitialValue=false this counts as the + // initial baseline and shouldn't hit localStorage yet. + handle.setDraftAndMeta('backendValue', { + remoteRev: 42, + remoteDraftRev: '2026-01-01T00:00:00Z' + }) + expect(handle.draft).toBe('backendValue') + expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' }) + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull() + + // A subsequent user edit persists *with* the rev metadata. + handle.draft = 'userEdit' + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe( + JSON.stringify({ + value: 'userEdit', + remoteRev: 42, + remoteDraftRev: '2026-01-01T00:00:00Z' + }) + ) + }) + + it('setMeta updates only the rev fields, preserving the value', () => { + const handle = UserDraft.use('flow', 'u/me/setmeta') + handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted + handle.draft = 'edited' // persisted with remoteRev: 1 + + handle.setMeta({ remoteRev: 2 }) + expect(handle.draft).toBe('edited') + expect(handle.meta).toEqual({ remoteRev: 2 }) + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( + JSON.stringify({ value: 'edited', remoteRev: 2 }) + ) + }) + + it('handle.draft setter preserves rev metadata across user edits', () => { + const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve') + handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' }) + handle.draft = { count: 1 } // first edit, persisted + handle.draft = { count: 2 } // another edit + + expect(handle.meta).toEqual({ remoteRev: 'v1' }) + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe( + JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' }) + ) + }) + + it('UserDraft.getMeta reads from localStorage when no live handle exists', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/getmeta', + JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' }) + ) + expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({ + remoteRev: 7, + remoteDraftRev: '2026-01-02' + }) + }) + + it('UserDraft.getMeta returns empty object when there is no entry', () => { + expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({}) + }) + + it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/savepreserve', + JSON.stringify({ value: 'old', remoteRev: 5 }) + ) + UserDraft.save('flow', 'u/me/savepreserve', 'new') + + expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( + JSON.stringify({ value: 'new', remoteRev: 5 }) + ) + }) + + it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/legacy', + JSON.stringify({ value: 'no-rev' }) + ) + const handle = UserDraft.use('flow', 'u/me/legacy') + expect(handle.draft).toBe('no-rev') + expect(handle.meta).toEqual({}) + }) + + it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/forceack', + JSON.stringify({ value: 'edited', remoteRev: 'v1' }) + ) + const handle = UserDraft.use('flow', 'u/me/forceack') + + // Without force, this is the entry's first state mutation and gets + // swallowed by saveInitialValue=false — localStorage would still + // hold the old remoteRev. + handle.setMeta({ remoteRev: 'v2' }, { force: true }) + + expect(handle.meta).toEqual({ remoteRev: 'v2' }) + expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe( + JSON.stringify({ value: 'edited', remoteRev: 'v2' }) + ) + }) +}) + +describe('checkStaleness', () => { + let checkStaleness: ( + meta: { remoteRev?: string | number; remoteDraftRev?: string | number }, + currentRev: string | number | undefined, + currentDraftRev?: string | number | undefined + ) => 'draft' | 'version' | null + + beforeEach(async () => { + // Re-import to dodge ESM caching surprises across test files. + ;({ checkStaleness } = await import('./userDraft.svelte')) + }) + + it('returns null for legacy entries with no recorded rev', () => { + expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull() + }) + + it('returns null when meta matches current revs exactly', () => { + expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull() + expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull() + }) + + it('returns "draft" when a newer DB draft was pushed on the remote', () => { + expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft') + }) + + it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => { + expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft') + }) + + it('returns "version" when the deployed rev moved and draft revs match', () => { + expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version') + }) + + it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => { + expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe( + 'version' + ) + }) + + it('prefers "draft" over "version" when both have changed', () => { + expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft') + }) +}) + +describe('UserDraft.use() — reference counting & cleanup', () => { + it('destroys the entry when the last handle is released', () => { + // First handle acquires the entry. + const a = UserDraft.use('flow', 'u/me/ref') + a.draft = 1 // baseline write — not persisted + + // Second handle increments the count. + const b = UserDraft.use('flow', 'u/me/ref') + expect(b.draft).toBe(1) + + // onDestroy for both handles got registered. + expect(onDestroyCallbacks.length).toBe(2) + + // Releasing one handle keeps the entry alive — save() still updates handle a. + const firstCb = onDestroyCallbacks.shift()! + firstCb() + + UserDraft.save('flow', 'u/me/ref', 2) + expect(a.draft).toBe(2) + // Now persisted (second write after the baseline). + flushPersist() + expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2)) + + // Releasing the second handle drops the entry; subsequent save() + // must go straight to localStorage rather than mutating in-memory + // state (which no longer exists). + const secondCb = onDestroyCallbacks.shift()! + secondCb() + + UserDraft.save('flow', 'u/me/ref', 3) + // UserDraft.save without a live entry writes synchronously. + expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3)) + }) + + it('a fresh use() after cleanup re-reads the latest persisted value', () => { + const a = UserDraft.use('flow', 'u/me/cycle') + a.draft = 'initial' // baseline — not persisted + a.draft = 'edited' // persisted (after debounce) + flushPersist() + flushDestroyCallbacks() + + // After all handles release, a brand-new use() must pick up the + // value persisted to localStorage from the previous round. + const b = UserDraft.use('flow', 'u/me/cycle') + expect(b.draft).toBe('edited') + }) + + it('coalesces a typing storm into a single localStorage write per 500 ms window', () => { + const handle = UserDraft.use('flow', 'u/me/debounce') + handle.draft = 'baseline' // first write — skipped under saveInitialValue=false + + // Three quick edits inside the 500 ms window: in-memory updates every + // time, but localStorage stays untouched until the timer fires. + handle.draft = 'one' + vi.advanceTimersByTime(100) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() + handle.draft = 'two' + vi.advanceTimersByTime(100) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() + handle.draft = 'three' + expect(handle.draft).toBe('three') + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() + + // After the window elapses, only the latest value lands. + vi.advanceTimersByTime(500) + expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three')) + }) +}) + +describe('UserDraft.useMany()', () => { + it('acquires one handle per spec in the synchronous initial reconcile', () => { + // `useMany`'s sync reconcile populates handles[0..] before returning, + // so callers (and `use()`'s 1-len wrapper) can use them immediately + // without waiting for an `$effect` tick. + const handles = UserDraft.useMany(() => [ + { itemKind: 'flow', path: 'u/me/many', workspace: 'a' }, + { itemKind: 'flow', path: 'u/me/many', workspace: 'b' } + ]) + expect(handles.length).toBe(2) + + // Each spec gets its own entry in the workspace-keyed store. + handles[0].draft = 0 // baseline + handles[0].draft = 1 // persisted + handles[1].draft = 0 + handles[1].draft = 9 + flushPersist() + expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1)) + expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9)) + + // One component-level onDestroy releases every acquired entry. + expect(onDestroyCallbacks.length).toBe(1) + }) +}) + +describe('gcUserDrafts', () => { + let gcUserDrafts: (maxAgeMs?: number) => void + let USER_DRAFT_GC_MAX_AGE_MS: number + const DAY = 24 * 60 * 60 * 1000 + + beforeEach(async () => { + ;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte')) + }) + + it('sweeps entries whose lastWrittenAt is older than the cutoff', () => { + vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) + const old = Date.now() - 31 * DAY + const fresh = Date.now() - 1 * DAY + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/old', + JSON.stringify({ value: 1, lastWrittenAt: old }) + ) + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/fresh', + JSON.stringify({ value: 2, lastWrittenAt: fresh }) + ) + // Unrelated keys are left alone. + localStorage.setItem('some_other_key', 'unrelated') + + gcUserDrafts() + + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull() + expect(localStorage.getItem('some_other_key')).toBe('unrelated') + }) + + it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => { + // Pre-GC-feature entry (legacy migration output, or just an old entry + // from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC + // pass should stamp it as "now" rather than wipe it on sight. + localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' })) + vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) + + gcUserDrafts() + + const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy') + expect(raw).not.toBeNull() + const parsed = JSON.parse(raw!) + expect(parsed.lastWrittenAt).toBe(Date.now()) + expect(parsed.value).toBe('data') + }) + + it('exposes a 30-day default retention window', () => { + expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000) + }) + + it('respects a custom maxAgeMs', () => { + vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/two_hours_ago', + JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 }) + ) + + gcUserDrafts(60 * 60 * 1000) // 1h cutoff + + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull() + }) +}) + +describe('normalizeForCompare', () => { + it('returns undefined for undefined input', () => { + expect(normalizeForCompare(undefined)).toBeUndefined() + }) + + it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => { + const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } }) + expect(out).toEqual({ a: 1, c: { e: 2 } }) + expect(Object.keys(out as object)).not.toContain('b') + expect(Object.keys((out as any).c)).not.toContain('d') + }) + + it('falls back to the original value when not serializable (cyclic)', () => { + const cyclic: any = { a: 1 } + cyclic.self = cyclic + expect(normalizeForCompare(cyclic)).toBe(cyclic) + }) +}) + +describe('localDraftDiffers', () => { + it('returns false when there is no local draft', () => { + expect(localDraftDiffers(undefined, { a: 1 })).toBe(false) + expect(localDraftDiffers(null, { a: 1 })).toBe(false) + }) + + it('treats a draft that round-trips equal to the config as NOT differing', () => { + // The Schedule bug: getXCfg() emits conditionally-undefined keys, but + // the persisted draft went through JSON.stringify which dropped them. + const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule } + expect(localDraftDiffers(persisted, freshCfg)).toBe(false) + }) + + it('returns true for a genuine difference', () => { + expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true) + expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true) + }) +}) + +describe('UserDraft.saveIfChanged', () => { + const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s' + + it('does not persist a draft equal to the deployed baseline', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + // value is the post-load reactive cfg — same shape, undefined keys present + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('treats a value that round-trips equal to deployed as unchanged', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } + const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('persists when the value differs from the deployed baseline', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } + const value = { path: 'u/me/s', schedule: '5 0 * * *' } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) + expect(storedShape(KEY)).toBe(wrapped(value)) + }) + + it('removes a pre-existing draft once the value reverts to deployed', () => { + const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } + UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' }) + expect(localStorage.getItem(KEY)).not.toBeNull() + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) + expect(localStorage.getItem(KEY)).toBeNull() + }) + + it('persists when there is no deployed baseline (undefined)', () => { + const value = { path: 'u/me/s', schedule: '0 0 * * *' } + UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined) + expect(storedShape(KEY)).toBe(wrapped(value)) + }) +}) diff --git a/frontend/src/lib/userDraftLegacyMigration.test.ts b/frontend/src/lib/userDraftLegacyMigration.test.ts new file mode 100644 index 0000000000..7069929897 --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + migrateLegacyUserDrafts, + __resetUserDraftLegacyMigrationForTesting +} from './userDraftLegacyMigration' + +function encodeLegacy(value: unknown): string { + return btoa(encodeURIComponent(JSON.stringify(value))) +} + +function wrapped(value: V): string { + return JSON.stringify({ value }) +} + +// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions +// can match the `{ value }` shape regardless of when the migration ran. +function storedShape(key: string): string | null { + const raw = localStorage.getItem(key) + if (raw == null) return null + const parsed = JSON.parse(raw) + delete parsed.lastWrittenAt + return JSON.stringify(parsed) +} + +beforeEach(() => { + localStorage.clear() + __resetUserDraftLegacyMigrationForTesting() +}) + +describe('migrateLegacyUserDrafts', () => { + it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => { + // Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`, + // i.e. the inner App value, not the wrapping AppWithLastVersion. + const legacyApp = { + grid: [], + fullscreen: false, + theme: undefined, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dashboard')).toBeNull() + expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp)) + }) + + it('migrates a legacy empty-path app draft (the `app` literal key)', () => { + const legacyApp = { + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + localStorage.setItem('app', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app')).toBeNull() + expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp)) + }) + + it('migrates a legacy flow draft and strips the view-state envelope', () => { + const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' } + const legacyBundle = { + flow, + path: 'u/me/myflow', + selectedId: 'settings', + draft_triggers: [{ id: 't1' }], + selected_trigger: null, + loadedFromHistory: undefined + } + localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('flow-u/me/myflow')).toBeNull() + // Only the inner Flow survives; the view-state envelope is dropped. + expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow)) + }) + + it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => { + const legacy = { + files: { 'index.tsx': 'export default () => null' }, + runnables: {}, + data: { tables: [] } + } + localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('rawapp-u/me/site')).toBeNull() + expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe( + wrapped({ ...legacy, summary: '' }) + ) + }) + + it('preserves an existing new-format entry instead of overwriting it', () => { + // Old and new both exist for the same item — the new one is presumed + // fresher. + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + const existingNew = wrapped({ value: 'new' }) + localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dash')).toBeNull() + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew) + }) + + it('is idempotent — the second invocation is a no-op', () => { + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + migrateLegacyUserDrafts('main') + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull() + + // Drop the migrated entry to detect any re-migration attempt. + localStorage.removeItem('userdraft/w/main/app/u/me/dash') + // Drop the source too, so re-running couldn't even find a source. + // (The sentinel alone should be enough; this just clarifies the intent.) + migrateLegacyUserDrafts('main') + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull() + }) + + it('skips entirely when no workspace is available', () => { + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + migrateLegacyUserDrafts('') + + expect(localStorage.getItem('app-u/me/dash')).not.toBeNull() + }) + + it('handles malformed legacy payloads without throwing', () => { + localStorage.setItem('app-u/me/garbled', 'not-base64!!!') + expect(() => migrateLegacyUserDrafts('main')).not.toThrow() + // Migration didn't migrate, didn't crash — leaves the entry alone. + expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!') + }) + + it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => { + // A future feature or neighbouring code might pick a key like + // `app-recent` for its own purposes. The path doesn't look like a + // Windmill item path, so the migration must skip it. + localStorage.setItem('app-recent', 'whatever') + localStorage.setItem('app-some_other_app', 'whatever') + // `flow-u/me/foo` matches the shape and would be migrated, but the + // payload also needs to look like a Windmill draft (asserted below). + localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } })) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-recent')).toBe('whatever') + expect(localStorage.getItem('app-some_other_app')).toBe('whatever') + expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull() + }) + + it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => { + // `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON, + // but none of the App-shape fields (grid/fullscreen/theme/ + // unusedInlineScripts/hiddenInlineScripts) are present. Treat it as + // unrelated and leave it untouched. + const unrelated = encodeLegacy({ random: 'data', count: 7 }) + localStorage.setItem('app-u/me/dash', unrelated) + const unrelatedFlow = encodeLegacy({ stepsState: {} }) + localStorage.setItem('flow-u/me/bar', unrelatedFlow) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated) + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull() + expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow) + expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull() + }) + + it('migrates multiple legacy entries in a single invocation', () => { + localStorage.setItem( + 'app-u/me/a', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + localStorage.setItem( + 'flow-u/me/b', + encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } }) + ) + localStorage.setItem( + 'rawapp-u/me/c', + encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } }) + ) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull() + expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull() + expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull() + }) +}) diff --git a/frontend/src/lib/userDraftLegacyMigration.ts b/frontend/src/lib/userDraftLegacyMigration.ts new file mode 100644 index 0000000000..d4ee04ee61 --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.ts @@ -0,0 +1,192 @@ +/** + * One-off migration from the pre-UserDraft localStorage autosave entries to + * the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format. + * + * Legacy keys (global, not workspace-scoped — assumed to belong to the user's + * current workspace at migration time): + * + * `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })` + * `app` / `app-{path}` base64 of `encodeState(App)` + * `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })` + * + * Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing + * `JSON.stringify({ value: })`. + * + * Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so + * subsequent invocations are no-ops. Existing new-format entries are never + * overwritten — when both an old and a new entry exist for the same item, the + * old one is simply dropped on the assumption that the new entry is the more + * recent edit. + * + * This file is intentionally standalone — it does not import from + * `userDraft.svelte.ts` so the new code stays uncluttered by the legacy + * decoders. + */ + +const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1' + +type LegacyKind = 'flow' | 'app' | 'raw_app' + +const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [ + // `rawapp` is listed before `app` even though our matcher uses exact / + // dash-separated comparison (so there's no ambiguity); it documents the + // intent that raw apps are a distinct kind, not a sub-case of apps. + { prefix: 'rawapp', newKind: 'raw_app' }, + { prefix: 'flow', newKind: 'flow' }, + { prefix: 'app', newKind: 'app' } +] + +/** + * A Windmill item path: `u//` or `f//`. The + * `` segment may itself contain slashes, so we don't constrain it + * past requiring at least one character. Used to reject incidentally-named + * localStorage keys (e.g. `app-recent` from a future feature, or a + * neighbouring app's data) before treating them as Windmill drafts. + */ +const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/ + +function matchLegacyKey( + key: string +): { prefix: string; newKind: LegacyKind; path: string } | undefined { + for (const { prefix, newKind } of LEGACY_PREFIXES) { + if (key === prefix) return { prefix, newKind, path: '' } + if (key.startsWith(prefix + '-')) { + const path = key.slice(prefix.length + 1) + if (!LEGACY_PATH_SHAPE.test(path)) return undefined + return { prefix, newKind, path } + } + } + return undefined +} + +function decodeLegacyState(raw: string): unknown { + try { + return JSON.parse(decodeURIComponent(atob(raw))) + } catch { + return undefined + } +} + +/** + * Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are + * unusual enough that nothing else in the codebase has used them, but + * matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a + * Windmill draft (any base64-of-JSON could pass). Promoting a stray payload + * would silently surface as a phantom "Restored from local storage" toast + * on the next edit, so we reject anything that doesn't carry the fields the + * legacy writers actually produced. + */ +function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean { + if (decoded == null || typeof decoded !== 'object') return false + const obj = decoded as Record + switch (kind) { + case 'flow': + // Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }. + return obj.flow != null && typeof obj.flow === 'object' + case 'app': + // Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App + // value (see `frontend/src/lib/components/apps/types.ts`) — NOT the + // wrapping AppWithLastVersion. It carries `grid`, `fullscreen`, + // `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other + // fields — any one of those is a strong signal it's actually a + // Windmill app payload. + return ( + 'grid' in obj || + 'fullscreen' in obj || + 'theme' in obj || + 'unusedInlineScripts' in obj || + 'hiddenInlineScripts' in obj + ) + case 'raw_app': + // Legacy RawAppEditor wrote { files, runnables, data }. + return 'files' in obj || 'runnables' in obj || 'data' in obj + } +} + +function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown { + const obj = decoded as Record + switch (kind) { + case 'flow': + // The legacy bundle wrapped the Flow alongside view-state fields + // (selectedId, draft_triggers, ...). The new entry stores only the + // Flow — the view-state lives elsewhere or is re-derived. + return obj.flow + case 'app': + // Legacy stored the App directly. + return obj + case 'raw_app': + // Legacy bundle missed the `summary` field that the new editor adds. + return { + files: obj.files ?? {}, + runnables: obj.runnables ?? {}, + data: obj.data ?? {}, + summary: typeof obj.summary === 'string' ? obj.summary : '' + } + } +} + +function newKey(workspace: string, kind: LegacyKind, path: string): string { + return `userdraft/w/${workspace}/${kind}/${path}` +} + +function listLocalStorageKeys(): string[] { + const out: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null) out.push(k) + } + return out +} + +/** + * Run the legacy → new-format migration. Idempotent: returns immediately if a + * previous run completed (signalled by `MIGRATION_FLAG`). + * + * The migration is workspace-scoped because the legacy keys had no notion of + * workspace — we treat the caller's current workspace as the owner of any + * surviving legacy entries. + */ +export function migrateLegacyUserDrafts(workspace: string): void { + if (typeof localStorage === 'undefined') return + if (!workspace) return + if (localStorage.getItem(MIGRATION_FLAG) !== null) return + + try { + for (const key of listLocalStorageKeys()) { + const match = matchLegacyKey(key) + if (!match) continue + const raw = localStorage.getItem(key) + if (raw == null) continue + + try { + const decoded = decodeLegacyState(raw) + if (!isPlausibleLegacyValue(match.newKind, decoded)) continue + const value = transformLegacyValue(match.newKind, decoded) + const target = newKey(workspace, match.newKind, match.path) + if (value !== undefined && localStorage.getItem(target) == null) { + // `lastWrittenAt` makes the migrated entry visible to + // `gcUserDrafts`. We stamp it as "now" so a freshly-migrated + // autosave gets the full retention window — sweeping it + // immediately on the first GC pass would lose work the + // legacy migration just rescued. + localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() })) + } + localStorage.removeItem(key) + } catch (e) { + console.error('UserDraft legacy migration: failed to migrate', key, e) + } + } + localStorage.setItem(MIGRATION_FLAG, new Date().toISOString()) + } catch (e) { + console.error('UserDraft legacy migration: aborted', e) + } +} + +/** Test-only: clear the sentinel so the migration can re-run. */ +export function __resetUserDraftLegacyMigrationForTesting(): void { + try { + localStorage.removeItem(MIGRATION_FLAG) + } catch { + // ignore + } +} diff --git a/frontend/src/lib/userDraftToast.ts b/frontend/src/lib/userDraftToast.ts new file mode 100644 index 0000000000..7e3e48d211 --- /dev/null +++ b/frontend/src/lib/userDraftToast.ts @@ -0,0 +1,31 @@ +/** + * "Restored from local storage" toast, shown when an editor reopens on a + * local autosave that differs from the backend. Owns only the wording and + * which reset actions are offered; the reset side-effects live at each call + * site (route-specific state). + */ +import { sendUserToast } from '$lib/toast' + +export type RestoreFromLocalActions = { + /** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */ + onResetToSavedDraft?: () => void | Promise + /** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */ + onResetToDeployed?: () => void | Promise +} + +/** Show the toast with up to two reset actions, gated by what the backend has. */ +export function notifyRestoredFromLocal( + hasBackendDraft: boolean, + hasDeployed: boolean, + { onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions +): void { + const actions: Array<{ label: string; callback: () => void | Promise }> = [] + if (hasBackendDraft && onResetToSavedDraft) { + actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft }) + } + if (hasDeployed && onResetToDeployed) { + actions.push({ label: 'Reset to deployed', callback: onResetToDeployed }) + } + if (actions.length === 0) return + sendUserToast('Restored from local storage', false, actions) +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 4b044508d3..c6fca60e37 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -58,6 +58,8 @@ import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' + import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration' + import { gcUserDrafts } from '$lib/userDraft.svelte' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -364,8 +366,8 @@ async function loadCriticalAlertsMuted() { let g_muted = true const ws_muted = - (await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts || - false + (await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })) + .mute_critical_alerts || false if ($superadmin) { g_muted = (await SettingService.getGlobal({ @@ -418,6 +420,19 @@ $effect(() => { $workspaceStore && untrack(() => onLoad()) }) + $effect(() => { + if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!)) + }) + // Sweep UserDraft entries that haven't been touched in 30 days. Runs + // once on mount and on a 30-min timer so a single very long session + // also clears out stale autosaves over time. Live entries stamp + // `lastWrittenAt` on every persist, so the sweep only touches truly + // dormant records. + $effect(() => { + gcUserDrafts() + const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000) + return () => clearInterval(interval) + }) $effect(() => { innerWidth && untrack(() => changeCollapsed()) }) diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index a7da73e596..ae0e5ebfb1 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -4,10 +4,9 @@ import AppEditor from '$lib/components/apps/editor/AppEditor.svelte' import { AppService, type Policy } from '$lib/gen' import { page } from '$app/state' - import { decodeState } from '$lib/utils' import { userStore, workspaceStore } from '$lib/stores' import type { App } from '$lib/components/apps/types' - import { afterNavigate, replaceState } from '$app/navigation' + import { replaceState } from '$app/navigation' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { goto } from '$lib/navigation' @@ -15,8 +14,19 @@ import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils' import { emptyApp } from '$lib/components/apps/editor/appUtils' import { tick } from 'svelte' + import { UserDraft } from '$lib/userDraft.svelte' - let nodraft = page.url.searchParams.get('nodraft') + // "+ App" buttons navigate with ?nodraft=true to signal "start fresh". + // Wipe the persisted empty-path autosave and strip the flag from the URL + // synchronously so a reload doesn't wipe the freshly-started draft. A + // plain reload of /apps/add (no nodraft) instead restores the previous + // session via the child AppEditor's `UserDraft.use`. + if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') { + UserDraft.remove('app', '') + const url = new URL(window.location.href) + url.searchParams.delete('nodraft') + window.history.replaceState(window.history.state, '', url.toString()) + } let appEditor: AppEditor | undefined = $state(undefined) const hubId = page.url.searchParams.get('hub') const templatePath = page.url.searchParams.get('template') @@ -27,8 +37,6 @@ $importStore = undefined } - const appState = nodraft ? undefined : localStorage.getItem('app') - let summary = $state('') let value: App = $state({ grid: [], @@ -40,13 +48,6 @@ path: DEFAULT_THEME } }) - afterNavigate(() => { - if (nodraft) { - let url = new URL(page.url.href) - url.search = '' - replaceState(url.toString(), page.state) - } - }) let policy: Policy = $state({ on_behalf_of: $userStore?.username.includes('@') ? $userStore?.username @@ -59,6 +60,10 @@ async function loadApp() { if (importRaw) { + // Import/template/hub loads are an explicit "start fresh from this + // content" — drop any previous empty-path autosave so it doesn't + // shadow the imported value on AppEditor mount. + UserDraft.remove('app', '') sendUserToast('Loaded from YAML/JSON') if ('value' in importRaw) { summary = importRaw.summary @@ -68,6 +73,7 @@ value = importRaw } } else if (templatePath) { + UserDraft.remove('app', '') const template = await AppService.getAppByPath({ workspace: $workspaceStore!, path: templatePath @@ -76,6 +82,7 @@ sendUserToast('App loaded from template') goto('?', { replaceState: true }) } else if (templateId) { + UserDraft.remove('app', '') const template = await AppService.getAppByVersion({ workspace: $workspaceStore!, id: parseInt(templateId) @@ -84,6 +91,7 @@ sendUserToast('App loaded from template') goto('?', { replaceState: true }) } else if (hubId) { + UserDraft.remove('app', '') const hub = await AppService.getHubAppById({ id: Number(hubId) }) value = { hiddenInlineScripts: [], @@ -94,22 +102,6 @@ summary = hub.app.summary sendUserToast('App loaded from Hub') goto('?', { replaceState: true }) - } else if (!templatePath && !hubId && appState) { - sendUserToast('App restored from browser stored autosave', false, [ - { - label: 'Start from blank', - callback: () => { - value = { - grid: [], - fullscreen: false, - unusedInlineScripts: [], - hiddenInlineScripts: [], - theme: undefined - } - } - } - ]) - value = decodeState(appState) } else { value = emptyApp() } @@ -121,7 +113,7 @@ await tick() let attempts = 0 while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) { - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) attempts++ } appEditor?.triggerTutorial() diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index d22875f9b6..e2e1029d0e 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -7,16 +7,19 @@ DraftService } from '$lib/gen' import { workspaceStore } from '$lib/stores' - import { cleanValueProperties, decodeState, type Value } from '$lib/utils' - import { afterNavigate, replaceState } from '$app/navigation' + import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils' + import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' - import { sendUserToast, type ToastAction } from '$lib/toast' + import { sendUserToast } from '$lib/toast' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { App } from '$lib/components/apps/types' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' + import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { untrack } from 'svelte' import { page } from '$app/state' + import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte' + import { notifyRestoredFromLocal } from '$lib/userDraftToast' let app = $state( undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined @@ -33,19 +36,60 @@ } | undefined = $state(undefined) let redraw = $state(0) + let path = page.params.path ?? '' - let nodraft = page.url.searchParams.get('nodraft') + // Local-draft staleness modal: opened when the remote has moved on since + // the local autosave was written. + let staleModalOpen = $state(false) + let staleModalCause = $state<'draft' | 'version'>('version') + let pendingBaseline: + | { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta } + | undefined = undefined - afterNavigate(() => { - if (nodraft) { - let url = new URL(page.url.href) - url.search = '' - replaceState(url.toString(), page.state) + // Backend revs at the most recent `loadApp` — handed to AppEditor as + // `initialRevs` so the very first local autosave persists with a meta + // stamp. Without it the next reload's staleness check has nothing to + // compare against and the first external deploy/draft slips through. + let currentRevs = $state(undefined) + + function onStaleLoadLatest(): void { + if (!pendingBaseline) { + staleModalOpen = false + return } - }) - const initialState = nodraft ? undefined : localStorage.getItem(`app-${page.params.path}`) - let stateLoadedFromLocalStorage = - initialState != undefined ? decodeState(initialState) : undefined + // `discard` (not `remove`) so the entry's in-memory state.val is + // cleared synchronously. `redraw++` remounts AppEditor on the next + // microtask, but Svelte may mount the new instance before the old + // one's onDestroy releases its handle — the new instance would + // then re-acquire the SAME entry whose state.val still has the + // stale autosave, ignoring the just-emptied LS. Same reason every + // "reset" path below uses discard. + UserDraft.discard('app', path, undefined) + currentRevs = pendingBaseline.revs + app = pendingBaseline.baseline + pendingBaseline = undefined + staleModalOpen = false + redraw++ + } + + function onStaleKeepDraft(): void { + if (pendingBaseline) { + UserDraft.saveMeta('app', path, pendingBaseline.revs) + } + pendingBaseline = undefined + staleModalOpen = false + } + + // `?nodraft=true` is the callers' way of saying "skip the local autosave + // on this load." Wipe the UserDraft entry and strip the flag from the + // URL synchronously, before any descendant reads it. A plain reload + // (no nodraft) restores normally. + if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') { + UserDraft.remove('app', path) + const url = new URL(window.location.href) + url.searchParams.delete('nodraft') + window.history.replaceState(window.history.state, '', url.toString()) + } /** Increments per `loadApp` call. Stale loads (e.g. when picker * navigation races a draft-discard reload) bail at the next checkpoint @@ -80,101 +124,116 @@ custom_path: app_w_draft_.custom_path } - if (stateLoadedFromLocalStorage) { - const reloadAction = async () => { - stateLoadedFromLocalStorage = undefined - await loadApp() + // Resolve the app value: backend draft > deployed, then overlay any + // local autosave from UserDraft if present. + const backendApp = app_w_draft.draft + ? app_w_draft.summary !== undefined + ? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & { + draft_only?: boolean + value: any + }) + : ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & { + draft_only?: boolean + value: any + }) + : app_w_draft + + const localDraftValue = UserDraft.get('app', path) + const previousMeta = UserDraft.getMeta('app', path) + const newRevs: UserDraftMeta = { + remoteRev: app_w_draft.versions + ? app_w_draft.versions[app_w_draft.versions.length - 1] + : undefined, + remoteDraftRev: app_w_draft.draft_created_at + } + currentRevs = newRevs + if ( + localDraftValue != undefined && + orderedJsonStringify(cleanValueProperties(localDraftValue)) !== + orderedJsonStringify(cleanValueProperties(backendApp.value)) + ) { + const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev) + if (cause) { + pendingBaseline = { baseline: backendApp, revs: newRevs } + staleModalCause = cause + staleModalOpen = true + } else { + if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { + // Legacy entry — backfill meta so the next load can detect staleness. + UserDraft.saveMeta('app', path, newRevs) + } + const appPath = backendApp.path + const hasBackendDraft = app_w_draft.draft != undefined + notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, { + onResetToSavedDraft: () => { + UserDraft.discard('app', path, undefined) + currentRevs = newRevs + app = backendApp + redraw++ + }, + onResetToDeployed: async () => { + if (hasBackendDraft) { + await DraftService.deleteDraft({ + workspace: $workspaceStore!, + kind: 'app', + path: appPath + }) + } + UserDraft.discard('app', path, undefined) + goto(`/apps/edit/${appPath}`) + await loadApp() + redraw++ + } + }) + } + app = { ...backendApp, value: localDraftValue } + } else { + // Local is missing or matches backend — wipe any stale entry so it + // doesn't haunt the next session and use the backend value. + if (localDraftValue != undefined) UserDraft.remove('app', path) + app = backendApp + } + + if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) { + const reloadAction = () => { + app = app_w_draft redraw++ } - const actions: ToastAction[] = [] - if (stateLoadedFromLocalStorage) { - actions.push({ - label: 'Discard browser autosave and reload', - callback: reloadAction - }) - const draftOrDeployed = cleanValueProperties(savedApp?.draft || savedApp) - const urlScript = { - ...draftOrDeployed, - value: stateLoadedFromLocalStorage - } - actions.push({ + const deployed = cleanValueProperties(app_w_draft as Value) + const draft = cleanValueProperties(app ?? {}) + sendUserToast('app loaded from latest saved draft', false, [ + { + label: 'Reset to deployed', + callback: reloadAction + }, + { label: 'Show diff', callback: async () => { diffDrawer?.openDrawer() diffDrawer?.setDiff({ mode: 'simple', - original: draftOrDeployed, - current: urlScript, - title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, - button: { text: 'Discard autosave', onClick: reloadAction } + original: deployed, + current: draft, + title: 'Deployed <> Draft', + button: { text: 'Discard draft', onClick: reloadAction } }) } - }) - } - - sendUserToast('App restored from browser storage', false, actions) - app_w_draft.value = stateLoadedFromLocalStorage - app = app_w_draft - } else if (app_w_draft.draft) { - if (app_w_draft.summary !== undefined) { - // backward compatibility for old drafts missing metadata - app = { - ...app_w_draft, - ...app_w_draft.draft } - } else { - app = { - ...app_w_draft, - value: app_w_draft.draft as any - } - } - - if (!app_w_draft.draft_only) { - const reloadAction = () => { - stateLoadedFromLocalStorage = undefined - app = app_w_draft - redraw++ - } - - const deployed = cleanValueProperties(app_w_draft as Value) - const draft = cleanValueProperties(app ?? {}) - sendUserToast('app loaded from latest saved draft', false, [ - { - label: 'Discard draft and load from latest deployed version', - callback: reloadAction - }, - { - label: 'Show diff', - callback: async () => { - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'simple', - original: deployed, - current: draft, - title: 'Deployed <> Draft', - button: { text: 'Discard draft', onClick: reloadAction } - }) - } - } - ]) - } - } else { - app = app_w_draft + ]) } } $effect(() => { // Re-run on workspace OR path change so navigating from one app editor // to another (e.g. via the workspace picker) reloads the new app. - const newPath = page.params.path - if ($workspaceStore) { + const currentPath = page.params.path + if ($workspaceStore && currentPath !== undefined) { untrack(() => { // Clear the app so AppEditor unmounts; it will remount once loadApp // completes with fresh data, re-initializing its internal stores. app = undefined - const s = nodraft ? undefined : localStorage.getItem(`app-${newPath}`) - stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined + path = currentPath loadApp() }) } @@ -186,6 +245,7 @@ return } diffDrawer?.closeDrawer() + UserDraft.discard('app', path, undefined) goto(`/apps/edit/${savedApp.draft.path}`) await loadApp() redraw++ @@ -204,6 +264,7 @@ path: savedApp.path }) } + UserDraft.discard('app', path, undefined) goto(`/apps/edit/${savedApp.path}`) await loadApp() redraw++ @@ -227,6 +288,12 @@ + {#key redraw} {#if app} @@ -248,6 +315,7 @@ {diffDrawer} version={app.versions ? app.versions[app.versions.length - 1] : undefined} newApp={false} + initialRevs={currentRevs} replaceStateFn={(path) => replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} > diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 31100d3b07..fb68f09f1b 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -3,15 +3,16 @@ import { AppService, type Policy } from '$lib/gen' import { page } from '$app/state' - import { decodeState } from '$lib/utils' import { userStore, workspaceStore } from '$lib/stores' - import { afterNavigate, replaceState } from '$app/navigation' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte' + import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte' + import { readFieldsRecursively } from '$lib/utils' + import { untrack } from 'svelte' import { react18Template, react19Template, @@ -40,11 +41,26 @@ import { Alert } from '$lib/components/common' import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - let nodraft = page.url.searchParams.get('nodraft') + // `nodraft` is captured into a local because we strip it from the URL + // below — downstream readers like `templatePicker` must see the original + // signal. + const nodraft = page.url.searchParams.get('nodraft') const templatePath = page.url.searchParams.get('template') const templateId = page.url.searchParams.get('template_id') const hubId = page.url.searchParams.get('hub') + // "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to + // signal "start fresh". Wipe the persisted empty-path autosave and strip + // the flag from the URL synchronously so a reload doesn't wipe the + // freshly-started draft. A plain reload of /apps_raw/add (no nodraft) + // instead restores the previous session. + if (nodraft && typeof window !== 'undefined') { + UserDraft.discard('raw_app', '', undefined) + const url = new URL(window.location.href) + url.searchParams.delete('nodraft') + window.history.replaceState(window.history.state, '', url.toString()) + } + // Check in-memory store first, then sessionStorage (used when full page reload occurs) let importRaw = $importStore if ($importStore) { @@ -58,26 +74,19 @@ } } - const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp') + const draftHandle = UserDraft.use<{ + files: Record + runnables: Record + data: RawAppData + summary: string + }>('raw_app', '') + // Restore the persisted autosave so a plain reload of /apps_raw/add + // resumes the last session. Captured once; the $effect below mirrors + // later edits back. Import/template/hub flows in loadApp() wipe the + // entry first (`UserDraft.remove`) for "start fresh" semantics. + const restoredDraft = untrack(() => draftHandle.draft) - let summary = $state('') - let files: Record = $state(react19Template) - afterNavigate(() => { - if (nodraft) { - let url = new URL(page.url.href) - url.search = '' - replaceState(url.toString(), page.state) - } - }) - let policy: Policy = $state({ - on_behalf_of: $userStore?.username.includes('@') - ? $userStore?.username - : `u/${$userStore?.username}`, - on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' - }) - - let runnables: Record = $state({ + const defaultRunnables: Record = { a: { name: 'a', fields: {}, @@ -101,9 +110,55 @@ } } } + } + + let summary = $state(restoredDraft?.summary ?? '') + let files: Record = $state(restoredDraft?.files ?? react19Template) + let policy: Policy = $state({ + on_behalf_of: $userStore?.username.includes('@') + ? $userStore?.username + : `u/${$userStore?.username}`, + on_behalf_of_email: $userStore?.email, + execution_mode: 'publisher' }) + + let runnables: Record = $state(restoredDraft?.runnables ?? defaultRunnables) /** Data configuration including tables and creation policy */ - let data: RawAppData = $state({ ...DEFAULT_DATA }) + let data: RawAppData = $state(restoredDraft?.data ?? { ...DEFAULT_DATA }) + + // First mirror consumes the handle's first-write skip up-front (wipe + // then restore) so the user's first real edit isn't the one dropped. + let firstMirror = true + $effect(() => { + readFieldsRecursively(files) + readFieldsRecursively(runnables) + readFieldsRecursively(data) + void summary + untrack(() => { + if (firstMirror) { + firstMirror = false + draftHandle.setDraftAndMeta(undefined, {}) + } + draftHandle.draft = { files, runnables, data, summary } + }) + }) + + // Reflect an external UserDraft.save into the form. Idempotent + the + // d == null guard keeps it from looping with the mirror above or + // clobbering "start fresh" loads (which discard the in-memory draft). + $effect(() => { + const d = draftHandle.draft + if (d == null) return + untrack(() => { + if (localDraftDiffers(d, { files, runnables, data, summary })) { + files = d.files + runnables = d.runnables + data = d.data + summary = d.summary + } + }) + }) + loadApp() function extractValue(value: any) { @@ -128,6 +183,10 @@ } async function loadApp() { if (importRaw) { + // Import/template/hub loads are an explicit "start fresh from this + // content" — drop the restored empty-path autosave so it doesn't + // linger as the next plain reload's baseline. + UserDraft.discard('raw_app', '', undefined) sendUserToast('Loaded from YAML/JSON') if ('value' in importRaw) { summary = importRaw.summary @@ -139,6 +198,7 @@ } console.log('importRaw', importRaw) } else if (templatePath) { + UserDraft.discard('raw_app', '', undefined) const template = await AppService.getAppByPath({ workspace: $workspaceStore!, path: templatePath @@ -148,6 +208,7 @@ sendUserToast('App loaded from template path') goto('?', { replaceState: true }) } else if (templateId) { + UserDraft.discard('raw_app', '', undefined) const template = await AppService.getAppByVersion({ workspace: $workspaceStore!, id: parseInt(templateId) @@ -157,6 +218,7 @@ sendUserToast('App loaded from template') goto('?', { replaceState: true }) } else if (hubId) { + UserDraft.discard('raw_app', '', undefined) const hub = await AppService.getHubRawAppById({ id: Number(hubId) }) if (hub.app?.value) { extractValue(hub.app.value) @@ -167,19 +229,6 @@ console.log('App loaded from Hub') sendUserToast('App loaded from Hub') goto('?', { replaceState: true }) - } else if (!templatePath && !hubId && appState) { - console.log('App loaded from browser stored autosave') - sendUserToast('App restored from browser stored autosave', false, [ - { - label: 'Start from blank', - callback: () => { - files = {} - runnables = {} - } - } - ]) - let decoded = decodeState(appState) - extractValue(decoded) } } diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 3f9d08b224..93417aa3f4 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,18 +1,39 @@ + {#if files} {#key redraw}
{ + UserDraft.remove('raw_app', path) goto(`/apps_raw/edit/${event.detail}`) newPath = event.detail }} diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 0fc9c0219d..cbeb9becb5 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -1,6 +1,5 @@ -{#if script} + + + +{#if scriptHandle.draft} goto(editPathFor(item))} searchParams={page.url.searchParams} - bind:script + bind:script={scriptHandle.draft} {showMeta} - replaceStateFn={(path) => replaceState(path, page.state)} > ('script', draftPath) + + /** Some pages base64-JSON-encode a NewScript-like payload into the URL + * hash on `/scripts/edit/#…`. Treat it as a one-shot seed that + * wins over local autosave + backend draft + deployed: apply, toast, + * strip from the URL. Same logic as /scripts/add, kept in this file for + * a faithful mirror of its decoder. + * + * Can't reuse `decodeState` from utils.ts — it fires its own error toast + * on parse failure, which would noise up the UI for unrelated anchors. + */ + function decodeUrlScriptSeed(): Partial | undefined { + const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : '' + if (!fragment) return undefined + try { + const decoded = JSON.parse(decodeURIComponent(atob(fragment))) + if (decoded && typeof decoded === 'object') return decoded as Partial + } catch { + // Hash isn't a valid encoded script — ignore. + } + return undefined + } + let urlScriptSeed = decodeUrlScriptSeed() + + // Seed from the URL so ScriptBuilder mounts with a populated `initialPath` + // even when `scriptHandle.draft` is already defined synchronously from a + // local autosave. An empty initialPath flips ScriptBuilder's + // `metadataOpen` heuristic (intended for /scripts/add) into "true" and + // pops the settings drawer open on /edit. + let initialPath: string = $state(hash ? '' : (page.params.path ?? '')) let scriptBuilder: ScriptBuilder | undefined = $state(undefined) - let reloadAction: () => Promise = async () => {} - let savedScript: NewScriptWithDraft | undefined = $state(undefined) let fullyLoaded = $state(false) let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined) + // Local-draft staleness modal: opened when the remote (deployed or DB + // draft) has moved on since the user's autosave was created. + let staleModalOpen = $state(false) + let staleModalCause = $state<'draft' | 'version'>('version') + let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined + + // === BEGIN TEMP URL-HASH SYNC (remove with future PR) === + // Legacy behavior: URL hash both seeds the editor and stays in sync with + // edits. Asks the user via modal when the URL value would clobber an + // existing local autosave that differs from it. + let urlConflictModalOpen = $state(false) + let urlConflictPending: { seed: EditableScript; revs: UserDraftMeta } | undefined = undefined + // Gates the URL-sync effect until the initial URL-seed has been resolved + // (silent apply OR modal closed) so it doesn't overwrite the URL payload + // before the user has decided. + let initialUrlSeedResolved = $state(!urlScriptSeed) + // === END TEMP URL-HASH SYNC === + + function applyBaseline(baseline: EditableScript): void { + initialPath = baseline.path + scriptBuilder?.setDraftTriggers(baseline.draft_triggers) + scriptBuilder?.setCode(baseline.content) + if (baseline['primary_schedule']) { + savedPrimarySchedule = baseline['primary_schedule'] + scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) + } + } + + function onStaleLoadLatest(): void { + if (!pendingBaseline) { + staleModalOpen = false + return + } + const { baseline, revs } = pendingBaseline + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(baseline, revs) + applyBaseline(baseline) + pendingBaseline = undefined + staleModalOpen = false + } + + function onStaleKeepDraft(): void { + if (pendingBaseline) { + scriptHandle.setMeta(pendingBaseline.revs, { force: true }) + } + pendingBaseline = undefined + staleModalOpen = false + } + + // === BEGIN TEMP URL-HASH SYNC (remove with future PR) === + function onUrlConflictUseUrl(): void { + if (urlConflictPending) { + const { seed, revs } = urlConflictPending + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(seed, revs) + applyBaseline(seed) + sendUserToast('Loaded from URL') + } + urlConflictPending = undefined + urlConflictModalOpen = false + initialUrlSeedResolved = true + } + function onUrlConflictKeepLocal(): void { + urlConflictPending = undefined + urlConflictModalOpen = false + initialUrlSeedResolved = true + } + // === END TEMP URL-HASH SYNC === + /** Increments per `loadScript` call. Stale loads (e.g. when picker * navigation races a draft-discard reload) bail at the next checkpoint * after their captured token no longer matches. */ @@ -43,38 +151,151 @@ async function loadScript(): Promise { const tok = ++loadScriptToken fullyLoaded = false + if (hash) { + const scriptByHash = await ScriptService.getScriptByHash({ + workspace: $workspaceStore!, + hash + }) + if (tok !== loadScriptToken) return + savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft + scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined } + } else { + const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({ + workspace: $workspaceStore!, + path: page.params.path ?? '' + }) + if (tok !== loadScriptToken) return + savedScript = structuredClone($state.snapshot(scriptWithDraft)) - // Re-read URL-derived state on every load. The component doesn't - // remount when the picker navigates between scripts, so capturing - // these at module init would leave them stale. - const urlFragment = window.location.hash != '' ? window.location.hash.slice(1) : undefined - const scriptLoadedFromUrl = urlFragment != undefined ? decodeState(urlFragment) : undefined - const hash = page.url.searchParams.get('hash') ?? undefined - const topHash = page.url.searchParams.get('topHash') ?? undefined - - if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) { - script = scriptLoadedFromUrl - reloadAction = async () => { - goto(`/scripts/edit/${script!.path}`) - loadScript() + const localDraft = scriptHandle.draft + const previousMeta = scriptHandle.meta + const backendDraft = scriptWithDraft.draft + ? ({ ...scriptWithDraft.draft } as EditableScript) + : undefined + const newRevs: UserDraftMeta = { + remoteRev: scriptWithDraft.hash, + remoteDraftRev: scriptWithDraft.draft_created_at } - async function compareAutosave() { - const sf = await ScriptService.getScriptByPathWithDraft({ - workspace: $workspaceStore!, - path: script!.path - }) - if (tok !== loadScriptToken) return - savedScript = sf + // Compute the fully-baked initial value once so the assignment + // below is a single write — otherwise post-load mutations like + // `parent_hash = ...` would count as a second write under + // useLocalStorageValue's saveInitialValue=false contract and get + // persisted before the user has touched anything. + const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript + const bakedBaseline: EditableScript = { + ...baseline, + parent_hash: topHash ?? scriptWithDraft.hash + } - const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript) - const urlScript = cleanValueProperties(scriptLoadedFromUrl) - if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) { - reloadAction() + if (urlScriptSeed) { + // === TEMP URL-HASH SYNC branch (remove with future PR) === + // URL hash seed competes with the local autosave on load. + // When they differ, defer to a user-facing modal instead of + // silently overwriting. + const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript + if (localDraft != undefined) { + const localClean = orderedJsonStringify(cleanValueProperties(localDraft)) + const seededClean = orderedJsonStringify(cleanValueProperties(seeded)) + if (localClean === seededClean) { + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(seeded, newRevs) + initialUrlSeedResolved = true + } else { + urlConflictPending = { seed: seeded, revs: newRevs } + urlConflictModalOpen = true + } } else { - sendUserToast('Script loaded from latest autosave stored in the URL', false, [ + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(seeded, newRevs) + sendUserToast('Loaded from URL') + initialUrlSeedResolved = true + } + urlScriptSeed = undefined + // === END TEMP URL-HASH SYNC branch === + } else if (localDraft != undefined) { + const reference = backendDraft ?? scriptWithDraft + const referenceClean = cleanValueProperties(reference) + const localClean = cleanValueProperties(localDraft) + if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) { + // Local matches the saved version — silently drop it and use the saved one. + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(bakedBaseline, newRevs) + } else { + const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev) + if (cause) { + // Remote moved on since the local autosave was written — + // surface the choice via modal. The local draft stays on + // screen until the user picks. + pendingBaseline = { baseline: bakedBaseline, revs: newRevs } + staleModalCause = cause + staleModalOpen = true + } else { + if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { + // Legacy entry (no meta recorded) — backfill so future + // loads can detect staleness even if the user doesn't edit. + scriptHandle.setMeta(newRevs, { force: true }) + } + const scriptPath = bakedBaseline.path + const hasBackendDraft = !!backendDraft + notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, { + onResetToSavedDraft: () => { + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(bakedBaseline, newRevs) + applyBaseline(bakedBaseline) + }, + onResetToDeployed: async () => { + if (hasBackendDraft) { + await DraftService.deleteDraft({ + workspace: $workspaceStore!, + kind: 'script', + path: scriptPath + }) + } + UserDraft.remove('script', draftPath) + // UserDraft.remove only clears localStorage. The entry's + // in-memory state is kept alive by this route's handle, so + // loadScript would re-read the stale autosave and the toast + // would fire again. Drop the in-memory state first. + scriptHandle.setDraftAndMeta(undefined, {}) + goto(`/scripts/edit/${scriptPath}`) + loadScript() + } + }) + } + } + } else if (backendDraft) { + scriptHandle.setDraftAndMeta(bakedBaseline, newRevs) + if (bakedBaseline['primary_schedule']) { + savedPrimarySchedule = bakedBaseline['primary_schedule'] + scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) + } + scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers) + + if (!scriptWithDraft.draft_only) { + const reloadAction = async () => { + await DraftService.deleteDraft({ + workspace: $workspaceStore!, + kind: 'script', + path: bakedBaseline.path + }) + UserDraft.remove('script', draftPath) + // UserDraft.remove only clears localStorage. The + // scriptHandle's in-memory state still holds the now- + // deleted DB draft + its meta — loadScript would treat + // it as a local autosave and the staleness check + // would fire a spurious "newer version was deployed" + // modal because remoteDraftRev moved from "defined" + // to "undefined". Drop the in-memory state first. + scriptHandle.setDraftAndMeta(undefined, {}) + goto(`/scripts/edit/${bakedBaseline.path}`) + loadScript() + } + const deployed = cleanValueProperties(scriptWithDraft) + const draft = cleanValueProperties(bakedBaseline) + sendUserToast('Script loaded from latest saved draft', false, [ { - label: 'Discard browser stored autosave and reload', + label: 'Reset to deployed', callback: reloadAction }, { @@ -83,96 +304,24 @@ diffDrawer?.openDrawer() diffDrawer?.setDiff({ mode: 'simple', - original: draftOrDeployed, - current: urlScript, - title: `${savedScript?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`, - button: { text: 'Discard autosave', onClick: reloadAction } + original: deployed, + current: draft, + title: 'Deployed <> Draft', + button: { text: 'Discard draft', onClick: reloadAction } }) } } ]) } - } - compareAutosave() - } else { - if (hash) { - const scriptByHash = await ScriptService.getScriptByHash({ - workspace: $workspaceStore!, - hash - }) - if (tok !== loadScriptToken) return - savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft - script = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { - const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({ - workspace: $workspaceStore!, - path: page.params.path ?? '' - }) - if (tok !== loadScriptToken) return - savedScript = structuredClone($state.snapshot(scriptWithDraft)) - if (scriptWithDraft.draft != undefined) { - script = scriptWithDraft.draft - scriptBuilder?.setDraftTriggers(script.draft_triggers) - if (script['primary_schedule']) { - savedPrimarySchedule = script['primary_schedule'] - scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) - } - - if (!scriptWithDraft.draft_only) { - reloadAction = async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore!, - kind: 'script', - path: script!.path - }) - goto(`/scripts/edit/${script!.path}`) - loadScript() - } - const deployed = cleanValueProperties(scriptWithDraft) - const draft = cleanValueProperties(script) - sendUserToast('Script loaded from latest saved draft', false, [ - { - label: 'Discard draft reset to deployed version', - callback: reloadAction - }, - { - label: 'Show diff', - callback: async () => { - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'simple', - original: deployed, - current: draft, - title: 'Deployed <> Draft', - button: { text: 'Discard draft', onClick: reloadAction } - }) - } - } - ]) - } - } else { - script = scriptWithDraft - } - script.parent_hash = scriptWithDraft.hash + scriptHandle.setDraftAndMeta(bakedBaseline, newRevs) } } - // hash - // ? await ScriptService.getScriptByHash({ - // workspace: $workspaceStore!, - // hash: page.params.hash - // }) - // : await ScriptService.getScriptByPathWithDraft({ - // workspace: $workspaceStore!, - // path: $page.params.path - // }) - if (script) { - initialPath = script.path - scriptBuilder?.setDraftTriggers(script.draft_triggers) - scriptBuilder?.setCode(script.content) - if (topHash) { - script.parent_hash = topHash - } + if (scriptHandle.draft) { + initialPath = scriptHandle.draft.path + scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers) + scriptBuilder?.setCode(scriptHandle.draft.content) } fullyLoaded = true } @@ -186,6 +335,31 @@ } }) + // === BEGIN TEMP URL-HASH SYNC (remove with future PR) === + // Mirror the current draft to the URL hash on every edit (debounced). + let _urlHashSyncTimeout: number | undefined + $effect(() => { + const draft = scriptHandle.draft + if (!draft) return + // Wait until the initial URL-seed has been resolved (silent apply or + // modal closed) so we don't clobber the URL payload prematurely. + if (!initialUrlSeedResolved) { + if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout) + return + } + readFieldsRecursively(draft) + if (typeof window === 'undefined') return + if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout) + _urlHashSyncTimeout = setTimeout(() => { + const snapshot = $state.snapshot(scriptHandle.draft) + if (!snapshot) return + const url = new URL(window.location.href) + url.hash = encodeState(snapshot) + window.history.replaceState(window.history.state, '', url.toString()) + }, 500) + }) + // === END TEMP URL-HASH SYNC === + let diffDrawer: DiffDrawer | undefined = $state() async function restoreDraft() { @@ -194,6 +368,12 @@ return } diffDrawer?.closeDrawer() + UserDraft.remove('script', draftPath) + // Drop the in-memory handle state so loadScript sees no local draft + // on the next pass — otherwise the staleness check would compare the + // stale in-memory meta against the freshly fetched backend and fire + // a spurious modal. + scriptHandle.setDraftAndMeta(undefined, {}) goto(`/scripts/edit/${savedScript.draft.path}`) loadScript() } @@ -211,17 +391,32 @@ path: savedScript.path }) } + UserDraft.remove('script', draftPath) + scriptHandle.setDraftAndMeta(undefined, {}) goto(`/scripts/edit/${savedScript.path}`) loadScript() } -{#if script} + + + +{#if scriptHandle.draft} { + UserDraft.remove('script', draftPath) if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} @@ -240,9 +436,6 @@ goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - replaceStateFn={(path) => { - replaceState(path, page.state) - }} > Date: Wed, 20 May 2026 17:24:07 +0200 Subject: [PATCH 7/7] refactor: move google ai proxy handling to windmill-ai (#9260) * refactor: add ai proxy execution mode * refactor: move google ai proxy handling * refactor: share google ai request building --- backend/Cargo.lock | 1 + backend/windmill-ai/Cargo.toml | 1 + .../windmill-ai/src/providers/google_ai.rs | 540 +++++++++++++++++- backend/windmill-ai/src/proxy.rs | 66 ++- backend/windmill-api/src/ai.rs | 189 +++--- backend/windmill-api/src/google.rs | 354 ------------ backend/windmill-api/src/lib.rs | 1 - docs/windmill-ai-refactor-plan.md | 37 +- 8 files changed, 678 insertions(+), 511 deletions(-) delete mode 100644 backend/windmill-api/src/google.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b8b0ab8625..8e590776cd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13871,6 +13871,7 @@ dependencies = [ name = "windmill-ai" version = "1.704.1" dependencies = [ + "async-stream", "async-trait", "aws-config", "aws-credential-types", diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index b419101f25..8f2f679c7e 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -20,6 +20,7 @@ windmill-parser.workspace = true windmill-mcp = { workspace = true, optional = true } async-trait.workspace = true +async-stream.workspace = true base64.workspace = true bytes.workspace = true eventsource-stream.workspace = true diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 81f34e7acb..b6a6295d88 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -1,15 +1,24 @@ use crate::{ ai_google::{ - openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, + gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini, + openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event, + sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest, GeminiTool, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, + proxy::{ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{GeminiSSEParser, SSEParser}, types::*, }; use async_trait::async_trait; +use bytes::Bytes; +use eventsource_stream::Eventsource; +use futures::{stream::BoxStream, StreamExt}; +use http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use serde::Deserialize; +use serde_json::json; use windmill_common::{client::AuthedClient, error::Error}; // ============================================================================ @@ -37,22 +46,11 @@ impl GoogleAIQueryBuilder { ) -> Result { let prepared_messages = prepare_messages_for_api(args.messages, client, workspace_id).await?; - let (contents, system_instruction) = openai_messages_to_gemini(&prepared_messages); - - let tools = self.convert_tools_to_gemini(args.tools, args.has_websearch); - - let generation_config = self.build_generation_config(args); - - let request = GeminiTextRequest { - contents, - tools, - tool_config: None, - system_instruction, - generation_config, - }; - - serde_json::to_string(&request) - .map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e))) + build_gemini_text_request_body( + &prepared_messages, + self.convert_tools_to_gemini(args.tools, args.has_websearch), + self.build_generation_config(args), + ) } async fn build_image_request( @@ -155,19 +153,378 @@ impl GoogleAIQueryBuilder { (None, None) }; - if args.temperature.is_some() || args.max_tokens.is_some() || response_mime_type.is_some() { - Some(GeminiGenerationConfig { - temperature: args.temperature, - max_output_tokens: args.max_tokens, - response_mime_type, - response_schema, - }) - } else { - None - } + build_gemini_generation_config( + args.temperature, + args.max_tokens, + response_mime_type, + response_schema, + ) } } +fn build_gemini_text_request_body( + messages: &[OpenAIMessage], + tools: Option>, + generation_config: Option, +) -> Result { + let request = build_gemini_text_request(messages, tools, generation_config); + serde_json::to_string(&request) + .map_err(|e| Error::internal_err(format!("Failed to serialize Gemini request: {}", e))) +} + +fn build_gemini_text_request( + messages: &[OpenAIMessage], + tools: Option>, + generation_config: Option, +) -> GeminiTextRequest { + let (contents, system_instruction) = openai_messages_to_gemini(messages); + + GeminiTextRequest { contents, tools, tool_config: None, system_instruction, generation_config } +} + +fn build_gemini_generation_config( + temperature: Option, + max_tokens: Option, + response_mime_type: Option, + response_schema: Option, +) -> Option { + if temperature.is_some() + || max_tokens.is_some() + || response_mime_type.is_some() + || response_schema.is_some() + { + Some(GeminiGenerationConfig { + temperature, + max_output_tokens: max_tokens, + response_mime_type, + response_schema, + }) + } else { + None + } +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatRequest { + model: String, + messages: Vec, + #[serde(default)] + stream: bool, + #[serde(default)] + temperature: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + tools: Option>, +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatTool { + function: GoogleAIProxyChatToolFunction, +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatToolFunction { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + parameters: Option, +} + +#[derive(Deserialize)] +struct GeminiModel { + name: String, + #[serde(rename = "displayName", default)] + display_name: String, +} + +#[derive(Deserialize)] +struct GeminiModelsResponse { + #[serde(default)] + models: Vec, +} + +struct GoogleAIProxyRequest { + request: ProxyRequest, + model: String, + stream: bool, +} + +pub enum GoogleAIProxyResponseBody { + Fixed(Bytes), + Stream(BoxStream<'static, std::result::Result>), +} + +pub struct GoogleAIProxyResponse { + pub status_code: StatusCode, + pub headers: HeaderMap, + pub body: GoogleAIProxyResponseBody, +} + +/// Handle a workspace Google AI chat proxy request. +/// +/// The API still owns credential resolution, auditing, and keepalive injection. +/// Callers must verify the user can use the supplied credentials before calling. +/// This helper owns the provider-specific OpenAI <-> Gemini transformations. +pub async fn handle_google_ai_chat_proxy( + client: &reqwest::Client, + args: &ProxyBuildArgs<'_>, +) -> Result { + let GoogleAIProxyRequest { request, model, stream } = build_google_ai_chat_proxy_request(args)?; + + let response = + send_google_ai_proxy_request(client, request, "Failed to send request to Gemini API") + .await?; + + if stream { + Ok(convert_streaming_response(response, &model)) + } else { + convert_non_streaming_response(response, &model).await + } +} + +/// Handle a workspace Google AI model-list proxy request. +/// +/// The API still owns credential resolution and auditing. Callers must verify +/// the user can use the supplied credentials before calling. +pub async fn handle_google_ai_models_proxy( + client: &reqwest::Client, + args: &ProxyBuildArgs<'_>, +) -> Result { + let request = build_google_ai_models_proxy_request(args); + let response = + send_google_ai_proxy_request(client, request, "Failed to fetch Gemini models").await?; + + let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { + Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) + })?; + + let data: Vec = gemini_resp + .models + .into_iter() + .map(|m| { + json!({ + "id": m.name, + "object": "model", + "display_name": m.display_name, + }) + }) + .collect(); + + let body = serde_json::to_vec(&json!({ "data": data })) + .map_err(|e| Error::internal_err(format!("Failed to serialize models: {}", e)))?; + + Ok(GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: GoogleAIProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn build_google_ai_chat_proxy_request( + args: &ProxyBuildArgs<'_>, +) -> Result { + let request: GoogleAIProxyChatRequest = serde_json::from_slice(args.body) + .map_err(|e| Error::BadRequest(format!("Failed to parse request body: {}", e)))?; + + let gemini_tools = request.tools.as_ref().map(|tools| { + let declarations: Vec = tools + .iter() + .map(|t| { + let mut params = t.function.parameters.clone().unwrap_or(json!({})); + sanitize_schema_for_google(&mut params); + GeminiFunctionDeclaration { + name: t.function.name.clone(), + description: t.function.description.clone(), + parameters: params, + } + }) + .collect(); + vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] + }); + + let body = build_gemini_text_request_body( + &request.messages, + gemini_tools, + build_gemini_generation_config(request.temperature, request.max_tokens, None, None), + )? + .into_bytes(); + + let credentials = args.credentials; + let base_url = credentials.base_url.trim_end_matches('/'); + let is_vertex = credentials.platform == AIPlatform::GoogleVertexAi; + let endpoint = if request.stream { + format!( + "{}?alt=sse", + build_google_ai_model_endpoint( + base_url, + &request.model, + "streamGenerateContent", + is_vertex, + ) + ) + } else { + build_google_ai_model_endpoint(base_url, &request.model, "generateContent", is_vertex) + }; + + let mut headers = vec![("content-type".to_string(), "application/json".to_string())]; + add_google_ai_auth_header( + &mut headers, + credentials.api_key.as_deref().unwrap_or(""), + is_vertex, + ); + + Ok(GoogleAIProxyRequest { + request: ProxyRequest { method: Method::POST, url: endpoint, headers, body }, + model: request.model, + stream: request.stream, + }) +} + +fn build_google_ai_models_proxy_request(args: &ProxyBuildArgs<'_>) -> ProxyRequest { + let credentials = args.credentials; + let base_url = credentials.base_url.trim_end_matches('/'); + let is_vertex = credentials.platform == AIPlatform::GoogleVertexAi; + let url = if is_vertex { + base_url.to_string() + } else { + format!("{}/models", base_url) + }; + + let mut headers = Vec::new(); + add_google_ai_auth_header( + &mut headers, + credentials.api_key.as_deref().unwrap_or(""), + is_vertex, + ); + + ProxyRequest { method: Method::GET, url, headers, body: Vec::new() } +} + +fn build_google_ai_model_endpoint( + base_url: &str, + model: &str, + action: &str, + is_vertex: bool, +) -> String { + if is_vertex { + format!("{}/{}:{}", base_url, model, action) + } else { + format!("{}/models/{}:{}", base_url, model, action) + } +} + +fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) { + if is_vertex { + headers.push(("Authorization".to_string(), format!("Bearer {}", api_key))); + } else { + headers.push(("x-goog-api-key".to_string(), api_key.to_string())); + } +} + +async fn send_google_ai_proxy_request( + client: &reqwest::Client, + proxy_request: ProxyRequest, + send_error_message: &str, +) -> Result { + let mut request = client.request(proxy_request.method.clone(), &proxy_request.url); + for (header_name, header_value) in &proxy_request.headers { + request = request.header(header_name.as_str(), header_value.as_str()); + } + + let response = request + .body(proxy_request.body) + .send() + .await + .map_err(|e| Error::internal_err(format!("{}: {}", send_error_message, e)))?; + + if let Err(e) = response.error_for_status_ref() { + let status = e.status().map(|s| s.to_string()).unwrap_or_default(); + let body = response.text().await.unwrap_or_default(); + return Err(Error::AIError(format!("{}: {}", status, body))); + } + + Ok(response) +} + +fn convert_streaming_response(response: reqwest::Response, model: &str) -> GoogleAIProxyResponse { + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let model = model.to_string(); + + let gemini_sse_stream = response.bytes_stream().eventsource(); + let openai_sse_stream = async_stream::stream! { + tokio::pin!(gemini_sse_stream); + let mut tool_call_index: usize = 0; + while let Some(event) = gemini_sse_stream.next().await { + match event { + Ok(event) => match parse_gemini_sse_event(&event.data) { + Ok(Some(parsed)) => { + for chunk in gemini_event_to_openai_sse_chunks( + &parsed, &id, &model, &mut tool_call_index, + ) { + yield Ok::(Bytes::from(chunk)); + } + } + Ok(None) => {} + Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), + }, + Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), + } + } + yield Ok::(Bytes::from("data: [DONE]\n\n")); + } + .boxed(); + + GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: event_stream_response_headers(), + body: GoogleAIProxyResponseBody::Stream(openai_sse_stream), + } +} + +async fn convert_non_streaming_response( + response: reqwest::Response, + model: &str, +) -> Result { + let body = response + .bytes() + .await + .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; + + let parsed = parse_gemini_response(&body)?; + let openai_response = gemini_response_to_openai(&parsed, model); + + let body = serde_json::to_vec(&openai_response) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: GoogleAIProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn json_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers +} + +fn event_stream_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/event-stream"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive")); + headers +} + #[async_trait] impl QueryBuilder for GoogleAIQueryBuilder { fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool { @@ -324,3 +681,132 @@ impl QueryBuilder for GoogleAIQueryBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use std::collections::HashMap; + + fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { + ProviderCredentials { + provider: AIProvider::GoogleAI, + base_url: base_url.to_string(), + api_key: Some("api-key".to_string()), + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform, + enable_1m_context: false, + custom_headers: HashMap::new(), + } + } + + #[test] + fn builds_standard_google_ai_chat_proxy_request() { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = br#"{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "hello"}], + "temperature": 0.2, + "max_tokens": 123, + "stream": false + }"#; + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!(request.request.method, Method::POST); + assert_eq!( + request.request.url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + ); + assert!(!request.stream); + assert_eq!(request.model, "gemini-2.0-flash"); + assert!(request + .request + .headers + .contains(&("x-goog-api-key".to_string(), "api-key".to_string()))); + + let body: serde_json::Value = serde_json::from_slice(&request.request.body).unwrap(); + assert_eq!(body["generationConfig"]["maxOutputTokens"], 123); + assert_eq!(body["generationConfig"]["temperature"], 0.2); + assert!(body["contents"].is_array()); + } + + #[test] + fn builds_vertex_google_ai_streaming_proxy_request() { + let credentials = credentials( + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/", + AIPlatform::GoogleVertexAi, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = br#"{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "hello"}], + "stream": true + }"#; + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!( + request.request.url, + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent?alt=sse" + ); + assert!(request.stream); + assert!(request + .request + .headers + .contains(&("Authorization".to_string(), "Bearer api-key".to_string()))); + } + + #[test] + fn builds_google_ai_models_proxy_request() { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::GET; + let headers = HeaderMap::new(); + + let request = build_google_ai_models_proxy_request(&ProxyBuildArgs { + method: &method, + path: "models", + headers: &headers, + body: &[], + credentials: &credentials, + }); + + assert_eq!(request.method, Method::GET); + assert_eq!( + request.url, + "https://generativelanguage.googleapis.com/v1beta/models" + ); + assert!(request + .headers + .contains(&("x-goog-api-key".to_string(), "api-key".to_string()))); + } +} diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 21b92705db..bbc570a18a 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -46,6 +46,24 @@ pub struct ProxyRequest { pub body: Vec, } +/// How the API proxy should execute a request for a provider. +/// +/// Most providers can be represented as a transformed HTTP request. Google AI +/// and Bedrock need native execution because their proxy paths also transform +/// responses or call an SDK rather than forwarding an HTTP request directly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProxyExecutionMode { + HttpForward, + NativeGoogleAi, + NativeAwsBedrock, +} + +impl ProxyExecutionMode { + pub fn uses_query_builder_proxy(self) -> bool { + matches!(self, Self::HttpForward) + } +} + pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool { matches!( provider, @@ -60,8 +78,24 @@ pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool { ) } +pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode { + match provider { + AIProvider::OpenAI + | AIProvider::AzureOpenAI + | AIProvider::Anthropic + | AIProvider::Mistral + | AIProvider::DeepSeek + | AIProvider::Groq + | AIProvider::OpenRouter + | AIProvider::TogetherAI + | AIProvider::CustomAI => ProxyExecutionMode::HttpForward, + AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi, + AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock, + } +} + pub fn supports_query_builder_proxy(provider: &AIProvider) -> bool { - supports_openai_compatible_proxy(provider) || matches!(provider, AIProvider::Anthropic) + proxy_execution_mode(provider).uses_query_builder_proxy() } pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Result { @@ -180,10 +214,32 @@ mod tests { #[test] fn query_builder_proxy_support_includes_anthropic() { - assert!(supports_query_builder_proxy(&AIProvider::OpenAI)); - assert!(supports_query_builder_proxy(&AIProvider::Anthropic)); - assert!(!supports_query_builder_proxy(&AIProvider::GoogleAI)); - assert!(!supports_query_builder_proxy(&AIProvider::AWSBedrock)); + let cases = [ + (AIProvider::OpenAI, ProxyExecutionMode::HttpForward), + (AIProvider::AzureOpenAI, ProxyExecutionMode::HttpForward), + (AIProvider::Anthropic, ProxyExecutionMode::HttpForward), + (AIProvider::Mistral, ProxyExecutionMode::HttpForward), + (AIProvider::DeepSeek, ProxyExecutionMode::HttpForward), + (AIProvider::Groq, ProxyExecutionMode::HttpForward), + (AIProvider::OpenRouter, ProxyExecutionMode::HttpForward), + (AIProvider::TogetherAI, ProxyExecutionMode::HttpForward), + (AIProvider::CustomAI, ProxyExecutionMode::HttpForward), + (AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi), + (AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock), + ]; + + for (provider, expected_mode) in cases { + let mode = proxy_execution_mode(&provider); + assert_eq!( + mode, expected_mode, + "unexpected proxy mode for {provider:?}" + ); + assert_eq!( + supports_query_builder_proxy(&provider), + mode.uses_query_builder_proxy(), + "query-builder support drifted for {provider:?}" + ); + } } #[test] diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index eb337e8125..05bf995700 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -19,9 +19,16 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; -use windmill_ai::providers::create_proxy_query_builder; +use windmill_ai::providers::{ + create_proxy_query_builder, + google_ai::{ + handle_google_ai_chat_proxy, handle_google_ai_models_proxy, GoogleAIProxyResponse, + GoogleAIProxyResponseBody, + }, +}; use windmill_ai::proxy::{ - supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, ProxyRequest, + proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, + ProxyExecutionMode, ProxyRequest, }; use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; @@ -368,82 +375,6 @@ impl AIRequestConfig { Ok(response.access_token) } - pub fn prepare_request( - self, - provider: &AIProvider, - path: &str, - method: Method, - _headers: HeaderMap, - body: Bytes, - ) -> Result { - let credentials = self.into_provider_credentials(provider.clone()); - - let body = if let Some(user) = credentials.user.as_ref() { - Self::add_user_to_body(body, user.clone())? - } else { - body - }; - - let base_url = credentials.base_url.trim_end_matches('/'); - - let is_azure = credentials.provider.is_azure_openai(base_url); - let is_google_ai = credentials.provider == AIProvider::GoogleAI; - - let base_url = base_url.to_string(); - let base_url = base_url.as_str(); - - // Build URL based on provider - let url = if is_azure { - let azure_url = AIProvider::build_azure_openai_url(base_url, path); - azure_url - } else { - let default_url = format!("{}/{}", base_url, path); - default_url - }; - - tracing::debug!("AI request URL: {}", url); - - let mut request = HTTP_CLIENT - .request(method.clone(), &url) - .header("content-type", "application/json"); - - // Add authentication headers - if let Some(api_key) = credentials.api_key { - if is_azure { - request = request.header("api-key", api_key.clone()) - } else if is_google_ai { - // Note: GoogleAI requests are intercepted earlier (see the GoogleAI - // handler block above) and never reach this code path. This branch - // is kept as a safety net for the standard Gemini API auth format. - request = request.header("x-goog-api-key", api_key.clone()) - } else { - request = request.header("authorization", format!("Bearer {}", api_key.clone())) - } - } - - if let Some(access_token) = credentials.access_token { - request = request.header("authorization", format!("Bearer {}", access_token)) - } - - request = request.body(body); - - if let Some(org_id) = credentials.organization_id { - request = request.header("OpenAI-Organization", org_id); - } - - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); - } - - // Apply custom headers from the resource - for (header_name, header_value) in &credentials.custom_headers { - request = request.header(header_name.as_str(), header_value.as_str()); - } - - Ok(request) - } - fn into_provider_credentials(self, provider: AIProvider) -> ProviderCredentials { ProviderCredentials { provider, @@ -461,24 +392,6 @@ impl AIRequestConfig { custom_headers: self.custom_headers, } } - - fn add_user_to_body(body: Bytes, user: String) -> Result { - tracing::debug!("Adding user to request body"); - let mut json_body: HashMap> = serde_json::from_slice(&body) - .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; - - let user_json_string = serde_json::Value::String(user).to_string(); // makes sure to escape characters - - json_body.insert( - "user".to_string(), - RawValue::from_string(user_json_string) - .map_err(|e| Error::internal_err(format!("Failed to parse user: {}", e)))?, - ); - - Ok(serde_json::to_vec(&json_body) - .map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e)))? - .into()) - } } #[derive(Clone, Debug)] @@ -613,6 +526,19 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +fn google_ai_proxy_response_to_body( + response: GoogleAIProxyResponse, +) -> (http::StatusCode, HeaderMap, axum::body::Body) { + let body = match response.body { + GoogleAIProxyResponseBody::Fixed(body) => axum::body::Body::from(body), + GoogleAIProxyResponseBody::Stream(stream) => axum::body::Body::from_stream( + inject_keepalives(stream, Duration::from_secs(KEEPALIVE_INTERVAL_SECS)), + ), + }; + + (response.status_code, response.headers, body) +} + pub(crate) fn inject_keepalives( upstream: S, interval: Duration, @@ -888,12 +814,10 @@ async fn proxy( ai_path = chat_path; } - // Handle GoogleAI (Gemini) using the native Gemini API - if matches!(provider, AIProvider::GoogleAI) { - let api_key = request_config.api_key.as_deref().unwrap_or(""); - let base_url = request_config.base_url.trim_end_matches('/'); - let is_vertex = request_config.platform == AIPlatform::GoogleVertexAi; + let proxy_mode = proxy_execution_mode(&provider); + // Handle GoogleAI (Gemini) using the native Gemini API + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { let mut tx = db.begin().await?; audit_log( &mut *tx, @@ -907,23 +831,32 @@ async fn proxy( .await?; tx.commit().await?; - return match ai_path.as_str() { - "chat/completions" => { - crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await - } - "models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await, + let credentials = request_config.into_provider_credentials(provider.clone()); + let proxy_args = ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + }; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, _ => Err(Error::BadRequest(format!( "Unsupported Google AI path: {}", ai_path ))), - }; + }?; + + return Ok(google_ai_proxy_response_to_body(response)); } // Handle Bedrock-specific logic when the feature is enabled #[cfg(feature = "bedrock")] { // Extract model and streaming flag for Bedrock transformation (only for POST requests) - let (model, is_streaming) = if matches!(provider, AIProvider::AWSBedrock) + let (model, is_streaming) = if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) && method == Method::POST { #[derive(Deserialize, Debug)] @@ -940,7 +873,7 @@ async fn proxy( }; // For Bedrock requests, use the SDK-based approach - if matches!(provider, AIProvider::AWSBedrock) { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { let region = request_config .region .as_deref() @@ -1014,25 +947,35 @@ async fn proxy( // When bedrock feature is disabled, return error for Bedrock provider #[cfg(not(feature = "bedrock"))] - if matches!(provider, AIProvider::AWSBedrock) { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { return Err(Error::BadRequest( "AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(), )); } - let request = if supports_query_builder_proxy(&provider) { - let credentials = request_config.into_provider_credentials(provider.clone()); - let query_builder = create_proxy_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { - method: &method, - path: &ai_path, - headers: &headers, - body: &body, - credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - request_config.prepare_request(&provider, &ai_path, method, headers, body)? + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let credentials = request_config.into_provider_credentials(provider.clone()); + let query_builder = create_proxy_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi => { + return Err(Error::internal_err( + "Google AI proxy route was not handled".to_string(), + )) + } + ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest( + "Unsupported AWS Bedrock proxy request".to_string(), + )) + } }; let response = request.send().await.map_err(to_anyhow)?; diff --git a/backend/windmill-api/src/google.rs b/backend/windmill-api/src/google.rs deleted file mode 100644 index 95987ab789..0000000000 --- a/backend/windmill-api/src/google.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Google AI (Gemini API) handler for the AI chat proxy. -//! -//! Handles POST `chat/completions` requests using the native Gemini API, -//! converting from/to OpenAI format so the existing frontend parsers continue to work. -//! -//! Supports both standard Google AI (generativelanguage.googleapis.com) and -//! Google Vertex AI ({region}-aiplatform.googleapis.com) endpoints. -//! -//! Used by `windmill-api/src/ai.rs` when the provider is `GoogleAI`. -//! Shared conversion logic lives in `windmill_common::ai_google`. - -use axum::body::Body; -use bytes::Bytes; -use eventsource_stream::Eventsource; -use futures::StreamExt; -use serde::Deserialize; -use serde_json::json; -use windmill_ai::{ - ai_google::{ - gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini, - parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, - GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiTextRequest, GeminiTool, - }, - ai_types::OpenAIMessage, -}; -use windmill_common::error::{Error, Result}; - -use crate::ai::{inject_keepalives, HTTP_CLIENT, KEEPALIVE_INTERVAL_SECS}; - -// ============================================================================ -// Request type (OpenAI format received from the frontend) -// ============================================================================ - -#[derive(Deserialize, Debug)] -struct ChatRequest { - model: String, - messages: Vec, - #[serde(default)] - stream: bool, - #[serde(default)] - temperature: Option, - #[serde(default)] - max_tokens: Option, - #[serde(default)] - tools: Option>, -} - -#[derive(Deserialize, Debug)] -struct ChatRequestTool { - function: ChatRequestToolFunction, -} - -#[derive(Deserialize, Debug)] -struct ChatRequestToolFunction { - name: String, - #[serde(default)] - description: Option, - #[serde(default)] - parameters: Option, -} - -// ============================================================================ -// Helpers for Vertex AI vs standard Google AI URL/auth -// ============================================================================ - -/// Build the endpoint URL for a model action (streamGenerateContent, generateContent, predict). -/// -/// - Standard: `{base_url}/models/{model}:{action}` -/// - Vertex AI: `{base_url}/{model}:{action}` (base_url already contains .../publishers/google/models) -fn build_model_endpoint(base_url: &str, model: &str, action: &str, is_vertex: bool) -> String { - if is_vertex { - format!("{}/{}:{}", base_url, model, action) - } else { - format!("{}/models/{}:{}", base_url, model, action) - } -} - -/// Set the appropriate auth header on a request builder. -/// -/// - Standard: `x-goog-api-key` header -/// - Vertex AI: `Authorization: Bearer` header -fn set_auth( - request: reqwest::RequestBuilder, - api_key: &str, - is_vertex: bool, -) -> reqwest::RequestBuilder { - if is_vertex { - request.header("Authorization", format!("Bearer {}", api_key)) - } else { - request.header("x-goog-api-key", api_key) - } -} - -// ============================================================================ -// Public handler -// ============================================================================ - -/// Handle a `chat/completions` POST request using the native Gemini API. -/// -/// Converts the incoming OpenAI-format body to a `GeminiTextRequest`, sends it -/// to the appropriate Gemini endpoint, and converts the response back to the -/// OpenAI SSE or JSON format that the frontend expects. -pub async fn handle_google_ai_chat( - body: &Bytes, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let request: ChatRequest = serde_json::from_slice(body) - .map_err(|e| Error::BadRequest(format!("Failed to parse request body: {}", e)))?; - - let (contents, system_instruction) = openai_messages_to_gemini(&request.messages); - - let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() { - Some(GeminiGenerationConfig { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - response_mime_type: None, - response_schema: None, - }) - } else { - None - }; - - let gemini_tools = request.tools.as_ref().map(|tools| { - let declarations: Vec = tools - .iter() - .map(|t| { - let mut params = t.function.parameters.clone().unwrap_or(json!({})); - sanitize_schema_for_google(&mut params); - GeminiFunctionDeclaration { - name: t.function.name.clone(), - description: t.function.description.clone(), - parameters: params, - } - }) - .collect(); - vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] - }); - - let gemini_request = GeminiTextRequest { - contents, - tools: gemini_tools, - tool_config: None, - system_instruction, - generation_config, - }; - - let request_body = serde_json::to_string(&gemini_request) - .map_err(|e| Error::internal_err(format!("Failed to serialize Gemini request: {}", e)))?; - - let base_url = base_url.trim_end_matches('/'); - - if request.stream { - handle_streaming(&request.model, request_body, api_key, base_url, is_vertex).await - } else { - handle_non_streaming(&request.model, request_body, api_key, base_url, is_vertex).await - } -} - -// ============================================================================ -// Streaming path -// ============================================================================ - -async fn handle_streaming( - model: &str, - request_body: String, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let endpoint = format!( - "{}?alt=sse", - build_model_endpoint(base_url, model, "streamGenerateContent", is_vertex) - ); - - let request = HTTP_CLIENT - .post(&endpoint) - .header("content-type", "application/json") - .body(request_body); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let model_str = model.to_string(); - - let gemini_sse_stream = response.bytes_stream().eventsource(); - let openai_sse_stream = async_stream::stream! { - tokio::pin!(gemini_sse_stream); - let mut tool_call_index: usize = 0; - while let Some(event) = gemini_sse_stream.next().await { - match event { - Ok(event) => match parse_gemini_sse_event(&event.data) { - Ok(Some(parsed)) => { - for chunk in gemini_event_to_openai_sse_chunks( - &parsed, &id, &model_str, &mut tool_call_index, - ) { - yield Ok::(Bytes::from(chunk)); - } - } - Ok(None) => {} - Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), - }, - Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), - } - } - yield Ok::(Bytes::from("data: [DONE]\n\n")); - }; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "text/event-stream".parse().unwrap()); - headers.insert("cache-control", "no-cache".parse().unwrap()); - headers.insert("connection", "keep-alive".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - Body::from_stream(inject_keepalives( - Box::pin(openai_sse_stream), - std::time::Duration::from_secs(KEEPALIVE_INTERVAL_SECS), - )), - )) -} - -// ============================================================================ -// Model listing -// ============================================================================ - -/// List available Gemini models and convert to OpenAI format. -/// -/// - Standard: `GET {base_url}/models` — returns `{ models: [...] }` -/// - Vertex AI: `GET {base_url}` — returns `{ models: [...] }` (base_url already ends with .../models) -pub async fn handle_google_ai_models( - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - #[derive(Deserialize)] - struct GeminiModel { - name: String, - #[serde(rename = "displayName", default)] - display_name: String, - } - - #[derive(Deserialize)] - struct GeminiModelsResponse { - #[serde(default)] - models: Vec, - } - - let base_url = base_url.trim_end_matches('/'); - let endpoint = if is_vertex { - // Vertex AI: base_url is .../publishers/google/models - base_url.to_string() - } else { - // Standard: append /models - format!("{}/models", base_url) - }; - - let request = HTTP_CLIENT.get(&endpoint); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to fetch Gemini models: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { - Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) - })?; - - let data: Vec = gemini_resp - .models - .into_iter() - .map(|m| { - json!({ - "id": m.name, - "object": "model", - "display_name": m.display_name, - }) - }) - .collect(); - - let body_bytes = serde_json::to_vec(&json!({ "data": data })) - .map_err(|e| Error::internal_err(format!("Failed to serialize models: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok((http::StatusCode::OK, headers, Body::from(body_bytes))) -} - -// ============================================================================ -// Non-streaming path -// ============================================================================ - -async fn handle_non_streaming( - model: &str, - request_body: String, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let endpoint = build_model_endpoint(base_url, model, "generateContent", is_vertex); - - let request = HTTP_CLIENT - .post(&endpoint) - .header("content-type", "application/json") - .body(request_body); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let body = response - .bytes() - .await - .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; - - let parsed = parse_gemini_response(&body)?; - let openai_response = gemini_response_to_openai(&parsed, model); - - let body_bytes = serde_json::to_vec(&openai_response) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok((http::StatusCode::OK, headers, Body::from(body_bytes))) -} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 75b3469c41..e7e4508971 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -79,7 +79,6 @@ mod capture; mod concurrency_groups; mod db; mod db_health; -mod google; mod drafts; #[cfg(feature = "private")] diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index 4c0e109735..ea2b9f451e 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -37,7 +37,7 @@ Avoid adding modules whose only purpose is to re-export moved code. Direct impor Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. -## Current Phase PR: Proxy Contract + OpenAI-Compatible Proxy +## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. @@ -66,6 +66,41 @@ Validation: - `cargo check -p windmill-ai -p windmill-api` - `cargo check -p windmill-ai -p windmill-api --features bedrock` +Follow-up status: Anthropic/Vertex proxy handling has since moved into +`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has +been removed. + +## Current Phase PR: Proxy Execution Mode + Google AI Proxy Migration + +Goal: introduce a shared provider execution classifier before moving Google AI +and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers +such as OpenAI-compatible providers and Anthropic, but Google AI also converts +responses back to OpenAI shape and Bedrock uses SDK execution. Model that split +explicitly before moving those providers, then move the Google AI proxy +transformation into `windmill-ai` as the first native-provider migration. + +Suggested PR title: `refactor(ai): add provider proxy execution mode`. + +Scope: +- Add `ProxyExecutionMode` in `windmill-ai::proxy`. +- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. +- Make `supports_query_builder_proxy` derive from the shared execution mode. +- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. +- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. +- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. +- Delete the API-local `windmill-api/src/google.rs` module. +- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. + +Out of scope: +- Do not move `windmill-api/src/bedrock.rs`. +- Do not unify `AIRequestConfig` and `ProviderWithResource`. + +Validation: +- `cargo test -p windmill-ai google_ai` +- `cargo test -p windmill-ai proxy` +- `cargo test -p windmill-api maps_request_config_to_provider_credentials` +- `cargo test -p windmill-ai anthropic` + ## Step-by-Step Plan Each step produces a compiling, working backend.