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/.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/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/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/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-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-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-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-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/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/apps.rs b/backend/windmill-api/src/apps.rs index ab36b8cb89..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 "#, ) @@ -2589,6 +2593,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 +3119,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-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-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/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()); diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 57e41bb487..07ac44b755 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)] @@ -50,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)] @@ -452,7 +457,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 +470,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()), 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-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/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; 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/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. diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index c745d7b3a5..56451b9174 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1935,6 +1935,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.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, - onNavigate + onNavigate, + 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) => { @@ -167,7 +210,7 @@ runnableComponents: writable({}), appPath: writablePath, workspace: $workspaceStore ?? '', - onchange: () => saveFrontendDraft(), + onchange: undefined, isEditor: true, jobs: writable([]), staticExporter: writable({}), @@ -220,19 +263,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 @@ -755,9 +785,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 681fbfedd3..a8278778d0 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' @@ -230,11 +231,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) @@ -334,12 +331,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) } } @@ -411,6 +404,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) @@ -501,11 +498,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 213f28ee34..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -166,6 +166,16 @@ export interface AppEditorProps { onSavedNewAppPath?: (path: string) => void /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => 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/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 96ebf5abe9..f6ccec4186 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -21,8 +21,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 5953af6849..3c254a2e0c 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 }) } 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 46ee920020..3bb8ebdf38 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' @@ -182,25 +181,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) @@ -416,7 +396,6 @@ if (data.datatable !== policy.datatable || data.schema !== policy.schema) { data.datatable = policy.datatable data.schema = policy.schema - saveFrontendDraft() } }) @@ -657,7 +636,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] @@ -687,7 +665,6 @@ // Only add if not already present if (!data.tables.includes(newRef)) { data.tables = [...data.tables, newRef] - saveFrontendDraft() void aiChatManager.refreshDatatables() } } @@ -775,9 +752,6 @@ } let darkMode: boolean = $state(false) - $effect(() => { - runnables && files && saveFrontendDraft() - }) $effect(() => { iframe?.addEventListener('load', () => { iframeLoaded = true @@ -1002,7 +976,6 @@ dataTableRefs={dataTableRefsObjects} onDataTableRefsChange={(newRefs) => { data.tables = newRefs.map(formatDataTableRef) - saveFrontendDraft() }} defaultDatatable={data.datatable} defaultSchema={data.schema} @@ -1015,7 +988,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 2ff64aa4ac..8e77e8dcda 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' @@ -268,14 +269,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) } } @@ -383,12 +380,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) } } @@ -466,9 +459,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 } @@ -566,11 +563,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 d57140a5ee..a2018d678d 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/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' diff --git a/frontend/src/lib/components/workspacePicker.ts b/frontend/src/lib/components/workspacePicker.ts index 2bdc30a2ec..ba2985ecbb 100644 --- a/frontend/src/lib/components/workspacePicker.ts +++ b/frontend/src/lib/components/workspacePicker.ts @@ -43,31 +43,59 @@ type WorkspaceCache = { app?: WorkspaceItem[] } -/** Module-level snapshot of the last fetched items per workspace+kind. Used - * for instant first paint (`getCachedItems`); the picker always re-fetches in - * the background via `loadKind` so the snapshot becomes "what we last saw" - * rather than "the source of truth". This guarantees freshness after AI - * draft creates and after deploy without explicit invalidation. */ -const lastFetched = new Map() +/** Module-level session cache. Persists across picker mounts within a single + * page session. NOT invalidated automatically — call `invalidate()` after + * creating/deleting an item if the picker may be opened again before a full + * reload. */ +const cache = new Map() const inflight = new Map>() +/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version + * at start and only writes back to the cache if it still matches — so a + * deploy mid-fetch can't have its stale predecessor repopulate the cache. */ +const cacheVersion = new Map() const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}` +const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app'] + +function bumpVersion(workspace: string, kind: WorkspaceItemKind) { + const k = cacheKey(workspace, kind) + cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1) +} + export function getCachedItems( workspace: string, kind: WorkspaceItemKind ): WorkspaceItem[] | undefined { - return lastFetched.get(workspace)?.[kind] + return cache.get(workspace)?.[kind] +} + +/** Drop a workspace+kind (or a whole workspace) from the cache so the next + * picker open re-fetches. Use after creating/deleting items. Also bumps the + * version so any in-flight `loadKind` started before the invalidate won't + * write its (now-stale) result back to the cache. */ +export function invalidate(workspace: string, kind?: WorkspaceItemKind) { + if (!kind) { + cache.delete(workspace) + for (const k of KINDS) bumpVersion(workspace, k) + return + } + const bucket = cache.get(workspace) + if (bucket) delete bucket[kind] + bumpVersion(workspace, kind) } export async function loadKind( workspace: string, kind: WorkspaceItemKind ): Promise { + const existing = cache.get(workspace)?.[kind] + if (existing) return existing const key = cacheKey(workspace, kind) const flying = inflight.get(key) if (flying) return flying + const startVersion = cacheVersion.get(key) ?? 0 const promise = (async () => { const { ScriptService, FlowService, AppService } = await import('$lib/gen') let items: WorkspaceItem[] @@ -105,9 +133,13 @@ export async function loadKind( raw_app: a.raw_app ?? false })) } - const bucket = lastFetched.get(workspace) ?? {} - bucket[kind] = items - lastFetched.set(workspace, bucket) + // Only commit if the cache version hasn't changed since we started — + // otherwise we'd overwrite a deliberate `invalidate()` with stale data. + if ((cacheVersion.get(key) ?? 0) === startVersion) { + const bucket = cache.get(workspace) ?? {} + bucket[kind] = items + cache.set(workspace, bucket) + } return items })() inflight.set(key, promise) 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 b60fcf88bf..01461b78cb 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' @@ -422,6 +424,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 edb799b4a2..cbeb9becb5 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -1,10 +1,9 @@ -{#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}`) }} onSaveInitial={(e) => { @@ -238,9 +435,6 @@ goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - replaceStateFn={(path) => { - replaceState(path, page.state) - }} >