Merge remote-tracking branch 'origin/main' into gl/layout-ai

This commit is contained in:
Guilhem Lemouel
2026-05-20 18:38:26 +02:00
90 changed files with 5238 additions and 1467 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1
View File
@@ -13871,6 +13871,7 @@ dependencies = [
name = "windmill-ai"
version = "1.704.1"
dependencies = [
"async-stream",
"async-trait",
"aws-config",
"aws-credential-types",
+1 -1
View File
@@ -1 +1 @@
3489c243b0e5a8eb0dbc86e90917fbe72843573b
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
@@ -0,0 +1,3 @@
ALTER TABLE variable
DROP COLUMN IF EXISTS edited_by,
DROP COLUMN IF EXISTS edited_at;
@@ -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);
@@ -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;
@@ -0,0 +1,12 @@
-- `draft.created_at` was originally created as `TIMESTAMP` (no timezone). The
-- new `*WithDraft` API responses surface it as `chrono::DateTime<Utc>` 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;
+1
View File
@@ -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
+513 -27
View File
@@ -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<String, Error> {
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<Vec<GeminiTool>>,
generation_config: Option<GeminiGenerationConfig>,
) -> Result<String, Error> {
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<Vec<GeminiTool>>,
generation_config: Option<GeminiGenerationConfig>,
) -> 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<f32>,
max_tokens: Option<u32>,
response_mime_type: Option<String>,
response_schema: Option<serde_json::Value>,
) -> Option<GeminiGenerationConfig> {
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<OpenAIMessage>,
#[serde(default)]
stream: bool,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
tools: Option<Vec<GoogleAIProxyChatTool>>,
}
#[derive(Deserialize, Debug)]
struct GoogleAIProxyChatTool {
function: GoogleAIProxyChatToolFunction,
}
#[derive(Deserialize, Debug)]
struct GoogleAIProxyChatToolFunction {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct GeminiModel {
name: String,
#[serde(rename = "displayName", default)]
display_name: String,
}
#[derive(Deserialize)]
struct GeminiModelsResponse {
#[serde(default)]
models: Vec<GeminiModel>,
}
struct GoogleAIProxyRequest {
request: ProxyRequest,
model: String,
stream: bool,
}
pub enum GoogleAIProxyResponseBody {
Fixed(Bytes),
Stream(BoxStream<'static, std::result::Result<Bytes, reqwest::Error>>),
}
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<GoogleAIProxyResponse, Error> {
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<GoogleAIProxyResponse, Error> {
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<serde_json::Value> = 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<GoogleAIProxyRequest, Error> {
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<GeminiFunctionDeclaration> = 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<reqwest::Response, Error> {
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, reqwest::Error>(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, reqwest::Error>(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<GoogleAIProxyResponse, Error> {
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())));
}
}
+61 -5
View File
@@ -46,6 +46,24 @@ pub struct ProxyRequest {
pub body: Vec<u8>,
}
/// 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<ProxyRequest> {
@@ -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]
+4
View File
@@ -1480,6 +1480,9 @@ pub struct FlowWDraft {
pub extra_perms: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[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,
+13 -4
View File
@@ -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?;
+5 -1
View File
@@ -93,6 +93,9 @@ pub struct ScriptWDraft<SR> {
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub schema: Option<Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
@@ -170,6 +173,7 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
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<ScriptRunnableSettingsHandle>>(
"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",
+17
View File
@@ -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
+66 -123
View File
@@ -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<RequestBuilder> {
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<Bytes> {
tracing::debug!("Adding user to request body");
let mut json_body: HashMap<String, Box<RawValue>> = 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<S>(
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)?;
+46 -13
View File
@@ -217,6 +217,9 @@ pub struct AppWithLastVersionAndDraft {
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[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<AppUploadFileResponse> {
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::<Vec<S3Key>>(&force_viewer_allowed_s3_keys).unwrap_or_default())
} else {
None
+2 -1
View File
@@ -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
-354
View File
@@ -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<OpenAIMessage>,
#[serde(default)]
stream: bool,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
tools: Option<Vec<ChatRequestTool>>,
}
#[derive(Deserialize, Debug)]
struct ChatRequestTool {
function: ChatRequestToolFunction,
}
#[derive(Deserialize, Debug)]
struct ChatRequestToolFunction {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<serde_json::Value>,
}
// ============================================================================
// 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<GeminiFunctionDeclaration> = 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, reqwest::Error>(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, reqwest::Error>(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<GeminiModel>,
}
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<serde_json::Value> = 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)))
}
-1
View File
@@ -79,7 +79,6 @@ mod capture;
mod concurrency_groups;
mod db;
mod db_health;
mod google;
mod drafts;
#[cfg(feature = "private")]
@@ -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());
+13 -1
View File
@@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize};
lazy_static::lazy_static! {
pub static ref SECRET_SALT: Option<String> = 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<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_specific: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_at: Option<chrono::DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_by: Option<String>,
}
#[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()),
@@ -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/
cp target/release/libwindmill_duckdb_ffi_internal.* ../target/debug/
@@ -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<Option<String>, 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<String>,
temp_directory: Option<String>,
}
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<String, 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<PrepareQueryResult> = 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<Vec<String>>), 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<Box<RawValue>>> = vec![];
let mut column_order = None;
+12 -5
View File
@@ -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<String> = 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?;
+89 -2
View File
@@ -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<Vec<String>>,
occupancy_metrics: &mut OccupancyMetrics,
parent_runnable_path: Option<String>,
job_dir: &str,
run_inline: bool,
) -> Result<Box<RawValue>> {
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<String> {
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<String> {
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<Item = &'a str>,
@@ -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<RawValue>, Option<Vec<String>>)> {
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<Box<RawValue>> {
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() {
+1
View File
@@ -4700,6 +4700,7 @@ pub async fn run_language_executor(
column_order,
occupancy_metrics,
parent_runnable_path,
job_dir,
run_inline,
))
.await;
+18 -18
View File
@@ -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",
+36 -1
View File
@@ -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.
+19
View File
@@ -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
+2 -49
View File
@@ -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)
@@ -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<string, ResourceState> = $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<Array<{ ws: string; defaultValue: ResourceState }>>([])
let initialStates: Record<string, ResourceState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let fetchedResources: Record<string, Resource> = $state({})
let perWsUser: Record<string, UserExt | undefined> = $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<string, string | undefined> = $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<ResourceState>(() =>
workspaceSpecs.map((s) => ({
itemKind: 'resource' as const,
path: initialPath ?? '',
workspace: s.ws,
defaultValue: s.defaultValue
}))
)
const states = $derived.by(() => {
const out: Record<string, UserDraftHandle<ResourceState>> = {}
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<ResourceState>('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 @@
}
</script>
<LocalDraftStaleModal
open={staleModalOpen}
cause="version"
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<div>
<div class="flex flex-col gap-6 py-2">
{#if otherDirty.length > 0}
@@ -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<boolean | undefined>(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()
@@ -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"
+11 -3
View File
@@ -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
@@ -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<string, VariableState> = $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<Array<{ ws: string; defaultValue: VariableState }>>([])
let initialStates: Record<string, VariableState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let extraPerms: Record<string, Record<string, boolean>> = $state({})
let perWsUser: Record<string, UserExt | undefined> = $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<string, string | undefined> = $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<VariableState>(() =>
workspaceSpecs.map((s) => ({
itemKind: 'variable' as const,
path: editPath ?? '',
workspace: s.ws,
defaultValue: s.defaultValue
}))
)
const states = $derived.by(() => {
const out: Record<string, UserDraftHandle<VariableState>> = {}
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<VariableState>('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 @@
}
</script>
<LocalDraftStaleModal
open={staleModalOpen}
cause="version"
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<Drawer bind:this={drawer} size="50rem">
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
@@ -29,11 +29,12 @@
import { userStore, workspaceStore } from '$lib/stores'
import {
classNames,
encodeState,
getModifierKey,
readFieldsRecursively,
sendUserToast,
urlParamsToObject
} from '$lib/utils'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import AppPreview from './AppPreview.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
@@ -78,13 +79,55 @@
gotoFn = (path: string, opt?: Record<string, any>) => 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>('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<App>(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<string[] | undefined>(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'
})
@@ -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)
@@ -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')
}
</script>
+10
View File
@@ -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 = {
@@ -0,0 +1,125 @@
<script lang="ts">
/**
* Modal shown when the four route-level editors detect that the
* remote state has moved on since the local autosave was captured —
* either a teammate (or another tab) pushed a fresh "Save as draft"
* via `DraftService` (`cause = 'draft'`), or the deployed version
* changed (`cause = 'version'`).
*
* Sits above the per-browser `UserDraft` autosave layer and is
* separate from the backend `DraftService` flow. The actual reset
* actions live at each call site; this modal just owns the
* "Load latest version" vs "Keep current draft" decision.
*/
import { classNames } from '$lib/utils'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
// 'url' is temporary — used by /scripts/{add,edit}'s URL-hash sync block
// while we wait for a future PR to replace that legacy behavior.
type Cause = 'draft' | 'version' | 'url'
let {
open = false,
cause = 'version',
onLoadLatest,
onKeepDraft
}: {
open?: boolean
/** What changed on the remote since the local draft was created. */
cause?: Cause
onLoadLatest: () => void | Promise<void>
onKeepDraft: () => void | Promise<void>
} = $props()
function onKeyDown(event: KeyboardEvent) {
if (!open) return
event.stopPropagation()
event.preventDefault()
switch (event.key) {
case 'Enter':
onLoadLatest()
break
case 'Escape':
onKeepDraft()
break
}
}
function fadeFast(node: HTMLElement) {
return fade(node, { duration: 100 })
}
const title = $derived(
cause === 'draft'
? 'A newer draft was saved on the server'
: cause === 'url'
? 'The URL contains a different script payload'
: 'A newer version was deployed on the server'
)
const body = $derived(
cause === 'draft'
? "The editor is showing your local autosave. Someone else (or another tab) pushed a newer draft to the server while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
: cause === 'url'
? 'The current page URL encodes a script (e.g. from a Fork link or shared URL) that differs from your local autosave. Load from URL replaces your local draft; Keep current draft ignores the URL payload.'
: "The editor is showing your local autosave. A newer version was deployed while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
)
const loadLabel = $derived(cause === 'url' ? 'Load from URL' : 'Load latest version')
</script>
<svelte:window onkeydowncapture={onKeyDown} />
{#if open}
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
<div
class={classNames(
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
)}
></div>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class={classNames(
'relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
open
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
)}
>
<div class="flex">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
>
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
</div>
<div class="ml-4 flex-1 text-left">
<h3 class="text-lg font-medium text-primary">{title}</h3>
<p class="mt-2 text-sm text-secondary">{body}</p>
</div>
</div>
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
<Button
on:click={() => onLoadLatest()}
color="dark"
size="sm"
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
variant="accent"
>
<span class="min-w-20">{loadLabel}</span>
</Button>
<Button
on:click={() => onKeepDraft()}
variant="default"
size="sm"
shortCut={{ key: 'Esc', withoutModifier: true }}
>
Keep current draft
</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
@@ -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)
)
@@ -107,7 +107,8 @@ export async function parseAnthropicCompletion(
toolName,
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
showDetails: tool?.showDetails
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
})
}
}
@@ -420,7 +420,8 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
},
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<FlowAIChatHelpers>[] = [
},
requiresConfirmation: true,
confirmationMessage: 'Run flow step test',
showDetails: true
showDetails: true,
autoCollapseDetails: false
},
{
def: inspectInlineScriptToolDef,
@@ -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
})
}
})
@@ -905,7 +905,8 @@ export const testRunScriptTool: Tool<ScriptChatHelpers> = {
},
requiresConfirmation: true,
confirmationMessage: 'Run script test',
showDetails: true
showDetails: true,
autoCollapseDetails: false
}
export const getLintErrorsTool: Tool<ScriptChatHelpers> = {
@@ -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')
})
@@ -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<T>({
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<T>({
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<T>({
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<T>({
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<T> {
requiresConfirmation?: boolean
confirmationMessage?: string
showDetails?: boolean
autoCollapseDetails?: boolean
streamArguments?: boolean
showFade?: boolean
}
@@ -1069,6 +1069,7 @@ export async function parseOpenAICompletion(
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails,
parameters: parameters
})
}
@@ -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?.()
}
@@ -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}
@@ -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)
@@ -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
@@ -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
@@ -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<void> {
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
@@ -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<EmailTrigger>),
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
@@ -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<void> {
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
@@ -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<HttpTrigger>),
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
@@ -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<void> {
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
@@ -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<void> {
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
@@ -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<void> {
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
@@ -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
@@ -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<string, any>) {
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<void> {
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()
}
@@ -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<Record<string, any> | 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<void> {
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).
</script>
{#if mode === 'suspended'}
@@ -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<string, any> | 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
}
@@ -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<string, any>
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<void>
/**
* 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<void>
/**
* 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<Cfg>(() => {
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
}
}
@@ -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<void> {
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
@@ -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>('FlowEditorContext')
const { flowStore, flowStateStore, selectionManager, currentEditor } =
getContext<FlowEditorContext>('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<void> {
async function typeText(
input: HTMLInputElement,
text: string,
delay: number = DELAY_TYPING
): Promise<void> {
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 @@
<Tutorial
bind:this={tutorial}
index={index}
{index}
name="flow-live-tutorial"
tainted={isFlowTainted(flowStore.val)}
on:error
@@ -260,7 +260,9 @@
overlay.style.left = '0'
}
const celsiusInput = document.querySelector('input[type="number"][placeholder=""]') as HTMLInputElement
const celsiusInput = document.querySelector(
'input[type="number"][placeholder=""]'
) as HTMLInputElement
if (celsiusInput) {
celsiusInput.value = ''
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
@@ -312,7 +314,9 @@
await wait(DELAY_LONG)
const spans = Array.from(document.querySelectorAll('span'))
const bunSpan = spans.find(span => 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.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
description:
'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
onNextClick: () => {
updateProgress(index)
driver.destroy()
@@ -712,7 +746,7 @@
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
}
]
return steps
+1
View File
@@ -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'
+42 -10
View File
@@ -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<string, WorkspaceCache>()
/** 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<string, WorkspaceCache>()
const inflight = new Map<string, Promise<WorkspaceItem[]>>()
/** 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<string, number>()
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<WorkspaceItem[]> {
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)
-8
View File
@@ -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)
+114 -6
View File
@@ -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, Props>(component: Component, props: Props) {
@@ -575,8 +575,36 @@ export class DebouncedTempValue<T> {
export function useLocalStorageValue<T>(
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<T>(
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<T>(
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<typeof setTimeout> | 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
}
}
}
+10
View File
@@ -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?: {
+677
View File
@@ -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<V> = 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<V> = {
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<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
if (stored === undefined) return undefined
return { ...stored, lastWrittenAt: Date.now() }
}
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
}
type DraftEntry = {
count: number
state: DraftState<unknown>
/**
* 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<string, DraftEntry>()
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<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
if (value === undefined) return undefined
const out: StoredDraft<V> = { value }
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
return out
}
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
return stored?.value
}
function extractMeta(stored: StoredDraft<unknown> | 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<V>(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<V>(key: string): StoredDraft<V> | 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<V>
} 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<V> = {
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<V>(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<V>(
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<V>(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<unknown> | 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<unknown>(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<V>(
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<V = unknown>(
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<V> | undefined)
}
return unwrap(readPersisted<V>(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<unknown> | undefined)
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
}
const existing = readPersisted<unknown>(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<unknown> | undefined)
return extractMeta(readPersisted<unknown>(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<V>(
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<unknown> | undefined
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.discard: localStorage remove failed', e)
}
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftUseOptions<V>
): UserDraftHandle<V> {
// `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<V>(() =>
untrack(() => [
{
itemKind,
path,
workspace: opts?.workspace,
defaultValue: opts?.defaultValue
}
])
)
return handles[0]
},
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
// 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<UserDraftHandle<V>[]>([])
const acquired = new Set<string>()
const handleCache = new Map<string, UserDraftHandle<V>>()
function reconcile() {
const specs = getSpecs()
const seen = new Set<string>()
const next: UserDraftHandle<V>[] = []
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<V>(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<unknown>
} as const
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
stateRef = useLocalStorageValue<StoredDraft<unknown> | 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<StoredDraft<unknown> | 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<V>(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): UserDraftHandle<V> {
// 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<unknown> | undefined => entries.get(mk)?.state
return {
get draft(): V | undefined {
return unwrap(stateOf()?.val as StoredDraft<V> | 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<V> | undefined)
state.val = wrap(value, extractMeta(current))
},
get meta(): UserDraftMeta {
return extractMeta(stateOf()?.val as StoredDraft<unknown> | 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<V> | 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()
}
+721
View File
@@ -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<string, unknown>
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: <draft> } so
// future metadata fields can be added without breaking existing entries.
function wrapped<V>(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<string>('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<number>('flow', 'u/me/shared')
const b = UserDraft.use<number>('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<number>('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<number>('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<string>('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<string>('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<string>('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<string>('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<number>('flow', 'u/me/iso', { workspace: 'ws_a' })
const b = UserDraft.use<number>('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<string>('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<string>('flow', 'u/me/withdefault', { defaultValue: 'fallback' })
expect(handle.draft).toBe('fallback')
})
it('does not persist the defaultValue on first read', () => {
UserDraft.use<string>('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<string>('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<string>('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<number>('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<number>('flow', '')
const b = UserDraft.use<number>('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<string>('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<string>('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<string>('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<string>('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<number>('flow', 'u/me/ref')
a.draft = 1 // baseline write — not persisted
// Second handle increments the count.
const b = UserDraft.use<number>('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<string>('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<string>('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<string>('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<number>(() => [
{ 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))
})
})
@@ -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<V>(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()
})
})
@@ -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: <transformed legacy 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/<owner>/<name…>` or `f/<folder>/<name…>`. The
* `<name…>` 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<string, unknown>
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<string, unknown>
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
}
}
+31
View File
@@ -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<void>
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
onResetToDeployed?: () => void | Promise<void>
}
/** 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<void> }> = []
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)
}
@@ -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())
})
@@ -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()
@@ -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<UserDraftMeta | undefined>(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>('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 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#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)}
>
@@ -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<string, string>
runnables: Record<string, Runnable>
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<string, string> = $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<string, Runnable> = $state({
const defaultRunnables: Record<string, Runnable> = {
a: {
name: 'a',
fields: {},
@@ -101,9 +110,55 @@
}
}
}
}
let summary = $state(restoredDraft?.summary ?? '')
let files: Record<string, string> = $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<string, Runnable> = $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)
}
}
@@ -1,18 +1,39 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { untrack } from 'svelte'
import { AppService, 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,
readFieldsRecursively,
type Value
} from '$lib/utils'
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 { HiddenRunnable } from '$lib/components/apps/types'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
UserDraft,
checkStaleness,
localDraftDiffers,
type UserDraftMeta
} from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
}
let files: Record<string, string> | undefined = $state(undefined)
let runnables = $state({})
/** Data configuration including tables and creation policy */
@@ -37,15 +58,74 @@
}
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
let nodraft = page.url.searchParams.get('nodraft')
// `?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 the handle is created. A plain reload (no
// nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('raw_app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
// 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: RawAppDraft; backendSource: any; revs: UserDraftMeta }
| undefined = undefined
function onStaleLoadLatest(): void {
if (!pendingBaseline) {
staleModalOpen = false
return
}
const { baseline, backendSource, revs } = pendingBaseline
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(baseline, revs)
extractRawApp(backendSource)
pendingBaseline = undefined
staleModalOpen = false
redraw++
}
function onStaleKeepDraft(): void {
if (pendingBaseline) {
draftHandle.setMeta(pendingBaseline.revs, { force: true })
}
pendingBaseline = undefined
staleModalOpen = false
}
// Persist the bundle whenever any of the four pieces of state changes.
$effect(() => {
if (!files) return
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
void summary
draftHandle.draft = { files, runnables, data, summary }
})
// Reflect an external UserDraft.save into the form. Idempotent; the
// `!files` guard skips the reload window so it doesn't fight loadApp.
$effect(() => {
const d = draftHandle.draft
if (d == null || !files) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
}
})
})
function extractRawApp(app: any) {
@@ -73,10 +153,6 @@
newPath = app.path
}
const initialState = nodraft ? undefined : localStorage.getItem(`rawapp-${page.params.path}`)
let stateLoadedFromLocalStorage =
initialState != undefined ? decodeState(initialState) : undefined
/** Increments per `loadApp` 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. */
@@ -99,48 +175,81 @@
custom_path: app_w_draft_.custom_path
}
if (stateLoadedFromLocalStorage) {
const reloadAction = async () => {
stateLoadedFromLocalStorage = undefined
await loadApp()
redraw++
}
const actions: ToastAction[] = []
if (stateLoadedFromLocalStorage) {
actions.push({
label: 'Discard browser autosave and reload',
callback: reloadAction
})
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
const localDraft = draftHandle.draft
const previousMeta = draftHandle.meta
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
}
const backendBundle: RawAppDraft = {
files: backendSource.value?.files ?? {},
runnables: backendSource.value?.runnables ?? {},
data:
backendSource.value?.data ??
(backendSource.value?.datatables
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? ''
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const urlScript = {
...draftOrDeployed,
value: stateLoadedFromLocalStorage
if (
localDraft != undefined &&
orderedJsonStringify(cleanValueProperties(localDraft)) !==
orderedJsonStringify(cleanValueProperties(backendBundle))
) {
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendBundle, backendSource, 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.
draftHandle.setMeta(newRevs, { force: true })
}
actions.push({
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 }
})
const appPath = app_w_draft.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
extractRawApp(backendSource)
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.remove('raw_app', path)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadApp doesn't re-read
// the stale autosave and re-fire the same toast.
draftHandle.setDraftAndMeta(undefined, {})
await loadApp()
redraw++
}
})
}
sendUserToast('App restored from browser storage', false, actions)
app_w_draft.value = stateLoadedFromLocalStorage
extractRawApp(app_w_draft)
redraw += 1
} else if (app_w_draft.draft) {
extractRawApp(app_w_draft.draft)
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
if (localDraft != undefined) UserDraft.remove('raw_app', path)
extractRawApp(backendSource)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
if (!app_w_draft.draft_only) {
if (app_w_draft.draft && !app_w_draft.draft_only) {
const reloadAction = () => {
stateLoadedFromLocalStorage = undefined
extractRawApp(app_w_draft)
redraw++
}
@@ -149,7 +258,7 @@
const draft = cleanValueProperties({ files, runnables })
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Discard draft and load from latest deployed version',
label: 'Reset to deployed',
callback: reloadAction
},
{
@@ -167,21 +276,18 @@
}
])
}
} else {
extractRawApp(app_w_draft)
}
}
run(() => {
// Re-run on workspace OR path change so navigating from one raw 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) {
// Clear files so RawAppEditor unmounts; it will remount when loadApp
// completes with fresh data, re-initializing its internal stores.
files = undefined
const s = nodraft ? undefined : localStorage.getItem(`rawapp-${newPath}`)
stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined
path = currentPath
loadApp()
}
})
@@ -192,6 +298,12 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('raw_app', path)
// Drop the in-memory handle state so loadApp 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.
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -210,6 +322,8 @@
path: savedApp.path
})
}
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -233,12 +347,19 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if files}
{#key redraw}
<div class="h-screen">
<RawAppEditor
on:savedNewAppPath={(event) => {
UserDraft.remove('raw_app', path)
goto(`/apps_raw/edit/${event.detail}`)
newPath = event.detail
}}
@@ -1,10 +1,9 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
import { page } from '$app/state'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte'
import { FlowService, type Flow } from '$lib/gen'
@@ -14,16 +13,19 @@
import { tick } from 'svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
import { UserDraft } from '$lib/userDraft.svelte'
let nodraft = page.url.searchParams.get('nodraft')
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
// "+ Flow" 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 /flows/add (no nodraft) instead restores whatever the
// user was last working on.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
@@ -44,9 +46,6 @@
}
}
const initialState =
hubId || templatePath || nodraft || isFork ? undefined : localStorage.getItem('flow')
let selectedId: string = $state('settings-metadata')
let loading = $state(false)
@@ -61,8 +60,8 @@
// initialArgs may also be set from decoded state below (e.g. fork preview)
let flowBuilder: FlowBuilder | undefined = $state(undefined)
const flowStore: StateStore<Flow> = $state({
val: {
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -72,25 +71,32 @@
extra_perms: {},
schema: emptySchema()
}
})
}
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
async function loadFlow() {
loading = true
let flow: Flow = {
path: '',
summary: '',
value: { modules: [] },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
// Start from the persisted autosave, not a fresh `emptyFlow()`. The
// branches below override `flow` when the user explicitly asked for a
// different starting point (import/fork/URL state/template/hub); a
// plain reload of /flows/add (no query params) falls through with
// the LS value intact so the user's last session is restored.
let flow: Flow = flowHandle.draft ?? emptyFlow()
let state = forkState ?? (initialState ? decodeState(initialState) : undefined)
let state = forkState
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
if (initialStateQuery) {
@@ -101,24 +107,6 @@
$importFlowStore = undefined
sendUserToast('Flow loaded from YAML/JSON')
} else if (!templatePath && !hubId && state) {
sendUserToast('Flow restored from draft', false, [
{
label: 'Start from blank instead',
callback: () => {
flowStore.val = {
summary: '',
value: { modules: [] },
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
}
}
])
flow = state.flow
pathStoreInit = state.path
if (state.initialArgs) {
@@ -143,12 +131,15 @@
path: templatePath
})
}
// Template/hub flows are an explicit "start fresh from this
// content" — drop any previous empty-path autosave and use
// the freshly built flow as the baseline.
flow = emptyFlow()
Object.assign(flow, template)
const oldPath = templatePath.split('/')
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
oldPath[oldPath.length - 1]
}_fork`
flow = flow
goto('?', { replaceState: true })
selectedId = 'settings-metadata'
} else if (hubId) {
@@ -157,6 +148,7 @@
initialPath = `u/${$userStore?.username
.split('@')[0]
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
flow = emptyFlow()
Object.assign(flow, hub.flow)
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
@@ -164,7 +156,6 @@
flow.value.preprocessor_module.value.content
)
}
flow = flow
goto('?', { replaceState: true })
selectedId = 'constants'
}
@@ -194,9 +185,13 @@
<FlowBuilder
onSaveInitial={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
onDeploy={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
@@ -2,7 +2,7 @@
import { FlowService, type Flow, DraftService } from '$lib/gen'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import {
cleanValueProperties,
@@ -13,23 +13,25 @@
} from '$lib/utils'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
let version: undefined | number = $state(undefined)
// `initialArgs` is captured once at mount — it's the session's initial
// argument set. Per-flow autosave (`stateLoadedFromUrl`) and the
// `nodraft` flag are re-read inside `loadFlow` / the navigation hook so
// picker navigation doesn't reuse the original path's state.
// argument set. The flow draft itself lives in UserDraft and is re-read
// per loadFlow() so picker navigation doesn't reuse the original path's
// state.
const urlArgs = page.url.searchParams.get('initial_args')
let initialArgs = $state({})
@@ -46,16 +48,23 @@
})
| undefined = $state(undefined)
afterNavigate(() => {
if (page.url.searchParams.get('nodraft')) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
const flowDraftPath = page.params.path ?? ''
export const flowStore: StateStore<Flow> = $state({
val: {
// `?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 the handle is created — same pattern as
// /flows/add. A plain reload (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', flowDraftPath)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const flowHandle = UserDraft.use<Flow>('flow', flowDraftPath)
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -65,7 +74,16 @@
extra_perms: {},
schema: emptySchema()
}
})
}
export const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let loading = $state(false)
@@ -74,15 +92,34 @@
let nobackenddraft = false
// One-shot read of mount-time autosave, used only to seed the initial
// `savedPrimarySchedule` before `loadFlow` runs. `loadFlow` itself
// re-reads localStorage on every invocation (see comment there).
const initialAutosave = (() => {
if (page.url.searchParams.get('nodraft')) return undefined
const raw = localStorage.getItem(`flow-${page.params.path}`)
return raw != undefined ? decodeState(raw) : undefined
})()
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(initialAutosave?.primarySchedule)
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
// 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: Flow; revs: UserDraftMeta } | undefined = undefined
function onStaleLoadLatest(): void {
if (!pendingBaseline) {
staleModalOpen = false
return
}
const { baseline, revs } = pendingBaseline
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(baseline, revs)
pendingBaseline = undefined
staleModalOpen = false
loadFlow()
}
function onStaleKeepDraft(): void {
if (pendingBaseline) {
flowHandle.setMeta(pendingBaseline.revs, { force: true })
}
pendingBaseline = undefined
staleModalOpen = false
}
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
@@ -100,77 +137,127 @@
async function loadFlow(): Promise<void> {
const tok = ++loadFlowToken
loading = true
// Re-read autosave per load. The component doesn't remount when the
// picker navigates between flows, so capturing at module init would
// keep reusing the original flow's state.
const stored = page.url.searchParams.get('nodraft')
? undefined
: localStorage.getItem(`flow-${page.params.path}`)
const stateLoadedFromUrl = stored != undefined ? decodeState(stored) : undefined
let flow: Flow
let statePath = stateLoadedFromUrl?.path
if (stateLoadedFromUrl != undefined && statePath == page.params.path) {
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: statePath
})
)?.id
if (tok !== loadFlowToken) return
version = v
if (version == undefined) {
notFound = true
sendUserToast(`Flow not found at path ${statePath}`, true)
return
}
const sf = await FlowService.getFlowByPathWithDraft({
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: statePath
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = sf
).id
if (tok !== loadFlowToken) return
version = v
const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow)
const urlScript = cleanValueProperties(
$state.snapshot({
...stateLoadedFromUrl.flow,
draft_triggers: stateLoadedFromUrl.draft_triggers
})
)
flow = stateLoadedFromUrl.flow
draftTriggersFromUrl = stateLoadedFromUrl.draft_triggers
selectedTriggerIndexFromUrl = stateLoadedFromUrl.selected_trigger
loadedFromHistoryFromUrl = stateLoadedFromUrl.loadedFromHistory
flowBuilder?.setDraftTriggers(draftTriggersFromUrl)
flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl)
flowBuilder?.setLoadedFromHistory(loadedFromHistoryFromUrl)
const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata'
const reloadAction = () => {
// Discard the localStorage autosave so the next `loadFlow`
// (re-)read sees an empty slot and falls through to the
// fetch branch — otherwise we'd re-enter this branch and
// loop. Scripts dodge this because their state lives in
// the URL fragment, which `goto` clears for us.
try {
localStorage.removeItem(`flow-${statePath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
goto(`/flows/edit/${statePath}?selected=${selectedId}`)
loadFlow()
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
reloadAction()
}
const backendFlow =
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
const localDraft = flowHandle.draft
const previousMeta = flowHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: v,
remoteDraftRev: flowWithDraft.draft_created_at
}
if (localDraft != undefined) {
const localClean = cleanValueProperties(localDraft)
const backendClean = cleanValueProperties(backendFlow)
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
// Local matches backend exactly — silently drop the autosave.
flow = backendFlow
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
} else {
sendUserToast('Flow loaded from browser storage', false, [
flow = localDraft
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendFlow, 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.
flowHandle.setMeta(newRevs, { force: true })
}
const flowPath = backendFlow.path
const hasBackendDraft = flowWithDraft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
loadFlow()
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flowPath
})
}
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadFlow doesn't re-read
// the stale autosave and re-fire the same toast.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
})
}
}
} else {
flow = backendFlow
flowHandle.setDraftAndMeta(backendFlow, newRevs)
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. The
// flowHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadFlow 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.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow loaded from latest saved draft', false, [
{
label: 'Discard browser autosave and reload',
label: 'Reset to deployed',
callback: reloadAction
},
{
@@ -179,93 +266,23 @@
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedFlow?.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 }
})
}
}
])
}
} else {
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
).id
if (tok !== loadFlowToken) return
version = v
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
flow = flowWithDraft.draft
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow 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 {
flow = flowWithDraft
flowBuilder?.setDraftTriggers(undefined)
}
flowBuilder?.setDraftTriggers(undefined)
}
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false
selectedId = stateLoadedFromUrl?.selectedId ?? page.url.searchParams.get('selected')
selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata'
flowBuilder?.loadFlowState()
}
@@ -286,6 +303,12 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('flow', flowDraftPath)
// Drop the in-memory handle state so loadFlow 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.
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.draft.path}`)
loadFlow()
}
@@ -303,6 +326,8 @@
path: savedFlow.path
})
}
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.path}`)
loadFlow()
}
@@ -311,6 +336,12 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
@@ -319,6 +350,8 @@
{:else}
<FlowBuilder
onDeploy={(e) => {
UserDraft.remove('flow', flowDraftPath)
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
@@ -6,9 +6,18 @@
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import type { Schema } from '$lib/common'
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import {
cleanValueProperties,
decodeState,
emptySchema,
emptyString,
encodeState,
orderedJsonStringify,
readFieldsRecursively,
sendUserToast
} from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
@@ -17,6 +26,7 @@
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
import { UserDraft } from '$lib/userDraft.svelte'
type Script = NewScript & {
draft_triggers?: Trigger[]
@@ -35,25 +45,46 @@
const wacParam = page.url.searchParams.get('wac')
const importParam = page.url.searchParams.get('import')
/** Some pages (run/[...run]'s "Fork" action, workspace_settings'
* error/success-handler template buttons) base64-JSON-encode a NewScript
* payload into the URL hash. That value is an explicit "open this script"
* intent and wins over local autosave, templates, hubs, and YAML imports.
*
* We can't use `decodeState` from utils.ts directly — it fires its own
* "Impossible to parse state" toast on failure, which would noise up the
* UI when the hash isn't a script payload at all (e.g. a route anchor).
*/
function decodeUrlScript(): Partial<Script> | 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<Script>
} catch {
// Hash isn't a valid encoded script — ignore.
}
return undefined
}
const urlScript = decodeUrlScript()
// "+ Script" 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 /scripts/add (no nodraft) instead restores the
// previous session.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('script', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
if (get(initialArgsStore)) $initialArgsStore = undefined
const path = page.url.searchParams.get('path')
const initialState = page.url.hash != '' ? page.url.hash.slice(1) : undefined
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
function decodeStateAndHandleError(state) {
try {
const decoded = decodeState(state)
return decoded
} catch (e) {
console.error('Error decoding state', e)
return defaultScript()
}
}
function defaultScript(): Script {
return {
hash: '',
@@ -74,22 +105,78 @@
}
}
let script: Script | undefined = $state(
templatePath || hubPath
? undefined
: !path && initialState != undefined
? decodeStateAndHandleError(initialState)
: defaultScript()
)
// templatePath/hubPath/import/url-hash flows replace the value before
// render, so defaultValue is left undefined for those to avoid flashing a
// blank editor.
const scriptHandle = UserDraft.use<Script>('script', '', {
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
})
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
// sync with edits (encoded back into the hash, debounced). Asks the user
// via modal when the URL payload would clobber an existing local autosave.
let urlConflictModalOpen = $state(false)
let pendingUrlPayload: Script | undefined = undefined
if (urlScript) {
const seeded = { ...defaultScript(), ...urlScript } as Script
const existing = scriptHandle.draft
if (existing) {
const localClean = orderedJsonStringify(cleanValueProperties(existing))
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
if (localClean !== seededClean) {
pendingUrlPayload = seeded
urlConflictModalOpen = true
}
} else {
scriptHandle.draft = seeded
sendUserToast('Loaded from URL')
}
}
function onUrlConflictUseUrl() {
if (pendingUrlPayload) {
scriptHandle.draft = pendingUrlPayload
sendUserToast('Loaded from URL')
}
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
function onUrlConflictKeepLocal() {
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
let _urlHashSyncTimeout: number | undefined
$effect(() => {
const draft = scriptHandle.draft
if (!draft) return
// Gate while the conflict modal is open so we don't overwrite the URL
// payload before the user has decided.
if (urlConflictModalOpen) 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 ===
async function loadTemplate(): Promise<void> {
if (urlScript) return
if (templatePath) {
try {
const template = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: templatePath
})
script = {
scriptHandle.draft = {
...defaultScript(),
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
description: template.description,
@@ -99,7 +186,7 @@
path: template.path + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading template', err)
sendUserToast('Error loading template: ' + err.message, true)
}
@@ -107,12 +194,13 @@
}
async function loadHub(): Promise<void> {
if (urlScript) return
if (hubPath) {
try {
const { content, language, summary } = await ScriptService.getHubScriptByPath({
path: hubPath
})
script = {
scriptHandle.draft = {
...defaultScript(),
description: `Fork of ${hubPath}`,
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
@@ -121,7 +209,7 @@
path: hubPath + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading script from hub', err)
sendUserToast('Error loading script from hub: ' + err.message, true)
}
@@ -131,11 +219,11 @@
loadHub()
let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined
if (importParam && $importScriptStore) {
if (!urlScript && importParam && $importScriptStore) {
const imported = $importScriptStore
$importScriptStore = undefined
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
script = {
scriptHandle.draft = {
...defaultScript(),
...imported,
path: path ?? '',
@@ -157,7 +245,15 @@
})
</script>
{#if script}
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
<LocalDraftStaleModal
open={urlConflictModalOpen}
cause="url"
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft}
<ScriptBuilder
{initialArgs}
bind:this={scriptBuilder}
@@ -176,9 +272,8 @@
}}
onNavigate={(item) => goto(editPathFor(item))}
searchParams={page.url.searchParams}
bind:script
bind:script={scriptHandle.draft}
{showMeta}
replaceStateFn={(path) => replaceState(path, page.state)}
>
<UnsavedConfirmationModal
getInitialAndModifiedValues={scriptBuilder?.getInitialAndModifiedValues}
@@ -3,39 +3,147 @@
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import {
cleanValueProperties,
encodeState,
orderedJsonStringify,
readFieldsRecursively
} from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
// `initialArgs` is intentionally captured once at mount — it's the
// session's initial argument set, not per-script. URL-derived state
// (`hash`, `topHash`, fragment autosave) is re-read inside `loadScript`
// because picker navigation reuses this component without remounting.
// session's initial argument set, not per-script.
let initialArgs = get(initialArgsStore) ?? {}
if (get(initialArgsStore)) $initialArgsStore = undefined
let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined)
let topHash = page.url.searchParams.get('topHash') ?? undefined
let initialPath: string = $state('')
let hash = page.url.searchParams.get('hash') ?? undefined
// When viewing a specific historical hash we don't want to load or write a
// local draft — that view is read-only relative to drafts.
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
* hash on `/scripts/edit/<path>#…`. 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<EditableScript> | 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<EditableScript>
} 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<void> = 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<void> {
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()
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
{#if script}
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
<LocalDraftStaleModal
open={urlConflictModalOpen}
cause="url"
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft}
<ScriptBuilder
bind:this={scriptBuilder}
{initialPath}
bind:script
bind:script={scriptHandle.draft}
{fullyLoaded}
bind:savedScript
{initialArgs}
@@ -229,6 +424,8 @@
{savedPrimarySchedule}
searchParams={page.url.searchParams}
onDeploy={(e) => {
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)
}}
>
<UnsavedConfirmationModal
{diffDrawer}