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

Conflicts: WorkspaceItemDrillPicker, RawAppEditorHeader, copilot/global/core.

Main rewrote the global chat draft layer to live entirely on `UserDraft`
(see #9291 — `globalDraftStore` deleted, replaced by `userDraftAdapter`
which surfaces drafts as `WorkspaceItem`s for the chat). The session
runtime and the three session editor views were the only remaining
consumers of `globalDraftStore`, so this merge folds them onto the same
single-source-of-truth.

Session ↔ chat plumbing (the user's stated requirement):
- `FlowEditorView`, `ScriptEditorView`, `RawAppEditorView` register
  themselves as the live editor for `(workspace, itemKind)` via
  `UserDraft.setLiveEditorDraft({...})` on mount and clear on unmount —
  same registration the `/scripts/edit`, `/flows/edit`,
  `/apps_raw/edit` route pages now do. The session's chat sees the
  open path through the `isLiveDraft` hint and can target it with
  `discord_local_draft` or `read_workspace_item`.
- Bidirectional editor ↔ chat sync now goes through `UserDraft.get/save`
  directly. The session's `workspace_id` (a fork) is passed as
  `opts.workspace` so reads/writes target the fork's UserDraft scope —
  the session's chat operates in the same scope, so they share content
  without an extra bridge.
- The slim `FlowDraftValue`/`AppDraftValue` shapes the old store kept
  are gone; `UserDraft<Flow>` and `UserDraft<RawAppDraft>` store the
  full editor types (matching how the chat tools write them in
  `core.ts`). `flowDraftCodec` deleted; `appDraftCodec` renamed
  `applyDraftValueToRawApp` → `applyDraftToRuntimeRawApp` to reflect
  that the draft type *is* the editor shape now.
- `WorkspaceItemDrillPicker.aiDraftsForKind` reads via
  `listGlobalDrafts(workspace)` (the new userDraftAdapter export)
  instead of `globalDraftStore.listDrafts`.

The session retry-loop fixes, the not-found EditorHeader, and the
drill-picker $effect→callback refactor from earlier on this branch all
ride along unchanged.
This commit is contained in:
Guilhem Lemouel
2026-05-25 17:03:53 +02:00
41 changed files with 3706 additions and 1778 deletions
+1 -5
View File
@@ -13875,6 +13875,7 @@ dependencies = [
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-bedrock",
"aws-sdk-bedrockruntime",
"aws-smithy-types",
"base64 0.22.1",
@@ -13923,13 +13924,8 @@ dependencies = [
"async-stream",
"async-trait",
"async_zip",
"aws-config",
"aws-credential-types",
"aws-sdk-bedrock",
"aws-sdk-bedrockruntime",
"aws-sdk-config",
"aws-sigv4",
"aws-smithy-types",
"axum 0.8.9",
"base32",
"base64 0.22.1",
+2 -1
View File
@@ -6,7 +6,7 @@ edition.workspace = true
[features]
default = []
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
mcp = ["dep:windmill-mcp"]
[lib]
@@ -42,4 +42,5 @@ ulid.workspace = true
aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
aws-sdk-bedrock = { workspace = true, optional = true }
aws-sdk-bedrockruntime = { workspace = true, optional = true }
+775 -8
View File
@@ -7,21 +7,731 @@
//! - Helper utilities
use crate::{
ai_bedrock::{
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
BearerTokenProvider, BedrockClient, StreamingToolCall,
},
ai_providers::USE_ENV_REGION,
ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction},
image_handler::prepare_messages_for_api,
proxy::ProxyBuildArgs,
query_builder::{ParsedResponse, StreamEventSink},
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
};
use bytes::Bytes;
use futures::{stream::BoxStream, StreamExt};
use http::{HeaderMap, Method, StatusCode};
use serde::Deserialize;
use std::collections::HashMap;
use windmill_common::{client::AuthedClient, error::Error};
// Import shared Bedrock helpers for provider orchestration.
use crate::ai_bedrock::{
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
BedrockClient, StreamingToolCall,
};
// ============================================================================
// Native Proxy Execution
// ============================================================================
/// OpenAI-format request body for Bedrock SDK proxy handlers.
#[derive(Deserialize, Debug)]
struct OpenAIRequest {
messages: Vec<OpenAIMessage>,
#[serde(default)]
tools: Option<Vec<OpenAIToolDef>>,
#[serde(default)]
tool_choice: Option<serde_json::Value>,
#[serde(default)]
max_tokens: Option<i32>,
#[serde(default)]
temperature: Option<f32>,
}
#[derive(Deserialize, Debug)]
struct OpenAIToolDef {
#[serde(default)]
#[allow(dead_code)]
r#type: Option<String>,
function: OpenAIToolFunction,
}
#[derive(Deserialize, Debug)]
struct OpenAIToolFunction {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
struct BedrockProxyChatRequest {
model: String,
#[serde(default)]
stream: bool,
}
enum BedrockAuthConfig {
BearerToken(String),
IamCredentials {
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
},
Environment,
}
pub enum BedrockProxyResponseBody {
Fixed(Bytes),
Stream(BoxStream<'static, std::result::Result<Bytes, std::io::Error>>),
}
pub struct BedrockProxyResponse {
pub status_code: StatusCode,
pub headers: HeaderMap,
pub body: BedrockProxyResponseBody,
}
/// Handle a workspace Bedrock proxy request through the AWS SDK.
///
/// The API still owns credential resolution, route authorization, auditing, and
/// cache behavior. This helper owns Bedrock-specific control-plane and
/// OpenAI-compatible Converse transformations.
pub async fn handle_bedrock_proxy(
args: &ProxyBuildArgs<'_>,
) -> Result<BedrockProxyResponse, Error> {
let region = args.credentials.region.as_deref().unwrap_or(USE_ENV_REGION);
if *args.method == Method::GET {
return match args.path {
"foundation-models" => list_foundation_models(args, region).await,
"inference-profiles" => list_inference_profiles(args, region).await,
_ => Err(Error::BadRequest(format!(
"Unsupported AWS Bedrock proxy path: {}",
args.path
))),
};
}
if *args.method != Method::POST {
return Err(Error::BadRequest(format!(
"Unsupported AWS Bedrock proxy method: {}",
args.method
)));
}
let request: BedrockProxyChatRequest = serde_json::from_slice(args.body)
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
if request.stream {
handle_bedrock_sdk_streaming(&request.model, args.body, args, region).await
} else {
handle_bedrock_sdk_non_streaming(&request.model, args.body, args, region).await
}
}
fn determine_auth_config(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
) -> BedrockAuthConfig {
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
BedrockAuthConfig::BearerToken(key.to_string())
} else if let (Some(access_key_id), Some(secret_access_key)) = (
aws_access_key_id.filter(|s| !s.is_empty()),
aws_secret_access_key.filter(|s| !s.is_empty()),
) {
BedrockAuthConfig::IamCredentials {
access_key_id: access_key_id.to_string(),
secret_access_key: secret_access_key.to_string(),
session_token: aws_session_token
.filter(|token| !token.is_empty())
.map(str::to_string),
}
} else {
BedrockAuthConfig::Environment
}
}
async fn create_bedrock_client(
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<BedrockClient, Error> {
match determine_auth_config(
args.credentials.api_key.as_deref(),
args.credentials.aws_access_key_id.as_deref(),
args.credentials.aws_secret_access_key.as_deref(),
args.credentials.aws_session_token.as_deref(),
) {
BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await,
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region)
.await
}
BedrockAuthConfig::Environment => BedrockClient::from_env(region).await,
}
}
fn build_tool_config_from_request(
tools: Option<&[OpenAIToolDef]>,
tool_choice: Option<&serde_json::Value>,
enable_prompt_caching: bool,
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>, Error> {
if let Some(tools) = tools {
let tool_defs: Vec<ToolDef> = tools
.iter()
.map(|t| ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: t.function.name.clone(),
description: t.function.description.clone(),
parameters: Box::from(
serde_json::value::RawValue::from_string(
serde_json::to_string(
&t.function
.parameters
.clone()
.unwrap_or(serde_json::json!({})),
)
.unwrap_or_default(),
)
.unwrap_or_else(|_| {
serde_json::value::RawValue::from_string("{}".to_string()).unwrap()
}),
),
},
})
.collect();
let force_tool_use = tool_choice
.map(|tc| tc == "required" || tc.as_str() == Some("required"))
.unwrap_or(false);
build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching)
} else {
Ok(None)
}
}
async fn create_bedrock_control_client(
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<aws_sdk_bedrock::Client, Error> {
use aws_config::BehaviorVersion;
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
match determine_auth_config(
args.credentials.api_key.as_deref(),
args.credentials.aws_access_key_id.as_deref(),
args.credentials.aws_secret_access_key.as_deref(),
args.credentials.aws_session_token.as_deref(),
) {
BedrockAuthConfig::BearerToken(key) => {
let config = aws_sdk_bedrock::config::Builder::new()
.region(region_provider)
.behavior_version(BehaviorVersion::latest())
.token_provider(BearerTokenProvider::new(key))
.build();
Ok(aws_sdk_bedrock::Client::from_conf(config))
}
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
let credentials = aws_credential_types::Credentials::new(
access_key_id,
secret_access_key,
session_token,
None,
"windmill",
);
let config = aws_sdk_bedrock::config::Builder::new()
.region(region_provider)
.behavior_version(BehaviorVersion::latest())
.credentials_provider(credentials)
.build();
Ok(aws_sdk_bedrock::Client::from_conf(config))
}
BedrockAuthConfig::Environment => {
let config = aws_config::defaults(BehaviorVersion::latest())
.region(region_provider)
.load()
.await;
Ok(aws_sdk_bedrock::Client::new(&config))
}
}
}
async fn list_foundation_models(
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<BedrockProxyResponse, Error> {
let client = create_bedrock_control_client(args, region).await?;
let response = client
.list_foundation_models()
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?;
let models: Vec<serde_json::Value> = response
.model_summaries()
.iter()
.map(|m| {
serde_json::json!({
"modelId": m.model_id(),
"modelName": m.model_name(),
"providerName": m.provider_name(),
"modelArn": m.model_arn(),
"inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
"outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::<Vec<_>>(),
"responseStreamingSupported": m.response_streaming_supported(),
"inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
})
})
.collect();
let body = serde_json::to_vec(&serde_json::json!({ "modelSummaries": models }))
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
Ok(BedrockProxyResponse {
status_code: StatusCode::OK,
headers: json_response_headers(),
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
})
}
async fn list_inference_profiles(
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<BedrockProxyResponse, Error> {
let client = create_bedrock_control_client(args, region).await?;
let response =
client.list_inference_profiles().send().await.map_err(|e| {
Error::internal_err(format!("Failed to list inference profiles: {}", e))
})?;
let profiles: Vec<serde_json::Value> = response
.inference_profile_summaries()
.iter()
.map(|p| {
serde_json::json!({
"inferenceProfileId": p.inference_profile_id(),
"inferenceProfileName": p.inference_profile_name(),
"inferenceProfileArn": p.inference_profile_arn(),
"description": p.description(),
"status": p.status().as_str(),
"type": p.r#type().as_str(),
})
})
.collect();
let body = serde_json::to_vec(&serde_json::json!({ "inferenceProfileSummaries": profiles }))
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
Ok(BedrockProxyResponse {
status_code: StatusCode::OK,
headers: json_response_headers(),
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
})
}
async fn handle_bedrock_sdk_streaming(
model: &str,
body: &[u8],
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<BedrockProxyResponse, Error> {
let openai_req: OpenAIRequest = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
let bedrock_client = create_bedrock_client(args, region).await?;
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens);
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
let mut request_builder = bedrock_client
.client()
.converse_stream()
.model_id(model)
.set_messages(Some(bedrock_messages));
if !system_prompts.is_empty() {
request_builder = request_builder.set_system(Some(system_prompts));
}
if let Some(config) = inference_config {
request_builder = request_builder.inference_config(config);
}
if let Some(config) = tool_config {
request_builder = request_builder.set_tool_config(Some(config));
}
tracing::debug!("Bedrock SDK streaming: sending converse_stream request");
let stream_output = request_builder.send().await.map_err(|e| {
let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e));
tracing::error!("Bedrock SDK streaming failed: {}", error_msg);
Error::internal_err(error_msg)
})?;
tracing::debug!("Bedrock SDK streaming: stream established successfully");
Ok(BedrockProxyResponse {
status_code: StatusCode::OK,
headers: event_stream_response_headers(),
body: BedrockProxyResponseBody::Stream(
sdk_stream_to_sse(stream_output.stream, model.to_string()).boxed(),
),
})
}
pub fn sdk_stream_to_sse(
stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
aws_sdk_bedrockruntime::types::ConverseStreamOutput,
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
>,
model: String,
) -> impl futures::Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send {
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
struct StreamState {
id: String,
model: String,
created: u64,
tool_calls: HashMap<usize, (String, String, String)>,
current_tool_index: usize,
}
let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState {
id,
model,
created,
tool_calls: HashMap::new(),
current_tool_index: 0,
}));
async_stream::stream! {
let mut stream = stream;
let state = state.clone();
loop {
match stream.recv().await {
Ok(Some(event)) => {
let mut state = state.lock().await;
if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) {
let index = state.current_tool_index;
state.tool_calls.insert(
index,
(tool_call.id.clone(), tool_call.name.clone(), String::new()),
);
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": index,
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": ""
}
}]
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
}
if let Some(text) = bedrock_stream_event_to_text(&event) {
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"content": text
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
}
if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) {
let index = state.current_tool_index;
if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) {
args.push_str(&input_delta);
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": index,
"function": {
"arguments": input_delta
}
}]
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
}
}
if bedrock_stream_event_is_block_stop(&event) {
state.current_tool_index += 1;
}
if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event {
let stop_reason = stop.stop_reason().as_str();
let finish_reason = match stop_reason {
"end_turn" => "stop",
"max_tokens" => "length",
"tool_use" => "tool_calls",
"stop_sequence" => "stop",
"guardrail_intervened" | "content_filtered" => "content_filter",
_ => "stop",
};
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": finish_reason
}]
});
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
}
}
Ok(None) => break,
Err(e) => {
yield Err(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
));
break;
}
}
}
yield Ok(Bytes::from("data: [DONE]\n\n"));
}
}
async fn handle_bedrock_sdk_non_streaming(
model: &str,
body: &[u8],
args: &ProxyBuildArgs<'_>,
region: &str,
) -> Result<BedrockProxyResponse, Error> {
let openai_req: OpenAIRequest = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
let bedrock_client = create_bedrock_client(args, region).await?;
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens);
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
let mut request_builder = bedrock_client
.client()
.converse()
.model_id(model)
.set_messages(Some(bedrock_messages));
if !system_prompts.is_empty() {
request_builder = request_builder.set_system(Some(system_prompts));
}
if let Some(config) = inference_config {
request_builder = request_builder.inference_config(config);
}
if let Some(config) = tool_config {
request_builder = request_builder.set_tool_config(Some(config));
}
tracing::debug!("Bedrock SDK non-streaming: sending converse request");
let response = request_builder.send().await.map_err(|e| {
let error_msg = format!(
"Bedrock SDK non-streaming error: {}",
format_bedrock_error(&e)
);
tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg);
Error::internal_err(error_msg)
})?;
tracing::debug!(
"Bedrock SDK non-streaming: response received, stop_reason={}",
response.stop_reason().as_str()
);
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let stop_reason = response.stop_reason().as_str();
let finish_reason = match stop_reason {
"end_turn" => "stop",
"max_tokens" => "length",
"tool_use" => "tool_calls",
"stop_sequence" => "stop",
"guardrail_intervened" | "content_filtered" => "content_filter",
_ => "stop",
};
let mut text_content = String::new();
let mut tool_calls: Vec<OpenAIToolCall> = Vec::new();
if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output()
{
for block in message.content() {
match block {
aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => {
text_content.push_str(text);
}
aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => {
let input_json = document_to_json(tool_use.input());
tool_calls.push(OpenAIToolCall {
id: tool_use.tool_use_id().to_string(),
function: OpenAIFunction {
name: tool_use.name().to_string(),
arguments: serde_json::to_string(&input_json).unwrap_or_default(),
},
r#type: "function".to_string(),
extra_content: None,
});
}
_ => {}
}
}
}
let message = if !tool_calls.is_empty() {
serde_json::json!({
"role": "assistant",
"content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) },
"tool_calls": tool_calls
})
} else {
serde_json::json!({
"role": "assistant",
"content": text_content
})
};
let usage = if let Some(usage_data) = response.usage() {
serde_json::json!({
"prompt_tokens": usage_data.input_tokens(),
"completion_tokens": usage_data.output_tokens(),
"total_tokens": usage_data.total_tokens()
})
} else {
serde_json::json!({
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
})
};
let openai_resp = serde_json::json!({
"id": id,
"object": "chat.completion",
"created": created,
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": finish_reason
}],
"usage": usage
});
let body = serde_json::to_vec(&openai_resp)
.map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?;
Ok(BedrockProxyResponse {
status_code: StatusCode::OK,
headers: json_response_headers(),
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
})
}
fn json_response_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("content-type", "application/json".parse().unwrap());
headers
}
fn event_stream_response_headers() -> HeaderMap {
let mut headers = 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());
headers
}
fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value {
match doc {
aws_smithy_types::Document::Object(map) => {
let mut json_map = serde_json::Map::new();
for (key, value) in map {
json_map.insert(key.clone(), document_to_json(value));
}
serde_json::Value::Object(json_map)
}
aws_smithy_types::Document::Array(values) => {
serde_json::Value::Array(values.iter().map(document_to_json).collect())
}
aws_smithy_types::Document::Number(number) => match number {
aws_smithy_types::Number::PosInt(number) => serde_json::Value::Number((*number).into()),
aws_smithy_types::Number::NegInt(number) => serde_json::Value::Number((*number).into()),
aws_smithy_types::Number::Float(number) => serde_json::json!(*number),
},
aws_smithy_types::Document::String(value) => serde_json::Value::String(value.clone()),
aws_smithy_types::Document::Bool(value) => serde_json::Value::Bool(*value),
aws_smithy_types::Document::Null => serde_json::Value::Null,
}
}
// ============================================================================
// Query Builder
@@ -256,3 +966,60 @@ impl BedrockQueryBuilder {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn determine_auth_config_prioritizes_bearer_token() {
let config = determine_auth_config(
Some("bearer-token"),
Some("AKIA123"),
Some("secret"),
Some("session-token"),
);
match config {
BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"),
_ => panic!("expected bearer token auth config"),
}
}
#[test]
fn determine_auth_config_uses_iam_with_optional_session_token() {
let config =
determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token"));
match config {
BedrockAuthConfig::IamCredentials {
access_key_id,
secret_access_key,
session_token,
} => {
assert_eq!(access_key_id, "AKIA123");
assert_eq!(secret_access_key, "secret");
assert_eq!(session_token.as_deref(), Some("session-token"));
}
_ => panic!("expected IAM auth config"),
}
}
#[test]
fn determine_auth_config_treats_empty_session_token_as_none() {
let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some(""));
match config {
BedrockAuthConfig::IamCredentials { session_token, .. } => {
assert!(session_token.is_none());
}
_ => panic!("expected IAM auth config"),
}
}
#[test]
fn determine_auth_config_falls_back_to_environment() {
let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token"));
assert!(matches!(config, BedrockAuthConfig::Environment));
}
}
@@ -408,6 +408,8 @@ fn build_google_ai_model_endpoint(
action: &str,
is_vertex: bool,
) -> String {
let model = model.strip_prefix("models/").unwrap_or(model);
if is_vertex {
format!("{}/{}:{}", base_url, model, action)
} else {
@@ -416,6 +418,10 @@ fn build_google_ai_model_endpoint(
}
fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) {
// Native Google AI proxy intentionally does not apply AI_HTTP_HEADERS or
// resource custom headers yet. Gemini/Vertex header semantics are
// provider-specific; keep this limited to required auth headers until
// explicit custom-header support is designed.
if is_vertex {
headers.push(("Authorization".to_string(), format!("Bearer {}", api_key)));
} else {
@@ -749,6 +755,32 @@ mod tests {
assert!(body["contents"].is_array());
}
#[test]
fn builds_standard_google_ai_endpoint_from_model_resource_name() {
assert_eq!(
build_google_ai_model_endpoint(
"https://generativelanguage.googleapis.com/v1beta",
"models/gemini-2.0-flash",
"generateContent",
false,
),
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
);
}
#[test]
fn builds_vertex_google_ai_endpoint_from_model_resource_name() {
assert_eq!(
build_google_ai_model_endpoint(
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models",
"models/gemini-2.0-flash",
"streamGenerateContent",
true,
),
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent"
);
}
#[test]
fn builds_vertex_google_ai_streaming_proxy_request() {
let credentials = credentials(
+106
View File
@@ -235,6 +235,50 @@ where
Ok(())
}
/// Returns a predicate that checks whether `path` is within the token's
/// scope for `{domain}:{action}:{path}`. For tokens without scope
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
/// the predicate always returns `true`.
///
/// Pre-parses the token's scopes once so the returned closure can cheaply
/// filter large listings without re-parsing on each call.
pub fn build_scope_path_predicate(
authed: &ApiAuthed,
domain: &str,
action: &str,
) -> impl Fn(&str) -> bool {
// Mirror check_scopes semantics: a token is "scope-restricted" iff it has
// at least one non-`if_jobs:filter_tags:` scope. Unparseable scopes still
// count as restrictive — they just match nothing.
let (is_scoped_token, parsed): (bool, Vec<ScopeDefinition>) = match authed.scopes.as_ref() {
Some(scopes) => {
let mut is_scoped = false;
let parsed = scopes
.iter()
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
.inspect(|_| is_scoped = true)
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
.collect();
(is_scoped, parsed)
}
None => (false, Vec::new()),
};
let domain = domain.to_string();
let action = action.to_string();
move |path: &str| -> bool {
if !is_scoped_token {
return true;
}
let required =
match ScopeDefinition::from_scope_string(&format!("{}:{}:{}", domain, action, path)) {
Ok(r) => r,
Err(_) => return false,
};
parsed.iter().any(|s| s.includes(&required))
}
}
pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> {
let is_devops = is_devops_email(db, email).await?;
@@ -803,3 +847,65 @@ pub fn require_path_read_access_for_preview(
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn authed_with_scopes(scopes: Option<Vec<&str>>) -> ApiAuthed {
ApiAuthed {
scopes: scopes.map(|v| v.into_iter().map(String::from).collect()),
..Default::default()
}
}
#[test]
fn predicate_no_scopes_allows_all() {
let authed = authed_with_scopes(None);
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(allowed("u/alice/anything"));
assert!(allowed("u/bob/other"));
}
#[test]
fn predicate_tag_filter_only_allows_all() {
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(allowed("u/alice/foo"));
}
#[test]
fn predicate_single_resource_scope_filters_others() {
// Regression test for WIN-1981: a token scoped to one resource must
// not match unrelated paths in listings (e.g. /resources/list_search).
let authed = authed_with_scopes(Some(vec!["resources:read:u/alice/allowed_resource"]));
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(allowed("u/alice/allowed_resource"));
assert!(!allowed("u/alice/other_resource"));
assert!(!allowed("u/bob/foo"));
}
#[test]
fn predicate_wildcard_scope_matches_subtree() {
let authed = authed_with_scopes(Some(vec!["resources:read:f/team/*"]));
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(allowed("f/team/db"));
assert!(allowed("f/team/sub/nested"));
assert!(!allowed("f/other/db"));
}
#[test]
fn predicate_wrong_domain_is_rejected() {
let authed = authed_with_scopes(Some(vec!["variables:read:u/alice/secret"]));
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(!allowed("u/alice/secret"));
}
#[test]
fn predicate_write_implies_read() {
let authed = authed_with_scopes(Some(vec!["resources:write:u/alice/foo"]));
let allowed = build_scope_path_predicate(&authed, "resources", "read");
assert!(allowed("u/alice/foo"));
assert!(!allowed("u/alice/bar"));
}
}
+1 -6
View File
@@ -40,7 +40,7 @@ gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"]
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
bedrock = ["windmill-ai/bedrock"]
python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"]
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"]
quickjs = ["windmill-jseval/quickjs"]
@@ -172,11 +172,6 @@ rustls = { workspace = true }
aws-sigv4 = { workspace = true, optional = true }
aws-sdk-config = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-sdk-bedrock = { workspace = true, optional = true }
aws-sdk-bedrockruntime = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
async-trait.workspace = true
eventsource-stream.workspace = true
windmill-jseval.workspace = true
+39 -89
View File
@@ -1,5 +1,3 @@
#[cfg(feature = "bedrock")]
use crate::bedrock;
use crate::db::{ApiAuthed, DB};
use crate::utils::check_scopes;
@@ -20,6 +18,10 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision;
use windmill_ai::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
#[cfg(feature = "bedrock")]
use windmill_ai::providers::bedrock::{
handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody,
};
use windmill_ai::providers::{
create_proxy_query_builder,
google_ai::{
@@ -540,6 +542,18 @@ fn google_ai_proxy_response_to_body(
(response.status_code, response.headers, body)
}
#[cfg(feature = "bedrock")]
fn bedrock_proxy_response_to_body(
response: BedrockProxyResponse,
) -> (http::StatusCode, HeaderMap, axum::body::Body) {
let body = match response.body {
BedrockProxyResponseBody::Fixed(body) => axum::body::Body::from(body),
BedrockProxyResponseBody::Stream(stream) => axum::body::Body::from_stream(stream),
};
(response.status_code, response.headers, body)
}
pub(crate) fn inject_keepalives<S>(
upstream: S,
interval: Duration,
@@ -885,95 +899,31 @@ async fn proxy(
// 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!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock)
&& method == Method::POST
{
#[derive(Deserialize, Debug)]
struct BedrockRequest {
model: String,
#[serde(default)]
stream: bool,
}
let parsed: BedrockRequest = serde_json::from_slice(&body)
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
(Some(parsed.model), parsed.stream)
} else {
(None, false)
};
if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) {
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
)
.await?;
tx.commit().await?;
// For Bedrock requests, use the SDK-based approach
if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) {
let region = request_config
.region
.as_deref()
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
let credentials = request_config.into_provider_credentials(provider.clone());
let response = handle_bedrock_proxy(&ProxyBuildArgs {
method: &method,
path: &ai_path,
headers: &headers,
body: &body,
credentials: &credentials,
})
.await?;
// Audit log before making the SDK request
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
)
.await?;
tx.commit().await?;
// Handle GET requests for control plane operations
if method == Method::GET {
if ai_path == "foundation-models" {
return bedrock::list_foundation_models(
request_config.api_key.as_deref(),
request_config.aws_access_key_id.as_deref(),
request_config.aws_secret_access_key.as_deref(),
request_config.aws_session_token.as_deref(),
region,
)
.await;
} else if ai_path == "inference-profiles" {
return bedrock::list_inference_profiles(
request_config.api_key.as_deref(),
request_config.aws_access_key_id.as_deref(),
request_config.aws_secret_access_key.as_deref(),
request_config.aws_session_token.as_deref(),
region,
)
.await;
}
}
// Handle POST requests for inference
if method == Method::POST && model.is_some() {
if is_streaming {
return bedrock::handle_bedrock_sdk_streaming(
model.as_ref().unwrap(),
&body,
request_config.api_key.as_deref(),
request_config.aws_access_key_id.as_deref(),
request_config.aws_secret_access_key.as_deref(),
request_config.aws_session_token.as_deref(),
region,
)
.await;
} else {
return bedrock::handle_bedrock_sdk_non_streaming(
model.as_ref().unwrap(),
&body,
request_config.api_key.as_deref(),
request_config.aws_access_key_id.as_deref(),
request_config.aws_secret_access_key.as_deref(),
request_config.aws_session_token.as_deref(),
region,
)
.await;
}
}
}
return Ok(bedrock_proxy_response_to_body(response));
}
// When bedrock feature is disabled, return error for Bedrock provider
-873
View File
@@ -1,873 +0,0 @@
//! AWS Bedrock SDK-based operations for the AI chat proxy.
//!
//! This module provides SDK-based request handling for Bedrock:
//!
//! ## Inference (Runtime SDK):
//! - `handle_bedrock_sdk_streaming`: Uses BedrockClient for streaming requests
//! - `handle_bedrock_sdk_non_streaming`: Uses BedrockClient for non-streaming requests
//! - `sdk_stream_to_sse`: Converts SDK ConverseStream events to SSE format
//!
//! ## Control Plane (Bedrock SDK):
//! - `list_foundation_models`: Lists available foundation models
//! - `list_inference_profiles`: Lists inference profiles
//!
//! Shared AWS SDK code is available in `windmill_common::ai_bedrock`, including:
//! - `BedrockClient`: SDK wrapper with bearer token and IAM auth
//! - Stream event parsing functions
//! - Helper utilities
use axum::body::Bytes;
use serde::Deserialize;
use windmill_ai::ai_bedrock::build_tool_config;
use windmill_ai::ai_bedrock::{
bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text,
bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error,
BedrockClient,
};
use windmill_ai::ai_types::{
OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction,
};
use windmill_common::error::{Error, Result};
// ============================================================================
// Shared Request Types for SDK-Based Handlers
// ============================================================================
/// OpenAI-format request body for Bedrock SDK handlers
#[derive(Deserialize, Debug)]
struct OpenAIRequest {
messages: Vec<OpenAIMessage>,
#[serde(default)]
tools: Option<Vec<OpenAIToolDef>>,
#[serde(default)]
tool_choice: Option<serde_json::Value>,
#[serde(default)]
max_tokens: Option<i32>,
#[serde(default)]
temperature: Option<f32>,
}
#[derive(Deserialize, Debug)]
struct OpenAIToolDef {
#[serde(default)]
#[allow(dead_code)]
r#type: Option<String>,
function: OpenAIToolFunction,
}
#[derive(Deserialize, Debug)]
struct OpenAIToolFunction {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<serde_json::Value>,
}
// ============================================================================
// Shared Helper Functions for SDK-Based Handlers
// ============================================================================
/// Authentication configuration for Bedrock clients
enum BedrockAuthConfig {
BearerToken(String),
IamCredentials {
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
},
Environment,
}
/// Determine auth configuration with priority: bearer token → IAM credentials → environment
fn determine_auth_config(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
) -> BedrockAuthConfig {
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
BedrockAuthConfig::BearerToken(key.to_string())
} else if let (Some(access_key_id), Some(secret_access_key)) = (
aws_access_key_id.filter(|s| !s.is_empty()),
aws_secret_access_key.filter(|s| !s.is_empty()),
) {
BedrockAuthConfig::IamCredentials {
access_key_id: access_key_id.to_string(),
secret_access_key: secret_access_key.to_string(),
session_token: aws_session_token
.filter(|token| !token.is_empty())
.map(str::to_string),
}
} else {
BedrockAuthConfig::Environment
}
}
/// Create a BedrockClient with auth priority: bearer token → IAM credentials → environment
async fn create_bedrock_client(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<BedrockClient> {
match determine_auth_config(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
) {
BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await,
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region)
.await
}
BedrockAuthConfig::Environment => BedrockClient::from_env(region).await,
}
}
/// Convert OpenAIToolDef array to tool configuration for Bedrock SDK
fn build_tool_config_from_request(
tools: Option<&[OpenAIToolDef]>,
tool_choice: Option<&serde_json::Value>,
enable_prompt_caching: bool,
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>> {
if let Some(tools) = tools {
let tool_defs: Vec<ToolDef> = tools
.iter()
.map(|t| ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: t.function.name.clone(),
description: t.function.description.clone(),
parameters: Box::from(
serde_json::value::RawValue::from_string(
serde_json::to_string(
&t.function
.parameters
.clone()
.unwrap_or(serde_json::json!({})),
)
.unwrap_or_default(),
)
.unwrap_or_else(|_| {
serde_json::value::RawValue::from_string("{}".to_string()).unwrap()
}),
),
},
})
.collect();
// Determine if we should force tool use based on tool_choice
let force_tool_use = tool_choice
.map(|tc| tc == "required" || tc.as_str() == Some("required"))
.unwrap_or(false);
build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching)
} else {
Ok(None)
}
}
// ============================================================================
// Control Plane Operations (using aws-sdk-bedrock)
// ============================================================================
/// Create a Bedrock control plane client with auth priority: bearer token → IAM credentials → environment
async fn create_bedrock_control_client(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<aws_sdk_bedrock::Client> {
use aws_config::BehaviorVersion;
use windmill_ai::ai_bedrock::BearerTokenProvider;
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
match determine_auth_config(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
) {
BedrockAuthConfig::BearerToken(key) => {
let config = aws_sdk_bedrock::config::Builder::new()
.region(region_provider)
.behavior_version(BehaviorVersion::latest())
.token_provider(BearerTokenProvider::new(key))
.build();
Ok(aws_sdk_bedrock::Client::from_conf(config))
}
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
let credentials = aws_credential_types::Credentials::new(
access_key_id,
secret_access_key,
session_token,
None,
"windmill",
);
let config = aws_sdk_bedrock::config::Builder::new()
.region(region_provider)
.behavior_version(BehaviorVersion::latest())
.credentials_provider(credentials)
.build();
Ok(aws_sdk_bedrock::Client::from_conf(config))
}
BedrockAuthConfig::Environment => {
let config = aws_config::defaults(BehaviorVersion::latest())
.region(region_provider)
.load()
.await;
Ok(aws_sdk_bedrock::Client::new(&config))
}
}
}
/// List foundation models using the Bedrock SDK
pub async fn list_foundation_models(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
let client = create_bedrock_control_client(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
region,
)
.await?;
let response = client
.list_foundation_models()
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?;
// Convert to JSON response
let models: Vec<serde_json::Value> = response
.model_summaries()
.iter()
.map(|m| {
serde_json::json!({
"modelId": m.model_id(),
"modelName": m.model_name(),
"providerName": m.provider_name(),
"modelArn": m.model_arn(),
"inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
"outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::<Vec<_>>(),
"responseStreamingSupported": m.response_streaming_supported(),
"inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
})
})
.collect();
let body = serde_json::json!({ "modelSummaries": models });
let body_bytes = serde_json::to_vec(&body)
.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,
axum::body::Body::from(body_bytes),
))
}
/// List inference profiles using the Bedrock SDK
pub async fn list_inference_profiles(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
let client = create_bedrock_control_client(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
region,
)
.await?;
let response =
client.list_inference_profiles().send().await.map_err(|e| {
Error::internal_err(format!("Failed to list inference profiles: {}", e))
})?;
// Convert to JSON response
let profiles: Vec<serde_json::Value> = response
.inference_profile_summaries()
.iter()
.map(|p| {
serde_json::json!({
"inferenceProfileId": p.inference_profile_id(),
"inferenceProfileName": p.inference_profile_name(),
"inferenceProfileArn": p.inference_profile_arn(),
"description": p.description(),
"status": p.status().as_str(),
"type": p.r#type().as_str(),
})
})
.collect();
let body = serde_json::json!({ "inferenceProfileSummaries": profiles });
let body_bytes = serde_json::to_vec(&body)
.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,
axum::body::Body::from(body_bytes),
))
}
// ============================================================================
// Inference Operations (using aws-sdk-bedrockruntime)
// ============================================================================
/// Handle Bedrock streaming request using the AWS SDK.
///
/// This function uses the shared BedrockClient to make streaming requests
/// and converts the SDK stream events to SSE format for the proxy response.
///
/// Auth priority: bearer token → IAM credentials → environment credentials
pub async fn handle_bedrock_sdk_streaming(
model: &str,
body: &Bytes,
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
let openai_req: OpenAIRequest = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
// Create Bedrock client using shared helper
let bedrock_client = create_bedrock_client(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
region,
)
.await?;
// Convert messages using shared conversion
let enable_prompt_caching =
windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
windmill_ai::ai_bedrock::openai_messages_to_bedrock(
&openai_req.messages,
enable_prompt_caching,
)?;
// Build inference configuration
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
openai_req.temperature,
openai_req.max_tokens,
);
// Convert tools using shared helper
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
// Build the SDK request
let mut request_builder = bedrock_client
.client()
.converse_stream()
.model_id(model)
.set_messages(Some(bedrock_messages));
if !system_prompts.is_empty() {
request_builder = request_builder.set_system(Some(system_prompts));
}
if let Some(config) = inference_config {
request_builder = request_builder.inference_config(config);
}
if let Some(config) = tool_config {
request_builder = request_builder.set_tool_config(Some(config));
}
// Send the request and get the stream
tracing::debug!("Bedrock SDK streaming: sending converse_stream request");
let stream_output = request_builder.send().await.map_err(|e| {
let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e));
tracing::error!("Bedrock SDK streaming failed: {}", error_msg);
Error::internal_err(error_msg)
})?;
tracing::debug!("Bedrock SDK streaming: stream established successfully");
// Convert SDK stream to SSE (pass the inner stream, not the full output)
let sse_stream = sdk_stream_to_sse(stream_output.stream, model.to_string());
// Build response headers
let mut response_headers = http::HeaderMap::new();
response_headers.insert("content-type", "text/event-stream".parse().unwrap());
response_headers.insert("cache-control", "no-cache".parse().unwrap());
response_headers.insert("connection", "keep-alive".parse().unwrap());
Ok((
http::StatusCode::OK,
response_headers,
axum::body::Body::from_stream(sse_stream),
))
}
/// Convert AWS SDK ConverseStream events to SSE format.
///
/// Uses shared stream parsing functions from windmill_common::ai_bedrock
/// to extract text deltas and tool calls from the SDK stream events.
pub fn sdk_stream_to_sse(
stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
aws_sdk_bedrockruntime::types::ConverseStreamOutput,
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
>,
model: String,
) -> impl futures::Stream<Item = std::result::Result<bytes::Bytes, std::io::Error>> + Send {
use std::collections::HashMap;
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// State to track partial tool calls
struct StreamState {
id: String,
model: String,
created: u64,
tool_calls: HashMap<usize, (String, String, String)>, // index -> (id, name, args)
current_tool_index: usize,
}
let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState {
id: id.clone(),
model: model.clone(),
created,
tool_calls: HashMap::new(),
current_tool_index: 0,
}));
async_stream::stream! {
let mut stream = stream;
let state = state.clone();
loop {
match stream.recv().await {
Ok(Some(event)) => {
let mut state = state.lock().await;
// Handle tool use start
if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) {
let index = state.current_tool_index;
state.tool_calls.insert(
index,
(tool_call.id.clone(), tool_call.name.clone(), String::new()),
);
// Send initial tool call chunk
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": index,
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": ""
}
}]
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
}
// Handle text delta
if let Some(text) = bedrock_stream_event_to_text(&event) {
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"content": text
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
}
// Handle tool use input delta
if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) {
let index = state.current_tool_index;
if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) {
args.push_str(&input_delta);
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": index,
"function": {
"arguments": input_delta
}
}]
},
"finish_reason": serde_json::Value::Null
}]
});
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
}
}
// Handle content block stop
if bedrock_stream_event_is_block_stop(&event) {
state.current_tool_index += 1;
}
// Handle message stop
if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event {
let stop_reason = stop.stop_reason().as_str();
let finish_reason = match stop_reason {
"end_turn" => "stop",
"max_tokens" => "length",
"tool_use" => "tool_calls",
"stop_sequence" => "stop",
"guardrail_intervened" | "content_filtered" => "content_filter",
_ => "stop",
};
let chunk = serde_json::json!({
"id": state.id,
"object": "chat.completion.chunk",
"created": state.created,
"model": state.model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": finish_reason
}]
});
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
}
}
Ok(None) => break,
Err(e) => {
yield Err(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
));
break;
}
}
}
// Send [DONE] at the end
yield Ok(bytes::Bytes::from("data: [DONE]\n\n"));
}
}
/// Handle non-streaming Bedrock request using the AWS SDK.
///
/// Auth priority: bearer token → IAM credentials → environment credentials
pub async fn handle_bedrock_sdk_non_streaming(
model: &str,
body: &Bytes,
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
aws_session_token: Option<&str>,
region: &str,
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
let openai_req: OpenAIRequest = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
// Create Bedrock client using shared helper
let bedrock_client = create_bedrock_client(
api_key,
aws_access_key_id,
aws_secret_access_key,
aws_session_token,
region,
)
.await?;
// Convert messages using shared conversion
let enable_prompt_caching =
windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model);
let (bedrock_messages, system_prompts) =
windmill_ai::ai_bedrock::openai_messages_to_bedrock(
&openai_req.messages,
enable_prompt_caching,
)?;
// Build inference configuration
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
openai_req.temperature,
openai_req.max_tokens,
);
// Convert tools using shared helper
let tool_config = build_tool_config_from_request(
openai_req.tools.as_deref(),
openai_req.tool_choice.as_ref(),
enable_prompt_caching,
)?;
// Build the SDK request (non-streaming)
let mut request_builder = bedrock_client
.client()
.converse()
.model_id(model)
.set_messages(Some(bedrock_messages));
if !system_prompts.is_empty() {
request_builder = request_builder.set_system(Some(system_prompts));
}
if let Some(config) = inference_config {
request_builder = request_builder.inference_config(config);
}
if let Some(config) = tool_config {
request_builder = request_builder.set_tool_config(Some(config));
}
// Send the request
tracing::debug!("Bedrock SDK non-streaming: sending converse request");
let response = request_builder.send().await.map_err(|e| {
let error_msg = format!(
"Bedrock SDK non-streaming error: {}",
format_bedrock_error(&e)
);
tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg);
Error::internal_err(error_msg)
})?;
tracing::debug!(
"Bedrock SDK non-streaming: response received, stop_reason={}",
response.stop_reason().as_str()
);
// Convert response to OpenAI format
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Extract stop reason
let stop_reason = response.stop_reason().as_str();
let finish_reason = match stop_reason {
"end_turn" => "stop",
"max_tokens" => "length",
"tool_use" => "tool_calls",
"stop_sequence" => "stop",
"guardrail_intervened" | "content_filtered" => "content_filter",
_ => "stop",
};
// Extract message content
let mut text_content = String::new();
let mut tool_calls: Vec<OpenAIToolCall> = Vec::new();
if let Some(output) = response.output() {
if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(message) = output {
for block in message.content() {
match block {
aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => {
text_content.push_str(text);
}
aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => {
// Convert Document back to JSON string
let input_json = document_to_json(tool_use.input());
tool_calls.push(OpenAIToolCall {
id: tool_use.tool_use_id().to_string(),
function: OpenAIFunction {
name: tool_use.name().to_string(),
arguments: serde_json::to_string(&input_json).unwrap_or_default(),
},
r#type: "function".to_string(),
extra_content: None,
});
}
_ => {}
}
}
}
}
// Build the message
let message = if !tool_calls.is_empty() {
serde_json::json!({
"role": "assistant",
"content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) },
"tool_calls": tool_calls
})
} else {
serde_json::json!({
"role": "assistant",
"content": text_content
})
};
// Extract usage information
let usage = if let Some(usage_data) = response.usage() {
serde_json::json!({
"prompt_tokens": usage_data.input_tokens(),
"completion_tokens": usage_data.output_tokens(),
"total_tokens": usage_data.total_tokens()
})
} else {
serde_json::json!({
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
})
};
// Build OpenAI-format response
let openai_resp = serde_json::json!({
"id": id,
"object": "chat.completion",
"created": created,
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": finish_reason
}],
"usage": usage
});
let response_body = serde_json::to_vec(&openai_resp)
.map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?;
let mut response_headers = http::HeaderMap::new();
response_headers.insert("content-type", "application/json".parse().unwrap());
Ok((
http::StatusCode::OK,
response_headers,
axum::body::Body::from(response_body),
))
}
/// Convert AWS Smithy Document to serde_json::Value
fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value {
match doc {
aws_smithy_types::Document::Object(map) => {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
json_map.insert(k.clone(), document_to_json(v));
}
serde_json::Value::Object(json_map)
}
aws_smithy_types::Document::Array(arr) => {
serde_json::Value::Array(arr.iter().map(document_to_json).collect())
}
aws_smithy_types::Document::Number(num) => match num {
aws_smithy_types::Number::PosInt(n) => serde_json::Value::Number((*n).into()),
aws_smithy_types::Number::NegInt(n) => serde_json::Value::Number((*n).into()),
aws_smithy_types::Number::Float(f) => serde_json::json!(*f),
},
aws_smithy_types::Document::String(s) => serde_json::Value::String(s.clone()),
aws_smithy_types::Document::Bool(b) => serde_json::Value::Bool(*b),
aws_smithy_types::Document::Null => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn determine_auth_config_prioritizes_bearer_token() {
let config = determine_auth_config(
Some("bearer-token"),
Some("AKIA123"),
Some("secret"),
Some("session-token"),
);
match config {
BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"),
_ => panic!("expected bearer token auth config"),
}
}
#[test]
fn determine_auth_config_uses_iam_with_optional_session_token() {
let config =
determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token"));
match config {
BedrockAuthConfig::IamCredentials {
access_key_id,
secret_access_key,
session_token,
} => {
assert_eq!(access_key_id, "AKIA123");
assert_eq!(secret_access_key, "secret");
assert_eq!(session_token.as_deref(), Some("session-token"));
}
_ => panic!("expected IAM auth config"),
}
}
#[test]
fn determine_auth_config_treats_empty_session_token_as_none() {
let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some(""));
match config {
BedrockAuthConfig::IamCredentials { session_token, .. } => {
assert!(session_token.is_none());
}
_ => panic!("expected IAM auth config"),
}
}
#[test]
fn determine_auth_config_falls_back_to_environment() {
let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token"));
assert!(matches!(config, BedrockAuthConfig::Environment));
}
}
-2
View File
@@ -73,8 +73,6 @@ pub mod auth;
#[cfg(all(feature = "private", feature = "parquet"))]
pub mod azure_proxy_ee;
mod azure_proxy_oss;
#[cfg(feature = "bedrock")]
mod bedrock;
mod capture;
mod concurrency_groups;
mod db;
+11 -3
View File
@@ -10,8 +10,8 @@ use std::collections::HashMap;
use std::net::IpAddr;
use windmill_api_auth::{
check_scopes, maybe_refresh_folders, require_owner_of_path, require_super_admin, ApiAuthed,
Tokened,
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
require_super_admin, ApiAuthed, Tokened,
};
use windmill_common::db::DB;
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
@@ -194,6 +194,7 @@ async fn list_names(
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<NamePath>> {
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "resources", "read");
let rows = sqlx::query!(
"SELECT value->>'name' as name, path from resource WHERE resource_type = $1 AND workspace_id = $2",
rt,
@@ -203,6 +204,7 @@ async fn list_names(
.await?
.into_iter()
.filter_map(|x| x.name.map(|name| NamePath { name, path: x.path }))
.filter(|np| allowed(&np.path))
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(rows))
@@ -225,6 +227,7 @@ async fn list_search_resources(
#[cfg(not(feature = "enterprise"))]
let n = 3;
let allowed = build_scope_path_predicate(&authed, "resources", "read");
let rows = sqlx::query_as!(
SearchResource,
"SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2",
@@ -234,6 +237,7 @@ async fn list_search_resources(
.fetch_all(&mut *tx)
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(rows))
@@ -338,9 +342,13 @@ async fn list_resources(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "resources", "read");
let rows = sqlx::query_as::<_, ListableResource>(&sql)
.fetch_all(&mut *tx)
.await?;
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.collect::<Vec<_>>();
tx.commit().await?;
+9 -2
View File
@@ -6,7 +6,10 @@
* LICENSE-AGPL for a copy of the license.
*/
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
use windmill_api_auth::{
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
ApiAuthed,
};
use windmill_common::db::DB;
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
@@ -188,9 +191,13 @@ async fn list_variables(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "variables", "read");
let rows = sqlx::query_as::<_, ListableVariable>(&sql)
.fetch_all(&mut *tx)
.await?;
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(rows))
+73 -14
View File
@@ -5,10 +5,10 @@
AI provider logic is currently split across three crates with duplicate code:
- **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`)
- **windmill-api** — chat proxy (`ai.rs`, `google.rs`, `bedrock.rs`) with its own request building for Google/Bedrock, plus `AIRequestConfig::prepare_request` for auth/URL handling
- **windmill-api** — chat proxy routes (`ai.rs`), audit logging, caching, and DB-backed credential resolution through `AIRequestConfig`
- **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations
The goal: a single `windmill-ai` crate with all AI provider logic. Both the API proxy and worker agent use `QueryBuilder` for every provider — no more duplicate logic.
The goal: a single `windmill-ai` crate with all AI provider logic. Worker agent execution uses `QueryBuilder`; the API proxy uses `QueryBuilder::build_proxy_request` for HTTP-forwarding providers and native proxy handlers for providers that need response conversion or SDK execution.
## Dependency Direction
@@ -25,7 +25,7 @@ windmill-common does **NOT** re-export from windmill-ai (would be circular). All
## Reviewer Note: Keep API Proxy Unification Split
The crate boundary, shared utilities, SSE parsers, image handling, and worker provider implementations are now in `windmill-ai`. The remaining duplication is the API proxy path: `AIRequestConfig::prepare_request`, `windmill-api/src/google.rs`, and `windmill-api/src/bedrock.rs` still own API-specific request transformation.
The crate boundary, shared utilities, SSE parsers, image handling, worker provider implementations, and provider-specific API proxy transformations are now in `windmill-ai`. The remaining duplication is credential shape and resolution: `windmill-api` still resolves DB-backed proxy credentials through `AIRequestConfig`, while worker agent execution still receives `ProviderWithResource`.
Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk:
- Introduce shared proxy request and credential types first.
@@ -70,7 +70,7 @@ 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
## Completed Phase: 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
@@ -101,6 +101,62 @@ Validation:
- `cargo test -p windmill-api maps_request_config_to_provider_credentials`
- `cargo test -p windmill-ai anthropic`
Follow-up status: Bedrock native proxy handling has since moved into
`windmill-ai`, and the API-local `windmill-api/src/bedrock.rs` module has been
removed.
## Current Phase PR: Bedrock Native Proxy Migration
Goal: move the remaining native-provider API proxy execution out of
`windmill-api` and into `windmill-ai`, while leaving API-owned routing,
credential resolution, auditing, cache behavior, and Axum response conversion in
`windmill-api`.
Suggested PR title: `refactor(ai): move bedrock proxy handling to windmill-ai`.
Scope:
- Move Bedrock control-plane proxy calls (`foundation-models`,
`inference-profiles`) into `windmill-ai::providers::bedrock`.
- Move Bedrock chat proxy OpenAI request parsing, Converse request execution,
streaming SSE conversion, non-streaming OpenAI-shaped response conversion, and
auth selection into `windmill-ai::providers::bedrock`.
- Add an Axum-free `BedrockProxyResponse` shape in `windmill-ai`; the API route
converts it into an Axum body.
- Move the optional `aws-sdk-bedrock` dependency from `windmill-api` to
`windmill-ai`.
- Delete the API-local `windmill-api/src/bedrock.rs` module.
Out of scope:
- Do not unify `AIRequestConfig` and `ProviderWithResource`.
- Do not change Bedrock credential resolution, audit logging, request caching,
or non-Bedrock proxy behavior.
Validation:
- `cargo test -p windmill-ai bedrock --features bedrock`
- `cargo check -p windmill-ai -p windmill-api`
- `cargo check -p windmill-ai -p windmill-api --features bedrock`
## Known Follow-Ups
These are not blockers for the current migration PR because they either preserve
existing behavior or need a separate product decision, but they should stay
visible for later hardening work.
- **Google AI/Gemini native proxy custom headers**: the native Google AI proxy
path intentionally does not apply `AI_HTTP_HEADERS` or resource-level custom
headers today. Decide whether and how env/resource custom-header injection
should apply to Google AI once the proxy behavior is unified further.
- **Bedrock SSE tool-call indexing**: Bedrock streaming currently increments
the OpenAI tool-call index on every Bedrock `ContentBlockStop`, including text
content blocks. This behavior existed before the move from `windmill-api` to
`windmill-ai`, but a later cleanup should advance the index only when the
stopped block was a tool-use block.
- **Bedrock SSE keepalives**: Bedrock native SSE streams are still returned
directly without the API proxy keepalive injection used by other SSE paths.
This also preserves the pre-move behavior. A later cleanup can generalize the
keepalive wrapper so it works for both `reqwest::Error` streams and Bedrock's
SDK-backed `std::io::Error` streams.
## Step-by-Step Plan
Each step produces a compiling, working backend.
@@ -200,9 +256,10 @@ Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai
---
### Step 8: Add proxy support to QueryBuilder — API uses QueryBuilder for all providers
### Step 8: Add API proxy execution support to windmill-ai ✅
This is the key unification step. Add a new method to the `QueryBuilder` trait:
This is the key proxy unification step. HTTP-forwarding providers use
`QueryBuilder::build_proxy_request`:
```rust
/// Build a request from a raw OpenAI-format proxy request.
@@ -237,19 +294,21 @@ pub struct ProxyRequest {
**Provider implementations:**
- **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers.
- **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers.
- **Google AI**: Convert OpenAI format → Gemini format (using existing `ai_google` functions). Replaces `windmill-api/src/google.rs`.
- **Bedrock**: Convert OpenAI format → Bedrock SDK calls. Replaces `windmill-api/src/bedrock.rs`.
- **Google AI**: Native execution mode converts OpenAI format → Gemini format and Gemini responses → OpenAI shape. Replaces `windmill-api/src/google.rs`.
- **Bedrock**: Native execution mode converts OpenAI format → Bedrock SDK calls and SDK responses → OpenAI shape. Replaces `windmill-api/src/bedrock.rs`.
**Refactor API proxy** (`windmill-api/src/ai.rs`):
1. Parse provider from headers, resolve credentials → `ProviderCredentials`
2. Create `QueryBuilder` via `create_query_builder`
3. Call `query_builder.build_proxy_request(&proxy_args)``ProxyRequest`
4. Send the request, return response with SSE keepalive injection
3. Dispatch by `ProxyExecutionMode`:
- HTTP-forwarding providers call `query_builder.build_proxy_request(&proxy_args)``ProxyRequest`
- Google AI and Bedrock call native handlers in `windmill-ai`
4. Convert the provider response to the API response body
**Remove** from windmill-api:
- `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request`
- `google.rs` — replaced by `GoogleAIQueryBuilder::build_proxy_request`
- `bedrock.rs` — replaced by `BedrockQueryBuilder::build_proxy_request`
- `google.rs` — replaced by `windmill_ai::providers::google_ai` native proxy handlers
- `bedrock.rs` — replaced by `windmill_ai::providers::bedrock` native proxy handlers
- `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder`
- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai
@@ -310,8 +369,8 @@ windmill-ai/src/
├── mod.rs # create_query_builder factory
├── anthropic.rs # build_request + build_proxy_request
├── openai.rs # build_request + build_proxy_request
├── google_ai.rs # build_request + build_proxy_request
├── bedrock.rs # build_request + build_proxy_request (feature: bedrock)
├── google_ai.rs # build_request + native proxy handlers
├── bedrock.rs # build_request + native proxy handlers (feature: bedrock)
├── other.rs # build_request + build_proxy_request
└── openrouter.rs # build_request + build_proxy_request
```
@@ -100,6 +100,7 @@
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { buildForkEditUrl } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { UserDraft } from '$lib/userDraft.svelte'
let {
initialPath = $bindable(''),
@@ -123,6 +124,7 @@
children,
loadedFromHistoryFromUrl,
noInitial = false,
liveEditorDraftStoragePath = undefined,
onSaveInitial,
onSaveDraft,
onDeploy,
@@ -588,6 +590,23 @@
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
$effect(() => {
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'flow',
storagePath: liveEditorDraftStoragePath,
effectivePath: $pathStore
})
return () =>
UserDraft.clearLiveEditorDraft('flow', {
workspace,
storagePath: liveEditorDraftStoragePath
})
})
const captureOn = writable<boolean>(false)
const showCaptureHint = writable<boolean | undefined>(undefined)
const flowInputEditorStateStore = writable<FlowInputEditorState>({
+13 -4
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import { pathToMeta, type Meta } from '$lib/common'
import { localeConcatAnd, pluralize } from '$lib/utils'
import { copyToClipboard, localeConcatAnd, pluralize } from '$lib/utils'
import {
AppService,
FlowService,
@@ -30,7 +30,7 @@
import { writable } from 'svelte/store'
import { Alert, Button } from './common'
import { random_adj } from './random_positive_adjetive'
import { ChevronDown, SearchCode } from 'lucide-svelte'
import { ChevronDown, Copy, SearchCode } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
import FolderPicker from './FolderPicker.svelte'
@@ -428,7 +428,7 @@
inputBaseClass,
inputBorderClass({ error: !!error }),
inputSizeClasses[size],
'flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
'relative flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
disabled && '!bg-surface-disabled cursor-not-allowed border-none'
)}
>
@@ -500,7 +500,7 @@
{/if}
</div>
<div class="text-sm text-secondary">/</div>
<label class="block grow min-w-32">
<label class="block grow min-w-32 mr-3">
<!-- svelte-ignore a11y_autofocus -->
<PathNameAutocomplete
bind:this={inputP}
@@ -519,6 +519,15 @@
)}
/>
</label>
<Button
iconOnly
size="xs2"
variant="subtle"
startIcon={{ icon: Copy }}
title="Copy path"
wrapperClasses="absolute right-1 top-1/2 -translate-y-1/2"
on:click={() => copyToClipboard(path)}
/>
{/if}
</div>
@@ -31,7 +31,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { globalDraftStore } from '$lib/components/copilot/chat/global/draftStore.svelte'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -153,16 +153,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// AI tools populate `globalDraftStore` with in-memory drafts (the AI may
// have edited a flow that hasn't been persisted to the backend yet).
// Merge those into the picker so users can navigate to them. Filter to
// kinds the picker actually displays.
// Chat tools and session editor previews write drafts through
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
// the picker so users can navigate to in-flight items that haven't been
// deployed yet. Filter to kinds the picker actually displays.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return globalDraftStore
.listDrafts($workspaceStore)
return listGlobalDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
@@ -42,17 +42,75 @@ vi.mock('$lib/gen', async () => {
return {
...actual,
ScriptService: wrapService(actual.ScriptService, {
existsScriptByPath: vi.fn(async () => false),
createScript: vi.fn(async () => 'created'),
getScriptByPathWithDraft: vi.fn(async () => {
throw new Error('getScriptByPathWithDraft mock not configured')
}),
listScripts: vi.fn(async () => [])
}),
FlowService: wrapService(actual.FlowService, {
existsFlowByPath: vi.fn(async () => false)
existsFlowByPath: vi.fn(async () => false),
createFlow: vi.fn(async () => 'created'),
updateFlow: vi.fn(async () => 'updated'),
getFlowByPath: vi.fn(async () => {
throw new Error('getFlowByPath mock not configured')
}),
getFlowByPathWithDraft: vi.fn(async () => {
throw new Error('getFlowByPathWithDraft mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
listFlows: vi.fn(async () => [])
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: vi.fn(async () => false),
getSchedule: vi.fn(async () => {
throw new Error('getSchedule mock not configured')
})
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: vi.fn(async () => false),
getHttpTrigger: vi.fn(async () => {
throw new Error('getHttpTrigger mock not configured')
})
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false),
getAppByPathWithDraft: vi.fn(async () => {
throw new Error('getAppByPathWithDraft mock not configured')
}),
listApps: vi.fn(async () => [])
}),
ResourceService: wrapService(actual.ResourceService, {
existsResource: vi.fn(async () => false),
getResource: vi.fn(async () => {
throw new Error('getResource mock not configured')
})
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false)
existsVariable: vi.fn(async () => false),
getVariable: vi.fn(async () => {
throw new Error('getVariable mock not configured')
}),
createVariable: vi.fn(async () => 'created'),
updateVariable: vi.fn(async () => 'updated')
})
}
})
import { globalTools, prepareGlobalUserMessage } from './core'
import { globalDraftStore } from './draftStore.svelte'
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import {
AppService,
FlowService,
HttpTriggerService,
ResourceService,
ScheduleService,
ScriptService,
VariableService
} from '$lib/gen'
import type { Tool, ToolCallbacks } from '../shared'
const WORKSPACE = 'global-core-test'
@@ -84,9 +142,20 @@ async function callGlobalTool(
})
}
function localStorageSnapshot(): string {
const values: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key) values.push(`${key}: ${localStorage.getItem(key)}`)
}
return values.join('\n')
}
describe('global AI tools', () => {
beforeEach(() => {
globalDraftStore.clearDrafts(WORKSPACE)
__resetUserDraftForTesting()
localStorage.clear()
clearGlobalDrafts(WORKSPACE)
vi.clearAllMocks()
})
@@ -105,6 +174,7 @@ describe('global AI tools', () => {
const item = JSON.parse(raw)
expect(raw).not.toContain('super-secret-token')
expect(localStorageSnapshot()).not.toContain('super-secret-token')
expect(item).toEqual({
type: 'variable',
path: 'f/secrets/api_key',
@@ -113,6 +183,837 @@ describe('global AI tools', () => {
})
})
it('writes resource drafts in the editor UserDraft shape', async () => {
vi.mocked(ResourceService.existsResource).mockResolvedValueOnce(true)
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/resources/db',
description: 'existing database',
value: { host: 'old.example.com', port: 5432 },
resource_type: 'postgresql',
labels: ['prod'],
ws_specific: true,
edited_at: '2026-05-22T09:30:00Z'
} as any)
await callGlobalTool('write_resource', {
path: 'f/resources/db',
value: { host: 'new.example.com', port: 5432 },
resource_type: 'postgresql'
})
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
path: 'f/resources/db',
description: 'existing database',
args: { host: 'new.example.com', port: 5432 },
labels: ['prod'],
wsSpecific: true,
resource_type: 'postgresql'
})
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
})
it('writes variable drafts in the editor UserDraft shape', async () => {
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true)
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
path: 'f/secrets/api_key',
value: undefined,
is_secret: true,
description: 'old description',
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z',
labels: ['prod'],
ws_specific: true,
edited_at: '2026-05-22T09:30:00Z'
} as any)
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description'
})
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: ['prod'],
wsSpecific: true,
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z'
})
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
it('deploys secret variable drafts with ephemeral values only', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description'
})
expect(
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
wsSpecific: false
})
expect(localStorageSnapshot()).not.toContain('new-secret-token')
await callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
})
expect(VariableService.createVariable).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description',
ws_specific: false
})
})
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
UserDraft.save(
'variable',
'f/secrets/api_key',
{
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: undefined,
wsSpecific: false
},
{ workspace: WORKSPACE }
)
await expect(
callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
})
).rejects.toThrow('secret draft values are kept only in memory')
expect(VariableService.createVariable).not.toHaveBeenCalled()
expect(VariableService.updateVariable).not.toHaveBeenCalled()
})
it('writes script drafts into UserDraft', async () => {
const content = 'export async function main() {\n\treturn "hello"\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
}
)
})
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
await callGlobalTool('write_script', {
path: 'f/other/outside',
summary: 'Outside draft',
language: 'bun',
content: 'export async function main() { return "outside" }'
})
await callGlobalTool('write_script', {
path: 'f/matching/inside',
summary: 'Inside draft',
language: 'bun',
content: 'export async function main() { return "inside" }'
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
path_prefix: 'f/matching/',
limit: 1
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
path: 'f/matching/inside',
isDraft: true
})
])
})
it('lists and edits the live script editor draft through its effective path', async () => {
UserDraft.save(
'script',
'',
{
path: 'u/admin/amazed_script',
summary: 'Live script',
description: '',
content: 'export async function main(a: number, b: number) {\n\treturn a + b\n}',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: '',
effectivePath: 'u/admin/amazed_script'
})
const listRaw = await callGlobalTool('list_workspace_items', { types: ['script'] })
expect(JSON.parse(listRaw)).toContainEqual(
expect.objectContaining({
type: 'script',
path: 'u/admin/amazed_script',
isDraft: true,
isLiveDraft: true
})
)
await callGlobalTool('edit_script', {
path: 'u/admin/amazed_script',
old_string: 'return a + b',
new_string: 'return a * b'
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/amazed_script',
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
})
expect(
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
).toBeUndefined()
})
it('lists and writes the live flow editor draft through its effective path', async () => {
UserDraft.save(
'flow',
'',
{
path: '',
summary: 'Live flow',
value: { modules: [] },
schema: {},
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {}
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/admin/live_flow'
})
const listRaw = await callGlobalTool('list_workspace_items', { types: ['flow'] })
expect(JSON.parse(listRaw)).toContainEqual(
expect.objectContaining({
type: 'flow',
path: 'u/admin/live_flow',
isDraft: true,
isLiveDraft: true
})
)
await callGlobalTool('write_flow', {
path: 'u/admin/live_flow',
summary: 'Updated live flow',
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/live_flow',
summary: 'Updated live flow',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
})
it('writes the live raw app editor draft through its effective path', async () => {
UserDraft.save(
'raw_app',
'',
{
summary: 'Live app',
files: { '/src/App.tsx': 'export default function App() { return null }' },
runnables: {},
data: { tables: [] }
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'raw_app',
storagePath: '',
effectivePath: 'u/admin/live_app'
})
await callGlobalTool('write_app_file', {
path: 'u/admin/live_app',
file_path: '/src/New.tsx',
content: 'export default function New() { return null }'
})
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
files: {
'/src/App.tsx': 'export default function App() { return null }',
'/src/New.tsx': 'export default function New() { return null }'
}
})
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
})
it('discards a local draft without deleting the workspace item', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/discard-me',
summary: 'Temporary draft',
language: 'bun',
content: 'export async function main() { return 1 }'
})
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
const raw = await callGlobalTool('discard_local_draft', {
type: 'script',
path: 'f/scripts/discard-me'
})
expect(JSON.parse(raw)).toMatchObject({
success: true,
type: 'script',
path: 'f/scripts/discard-me'
})
expect(raw).toContain('The deployed workspace item was not changed')
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeUndefined()
})
it('requires trigger_kind when discarding a trigger draft', async () => {
await expect(
callGlobalTool('discard_local_draft', {
type: 'trigger',
path: 'f/routes/missing-kind'
})
).rejects.toThrow('trigger_kind is required')
})
it('preserves existing script metadata and seeds freshness on first script write', async () => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
path: 'f/scripts/existing',
hash: 'deployed-hash',
draft_created_at: '2026-05-22T10:00:00Z',
summary: 'deployed summary',
description: 'deployed description',
content: 'old deployed content',
language: 'bun',
kind: 'script',
draft: {
path: 'f/scripts/existing',
summary: 'db draft summary',
description: 'db draft description',
content: 'old draft content',
language: 'bun',
kind: 'script'
}
} as any)
await callGlobalTool('write_script', {
path: 'f/scripts/existing',
summary: 'new summary',
language: 'bun',
content: 'new content'
})
expect(
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
content: 'new content',
language: 'bun'
})
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
})
})
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
value: { modules: [] },
schema: { properties: { deployed: { type: 'boolean' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
archived: false,
extra_perms: {},
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path: 'f/flows/existing',
summary: 'db draft summary',
description: 'db draft description',
value: { modules: [] },
schema: { properties: { draft: { type: 'string' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
} as any)
await callGlobalTool('write_flow', {
path: 'f/flows/existing',
summary: 'new summary',
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 42,
remoteDraftRev: '2026-05-22T10:00:00Z'
})
})
it('preserves editor schedule fields when writing over an existing schedule', async () => {
vi.mocked(ScheduleService.existsSchedule).mockResolvedValueOnce(true)
vi.mocked(ScheduleService.getSchedule).mockResolvedValueOnce({
path: 'f/schedules/nightly',
schedule: '0 0 0 * * *',
timezone: 'UTC',
enabled: true,
script_path: 'f/scripts/old',
is_flow: false,
args: {},
extra_perms: { 'u/viewer': true },
email: 'admin@windmill.dev',
permissioned_as: 'u/admin',
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
summary: 'old summary',
description: 'keep this description',
no_flow_overlap: true,
cron_version: 'v2'
} as any)
await callGlobalTool('write_schedule', {
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
timezone: 'Europe/Paris',
script_path: 'f/flows/new',
is_flow: true,
args: { limit: 5 }
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
timezone: 'Europe/Paris',
script_path: 'f/flows/new',
is_flow: true,
args: { limit: 5 },
extra_perms: { 'u/viewer': true },
permissioned_as: 'u/admin',
summary: 'old summary',
description: 'keep this description',
no_flow_overlap: true
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).not.toMatchObject({
edited_by: expect.anything()
})
})
it('preserves editor trigger fields when writing over an existing trigger', async () => {
vi.mocked(HttpTriggerService.existsHttpTrigger).mockResolvedValueOnce(true)
vi.mocked(HttpTriggerService.getHttpTrigger).mockResolvedValueOnce({
path: 'f/routes/api',
script_path: 'f/scripts/old',
is_flow: false,
route_path: 'api/old',
http_method: 'post',
request_type: 'sync',
authentication_method: 'none',
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
mode: 'enabled',
extra_perms: { 'u/viewer': true },
workspace_id: WORKSPACE,
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
permissioned_as: 'u/admin',
summary: 'old route',
description: 'keep route description'
} as any)
await callGlobalTool('write_trigger', {
kind: 'http',
config: {
path: 'f/routes/api',
script_path: 'f/flows/new',
is_flow: true,
route_path: 'api/new',
http_method: 'get',
authentication_method: 'windmill',
is_static_website: false
}
})
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
expect(draft).toMatchObject({
path: 'f/routes/api',
script_path: 'f/flows/new',
is_flow: true,
route_path: 'api/new',
http_method: 'get',
authentication_method: 'windmill',
extra_perms: { 'u/viewer': true },
permissioned_as: 'u/admin',
summary: 'old route',
description: 'keep route description'
})
expect(draft).not.toMatchObject({
workspace_id: expect.anything(),
edited_by: expect.anything()
})
})
it('seeds raw app draft metadata on first app write', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [3, 4],
draft_created_at: '2026-05-22T10:30:00Z',
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
policy: { execution_mode: 'publisher' },
custom_path: 'report',
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' }
}
} as any)
await callGlobalTool('write_app_file', {
path: 'f/apps/report',
file_path: '/src/New.tsx',
content: 'export default function New() { return null }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'saved app draft',
files: {
'/src/App.tsx': 'draft content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
custom_path: 'report'
})
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
remoteRev: 4,
remoteDraftRev: '2026-05-22T10:30:00Z'
})
})
it('summarizes local raw app drafts in read_workspace_item', async () => {
UserDraft.save(
'raw_app',
'f/apps/local',
{
summary: 'local app',
files: { '/src/App.tsx': 'const frontendSecret = "do-not-dump"' },
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'const backendSecret = "do-not-dump"'
}
}
},
data: { tables: ['orders'] }
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
path: 'f/apps/local'
})
const item = JSON.parse(raw)
expect(raw).not.toContain('frontendSecret')
expect(raw).not.toContain('backendSecret')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/local',
summary: 'local app',
isDraft: true,
value: {
frontend: [{ path: '/src/App.tsx', size: 'const frontendSecret = "do-not-dump"'.length }],
backend: [
expect.objectContaining({
key: 'main',
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'const backendSecret = "do-not-dump"'.length
})
],
data: { tables: ['orders'] }
}
})
expect(item.value.backend[0]).not.toHaveProperty('content')
})
it('summarizes backend raw app drafts from the same source as file reads', async () => {
const appWithDraft = {
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: ['deployed'] }
},
draft: {
summary: 'saved app draft',
value: {
files: {
'/src/App.tsx': 'draft content',
'/src/DraftOnly.tsx': 'draft-only content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "draft" }'
}
}
},
data: { tables: ['draft'] }
}
}
}
vi.mocked(AppService.getAppByPathWithDraft)
.mockResolvedValueOnce(appWithDraft as any)
.mockResolvedValueOnce(appWithDraft as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
path: 'f/apps/report'
})
const item = JSON.parse(raw)
expect(raw).not.toContain('draft-only content')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/report',
summary: 'saved app draft',
value: {
frontend: [
{ path: '/src/App.tsx', size: 'draft content'.length },
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
],
backend: [
expect.objectContaining({
key: 'main',
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'export async function main() { return "draft" }'.length
})
],
data: { tables: ['draft'] }
},
isDraft: false
})
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/DraftOnly.tsx'
})
).resolves.toBe('draft-only content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('reads raw app files without creating a local draft', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {},
data: { tables: [] }
}
}
} as any)
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/App.tsx'
})
).resolves.toBe('draft content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('patch_app_file', {
path: 'f/apps/report',
file_path: '/src/App.tsx',
old_string: 'missing content',
new_string: 'replacement',
replace_all: false
})
).rejects.toThrow()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('delete_app_file', {
path: 'f/apps/report',
file_path: '/src/Missing.tsx'
})
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('delete_app_runnable', {
path: 'f/apps/report',
key: 'missing'
})
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('fills an empty rawscript module through set_flow_module_code', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
@@ -138,7 +1039,7 @@ describe('global AI tools', () => {
module_id: 'empty_step',
code
})
).resolves.toContain('Updated AI draft flow')
).resolves.toContain('Updated local draft flow')
await expect(
callGlobalTool('read_flow_module_code', {
@@ -239,6 +1140,36 @@ describe('global AI tools', () => {
})
})
describe('prepareGlobalSystemMessage', () => {
it('keeps global chat draft instructions concise and user-facing', () => {
const message = prepareGlobalSystemMessage()
const content = message.content
expect(content).toContain('Draft tools create or update local drafts only')
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
)
expect(content).not.toContain('AI draft')
expect(content).not.toContain('UserDraft')
expect(content).not.toContain('localStorage')
expect(content).not.toContain('frontend AI draft store')
})
it('exposes separate tools for discarding drafts and deleting workspace items', () => {
const discard = getGlobalTool('discard_local_draft')
const deleteItem = getGlobalTool('delete_workspace_item')
expect(discard.def.function.description).toBe(
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
)
expect(deleteItem.def.function.description).toBe(
'Delete a deployed workspace item. Mutates the workspace.'
)
expect(discard.requiresConfirmation).toBe(true)
expect(deleteItem.requiresConfirmation).toBe(true)
})
})
describe('prepareGlobalUserMessage', () => {
it('includes selected workspace item references without contents', () => {
const message = prepareGlobalUserMessage('Update these items', [
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import type { WorkspaceItem } from './draftStore.svelte'
import type { WorkspaceItem } from './workspaceItems'
describe('global AI deploy request builders', () => {
it('preserves existing script metadata while replacing draft-controlled fields', () => {
@@ -1,5 +1,5 @@
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
@@ -1,192 +0,0 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* — the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft → workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
function clone<T>(value: T): T {
return structuredClone($state.snapshot(value)) as T
}
class GlobalDraftStore {
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
return this.drafts[workspace] ?? {}
}
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
if (!this.drafts[workspace]) {
this.drafts[workspace] = {}
}
return this.drafts[workspace]
}
listDrafts(workspace: string): WorkspaceItem[] {
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
}
getDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
return draft ? clone(draft) : undefined
}
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
this.ensureWorkspaceDrafts(workspace)[
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
] = stored
return clone(stored)
}
deleteDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
const drafts = this.drafts[workspace]
if (!drafts) return
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
if (Object.keys(drafts).length === 0) {
delete this.drafts[workspace]
}
}
clearDrafts(workspace: string): void {
delete this.drafts[workspace]
}
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'script', path)
}
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'flow', path)
}
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'schedule', path)
}
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'trigger', path, kind)
}
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'resource', path)
}
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'variable', path)
}
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'app', path)
}
}
export const globalDraftStore = new GlobalDraftStore()
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { globalDraftStore } from './draftStore.svelte'
const WORKSPACE_A = 'draft-store-test-a'
const WORKSPACE_B = 'draft-store-test-b'
function clearTestDrafts() {
globalDraftStore.clearDrafts(WORKSPACE_A)
globalDraftStore.clearDrafts(WORKSPACE_B)
}
describe('globalDraftStore', () => {
beforeEach(clearTestDrafts)
it('lists and reads drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'script',
path: 'f/shared/path',
language: 'bun',
value: 'export async function main() {}',
isDraft: true
})
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
'export async function main() {}'
)
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
it('deletes and clears drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: null, groups: null },
isDraft: true
})
globalDraftStore.setDraft(WORKSPACE_B, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
isDraft: true
})
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
globalDraftStore.clearDrafts(WORKSPACE_B)
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
})
@@ -0,0 +1,395 @@
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
UserDraft,
type UserDraftEntry,
type UserDraftItemKind,
type UserDraftMeta
} from '$lib/userDraft.svelte'
import {
getWorkspaceItemKey,
type AppDraftValue,
type ResourceDraftState,
type TriggerKind,
type TriggerRequestBody,
type VariableDraftState,
type WorkspaceItem,
type WorkspaceItemType
} from './workspaceItems'
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
http: 'trigger_http',
websocket: 'trigger_websocket',
kafka: 'trigger_kafka',
nats: 'trigger_nats',
postgres: 'trigger_postgres',
mqtt: 'trigger_mqtt',
sqs: 'trigger_sqs',
gcp: 'trigger_gcp',
azure: 'trigger_azure'
} as const satisfies Record<TriggerKind, UserDraftItemKind>
const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [
draftKind,
triggerKind
])
) as Partial<Record<UserDraftItemKind, TriggerKind>>
const GLOBAL_DRAFT_KINDS = [
'script',
'flow',
'raw_app',
'trigger_schedule',
'trigger_http',
'trigger_websocket',
'trigger_kafka',
'trigger_nats',
'trigger_postgres',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'resource',
'variable'
] as const satisfies UserDraftItemKind[]
const secretVariableDraftValues = new Map<string, Map<string, string>>()
function clone<T>(value: T): T {
return structuredClone(value) as T
}
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
return {
summary: value.summary,
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
policy: value.policy === undefined ? undefined : clone(value.policy),
custom_path: value.custom_path
}
}
function getItemSummary(value: unknown): string | undefined {
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
}
export function setEphemeralSecretVariableDraftValue(
workspace: string,
path: string,
value: string
): void {
let workspaceValues = secretVariableDraftValues.get(workspace)
if (!workspaceValues) {
workspaceValues = new Map()
secretVariableDraftValues.set(workspace, workspaceValues)
}
workspaceValues.set(path, value)
}
export function getEphemeralSecretVariableDraftValue(
workspace: string,
path: string
): string | undefined {
return secretVariableDraftValues.get(workspace)?.get(path)
}
export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void {
const workspaceValues = secretVariableDraftValues.get(workspace)
if (!workspaceValues) return
workspaceValues.delete(path)
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
}
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
secretVariableDraftValues.delete(workspace)
}
function itemKindFor(
type: WorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
switch (type) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
return type
case 'app':
return 'raw_app'
case 'schedule':
return 'trigger_schedule'
case 'trigger':
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
}
}
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
}
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
return {
type: 'script',
path,
summary: draft.summary,
language: draft.language,
value: draft.content,
isDraft: true
}
}
function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
return {
type: 'flow',
path,
summary: draft.summary,
value: {
value: draft.value,
schema: draft.schema ?? null,
groups: draft.value.groups ?? null
},
isDraft: true
}
}
function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem {
const value = normalizeAppDraftValue(draft)
return {
type: 'app',
path,
summary: value.summary,
value,
isDraft: true
}
}
function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem {
return {
type: 'schedule',
path,
summary: draft.summary ?? undefined,
value: clone(draft),
isDraft: true
}
}
function triggerDraftToWorkspaceItem(
kind: TriggerKind,
path: string,
draft: TriggerRequestBody
): WorkspaceItem {
return {
type: 'trigger',
triggerKind: kind,
path,
summary: getItemSummary(draft),
value: clone(draft),
isDraft: true
}
}
function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem {
return {
type: 'resource',
path,
summary: draft.description || undefined,
value: {
path,
value: clone(draft.args),
description: draft.description,
resource_type: draft.resource_type ?? '',
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem {
return {
type: 'variable',
path,
summary: draft.variable.description || undefined,
value: {
path,
value: draft.variable.value,
is_secret: draft.variable.is_secret,
description: draft.variable.description,
account: draft.account,
is_oauth: draft.is_oauth,
expires_at: draft.expires_at,
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function userDraftEntryToWorkspaceItem(
entry: UserDraftEntry,
path = entry.path,
isLiveDraft = false
): WorkspaceItem | undefined {
let item: WorkspaceItem | undefined
switch (entry.itemKind) {
case 'script':
item = scriptDraftToWorkspaceItem(path, entry.value as NewScript)
break
case 'flow':
item = flowDraftToWorkspaceItem(path, entry.value as Flow)
break
case 'raw_app':
item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue)
break
case 'trigger_schedule':
item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule)
break
case 'resource':
item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState)
break
case 'variable':
item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState)
break
default: {
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
item = triggerKind
? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody)
: undefined
}
}
return item && isLiveDraft ? { ...item, isLiveDraft: true } : item
}
function liveDisplayPath(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): { displayPath: string; isLiveDraft: boolean } {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (liveDraft?.storagePath !== storagePath) {
return { displayPath: storagePath, isLiveDraft: false }
}
return {
displayPath: liveDraft.effectivePath || storagePath,
isLiveDraft: true
}
}
function resolveDraftStoragePath(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): string {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!liveDraft) return path
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
return liveDraft.storagePath
return path
}
export function getGlobalDraftStoragePath(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
const itemKind = itemKindFor(type, triggerKind)
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
}
function getGlobalDraftSlot(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
) {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const draft = UserDraft.get(itemKind, storagePath, { workspace })
if (draft === undefined) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const entry = {
workspace,
itemKind,
path: storagePath,
value: draft,
meta: {},
persisted: false,
live: false
}
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!item) return undefined
return { itemKind, storagePath, displayPath, item }
}
export function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
const drafts = new Map<string, WorkspaceItem>()
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!draft) continue
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
}
return Array.from(drafts.values())
}
export function saveGlobalAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta
): WorkspaceItem {
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
const normalized = normalizeAppDraftValue(value)
if (meta) {
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
} else {
UserDraft.save('raw_app', storagePath, normalized, { workspace })
}
const stored = getGlobalDraft(workspace, 'app', path)
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
return stored
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
export function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
options: DeleteGlobalDraftOptions = {}
): void {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
}
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
UserDraft.clear(draft.itemKind, draft.path, { workspace })
}
clearEphemeralSecretVariableDraftValues(workspace)
}
@@ -0,0 +1,122 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft -> workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type ResourceDraftState = {
path: string
description: string
args: Record<string, any>
labels: string[] | undefined
wsSpecific: boolean
resource_type?: string
}
export type VariableDraftState = {
path: string
variable: { value: string; is_secret: boolean; description: string }
labels: string[] | undefined
wsSpecific: boolean
account?: number
is_oauth?: boolean
expires_at?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
isLiveDraft?: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
@@ -33,6 +33,7 @@ export type FlowBuilderProps = {
stepsState: Record<string, stepState>
}
noInitial?: boolean
liveEditorDraftStoragePath?: string
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
@@ -71,6 +71,7 @@
* key when the editor is rendered in a context that wants its own
* preference. */
sidebarStorageKey?: string
liveEditorDraftStoragePath?: string
}
let {
@@ -86,7 +87,8 @@
diffDrawer = undefined,
onNavigate,
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed'
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined
}: Props = $props()
export const version: number | undefined = undefined
@@ -936,6 +938,7 @@
{newApp}
{newPath}
appPath={path}
{liveEditorDraftStoragePath}
{files}
{data}
{runnables}
@@ -126,6 +126,7 @@
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
liveEditorDraftStoragePath?: string
}
let {
@@ -150,7 +151,8 @@
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined,
onNavigate = undefined
onNavigate = undefined,
liveEditorDraftStoragePath = undefined
}: Props = $props()
let newEditedPath = $state(
@@ -161,6 +163,22 @@
)
)
$effect(() => {
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'raw_app',
storagePath: liveEditorDraftStoragePath,
effectivePath: newEditedPath || appPath || savedApp?.path
})
return () =>
UserDraft.clearLiveEditorDraft('raw_app', {
workspace,
storagePath: liveEditorDraftStoragePath
})
})
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
@@ -4,12 +4,9 @@
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
globalDraftStore,
type FlowDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import type { Flow } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { initFlowState } from '$lib/components/flows/flowState'
import { applyDraftValueToFlow, flowToDraftValue } from './flowDraftCodec'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
@@ -41,32 +38,50 @@
await runtime.loadFlow(workspaceId, path)
}
// Bidirectional sync between this preview and the global AI chat's
// in-memory draft store. Same one-way-reactive discipline as the
// script case in ScriptEditorView.svelte — inbound tracks only the
// store, outbound tracks only `flowStore.val`; the "other side" read
// inside each effect goes through `untrack()`. Without that
// asymmetry, a user keystroke would re-fire the inbound effect with
// the pre-keystroke store value and revert the edit.
// Mark this editor as the "live editor" for the session's workspace so
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
// this path. Same registration the regular /flows/edit page does on
// mount, scoped to the session's (forked) workspace.
$effect(() => {
if (!workspaceId || !path) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'flow',
storagePath: path,
effectivePath: runtime.flowStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('flow', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<Flow>`. Same
// one-way-reactive discipline as before: inbound tracks only the store,
// outbound tracks only `flowStore.val`; the read on the "other side"
// inside each effect goes through `untrack()`. Without that asymmetry, a
// user keystroke would re-fire the inbound effect with the pre-keystroke
// stored value and revert the edit.
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs when globalDraftStore changes (AI write
// from this or another session). The flowStore read is untracked so
// Store → editor. Re-runs on UserDraft changes (AI write from this
// session's chat or another session). flowStore reads are untracked so
// the editor's own mutations don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getFlowDraft(workspaceId, path)
if (!draft || !draft.value || typeof draft.value !== 'object' || !('value' in draft.value))
return
const incoming = draft.value as FlowDraftValue
const sig = JSON.stringify(incoming)
const incoming = UserDraft.get<Flow>('flow', path, { workspace: workspaceId })
if (!incoming) return
const sig = JSON.stringify({ value: incoming.value, schema: incoming.schema })
untrack(() => {
if (runtime.loadedPath !== path) return
if (sig === lastInboundSig) return
const current = runtime.flowStore.val
if (!current) return
lastInboundSig = sig
runtime.flowStore.val = applyDraftValueToFlow(current, incoming)
runtime.flowStore.val = {
...current,
value: incoming.value,
schema: incoming.schema ?? current.schema,
summary: incoming.summary ?? current.summary
}
// flowStateStore is keyed by module_id; after an AI write the set
// of module ids may differ, so rebuild the UI state. This wipes
// per-module test args / preview output for the new flow — a
@@ -77,29 +92,23 @@
// Editor → store. Re-runs on any deep mutation of flowStore.val
// (modules, schema, module bodies). The store read is untracked.
// Debounced 150ms so a typing burst inside an inline rawscript
// editor results in one serialise-and-write instead of one per
// keystroke.
// Debounced 150ms so a typing burst inside an inline rawscript editor
// results in one serialise-and-write instead of one per keystroke.
let outboundTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedPath !== path) return
const flow = runtime.flowStore.val
if (!flow) return
const sig = JSON.stringify(flowToDraftValue(flow))
const sig = JSON.stringify({ value: flow.value, schema: flow.schema })
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = globalDraftStore.getFlowDraft(workspaceId, path)
if (current?.value && JSON.stringify(current.value) === sig) return
globalDraftStore.setDraft(workspaceId, {
type: 'flow',
path,
summary: flow.summary,
value: flowToDraftValue(flow),
isDraft: true
})
const current = UserDraft.get<Flow>('flow', path, { workspace: workspaceId })
if (current && JSON.stringify({ value: current.value, schema: current.schema }) === sig)
return
UserDraft.save('flow', path, flow, { workspace: workspaceId })
})
}, 150)
return () => {
@@ -4,11 +4,9 @@
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
globalDraftStore,
type AppDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import { applyDraftValueToRawApp, rawAppToDraftValue } from './appDraftCodec'
import { UserDraft } from '$lib/userDraft.svelte'
import type { RawAppDraft } from './appDraftCodec'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
@@ -36,25 +34,34 @@
await runtime.loadRawApp(workspaceId, path)
}
// Bidirectional sync with the global AI chat's globalDraftStore.
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /apps_raw/edit page does.
$effect(() => {
if (!workspaceId || !path) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'raw_app',
storagePath: path,
effectivePath: runtime.rawApp.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<RawAppDraft>`.
// Same one-way-reactive discipline as ScriptEditorView / FlowEditorView:
// inbound tracks only the store, outbound tracks only rawApp.val; each
// side's read into the other goes through untrack() to break the
// inbound tracks only UserDraft, outbound tracks only rawApp.val; each
// side's read of the other goes through untrack() to break the
// keystroke-revert race.
let lastInboundSig: string | undefined = $state(undefined)
// Store → editor. Re-runs on globalDraftStore changes (AI write).
// Store → editor. Re-runs on UserDraft changes (chat write, other
// session edit).
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getAppDraft(workspaceId, path)
if (
!draft ||
!draft.value ||
typeof draft.value !== 'object' ||
!('files' in (draft.value as object))
)
return
const incoming = draft.value as AppDraftValue
const incoming = UserDraft.get<RawAppDraft>('raw_app', path, { workspace: workspaceId })
if (!incoming) return
const sig = JSON.stringify(incoming)
untrack(() => {
if (runtime.loadedRawAppPath !== path) return
@@ -62,7 +69,7 @@
const current = runtime.rawApp.val
if (!current) return
lastInboundSig = sig
runtime.rawApp.val = applyDraftValueToRawApp(current, incoming)
runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming)
})
})
@@ -74,20 +81,15 @@
if (runtime.loadedRawAppPath !== path) return
const raw = runtime.rawApp.val
if (!raw) return
const sig = JSON.stringify(rawAppToDraftValue(raw))
const draft = runtimeRawAppToDraft(raw)
const sig = JSON.stringify(draft)
if (sig === lastInboundSig) return
if (outboundTimer) clearTimeout(outboundTimer)
outboundTimer = setTimeout(() => {
untrack(() => {
const current = globalDraftStore.getAppDraft(workspaceId, path)
if (current?.value && JSON.stringify(current.value) === sig) return
globalDraftStore.setDraft(workspaceId, {
type: 'app',
path,
summary: raw.summary,
value: rawAppToDraftValue(raw),
isDraft: true
})
const current = UserDraft.get<RawAppDraft>('raw_app', path, { workspace: workspaceId })
if (current && JSON.stringify(current) === sig) return
UserDraft.save('raw_app', path, draft, { workspace: workspaceId })
})
}, 150)
return () => {
@@ -4,7 +4,8 @@
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { globalDraftStore } from '$lib/components/copilot/chat/global/draftStore.svelte'
import type { NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
@@ -34,30 +35,41 @@
await runtime.loadScript(workspaceId, path)
}
// Bidirectional sync between this preview and the global AI chat's
// in-memory draft store. The store is workspace-scoped and keyed by
// (type, path), so any session looking at the same script — and the
// AI's read_workspace_item / write_script / edit_script tools — all
// converge on the same content.
//
// CRITICAL: each effect must be one-way reactive. The inbound tracks
// ONLY the store (and unwraps its mutation through `script.content`
// via untrack), and the outbound tracks ONLY `script.content` (and
// unwraps the store via untrack). Tracking both sides in either effect
// creates a race: a user keystroke updates `script.content` first,
// inbound re-fires while the store still holds the pre-keystroke
// value, and writes the stale store value back into the editor —
// visibly "resetting" the user's typing.
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs when globalDraftStore changes (AI write,
// other session edit, etc.). The script.content read is untracked
// so user keystrokes don't trigger this effect.
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
$effect(() => {
if (!workspaceId || !path) return
const draft = globalDraftStore.getScriptDraft(workspaceId, path)
if (!draft || typeof draft.value !== 'string') return
const incoming = draft.value
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'script',
storagePath: path,
effectivePath: runtime.scriptStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<NewScript>`.
// The same path under the same workspace is shared with the session's
// chat (read_workspace_item / write_script / edit_script) and any other
// open editor on the same workspace.
//
// One-way-reactive discipline: inbound tracks ONLY UserDraft.get
// (and unwraps `script.content` via untrack); outbound tracks ONLY
// `script.content` (and unwraps UserDraft via untrack). Without that
// asymmetry, a user keystroke would re-fire the inbound effect with
// the pre-keystroke stored value and revert the edit.
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs on UserDraft changes (chat write, other
// session edit, …). `script.content` is read inside untrack so user
// keystrokes don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (!draft || typeof draft.content !== 'string') return
const incoming = draft.content
untrack(() => {
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
@@ -70,9 +82,9 @@
})
})
// Editor → store. Re-runs when script.content changes (user typing,
// inbound mutation). The store read is untracked so writing to it
// here doesn't ping-pong the inbound effect.
// Editor → store. Re-runs on `script.content` mutation (user typing
// or inbound write). UserDraft is read inside untrack so writing here
// doesn't ping-pong the inbound effect.
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedScriptPath !== path) return
@@ -81,16 +93,16 @@
const content = script.content
if (content === lastInboundContent) return
untrack(() => {
const current = globalDraftStore.getScriptDraft(workspaceId, path)
if (typeof current?.value === 'string' && current.value === content) return
globalDraftStore.setDraft(workspaceId, {
type: 'script',
const current = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (current && current.content === content) return
UserDraft.save<NewScript>(
'script',
path,
language: script.language,
summary: script.summary,
value: content,
isDraft: true
})
{ ...(current ?? script), ...script },
{
workspace: workspaceId
}
)
})
})
</script>
@@ -1,10 +1,21 @@
import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils'
import type { AppDraftValue } from '$lib/components/copilot/chat/global/draftStore.svelte'
// The raw-app draft shape stored under `UserDraft<RawAppDraft>` — matches the
// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's
// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so
// session previews and the chat round-trip identically.
export type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts lines 74-84). Slightly flatter than the AI's
// `AppDraftValue`: `path` is metadata not present on the AI side, and
// `summary` is required here.
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
// makes `policy` required for the editor's live binding.
export type RuntimeRawApp = {
summary: string
path: string
@@ -14,28 +25,27 @@ export type RuntimeRawApp = {
policy: any
}
// Strip runtime metadata (just `path` for raw apps) and project into the
// AI-facing `AppDraftValue` envelope.
export function rawAppToDraftValue(raw: RuntimeRawApp): AppDraftValue {
// Strip runtime-only metadata (just `path`, the storage key) when persisting
// to UserDraft.
export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
return {
summary: raw.summary,
files: raw.files,
runnables: raw.runnables,
data: raw.data,
policy: raw.policy
// custom_path is read-only and not held on the runtime.
}
}
// Overlay an AI-produced draft onto an existing runtime raw app,
// preserving metadata fields (path) that don't live in `AppDraftValue`.
export function applyDraftValueToRawApp(raw: RuntimeRawApp, dv: AppDraftValue): RuntimeRawApp {
// Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app,
// preserving the runtime-only `path` field.
export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp {
return {
...raw,
summary: dv.summary ?? raw.summary,
summary: dv.summary,
files: dv.files,
runnables: dv.runnables,
data: (dv.data as RawAppData | undefined) ?? raw.data,
data: dv.data,
policy: dv.policy ?? raw.policy
}
}
@@ -1,28 +0,0 @@
import type { Flow } from '$lib/gen'
import type { FlowDraftValue } from '$lib/components/copilot/chat/global/draftStore.svelte'
// Convert the editor's full `Flow` (carrying metadata like path, edited_by,
// edited_at, archived, etc.) into the slimmer `FlowDraftValue` shape the
// global AI chat's draft store uses. Metadata stays on the runtime side —
// the draft store only holds what the AI's tools need to round-trip the
// in-flight edit.
export function flowToDraftValue(flow: Flow): FlowDraftValue {
return {
value: flow.value,
schema: flow.schema ?? null,
groups: flow.value.groups ?? null
}
}
// Overlay a draft from the store onto an existing `Flow`. Preserves the
// metadata fields that aren't in `FlowDraftValue` (path, edited_by,
// edited_at, archived, extra_perms, …). `groups` lives inside
// `FlowValue`, so it rides along on `dv.value` automatically; the
// sibling-key on `FlowDraftValue` is purely for the AI's tool I/O.
export function applyDraftValueToFlow(flow: Flow, dv: FlowDraftValue): Flow {
return {
...flow,
value: dv.value,
schema: dv.schema ?? flow.schema
}
}
@@ -28,13 +28,8 @@ import {
type Session,
type SessionTarget
} from './sessionState.svelte'
import {
globalDraftStore,
type AppDraftValue,
type FlowDraftValue
} from '$lib/components/copilot/chat/global/draftStore.svelte'
import { applyDraftValueToFlow, flowToDraftValue } from './flowDraftCodec'
import { applyDraftValueToRawApp, rawAppToDraftValue } from './appDraftCodec'
import { UserDraft } from '$lib/userDraft.svelte'
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } from './appDraftCodec'
import { setOpenPreviewHandler } from '$lib/components/copilot/chat/global/core'
export interface SessionRuntime {
@@ -211,21 +206,14 @@ function createRuntime(session: Session): SessionRuntime {
loadingFlow = true
notFound = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (write_flow / patch_flow_json
// / set_flow_module_code) and the editor's outbound $effect
// mirrors user edits back into it. If a draft exists we render
// from it, even when the path has never been deployed.
const aiDraft = globalDraftStore.getFlowDraft(workspace, path)
const draftValue =
aiDraft &&
aiDraft.value &&
typeof aiDraft.value === 'object' &&
'value' in (aiDraft.value as object)
? (aiDraft.value as FlowDraftValue)
: undefined
// Draft first. UserDraft is the shared authoritative content
// source the chat (write_flow / patch_flow_json /
// set_flow_module_code) and the editor's outbound $effect both
// write through it. If a draft exists we render from it, even
// when the path has never been deployed.
const aiDraft = UserDraft.get<Flow>('flow', path, { workspace })
if (draftValue) {
if (aiDraft) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only flows are a valid state.
@@ -235,18 +223,7 @@ function createRuntime(session: Session): SessionRuntime {
} catch {
savedFlow.val = undefined
}
const skeleton: Flow = (savedFlow.val as Flow | undefined) ?? {
path,
summary: '',
value: { modules: [] },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
const flow = applyDraftValueToFlow(skeleton, draftValue)
await initFlow(flow, flowStore, flowStateStore)
await initFlow(aiDraft, flowStore, flowStateStore)
loadedPath = path
return
}
@@ -256,13 +233,7 @@ function createRuntime(session: Session): SessionRuntime {
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow)
globalDraftStore.setDraft(workspace, {
type: 'flow',
path,
summary: flow.summary,
value: flowToDraftValue(flow),
isDraft: true
})
UserDraft.save('flow', path, flow, { workspace })
await initFlow(flow, flowStore, flowStateStore)
loadedPath = path
} catch (err) {
@@ -290,16 +261,14 @@ function createRuntime(session: Session): SessionRuntime {
loadingScript = true
notFoundScript = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (write_script / edit_script)
// and the editor's outbound $effect mirrors user edits back
// into it. If a draft exists we render from it, even when the
// path has never been deployed.
const aiDraft = globalDraftStore.getScriptDraft(workspace, path)
const draftContent =
aiDraft && typeof aiDraft.value === 'string' ? aiDraft.value : undefined
// Draft first. UserDraft is the shared authoritative content
// source the chat (write_script / edit_script) and the
// editor's outbound $effect both write through it. If a draft
// exists we render from it, even when the path has never been
// deployed.
const aiDraft = UserDraft.get<NewScript>('script', path, { workspace })
if (aiDraft && draftContent !== undefined) {
if (aiDraft && typeof aiDraft.content === 'string') {
// Best-effort fetch the backend baseline for the diff
// drawer + parent_hash. 404 means draft-only — leave
// savedScript undefined and skip parent_hash.
@@ -322,7 +291,7 @@ function createRuntime(session: Session): SessionRuntime {
if (savedScript.val?.hash) {
baseline.parent_hash = savedScript.val.hash
}
baseline.content = draftContent
baseline.content = aiDraft.content
if (aiDraft.language) baseline.language = aiDraft.language
if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary
scriptStore.val = baseline
@@ -335,14 +304,7 @@ function createRuntime(session: Session): SessionRuntime {
savedScript.val = result
const baseline = (result.draft as NewScript | undefined) ?? (result as NewScript)
baseline.parent_hash = result.hash
globalDraftStore.setDraft(workspace, {
type: 'script',
path,
language: baseline.language,
summary: baseline.summary,
value: baseline.content ?? '',
isDraft: true
})
UserDraft.save<NewScript>('script', path, baseline, { workspace })
scriptStore.val = baseline
loadedScriptPath = path
} catch (err) {
@@ -425,21 +387,14 @@ function createRuntime(session: Session): SessionRuntime {
loadingRawApp = true
notFoundRawApp = false
try {
// Draft first. globalDraftStore is the authoritative content
// source: the AI writes through it (init_app / write_app_file
// / ...) and the editor's outbound $effect mirrors user edits
// back into it. If a draft exists we render from it, even
// when the path has never been deployed.
const aiDraft = globalDraftStore.getAppDraft(workspace, path)
const draftValue =
aiDraft &&
aiDraft.value &&
typeof aiDraft.value === 'object' &&
'files' in (aiDraft.value as object)
? (aiDraft.value as AppDraftValue)
: undefined
// Draft first. UserDraft is the shared authoritative content
// source the chat (init_app / write_app_file / ...) and the
// editor's outbound $effect both write through it. If a draft
// exists we render from it, even when the path has never been
// deployed.
const aiDraft = UserDraft.get<RawAppDraft>('raw_app', path, { workspace })
if (draftValue) {
if (aiDraft) {
// Best-effort fetch the backend baseline for the diff
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only apps are a valid state.
@@ -457,16 +412,16 @@ function createRuntime(session: Session): SessionRuntime {
} catch {
savedRawApp.val = undefined
}
rawApp.val = applyDraftValueToRawApp(
rawApp.val = applyDraftToRuntimeRawApp(
{
files: {},
runnables: {},
data: { ...DEFAULT_DATA },
policy: undefined,
summary: draftValue.summary ?? '',
summary: aiDraft.summary ?? '',
path
},
draftValue
aiDraft
)
loadedRawAppPath = path
return
@@ -510,13 +465,7 @@ function createRuntime(session: Session): SessionRuntime {
summary: result.summary ?? '',
path: result.path
}
globalDraftStore.setDraft(workspace, {
type: 'app',
path,
summary: runtimeValue.summary,
value: rawAppToDraftValue(runtimeValue),
isDraft: true
})
UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace })
rawApp.val = runtimeValue
loadedRawAppPath = path
} catch (err) {
+61 -6
View File
@@ -130,7 +130,26 @@ export type UserDraftEntry<V = unknown> = {
live: boolean
}
export type LiveEditorDraft = {
workspace: string
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
}
export type LiveEditorDraftSpec = {
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
workspace?: string
}
export type ClearLiveEditorDraftOptions = UserDraftOptions & {
storagePath?: string
}
const entries = new Map<string, DraftEntry>()
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
@@ -230,6 +249,10 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
return `${workspace}/${itemKind}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
@@ -331,10 +354,13 @@ export const UserDraft = {
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.
// Static writes are external mutations. Update live observers and
// force the storage slot to match, even if the live entry still has
// its initial-write skip armed.
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
entry.state.val = wrap(value, extractMeta(current))
const meta = extractMeta(current)
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
// No live handle: preserve any persisted meta so the staleness
@@ -361,10 +387,10 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
entry.state.val = wrap(value, meta)
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
@@ -401,9 +427,9 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
return unwrap(entry.state.val as StoredDraft<V> | undefined)
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
}
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
},
/**
@@ -523,6 +549,34 @@ export const UserDraft = {
return Array.from(out.values())
},
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
const ws = resolveWorkspace({ workspace: spec.workspace })
liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), {
workspace: ws,
itemKind: spec.itemKind,
storagePath: spec.storagePath,
effectivePath: spec.effectivePath || undefined
})
},
getLiveEditorDraft(
itemKind: UserDraftItemKind,
opts?: UserDraftOptions
): LiveEditorDraft | undefined {
const ws = resolveWorkspace(opts)
const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind))
return draft ? { ...draft } : undefined
},
clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void {
const ws = resolveWorkspace(opts)
const key = liveEditorDraftKey(ws, itemKind)
const draft = liveEditorDrafts.get(key)
if (!draft) return
if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return
liveEditorDrafts.delete(key)
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
@@ -802,4 +856,5 @@ export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void
/** Test-only: clear all in-memory entries. */
export function __resetUserDraftForTesting(): void {
entries.clear()
liveEditorDrafts.clear()
}
+109 -12
View File
@@ -18,6 +18,7 @@ vi.mock('svelte', async (importOriginal) => {
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
await import('./userDraft.svelte')
const { workspaceStore } = await import('./stores')
const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter')
function flushDestroyCallbacks(): void {
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
@@ -119,6 +120,78 @@ describe('UserDraft.save / get / remove (no observers)', () => {
})
})
describe('UserDraft live editor draft registry', () => {
it('stores the live editor storage path and effective path per workspace and kind', () => {
UserDraft.setLiveEditorDraft({
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/generated_script'
})
expect(UserDraft.getLiveEditorDraft('script')).toEqual({
workspace: 'test_ws',
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/generated_script'
})
})
it('keeps live editor registrations isolated by workspace', () => {
UserDraft.setLiveEditorDraft({
workspace: 'ws_a',
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/me/a'
})
UserDraft.setLiveEditorDraft({
workspace: 'ws_b',
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/me/b'
})
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe(
'u/me/a'
)
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe(
'u/me/b'
)
})
it('clears only the matching live editor storage path when provided', () => {
UserDraft.setLiveEditorDraft({
itemKind: 'raw_app',
storagePath: '',
effectivePath: 'u/me/live_app'
})
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' })
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined()
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' })
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined()
})
it('can remove persisted global draft storage without blanking the live editor', () => {
const draft = { path: 'u/me/live_script', content: 'export async function main() {}' }
localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft))
const handle = UserDraft.use<typeof draft>('script', '')
UserDraft.setLiveEditorDraft({
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/live_script'
})
deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, {
preserveLiveDraft: true
})
flushPersist()
expect(handle.draft).toEqual(draft)
expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull()
})
})
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'))
@@ -138,24 +211,29 @@ describe('UserDraft.use() — observer sync', () => {
expect(a.draft).toBe(99)
})
it('save() propagates to live use() handles (in-memory)', () => {
it('save() propagates to live use() handles and persists immediately', () => {
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()
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7))
// 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('get() returns a cloneable snapshot of live handle values', () => {
const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '')
handle.draft = { path: 'u/me/live', nested: { value: 1 } }
const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '')
expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } })
expect(draft).not.toBe(handle.draft)
expect(() => structuredClone(draft)).not.toThrow()
})
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))
@@ -411,6 +489,28 @@ describe('UserDraft — rev metadata for staleness checks', () => {
)
})
it('UserDraft.save persists immediately when a live handle exists', () => {
const handle = UserDraft.use<string>('flow', 'u/me/live-save')
UserDraft.save('flow', 'u/me/live-save', 'external')
expect(handle.draft).toBe('external')
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external'))
})
it('UserDraft.save preserves live rev metadata while forcing persistence', () => {
const handle = UserDraft.use<string>('flow', 'u/me/live-save-meta')
handle.setDraftAndMeta('baseline', { remoteRev: 5 })
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull()
UserDraft.save('flow', 'u/me/live-save-meta', 'external')
expect(handle.draft).toBe('external')
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe(
JSON.stringify({ value: 'external', 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',
@@ -503,8 +603,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
UserDraft.save('flow', 'u/me/ref', 2)
expect(a.draft).toBe(2)
// Now persisted (second write after the baseline).
flushPersist()
// External save() calls persist immediately, even with a live handle.
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
// Releasing the second handle drops the entry; subsequent save()
@@ -878,9 +977,7 @@ describe('UserDraft.list / clear / setDraftAndMeta', () => {
)
flushPersist()
expect(storedShape(key)).toBe(
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
)
expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' }))
})
it('list hides persisted drafts when a live handle has cleared the value', () => {
@@ -79,6 +79,8 @@
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy?: Policy
custom_path?: 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
@@ -114,13 +116,15 @@
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 policy: Policy = $state(
restoredDraft?.policy ?? {
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 */
@@ -133,13 +137,14 @@
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
untrack(() => {
if (firstMirror) {
firstMirror = false
draftHandle.setDraftAndMeta(undefined, {})
}
draftHandle.draft = { files, runnables, data, summary }
draftHandle.draft = { files, runnables, data, summary, policy }
})
})
@@ -150,11 +155,12 @@
const d = draftHandle.draft
if (d == null) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
if (localDraftDiffers(d, { files, runnables, data, summary, policy })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
if (d.policy !== undefined) policy = d.policy
}
})
})
@@ -666,6 +672,7 @@
bind:data
{policy}
path={''}
liveEditorDraftStoragePath=""
bind:summary
newApp
/>
@@ -32,6 +32,8 @@
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
let files: Record<string, string> | undefined = $state(undefined)
@@ -105,25 +107,45 @@
// Persist the bundle whenever any of the four pieces of state changes.
$effect(() => {
if (!files) return
readFieldsRecursively(files)
const currentFiles = files
if (!currentFiles) return
readFieldsRecursively(currentFiles)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
draftHandle.draft = { files, runnables, data, summary }
draftHandle.draft = {
files: currentFiles,
runnables,
data,
summary,
policy,
custom_path: savedApp?.custom_path
}
})
// 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
const currentFiles = files
if (d == null || !currentFiles) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
if (
localDraftDiffers(d, {
files: currentFiles,
runnables,
data,
summary,
policy,
custom_path: savedApp?.custom_path
})
) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
if (d.policy !== undefined) policy = d.policy
}
})
})
@@ -192,7 +214,9 @@
(backendSource.value?.datatables
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? ''
summary: backendSource.summary ?? '',
policy: backendSource.policy ?? app_w_draft.policy,
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
}
if (
@@ -240,7 +264,7 @@
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = app_w_draft.policy
policy = localDraft.policy ?? app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
@@ -370,6 +394,7 @@
bind:summary
{newPath}
path={page.params.path ?? ''}
liveEditorDraftStoragePath={path}
{policy}
bind:savedApp
{diffDrawer}
@@ -200,6 +200,7 @@
onNavigate={(item) => goto(editPathFor(item))}
{initialPath}
{pathStoreInit}
liveEditorDraftStoragePath=""
bind:this={flowBuilder}
newFlow
{initialArgs}
@@ -367,6 +367,7 @@
{flowStore}
{flowStateStore}
initialPath={page.params.path ?? ''}
liveEditorDraftStoragePath={flowDraftPath}
newFlow={false}
{selectedId}
{initialArgs}
@@ -1,9 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
globalDraftStore,
type WorkspaceItem
} from '$lib/components/copilot/chat/global/draftStore.svelte'
clearGlobalDrafts,
deleteGlobalDraft,
listGlobalDrafts
} from '$lib/components/copilot/chat/global/userDraftAdapter'
import type { WorkspaceItem } from '$lib/components/copilot/chat/global/workspaceItems'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { goto } from '$lib/navigation'
import { workspaceStore } from '$lib/stores'
@@ -11,6 +13,11 @@
import { onMount } from 'svelte'
let enabled = $state(false)
let refreshToken = $state(0)
function refreshDrafts() {
refreshToken += 1
}
onMount(() => {
// Dev-only route. Bounce to home when the global mode gate is closed.
@@ -18,9 +25,24 @@
if (!enabled) {
goto('/')
}
const onStorage = (event: StorageEvent) => {
if (event.key?.startsWith('userdraft/')) refreshDrafts()
}
window.addEventListener('storage', onStorage)
// Same-tab saves and live editor registry changes don't emit `storage`.
const interval = window.setInterval(refreshDrafts, 1000)
return () => {
window.removeEventListener('storage', onStorage)
window.clearInterval(interval)
}
})
let drafts = $derived($workspaceStore ? globalDraftStore.listDrafts($workspaceStore) : [])
let drafts = $derived.by(() => {
refreshToken
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
})
function draftKey(item: WorkspaceItem): string {
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
@@ -28,12 +50,14 @@
function deleteDraft(item: WorkspaceItem) {
if (!$workspaceStore) return
globalDraftStore.deleteDraft($workspaceStore, item.type, item.path, item.triggerKind)
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
refreshDrafts()
}
function clearAll() {
if (!$workspaceStore) return
globalDraftStore.clearDrafts($workspaceStore)
clearGlobalDrafts($workspaceStore)
refreshDrafts()
}
</script>
@@ -41,9 +65,9 @@
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
<h1 class="text-2xl font-semibold">Global local drafts</h1>
<p class="text-sm text-tertiary">
Dev-only inspector for the in-memory global draft store.
Dev-only inspector for global local drafts.
</p>
</div>
<Button
@@ -112,6 +112,18 @@
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
})
$effect(() => {
if (!$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'script',
storagePath: '',
effectivePath: scriptHandle.draft?.path
})
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
})
// === 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
@@ -39,6 +39,18 @@
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
$effect(() => {
if (hash || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'script',
storagePath: draftPath,
effectivePath: scriptHandle.draft?.path ?? draftPath
})
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: 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,