mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
Merge remote-tracking branch 'origin/main' into gl/layout-ai
This commit is contained in:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+23
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -13871,6 +13871,7 @@ dependencies = [
|
||||
name = "windmill-ai"
|
||||
version = "1.704.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -4700,6 +4700,7 @@ pub async fn run_language_executor(
|
||||
column_order,
|
||||
occupancy_metrics,
|
||||
parent_runnable_path,
|
||||
job_dir,
|
||||
run_inline,
|
||||
))
|
||||
.await;
|
||||
|
||||
Generated
+18
-18
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user