Merge remote-tracking branch 'origin/main' into di/svelte5-migration

This commit is contained in:
Diego Imbert
2026-02-26 14:24:17 +01:00
54 changed files with 2419 additions and 718 deletions
+4
View File
@@ -73,3 +73,7 @@ profiles:
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee
+1 -1
View File
@@ -11,7 +11,7 @@ worktree_prefix: ""
window_prefix: "wm-"
auto_name:
model: "claude-sonnet-4.6"
model: "gemini-2.5-flash-lite"
system_prompt: |
Generate a concise git branch name based on the task description.
+10 -2
View File
@@ -65,7 +65,7 @@ Setting up zsh autocomplete is also recommended — see the [workmux docs](https
Each worktree is assigned a **slot** that determines its ports:
| Slot | Backend | Frontend |
|------|---------|----------|
| ---- | ------- | -------- |
| 0 | 8000 | 3000 |
| 1 | 8010 | 3010 |
| 2 | 8020 | 3020 |
@@ -192,7 +192,6 @@ sandbox:
This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container.
## Cursor SSH Integration (`wmc`)
`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree.
@@ -219,6 +218,7 @@ This:
1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved.
2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens.
3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window.
4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`).
After setup, reopen Cursor's terminal to pick up the new profile.
@@ -240,6 +240,14 @@ This runs `workmux add`, creates a grouped tmux session, writes `.vscode/setting
wmc open my-feature
```
**Open the EE worktree in Cursor (no tmux session):**
```bash
wmc open-ee my-feature
```
This finds the matching `windmill-ee-private__worktrees/<name>` directory and opens it in a new Cursor window.
**Close a worktree's Cursor window and tmux window (keeps the worktree):**
```bash
@@ -20,7 +20,8 @@
"resource",
"variable",
"ducklake",
"datatable"
"datatable",
"volume"
]
}
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Text",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )",
"query": "SELECT email FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
false
]
},
"hash": "0ef37117c369f03236e18f9dbb1f3d52776c8cb73f2507199c6ca16d4d2405ba"
"hash": "886a921adc115f0a9c6f3a68381bd8f5a16866135120175d9073b9b2c41bbd51"
}
@@ -16,7 +16,8 @@
"resource",
"variable",
"ducklake",
"datatable"
"datatable",
"volume"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n SELECT $1::text, email, false, $3 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING",
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n SELECT $1::text, email, false, $3 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "2e1d1c59bfc53d58962251822c85cf9a26e3b2888702e5e9d5fc1b082901df09"
"hash": "c0fad64e5d707ffa29d236f558e23b608168dc3a1b3857d2ad33ec20627acbff"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Timestamptz",
"Bool",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "c624f15f3e321b1eecf123da9bf0b18e8c1d16ef25ffb9d04e5447d0d583d55c"
}
+1 -1
View File
@@ -1 +1 @@
a4546264d41ce7122dfd127f4b17f724eb63c40a
2fb7884849a563bae023574baa2d55fa1fab1176
@@ -0,0 +1,10 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
ALTER ROLE custom_instance_user NOREPLICATION;
END IF;
EXCEPTION
WHEN others THEN
RAISE NOTICE 'Error revoking REPLICATION from custom_instance_user: %', SQLERRM;
END
$$;
@@ -0,0 +1,10 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
ALTER ROLE custom_instance_user REPLICATION;
END IF;
EXCEPTION
WHEN others THEN
RAISE NOTICE 'Error granting REPLICATION to custom_instance_user: %', SQLERRM;
END
$$;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_asset_ws_path_kind_recent;
-- Restore the dropped indexes
CREATE INDEX IF NOT EXISTS idx_asset_workspace_created_id ON asset (workspace_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_asset_kind_path ON asset (workspace_id, kind, path);
@@ -0,0 +1,11 @@
-- Covering index for the list_assets CTE: GROUP BY (path, kind) + MAX(created_at, id) + ORDER BY
-- Includes usage_kind and usage_path to allow full index-only scan (avoiding heap lookups for filter conditions)
CREATE INDEX IF NOT EXISTS idx_asset_ws_path_kind_recent
ON asset (workspace_id, path, kind, created_at DESC, id DESC)
INCLUDE (usage_kind, usage_path);
-- Drop indexes now subsumed by idx_asset_ws_path_kind_recent:
-- idx_asset_workspace_created_id (workspace_id, created_at DESC, id DESC) - only used by list_assets CTE
-- idx_asset_kind_path (workspace_id, kind, path) - only used by list_assets CTE/outer join, covered by new index + PK
DROP INDEX IF EXISTS idx_asset_workspace_created_id;
DROP INDEX IF EXISTS idx_asset_kind_path;
+2 -2
View File
@@ -144,7 +144,7 @@ async fn list_assets(
format!(
r#"FROM asset
LEFT JOIN v2_job job_cte ON asset.usage_kind = 'job'
AND asset.usage_path = job_cte.id::text
AND job_cte.id = CASE WHEN asset.usage_kind = 'job' THEN asset.usage_path::uuid END
AND job_cte.workspace_id = $1"#
)
} else {
@@ -209,7 +209,7 @@ async fn list_assets(
) = resource.path
AND resource.workspace_id = $1
LEFT JOIN v2_job job ON asset.usage_kind = 'job'
AND asset.usage_path = job.id::text
AND job.id = CASE WHEN asset.usage_kind = 'job' THEN asset.usage_path::uuid END
AND job.workspace_id = $1
WHERE asset.workspace_id = $1
AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)
+96 -68
View File
@@ -12,24 +12,24 @@ use std::{collections::HashMap, time::Duration};
mod ee;
pub mod ee_oss;
use windmill_api_auth::{require_super_admin, ApiAuthed};
#[cfg(feature = "enterprise")]
use windmill_api_auth::require_devops_role;
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
use windmill_common::DB;
use ee_oss::validate_license_key;
use windmill_common::usernames::generate_instance_username_for_all_users;
use axum::{
extract::{Extension, Path},
routing::{get, post},
body::Body,
response::Response,
Json, Router,
};
#[cfg(feature = "enterprise")]
use axum::extract::Query;
use axum::{
body::Body,
extract::{Extension, Path},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::json;
use serde::{Deserialize, Serialize};
@@ -43,9 +43,8 @@ use windmill_common::{
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING,
HUB_BASE_URL_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -171,7 +170,9 @@ pub async fn test_s3_bucket(
.await?
.store;
let mut list = client.list(Some(&windmill_object_store::object_store_reexports::Path::from("".to_string())));
let mut list = client.list(Some(
&windmill_object_store::object_store_reexports::Path::from("".to_string()),
));
let first_file = list.next().await;
if first_file.is_some() {
if let Err(e) = first_file.as_ref().unwrap() {
@@ -189,7 +190,10 @@ pub async fn test_s3_bucket(
));
tracing::info!("Testing blob storage at path: {path}");
client
.put(&path, windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"))
.put(
&path,
windmill_object_store::object_store_reexports::PutPayload::from_static(b"hello"),
)
.await
.map_err(|e| anyhow::anyhow!("error writing file to {path}: {e:#}"))?;
let content = client
@@ -290,17 +294,17 @@ pub async fn set_global_setting_internal(
match value {
serde_json::Value::Null => {
if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) {
return Err(error::Error::BadRequest(
format!("{key} is a protected setting and cannot be deleted"),
));
return Err(error::Error::BadRequest(format!(
"{key} is a protected setting and cannot be deleted"
)));
}
delete_global_setting(db, &key).await?;
}
serde_json::Value::String(x) if x.is_empty() => {
if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) {
return Err(error::Error::BadRequest(
format!("{key} is a protected setting and cannot be set to empty"),
));
return Err(error::Error::BadRequest(format!(
"{key} is a protected setting and cannot be set to empty"
)));
}
delete_global_setting(db, &key).await?;
}
@@ -437,7 +441,8 @@ async fn get_instance_config_yaml(
let config = InstanceConfig::from_db(&db)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let yaml = config.to_sorted_yaml()
let yaml = config
.to_sorted_yaml()
.map_err(|e| error::Error::internal_err(e))?;
Response::builder()
.header("content-type", "application/yaml")
@@ -478,8 +483,7 @@ async fn set_instance_config(
.map(|(k, v)| {
(
k.clone(),
serde_json::to_value(v)
.expect("WorkerGroupConfig serialization cannot fail"),
serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"),
)
})
.collect();
@@ -489,8 +493,7 @@ async fn set_instance_config(
.map(|(k, v)| {
(
k.clone(),
serde_json::to_value(v)
.expect("WorkerGroupConfig serialization cannot fail"),
serde_json::to_value(v).expect("WorkerGroupConfig serialization cannot fail"),
)
})
.collect();
@@ -944,7 +947,8 @@ async fn setup_custom_instance_pg_database_inner(
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;
ALTER ROLE custom_instance_user CREATEROLE;"
ALTER ROLE custom_instance_user CREATEROLE;
ALTER ROLE custom_instance_user REPLICATION;"
))
.await
.map_err(|e| {
@@ -1083,19 +1087,16 @@ async fn sync_cached_resource_types(
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", HUB_RT_CACHE_DIR);
let content = tokio::fs::read_to_string(&cache_path)
.await
.map_err(|e| {
error::Error::NotFound(format!(
"No cached resource types found at {}: {}",
cache_path, e
))
})?;
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
error::Error::NotFound(format!(
"No cached resource types found at {}: {}",
cache_path, e
))
})?;
let cached_types: Vec<CachedResourceType> =
serde_json::from_str(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?;
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?;
let mut synced_count = 0;
@@ -1176,7 +1177,10 @@ mod tests {
deserialized.global_settings.base_url.as_deref(),
Some("https://windmill.example.com")
);
assert_eq!(deserialized.global_settings.retention_period_secs, Some(86400));
assert_eq!(
deserialized.global_settings.retention_period_secs,
Some(86400)
);
assert_eq!(deserialized.global_settings.expose_metrics, Some(true));
let wc = &deserialized.worker_configs["default"];
assert_eq!(
@@ -1208,9 +1212,7 @@ mod tests {
let retention_pos = yaml.find("retention_period_secs:").unwrap();
assert!(
base_url_pos < email_pos
&& email_pos < expose_pos
&& expose_pos < retention_pos,
base_url_pos < email_pos && email_pos < expose_pos && expose_pos < retention_pos,
"global_settings keys should be alphabetically sorted, got yaml:\n{yaml}"
);
}
@@ -1220,22 +1222,34 @@ mod tests {
let config = InstanceConfig {
global_settings: GlobalSettings::default(),
worker_configs: BTreeMap::from([
("gpu".to_string(), WorkerGroupConfig {
init_bash: Some("echo gpu".to_string()),
..Default::default()
}),
("native".to_string(), WorkerGroupConfig {
init_bash: Some("echo native".to_string()),
..Default::default()
}),
("default".to_string(), WorkerGroupConfig {
init_bash: Some("echo default".to_string()),
..Default::default()
}),
("alpha".to_string(), WorkerGroupConfig {
init_bash: Some("echo alpha".to_string()),
..Default::default()
}),
(
"gpu".to_string(),
WorkerGroupConfig {
init_bash: Some("echo gpu".to_string()),
..Default::default()
},
),
(
"native".to_string(),
WorkerGroupConfig {
init_bash: Some("echo native".to_string()),
..Default::default()
},
),
(
"default".to_string(),
WorkerGroupConfig {
init_bash: Some("echo default".to_string()),
..Default::default()
},
),
(
"alpha".to_string(),
WorkerGroupConfig {
init_bash: Some("echo alpha".to_string()),
..Default::default()
},
),
]),
};
@@ -1264,26 +1278,40 @@ mod tests {
..Default::default()
},
worker_configs: BTreeMap::from([
("default".to_string(), WorkerGroupConfig {
worker_tags: Some(vec!["deno".to_string()]),
..Default::default()
}),
("native".to_string(), WorkerGroupConfig {
init_bash: Some("echo hi".to_string()),
..Default::default()
}),
(
"default".to_string(),
WorkerGroupConfig {
worker_tags: Some(vec!["deno".to_string()]),
..Default::default()
},
),
(
"native".to_string(),
WorkerGroupConfig {
init_bash: Some("echo hi".to_string()),
..Default::default()
},
),
]),
};
let yaml = config.to_sorted_yaml().unwrap();
let deserialized: InstanceConfig = serde_yml::from_str(&yaml).unwrap();
assert_eq!(deserialized.global_settings.base_url.as_deref(), Some("https://rt.test"));
assert_eq!(deserialized.global_settings.retention_period_secs, Some(7200));
assert_eq!(
deserialized.global_settings.base_url.as_deref(),
Some("https://rt.test")
);
assert_eq!(
deserialized.global_settings.retention_period_secs,
Some(7200)
);
assert_eq!(deserialized.global_settings.expose_metrics, Some(false));
assert_eq!(deserialized.worker_configs.len(), 2);
assert_eq!(
deserialized.worker_configs["default"].worker_tags.as_deref(),
deserialized.worker_configs["default"]
.worker_tags
.as_deref(),
Some(["deno".to_string()].as_slice())
);
assert_eq!(
+20
View File
@@ -10680,6 +10680,16 @@ paths:
required: false
schema:
type: string
- name: resume_button_text
in: query
required: false
schema:
type: string
- name: cancel_button_text
in: query
required: false
schema:
type: string
responses:
"200":
description: Interactive slack approval message sent successfully
@@ -10726,6 +10736,16 @@ paths:
required: false
schema:
type: string
- name: resume_button_text
in: query
required: false
schema:
type: string
- name: cancel_button_text
in: query
required: false
schema:
type: string
responses:
"200":
description: Interactive slack approval message sent successfully
+20 -2
View File
@@ -663,12 +663,30 @@ async fn global_proxy(
let base_url = provider.get_base_url(None, &db).await?;
let url = format!("{}/{}", base_url, ai_path);
let is_anthropic = provider.is_anthropic();
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let url = if is_anthropic_sdk {
let truncated_base_url = base_url.trim_end_matches("/v1");
format!("{}/{}", truncated_base_url, ai_path)
} else {
format!("{}/{}", base_url, ai_path)
};
let mut request = HTTP_CLIENT
.request(method, url)
.header("content-type", "application/json")
.header("Authorization", format!("Bearer {}", api_key));
.header("Authorization", format!("Bearer {}", &api_key));
if is_anthropic {
request = request.header("X-API-Key", &api_key);
}
for (header_name, header_value) in headers.iter() {
if header_name.to_string().starts_with("anthropic-") {
request = request.header(header_name, header_value);
}
}
// Apply custom headers from AI_HTTP_HEADERS environment variable
for (header_name, header_value) in AI_HTTP_HEADERS.iter() {
+13 -2
View File
@@ -83,6 +83,12 @@ pub struct QueryDynamicEnumJson {
pub dynamic_enums_json: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
pub struct QueryButtonText {
pub resume_button_text: Option<String>,
pub cancel_button_text: Option<String>,
}
#[derive(Debug)]
pub struct ApprovalFormDetails {
pub message_str: String,
@@ -266,7 +272,12 @@ pub async fn get_approval_form_details(
})
});
let args_str = args.map_or("None".to_string(), |a| a.get().to_string());
let args_str = args.map_or("None".to_string(), |a| {
serde_json::from_str::<serde_json::Value>(a.get())
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| a.get().to_string())
});
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
let script_path_str = script_path.as_deref().unwrap_or("None");
@@ -282,7 +293,7 @@ pub async fn get_approval_form_details(
{}: {created_by}\n\n\
{}: {created_at_formatted}\n\n\
{}: {script_path_str}\n\n\
{}: {args_str}\n\n\
{}:\n```\n{args_str}\n```\n\n\
{}: {parent_job_id_str}\n\n",
bold_format.replace("{}", "Created by"),
bold_format.replace("{}", "Created at"),
+3
View File
@@ -78,6 +78,9 @@ lazy_static::lazy_static! {
(20260207000004, include_str!(
"../../migrations/20260207000004_concurrent_indexes_other.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
(20260225100000, include_str!(
"../../migrations/20260225100000_asset_covering_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
].into_iter().collect();
}
+32 -5
View File
@@ -16,8 +16,8 @@ use crate::jobs::{QueryApprover, ResumeUrls};
use crate::{
approvals::{
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
MessageFormat, QueryDefaultArgsJson, QueryDynamicEnumJson, QueryFlowStepId, QueryMessage,
ResumeFormField, ResumeSchema,
MessageFormat, QueryButtonText, QueryDefaultArgsJson, QueryDynamicEnumJson,
QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
},
auth::OptTokened,
};
@@ -107,6 +107,8 @@ struct ModalActionValue {
flow_step_id: Option<String>,
default_args_json: Option<String>,
dynamic_enums_json: Option<String>,
resume_button_text: Option<String>,
cancel_button_text: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -200,6 +202,8 @@ pub async fn slack_app_callback_handler(
container,
default_args_json.as_ref(),
dynamic_enums_json.as_ref(),
parsed_value.resume_button_text.as_deref(),
parsed_value.cancel_button_text.as_deref(),
)
.await
.map_err(|e| Error::BadRequest(e.to_string()))?;
@@ -229,6 +233,7 @@ pub async fn request_slack_approval(
Query(flow_step_id): Query<QueryFlowStepId>,
Query(default_args_json): Query<QueryDefaultArgsJson>,
Query(dynamic_enums_json): Query<QueryDynamicEnumJson>,
Query(button_text): Query<QueryButtonText>,
) -> Result<StatusCode, Error> {
let slack_resource_path = slack_resource_path.slack_resource_path;
let channel_id = channel_id.channel_id;
@@ -255,6 +260,8 @@ pub async fn request_slack_approval(
flow_step_id.as_str(),
default_args_json.default_args_json.as_ref(),
dynamic_enums_json.dynamic_enums_json.as_ref(),
button_text.resume_button_text.as_deref(),
button_text.cancel_button_text.as_deref(),
)
.await
.map_err(|e| Error::BadRequest(e.to_string()))?;
@@ -752,6 +759,8 @@ async fn send_slack_message(
flow_step_id: &str,
default_args_json: Option<&serde_json::Value>,
dynamic_enums_json: Option<&serde_json::Value>,
resume_button_text: Option<&str>,
cancel_button_text: Option<&str>,
) -> Result<StatusCode, Box<dyn std::error::Error>> {
let url = "https://slack.com/api/chat.postMessage";
@@ -779,6 +788,14 @@ async fn send_slack_message(
value["dynamic_enums_json"] = dynamic_enums_json.clone();
}
if let Some(resume_button_text) = resume_button_text {
value["resume_button_text"] = serde_json::json!(resume_button_text);
}
if let Some(cancel_button_text) = cancel_button_text {
value["cancel_button_text"] = serde_json::json!(cancel_button_text);
}
let payload = serde_json::json!({
"channel": channel_id,
"text": "A flow has been suspended. Please approve or reject the flow.",
@@ -842,6 +859,8 @@ async fn get_modal_blocks(
container: Container,
default_args_json: Option<&serde_json::Value>,
dynamic_enums_json: Option<&serde_json::Value>,
resume_button_text: Option<&str>,
cancel_button_text: Option<&str>,
) -> Result<axum::Json<serde_json::Value>, Error> {
let approval_details = crate::approvals::get_approval_form_details(
db,
@@ -895,6 +914,8 @@ async fn get_modal_blocks(
&urls.resume,
resource_path,
container,
resume_button_text,
cancel_button_text,
)))
}
@@ -905,6 +926,8 @@ fn construct_payload(
resume_url: &str,
resource_path: &str,
container: Container,
resume_button_text: Option<&str>,
cancel_button_text: Option<&str>,
) -> serde_json::Value {
let mut view = serde_json::json!({
"type": "modal",
@@ -912,12 +935,12 @@ fn construct_payload(
"notify_on_close": true,
"title": {
"type": "plain_text",
"text": "Worfklow Suspended"
"text": "Workflow Suspended"
},
"blocks": blocks,
"submit": {
"type": "plain_text",
"text": "Resume Workflow"
"text": resume_button_text.unwrap_or("Resume Workflow")
},
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel }).to_string(),
});
@@ -925,7 +948,7 @@ fn construct_payload(
if !hide_cancel {
view["close"] = serde_json::json!({
"type": "plain_text",
"text": "Cancel Workflow"
"text": cancel_button_text.unwrap_or("Cancel Workflow")
});
}
@@ -949,6 +972,8 @@ async fn open_modal_with_blocks(
container: Container,
default_args_json: Option<&serde_json::Value>,
dynamic_enums_json: Option<&serde_json::Value>,
resume_button_text: Option<&str>,
cancel_button_text: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let resume_id = rand::random::<u32>();
let blocks_json = match get_modal_blocks(
@@ -964,6 +989,8 @@ async fn open_modal_with_blocks(
container,
default_args_json,
dynamic_enums_json,
resume_button_text,
cancel_button_text,
)
.await
{
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
chrono = "0.4.41"
duckdb = { rev = "fe0702529de6ec5a568337726bba9355503157d2", git = "https://github.com/windmill-labs/duckdb-rs.git", features = ["bundled"] }
duckdb = { version = "1.4.4", features = ["bundled"] }
rust_decimal = "1.37.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
@@ -4,7 +4,7 @@ use std::{
ptr::null_mut,
};
use duckdb::{Row, params_from_iter, types::TimeUnit};
use duckdb::{Row, core::LogicalTypeId, params_from_iter, types::TimeUnit};
use rust_decimal::{Decimal, prelude::FromPrimitive};
use serde::Deserialize;
use serde_json::value::RawValue;
@@ -267,7 +267,10 @@ fn do_duckdb_inner(
(0..stmt.column_count())
.map(|i| {
let logical_type = stmt.column_logical_type(i);
if logical_type.is_invalid() {
let logical_type_id = logical_type.id();
let invalid = logical_type_id == LogicalTypeId::Invalid
|| logical_type_id == LogicalTypeId::Unsupported;
if invalid {
None
} else {
logical_type.get_alias()
+15 -12
View File
@@ -274,16 +274,19 @@ pub async fn eval_timeout_quickjs(
let expr_clone = expr.clone();
// Run the QuickJS evaluation with a timeout
// Run the QuickJS evaluation with a timeout.
// Use the current runtime handle rather than creating an independent
// current-thread runtime so that HTTP connections opened by the global
// reqwest client (HTTP_CLIENT) are managed by the main runtime.
// Creating a child runtime caused connection-pool dispatch tasks to be
// dropped when the child runtime exited, poisoning pooled connections
// and producing spurious "DispatchGone" / "runtime dropped the dispatch
// task" errors on subsequent requests from the main runtime.
let handle = tokio::runtime::Handle::current();
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
// Create a new tokio runtime for async operations within the blocking context
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async move {
handle.block_on(async move {
eval_quickjs_inner(
&expr_clone,
filtered_context,
@@ -300,7 +303,9 @@ pub async fn eval_timeout_quickjs(
)
.await
.map_err(|_| {
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
anyhow::anyhow!(
"The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)"
)
})??
}
@@ -787,13 +792,11 @@ pub async fn eval_simple_js(
expr: String,
globals: HashMap<String, serde_json::Value>,
) -> anyhow::Result<Box<RawValue>> {
let handle = tokio::runtime::Handle::current();
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async move {
handle.block_on(async move {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(QUICKJS_MEMORY_LIMIT).await;
let context = AsyncContext::full(&runtime).await?;
@@ -361,19 +361,13 @@ mod tests {
fn test_timestamp() {
let result =
Converter::try_from_str(Some(Type::TIMESTAMP), "2024-01-15 14:30:00.0").unwrap();
assert_eq!(
result,
Value::String("2024-01-15 14:30:00".to_string())
);
assert_eq!(result, Value::String("2024-01-15 14:30:00".to_string()));
}
#[test]
fn test_timestamptz() {
let result = Converter::try_from_str(
Some(Type::TIMESTAMPTZ),
"2024-01-15 14:30:00.0+00",
)
.unwrap();
let result =
Converter::try_from_str(Some(Type::TIMESTAMPTZ), "2024-01-15 14:30:00.0+00").unwrap();
assert!(result.as_str().unwrap().contains("2024-01-15"));
}
@@ -484,8 +478,7 @@ mod tests {
#[test]
fn test_array_with_escaped_backslash() {
let result =
Converter::try_from_str(Some(Type::TEXT_ARRAY), r#"{"a\\b","c"}"#).unwrap();
let result = Converter::try_from_str(Some(Type::TEXT_ARRAY), r#"{"a\\b","c"}"#).unwrap();
assert_eq!(result, json!(["a\\b", "c"]));
}
+25 -5
View File
@@ -1,8 +1,5 @@
use std::collections::HashMap;
use windmill_api_auth::ApiAuthed;
use windmill_store::resources::try_get_resource_from_db_as;
use windmill_trigger::trigger_helpers::TriggerJobArgs;
use chrono::Utc;
use itertools::Itertools;
use native_tls::{Certificate, TlsConnector};
@@ -13,6 +10,8 @@ use rust_postgres_native_tls::MakeTlsConnector;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::value::RawValue;
use sqlx::FromRow;
use windmill_api_auth::ApiAuthed;
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
@@ -20,6 +19,8 @@ use windmill_common::{
utils::empty_as_none,
DB,
};
use windmill_store::resources::try_get_resource_from_db_as;
use windmill_trigger::trigger_helpers::TriggerJobArgs;
mod bool;
mod converter;
@@ -373,6 +374,26 @@ pub async fn get_raw_postgres_connection(
Ok(client)
}
pub async fn resolve_postgres_resource(
authed: &ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
) -> Result<Postgres> {
if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") {
let resource_value =
get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
serde_json::from_value::<Postgres>(resource_value).map_err(|e| Error::SerdeJson {
error: e,
location: "resolve_postgres_resource".to_string(),
})
} else {
try_get_resource_from_db_as::<Postgres>(authed, user_db, db, postgres_resource_path, w_id)
.await
}
}
pub async fn get_pg_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
@@ -382,8 +403,7 @@ pub async fn get_pg_connection(
logical_mode: bool,
) -> Result<Client> {
let database =
try_get_resource_from_db_as::<Postgres>(&authed, user_db, db, postgres_resource_path, w_id)
.await?;
resolve_postgres_resource(&authed, user_db, db, postgres_resource_path, w_id).await?;
Ok(get_raw_postgres_connection(&database, logical_mode).await?)
}
@@ -15,7 +15,6 @@ use windmill_common::{
DB,
};
use windmill_store::resources::try_get_resource_from_db_as;
use windmill_trigger::{listener::ListeningTrigger, trigger_helpers::TriggerJobArgs, Listener};
use super::{
@@ -26,7 +25,8 @@ use super::{
LogicalReplicationMessage::{Begin, Commit, Delete, Insert, Relation, Type, Update},
PrimaryKeepAliveBody, ReplicationMessage,
},
Postgres, PostgresConfig, PostgresTrigger, ERROR_PUBLICATION_NAME_NOT_EXISTS,
resolve_postgres_resource, Postgres, PostgresConfig, PostgresTrigger,
ERROR_PUBLICATION_NAME_NOT_EXISTS,
};
const ERROR_REPLICATION_SLOT_NOT_EXISTS: &str = r#"The replication slot associated with this trigger no longer exists. Recreate a new replication slot or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
@@ -147,7 +147,7 @@ impl Listener for PostgresTrigger {
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let database = try_get_resource_from_db_as::<Postgres>(
let database = resolve_postgres_resource(
&authed,
Some(UserDB::new(db.clone())),
&db,
@@ -180,7 +180,10 @@ mod tests {
fn test_postgres_to_typescript_date_types() {
assert_eq!(postgres_to_typescript_type(Some(Type::DATE)), "string");
assert_eq!(postgres_to_typescript_type(Some(Type::TIMESTAMP)), "string");
assert_eq!(postgres_to_typescript_type(Some(Type::TIMESTAMPTZ)), "string");
assert_eq!(
postgres_to_typescript_type(Some(Type::TIMESTAMPTZ)),
"string"
);
assert_eq!(postgres_to_typescript_type(Some(Type::UUID)), "string");
}
@@ -80,10 +80,17 @@ mod tests {
use rust_postgres::types::Type;
use serde_json::json;
use super::super::replication_message::{Column, ReplicaIdentity, RelationBody};
use super::super::replication_message::{Column, RelationBody, ReplicaIdentity};
fn make_relation(o_id: Oid, columns: Vec<Column>) -> RelationBody {
RelationBody::new(None, o_id, "public".to_string(), "test".to_string(), ReplicaIdentity::Default, columns)
RelationBody::new(
None,
o_id,
"public".to_string(),
"test".to_string(),
ReplicaIdentity::Default,
columns,
)
}
fn text_col(name: &str, typ: Option<Type>) -> Column {
@@ -93,10 +100,13 @@ mod tests {
#[test]
fn test_row_to_json_text_columns() {
let mut converter = RelationConverter::new();
converter.add_relation(make_relation(1, vec![
text_col("id", Some(Type::INT4)),
text_col("name", Some(Type::TEXT)),
]));
converter.add_relation(make_relation(
1,
vec![
text_col("id", Some(Type::INT4)),
text_col("name", Some(Type::TEXT)),
],
));
let tuple = vec![
TupleData::Text(Bytes::from("42")),
@@ -111,15 +121,15 @@ mod tests {
#[test]
fn test_row_to_json_with_null() {
let mut converter = RelationConverter::new();
converter.add_relation(make_relation(1, vec![
text_col("id", Some(Type::INT4)),
text_col("email", Some(Type::TEXT)),
]));
converter.add_relation(make_relation(
1,
vec![
text_col("id", Some(Type::INT4)),
text_col("email", Some(Type::TEXT)),
],
));
let tuple = vec![
TupleData::Text(Bytes::from("1")),
TupleData::Null,
];
let tuple = vec![TupleData::Text(Bytes::from("1")), TupleData::Null];
let result = converter.row_to_json((1, tuple)).unwrap();
assert_eq!(result["id"], json!(1));
@@ -129,9 +139,7 @@ mod tests {
#[test]
fn test_row_to_json_with_unchanged_toast() {
let mut converter = RelationConverter::new();
converter.add_relation(make_relation(1, vec![
text_col("data", Some(Type::TEXT)),
]));
converter.add_relation(make_relation(1, vec![text_col("data", Some(Type::TEXT))]));
let tuple = vec![TupleData::UnchangedToast];
let result = converter.row_to_json((1, tuple)).unwrap();
@@ -141,9 +149,7 @@ mod tests {
#[test]
fn test_row_to_json_binary_rejected() {
let mut converter = RelationConverter::new();
converter.add_relation(make_relation(1, vec![
text_col("data", Some(Type::BYTEA)),
]));
converter.add_relation(make_relation(1, vec![text_col("data", Some(Type::BYTEA))]));
let tuple = vec![TupleData::Binary(Bytes::from("data"))];
assert!(matches!(
@@ -168,8 +174,12 @@ mod tests {
converter.add_relation(make_relation(1, vec![text_col("a", Some(Type::TEXT))]));
converter.add_relation(make_relation(2, vec![text_col("b", Some(Type::INT4))]));
let result1 = converter.row_to_json((1, vec![TupleData::Text(Bytes::from("hello"))])).unwrap();
let result2 = converter.row_to_json((2, vec![TupleData::Text(Bytes::from("42"))])).unwrap();
let result1 = converter
.row_to_json((1, vec![TupleData::Text(Bytes::from("hello"))]))
.unwrap();
let result2 = converter
.row_to_json((2, vec![TupleData::Text(Bytes::from("42"))]))
.unwrap();
assert_eq!(result1["a"], json!("hello"));
assert_eq!(result2["b"], json!(42));
@@ -178,11 +188,11 @@ mod tests {
#[test]
fn test_row_to_json_bool_column() {
let mut converter = RelationConverter::new();
converter.add_relation(make_relation(1, vec![
text_col("active", Some(Type::BOOL)),
]));
converter.add_relation(make_relation(1, vec![text_col("active", Some(Type::BOOL))]));
let result = converter.row_to_json((1, vec![TupleData::Text(Bytes::from("t"))])).unwrap();
let result = converter
.row_to_json((1, vec![TupleData::Text(Bytes::from("t"))]))
.unwrap();
assert_eq!(result["active"], json!(true));
}
}
@@ -682,10 +682,7 @@ mod tests {
#[test]
fn test_parse_insert_with_null_tuple() {
let data = Bytes::from(build_insert_message(
10,
&[(TUPLE_DATA_NULL_BYTE, &[])],
));
let data = Bytes::from(build_insert_message(10, &[(TUPLE_DATA_NULL_BYTE, &[])]));
let body = XLogDataBody::new(0, 0, 0, data);
match body.parse(&settings(false)).unwrap() {
LogicalReplicationMessage::Insert(insert) => {
@@ -698,10 +695,7 @@ mod tests {
#[test]
fn test_parse_insert_with_toast_tuple() {
let data = Bytes::from(build_insert_message(
10,
&[(TUPLE_DATA_TOAST_BYTE, &[])],
));
let data = Bytes::from(build_insert_message(10, &[(TUPLE_DATA_TOAST_BYTE, &[])]));
let body = XLogDataBody::new(0, 0, 0, data);
match body.parse(&settings(false)).unwrap() {
LogicalReplicationMessage::Insert(insert) => {
@@ -759,7 +753,7 @@ mod tests {
buf.extend_from_slice(b"users\0"); // name
buf.push(REPLICA_IDENTITY_DEFAULT_BYTE as u8); // replica identity
buf.extend_from_slice(&1i16.to_be_bytes()); // num columns
// column: flags=0, name="id", type_oid=23 (INT4), type_modifier=-1
// column: flags=0, name="id", type_oid=23 (INT4), type_modifier=-1
buf.push(0); // flags
buf.extend_from_slice(b"id\0"); // name
buf.extend_from_slice(&23u32.to_be_bytes()); // type_oid (INT4)
+16 -15
View File
@@ -1099,21 +1099,22 @@ pub async fn update_flow_status_after_job_completion_internal(
|| (flow_jobs.is_some() && (skip_loop_failures || skip_seq_branch_failure)))
&& !(stop_early && stop_early_err_msg.is_some() && !skip_if_stop_early)
{
let is_skipped = if current_module.as_ref().is_some_and(|m| m.skip_if.is_some())
{
sqlx::query_scalar!(
"SELECT kind = 'identity' FROM v2_job WHERE id = $1",
job_id_for_status
)
.fetch_one(db)
.await
.map_err(|e| {
Error::internal_err(format!("error during skip check: {e:#}"))
})?
.unwrap_or(false)
} else {
stop_early && skip_if_stop_early // Mark as skipped when stop_after_if with skip_if_stopped=true
};
let is_skipped = (stop_early && skip_if_stop_early)
|| if current_module.as_ref().is_some_and(|m| m.skip_if.is_some()) {
sqlx::query_scalar!(
"SELECT kind = 'identity' FROM v2_job WHERE id = $1",
job_id_for_status
)
.fetch_optional(db)
.await
.map_err(|e| {
Error::internal_err(format!("error during skip check: {e:#}"))
})?
.flatten()
.unwrap_or(false)
} else {
false
};
success = true;
(
true,
+83 -94
View File
@@ -102,9 +102,9 @@
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/package": "^2.3.7",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/kit": "^2.53.2",
"@sveltejs/package": "^2.5.7",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.8",
"@types/d3": "^7.4.0",
@@ -131,9 +131,9 @@
"prettier-plugin-svelte": "^3.3.3",
"style-to-object": "^0.4.1",
"stylelint-config-recommended": "^13.0.0",
"svelte": "^5.38.0",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^3.0.4",
"svelte-check": "^4.0.0",
"svelte-check": "^4.4.3",
"svelte-fast-check": "^0.4.5",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
@@ -835,7 +835,6 @@
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -847,7 +846,6 @@
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -858,7 +856,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1348,7 +1345,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz",
"integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1503,7 +1499,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1520,7 +1515,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1537,7 +1531,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1554,7 +1547,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1571,7 +1563,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1588,7 +1579,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1605,7 +1595,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1622,7 +1611,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1639,7 +1627,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1656,7 +1643,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1673,7 +1659,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1690,7 +1675,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1707,7 +1691,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2102,9 +2085,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.49.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
"version": "2.53.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.2.tgz",
"integrity": "sha512-M+MqAvFve12T1HWws/2npP/s3hFtyjw3GB/OXW/8a1jZBk48qnvPJrtgE+VOMc3RnjUMxc4mv/vQ73nvj2uNMg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -2113,13 +2096,12 @@
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.3.2",
"devalue": "^5.6.3",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"sade": "^1.8.1",
"set-cookie-parser": "^2.6.0",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
"bin": {
@@ -2130,24 +2112,28 @@
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
"typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@sveltejs/package": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.4.tgz",
"integrity": "sha512-8+1hccAt0M3PPkHVPKH54Wc+cc1PNxRqCrICZiv/hEEto8KwbQVRghxNgTB4htIPyle+4CIB8RayTQH5zRQh9A==",
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.7.tgz",
"integrity": "sha512-qqD9xa9H7TDiGFrF6rz7AirOR8k15qDK/9i4MIE8te4vWsv5GEogPks61rrZcLy+yWph+aI6pIj2MdoK3YI8AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"chokidar": "^5.0.0",
"kleur": "^4.1.5",
"sade": "^1.8.1",
"semver": "^7.5.4",
@@ -2163,17 +2149,47 @@
"svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/@sveltejs/package/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sveltejs/package/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz",
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1",
"deepmerge": "^4.3.1",
"magic-string": "^0.30.17",
"magic-string": "^0.30.21",
"obug": "^2.1.0",
"vitefu": "^1.1.1"
},
"engines": {
@@ -2313,7 +2329,6 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2723,8 +2738,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -3442,9 +3456,9 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -4971,10 +4985,9 @@
}
},
"node_modules/devalue": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"devOptional": true,
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"license": "MIT"
},
"node_modules/devlop": {
@@ -5705,9 +5718,9 @@
}
},
"node_modules/esrap": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -7193,7 +7206,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7692,7 +7705,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7713,7 +7725,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7734,7 +7745,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7755,7 +7765,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7776,7 +7785,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7797,7 +7805,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7818,7 +7825,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7839,7 +7845,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7860,7 +7865,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7881,7 +7885,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7902,7 +7905,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9234,7 +9236,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -9541,7 +9543,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"devOptional": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
@@ -11566,7 +11568,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
@@ -11615,9 +11617,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
"integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==",
"devOptional": true,
"license": "MIT"
},
@@ -12430,21 +12432,23 @@
}
},
"node_modules/svelte": {
"version": "5.39.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.12.tgz",
"integrity": "sha512-CEzwxFuEycokU8K8CE/OuwVbmei+ivu2HvBGYIdASfMa1hCRSNr4RRkzNSvbAvu6h+BOig2CsZTAEY+WKvwZpA==",
"version": "5.53.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz",
"integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.3",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -12488,9 +12492,9 @@
}
},
"node_modules/svelte-check": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz",
"integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.3.tgz",
"integrity": "sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12529,21 +12533,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13274,7 +13263,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+5 -5
View File
@@ -27,9 +27,9 @@
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/package": "^2.3.7",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/kit": "^2.53.2",
"@sveltejs/package": "^2.5.7",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.8",
"@types/d3": "^7.4.0",
@@ -56,9 +56,9 @@
"prettier-plugin-svelte": "^3.3.3",
"style-to-object": "^0.4.1",
"stylelint-config-recommended": "^13.0.0",
"svelte": "^5.38.0",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^3.0.4",
"svelte-check": "^4.0.0",
"svelte-check": "^4.4.3",
"svelte-fast-check": "^0.4.5",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
+88 -85
View File
@@ -9,113 +9,116 @@ const USER_CUSTOM_PROMPTS_KEY = 'userCustomAIPrompts'
const sessionModel = getLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME)
const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
export const copilotSessionModel = writable<AIProviderModel | undefined>(
sessionModel && sessionProvider
? {
model: sessionModel,
provider: sessionProvider as AIProvider
}
: undefined
sessionModel && sessionProvider
? {
model: sessionModel,
provider: sessionProvider as AIProvider
}
: undefined
)
export const copilotInfo = writable<{
enabled: boolean
codeCompletionModel?: AIProviderModel
defaultModel?: AIProviderModel
aiModels: AIProviderModel[]
customPrompts?: Record<string, string>
maxTokensPerModel?: Record<string, number>
enabled: boolean
codeCompletionModel?: AIProviderModel
defaultModel?: AIProviderModel
aiModels: AIProviderModel[]
customPrompts?: Record<string, string>
maxTokensPerModel?: Record<string, number>
}>({
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
})
export async function loadCopilot(workspace: string) {
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
setCopilotInfo(info)
} catch (err) {
setCopilotInfo({})
console.error('Could not get copilot info', err)
}
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
setCopilotInfo(info)
} catch (err) {
setCopilotInfo({})
console.error('Could not get copilot info', err)
}
}
export function setCopilotInfo(aiConfig: AIConfig) {
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
([provider, providerConfig]) =>
providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider }))
)
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
([provider, providerConfig]) =>
providerConfig.models.map((m) => ({ model: m, provider: provider as AIProvider }))
)
copilotSessionModel.update((model) => {
if (
model &&
!aiModels.some((m) => m.model === model.model && m.provider === model.provider)
) {
return undefined
}
return model
})
copilotSessionModel.update((model) => {
if (
model &&
!aiModels.some((m) => m.model === model.model && m.provider === model.provider)
) {
return undefined
}
return model
})
copilotInfo.set({
enabled: true,
codeCompletionModel: aiConfig.code_completion_model,
defaultModel: aiConfig.default_model,
aiModels: aiModels,
customPrompts: aiConfig.custom_prompts ?? {},
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}
})
} else {
copilotSessionModel.set(undefined)
copilotInfo.set({
enabled: true,
codeCompletionModel: aiConfig.code_completion_model,
defaultModel: aiConfig.default_model,
aiModels: aiModels,
customPrompts: aiConfig.custom_prompts ?? {},
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}
})
} else {
copilotSessionModel.set(undefined)
copilotInfo.set({
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
})
}
copilotInfo.set({
enabled: false,
codeCompletionModel: undefined,
defaultModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
})
}
}
export function getCurrentModel() {
const model =
get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
if (!model) {
throw new Error('No model selected')
}
return model
export function getCurrentModel(): AIProviderModel {
const model =
get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
if (!model) {
throw new Error('No model selected')
}
return model
}
export function tryGetCurrentModel(): AIProviderModel | undefined {
return get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
}
export function getUserCustomPrompts(): Record<string, string> {
const stored = getLocalSetting(USER_CUSTOM_PROMPTS_KEY)
if (stored) {
try {
return JSON.parse(stored)
} catch (e) {
console.error('Failed to parse user custom prompts', e)
return {}
}
}
return {}
const stored = getLocalSetting(USER_CUSTOM_PROMPTS_KEY)
if (stored) {
try {
return JSON.parse(stored)
} catch (e) {
console.error('Failed to parse user custom prompts', e)
return {}
}
}
return {}
}
export function getCombinedCustomPrompt(mode: string): string | undefined {
const workspacePrompt = get(copilotInfo).customPrompts?.[mode]
const userPrompts = getUserCustomPrompts()
const userPrompt = userPrompts[mode]
const workspacePrompt = get(copilotInfo).customPrompts?.[mode]
const userPrompts = getUserCustomPrompts()
const userPrompt = userPrompts[mode]
const prompts = [workspacePrompt, userPrompt].filter((p) => p?.trim())
const prompts = [workspacePrompt, userPrompt].filter((p) => p?.trim())
if (prompts.length === 0) {
return undefined
}
if (prompts.length === 0) {
return undefined
}
return prompts.join('\n\n')
return prompts.join('\n\n')
}
@@ -1057,6 +1057,7 @@
{:else if inputCat == 'resource-object' && (resourceTypes == undefined || (format && format?.split('-').length > 1 && resourceTypes.includes(format?.substring('resource-'.length))))}
<!-- {JSON.stringify(value)} -->
<ObjectResourceInput
datatableAsPgResource={label === 'database'}
{disabled}
{defaultValue}
selectFirst={!noDefaultOnSelectFirst && required}
@@ -8,21 +8,37 @@
ArrowUpRight,
Building,
DiffIcon,
GitFork
GitFork,
Loader2,
Trash2,
Upload
} from 'lucide-svelte'
import { Alert, Badge } from './common'
import {
AppService,
EmailTriggerService,
FlowService,
FolderService,
GcpTriggerService,
HttpTriggerService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
PostgresTriggerService,
ScheduleService,
ScriptService,
SqsTriggerService,
UserService,
WebsocketTriggerService,
WorkspaceService,
type WorkspaceComparison,
type WorkspaceItemDiff
} from '$lib/gen'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import Row from './common/table/Row.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import DeployWorkspaceDrawer from './DeployWorkspaceDrawer.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
@@ -36,6 +52,10 @@
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import type { TriggerKind } from './triggers'
import { triggerDisplayNamesMap, triggerKindToTriggerType } from './triggers/utils'
import { getEmailAddress, getEmailDomain } from './triggers/email/utils'
import { base } from '$lib/base'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
@@ -56,9 +76,7 @@
let canDeployToParent = $state(true)
let canPreserveInParent = $state(false)
let canPreserveInCurrent = $state(false)
let canPreserveOnBehalfOf = $derived(
mergeIntoParent ? canPreserveInParent : canPreserveInCurrent
)
let canPreserveOnBehalfOf = $derived(mergeIntoParent ? canPreserveInParent : canPreserveInCurrent)
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) => {
@@ -438,6 +456,235 @@
diff
}))
)
// --- Fork Triggers ---
type ForkTrigger = {
path: string
triggerKind: TriggerKind
scriptPath: string
isFlow: boolean
enabled?: boolean
extraLabel?: string
}
let forkTriggers = $state<ForkTrigger[]>([])
let loadingTriggers = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let triggerToDelete = $state<ForkTrigger | undefined>(undefined)
/** Deployable trigger kinds and their list+delete services */
const triggerServices = {
schedules: {
list: (ws: string) => ScheduleService.listSchedules({ workspace: ws }),
delete: (ws: string, path: string) => ScheduleService.deleteSchedule({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'schedules',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.enabled,
extraLabel: item.schedule
})
},
routes: {
list: (ws: string) => HttpTriggerService.listHttpTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
HttpTriggerService.deleteHttpTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'routes',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: `${(item.http_method ?? 'get').toUpperCase()} ${item.route_path ?? ''}`
})
},
websockets: {
list: (ws: string) => WebsocketTriggerService.listWebsocketTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
WebsocketTriggerService.deleteWebsocketTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'websockets',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.url
})
},
kafka: {
list: (ws: string) => KafkaTriggerService.listKafkaTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
KafkaTriggerService.deleteKafkaTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'kafka',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.topics?.join(', ')
})
},
postgres: {
list: (ws: string) => PostgresTriggerService.listPostgresTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
PostgresTriggerService.deletePostgresTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'postgres',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled'
})
},
nats: {
list: (ws: string) => NatsTriggerService.listNatsTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
NatsTriggerService.deleteNatsTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'nats',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.subjects?.join(', ')
})
},
mqtt: {
list: (ws: string) => MqttTriggerService.listMqttTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
MqttTriggerService.deleteMqttTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'mqtt',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled'
})
},
sqs: {
list: (ws: string) => SqsTriggerService.listSqsTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
SqsTriggerService.deleteSqsTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'sqs',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.queue_url
})
},
gcp: {
list: (ws: string) => GcpTriggerService.listGcpTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
GcpTriggerService.deleteGcpTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'gcp',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: item.topic_id
})
},
emails: {
list: (ws: string) => EmailTriggerService.listEmailTriggers({ workspace: ws }),
delete: (ws: string, path: string) =>
EmailTriggerService.deleteEmailTrigger({ workspace: ws, path }),
normalize: (item: any): ForkTrigger => ({
path: item.path,
triggerKind: 'emails',
scriptPath: item.script_path,
isFlow: item.is_flow,
enabled: item.mode === 'enabled',
extraLabel: getEmailAddress(
item.local_part,
item.workspaced_local_part,
currentWorkspaceId,
emailDomain ?? ''
)
})
}
} as const
let emailDomain = $state<string | undefined>(undefined)
async function fetchAllTriggers() {
loadingTriggers = true
try {
emailDomain = await getEmailDomain()
const entries = Object.values(triggerServices)
const results = await Promise.allSettled(
entries.map(async (svc) => {
const items = await svc.list(currentWorkspaceId)
return items.map(svc.normalize)
})
)
forkTriggers = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))
} catch (e) {
console.error('Failed to fetch fork triggers:', e)
forkTriggers = []
} finally {
loadingTriggers = false
}
}
function deleteTrigger(trigger: ForkTrigger) {
triggerToDelete = trigger
}
async function confirmDeleteTrigger() {
const trigger = triggerToDelete
if (!trigger) return
triggerToDelete = undefined
const triggerType = triggerKindToTriggerType(trigger.triggerKind)
const displayName = triggerType ? triggerDisplayNamesMap[triggerType] : trigger.triggerKind
try {
const svc = triggerServices[trigger.triggerKind as keyof typeof triggerServices]
if (!svc) {
throw new Error(`No service for trigger kind: ${trigger.triggerKind}`)
}
await svc.delete(currentWorkspaceId, trigger.path)
forkTriggers = forkTriggers.filter(
(t) => !(t.path === trigger.path && t.triggerKind === trigger.triggerKind)
)
sendUserToast(`Deleted ${displayName} trigger '${trigger.path}'`)
} catch (e: any) {
sendUserToast(`Failed to delete trigger '${trigger.path}': ${e.body || e.message}`, true)
}
}
function getTriggerDisplayName(triggerKind: TriggerKind): string {
const triggerType = triggerKindToTriggerType(triggerKind)
return triggerType ? triggerDisplayNamesMap[triggerType] : triggerKind
}
const triggerKindToPagePath: Record<string, string> = {
schedules: '/schedules',
routes: '/routes',
websockets: '/websocket_triggers',
kafka: '/kafka_triggers',
postgres: '/postgres_triggers',
nats: '/nats_triggers',
mqtt: '/mqtt_triggers',
sqs: '/sqs_triggers',
gcp: '/gcp_triggers',
emails: '/email_triggers'
}
function getTriggerHref(triggerKind: TriggerKind): string | undefined {
const pagePath = triggerKindToPagePath[triggerKind]
return pagePath ? `${base}${pagePath}` : undefined
}
// Fetch triggers when workspace is available
$effect(() => {
if (currentWorkspaceId) {
fetchAllTriggers()
}
})
</script>
{#if $workspaceStore != currentWorkspaceId}
@@ -725,9 +972,8 @@
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be
applied during execution. Make sure this is set to an appropriate
user before deploying.
The "run on behalf of" field defines which user's permissions will be applied
during execution. Make sure this is set to an appropriate user before deploying.
</Tooltip>
</span>
{/if}
@@ -749,7 +995,99 @@
</div>
{/snippet}
</WorkspaceDeployLayout>
<!-- Fork Triggers Section -->
<div class="mt-6">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-sm font-semibold">Triggers created in this fork</h3>
{#if !loadingTriggers}
<Badge color="indigo" size="xs"
>{forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''}</Badge
>
{/if}
</div>
<Alert title="Deploy and/or delete these triggers" type="info" class="mb-2">
When forking a workspace, triggers are not forked to avoid unnecessary executions or
collisions. If you created this triggers with the intention of deploying them to the parent
workspace, you can do so here. Otherwise it is recommended to delete them or disable them.
</Alert>
{#if loadingTriggers}
<div class="flex items-center gap-2 text-secondary text-sm p-4">
<Loader2 class="animate-spin w-4 h-4" />
Loading triggers...
</div>
{:else if forkTriggers.length === 0}
<div class="text-secondary text-sm p-4 border rounded-md bg-surface-tertiary">
No triggers in this fork workspace.
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary">
{#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)}
<Row
kind="trigger"
triggerKind={trigger.triggerKind}
path={trigger.path}
href={getTriggerHref(trigger.triggerKind)}
marked={undefined}
isSelectable={false}
canFavorite={false}
workspaceId={currentWorkspaceId}
>
{#snippet customSummary()}
<span>{getTriggerDisplayName(trigger.triggerKind)}</span>
<span class="text-secondary mx-1">&rarr;</span>
<span class="text-secondary">{trigger.scriptPath}</span>
{#if trigger.isFlow}
<Badge color="blue" size="xs">flow</Badge>
{/if}
{#if trigger.extraLabel}
<span class="text-tertiary text-xs">({trigger.extraLabel})</span>
{/if}
{/snippet}
{#snippet actions()}
{#if trigger.enabled != null}
<Badge color={trigger.enabled ? 'green' : 'gray'} size="xs">
{trigger.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{/if}
<Button
size="xs"
variant="subtle"
onclick={() => {
deploymentDrawer?.openDrawer(trigger.path, 'trigger', {
triggers: { kind: trigger.triggerKind }
})
}}
>
<Upload size={12} />
Deploy
</Button>
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(trigger)}>
<Trash2 size={12} />
</Button>
{/snippet}
</Row>
{/each}
</div>
{/if}
</div>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<ConfirmationModal
title="Delete trigger"
confirmationText="Delete"
open={!!triggerToDelete}
onConfirmed={confirmDeleteTrigger}
onCanceled={() => (triggerToDelete = undefined)}
>
{#if triggerToDelete}
Are you sure you want to delete the {getTriggerDisplayName(triggerToDelete.triggerKind)} trigger
'{triggerToDelete.path}'?
{/if}
</ConfirmationModal>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500">No comparison data available</div>
@@ -18,6 +18,7 @@
editor?: SimpleEditor | undefined
path?: string
disabled?: boolean
datatableAsPgResource?: boolean
onClear?: () => void
}
@@ -30,16 +31,23 @@
defaultValue,
editor = $bindable(undefined),
disabled = false,
datatableAsPgResource = false,
onClear = undefined
}: Props = $props()
function isResource() {
return isString(value) && value.length >= '$res:'.length
return isString(value) && value.startsWith('$res:')
}
function isDatatable(val: any): boolean {
return (isString(val) && val.startsWith('datatable://')) || val === 'datatable'
}
function valueToPath() {
if (isResource()) {
return value.substr('$res:'.length)
} else if (isDatatable(value)) {
return value
}
}
</script>
@@ -50,6 +58,7 @@
<S3ObjectPicker bind:value />
{:else if value == undefined || typeof value === 'string'}
<ResourcePicker
{datatableAsPgResource}
{disabled}
{selectFirst}
{disablePortal}
@@ -60,7 +69,7 @@
if (v == undefined) {
value = undefined
} else {
value = `$res:${v}`
value = isDatatable(v) ? v : `$res:${v}`
}
}
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { ResourceService } from '$lib/gen'
import { ResourceService, WorkspaceService } from '$lib/gen'
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
import { onMount, untrack } from 'svelte'
import AppConnect from './AppConnectDrawer.svelte'
@@ -28,6 +28,7 @@
class?: string
onClear?: () => void
excludedValues?: string[]
datatableAsPgResource?: boolean
}
let {
@@ -45,7 +46,8 @@
selectInputClass = '',
class: className = '',
onClear = undefined,
excludedValues = undefined
excludedValues = undefined,
datatableAsPgResource = false
}: Props = $props()
if (initialValue && value == undefined) {
@@ -117,6 +119,23 @@
type: x.resource_type
}))
if (datatableAsPgResource && resourceType === 'postgresql') {
try {
const datatables = await WorkspaceService.listDataTables({
workspace: $workspaceStore!
})
for (const dt of datatables) {
nc.push({
value: `datatable://${dt}`,
label: `datatable://${dt}`,
type: 'postgresql'
})
}
} catch (e) {
console.error('Failed to load data tables', e)
}
}
// TODO check if this is needed
if (!nc.find((x) => x.value == value) && (initialValue || value)) {
nc.push({ value: value ?? initialValue!, label: value ?? initialValue!, type: '' })
@@ -157,6 +176,7 @@
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
let hovering = $state(false)
let isDatatableSelected = $derived(value?.startsWith('datatable://') ?? false)
</script>
<AppConnect
@@ -212,16 +232,18 @@
id="resource-picker-select"
>
{#snippet endSnippet({ item, close })}
<Button
{disabled}
variant="subtle"
size="xs2"
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
on:click={() => (resourceEditor?.initEdit?.(item.value ?? ''), close())}
startIcon={{ icon: Pen }}
iconOnly
/>
{#if !item.value?.startsWith('datatable://')}
<Button
{disabled}
variant="subtle"
size="xs2"
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
on:click={() => (resourceEditor?.initEdit?.(item.value ?? ''), close())}
startIcon={{ icon: Pen }}
iconOnly
/>
{/if}
{/snippet}
{#snippet bottomSnippet({ close })}
<div class="flex bg-surface border-t divide-x">
@@ -276,7 +298,7 @@
</div>
{/snippet}
</Select>
{#if value && hovering}
{#if value && hovering && !isDatatableSelected}
<div class="absolute {disabled ? 'right-2' : 'right-10'} z-20">
<Button
variant="subtle"
+9 -11
View File
@@ -429,14 +429,14 @@
}
function jobsFilter(f: 'waiting' | 'suspended') {
filters.val.path = undefined
filters.val.user = undefined
filters.val.folder = undefined
filters.val.label = undefined
filters.val.concurrency_key = undefined
filters.val.tag = undefined
filters.val.worker = undefined
filters.val.schedule_path = undefined
delete filters.val.path
delete filters.val.user
delete filters.val.folder
delete filters.val.label
delete filters.val.concurrency_key
delete filters.val.tag
delete filters.val.worker
delete filters.val.schedule_path
filters.val.status = filters.val.status == f ? undefined : f
filters.val.job_kinds = 'all'
}
@@ -703,9 +703,7 @@
bind:checked={
() => filters.val.show_future_jobs !== false,
(v) =>
v
? delete filters.val.show_future_jobs
: (filters.val.show_future_jobs = false)
v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false)
}
/>
<Clock size={14} />
@@ -13,38 +13,38 @@
DollarSign,
Folder,
LayoutDashboard,
Mail,
Route,
Unplug
} from 'lucide-svelte'
interface Props {
kind:
| 'script'
| 'flow'
| 'app'
| 'raw_app'
| 'resource'
| 'variable'
| 'resource_type'
| 'folder'
| 'schedule'
| 'trigger'
| 'routes'
| 'schedules'
| 'websockets'
| 'postgres'
| 'kafka'
| 'nats'
| 'mqtt'
| 'sqs'
| 'gcp';
kind:
| 'script'
| 'flow'
| 'app'
| 'raw_app'
| 'resource'
| 'variable'
| 'resource_type'
| 'folder'
| 'schedule'
| 'trigger'
| 'routes'
| 'schedules'
| 'websockets'
| 'postgres'
| 'kafka'
| 'nats'
| 'mqtt'
| 'sqs'
| 'gcp'
| 'emails'
/** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */
triggerKind?: string | undefined;
triggerKind?: string | undefined
}
let { kind, triggerKind = undefined }: Props = $props();
let { kind, triggerKind = undefined }: Props = $props()
// Use triggerKind if kind is 'trigger' and triggerKind is provided
let effectiveKind = $derived(kind === 'trigger' && triggerKind ? triggerKind : kind)
@@ -83,6 +83,8 @@
<AwsIcon size={16} class="text-gray-400" />
{:else if effectiveKind === 'gcp'}
<GoogleCloudIcon size={16} />
{:else if effectiveKind === 'emails'}
<Mail size={16} class="text-gray-400" />
{:else if effectiveKind === 'trigger'}
<Calendar size={16} class="text-gray-400" />
{:else}
@@ -57,7 +57,7 @@ import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
@@ -229,11 +229,12 @@ class AIChatManager {
closeScriptSettings?: boolean
}
) {
if (mode === AIMode.SCRIPT && !tryGetCurrentModel()) return
this.mode = mode
this.pendingPrompt = pendingPrompt ?? ''
if (mode === AIMode.SCRIPT) {
const customPrompt = getCombinedCustomPrompt(mode)
const currentModel = getCurrentModel()
const customPrompt = getCombinedCustomPrompt(mode)
const lang = this.scriptEditorOptions?.lang ?? 'bun'
const context = this.contextManager.getSelectedContext()
this.systemMessage = prepareScriptSystemMessage(currentModel, lang, {}, customPrompt)
+1 -1
View File
@@ -55,7 +55,7 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
},
anthropic: {
label: 'Anthropic',
defaultModels: ['claude-sonnet-4-0', 'claude-sonnet-4-0/thinking', 'claude-3-5-haiku-latest']
defaultModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6/thinking', 'claude-3-5-haiku-latest']
},
mistral: {
label: 'Mistral',
@@ -585,7 +585,7 @@
let assetNodesResult = $showAssets
? computeAssetNodes(
newNodes.map((n) => ({
data: { assets: n.data?.assets as AssetWithAltAccessType[] },
data: { assets: n.data?.assets as AssetWithAltAccessType[], offset: n.data?.offset as number },
id: n.id,
position: n.position
}))
@@ -11,7 +11,7 @@
let computeAssetNodesCache: [NodeDep[], ReturnType<typeof computeAssetNodes>] | undefined
type NodeDep = {
data: object & { assets?: AssetWithAltAccessType[] | undefined }
data: object & { assets?: AssetWithAltAccessType[] | undefined; offset?: number }
id: string
position: { x: number; y: number }
}
@@ -78,13 +78,14 @@
width: inputAssetWidth,
position: {
x:
displayedInputAssets.length === 1
(node.data.offset ?? 0) +
(displayedInputAssets.length === 1
? (NODE.width - inputAssetWidth) / 2 - 10 // Ensure we see the edge
: (inputAssetWidth + inputAssetXGap) * (i - displayedInputAssets.length / 2) +
(NODE.width + inputAssetXGap) / 2 +
(overflowedInputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
: 0),
: 0)),
y: READ_ASSET_Y_OFFSET
},
selectable: false
@@ -115,13 +116,14 @@
width: outputAssetWidth,
position: {
x:
displayedOutputAssets.length === 1
(node.data.offset ?? 0) +
(displayedOutputAssets.length === 1
? (NODE.width - outputAssetWidth) / 2 - 10 // Ensure we see the edge
: (outputAssetWidth + outputAssetXGap) * (i - displayedOutputAssets.length / 2) +
(NODE.width + outputAssetXGap) / 2 +
(overflowedOutputAssets.length
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
: 0),
: 0)),
y: WRITE_ASSET_Y_OFFSET
},
selectable: false
@@ -155,7 +157,7 @@
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: READ_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
@@ -174,7 +176,7 @@
parentId: node.id,
width: ASSETS_OVERFLOWED_NODE_WIDTH,
position: {
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
y: WRITE_ASSET_Y_OFFSET
}
} satisfies Node & AssetsOverflowedN)
@@ -640,6 +640,7 @@
disabled={!can_write}
bind:value={postgres_resource_path}
resourceType={'postgresql'}
datatableAsPgResource
/>
<CheckPostgresRequirement bind:postgres_resource_path bind:can_write />
</div>
@@ -137,7 +137,8 @@
`GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` +
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' +
'ALTER ROLE custom_instance_user CREATEROLE;'
'ALTER ROLE custom_instance_user CREATEROLE;\n' +
'ALTER ROLE custom_instance_user REPLICATION;'
}
],
status?.error ?? undefined
@@ -51,7 +51,7 @@
}
</script>
<script>
<script lang="ts">
import { Plus, SettingsIcon } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
+2 -1
View File
@@ -88,10 +88,11 @@ const config = {
plugins: [sveltekit(), ...(process.env.HTTPS === 'true' ? [mkcert()] : []), plugin],
define: { __pkg__: version },
optimizeDeps: {
include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim', 'monaco-editor-wrapper'],
include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim'],
exclude: [
'@codingame/monaco-vscode-standalone-typescript-language-features',
'@codingame/monaco-vscode-standalone-languages',
'windmill-client'
]
},
worker: { format: 'es' },
+79 -2
View File
@@ -199,6 +199,40 @@ cmd_close() {
workmux close $name
}
cmd_open_ee() {
local name=${1:?Usage: wm-cursor open-ee <name>}
local wt_path=$(workmux path $name)
local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
# Find ee repo (same discovery logic as worktree-env)
local ee_repo="" candidate
for candidate in \
"${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \
"${wt_path}/../windmill-ee-private" \
"${HOME}/windmill-ee-private" \
"${HOME}/projects/windmill-ee-private"; do
if [[ -n $candidate ]] && [[ -d $candidate ]]; then
ee_repo=${candidate:A}
break
fi
done
if [[ -z $ee_repo ]]; then
print -u2 "Error: Could not find windmill-ee-private repo"
exit 1
fi
local ee_worktree_dir="${ee_repo}__worktrees/${name}"
if [[ ! -d $ee_worktree_dir ]]; then
print -u2 "Error: EE worktree not found at ${ee_worktree_dir}"
exit 1
fi
$cursor_bin -n $ee_worktree_dir
print "Opened Cursor for EE worktree: ${ee_worktree_dir}"
}
cmd_setup() {
local repo_root=${1:?Usage: wm-cursor setup <repo-root>}
repo_root=${repo_root:A}
@@ -275,7 +309,7 @@ json.dump(json.loads(text), sys.stdout, indent=2)
print "Created ${settings_file}"
fi
# --- zsh alias ---
# --- zsh alias + completion ---
local rc=${ZDOTDIR:-$HOME}/.zshrc
local alias_line="alias wmc=${(q)script_path}"
@@ -286,6 +320,45 @@ json.dump(json.loads(text), sys.stdout, indent=2)
print "\n# wm-cursor alias\n${alias_line}" >> $rc
print "Added wmc alias to ${rc}"
fi
local eval_line='eval "$(wmc completions)"'
if ! grep -qF 'wmc completions' $rc; then
print "${eval_line}" >> $rc
print "Added completions eval to ${rc}"
fi
}
cmd_completions() {
cat <<'COMP'
_wmc_worktree_names() {
local -a names
names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"})
_describe 'worktree' names
}
_wmc() {
local -a subcmds=(
'add:Create worktree + open Cursor'
'open:Open Cursor for existing worktree'
'open-ee:Open EE worktree in Cursor'
'close:Clean up grouped tmux session'
'setup:Set up .vscode settings, tasks + wmc alias'
'completions:Print zsh completions'
)
if (( CURRENT == 2 )); then
_describe 'subcommand' subcmds
else
case $words[2] in
open|open-ee|close)
_wmc_worktree_names
;;
esac
fi
}
compdef _wmc wmc wm-cursor
COMP
}
# --- Main ---
@@ -293,16 +366,20 @@ json.dump(json.loads(text), sys.stdout, indent=2)
case ${1-} in
add) shift; cmd_add "$@" ;;
open) shift; cmd_open "$@" ;;
open-ee) shift; cmd_open_ee "$@" ;;
close) shift; cmd_close "$@" ;;
setup) shift; cmd_setup "$@" ;;
completions) cmd_completions ;;
*)
print -u2 "Usage: wm-cursor <add|open|close|setup> [args...]"
print -u2 "Usage: wm-cursor <add|open|open-ee|close|setup|completions> [args...]"
print -u2 ""
print -u2 "Subcommands:"
print -u2 " add [--features <f>] [workmux-add-args...] Create worktree + open Cursor"
print -u2 " open <name> [--features <f>] Open Cursor for existing worktree"
print -u2 " open-ee <name> Open EE worktree in Cursor"
print -u2 " close <name> Clean up grouped tmux session"
print -u2 " setup <repo-root> Set up .vscode settings, tasks + wmc alias"
print -u2 " completions Print zsh completions (use with eval)"
print -u2 ""
print -u2 "Options:"
print -u2 " --features <features> Cargo features for the backend (e.g. \"enterprise,parquet\")"
+35 -9
View File
@@ -23,21 +23,47 @@ else
echo "[cleanup] No .env.local at $wt_dir/.env.local"
fi
# Drop per-worktree database
if [ -n "${WM_DB_NAME:-}" ]; then
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if command -v psql &>/dev/null; then
psql "$db_conn/postgres" -c "DROP DATABASE IF EXISTS ${WM_DB_NAME} WITH (FORCE)" 2>/dev/null \
&& echo "[cleanup] Dropped database $WM_DB_NAME" \
|| echo "[cleanup] Warning: Could not drop database $WM_DB_NAME"
else
echo "[cleanup] psql not found, skipping database cleanup for $WM_DB_NAME"
fi
else
echo "[cleanup] No WM_DB_NAME in .env.local, skipping database cleanup"
fi
# Remove the matching windmill-ee-private worktree if one exists
wt_basename=$(basename "$wt_dir")
# Check parent directory first (sibling to worktree root), then fall back to home
# Find ee repo using same discovery logic as worktree-env
main_repo_root="$(cd "$(git -C "$wt_dir" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
parent_dir="$(cd "$wt_dir/.." && pwd)"
echo "[cleanup] wt_basename=$wt_basename parent_dir=$parent_dir"
if [ -d "${parent_dir}/windmill-ee-private" ]; then
ee_repo="${parent_dir}/windmill-ee-private"
else
ee_repo="${HOME}/windmill-ee-private"
echo "[cleanup] wt_basename=$wt_basename main_repo_root=$main_repo_root parent_dir=$parent_dir"
ee_repo=""
for candidate in \
"${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \
"${parent_dir}/windmill-ee-private" \
"${HOME}/windmill-ee-private" \
"${HOME}/projects/windmill-ee-private"; do
if [ -n "$candidate" ] && [ -d "$candidate" ]; then
ee_repo="$(cd "$candidate" && pwd)"
break
fi
done
if [ -z "$ee_repo" ]; then
echo "[cleanup] Could not find windmill-ee-private repo, skipping EE worktree cleanup"
fi
ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}"
echo "[cleanup] ee_repo=$ee_repo ee_worktree_dir=$ee_worktree_dir exists=$([ -d "$ee_worktree_dir" ] && echo yes || echo no)"
if [ -d "$ee_worktree_dir" ]; then
ee_worktree_dir="${ee_repo:+${ee_repo}__worktrees/${wt_basename}}"
echo "[cleanup] ee_repo=${ee_repo:-<not found>} ee_worktree_dir=${ee_worktree_dir:-<none>} exists=$([ -n "$ee_worktree_dir" ] && [ -d "$ee_worktree_dir" ] && echo yes || echo no)"
if [ -n "$ee_worktree_dir" ] && [ -d "$ee_worktree_dir" ]; then
git -C "$ee_repo" worktree remove "$ee_worktree_dir" --force 2>/dev/null \
&& echo "[cleanup] Removed EE worktree at $ee_worktree_dir" \
|| echo "[cleanup] Warning: Could not remove EE worktree at $ee_worktree_dir"
+47
View File
@@ -52,6 +52,34 @@ fi
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"
# --- Create per-worktree database ---
wt_basename=$(basename "$(pwd)")
db_name="windmill_${wt_basename//-/_}"
db_conn="postgres://postgres:changeme@127.0.0.1:5432"
if command -v psql &>/dev/null; then
if psql "$db_conn/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '${db_name}'" 2>/dev/null | grep -q 1; then
echo "Database $db_name already exists"
else
psql "$db_conn/postgres" -c "CREATE DATABASE ${db_name}" 2>/dev/null \
&& echo "Created database $db_name" \
|| echo "WARNING: Could not create database $db_name (is PostgreSQL running?)" >&2
fi
db_url="${db_conn}/${db_name}?sslmode=disable"
# Run migrations against the new database
DATABASE_URL="$db_url" sqlx migrate run --source backend/migrations \
&& echo "Migrations applied to $db_name" \
|| echo "WARNING: Could not run migrations on $db_name" >&2
# Use export so DATABASE_URL overrides the nix devshell value for child processes
cat >> .env.local <<EOF
export DATABASE_URL=$db_url
WM_DB_NAME=$db_name
EOF
echo "Added DATABASE_URL for database $db_name to .env.local"
else
echo "WARNING: psql not found, skipping per-worktree database creation" >&2
fi
# --- Copy frontend/node_modules preserving symlinks ---
# cp -a preserves .bin/ symlinks that cp -r would dereference, breaking require() paths
main_repo_root="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
@@ -66,6 +94,25 @@ if command -v direnv &>/dev/null && [ -f .envrc ]; then
echo "direnv allowed"
fi
# --- Trust worktree directory in Claude Code ---
claude_json="$HOME/.claude.json"
if [ -f "$claude_json" ]; then
wt_path="$(pwd)"
python3 -c "
import json, sys
path = '$wt_path'
with open('$claude_json', 'r') as f:
data = json.load(f)
projects = data.setdefault('projects', {})
proj = projects.setdefault(path, {})
proj['hasTrustDialogAccepted'] = True
proj['hasCompletedProjectOnboarding'] = True
with open('$claude_json', 'w') as f:
json.dump(data, f, indent=2)
" && echo "Added $wt_path to Claude Code trusted directories" \
|| echo "Warning: Could not update Claude Code trusted directories"
fi
# --- Create matching windmill-ee-private worktree ---
# Find ee repo: sibling to the main worktree (git toplevel of the main checkout),
# then try parent of cwd, then fall back to home
+15
View File
@@ -1169,6 +1169,8 @@ interface SlackApprovalOptions {
approver?: string;
defaultArgsJson?: Record<string, any>;
dynamicEnumsJson?: Record<string, any>;
resumeButtonText?: string;
cancelButtonText?: string;
}
interface TeamsApprovalOptions {
@@ -1208,6 +1210,8 @@ interface TeamsApprovalOptions {
* approver: "approver123",
* defaultArgsJson: { key1: "value1", key2: 42 },
* dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] },
* resumeButtonText: "Resume",
* cancelButtonText: "Cancel",
* });
* ```
*
@@ -1220,6 +1224,8 @@ export async function requestInteractiveSlackApproval({
approver,
defaultArgsJson,
dynamicEnumsJson,
resumeButtonText,
cancelButtonText,
}: SlackApprovalOptions): Promise<void> {
const workspace = getWorkspace();
const flowJobId = getEnv("WM_FLOW_JOB_ID");
@@ -1244,6 +1250,8 @@ export async function requestInteractiveSlackApproval({
flowStepId: string;
defaultArgsJson?: string;
dynamicEnumsJson?: string;
resumeButtonText?: string;
cancelButtonText?: string;
} = {
slackResourcePath,
channelId,
@@ -1265,6 +1273,13 @@ export async function requestInteractiveSlackApproval({
params.dynamicEnumsJson = JSON.stringify(dynamicEnumsJson);
}
if (resumeButtonText) {
params.resumeButtonText = resumeButtonText;
}
if (cancelButtonText) {
params.cancelButtonText = cancelButtonText;
}
await JobService.getSlackApprovalPayload({
workspace,
...params,