Merge origin/main into scim-handle-active-false

This commit is contained in:
hugocasa
2026-03-25 12:33:13 +01:00
100 changed files with 4645 additions and 1818 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
### Features
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
### Bug Fixes
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_app_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "default_app_raw: Option<bool>",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_app",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0"
}
+216 -216
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.663.0"
version = "1.664.0"
authors.workspace = true
edition.workspace = true
@@ -82,7 +82,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.663.0"
version = "1.664.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -373,7 +373,7 @@ tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
tower-cookies = "^0.10"
#stuck because of swc for now
serde = "=1.0.219"
serde = "=1.0.220"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4", "js"] }
@@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17"
# samael will break compilation on MacOS. Use this fork instead to make it work
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
libxml = { version = "=0.3.3" }
samael = { version="0.0.14", features = ["xmlsec"] }
samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] }
gcp_auth = "0.9.0"
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
jsonwebtoken = "8.3.0"
@@ -587,7 +587,7 @@ tikv-jemalloc-ctl = { version = "^0.5" }
triomphe = "^0"
pin-project-lite = "^0"
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" }
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" }
backon = "1.3.0"
+7 -2
View File
@@ -36,9 +36,10 @@ use windmill_common::ee_oss::{
use windmill_common::{
agent_workers::AgentConfig,
ai_cache::bump_instance_ai_config_revision,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
@@ -1813,6 +1814,10 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.status(), 200);
// --- allowed_domain_auto_invite ---
let resp = authed(client().get(format!(
"{global_base}/allowed_domain_auto_invite"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<String>>().await?;
// --- get_dependents (empty, no dependencies exist) ---
let resp = authed(client().get(format!(
"{base}/get_dependents/u/test-user/nonexistent"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
assert!(dependents.is_empty());
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!(
"{base}/edit_large_file_storage_config"
)))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
let invites = resp.json::<Vec<serde_json::Value>>().await?;
assert!(
invites
.iter()
.any(|i| i["email"] == "invited@example.com"),
invites.iter().any(|i| i["email"] == "invited@example.com"),
"invite not found: {:?}",
invites
);
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"delete_invite: {}",
resp.text().await?
);
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"create_fork: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.json::<bool>().await?, false);
// --- create_workspace_require_superadmin ---
let resp = authed(client().get(format!(
"{global_base}/create_workspace_require_superadmin"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let instance_ai_config = json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
});
let workspace_ai_config = json!({
"providers": {
"anthropic": {
"resource_path": "u/test-user/anthropic_workspace",
"models": ["claude-3-5-haiku-latest"]
}
}
});
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(instance_ai_config)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], true);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["models"][0],
"gpt-4o-mini"
);
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(workspace_ai_config)
.bind("test-workspace")
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], false);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings["providers"].is_null());
Ok(())
}
+15 -1
View File
@@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
use windmill_common::{
ai_cache::bump_instance_ai_config_revision,
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
key: String,
value: serde_json::Value,
) -> error::Result<()> {
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
let value = if key == "retention_period_secs" {
instance_config::clamp_retention_period(value)
} else {
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
}
};
if should_bump_instance_ai_revision {
bump_instance_ai_config_revision();
}
Ok(())
}
@@ -471,6 +477,10 @@ async fn set_instance_config(
let current_map = current.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
let ai_config_changed = settings_diff
.upserts
.iter()
.any(|(key, _)| key == AI_CONFIG_SETTING);
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
@@ -479,6 +489,10 @@ async fn set_instance_config(
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if ai_config_changed {
bump_instance_ai_config_revision();
}
}
if !desired.worker_configs.is_empty() {
@@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private"]
cloud = ["windmill-common/cloud"]
no_auth = ["windmill-api-auth/no_auth"]
parquet = ["windmill-object-store/parquet"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-object-store = { workspace = true, optional = true }
windmill-types.workspace = true
windmill-api-auth.workspace = true
windmill-api-users.workspace = true
+292 -49
View File
@@ -35,7 +35,6 @@ use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
@@ -82,6 +81,10 @@ pub fn workspaced_service() -> Router {
.route("/get_dependents/*imported_path", get(get_dependents))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route(
"/get_copilot_settings_state",
get(get_copilot_settings_state),
)
.route("/get_deploy_to", get(get_deploy_to))
.route("/edit_slack_command", post(edit_slack_command))
.route(
@@ -111,6 +114,7 @@ pub fn workspaced_service() -> Router {
.route("/list_datatables", get(list_datatables))
.route("/list_datatable_schemas", get(list_datatable_schemas))
.route("/edit_datatable_config", post(edit_datatable_config))
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
.route(
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
pub public_app_execution_limit_per_minute: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CopilotSettingsState {
pub has_instance_ai_config: bool,
pub uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_ai_summary: Option<InstanceAISummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIProviderSummary {
pub provider: String,
pub models: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIModelSummary {
pub provider: String,
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAISummary {
pub providers: Vec<InstanceAIProviderSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<InstanceAIModelSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<InstanceAIModelSummary>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
// pub enum WorkspaceKeyKind {
@@ -608,15 +641,106 @@ async fn get_settings(
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
if !authed.is_admin {
settings.slack_oauth_client_secret = None;
}
Ok(Json(settings))
}
async fn get_copilot_settings_state(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<CopilotSettingsState> {
let mut tx = user_db.begin(&authed).await?;
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
let instance_ai_config: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
tx.commit().await?;
Ok(Json(build_copilot_settings_state(
has_ai_providers(workspace_ai_config.as_ref()),
instance_ai_config.as_ref(),
)))
}
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
config
.and_then(|value| value.get("providers"))
.and_then(|providers| providers.as_object())
.map(|providers| !providers.is_empty())
.unwrap_or(false)
}
pub fn build_copilot_settings_state(
has_workspace_ai_config: bool,
instance_ai_config: Option<&serde_json::Value>,
) -> CopilotSettingsState {
let has_instance_ai_config = has_ai_providers(instance_ai_config);
CopilotSettingsState {
has_instance_ai_config,
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
}
}
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
let config = config?;
if !has_ai_providers(Some(config)) {
return None;
}
let providers = config.get("providers")?.as_object()?;
let mut provider_summaries = providers
.iter()
.map(|(provider, provider_config)| InstanceAIProviderSummary {
provider: provider.clone(),
models: provider_config
.get("models")
.and_then(|models| models.as_array())
.map(|models| {
models
.iter()
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
})
.collect::<Vec<_>>();
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
Some(InstanceAISummary {
providers: provider_summaries,
default_model: extract_instance_ai_model_summary(config, "default_model"),
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
})
}
fn extract_instance_ai_model_summary(
config: &serde_json::Value,
key: &str,
) -> Option<InstanceAIModelSummary> {
let model_config = config.get(key)?.as_object()?;
Some(InstanceAIModelSummary {
provider: model_config.get("provider")?.as_str()?.to_owned(),
model: model_config.get("model")?.as_str()?.to_owned(),
})
}
#[derive(Serialize)]
struct DeployTo {
deploy_to: Option<String>,
@@ -1471,24 +1595,20 @@ async fn edit_datatable_config(
#[derive(Deserialize)]
pub struct EditGitSyncConfig {
#[cfg(feature = "enterprise")]
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct EditGitSyncRepository {
pub git_repo_resource_path: String,
pub repository: GitRepositorySettings,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct DeleteGitSyncRepositoryRequest {
pub git_repo_resource_path: String,
}
#[cfg(feature = "enterprise")]
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
if path.is_empty() {
@@ -1537,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> {
Ok(())
}
#[cfg(feature = "enterprise")]
fn cleanup_legacy_git_sync_settings_in_memory(
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
workspace_id: &str,
@@ -1564,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory(
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_config(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
#[cfg(feature = "enterprise")]
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
Ok(())
}
#[cfg(not(feature = "enterprise"))]
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
if user_count > CE_GIT_SYNC_MAX_USERS {
return Err(Error::BadRequest(format!(
"Git sync is available for workspaces with up to {} members. \
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
CE_GIT_SYNC_MAX_USERS
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
Ok(Json(serde_json::json!({
"enabled": true,
"reason": "enterprise",
"max_repos": null,
"user_count": null,
"max_users": null,
})))
}
#[cfg(not(feature = "enterprise"))]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
Ok(Json(serde_json::json!({
"enabled": enabled,
"reason": if enabled { Some("free_tier") } else { None::<&str> },
"max_repos": if enabled { Some(1) } else { None::<i32> },
"user_count": user_count,
"max_users": CE_GIT_SYNC_MAX_USERS,
})))
}
async fn edit_git_sync_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1584,6 +1757,7 @@ async fn edit_git_sync_config(
Json(new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
let mut tx = db.begin().await?;
@@ -1640,19 +1814,6 @@ async fn edit_git_sync_config(
Ok(format!("Edit git sync config for workspace {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn edit_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1661,10 +1822,19 @@ async fn edit_git_sync_repository(
Json(new_config): Json<EditGitSyncRepository>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
// Validate the resource path format
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
// Promotion mode: EE only
#[cfg(not(feature = "enterprise"))]
if new_config.repository.use_individual_branch.unwrap_or(false) {
return Err(Error::BadRequest(
"Promotion mode is an Enterprise Edition feature".to_string(),
));
}
let mut tx = db.begin().await?;
// First, get the current git sync settings
@@ -1686,6 +1856,20 @@ async fn edit_git_sync_repository(
WorkspaceGitSyncSettings::default()
};
// Multi-repo: EE only
#[cfg(not(feature = "enterprise"))]
{
let is_new = !git_sync_settings
.repositories
.iter()
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
if is_new && !git_sync_settings.repositories.is_empty() {
return Err(Error::BadRequest(
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
));
}
}
// Audit log before we move the repository
audit_log(
&mut *tx,
@@ -1769,19 +1953,6 @@ async fn edit_git_sync_repository(
))
}
#[cfg(not(feature = "enterprise"))]
async fn delete_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_request): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn delete_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1791,7 +1962,7 @@ async fn delete_git_sync_repository(
) -> Result<String> {
require_admin(is_admin, &username)?;
// For deletion, only validate that path is not empty to allow cleanup of malformed entries
// No check_git_sync_access here — admins should always be able to delete/clean up repos
if request.git_repo_resource_path.is_empty() {
return Err(Error::BadRequest(
"Resource path cannot be empty".to_string(),
@@ -2109,22 +2280,29 @@ async fn edit_default_app(
#[derive(Serialize)]
struct WorkspaceDefaultApp {
pub default_app_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_app_raw: Option<bool>,
}
async fn get_default_app(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<WorkspaceDefaultApp> {
let mut tx = db.begin().await?;
let default_app_path = sqlx::query_scalar!(
"SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
let row = sqlx::query!(
"SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"
FROM workspace_settings ws
LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id
LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]
WHERE ws.workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
tx.commit().await?;
Ok(Json(WorkspaceDefaultApp { default_app_path }))
Ok(Json(WorkspaceDefaultApp {
default_app_path: row.default_app_path,
default_app_raw: row.default_app_raw,
}))
}
async fn edit_error_handler(
@@ -3296,6 +3474,11 @@ async fn clone_apps(
.fetch_all(&mut **tx)
.await?;
let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new();
for bundle in &bundles {
cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone()));
}
for bundle in bundles {
if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) {
sqlx::query!(
@@ -3310,6 +3493,66 @@ async fn clone_apps(
.await?;
}
}
// Clone bundles from S3 for versions not found in DB
#[cfg(all(feature = "enterprise", feature = "parquet"))]
{
let object_store = windmill_object_store::get_object_store().await;
if let Some(os) = object_store {
for (&old_version_id, &new_version_id) in &version_id_mapping {
for file_type in &["js", "css"] {
if cloned_from_db.contains(&(old_version_id, file_type.to_string())) {
continue;
}
let src_path = format!(
"/app_bundles/{}/{}.{}",
source_workspace_id, old_version_id, file_type
);
let get_result = os
.get(&windmill_object_store::object_store_reexports::Path::from(
src_path,
))
.await;
match get_result {
Ok(result) => {
let data = result.bytes().await.map_err(
windmill_object_store::object_store_error_to_error,
)?;
let dst_path = format!(
"/app_bundles/{}/{}.{}",
target_workspace_id, new_version_id, file_type
);
os.put(
&windmill_object_store::object_store_reexports::Path::from(
dst_path.clone(),
),
data.into(),
)
.await
.map_err(
windmill_object_store::object_store_error_to_error,
)?;
tracing::info!(
"Cloned app bundle from S3: {}.{} -> {}.{}",
old_version_id,
file_type,
new_version_id,
file_type
);
}
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
// No bundle in S3 for this version/type, skip
}
Err(e) => {
return Err(
windmill_object_store::object_store_error_to_error(e),
);
}
}
}
}
}
}
}
// Update app versions arrays
+1 -1
View File
@@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["windmill-api-embeddings/embedding"]
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
tantivy = ["dep:windmill-indexer"]
+247 -3
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.663.0
version: 1.664.0
title: Windmill API
contact:
@@ -3193,9 +3193,49 @@ paths:
"200":
description: status
content:
text/plain:
application/json:
schema:
type: string
type: object
properties:
effective_ai_config:
$ref: "#/components/schemas/AIConfig"
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- effective_ai_config
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_settings_state:
get:
summary: get copilot settings state
operationId: getCopilotSettingsState
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_info:
get:
@@ -3392,6 +3432,37 @@ paths:
application/json:
schema: {}
/w/{workspace}/workspaces/git_sync_enabled:
get:
summary: Check if git sync is available for this workspace
operationId: getGitSyncEnabled
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: Git sync availability status
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
reason:
type: string
nullable: true
max_repos:
type: integer
nullable: true
user_count:
type: integer
nullable: true
max_users:
type: integer
nullable: true
/w/{workspace}/workspaces/edit_git_sync_config:
post:
summary: edit workspace git sync settings
@@ -3664,6 +3735,8 @@ paths:
properties:
default_app_path:
type: string
default_app_raw:
type: boolean
/w/{workspace}/workspaces/usage:
get:
@@ -11050,6 +11123,129 @@ paths:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
post:
summary: resume or cancel a suspended flow/WAC job
description: >
Resume or cancel a suspended flow/WAC job. Uses approval rules to
determine authorization. Either a valid approval_token or an
authenticated session is required.
operationId: resumeSuspended
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
payload:
description: payload to send to the resumed job
approval_token:
type: string
description: approval token for unauthenticated access
approved:
type: boolean
description: whether to approve (true) or cancel (false) the job
default: true
responses:
"201":
description: job resumed
content:
text/plain:
schema:
type: string
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
get:
summary: get approval info for a suspended flow/WAC job
description: >
Get approval info for a suspended flow/WAC job. Returns form schema,
approval rules, and whether the current user can approve. Either a
valid token query parameter or an authenticated session is required.
operationId: getApprovalInfo
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
- name: token
in: query
required: false
schema:
type: string
description: approval token for unauthenticated access
responses:
"200":
description: approval info
content:
application/json:
schema:
type: object
required:
- flow_id
- can_approve
- user_auth_required
- approvers
properties:
flow_id:
type: string
format: uuid
form_schema:
description: form schema for the approval step
description:
description: description of the approval step
approval_conditions:
type: object
properties:
user_auth_required:
type: boolean
user_groups_required:
type: array
items:
type: string
self_approval_disabled:
type: boolean
required:
- user_auth_required
- user_groups_required
- self_approval_disabled
can_approve:
type: boolean
description: whether the current user/token holder can approve
user_auth_required:
type: boolean
description: whether user authentication is required to approve
hide_cancel:
type: boolean
description: whether to hide the cancel button in the UI
approvers:
type: array
items:
type: object
required:
- resume_id
- approver
properties:
resume_id:
type: integer
approver:
type: string
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
@@ -17266,6 +17462,27 @@ paths:
description: count of log lines that matched the query per hostname
type: object
/srch/index/storage/disk:
get:
summary: Get index disk storage sizes from the indexer.
operationId: getIndexDiskStorageSizes
tags:
- indexSearch
responses:
"200":
description: disk storage sizes for each index
content:
application/json:
schema:
type: object
properties:
job_index_disk_size_bytes:
type: integer
nullable: true
log_index_disk_size_bytes:
type: integer
nullable: true
/indexer/delete/{idx_name}:
delete:
summary: Clear an index and restart the indexer.
@@ -18794,6 +19011,33 @@ components:
minimum: 1
maximum: 2000000
InstanceAIProviderSummary:
type: object
properties:
provider:
$ref: "#/components/schemas/AIProvider"
models:
type: array
items:
type: string
required:
- provider
- models
InstanceAISummary:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/InstanceAIProviderSummary"
default_model:
$ref: "#/components/schemas/AIProviderModel"
code_completion_model:
$ref: "#/components/schemas/AIProviderModel"
required:
- providers
Alert:
type: object
properties:
+169 -44
View File
@@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue};
use std::collections::HashMap;
use std::time::Duration;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_cache::current_instance_ai_config_revision;
use windmill_common::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
};
}
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
}
#[derive(Deserialize, Debug)]
struct AIOAuthResource {
client_id: String,
@@ -373,8 +378,7 @@ impl AIRequestConfig {
let is_azure = provider.is_azure_openai(base_url);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_vertex =
is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
@@ -483,18 +487,27 @@ impl AIRequestConfig {
pub struct ExpiringAIRequestConfig {
config: AIRequestConfig,
expires_at: std::time::Instant,
instance_ai_config_revision: Option<u64>,
}
impl ExpiringAIRequestConfig {
fn new(config: AIRequestConfig) -> Self {
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
Self {
config,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
instance_ai_config_revision,
}
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
|| self
.instance_ai_config_revision
.is_some_and(|revision| revision != current_instance_ai_config_revision())
}
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
@@ -508,6 +521,14 @@ pub struct AIConfig {
pub max_tokens_per_model: Option<HashMap<String, i32>>,
}
impl AIConfig {
pub fn has_providers(&self) -> bool {
self.providers
.as_ref()
.is_some_and(|providers| !providers.is_empty())
}
}
/// Anthropic API version for Google Vertex AI
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
@@ -762,47 +783,76 @@ async fn proxy(
request_cache.config
}
_ => {
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false)
} else {
let ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_config.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
let provider_config = ai_config
.providers
.as_mut()
.map(|providers| providers.remove(&provider))
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
(provider_config.resource_path, true)
};
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
let resource= sqlx::query_scalar!(
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
@@ -811,11 +861,15 @@ async fn proxy(
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
let request_config =
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringAIRequestConfig::new(request_config.clone()),
ExpiringAIRequestConfig::new(
request_config.clone(),
instance_ai_config_revision,
),
);
}
request_config
@@ -858,9 +912,7 @@ async fn proxy(
"chat/completions" => {
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
}
"models" => {
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
}
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
_ => Err(Error::BadRequest(format!(
"Unsupported Google AI path: {}",
ai_path
@@ -1005,3 +1057,76 @@ async fn proxy(
};
Ok((status_code, headers, body))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{LazyLock, Mutex};
use windmill_common::ai_cache::bump_instance_ai_config_revision;
use windmill_common::ai_providers::AIPlatform;
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn sample_request_config() -> AIRequestConfig {
AIRequestConfig {
base_url: "https://example.com".to_string(),
api_key: None,
access_token: None,
organization_id: None,
user: None,
region: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
platform: AIPlatform::Standard,
enable_1m_context: false,
custom_headers: HashMap::new(),
}
}
#[test]
fn invalidates_all_cached_providers_for_workspace() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::Anthropic),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-b".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
invalidate_ai_request_cache_for_workspace("workspace-a");
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
.is_some());
}
#[test]
fn instance_backed_cache_entries_expire_when_revision_changes() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
let cached = ExpiringAIRequestConfig::new(
sample_request_config(),
Some(current_instance_ai_config_revision()),
);
assert!(!cached.is_expired());
bump_instance_ai_config_revision();
assert!(cached.is_expired());
}
}
+19 -7
View File
@@ -440,17 +440,29 @@ async fn get_raw_app_data(
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = object_store {
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
let stream = os
match os
.get(&windmill_object_store::object_store_reexports::Path::from(
path,
))
.await
.map_err(windmill_object_store::object_store_error_to_error)?
.bytes()
.await
.map_err(windmill_object_store::object_store_error_to_error)?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
{
Ok(result) => {
let stream = result
.bytes()
.await
.map_err(windmill_object_store::object_store_error_to_error)?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
}
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
..
}) => {
// S3 key not found, fall through to DB lookup below
}
Err(e) => {
return Err(windmill_object_store::object_store_error_to_error(e));
}
}
}
if body.is_none() {
+487 -43
View File
@@ -103,7 +103,7 @@ use windmill_common::{
cache,
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
post(cancel_persistent_script_api),
)
.route("/queue/force_cancel/:id", post(force_cancel))
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
.route("/flow/approval_info/:job_id", get(get_approval_info))
}
pub fn global_root_service() -> Router {
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
Self { with_code: false, ..self }
}
#[allow(dead_code)]
fn without_flow(self) -> Self {
Self { with_flow: false, ..self }
}
@@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
) -> error::Result<StatusCode> {
let mut tx = db.begin().await?;
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
require_owner_of_path(&authed, flow_path)?;
@@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
}
let trigger_email = flow.email.as_deref().unwrap_or("");
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
.ok()
.and_then(|fs| fs.approval_conditions)
.or_else(|| {
// WAC flows store approval_conditions directly in flow_status JSONB
flow_status_value
.get("approval_conditions")
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
});
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
}
let value = value.unwrap_or(serde_json::Value::Null);
@@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
)
.await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
if is_wac {
// WAC: directly decrement suspend counter
if flow.suspend > 0 {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
flow.id,
)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
}
tx.commit().await?;
Ok(StatusCode::CREATED)
}
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
db: &DB,
token: &str,
job_id: Uuid,
workspace_id: &str,
) -> error::Result<()> {
let expected = generate_approval_token(workspace_id, job_id, db).await?;
if token != expected {
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
struct ResumeSuspendedBody {
payload: Option<serde_json::Value>,
approval_token: Option<String>,
approved: Option<bool>,
}
async fn resume_suspended(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(body): Json<ResumeSuspendedBody>,
) -> error::Result<StatusCode> {
let approved = body.approved.unwrap_or(true);
let value = body.payload.unwrap_or(serde_json::Value::Null);
// Determine if we have a valid authed user or token
let has_token = if let Some(ref token) = body.approval_token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
let mut tx = db.begin().await?;
// Resolve the suspended flow (works for both WAC and classic flows)
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
// Check approval conditions
let approval_conditions = if is_wac {
flow.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
} else {
flow.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|fs| fs.approval_conditions)
};
if let Some(ref ac) = approval_conditions {
if ac.user_auth_required && opt_authed.is_none() {
return Err(Error::NotAuthorized(
"This approval requires a logged-in user. Please sign in.".to_string(),
));
}
}
// If logged in, check authorization rules
if let Some(ref authed) = opt_authed {
let is_admin = authed.is_admin;
let is_owner = flow
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if !is_admin && !is_owner {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)?;
}
} else if !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Generate a unique resume_id
let resume_id: u32 = rand::random();
// Check for duplicate
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest("Resume request already sent".to_string()));
}
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
insert_resume_job(
resume_id,
resume_job_id,
&flow,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
} else if is_wac {
if flow.suspend > 0 {
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = if let Some(ref authed) = opt_authed {
AuditAuthor::from(authed)
} else {
AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: None,
}
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct ApprovalInfoQuery {
token: Option<String>,
}
#[derive(Serialize)]
struct ApprovalInfo {
flow_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
form_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_conditions: Option<ApprovalConditions>,
can_approve: bool,
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
approvers: Vec<Approval>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(query): Query<ApprovalInfoQuery>,
) -> error::Result<Json<ApprovalInfo>> {
// Validate access: either logged in or valid token
let has_token = if let Some(ref token) = query.token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Fetch job info
#[derive(sqlx::FromRow)]
struct ApprovalJobRow {
id: Uuid,
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
workflow_as_code_status: Option<serde_json::Value>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status, s.workflow_as_code_status
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.workflow_as_code_status.is_some();
// Extract approval info based on WAC vs classic flow
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
let approval_meta = row
.workflow_as_code_status
.as_ref()
.and_then(|v| v.get("_approval"));
let form = approval_meta.and_then(|m| m.get("form").cloned());
let ac = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
(form, None, ac, None)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
let raw_flow: Option<FlowValue> = {
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_job {
serde_json::from_value(v).ok()
} else {
// Deployed flow: fetch from flow_version using runnable_id
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
from_version.and_then(|v| serde_json::from_value(v).ok())
}
};
let suspend_module = raw_flow
.as_ref()
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
let form = suspend_settings
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
// Fetch description and default_args from the step's completed job result
let step_job_id = fs
.as_ref()
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
.and_then(|m| m.job());
let (desc, _default_args) = if let Some(sjid) = step_job_id {
let result: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
)
.bind(sjid)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
(desc, da)
} else {
(None, None)
};
(form, desc, ac, hc)
};
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
// Determine if current user can approve
let can_approve = if let Some(ref authed) = opt_authed {
if authed.is_admin {
true
} else {
let is_owner = row
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if is_owner {
true
} else {
let trigger_email = row.email.as_str();
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)
.is_ok()
}
}
} else {
// Not logged in — can approve only if no auth required
!user_auth_required
};
// Get existing approvers
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
)
.bind(&job_id)
.fetch_all(&db)
.await?
.into_iter()
.map(|(rid, approver)| Approval {
resume_id: rid as u16,
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect();
Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema,
description,
approval_conditions,
can_approve,
user_auth_required,
hide_cancel,
approvers,
}))
}
// --- End new approval system endpoints ---
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
@@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal(
// Get flow info - works for step-level, flow-level, and WAC approval
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
// For step-level resumes, verify user auth and flow status
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
// For WAC approvals, skip flow status checks (there is no flow)
if !is_flow_level && !is_wac {
let parent_flow = GetQuery::new()
.without_logs()
.without_code()
.without_flow()
.fetch(&db, &flow_info.id, &w_id)
.await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let trigger_email = match &parent_flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
}
// HMAC secret = full capability. Skip approval_conditions checks.
// Authorization rules are enforced by the new resume_suspended endpoint instead.
let exists = sqlx::query_scalar!(
r#"
@@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
async fn get_suspended_flow_info<'c>(
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<(FlowInfo, Uuid)> {
) -> error::Result<(FlowInfo, Uuid, bool)> {
let flow = sqlx::query_as!(
FlowInfo,
r#"
@@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
let job_id = flow
// Try to extract step job_id from FlowStatus modules (classic flow path)
let step_job_id = flow
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
@@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
_ => None,
});
if let Some(job_id) = job_id {
Ok((flow, job_id))
if let Some(step_job_id) = step_job_id {
// Classic flow
Ok((flow, step_job_id, false))
} else if flow.suspend > 0 {
// WAC approval: no FlowStatus modules, but the job is suspended
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
// so for WAC it may contain approval_conditions from flow_status column
// or the WAC checkpoint from workflow_as_code_status column.
// We need the approval_conditions which are in flow_status column.
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
let flow_status_only: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(&job_id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow = FlowInfo {
id: flow.id,
flow_status: flow_status_only,
suspend: flow.suspend,
script_path: flow.script_path,
email: flow.email,
};
Ok((flow, job_id, true))
} else {
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
}
@@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow(
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
conditionally_require_authed_user(
authed.clone(),
flow_status.approval_conditions.clone(),
trigger_email,
)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
@@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow(
fn conditionally_require_authed_user(
_authed: Option<ApiAuthed>,
flow_status: FlowStatus,
approval_conditions_opt: Option<ApprovalConditions>,
_trigger_email: &str,
) -> error::Result<()> {
let approval_conditions_opt = flow_status.approval_conditions;
if approval_conditions_opt.is_none() {
return Ok(());
}
let approval_conditions = approval_conditions_opt.unwrap();
// Check self-approval independently of user_auth_required
if approval_conditions.self_approval_disabled {
if let Some(ref authed) = _authed {
if !authed.is_admin && authed.email.eq(_trigger_email) {
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
}
}
if approval_conditions.user_auth_required {
{
#[cfg(not(feature = "enterprise"))]
@@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user(
let authed = _authed.unwrap();
if !authed.is_admin {
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
{
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
if !approval_conditions.user_groups_required.is_empty() {
#[cfg(feature = "enterprise")]
{
@@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal(
.map(|x| format!("?approver={}", encode(x)))
.unwrap_or_else(String::new);
// Generate approval token for the new approval page URL.
// The token targets the parent flow/WAC job for proper resolution.
let approval_target_id = get_flow_id_for_job(&db, job_id)
.await
.unwrap_or(target_job_id);
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
let base_url_str = BASE_URL.read().await.clone();
let base_url = base_url_str.as_str();
let res = ResumeUrls {
approvalPage: format!(
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
),
cancel: build_resume_url(
"cancel",
+51 -22
View File
@@ -8,8 +8,9 @@
// Re-export everything from windmill-api-workspaces
pub use windmill_api_workspaces::workspaces::*;
use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary};
use crate::ai::{AIConfig, AI_REQUEST_CACHE};
use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig};
use crate::db::ApiAuthed;
use crate::teams_oss::{
connect_teams, edit_teams_command, run_teams_message_test_job,
@@ -24,7 +25,7 @@ use axum::{
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
error::{Error, JsonResult, Result},
error::{Error, JsonResult},
utils::require_admin,
DB,
};
@@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use axum::extract::Query;
#[cfg(feature = "enterprise")]
use serde::Deserialize;
use serde::Serialize;
#[cfg(feature = "enterprise")]
use windmill_common::error::Result;
#[cfg(feature = "enterprise")]
use windmill_common::utils::require_admin_or_devops;
@@ -83,7 +87,7 @@ async fn edit_copilot_config(
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(ai_config): Json<AIConfig>,
) -> Result<String> {
) -> JsonResult<EditCopilotConfigResponse> {
require_admin(is_admin, &username)?;
if let Some(ref custom_prompts) = ai_config.custom_prompts {
@@ -109,11 +113,7 @@ async fn edit_copilot_config(
.execute(&mut *tx)
.await?;
if let Some(ref providers) = ai_config.providers {
for provider in providers.keys() {
AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone()));
}
}
invalidate_ai_request_cache_for_workspace(&w_id);
audit_log(
&mut *tx,
@@ -139,37 +139,66 @@ async fn edit_copilot_config(
)
.await?;
Ok(format!("Edit copilot config for workspace {}", &w_id))
let workspace_has_config = ai_config.has_providers();
let instance_ai_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?;
let settings_state =
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
let effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_ai_config) = instance_ai_config {
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
} else {
AIConfig::default()
};
Ok(Json(EditCopilotConfigResponse {
effective_ai_config,
has_instance_ai_config: settings_state.has_instance_ai_config,
uses_instance_ai_config: settings_state.uses_instance_ai_config,
instance_ai_summary: settings_state.instance_ai_summary,
}))
}
#[derive(Serialize)]
struct EditCopilotConfigResponse {
effective_ai_config: AIConfig,
has_instance_ai_config: bool,
uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
instance_ai_summary: Option<InstanceAISummary>,
}
async fn get_copilot_info(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<AIConfig> {
let mut tx = db.begin().await?;
let copilot_info = sqlx::query_scalar!(
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await
.map_err(|e| {
Error::internal_err(format!(
"getting ai config: {e:#}"
))
})?;
tx.commit().await?;
if let Some(sqlx::types::Json(copilot_info)) = copilot_info {
Ok(Json(copilot_info))
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?
{
Ok(Json(
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
))
} else {
Ok(Json(AIConfig {
providers: None,
default_model: None,
code_completion_model: None,
custom_prompts: None,
max_tokens_per_model: None,
}))
Ok(Json(AIConfig::default()))
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::sync::atomic::{AtomicU64, Ordering};
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
pub fn current_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
}
pub fn bump_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
}
+1
View File
@@ -23,6 +23,7 @@ lazy_static::lazy_static! {
}
#[cfg(not(feature = "private"))]
#[derive(PartialEq, Eq)]
pub enum LicensePlan {
Community,
Pro,
@@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
pub const AI_CONFIG_SETTING: &str = "ai_config";
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3";
@@ -351,6 +351,13 @@ pub struct GlobalSettings {
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub ai_config: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
+1
View File
@@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres};
pub mod agent_workers;
#[cfg(feature = "bedrock")]
pub mod ai_bedrock;
pub mod ai_cache;
pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
+18
View File
@@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
Ok(key)
}
/// Generate a stateless approval token from workspace key + job_id.
/// This token grants access to view approval info and attempt to resume,
/// but cannot be reversed to obtain the HMAC resume secret.
pub async fn generate_approval_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job_id.as_bytes());
mac.update(b"approval_token");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
+1 -1
View File
@@ -9,7 +9,7 @@ name = "windmill_git_sync"
path = "./src/lib.rs"
[features]
private = []
private = ["windmill-common/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
all_sqlx_features = ["enterprise"]
default = []
+8 -12
View File
@@ -272,7 +272,10 @@ mod tests {
path: "f/folder/script".to_string(),
parent_path: Some("f/folder/old_script".to_string()),
};
assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string()));
assert_eq!(
obj.get_parent_path(),
Some("f/folder/old_script".to_string())
);
}
#[test]
@@ -313,21 +316,13 @@ mod tests {
#[test]
fn test_get_kind_flow() {
let obj = DeployedObject::Flow {
path: "test".to_string(),
parent_path: None,
version: 1,
};
let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 };
assert_eq!(obj.get_kind(), "flow");
}
#[test]
fn test_get_kind_app() {
let obj = DeployedObject::App {
path: "test".to_string(),
version: 1,
parent_path: None,
};
let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None };
assert_eq!(obj.get_kind(), "app");
}
@@ -346,7 +341,8 @@ mod tests {
"http_trigger"
);
assert_eq!(
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }
.get_kind(),
"websocket_trigger"
);
assert_eq!(
+1
View File
@@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true }
rust_decimal.workspace = true
jsonwebtoken.workspace = true
sha2.workspace = true
hmac.workspace = true
pem = { workspace = true, optional = true }
urlencoding.workspace = true
nix.workspace = true
+87 -2
View File
@@ -1504,7 +1504,7 @@ async function run() {{
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
}}
if (dispatch.mode === "approval") {{
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }};
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
}}
if (dispatch.mode === "sleep") {{
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
@@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output(
job.id, num_steps
)))
}
WacOutput::Approval { key, timeout, form } => {
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
let db = match conn {
Connection::Sql(db) => db,
_ => {
@@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output(
.await
.map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?;
// Store approval_conditions in flow_status for resume endpoint auth checks
let sad = self_approval_disabled.unwrap_or(false);
if sad {
#[cfg(not(feature = "enterprise"))]
return Err(error::Error::ExecutionErr(
"Disabling self-approval is an enterprise only feature".to_string(),
));
#[cfg(feature = "enterprise")]
{
use windmill_common::flow_status::ApprovalConditions;
let approval_conditions = ApprovalConditions {
user_auth_required: true,
user_groups_required: vec![],
self_approval_disabled: true,
};
sqlx::query(
"UPDATE v2_job_status SET flow_status = JSONB_SET(
COALESCE(flow_status, '{}'::jsonb),
'{approval_conditions}',
$2::jsonb
) WHERE id = $1",
)
.bind(&job.id)
.bind(&serde_json::json!(approval_conditions))
.execute(&mut *tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Failed to save approval conditions: {e}"
))
})?;
}
}
// Generate resume URLs for the inline approval buttons.
// Use a hash of the step key as resume_id so each waitForApproval()
// in the same workflow gets a unique resume_job record.
let resume_id: u32 = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hasher);
(hasher.finish() & 0xFFFF_FFFF) as u32
};
// Generate stateless approval token using shared utility
let approval_token =
windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db)
.await?;
let (resume_url, cancel_url, approval_page_url) = {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use windmill_common::variables::get_workspace_key;
let wkey = get_workspace_key(&job.workspace_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes())
.map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job.id.as_bytes());
mac.update(resume_id.to_be_bytes().as_ref());
let signature = hex::encode(mac.finalize().into_bytes());
let base_url = windmill_common::BASE_URL.read().await.clone();
let w_id = &job.workspace_id;
let job_id = &job.id;
let resume = format!(
"{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}"
);
let cancel = format!(
"{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}"
);
let approval_page =
format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}");
(resume, cancel, approval_page)
};
// Store approval form metadata for the approval page endpoint
let approval_meta = serde_json::json!({
"key": key,
"form": form,
"timeout": timeout_secs as u32,
"self_approval_disabled": sad,
"resume": resume_url,
"cancel": cancel_url,
"approvalPage": approval_page_url,
});
sqlx::query(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
@@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output(
"started_at": &now_str,
"name": key,
"approval": true,
"self_approval_disabled": sad,
"form": form,
"resume": &resume_url,
"cancel": &cancel_url,
"approvalPage": &approval_page_url,
});
let step_timeline_key = format!("_step/{}", key);
sqlx::query(
+9 -15
View File
@@ -59,7 +59,13 @@ pub enum WacOutput {
/// No child job is dispatched — the parent suspends directly and resumes
/// when a user hits the resume/cancel endpoint.
#[serde(rename = "approval")]
Approval { key: String, timeout: Option<u32>, form: Option<Value> },
Approval {
key: String,
timeout: Option<u32>,
form: Option<Value>,
#[serde(default)]
self_approval_disabled: Option<bool>,
},
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
#[serde(rename = "sleep")]
Sleep { key: String, seconds: u32 },
@@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume(
}
/// Detect WAC v2 patterns in TypeScript/Bun code.
/// Checks for `import ... from "windmill-client"` containing workflow/task,
/// Checks for `import ... from "windmill-client"` containing workflow,
/// skipping comment lines. Handles both single-line and multi-line imports.
pub fn is_wac_v2_ts(code: &str) -> bool {
let mut has_wac_import = false;
let mut has_workflow = false;
let mut has_task = false;
let mut in_import_block = false;
let mut import_block_has_workflow = false;
let mut import_block_has_task = false;
for line in code.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
@@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
if trimmed.contains("workflow") {
has_workflow = true;
}
if trimmed.contains("task") {
has_task = true;
}
in_import_block = false;
}
// Start of multi-line import: import {
else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") {
in_import_block = true;
import_block_has_workflow = trimmed.contains("workflow");
import_block_has_task = trimmed.contains("task");
}
// Inside multi-line import block
else if in_import_block {
if trimmed.contains("workflow") {
import_block_has_workflow = true;
}
if trimmed.contains("task") {
import_block_has_task = true;
}
// End of multi-line import: } from "windmill-client"
if trimmed.contains("windmill-client") {
has_wac_import = true;
if import_block_has_workflow {
has_workflow = true;
}
if import_block_has_task {
has_task = true;
}
in_import_block = false;
}
// End of import block but not windmill-client
@@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
has_workflow = true;
}
}
has_wac_import && has_workflow && has_task
has_wac_import && has_workflow
}
/// Detect WAC v2 patterns in Python code.
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.663.0";
export const VERSION = "v1.664.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+10 -4
View File
@@ -25,10 +25,11 @@
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "*",
"windmill-parser-wasm-py-imports": "*",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "*",
"windmill-parser-wasm-ts": "^1.659.1",
"windmill-parser-wasm-yaml": "*",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
@@ -1414,6 +1415,11 @@
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz",
"integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="
},
"node_modules/windmill-parser-wasm-py-imports": {
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz",
"integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.639.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz",
@@ -1430,9 +1436,9 @@
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.647.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz",
"integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw=="
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz",
"integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.593.0",
+10 -5
View File
@@ -740,7 +740,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -1403,7 +1403,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -2129,7 +2129,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -3069,7 +3069,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -4078,12 +4078,17 @@ async def sleep(seconds: int)
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+1 -1
View File
@@ -68,7 +68,7 @@ export {
workspaceAdd,
};
export const VERSION = "1.663.0";
export const VERSION = "1.664.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.663.0",
"version": "1.664.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.663.0",
"version": "1.664.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.663.0",
"version": "1.664.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -10,9 +10,11 @@
interface Props {
expressOAuthSetup?: boolean
workspace?: string
disableChatOffset?: boolean
}
let { expressOAuthSetup = false }: Props = $props()
let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props()
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
@@ -50,6 +52,7 @@
dispatch('close')
}}
size="800px"
{disableChatOffset}
>
<DrawerContent
title="Add a resource"
@@ -68,6 +71,7 @@
on:close={drawer?.closeDrawer}
on:refresh
express={expressOAuthSetup}
{workspace}
/>
{#snippet actions()}
<div class="flex gap-1">
@@ -42,6 +42,7 @@
disabled?: boolean
manual?: boolean
express?: boolean
workspace?: string
}
let {
@@ -50,9 +51,12 @@
isGoogleSignin = $bindable(false),
disabled = $bindable(false),
manual = $bindable(true),
express = false
express = false,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
const nativeLanguagesCategory = [
@@ -214,7 +218,7 @@
return
}
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
workspace: effectiveWorkspace
})
connectsManual = availableRts
@@ -316,7 +320,7 @@
async function getResourceTypeInfo() {
resourceTypeInfo = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resourceType
})
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
@@ -419,7 +423,7 @@
// Check if variable paths already exist
if (!manual || linkedSecrets.length <= 1) {
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
if (exists) {
@@ -429,7 +433,7 @@
for (const secretField of linkedSecrets) {
const varPath = `${path}_${secretField}`
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: varPath
})
if (exists) {
@@ -440,7 +444,7 @@
}
}
let exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
@@ -478,7 +482,7 @@
account = Number(
await OauthService.createAccount({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: accountData
})
)
@@ -492,7 +496,7 @@
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: value,
@@ -513,7 +517,7 @@
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: v,
@@ -532,7 +536,7 @@
const varPath = `${path}_${secretField}`
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path: varPath,
value: v,
@@ -549,7 +553,7 @@
}
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
@@ -2084,6 +2084,7 @@
stepResults={getStepResults(node.workflow_as_code_status)}
result={node.result}
success={node.type === 'Success'}
jobId={node.job_id}
/>
</div>
{/if}
@@ -18,11 +18,9 @@
light?: boolean
}
let { isOwner, workspaceId, job, light = false }: Props = $props()
let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props()
let default_payload: object = $state({})
let resumeUrl: string | undefined = $state(undefined)
let cancelUrl: string | undefined = $state(undefined)
let description: any = $state(undefined)
let hide_cancel = $state(false)
@@ -49,8 +47,6 @@
defaultValues = JSON.parse(JSON.stringify(args))
default_payload = args
resumeUrl = job_result?.['resume']
cancelUrl = job_result?.['cancel']
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
schema = mergeSchema(
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
@@ -61,61 +57,19 @@
let loading = $state(false)
async function continu(approve: boolean) {
loading = true
if ((resumeUrl && approve) || (cancelUrl && !approve)) {
let split = (approve ? resumeUrl : cancelUrl)!.split('/')
let signatureUrl = split.pop() ?? ''
const regex = /([^?]+)(?:\?[^=]+=(\w+))?/
const matches = signatureUrl.match(regex)
const signature = matches?.[1]
if (!signature) {
sendUserToast(`Could not parse signature: ${signatureUrl}`, true)
return
}
const approver = matches?.[2] || undefined
let resumeId = -1
let parsedResumeId = split.pop() ?? ''
try {
resumeId = new Number(parsedResumeId).valueOf()
} catch (e) {
console.error(`Could not parse resume id: ${parsedResumeId}`)
}
let jobId = split.pop() ?? ''
if (approve) {
await JobService.resumeSuspendedJobPost({
workspace: workspaceId ?? $workspaceStore ?? '',
id: jobId,
requestBody: default_payload as any,
resumeId,
signature,
approver
})
} else {
await JobService.cancelSuspendedJobPost({
workspace: workspaceId ?? $workspaceStore ?? '',
id: jobId,
resumeId,
signature,
approver,
requestBody: {}
})
}
} else {
if (approve) {
await JobService.resumeSuspendedFlowAsOwner({
workspace: workspaceId ?? $workspaceStore ?? '',
id: job?.id ?? '',
requestBody: default_payload as any
})
} else {
await JobService.cancelQueuedJob({
workspace: workspaceId ?? $workspaceStore ?? '',
id: job?.id ?? '',
requestBody: {}
})
}
try {
await JobService.resumeSuspended({
workspace: workspaceId ?? $workspaceStore ?? '',
jobId: job?.id ?? '',
requestBody: {
payload: approve ? (default_payload as any) : undefined,
approved: approve
}
})
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
} finally {
loading = false
}
}
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
@@ -130,51 +84,41 @@
<div class="mt-2"></div>
{/if}
<div>
{#if isOwner || resumeUrl}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
title="Cancel the step"
{loading}
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={!cancelUrl}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{/if}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button variant="accent" onClick={() => continu(true)} {loading} unifiedSize="md">
Resume
<Tooltip class="text-white">
Since you are an owner of this flow, you can send resume events without necessarily
knowing the resume id sent by the approval step
</Tooltip>
</Button>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume`
variable
</Tooltip>
{/if}
{/if}
<div>
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
{:else}
You cannot resume the flow yourself without receiving the resume secret since you are not an
owner of {job.script_path} and the approval step did not contain the resume url at key `resume`
{/if}
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume` variable
</Tooltip>
{/if}
</div>
</div>
</div>
+2 -1
View File
@@ -164,7 +164,8 @@
workspace: $workspaceStore!
})
if (!emptyString(defaultApp.default_app_path)) {
goto(`/apps/get/${defaultApp.default_app_path}`)
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
goto(`${prefix}/${defaultApp.default_app_path}`)
} else {
goto(rd ?? '/')
}
@@ -33,6 +33,7 @@
hidePath?: boolean
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
}
let {
@@ -41,9 +42,12 @@
path = $bindable(''),
hidePath = false,
onChange,
defaultValues = undefined
defaultValues = undefined,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
let jsonError = $state('')
let can_write = $state(true)
@@ -68,13 +72,13 @@
let rawCode: string | undefined = $state(undefined)
async function initEdit() {
resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path })
resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path })
description = resourceToEdit!.description ?? ''
resource_type = resourceToEdit!.resource_type
args = resourceToEdit?.value ?? ({} as any)
loadResourceType()
can_write =
resourceToEdit.workspace_id == $workspaceStore &&
resourceToEdit.workspace_id == effectiveWorkspace &&
canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore)
linkedVars = Object.entries(args)
.filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`)
@@ -92,12 +96,12 @@
export async function editResource(): Promise<void> {
if (resourceToEdit) {
await ResourceService.updateResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resourceToEdit.path,
requestBody: { path, value: args, description }
})
if (resourceToEdit.resource_type === 'json_schema') {
clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!)
clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace)
}
sendUserToast(`Updated resource at ${path}`)
dispatch('refresh', path)
@@ -108,7 +112,7 @@
export async function createResource(): Promise<void> {
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: { path, value: args, description, resource_type: resource_type! }
})
sendUserToast(`Updated resource at ${path}`)
@@ -119,7 +123,7 @@
if (resource_type) {
try {
const resourceType = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resource_type
})
@@ -5,6 +5,11 @@
import { Loader2, Save } from 'lucide-svelte'
let {
workspace = undefined,
disableChatOffset = false
}: { workspace?: string; disableChatOffset?: boolean } = $props()
let drawer: Drawer | undefined = $state()
let canSave = $state(true)
let resource_type: string | undefined = $state(undefined)
@@ -34,7 +39,7 @@
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
</script>
<Drawer bind:this={drawer} size="800px">
<Drawer bind:this={drawer} size="800px" {disableChatOffset}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
on:close={drawer?.closeDrawer}
@@ -46,6 +51,7 @@
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
@@ -29,6 +29,8 @@
onClear?: () => void
excludedValues?: string[]
datatableAsPgResource?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
}
let {
@@ -47,9 +49,13 @@
class: className = '',
onClear = undefined,
excludedValues = undefined,
datatableAsPgResource = false
datatableAsPgResource = false,
workspace = undefined,
disableChatOffset = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
if (initialValue && value == undefined) {
value = initialValue
}
@@ -104,7 +110,7 @@
const resources = await Promise.all(
resourceTypesToQuery.map((rt) =>
ResourceService.listResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
resourceType: rt
})
)
@@ -122,7 +128,7 @@
if (datatableAsPgResource && resourceType === 'postgresql') {
try {
const datatables = await WorkspaceService.listDataTables({
workspace: $workspaceStore!
workspace: effectiveWorkspace
})
for (const dt of datatables) {
nc.push({
@@ -155,7 +161,7 @@
let previousResourceType = untrack(() => resourceType)
$effect(() => {
$workspaceStore && resourceType
effectiveWorkspace && resourceType
untrack(() => {
if (previousResourceType != resourceType) {
previousResourceType = resourceType
@@ -167,7 +173,7 @@
$effect(() => {
excludedValues
if ($workspaceStore && resourceType && !disabled) {
if (effectiveWorkspace && resourceType && !disabled) {
untrack(() => loadResources(resourceType))
}
})
@@ -186,9 +192,13 @@
}}
bind:this={appConnect}
{expressOAuthSetup}
{workspace}
{disableChatOffset}
/>
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
{disableChatOffset}
on:refresh={async (e) => {
await loadResources(resourceType)
if (e.detail) {
@@ -146,6 +146,7 @@
bind:this={innerComponent}
closeDrawer={handleClose}
showHeaderInfo={false}
{disableChatOffset}
bind:yamlMode
bind:hasUnsavedChanges
bind:hasAnyInvalid
@@ -39,12 +39,14 @@
import TextInput from './text_input/TextInput.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte'
let filter = $state('')
let {
closeDrawer,
showHeaderInfo = true,
disableChatOffset = false,
yamlMode = $bindable(false),
hasUnsavedChanges = $bindable(false),
hasAnyInvalid = $bindable(false)
@@ -236,7 +238,9 @@
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
{#if tab === 'users' && !yamlMode}
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
@@ -1,6 +1,6 @@
<script lang="ts">
import { base } from '$lib/base'
import { displayDate, msToSec } from '$lib/utils'
import { displayDate, msToSec, emptyString } from '$lib/utils'
import { onDestroy } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
@@ -9,7 +9,11 @@
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { CheckCircle2, XCircle } from 'lucide-svelte'
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
flow_status: Record<string, WorkflowStatus>
@@ -18,6 +22,7 @@
result?: any
success?: boolean
autoExpandResult?: boolean
jobId?: string
}
let {
@@ -26,7 +31,8 @@
stepResults = {},
result = undefined,
success = true,
autoExpandResult = false
autoExpandResult = false,
jobId = undefined
}: Props = $props()
let resultExpanded = $state(false)
@@ -114,6 +120,49 @@
}
}
}, 2000)
// Approval action state
let approvalLoading: Record<string, boolean> = $state({})
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
async function handleApprove(key: string, formSchema: any) {
const ws = $workspaceStore
if (!ws || !jobId) return
approvalLoading[key] = true
try {
const payload =
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { payload, approved: true }
})
sendUserToast('Approval submitted')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
} finally {
approvalLoading[key] = false
}
}
let cancelLoading = $state(false)
async function handleCancel() {
const ws = $workspaceStore
if (!ws || !jobId) return
cancelLoading = true
try {
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { approved: false }
})
sendUserToast('Job cancelled')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
} finally {
cancelLoading = false
}
}
</script>
{#if flow_status}
@@ -167,22 +216,74 @@
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
</div>
{:else if isApproval}
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
waiting
{@const selfApprovalDisabled = (v as any).self_approval_disabled === true}
{@const formSchema = (v as any).form?.schema ?? (v as any).form}
{@const hasForm =
formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0}
{@const canApprove = !isDone && jobId}
<div class="w-full px-2 py-1.5 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
waiting
</span>
{#if canApprove}
<div class="ml-auto flex gap-1">
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k]}
onclick={() => handleApprove(k, formSchema)}
>
Approve
</Button>
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k] || cancelLoading}
onclick={() => handleCancel()}
>
Reject
</Button>
</div>
{/if}
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{/if}
</div>
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
Self-approval is disabled but allowed because you are an admin/owner
</div>
{/if}
{#if canApprove && hasForm}
<div class="mt-2 ml-5 max-w-md">
{#if emptyString($enterpriseLicense)}
<Alert
type="warning"
title="Adding a form to the approval page is an EE feature"
/>
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
schema={{
properties: formSchema,
order: Object.keys(formSchema)
}}
bind:args={approvalFormArgs[k]}
/>
{/if}
</div>
{/if}
</div>
{:else}
@@ -275,13 +376,13 @@
{@const result = stepResults[stepKey(k)]}
{#if isDone && result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={result} pureViewer />
</div>
</div>
{:else}
<div class="text-xs text-secondary py-1">Step completed (no result)</div>
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
{/if}
{:else if loadingJobs[k] && !childJobs[k]}
<div class="flex items-center gap-2 text-xs text-secondary py-1">
@@ -293,7 +394,7 @@
<!-- Logs -->
{#if job.logs || isRunning}
<div class="mb-2">
<div class="text-2xs text-secondary font-semibold mb-1">Logs</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
<LogViewer
content={job.logs ?? ''}
jobId={k}
@@ -309,7 +410,7 @@
<!-- Result -->
{#if isDone && job.result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={job.result} pureViewer />
</div>
@@ -7,6 +7,7 @@
interface Props {
disabled?: boolean
apiKey?: string | undefined
workspace?: string | undefined
resourcePath?: string | undefined
aiProvider: AIProvider
model: string
@@ -15,6 +16,7 @@
let {
disabled = false,
apiKey = undefined,
workspace = undefined,
resourcePath = undefined,
aiProvider,
model
@@ -38,6 +40,7 @@
await testKey({
apiKey,
workspace,
resourcePath,
messages: [
{
@@ -23,8 +23,7 @@ import {
} from './shared'
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
ChatCompletionSystemMessageParam
} from 'openai/resources/chat/completions.mjs'
import {
prepareInlineChatSystemPrompt,
@@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools'
import { prepareScriptUserMessage } from './script/core'
import { prepareNavigatorUserMessage } from './navigator/core'
import { sendUserToast } from '$lib/toast'
import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib'
import { getModelContextWindow, workspaceAIClients } from '../lib'
import { dfs } from '$lib/components/flows/previousResults'
import { getStringError } from './utils'
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
@@ -56,8 +55,7 @@ import type {
import type { Selection } from 'monaco-editor'
import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import { runChatLoop } from './chatLoop'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
@@ -413,130 +411,63 @@ class AIChatManager {
systemMessage?: ChatCompletionSystemMessageParam
}) => {
try {
let addedMessages: ChatCompletionMessageParam[] = []
while (true) {
const systemMessage = systemMessageOverride ?? this.systemMessage
const helpers = this.helpers
const tools = this.tools
for (const tool of tools) {
if (tool.setSchema) {
await tool.setSchema(helpers)
}
}
let pendingPrompt = this.pendingPrompt
let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined
if (pendingPrompt) {
// Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider
// on each iteration. This is critical for changeModeTool (Navigator → Script/Flow)
// which reassigns this.tools, this.helpers, this.systemMessage mid-loop.
const self = this
const result = await runChatLoop({
messages,
get systemMessage() {
return systemMessageOverride ?? self.systemMessage
},
get tools() {
return self.tools
},
get helpers() {
return self.helpers
},
abortController,
callbacks,
get modelProvider() {
return getCurrentModel()
},
clients: {
openai: workspaceAIClients.getOpenaiClient(),
anthropic: workspaceAIClients.getAnthropicClient()
},
workspace: get(workspaceStore) ?? '',
skipResponsesApi: this.skipResponsesApi,
onSkipResponsesApi: () => {
this.skipResponsesApi = true
},
getPendingUserMessage: () => {
const pendingPrompt = this.pendingPrompt
if (!pendingPrompt) return undefined
this.pendingPrompt = ''
if (this.mode === AIMode.SCRIPT) {
pendingUserMessage = prepareScriptUserMessage(
return prepareScriptUserMessage(
pendingPrompt,
this.contextManager.getSelectedContext()
)
} else if (this.mode === AIMode.FLOW) {
pendingUserMessage = prepareFlowUserMessage(
return prepareFlowUserMessage(
pendingPrompt,
this.flowAiChatHelpers!.getFlowAndSelectedId()
)
} else if (this.mode === AIMode.NAVIGATOR) {
pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt)
return prepareNavigatorUserMessage(pendingPrompt)
}
this.pendingPrompt = ''
}
const model = getCurrentModel()
const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai'
const isAnthropic = model.provider === 'anthropic'
const messageParams = [
systemMessage,
...messages,
...(pendingUserMessage ? [pendingUserMessage] : [])
]
const toolDefs = tools.map((t) => t.def)
// For OpenAI/Azure, try Responses API first, fallback to Completions API
if (isOpenAI) {
let useCompletionsApi = this.skipResponsesApi
if (!this.skipResponsesApi) {
try {
const completion = await getOpenAIResponsesCompletion(
messageParams,
abortController,
toolDefs
)
const continueCompletion = await parseOpenAIResponsesCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers
)
if (!continueCompletion) {
break
}
} catch (err) {
console.warn('OpenAI Responses API failed, falling back to Completions API:', err)
// If the error indicates Responses API is not available in this region, skip it for future requests
const errorMessage = err instanceof Error ? err.message : String(err)
if (errorMessage.includes('Responses API is not enabled')) {
this.skipResponsesApi = true
}
useCompletionsApi = true
}
}
// Use Completions API if Responses API is not available or failed
if (useCompletionsApi) {
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceCompletions: true
})
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers
)
if (!continueCompletion) {
break
}
}
} else if (isAnthropic) {
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs)
if (completion) {
const continueCompletion = await parseAnthropicCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
abortController
)
if (!continueCompletion) {
break
}
}
} else {
const completion = await getCompletion(messageParams, abortController, toolDefs)
if (completion) {
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers
)
if (!continueCompletion) {
break
return undefined
},
onBeforeIteration: async (tools) => {
for (const tool of tools) {
if (tool.setSchema) {
await tool.setSchema(this.helpers)
}
}
}
}
return addedMessages
})
return result.addedMessages
} catch (err) {
console.log('chatRequest error', err)
console.error('chatRequest error', err)
@@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader'
import { dirname, join } from 'path'
// @ts-ignore - Node.js url
import { fileURLToPath } from 'url'
import type { AIProvider } from '$lib/gen/types.gen'
// Get API key from environment - tests will be skipped if not set
// Get API keys from environment - tests will be skipped if none are set
// @ts-ignore
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
// @ts-ignore
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
// Skip all tests if no API key is provided
const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip
const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY
const describeWithApiKey = hasAnyKey ? describe : describe.skip
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o']
// Build model variants based on available keys
interface ModelVariant {
model: string
provider: AIProvider
apiKey: string
}
const MODEL_VARIANTS: ModelVariant[] = [
...(OPENAI_API_KEY
? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }]
: []),
...(ANTHROPIC_API_KEY
? [
{
model: 'claude-haiku-4-5-20241022',
provider: 'anthropic' as AIProvider,
apiKey: ANTHROPIC_API_KEY
}
]
: [])
]
const VARIANTS = [
...MODELS.map((model) => ({
...MODEL_VARIANTS.map((mv) => ({
...BASELINE_VARIANT,
model,
name: `baseline-${model.replace('/', '-')}`
model: mv.model,
name: `baseline-${mv.provider}-${mv.model}`,
_provider: mv.provider,
_apiKey: mv.apiKey
})),
...MODELS.map((model) => ({
...MODEL_VARIANTS.map((mv) => ({
...STREAMLINED_VARIANT,
model,
name: `streamlined-${model.replace('/', '-')}`
model: mv.model,
name: `streamlined-${mv.provider}-${mv.model}`,
_provider: mv.provider,
_apiKey: mv.apiKey
}))
]
describeWithApiKey('App Chat LLM Evaluation', () => {
const TEST_TIMEOUT = 120_000
if (!OPENROUTER_API_KEY) {
console.warn('OPENROUTER_API_KEY is not set, skipping tests')
if (!hasAnyKey) {
console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests')
}
it(
'test1: creates a simple counter app',
async () => {
const USER_PROMPT = `Create a counter app with increment/decrement buttons`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
// Write results to files
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
undefined,
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
@@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
it(
'test2: modifies existing counter app to add reset button',
async () => {
// Load initial app from fixture folder
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
join(__dirname, 'initial', 'test1_counter_app')
)
const USER_PROMPT = `Add a reset button that sets the counter back to 0`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
// Write results to files
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
console.log(`App files: ${appPaths.join(', ')}`)
@@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
)
const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
)
const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
)
const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
)
const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
)
const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialFrontend,
initialBackend
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialFrontend,
initialBackend
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
'test8: create quiz app from scratch',
async () => {
const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
undefined,
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
'test9: create recipe book from scratch',
async () => {
const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
undefined,
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -1,4 +1,4 @@
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type { AppFiles, BackendRunnable } from '../../app/core'
import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared'
import type { EvaluationResult } from '../shared'
@@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}`
/**
* Evaluates how well a generated app fulfills the user's request, considering any initial app state.
* This evaluator does not require an expected reference app - it evaluates based on the request alone.
*
* @param userPrompt The original user request
* @param generatedApp The app generated by the AI
* @param initialApp Optional initial app state (what the app looked like before AI changes)
* @returns Evaluation result with score, statement, and missing requirements
* Uses Anthropic API directly.
*/
export async function evaluateAppGeneration(
userPrompt: string,
@@ -84,9 +79,17 @@ export async function evaluateAppGeneration(
initialApp?: InitialApp
): Promise<EvaluationResult> {
// @ts-ignore
const apiKey = process.env.OPENROUTER_API_KEY
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
return {
success: false,
resemblanceScore: 0,
statement: 'No API key available for evaluation',
error: 'ANTHROPIC_API_KEY not set'
}
}
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
const client = new Anthropic({ apiKey })
let userMessage = `## User's Original Request
${userPrompt}
@@ -117,16 +120,18 @@ Please evaluate how well the generated app:
2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}`
try {
const response = await client.chat.completions.create({
model: 'anthropic/claude-sonnet-4.5',
const response = await client.messages.create({
model: 'claude-sonnet-4-5-20250514',
max_tokens: 2048,
system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT,
messages: [
{ role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
],
temperature: 0
})
const content = response.choices[0]?.message?.content
const textBlock = response.content.find((block) => block.type === 'text')
const content = textBlock?.text
if (!content) {
return {
success: false,
@@ -14,6 +14,7 @@ import {
type VariantDefaults
} from '../shared'
import { writeAppComparisonResultsToFolders } from './appResultsWriter'
import type { AIProvider } from '$lib/gen/types.gen'
// Re-export for convenience
export type { InitialApp } from './appEvalComparison'
@@ -38,6 +39,8 @@ export interface AppEvalOptions {
variant?: VariantConfig
/** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */
evaluateWithLLM?: boolean
/** AI provider (inferred from model name if omitted) */
provider?: AIProvider
}
/**
@@ -49,12 +52,11 @@ const appDefaults: VariantDefaults<AppAIChatHelpers> = {
}
/**
* Runs an app chat evaluation with real OpenAI API calls.
* Executes tool calls using the actual app tools from core.ts or variant-configured tools.
* Runs an app chat evaluation using the shared chat loop (same code path as production).
*/
export async function runAppEval(
userPrompt: string,
openaiApiKey: string,
apiKey: string,
options?: AppEvalOptions
): Promise<AppEvalResult> {
const { helpers, getFiles } = createAppEvalHelpers(
@@ -69,7 +71,7 @@ export async function runAppEval(
appDefaults,
options?.customSystemPrompt
)
const { toolDefs, tools } = resolveTools(options?.variant, appDefaults)
const { tools } = resolveTools(options?.variant, appDefaults)
const model = resolveModel(options?.variant, options?.model)
// Build user message
@@ -80,15 +82,15 @@ export async function runAppEval(
userPrompt,
systemMessage,
userMessage,
toolDefs,
tools,
helpers,
apiKey: openaiApiKey,
apiKey,
getOutput: getFiles,
options: {
maxIterations: options?.maxIterations,
model,
workspace: 'test-workspace'
workspace: 'test-workspace',
provider: options?.provider
}
})
@@ -114,21 +116,32 @@ export async function runAppEval(
}
}
/**
* Per-variant provider override.
*/
export interface VariantProviderOverride {
provider: AIProvider
apiKey: string
}
/**
* Runs the same prompt against multiple variants sequentially for comparison.
* Returns results in the same order as the input variants.
* Accepts optional per-variant provider/apiKey overrides.
*/
export async function runVariantComparison(
userPrompt: string,
variants: VariantConfig[],
openaiApiKey: string,
baseOptions?: Omit<AppEvalOptions, 'variant'>
defaultApiKey: string,
baseOptions?: Omit<AppEvalOptions, 'variant'>,
providerOverrides?: VariantProviderOverride[]
): Promise<AppEvalResult[]> {
const results: AppEvalResult[] = await Promise.all(
variants.map(async (variant) => {
return await runAppEval(userPrompt, openaiApiKey, {
variants.map(async (variant, i) => {
const override = providerOverrides?.[i]
return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, {
...baseOptions,
variant
variant,
provider: override?.provider ?? baseOptions?.provider
})
})
)
@@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json'
// @ts-ignore - JSON import
import initialTest7 from './initial/test7_initial.json'
import type { FlowModule } from '$lib/gen'
import type { AIProvider } from '$lib/gen/types.gen'
// Get API key from environment - tests will be skipped if not set
// Get API keys from environment - tests will be skipped if none are set
// @ts-ignore
// const OPENAI_API_KEY = process.env.OPENAI_API_KEY
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
// @ts-ignore
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
// Skip all tests if no API key is provided
// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip
const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip
const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY
const describeWithApiKey = hasAnyKey ? describe : describe.skip
const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o']
// Build model variants based on available keys
interface ModelVariant {
model: string
provider: AIProvider
apiKey: string
}
const MODEL_VARIANTS: ModelVariant[] = [
...(OPENAI_API_KEY
? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }]
: []),
...(ANTHROPIC_API_KEY
? [
{
model: 'claude-haiku-4-5-20241022',
provider: 'anthropic' as AIProvider,
apiKey: ANTHROPIC_API_KEY
}
]
: [])
]
const VARIANTS = [
...MODELS.map((model) => ({
...MODEL_VARIANTS.map((mv) => ({
...BASELINE_VARIANT,
model,
name: `baseline-${model.replace('/', '-')}`
model: mv.model,
name: `baseline-${mv.provider}-${mv.model}`,
_provider: mv.provider,
_apiKey: mv.apiKey
})),
...MODELS.map((model) => ({
...MODEL_VARIANTS.map((mv) => ({
...MINIMAL_SINGLE_TOOL_VARIANT,
model,
name: `minimal-single-tool-${model.replace('/', '-')}`
model: mv.model,
name: `minimal-single-tool-${mv.provider}-${mv.model}`,
_provider: mv.provider,
_apiKey: mv.apiKey
}))
]
describeWithApiKey('Flow Chat LLM Evaluation', () => {
const TEST_TIMEOUT = 120_000
if (!OPENROUTER_API_KEY) {
console.warn('OPENROUTER_API_KEY is not set, skipping tests')
if (!hasAnyKey) {
console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests')
}
it(
@@ -65,9 +90,15 @@ STEP 3: Loop on all users
STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator
STEP 5: Return action taken for each user
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
expectedFlow: expectedTest1 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
expectedFlow: expectedTest1 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
// Write results to files
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
@@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco
STEP 6: Send confirmation (mock email to customer_email)
STEP 7: Return final order summary with status
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
expectedFlow: expectedTest2 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
expectedFlow: expectedTest2 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -161,9 +198,15 @@ STEP 5: Branch based on quality score:
- If score < 70: Store in quarantine and send alert
STEP 6: Return processing report with statistics (total records, quality score, destination)
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
expectedFlow: expectedTest3 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
expectedFlow: expectedTest3 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce
STEP 4: Log the interaction to audit trail (customer_id, query, response summary)
STEP 5: Return the agent's response and any actions taken
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
expectedFlow: expectedTest4 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
expectedFlow: expectedTest4 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -256,11 +305,17 @@ Modify this existing flow to add error handling:
- If validation passes, return the data for the next step
- Update save_results to handle the validation result appropriately
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialModules: initialTest5.value.modules as FlowModule[],
initialSchema: initialTest5.schema,
expectedFlow: expectedTest5 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialModules: initialTest5.value.modules as FlowModule[],
initialSchema: initialTest5.schema,
expectedFlow: expectedTest5 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types:
- Move the original process_order step to the default branch for unknown order types
- Each branch step should return the orderId, shipping cost, and shipping type
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialModules: initialTest6.value.modules as FlowModule[],
initialSchema: initialTest6.schema,
expectedFlow: expectedTest6 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialModules: initialTest6.value.modules as FlowModule[],
initialSchema: initialTest6.schema,
expectedFlow: expectedTest6 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps:
- The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag
- Keep get_item as the first step and return_result as the last step unchanged
`
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
initialModules: initialTest7.value.modules as FlowModule[],
initialSchema: initialTest7.schema,
expectedFlow: expectedTest7 as ExpectedFlow
})
const results = await runVariantComparison(
USER_PROMPT,
VARIANTS,
VARIANTS[0]._apiKey,
{
initialModules: initialTest7.value.modules as FlowModule[],
initialSchema: initialTest7.schema,
expectedFlow: expectedTest7 as ExpectedFlow
},
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
)
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
console.log(`\nResults written to: ${summaryPath}`)
@@ -59,14 +59,10 @@ export async function evaluateFlowComparison(
expectedFlow: ExpectedFlow,
userPrompt: string
): Promise<EvaluationResult> {
// @ts-ignore
const apiKey = process.env.OPENROUTER_API_KEY
return evaluateWithLLM({
userPrompt,
generatedOutput: generatedFlow,
expectedOutput: expectedFlow,
evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT,
apiKey
evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT
})
}
@@ -1,4 +1,5 @@
import type { FlowModule } from '$lib/gen'
import type { AIProvider } from '$lib/gen/types.gen'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core'
import { createFlowEvalHelpers } from './flowEvalHelpers'
@@ -38,6 +39,8 @@ export interface FlowEvalOptions {
maxIterations?: number
variant?: VariantConfig
expectedFlow?: ExpectedFlow
/** AI provider (inferred from model name if omitted) */
provider?: AIProvider
}
/**
@@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults<FlowAIChatHelpers> = {
}
/**
* Runs a flow chat evaluation with real OpenAI API calls.
* Executes tool calls using the actual flowTools from core.ts or variant-configured tools.
* Runs a flow chat evaluation using the shared chat loop (same code path as production).
*/
export async function runFlowEval(
userPrompt: string,
openaiApiKey: string,
apiKey: string,
options?: FlowEvalOptions
): Promise<FlowEvalResult> {
const { helpers, getFlow } = createFlowEvalHelpers(
@@ -65,7 +67,7 @@ export async function runFlowEval(
// Resolve variant configuration
const variantName = options?.variant?.name ?? 'baseline'
const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt)
const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults)
const { tools } = resolveTools(options?.variant, flowDefaults)
const model = resolveModel(options?.variant, options?.model)
// Build user message
@@ -76,15 +78,15 @@ export async function runFlowEval(
userPrompt,
systemMessage,
userMessage,
toolDefs,
tools,
helpers,
apiKey: openaiApiKey,
apiKey,
getOutput: getFlow,
options: {
maxIterations: options?.maxIterations,
model,
workspace: 'test-workspace'
workspace: 'test-workspace',
provider: options?.provider
}
})
@@ -111,21 +113,32 @@ export async function runFlowEval(
}
}
/**
* Per-variant provider override.
*/
export interface VariantProviderOverride {
provider: AIProvider
apiKey: string
}
/**
* Runs the same prompt against multiple variants sequentially for comparison.
* Returns results in the same order as the input variants.
* Accepts optional per-variant provider/apiKey overrides.
*/
export async function runVariantComparison(
userPrompt: string,
variants: VariantConfig[],
openaiApiKey: string,
baseOptions?: Omit<FlowEvalOptions, 'variant'>
defaultApiKey: string,
baseOptions?: Omit<FlowEvalOptions, 'variant'>,
providerOverrides?: VariantProviderOverride[]
): Promise<FlowEvalResult[]> {
const results: FlowEvalResult[] = await Promise.all(
variants.map(async (variant) => {
return await runFlowEval(userPrompt, openaiApiKey, {
variants.map(async (variant, i) => {
const override = providerOverrides?.[i]
return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, {
...baseOptions,
variant
variant,
provider: override?.provider ?? baseOptions?.provider
})
})
)
@@ -1,8 +1,14 @@
import OpenAI, { APIError } from 'openai'
import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs'
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam
} from 'openai/resources/chat/completions.mjs'
import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen'
import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types'
import type { Tool } from './baseVariants'
import { runChatLoop, type ChatClients } from '../../chatLoop'
import type { Tool as ProductionTool, ToolCallbacks } from '../../shared'
/**
* Result from a single eval run (before domain-specific evaluation).
@@ -29,13 +35,13 @@ export interface RunEvalParams<THelpers, TOutput> {
systemMessage: ChatCompletionSystemMessageParam
/** User message for the LLM */
userMessage: ChatCompletionMessageParam
/** Tool definitions for the LLM API */
toolDefs: ChatCompletionTool[]
/** Tool definitions for the LLM API (unused — derived from tools) */
toolDefs?: unknown
/** Full tool implementations for execution */
tools: Tool<THelpers>[]
/** Domain-specific helpers for tool execution */
helpers: THelpers
/** API key for OpenRouter */
/** API key for the provider */
apiKey: string
/** Function to get the current output state */
getOutput: () => TOutput
@@ -44,10 +50,37 @@ export interface RunEvalParams<THelpers, TOutput> {
}
/**
* Runs a generic evaluation with real LLM API calls.
* Executes tool calls in a loop until the LLM stops calling tools.
*
* This is the core execution loop shared across all chat eval tests.
* Creates SDK clients for the given provider.
*/
function createEvalClients(provider: AIProvider, apiKey: string): ChatClients {
if (provider === 'anthropic') {
return {
openai: new OpenAI({ apiKey: 'unused' }),
anthropic: new Anthropic({ apiKey })
}
}
return {
openai: new OpenAI({ apiKey }),
anthropic: new Anthropic({ apiKey: 'unused' })
}
}
/**
* Resolves model string to AIProviderModel.
*/
function resolveModelProvider(
model: string,
provider?: AIProvider
): AIProviderModel {
if (provider) return { provider, model }
if (model.startsWith('claude')) return { provider: 'anthropic', model }
if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model }
return { provider: 'openai', model }
}
/**
* Runs a generic evaluation using the shared chat loop (same code path as production).
* Uses streaming via real provider SDKs instead of OpenRouter non-streaming.
*/
export async function runEval<THelpers, TOutput>(
params: RunEvalParams<THelpers, TOutput>
@@ -55,7 +88,6 @@ export async function runEval<THelpers, TOutput>(
const {
systemMessage,
userMessage,
toolDefs,
tools,
helpers,
apiKey,
@@ -63,134 +95,82 @@ export async function runEval<THelpers, TOutput>(
options
} = params
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
const model = options?.model ?? 'gpt-4o'
const maxIterations = options?.maxIterations ?? 20
const workspace = options?.workspace ?? 'test-workspace'
const provider = options?.provider
const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage]
const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 }
const modelProvider = resolveModelProvider(model, provider)
const clients = createEvalClients(modelProvider.provider, apiKey)
const messages: ChatCompletionMessageParam[] = [userMessage]
let toolCallsCount = 0
const toolsCalled: string[] = []
const toolCallDetails: ToolCallDetail[] = []
let iterations = 0
// No-op tool callbacks for eval
const toolCallbacks = {
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
toolCallsCount++
toolsCalled.push(tool.def.function.name)
try {
const args =
typeof p.args === 'string' ? JSON.parse(p.args) : p.args
toolCallDetails.push({ name: tool.def.function.name, arguments: args })
} catch {
toolCallDetails.push({
name: tool.def.function.name,
arguments: p.args
})
}
return tool.fn(p)
}
})) as ProductionTool<THelpers>[]
// No-op callbacks for eval
const callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
} = {
setToolStatus: () => {},
removeToolStatus: () => {}
removeToolStatus: () => {},
onNewToken: () => {},
onMessageEnd: () => {}
}
const abortController = new AbortController()
try {
// Tool resolution loop
while (iterations < maxIterations) {
iterations++
const response = await client.chat.completions.create({
model,
messages,
tools: toolDefs,
temperature: 0
})
// Track token usage
if (response.usage) {
totalTokens.prompt += response.usage.prompt_tokens
totalTokens.completion += response.usage.completion_tokens
totalTokens.total += response.usage.total_tokens
}
if (!response.choices.length) {
throw new Error('No response from API')
}
const choice = response.choices[0]
const assistantMessage = choice.message
// Add assistant message to history
messages.push(assistantMessage)
// If no tool calls, we're done
if (!assistantMessage.tool_calls?.length) {
break
}
// Execute each tool call
for (const toolCall of assistantMessage.tool_calls) {
toolCallsCount++
// Type guard: only handle function tool calls
if (toolCall.type !== 'function') {
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: `Unsupported tool type: ${toolCall.type}`
})
continue
}
toolsCalled.push(toolCall.function.name)
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
if (!tool) {
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: `Unknown tool: ${toolCall.function.name}`
})
continue
}
try {
const args = JSON.parse(toolCall.function.arguments)
toolCallDetails.push({ name: toolCall.function.name, arguments: args })
const result = await tool.fn({
args,
workspace,
helpers,
toolCallbacks,
toolId: toolCall.id
})
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result
})
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: `Error: ${errorMessage}`
})
}
}
}
const result = await runChatLoop({
messages,
systemMessage,
tools: wrappedTools,
helpers,
abortController,
callbacks,
modelProvider,
clients,
workspace,
maxIterations,
skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai'
})
return {
success: true,
output: getOutput(),
tokenUsage: totalTokens,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations,
iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length),
messages
}
} catch (err) {
// Build detailed error message
let errorMessage: string
if (err instanceof APIError) {
const details: string[] = [`${err.status} ${err.message}`]
if (err.code) details.push(`Code: ${err.code}`)
if (err.type) details.push(`Type: ${err.type}`)
if (err.param) details.push(`Param: ${err.param}`)
if (err.requestID) details.push(`Request ID: ${err.requestID}`)
if (err.error && typeof err.error === 'object') {
details.push(`Response: ${JSON.stringify(err.error, null, 2)}`)
}
errorMessage = details.join('\n')
} else if (err instanceof Error) {
if (err instanceof Error) {
errorMessage = err.stack ?? err.message
} else {
errorMessage = String(err)
@@ -200,11 +180,11 @@ export async function runEval<THelpers, TOutput>(
success: false,
output: getOutput(),
error: errorMessage,
tokenUsage: totalTokens,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations,
iterations: 0,
messages
}
}
@@ -1,4 +1,4 @@
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type { EvaluationResult } from './types'
/**
@@ -13,9 +13,9 @@ export interface EvaluateParams {
expectedOutput: unknown
/** Domain-specific system prompt for the evaluator */
evaluatorSystemPrompt: string
/** API key for OpenRouter */
apiKey: string
/** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */
/** Anthropic API key for evaluation */
apiKey?: string
/** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */
model?: string
}
@@ -41,10 +41,7 @@ Score guidelines:
/**
* Evaluates how well a generated output matches an expected output using an LLM.
* Returns a resemblance score (0-100), a qualitative statement, and any missing requirements.
*
* @param params Evaluation parameters including prompts, outputs, and API configuration
* @returns Evaluation result with score, statement, and missing requirements
* Uses Anthropic API directly instead of OpenRouter.
*/
export async function evaluateWithLLM(params: EvaluateParams): Promise<EvaluationResult> {
const {
@@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise<Evaluatio
expectedOutput,
evaluatorSystemPrompt,
apiKey,
model = 'anthropic/claude-sonnet-4.5'
model = 'claude-sonnet-4-5-20250514'
} = params
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
// @ts-ignore - process.env
const anthropicKey = apiKey ?? process.env.ANTHROPIC_API_KEY
if (!anthropicKey) {
return {
success: false,
resemblanceScore: 0,
statement: 'No API key available for evaluation',
error: 'ANTHROPIC_API_KEY not set and no apiKey provided'
}
}
const client = new Anthropic({ apiKey: anthropicKey })
const userMessage = `## User's Original Request
${userPrompt}
@@ -76,16 +84,18 @@ Please evaluate how well the generated output:
2. Matches the structure and logic of the expected reference output`
try {
const response = await client.chat.completions.create({
const response = await client.messages.create({
model,
max_tokens: 2048,
system: evaluatorSystemPrompt,
messages: [
{ role: 'system', content: evaluatorSystemPrompt },
{ role: 'user', content: userMessage }
],
temperature: 0
})
const content = response.choices[0]?.message?.content
const textBlock = response.content.find((block) => block.type === 'text')
const content = textBlock?.text
if (!content) {
return {
success: false,
@@ -98,7 +108,6 @@ Please evaluate how well the generated output:
// Parse JSON response - handle potential markdown code blocks
let jsonContent = content.trim()
if (jsonContent.startsWith('```')) {
// Remove markdown code block wrapper
jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')
}
@@ -1,4 +1,5 @@
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import type { AIProvider } from '$lib/gen/types.gen'
/**
* Token usage tracking for LLM calls.
@@ -83,6 +84,8 @@ export interface EvalRunnerOptions {
model?: string
/** Workspace ID for tool calls */
workspace?: string
/** AI provider (inferred from model name if omitted) */
provider?: AIProvider
}
/**
@@ -1,4 +1,5 @@
import { OpenAI } from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionMessageFunctionToolCall
@@ -13,19 +14,28 @@ import type {
RawMessageStreamEvent
} from '@anthropic-ai/sdk/resources'
import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'
import type { AIProviderModel } from '$lib/gen'
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
export async function getAnthropicCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[]
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[],
options?: {
forceModelProvider?: AIProviderModel
anthropicClient?: Anthropic
}
): Promise<MessageStream> {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true })
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: true,
forceModelProvider: options?.forceModelProvider
})
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
const anthropicTools = convertOpenAIToolsToAnthropic(tools)
const anthropicClient = workspaceAIClients.getAnthropicClient()
const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient()
const anthropicParams = {
model: config.model,
@@ -36,7 +46,7 @@ export async function getAnthropicCompletion(
...(typeof config.temperature === 'number' && { temperature: config.temperature })
}
const stream = anthropicClient.messages.stream(anthropicParams, {
const stream = client.messages.stream(anthropicParams, {
signal: abortController.signal,
headers: {
'X-Provider': provider,
@@ -58,7 +68,8 @@ export async function parseAnthropicCompletion(
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any,
abortController?: AbortController
abortController?: AbortController,
options?: { workspace?: string }
): Promise<boolean> {
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
let error = null
@@ -209,7 +220,8 @@ export async function parseAnthropicCompletion(
tools,
toolCall,
helpers,
toolCallbacks: callbacks
toolCallbacks: callbacks,
workspace: options?.workspace
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -0,0 +1,211 @@
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import type { AIProviderModel } from '$lib/gen'
import { getCompletion, parseOpenAICompletion } from '../lib'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import {
getOpenAIResponsesCompletion,
parseOpenAIResponsesCompletion
} from './openai-responses'
import type { Tool, ToolCallbacks } from './shared'
export interface ChatClients {
openai: OpenAI
anthropic: Anthropic
}
export interface ChatLoopConfig {
messages: ChatCompletionMessageParam[]
/**
* System message, tools, helpers, and modelProvider are re-read from this config
* on every iteration. Callers can use JS getters to provide dynamic values
* (e.g. AIChatManager uses getters so mode changes mid-loop take effect).
*/
systemMessage: ChatCompletionSystemMessageParam
tools: Tool<any>[]
helpers: any
abortController: AbortController
callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
}
modelProvider: AIProviderModel
clients: ChatClients
workspace: string
/** Maximum iterations for the loop. undefined = unlimited (production). */
maxIterations?: number
skipResponsesApi?: boolean
onSkipResponsesApi?: () => void
/** Return a pending user message to inject between iterations, or undefined. */
getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined
/** Called before each iteration (e.g. to refresh tool schemas). */
onBeforeIteration?: (tools: Tool<any>[], helpers: any) => Promise<void>
}
export interface ChatLoopResult {
addedMessages: ChatCompletionMessageParam[]
}
export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResult> {
const {
messages,
abortController,
callbacks,
clients,
workspace,
maxIterations,
onSkipResponsesApi,
getPendingUserMessage,
onBeforeIteration
} = config
let skipResponsesApi = config.skipResponsesApi ?? false
const addedMessages: ChatCompletionMessageParam[] = []
let iterations = 0
while (true) {
if (maxIterations !== undefined && iterations >= maxIterations) {
break
}
iterations++
// Re-read these from config each iteration so that mode changes
// (e.g. changeModeTool in Navigator) take effect immediately.
// Callers can use JS getter properties to provide dynamic values.
const tools = config.tools
const helpers = config.helpers
const systemMessage = config.systemMessage
const modelProvider = config.modelProvider
if (onBeforeIteration) {
await onBeforeIteration(tools, helpers)
}
const pendingUserMessage = getPendingUserMessage?.()
const isOpenAI =
modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai'
const isAnthropic = modelProvider.provider === 'anthropic'
const messageParams = [
systemMessage,
...messages,
...(pendingUserMessage ? [pendingUserMessage] : [])
]
const toolDefs = tools.map((t) => t.def)
const parseOptions = { workspace }
if (isOpenAI) {
let useCompletionsApi = skipResponsesApi
if (!skipResponsesApi) {
try {
const completion = await getOpenAIResponsesCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
openaiClient: clients.openai
}
)
const continueCompletion = await parseOpenAIResponsesCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
parseOptions
)
if (!continueCompletion) {
break
}
} catch (err) {
console.warn(
'OpenAI Responses API failed, falling back to Completions API:',
err
)
const errorMessage = err instanceof Error ? err.message : String(err)
if (errorMessage.includes('Responses API is not enabled')) {
skipResponsesApi = true
onSkipResponsesApi?.()
}
useCompletionsApi = true
}
}
if (useCompletionsApi) {
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceCompletions: true,
forceModelProvider: modelProvider,
openaiClient: clients.openai
})
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
undefined,
parseOptions
)
if (!continueCompletion) {
break
}
}
} else if (isAnthropic) {
const completion = await getAnthropicCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic
}
)
if (completion) {
const continueCompletion = await parseAnthropicCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
abortController,
parseOptions
)
if (!continueCompletion) {
break
}
}
} else {
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceModelProvider: modelProvider,
openaiClient: clients.openai
})
if (completion) {
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
undefined,
parseOptions
)
if (!continueCompletion) {
break
}
}
}
}
return { addedMessages }
}
@@ -5,10 +5,14 @@ import type {
ChatCompletionCreateParams
} from 'openai/resources/index.mjs'
import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs'
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
import {
createOpenAIProxyClient,
getAiProxyBaseURL,
getProviderAndCompletionConfig,
workspaceAIClients
} from '../lib'
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs'
import { OpenAPI } from '$lib/gen'
import type { AIProviderModel } from '$lib/gen'
// Conversion utilities for Responses API
@@ -121,15 +125,24 @@ function convertCompletionConfigToResponsesConfig(
export async function getOpenAIResponsesCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
options?: {
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
}
) {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: true,
tools,
forceModelProvider: options?.forceModelProvider
})
const { instructions, input } = convertMessagesToResponsesInput(messages)
const responsesConfig = convertCompletionConfigToResponsesConfig(config)
const openaiClient = workspaceAIClients.getOpenaiClient()
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const runner = openaiClient.responses.stream(
const runner = client.responses.stream(
{
...responsesConfig,
input,
@@ -204,7 +217,8 @@ export async function parseOpenAIResponsesCompletion(
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any
helpers: any,
options?: { workspace?: string }
): Promise<boolean> {
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
let error: OpenAIError | ResponseErrorEvent | null = null
@@ -338,7 +352,8 @@ export async function parseOpenAIResponsesCompletion(
tools,
toolCall,
helpers,
toolCallbacks: callbacks
toolCallbacks: callbacks,
workspace: options?.workspace
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -354,6 +369,7 @@ export async function getNonStreamingOpenAIResponsesCompletion(
abortController: AbortController,
testOptions?: {
apiKey?: string
workspace?: string
resourcePath?: string
forceModelProvider: AIProviderModel
}
@@ -390,15 +406,10 @@ export async function getNonStreamingOpenAIResponsesCompletion(
}
const openaiClient = testOptions?.apiKey
? new OpenAI({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
const response = await openaiClient.responses.create(
{
@@ -417,12 +417,14 @@ export async function processToolCall<T>({
tools,
toolCall,
helpers,
toolCallbacks
toolCallbacks,
workspace
}: {
tools: Tool<T>[]
toolCall: ChatCompletionMessageFunctionToolCall
helpers: T
toolCallbacks: ToolCallbacks
workspace?: string
}): Promise<ChatCompletionMessageParam> {
try {
const args = JSON.parse(toolCall.function.arguments || '{}')
@@ -472,7 +474,7 @@ export async function processToolCall<T>({
tools,
functionName: toolCall.function.name,
args,
workspace: get(workspaceStore) ?? '',
workspace: workspace ?? get(workspaceStore) ?? '',
helpers,
toolCallbacks,
toolId: toolCall.id
+74 -45
View File
@@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
},
googleai: {
label: 'Google AI',
defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite']
defaultModels: [
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-2.5-flash-lite',
'gemini-3-flash',
'gemini-3.1-pro',
'gemini-3.1-flash-lite'
]
},
groq: {
label: 'Groq',
@@ -289,7 +296,12 @@ function getModelSpecificConfig(
) {
const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
const modelKey = `${modelProvider.provider}:${modelProvider.model}`
const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
let customMaxTokensStore: Record<string, number> | undefined
try {
customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
} catch {
// copilotInfo store may not be initialized in vitest
}
const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
if (
(modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') &&
@@ -364,38 +376,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCr
aws_bedrock: DEFAULT_COMPLETION_CONFIG
} as const
export function getAiProxyBaseURL(workspace?: string): string {
return workspace
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
}
export function createOpenAIProxyClient(baseURL: string): OpenAI {
return new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
export function createAnthropicProxyClient(baseURL: string): Anthropic {
return new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
this.openaiClient = this.createOpenaiClient(workspace)
this.anthropicClient = this.createAnthropicClient(workspace)
}
private getBaseURL(workspace: string) {
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
createOpenaiClient(workspace: string): OpenAI {
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
}
private initOpenai(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.openaiClient = new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
createAnthropicClient(workspace: string): Anthropic {
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
}
getOpenaiClient() {
@@ -417,6 +437,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
export async function testKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
@@ -424,6 +445,7 @@ export async function testKey({
aiProvider
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string | undefined
messages: ChatCompletionMessageParam[]
@@ -443,6 +465,7 @@ export async function testKey({
if (aiProvider === 'anthropic') {
await testAnthropicKey({
apiKey,
workspace,
resourcePath,
model: modelToTest,
abortController,
@@ -453,6 +476,7 @@ export async function testKey({
await getNonStreamingCompletion(messages, abortController, {
apiKey,
workspace,
resourcePath,
forceModelProvider: {
model: modelToTest,
@@ -463,12 +487,14 @@ export async function testKey({
async function testAnthropicKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
messages
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string
abortController: AbortController
@@ -489,12 +515,10 @@ async function testAnthropicKey({
}
const anthropicClient = apiKey
? new Anthropic({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getAnthropicClient()
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
await anthropicClient.messages.create(
{
@@ -719,6 +743,7 @@ export async function getNonStreamingCompletion(
testOptions?: {
apiKey?: string // testing API KEY using the global ai proxy
resourcePath?: string // testing resource path passed as a header to the backend proxy
workspace?: string // use a specific workspace proxy when testing a workspace resource
forceModelProvider: AIProviderModel
}
) {
@@ -768,15 +793,10 @@ export async function getNonStreamingCompletion(
}
}
const openaiClient = testOptions?.apiKey
? new OpenAI({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getOpenaiClient()
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices?.[0]?.message.content || ''
@@ -861,9 +881,16 @@ export async function getCompletion(
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
options?: {
forceCompletions?: boolean
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
}
): Promise<Stream<ChatCompletionChunk>> {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: true,
tools,
forceModelProvider: options?.forceModelProvider
})
// Use Responses API for OpenAI and Azure OpenAI
if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) {
@@ -876,8 +903,8 @@ export async function getCompletion(
}
// Use Completions API for other providers
const openaiClient = workspaceAIClients.getOpenaiClient()
const completion = openaiClient.chat.completions.create(config, {
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const completion = client.chat.completions.create(config, {
signal: abortController.signal,
headers: {
'X-Provider': provider
@@ -906,7 +933,8 @@ export async function parseOpenAICompletion(
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any,
_abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion
_abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion
options?: { workspace?: string }
): Promise<boolean> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
let malformedFunctionCallError = false
@@ -1045,7 +1073,8 @@ export async function parseOpenAICompletion(
tools,
toolCall,
helpers,
toolCallbacks: callbacks
toolCallbacks: callbacks,
workspace: options?.workspace
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
@@ -39,27 +39,9 @@
render a cancel button, providing the operator with an option to cancel the step. e.g:
<Tabs selected="bun" class="pt-4">
<Tab value="bun" label="TypeScript (Bun)" />
<Tab value="deno" label="TypeScript (Deno)" />
<Tab value="python" label="Python" />
{#snippet content()}
<TabContent value="deno" class="p-2">
<HighlightCode
language={'deno'}
code={`import * as wmill from "npm:windmill-client@^1.158.2"
export async function main() {
const urls = await wmill.getResumeUrls("approver1")
return {
resume: urls['resume'],
cancel: urls['cancel'],
default_args: {}, // optional, see below
enums: {} // optional, see below
}
}`}
/>
</TabContent>
<TabContent value="bun" class="p-2">
<HighlightCode
language={'deno'}
@@ -1,4 +1,7 @@
import { getContext, setContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type {
GitRepositorySettings as BackendGitRepositorySettings,
@@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) {
}
function addSyncRepository() {
if (!get(enterpriseLicense) && repositories && repositories.length >= 1) {
sendUserToast('Multiple repositories requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
@@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) {
}
function addPromotionRepository() {
if (!get(enterpriseLicense)) {
sendUserToast('Promotion mode requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
@@ -6,12 +6,46 @@
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
// Create context reactively based on workspaceStore
const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null)
// Fetch git sync eligibility
let gitSyncStatus = $state<{
enabled: boolean
reason: string | null
max_repos: number | null
user_count: number | null
max_users: number | null
}>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null })
$effect(() => {
if ($workspaceStore) {
WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore })
.then((status) => {
gitSyncStatus = status as typeof gitSyncStatus
})
.catch(() => {
gitSyncStatus = {
enabled: false,
reason: null,
max_repos: null,
user_count: null,
max_users: null
}
})
}
})
const gitSyncAllowed = $derived(gitSyncStatus.enabled)
const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense)
const hasConfiguredRepos = $derived(
gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false
)
// Load settings when workspace context changes
$effect(() => {
if (gitSyncContext) {
@@ -58,7 +92,7 @@
link="https://www.windmill.dev/docs/advanced/git_sync"
>
{#snippet actions()}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<Button
variant="accent"
target="_blank"
@@ -74,15 +108,26 @@
Only new changes matching the filters will trigger a git sync. You still need to initialize the
repo to the desired state first.
</Alert>
{#if !$enterpriseLicense}
{#if !gitSyncAllowed}
<div class="mb-2"></div>
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
<Alert type={hasConfiguredRepos ? 'error' : 'warning'} title="Git sync disabled">
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
&le; {gitSyncStatus.max_users}. Your workspace has {gitSyncStatus.user_count} members. Settings
below are preserved but sync is inactive until membership is reduced or you upgrade to EE.
</Alert>
<div class="mb-2"></div>
{:else if isFreeTier}
<div class="mb-2"></div>
<Alert type="warning" title="CE Limited Feature">
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
&le; {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple
repositories, promotion mode, and GitHub App authentication.
</Alert>
<div class="mb-2"></div>
{/if}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<!-- Primary Sync Repository -->
<div class="space-y-6 pt-6">
<GitSyncRepositoryCard
@@ -95,120 +140,46 @@
showEmptyState={primarySync?.repo === null}
/>
<!-- Secondary Sync Repositories -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary sync repositories configured
</div>
{:else}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary sync
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
>
+ Add secondary sync repository
</button>
</div>
{/if}
{/if}
{/if}
<!-- Primary Promotion Repository -->
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
<!-- Secondary Promotion Repositories -->
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
{#if $enterpriseLicense}
<!-- Secondary Sync Repositories (EE only) -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondaryPromotionExpanded}
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary promotion repositories ({secondaryPromotion.length})
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondaryPromotionExpanded}
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondaryPromotion.length === 0}
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary promotion repositories configured
No secondary sync repositories configured
</div>
{:else}
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondaryPromotion}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addPromotionRepository()}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary promotion
Add secondary sync
</Button>
</div>
{/if}
@@ -216,23 +187,99 @@
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary promotion repos exist -->
{#if !hasUnsavedSecondaryPromotion}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondaryPromotionExpanded = true
gitSyncContext.addPromotionRepository()
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
>
+ Add secondary promotion repository
+ Add secondary sync repository
</button>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Primary Promotion Repository (EE only) -->
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
<!-- Secondary Promotion Repositories -->
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
>
{#if secondaryPromotionExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary promotion repositories ({secondaryPromotion.length})
</button>
{#if secondaryPromotionExpanded}
<div class="mt-3 space-y-3">
{#if secondaryPromotion.length === 0}
<div class="text-sm text-secondary italic">
No secondary promotion repositories configured
</div>
{:else}
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondaryPromotion}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addPromotionRepository()}
>
Add secondary promotion
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary promotion repos exist -->
{#if !hasUnsavedSecondaryPromotion}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondaryPromotionExpanded = true
gitSyncContext.addPromotionRepository()
}}
>
+ Add secondary promotion repository
</button>
</div>
{/if}
{/if}
{/if}
</div>
{/if}
</div>
<!-- Modals -->
@@ -822,6 +822,17 @@ export const instanceSettingsNavigationGroups = [
}
]
},
{
title: 'AI',
items: [
{
id: 'ai',
label: 'AI',
aiId: 'instance-settings-ai',
aiDescription: 'Instance AI settings (providers, models, prompts)'
}
]
},
{
title: 'Advanced',
items: [
@@ -863,6 +874,7 @@ export const instanceSettingsNavigationGroups = [
export const tabToCategoryMap: Record<string, string> = {
general: 'Core',
ai: 'AI',
sso: 'Auth/OAuth/SAML',
oauth: 'Auth/OAuth/SAML',
scim_saml: 'Auth/OAuth/SAML',
@@ -897,6 +909,7 @@ export const setupNavigationGroups = instanceSettingsNavigationGroups
export const categoryToTabMap: Record<string, string> = {
Core: 'general',
AI: 'ai',
SMTP: 'smtp',
'Auth/OAuth/SAML': 'sso',
Registries: 'registries',
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/common'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { IndexSearchService } from '$lib/gen'
import type { GetIndexerStatusResponse } from '$lib/gen'
import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { displaySize } from '$lib/utils'
import Tooltip from '../Tooltip.svelte'
@@ -24,6 +24,7 @@
let clearServiceLogsIndexModalOpen = $state(false)
let status: GetIndexerStatusResponse | undefined = $state(undefined)
let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined)
let statusLoading = $state(true)
let statusError = $state(false)
@@ -41,9 +42,15 @@
statusLoading = true
statusError = false
try {
status = await IndexSearchService.getIndexerStatus()
const [statusRes, diskRes] = await Promise.all([
IndexSearchService.getIndexerStatus(),
IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined)
])
status = statusRes
diskSizes = diskRes
} catch (e) {
status = undefined
diskSizes = undefined
statusError = true
} finally {
statusLoading = false
@@ -139,7 +146,11 @@
: 'bg-red-500'}"
></span>
<span class="text-primary font-medium">{label}:</span>
<span class="font-semibold {entry?.is_alive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
<span
class="font-semibold {entry?.is_alive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'}"
>
{entry?.is_alive ? 'Running' : 'Stopped'}
</span>
{#if entry?.last_locked_at}
@@ -161,21 +172,21 @@
<div class="flex flex-col gap-1 text-2xs text-tertiary">
<span>
Jobs index:
{#if status.job_indexer?.storage?.disk_size_bytes != null}
Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'}
{#if diskSizes?.job_index_disk_size_bytes != null}
Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'}
{/if}
{#if status.job_indexer?.storage?.s3_size_bytes != null}
{#if status.job_indexer?.storage?.disk_size_bytes != null}&middot;{/if}
{#if diskSizes?.job_index_disk_size_bytes != null}&middot;{/if}
S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'}
{/if}
</span>
<span>
Service logs index:
{#if status.log_indexer?.storage?.disk_size_bytes != null}
Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'}
{#if diskSizes?.log_index_disk_size_bytes != null}
Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'}
{/if}
{#if status.log_indexer?.storage?.s3_size_bytes != null}
{#if status.log_indexer?.storage?.disk_size_bytes != null}&middot;{/if}
{#if diskSizes?.log_index_disk_size_bytes != null}&middot;{/if}
S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'}
{/if}
</span>
@@ -0,0 +1,149 @@
<script lang="ts">
import { JobService, SettingService, WorkspaceService, type AIConfig } from '$lib/gen'
import { setCopilotInfo } from '$lib/aiStore'
import { workspaceStore, userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { workspaceAIClients } from '../copilot/lib'
import AISettings from '../workspaceSettings/AISettings.svelte'
import { Alert, Button } from '../common'
interface Props {
hasUnsavedChanges?: boolean
disableChatOffset?: boolean
showHubSync?: boolean
}
let {
hasUnsavedChanges = $bindable(false),
disableChatOffset = false,
showHubSync = false
}: Props = $props()
let initialConfig: AIConfig | undefined = $state(undefined)
let loaded = $state(false)
let aiSettings: AISettings | undefined = $state(undefined)
async function loadConfig() {
try {
initialConfig =
((await SettingService.getGlobal({ key: 'ai_config' })) as AIConfig | undefined) ?? {}
loaded = true
} catch (e) {
console.error('Failed to load instance AI config', e)
sendUserToast('Failed to load instance AI config', true)
}
}
async function handleCustomSave(config: AIConfig) {
const hasProviders = Object.keys(config.providers ?? {}).length > 0
await SettingService.setGlobal({
key: 'ai_config',
requestBody: { value: hasProviders ? config : null }
})
if ($workspaceStore) {
try {
const effectiveConfig = await WorkspaceService.getCopilotInfo({
workspace: $workspaceStore
})
setCopilotInfo(effectiveConfig)
} catch (e) {
console.error('Failed to refresh workspace AI state after instance save', e)
}
}
sendUserToast('Instance AI settings saved')
}
export async function persistBeforeExit(): Promise<boolean> {
return (await aiSettings?.saveIfDirtyAndValid()) ?? true
}
// Ensure stores are set (this page may bypass the (logged) layout)
async function ensureStores() {
if (!$workspaceStore) {
$workspaceStore = 'admins'
}
if (!$userStore) {
$userStore = await getUserExt($workspaceStore)
}
workspaceAIClients.init($workspaceStore)
}
ensureStores()
loadConfig()
// --- Hub sync ---
let hubSyncStatus: 'idle' | 'loading' | 'success' | 'error' = $state('idle')
let hubSyncMessage = $state('')
async function syncFromHub() {
hubSyncStatus = 'loading'
hubSyncMessage = ''
try {
await JobService.runWaitResultScriptByPath({
workspace: 'admins',
path: 'u/admin/hub_sync',
requestBody: {}
})
hubSyncStatus = 'success'
hubSyncMessage = 'Resource types synced from hub successfully'
} catch (e: any) {
hubSyncMessage =
e?.body?.error?.message ||
e?.body?.message ||
(typeof e?.body === 'string' ? e.body : null) ||
e?.message ||
'Failed to sync from hub'
hubSyncStatus = 'error'
}
}
</script>
{#if loaded}
{#if showHubSync}
<div
class="p-3 border rounded-md bg-surface-secondary mb-4 mt-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs font-medium text-secondary">Resource types</p>
<p class="text-2xs text-tertiary mt-0.5">
AI providers require their resource types. Sync from the Hub if they are missing.
</p>
</div>
<Button
variant="default"
unifiedSize="sm"
loading={hubSyncStatus === 'loading'}
onClick={syncFromHub}
>
Sync from hub
</Button>
</div>
{#if hubSyncStatus === 'success'}
<div class="mb-4">
<Alert type="success" title="Resource types synced">
{hubSyncMessage}
</Alert>
</div>
{:else if hubSyncStatus === 'error'}
<div class="mb-4">
<Alert type="error" title="Sync failed">
{hubSyncMessage}
</Alert>
</div>
{/if}
{/if}
<AISettings
bind:this={aiSettings}
bind:hasUnsavedChanges
{initialConfig}
workspace="admins"
{disableChatOffset}
title="Windmill AI"
description="Windmill AI integrates with your favorite AI providers and models. Set your AI settings at the instance level to be able to use them on all your workspaces. Workspace-level settings can override these."
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
promptScope="instance"
customSave={handleCustomSave}
/>
{/if}
@@ -163,6 +163,7 @@
stepResults={getStepResults(job.workflow_as_code_status)}
result={(job as any).result}
success={(job as any).success !== false}
jobId={job.id}
/>
</div>
{/if}
@@ -158,6 +158,7 @@
result={previewJob?.result}
success={previewJob?.success !== false}
autoExpandResult
jobId={previewJob?.id}
/>
</div>
{:else}
@@ -13,7 +13,7 @@
onSave?: () => void
onReset: () => void
hasChanges: boolean
isWorkspaceSettings?: boolean
scope?: 'user' | 'workspace' | 'instance'
}
let {
@@ -22,7 +22,7 @@
onSave,
onReset,
hasChanges,
isWorkspaceSettings = false
scope = 'user'
}: Props = $props()
const placeholders: Record<AIMode, string> = {
@@ -63,9 +63,12 @@
<div class="flex flex-col gap-6 h-full px-1 w-full">
<div class="grow min-h-0 overflow-y-auto" style="scrollbar-gutter: stable;">
<div class="text-xs text-secondary mb-6">
{#if isWorkspaceSettings}
{#if scope === 'workspace'}
Customize the system prompts for each AI mode. These prompts apply to all workspace
members.
{:else if scope === 'instance'}
Customize the system prompts for each AI mode. These prompts apply to workspaces using
instance AI defaults.
{:else}
Customize the system prompts for each AI mode. These prompts are stored locally in your
browser and apply in addition to workspace-level prompts.
@@ -1,5 +1,12 @@
<script lang="ts">
import { WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
import {
ResourceService,
WorkspaceService,
type AIConfig,
type AIProvider,
type GetCopilotSettingsStateResponse,
type InstanceAISummary
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib'
@@ -19,32 +26,139 @@
import { setCopilotInfo } from '$lib/aiStore'
import AIPromptsModal from '../settings/AIPromptsModal.svelte'
import { Settings } from 'lucide-svelte'
import { untrack } from 'svelte'
import { slide } from 'svelte/transition'
import SettingsFooter from './SettingsFooter.svelte'
import InstanceFallbackSettings from './InstanceFallbackSettings.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
let {
aiProviders = $bindable(),
codeCompletionModel = $bindable(),
defaultModel = $bindable(),
customPrompts = $bindable(),
maxTokensPerModel = $bindable(),
usingOpenaiClientCredentialsOauth = $bindable(),
onSave,
onDiscard,
hasUnsavedChanges = false
initialConfig = undefined,
hasUnsavedChanges = $bindable(false),
workspace = undefined,
disableChatOffset = false,
hasInstanceAiConfig = false,
usesInstanceAiConfig = false,
instanceAiSummary = undefined,
customSave = undefined,
onSave = undefined,
title = 'Windmill AI',
description = 'Windmill AI integrates with your favorite AI providers and models.',
link = 'https://www.windmill.dev/docs/core_concepts/ai_generation',
promptScope = 'workspace'
}: {
aiProviders: Exclude<AIConfig['providers'], undefined>
codeCompletionModel: string | undefined
defaultModel: string | undefined
customPrompts: Record<string, string>
maxTokensPerModel: Record<string, number>
usingOpenaiClientCredentialsOauth: boolean
onSave?: () => void
onDiscard?: () => void
initialConfig?: AIConfig | undefined
hasUnsavedChanges?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
hasInstanceAiConfig?: boolean
usesInstanceAiConfig?: boolean
instanceAiSummary?: InstanceAISummary
customSave?: (config: AIConfig) => Promise<void>
onSave?: (info?: GetCopilotSettingsStateResponse) => void | Promise<void>
title?: string
description?: string
link?: string
promptScope?: 'workspace' | 'instance'
} = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
// --- Internal state ---
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let codeCompletionModel: string | undefined = $state(undefined)
let defaultModel: string | undefined = $state(undefined)
let customPrompts: Record<string, string> = $state({})
let maxTokensPerModel: Record<string, number> = $state({})
let usingOpenaiClientCredentialsOauth = $state(false)
let workspaceOverrideEditorOpened = $state(false)
// --- Initial state for dirty tracking ---
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let initialCodeCompletionModel: string | undefined = $state(undefined)
let initialDefaultModel: string | undefined = $state(undefined)
let initialCustomPrompts: Record<string, string> = $state({})
let initialMaxTokensPerModel: Record<string, number> = $state({})
let initialPrompts: Record<string, string> = $state({})
let lastLoadedConfigKey = $state<string | undefined>(undefined)
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v))
}
function applyConfig(config: AIConfig | undefined) {
aiProviders = clone(config?.providers ?? {})
defaultModel = config?.default_model?.model
codeCompletionModel = config?.code_completion_model?.model
customPrompts = clone(config?.custom_prompts ?? {})
maxTokensPerModel = clone(config?.max_tokens_per_model ?? {})
for (const mode of ['edit', 'fix', 'gen']) {
if (!(mode in customPrompts)) {
customPrompts[mode] = ''
}
}
}
function storeInitialState() {
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
initialPrompts = clone(customPrompts)
}
export function loadFromConfig(config: AIConfig | undefined) {
applyConfig(config)
storeInitialState()
}
export function discard() {
aiProviders = clone(initialAiProviders)
defaultModel = initialDefaultModel
codeCompletionModel = initialCodeCompletionModel
customPrompts = clone(initialCustomPrompts)
maxTokensPerModel = clone(initialMaxTokensPerModel)
}
$effect(() => {
const configKey = JSON.stringify(initialConfig ?? {})
if (configKey === lastLoadedConfigKey) {
return
}
lastLoadedConfigKey = configKey
untrack(() => {
loadFromConfig(initialConfig)
})
})
// Check if openai_client_credentials_oauth resource type exists
async function loadOpenaiOauthFlag() {
try {
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
workspace: effectiveWorkspace,
path: 'openai_client_credentials_oauth'
})
} catch {
usingOpenaiClientCredentialsOauth = false
}
}
loadOpenaiOauthFlag()
// --- Dirty tracking ---
let dirty = $derived(
JSON.stringify(aiProviders) !== JSON.stringify(initialAiProviders) ||
defaultModel !== initialDefaultModel ||
codeCompletionModel !== initialCodeCompletionModel ||
JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) ||
JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel)
)
$effect(() => {
hasUnsavedChanges = dirty
})
// --- Model fetching ---
let fetchedAiModels = $state(false)
let availableAiModels = $state(
Object.fromEntries(
@@ -53,7 +167,6 @@
)
let modalOpen = $state(false)
let initialPrompts = $state($state.snapshot(customPrompts))
let hasPromptsChanges = $derived(
Array.from(new Set([...Object.keys(customPrompts), ...Object.keys(initialPrompts)])).some(
(key) => {
@@ -66,6 +179,14 @@
let promptCount = $derived(
Object.values(customPrompts).filter((p) => p?.trim().length > 0).length
)
let promptDescription = $derived(
promptScope === 'instance'
? 'Customize AI behavior with instance-level system prompts. These apply when a workspace uses instance AI defaults.'
: 'Customize AI behavior with workspace-level system prompts. These apply to all workspace members.'
)
let showWorkspaceOverrideEditor = $derived(
!usesInstanceAiConfig || Object.keys(aiProviders).length > 0 || workspaceOverrideEditorOpened
)
let selectedAiModels = $derived(Object.values(aiProviders).flatMap((p) => p.models))
let modelProviderMap = $derived(
@@ -91,7 +212,7 @@
try {
const models = await fetchAvailableModels(
aiProviders[provider].resource_path,
$workspaceStore!,
effectiveWorkspace,
provider as AIProvider
)
availableAiModels[provider] = models
@@ -109,44 +230,74 @@
sendUserToast('Reset to last saved state')
}
async function editCopilotConfig(): Promise<void> {
if (Object.keys(aiProviders ?? {}).length > 0) {
const code_completion_model =
codeCompletionModel && modelProviderMap[codeCompletionModel]
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
: undefined
const default_model =
defaultModel && modelProviderMap[defaultModel]
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
: undefined
// Convert customPrompts to include only non-empty prompts
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
.filter(([_, prompt]) => prompt.trim().length > 0)
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
function buildConfig(): AIConfig {
const code_completion_model =
codeCompletionModel && modelProviderMap[codeCompletionModel]
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
: undefined
const default_model =
defaultModel && modelProviderMap[defaultModel]
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
: undefined
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
.filter(([_, prompt]) => prompt.trim().length > 0)
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
const config: AIConfig = {
providers: aiProviders,
code_completion_model,
default_model,
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
max_tokens_per_model:
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
}
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
return Object.keys(aiProviders ?? {}).length > 0
? {
providers: aiProviders,
code_completion_model,
default_model,
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
max_tokens_per_model:
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
}
: {}
}
function isSaveDisabled(): boolean {
return (
!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)
)
}
export async function saveIfDirtyAndValid(): Promise<boolean> {
if (!dirty) {
return true
}
if (isSaveDisabled()) {
sendUserToast('Complete AI settings before leaving this page', true)
return false
}
await editCopilotConfig()
return true
}
async function editCopilotConfig(): Promise<void> {
const config = buildConfig()
let settingsState: GetCopilotSettingsStateResponse | undefined
if (customSave) {
await customSave(config)
} else {
const response = await WorkspaceService.editCopilotConfig({
workspace: effectiveWorkspace,
requestBody: config
})
setCopilotInfo(config)
} else {
await WorkspaceService.editCopilotConfig({
workspace: $workspaceStore!,
requestBody: {}
})
setCopilotInfo({})
setCopilotInfo(response.effective_ai_config)
settingsState = {
has_instance_ai_config: response.has_instance_ai_config,
uses_instance_ai_config: response.uses_instance_ai_config,
instance_ai_summary: response.instance_ai_summary
}
sendUserToast('AI settings updated')
}
sendUserToast(`AI settings updated`)
initialPrompts = { ...customPrompts } // Update initial prompts after successful save
onSave?.()
storeInitialState()
await onSave?.(settingsState)
}
async function onAiProviderChange(provider: AIProvider) {
@@ -154,7 +305,7 @@
try {
const models = await fetchAvailableModels(
aiProviders[provider].resource_path,
$workspaceStore!,
effectiveWorkspace,
provider as AIProvider
)
availableAiModels[provider] = models
@@ -176,197 +327,214 @@
const autocompleteModels = $derived(selectedAiModels.filter(supportsAutocomplete))
</script>
<SettingsPageHeader
title="Windmill AI"
description="Windmill AI integrates with your favorite AI providers and models."
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
/>
<SettingsPageHeader {title} {description} {link} />
<div class="flex flex-col gap-6 mt-4 pb-8">
<SettingCard label="AI Providers">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<Toggle
options={{
right: details.label
}}
checked={!!aiProviders[provider]}
on:change={(e) => {
if (e.detail) {
aiProviders = {
...aiProviders,
[provider]: {
resource_path: '',
models:
availableAiModels[provider].length > 0
? [availableAiModels[provider][0]]
: []
}
}
if (availableAiModels[provider].length > 0 && !defaultModel) {
defaultModel = availableAiModels[provider][0]
}
} else {
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
if (defaultModel) {
const currentDefaultModel = Object.values(aiProviders).find(
(p) => defaultModel && p.models.includes(defaultModel)
)
if (!currentDefaultModel) {
defaultModel = undefined
}
}
if (codeCompletionModel) {
const currentCodeCompletionModel = Object.values(aiProviders).find(
(p) => codeCompletionModel && p.models.includes(codeCompletionModel)
)
if (!currentCodeCompletionModel) {
codeCompletionModel = undefined
}
}
}
}}
/>
{#if provider === 'anthropic'}
<Badge color="blue">
Recommended
<Tooltip>
Anthropic models handle tool calls better than other providers, which makes them a
better choice for AI chat.
</Tooltip>
</Badge>
{/if}
</div>
{#if aiProviders[provider]}
<div
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
transition:slide|local={{ duration: 150 }}
>
<Label label="Resource">
<div class="flex flex-row gap-1">
<ResourcePicker
selectFirst
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: provider}
initialValue={aiProviders[provider].resource_path}
bind:value={
() => aiProviders[provider].resource_path || undefined,
(v) => {
aiProviders[provider].resource_path = v ?? ''
onAiProviderChange(provider as AIProvider)
{#if usesInstanceAiConfig}
<div
class="p-3 border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-secondary"
>
Instance-level AI settings are currently active. Configure workspace-specific settings below
to override them.
</div>
<InstanceFallbackSettings
{instanceAiSummary}
{showWorkspaceOverrideEditor}
onToggleOverride={() => (workspaceOverrideEditorOpened = !workspaceOverrideEditorOpened)}
/>
{:else if hasInstanceAiConfig && Object.keys(aiProviders).length > 0}
<div
class="p-3 border border-surface-hover bg-surface-secondary rounded-md text-xs text-secondary"
>
Workspace AI settings override instance defaults. Remove workspace settings to use instance
defaults.
</div>
{/if}
{#if showWorkspaceOverrideEditor}
<SettingCard label="AI Providers">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<Toggle
options={{
right: details.label
}}
checked={!!aiProviders[provider]}
on:change={(e) => {
if (e.detail) {
aiProviders = {
...aiProviders,
[provider]: {
resource_path: '',
models:
availableAiModels[provider].length > 0
? [availableAiModels[provider][0]]
: []
}
}
/>
<TestAiKey
aiProvider={provider as AIProvider}
resourcePath={aiProviders[provider].resource_path}
model={aiProviders[provider].models[0]}
/>
</div>
</Label>
<Label label="Enabled models">
<MultiSelect
items={safeSelectItems([
...availableAiModels[provider],
...aiProviders[provider].models
])}
bind:value={aiProviders[provider].models}
placeholder="Select models"
onCreateItem={(item) =>
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
/>
<p class="text-2xs text-hint">
If you don't see the model you want, you can type it manually in the selector.
</p>
</Label>
if (availableAiModels[provider].length > 0 && !defaultModel) {
defaultModel = availableAiModels[provider][0]
}
} else {
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
if (defaultModel) {
const currentDefaultModel = Object.values(aiProviders).find(
(p) => defaultModel && p.models.includes(defaultModel)
)
if (!currentDefaultModel) {
defaultModel = undefined
}
}
if (codeCompletionModel) {
const currentCodeCompletionModel = Object.values(aiProviders).find(
(p) => codeCompletionModel && p.models.includes(codeCompletionModel)
)
if (!currentCodeCompletionModel) {
codeCompletionModel = undefined
}
}
}
}}
/>
{#if provider === 'anthropic'}
<Badge color="blue">
Recommended
<Tooltip>
Anthropic models handle tool calls better than other providers, which makes them
a better choice for AI chat.
</Tooltip>
</Badge>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</SettingCard>
<SettingCard label="Default chat model">
{#key Object.keys(aiProviders).length}
<Select
items={safeSelectItems(selectedAiModels)}
bind:value={defaultModel}
disabled={false}
placeholder="Select a default model"
size="sm"
class="max-w-lg"
/>
{/key}
</SettingCard>
{#if aiProviders[provider]}
<div
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
transition:slide|local={{ duration: 150 }}
>
<Label label="Resource">
<div class="flex flex-row gap-1">
<ResourcePicker
selectFirst
{workspace}
{disableChatOffset}
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
? 'openai_client_credentials_oauth'
: provider}
initialValue={aiProviders[provider].resource_path}
bind:value={
() => aiProviders[provider].resource_path || undefined,
(v) => {
aiProviders[provider].resource_path = v ?? ''
onAiProviderChange(provider as AIProvider)
}
}
/>
<TestAiKey
aiProvider={provider as AIProvider}
workspace={effectiveWorkspace}
resourcePath={aiProviders[provider].resource_path}
model={aiProviders[provider].models[0]}
/>
</div>
</Label>
<!-- Code completion group for animation purposes -->
<div>
<SettingCard label="Code completion">
<Toggle
on:change={(e) => {
if (e.detail) {
codeCompletionModel = autocompleteModels[0] ?? ''
} else {
codeCompletionModel = undefined
}
}}
checked={codeCompletionModel != undefined}
disabled={autocompleteModels.length == 0}
options={{
right: 'Enable code completion',
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
}}
/>
<Label label="Enabled models">
<MultiSelect
items={safeSelectItems([
...availableAiModels[provider],
...aiProviders[provider].models
])}
bind:value={aiProviders[provider].models}
placeholder="Select models"
onCreateItem={(item) =>
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
/>
<p class="text-2xs text-hint">
If you don't see the model you want, you can type it manually in the selector.
</p>
</Label>
</div>
{/if}
</div>
{/each}
</div>
</SettingCard>
{#if codeCompletionModel != undefined}
<div transition:slide|local={{ duration: 150 }} class="mt-6">
<SettingCard label="Code completion model">
<Select
items={safeSelectItems(autocompleteModels)}
bind:value={codeCompletionModel}
disabled={false}
placeholder="Select a code completion model"
size="sm"
/>
</SettingCard>
</div>
{/if}
</div>
<SettingCard label="Default chat model">
{#key Object.keys(aiProviders).length}
<Select
items={safeSelectItems(selectedAiModels)}
bind:value={defaultModel}
disabled={false}
placeholder="Select a default model"
size="sm"
class="max-w-lg"
/>
{/key}
</SettingCard>
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
<!-- Code completion group for animation purposes -->
<div>
<SettingCard label="Code completion">
<Toggle
on:change={(e) => {
if (e.detail) {
codeCompletionModel = autocompleteModels[0] ?? ''
} else {
codeCompletionModel = undefined
}
}}
checked={codeCompletionModel != undefined}
disabled={autocompleteModels.length == 0}
options={{
right: 'Enable code completion',
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
}}
/>
</SettingCard>
<SettingCard
label="Custom system prompts"
description="Customize AI behavior with workspace-level system prompts. These apply to all workspace
members."
>
<div class="flex items-center gap-2 pt-1">
<Button
onclick={() => (modalOpen = true)}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Settings }}
disabled={Object.keys(aiProviders ?? {}).length === 0}
>
Configure AI prompts
</Button>
{#if promptCount > 0}
<span class="text-xs text-secondary">({promptCount} configured)</span>
{/if}
{#if hasPromptsChanges}
<Badge color="yellow">Unsaved changes</Badge>
{#if codeCompletionModel != undefined}
<div transition:slide|local={{ duration: 150 }} class="mt-6">
<SettingCard label="Code completion model">
<Select
items={safeSelectItems(autocompleteModels)}
bind:value={codeCompletionModel}
disabled={false}
placeholder="Select a code completion model"
size="sm"
/>
</SettingCard>
</div>
{/if}
</div>
</SettingCard>
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
<SettingCard label="Custom system prompts" description={promptDescription}>
<div class="flex items-center gap-2 pt-1">
<Button
onclick={() => (modalOpen = true)}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Settings }}
disabled={Object.keys(aiProviders ?? {}).length === 0}
>
Configure AI prompts
</Button>
{#if promptCount > 0}
<span class="text-xs text-secondary">({promptCount} configured)</span>
{/if}
{#if hasPromptsChanges}
<Badge color="yellow">Unsaved changes</Badge>
{/if}
</div>
</SettingCard>
{/if}
</div>
<AIPromptsModal
@@ -374,15 +542,15 @@
bind:customPrompts
onReset={resetPrompts}
hasChanges={hasPromptsChanges}
isWorkspaceSettings={true}
scope={promptScope}
/>
<SettingsFooter
{hasUnsavedChanges}
onSave={editCopilotConfig}
onDiscard={() => onDiscard?.()}
saveLabel="Save AI settings"
disabled={!Object.values(aiProviders).every((p) => p.resource_path) ||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
(Object.keys(aiProviders).length > 0 && !defaultModel)}
/>
{#if showWorkspaceOverrideEditor}
<SettingsFooter
hasUnsavedChanges={dirty}
onSave={editCopilotConfig}
onDiscard={discard}
saveLabel="Save AI settings"
disabled={isSaveDisabled()}
/>
{/if}
@@ -11,7 +11,8 @@
VariableService,
WorkspaceService,
type AIProvider,
type CompletedJob
type CompletedJob,
type GetCopilotInfoResponse
} from '$lib/gen'
import { validateUsername } from '$lib/utils'
import { logoutWithRedirect } from '$lib/logoutKit'
@@ -52,6 +53,10 @@
let aiKey = $state('')
let codeCompletionEnabled = $state(true)
let checking = $state(false)
let createLoading = $state(false)
let aiSetupLoading = $state(false)
let creationStep = $state<'details' | 'ai'>('details')
let createdWorkspaceId: string | undefined = $state(undefined)
let workspaceColor: string | undefined = $state(undefined)
let colorEnabled = $state(false)
@@ -85,6 +90,64 @@
let errorMsgs: string[] = $state([])
let failedSyncJobs: string[] = $state([])
function getErrorMessage(error: any): string {
return (
error?.body?.error?.message ||
error?.body?.message ||
(typeof error?.body === 'string' ? error.body : null) ||
error?.message ||
'Unknown error'
)
}
function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean {
return Object.keys(copilotInfo.providers ?? {}).length > 0
}
async function finishWorkspaceSetup(workspaceId: string): Promise<void> {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(workspaceId)
goto(rd ?? '/')
}
async function getWorkspaceUsername(workspaceId: string): Promise<string> {
if (!automateUsernameCreation) {
return username
}
const user = await UserService.whoami({
workspace: workspaceId
})
return user.username
}
async function maybeShowAiSetupStep(workspaceId: string): Promise<void> {
try {
const copilotInfo = await WorkspaceService.getCopilotInfo({
workspace: workspaceId
})
if (hasEffectiveAi(copilotInfo)) {
await finishWorkspaceSetup(workspaceId)
return
}
} catch (error) {
console.error('Failed to check effective AI configuration for new workspace', error)
sendUserToast(
'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.',
true
)
await finishWorkspaceSetup(workspaceId)
return
}
createdWorkspaceId = workspaceId
creationStep = 'ai'
aiKey = ''
codeCompletionEnabled = true
selected = 'openai'
}
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
let ret: CompletedJob[] = []
for (const job of jobs) {
@@ -188,20 +251,22 @@
forkCreationLoading = false
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
await finishWorkspaceSetup(prefixed_id)
} else {
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
}
} else {
await createWorkspace()
createLoading = true
try {
const workspaceId = await createWorkspace()
await maybeShowAiSetupStep(workspaceId)
} finally {
createLoading = false
}
}
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(isFork ? prefixed_id : id)
goto(rd ?? '/')
}
async function createWorkspace(): Promise<void> {
async function createWorkspace(): Promise<string> {
await WorkspaceService.createWorkspace({
requestBody: {
id,
@@ -216,17 +281,23 @@
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd }
})
}
if (aiKey != '') {
let actualUsername = username
if (automateUsernameCreation) {
const user = await UserService.whoami({
workspace: id
})
actualUsername = user.username
}
let path = `u/${actualUsername}/${selected}_windmill_codegen`
sendUserToast(`Created workspace id: ${id}`)
return id
}
async function saveWorkspaceAiSetup(): Promise<void> {
if (!createdWorkspaceId || !aiKey) {
return
}
aiSetupLoading = true
try {
const actualUsername = await getWorkspaceUsername(createdWorkspaceId)
const path = `u/${actualUsername}/${selected}_windmill_codegen`
await VariableService.createVariable({
workspace: id,
workspace: createdWorkspaceId,
requestBody: {
path,
value: aiKey,
@@ -235,7 +306,7 @@
}
})
await ResourceService.createResource({
workspace: id,
workspace: createdWorkspaceId,
requestBody: {
path,
value: {
@@ -245,40 +316,46 @@
}
})
await WorkspaceService.editCopilotConfig({
workspace: id,
requestBody: aiKey
? {
providers: {
[selected]: {
resource_path: path,
models: [AI_PROVIDERS[selected].defaultModels[0]]
}
},
default_model: {
model: AI_PROVIDERS[selected].defaultModels[0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
: undefined
workspace: createdWorkspaceId,
requestBody: {
providers: {
[selected]: {
resource_path: path,
models: [AI_PROVIDERS[selected].defaultModels[0]]
}
: {}
},
default_model: {
model: AI_PROVIDERS[selected].defaultModels[0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
: undefined
}
})
sendUserToast('Windmill AI configured')
await finishWorkspaceSetup(createdWorkspaceId)
} catch (error) {
sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true)
} finally {
aiSetupLoading = false
}
sendUserToast(`Created workspace id: ${id}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(id)
goto(rd ?? '/')
}
function handleKeyUp(event: KeyboardEvent) {
function handleCreateKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
createWorkspace()
createOrForkWorkspace()
}
}
function handleAiKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter' && aiKey) {
event.preventDefault()
saveWorkspaceAiSetup()
}
}
@@ -329,6 +406,9 @@
let operatorOnly = $state(false)
let autoAdd = $state(true)
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
let modalTitle = $derived(
isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace'
)
run(() => {
id = name.toLowerCase().replace(/\s/gi, '-')
})
@@ -344,7 +424,7 @@
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
</script>
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace" centerVertically={false}>
<CenteredModal title={modalTitle} centerVertically={false}>
<div class="flex flex-col gap-8">
{#if isFork}
<div class="flex flex-block gap-2">
@@ -410,88 +490,184 @@
{/if}
</Alert>
{/if}
<label class="flex flex-col gap-1">
{#if isFork}
<span class="text-xs font-semibold text-emphasis">Fork name</span>
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
{:else}
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<span class="text-xs text-secondary">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<TextInput bind:value={id} error={errorId} />
{/if}
{#if errorId}
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
{/if}
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
<span class="text-xs text-secondary"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-4">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}
<div class="flex items-center gap-1 grow">
<input
class="grow min-w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<TextInput
class="w-24"
bind:value={workspaceColor}
inputProps={{ disabled: !colorEnabled }}
/>
<Button
on:click={generateRandomColor}
size="xs"
variant="default"
disabled={!colorEnabled}>Random</Button
>
</div>
{/if}
</div>
</label>
{#if !automateUsernameCreation}
{#if isFork || creationStep === 'details'}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
<TextInput bind:value={username} inputProps={{ onkeyup: handleKeyUp }} error={errorUser} />
{#if errorUser}
<span class="text-red-500 text-2xs">{errorUser}</span>
{#if isFork}
<span class="text-xs font-semibold text-emphasis">Fork name</span>
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
{:else}
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<span class="text-xs text-secondary">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<TextInput bind:value={id} error={errorId} />
{/if}
{#if errorId}
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="block">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
<span class="text-xs text-secondary"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-4">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}
<div class="flex items-center gap-1 grow">
<input
class="grow min-w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<TextInput
class="w-24"
bind:value={workspaceColor}
inputProps={{ disabled: !colorEnabled }}
/>
<Button
on:click={generateRandomColor}
size="xs"
variant="default"
disabled={!colorEnabled}>Random</Button
>
</div>
{/if}
</div>
</label>
{#if !automateUsernameCreation}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
<TextInput
bind:value={username}
inputProps={{ onkeyup: handleCreateKeyUp }}
error={errorUser}
/>
{#if errorUser}
<span class="text-red-500 text-2xs">{errorUser}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="flex flex-col gap-1">
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
>{isCloudHosted()
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
>
<Toggle
id="auto-invite"
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
{/if}
{#if auto_invite}
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if isCloudHosted()}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Mode</span>
<span class="text-xs text-secondary font-normal"
>Whether to invite or add users directly to the workspace.</span
>
<ToggleButtonGroup
selected={autoAdd ? 'add' : 'invite'}
on:selected={async (e) => {
autoAdd = e.detail === 'add'
}}
>
{#snippet children({ item })}
<ToggleButton value="invite" label="Auto-invite" {item} />
<ToggleButton value="add" label="Auto-add" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
{/if}
<label class="font-semibold flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Role</span>
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
</div>
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
disabled={forkCreationLoading || createLoading}
variant="default"
size="sm"
href="{base}/user/workspaces">&leftarrow; Back to workspaces</Button
>
{#if !forkCreationLoading}
<Button
variant="accent"
loading={createLoading}
disabled={createLoading ||
checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
</Button>
{:else}
<Button variant="accent" disabled={true}>
<LoaderCircle class="animate-spin" /> Creating branch
</Button>
{/if}
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="ai-key" class="flex flex-row gap-2">
<span class="text-xs font-semibold text-emphasis">
AI key for Windmill AI
Set up Windmill AI
<Tooltip>
Find out how it can help you <a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
@@ -500,10 +676,21 @@
>
</Tooltip>
</span>
<span class="text-2xs text-secondary">(optional but recommended)</span>
</label>
<span class="text-xs text-secondary">
Windmill AI powers the chat, code generation, flow creation, and code completion. Set
it up now or configure it later in Workspace settings.
<a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
Learn more
</a>
</span>
<ToggleButtonGroup bind:selected>
<ToggleButtonGroup bind:selected class="mt-4">
{#snippet children({ item })}
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
@@ -517,7 +704,7 @@
type="password"
autocomplete="new-password"
bind:value={aiKey}
onkeyup={handleKeyUp}
onkeyup={handleAiKeyUp}
/>
<TestAIKey
apiKey={aiKey}
@@ -529,7 +716,7 @@
</div>
{#if aiKey}
<div class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-2">
<Toggle
disabled={!aiKey}
bind:checked={codeCompletionEnabled}
@@ -538,91 +725,25 @@
</div>
{/if}
</div>
<div class="flex flex-col gap-1">
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
>{isCloudHosted()
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
variant="default"
size="sm"
disabled={aiSetupLoading || !createdWorkspaceId}
on:click={() => createdWorkspaceId && finishWorkspaceSetup(createdWorkspaceId)}
>
<Toggle
id="auto-invite"
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
{/if}
{#if auto_invite}
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
<!-- svelte-ignore a11y_label_has_associated_control -->
{#if isCloudHosted()}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Mode</span>
<span class="text-xs text-secondary font-normal"
>Whether to invite or add users directly to the workspace.</span
>
<ToggleButtonGroup
selected={autoAdd ? 'add' : 'invite'}
on:selected={async (e) => {
autoAdd = e.detail === 'add'
}}
>
{#snippet children({ item })}
<ToggleButton value="invite" label="Auto-invite" {item} />
<ToggleButton value="add" label="Auto-add" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
{/if}
<label class="font-semibold flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Role</span>
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
</div>
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
disabled={forkCreationLoading}
variant="default"
size="sm"
href="{base}/user/workspaces">&leftarrow; Back to workspaces</Button
>
{#if !forkCreationLoading}
Skip for now
</Button>
<Button
variant="accent"
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
loading={aiSetupLoading}
disabled={aiSetupLoading || !aiKey}
on:click={saveWorkspaceAiSetup}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
Set up AI
</Button>
{:else}
<Button variant="accent" disabled={true}>
<LoaderCircle class="animate-spin" /> Creating branch
</Button>
{/if}
</div>
</div>
{/if}
</div>
</CenteredModal>
@@ -0,0 +1,93 @@
<script lang="ts">
import { type AIProvider, type InstanceAISummary } from '$lib/gen'
import { AI_PROVIDERS } from '../copilot/lib'
import Badge from '../common/badge/Badge.svelte'
import Button from '../common/button/Button.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
interface Props {
instanceAiSummary?: InstanceAISummary
showWorkspaceOverrideEditor?: boolean
onToggleOverride: () => void
}
let {
instanceAiSummary = undefined,
showWorkspaceOverrideEditor = false,
onToggleOverride
}: Props = $props()
let sortedInstanceProviders = $derived(
[...(instanceAiSummary?.providers ?? [])].sort((left, right) =>
left.provider.localeCompare(right.provider)
)
)
function getProviderLabel(provider: AIProvider): string {
return AI_PROVIDERS[provider]?.label ?? provider
}
</script>
{#if instanceAiSummary}
<SettingCard label="Active instance AI">
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
<p class="text-xs text-secondary">
This workspace is currently using the instance AI defaults shown below.
</p>
<div class="flex flex-col gap-3">
{#each sortedInstanceProviders as providerSummary}
<div class="rounded-md border bg-surface p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium">
{getProviderLabel(providerSummary.provider)}
</span>
<Badge color="blue">Instance</Badge>
</div>
<div class="flex flex-wrap gap-1">
{#each providerSummary.models as model}
<Badge color="gray">{model}</Badge>
{/each}
</div>
</div>
{/each}
</div>
{#if instanceAiSummary.default_model}
<div class="text-xs text-secondary">
Default chat model:
<span class="text-primary font-medium">{instanceAiSummary.default_model.model}</span>
<span class="text-tertiary">
({getProviderLabel(instanceAiSummary.default_model.provider)})
</span>
</div>
{/if}
{#if instanceAiSummary.code_completion_model}
<div class="text-xs text-secondary">
Code completion model:
<span class="text-primary font-medium">
{instanceAiSummary.code_completion_model.model}
</span>
<span class="text-tertiary">
({getProviderLabel(instanceAiSummary.code_completion_model.provider)})
</span>
</div>
{/if}
</div>
</SettingCard>
{/if}
<SettingCard label="Workspace override">
<div class="flex flex-col gap-3 p-4 rounded-md border bg-surface-tertiary">
<p class="text-xs text-secondary">
Create workspace-specific AI settings only if this workspace needs to override the active
instance defaults.
</p>
<div>
<Button onclick={onToggleOverride} variant="default" unifiedSize="sm">
{showWorkspaceOverrideEditor ? 'Hide override form' : 'Override for this workspace'}
</Button>
</div>
</div>
</SettingCard>
+1 -1
View File
@@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea
return false
}
return ['python3', 'bun', 'deno'].includes(language)
return ['python3', 'bun'].includes(language)
}
export function canHaveFailure(language: SupportedLanguage | undefined): boolean {
@@ -797,6 +797,7 @@
stepResults={getStepResults(job.workflow_as_code_status)}
result={job.result}
success={(job as any).success !== false}
jobId={job.id}
/>
</div>
</div>
@@ -25,13 +25,20 @@
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte'
const settingsSteps = [
{ id: 'Core', label: 'Core' },
{ id: 'Auth/OAuth/SAML', label: 'Authentication' }
] as const
const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types']
const AI_STEP_INDEX = settingsSteps.length
const wizardStepLabels = [
...settingsSteps.map((s) => s.label),
'AI',
'Root login & Resource Types'
]
const fullStepLabels = ['Settings', 'Root login & Resource Types']
@@ -67,6 +74,7 @@
})
let instanceSettings: InstanceSettings | undefined = $state()
let instanceAiSettings: InstanceAISettings | undefined = $state()
function isSettingsStep(step: number): boolean {
return step < settingsSteps.length
@@ -148,6 +156,9 @@
let passwordValid = $derived(newPassword.length >= 2)
let accountFormValid = $derived(emailValid && passwordValid)
// --- AI step state ---
let aiHasUnsavedChanges = $state(false)
// --- EE license key warning ---
let showLicenseKeyWarning = $state(false)
let pendingNextCallback: (() => void) | undefined = $state(undefined)
@@ -168,9 +179,20 @@
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso')
let yamlMode = $state(false)
function handleNavigate(newTab: string) {
if (newTab === fullTab) return
function isAiStepActive(): boolean {
return (
(mode === 'wizard' && wizardStep === AI_STEP_INDEX) ||
(mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode)
)
}
async function handleNavigate(newTab: string): Promise<boolean> {
if (newTab === fullTab) return true
if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return false
}
fullTab = newTab
return true
}
// --- Settings search (full mode) ---
@@ -180,7 +202,10 @@
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
async function handleSearchSelect(item: SearchableSettingItem) {
handleNavigate(item.tabId)
const didNavigate = await handleNavigate(item.tabId)
if (!didNavigate) {
return
}
if (item.settingKey) {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
@@ -202,7 +227,7 @@
})
/** Check if we need to warn about missing EE license key before proceeding */
function proceedFromCore(callback: () => void) {
async function proceedFromCore(callback: () => void) {
const leavingSettings =
(mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0)
if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) {
@@ -210,12 +235,16 @@
showLicenseKeyWarning = true
return
}
saveAndProceed(callback)
await saveAndProceed(callback)
}
/** Auto-save dirty settings, then run the callback */
async function saveAndProceed(callback: () => void) {
if (yamlMode) {
if (isAiStepActive()) {
if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return
}
} else if (yamlMode) {
// In YAML mode, sync editor → form, then bulk-save everything
if (!instanceSettings?.syncBeforeDiff()) return
await instanceSettings.saveSettings()
@@ -231,11 +260,14 @@
callback()
}
function switchToFullMode() {
async function switchToFullMode() {
mode = 'full'
}
function switchToWizardMode() {
async function switchToWizardMode() {
if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) {
return
}
yamlMode = false
fullStep = 0
mode = 'wizard'
@@ -461,6 +493,13 @@
tab={settingsSteps[wizardStep].id}
/>
{/key}
{:else if wizardStep === AI_STEP_INDEX}
<InstanceAISettings
bind:this={instanceAiSettings}
bind:hasUnsavedChanges={aiHasUnsavedChanges}
disableChatOffset
showHubSync
/>
{:else}
{@render accountSetupContent()}
{/if}
@@ -505,19 +544,28 @@
{/if}
<div class="flex-1 min-w-0 h-full overflow-auto px-4">
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
bind:yamlMode
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{#if fullTab === 'ai' && !yamlMode}
<InstanceAISettings
bind:this={instanceAiSettings}
bind:hasUnsavedChanges={aiHasUnsavedChanges}
disableChatOffset
showHubSync
/>
{:else}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
tab={instanceSettingsCategory}
{authSubTab}
bind:yamlMode
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{/if}
</div>
</div>
{:else}
@@ -87,7 +87,8 @@
workspace: $workspaceStore!
})
if (!emptyString(defaultApp.default_app_path)) {
goto(`/apps/get/${defaultApp.default_app_path}`)
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
goto(`${prefix}/${defaultApp.default_app_path}`)
} else {
goto(rd ?? '/')
}
@@ -151,7 +151,8 @@
workspace: $workspaceStore!
})
if (!emptyString(defaultApp.default_app_path)) {
await goto(`/apps/get/${defaultApp.default_app_path}`)
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
await goto(`${prefix}/${defaultApp.default_app_path}`)
} else {
if (rd?.startsWith('http')) {
window.location.href = rd
@@ -19,10 +19,12 @@
import {
OauthService,
WorkspaceService,
ResourceService,
SettingService,
type AIConfig,
type ErrorHandler
type ErrorHandler,
type GetCopilotSettingsStateResponse,
type InstanceAISummary,
type GetSettingsResponse
} from '$lib/gen'
import {
enterpriseLicense,
@@ -60,7 +62,6 @@
convertDucklakeSettingsFromBackend,
type DucklakeSettingsType
} from '$lib/components/workspaceSettings/DucklakeSettings.svelte'
import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import CollapseLink from '$lib/components/CollapseLink.svelte'
@@ -112,19 +113,12 @@
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let codeCompletionModel: string | undefined = $state(undefined)
let defaultModel: string | undefined = $state(undefined)
let customPrompts: Record<string, string> = $state({})
let maxTokensPerModel: Record<string, number> = $state({})
// Track initial AI config for unsaved changes detection
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let initialCodeCompletionModel: string | undefined = $state(undefined)
let initialDefaultModel: string | undefined = $state(undefined)
let initialCustomPrompts: Record<string, string> = $state({})
let initialMaxTokensPerModel: Record<string, number> = $state({})
let hasInstanceAiConfig = $state(false)
let usesInstanceAiConfig = $state(false)
let instanceAiSummary: InstanceAISummary | undefined = $state(undefined)
let aiInitialConfig: AIConfig | undefined = $state(undefined)
let aiSettingsComponent: AISettings | undefined = $state(undefined)
let hasAiSettingsChanges = $state(false)
// Track initial deploy settings for unsaved changes detection
let initialWorkspaceToDeployTo: string | undefined = $state(undefined)
let initialDeployUiSettings: {
@@ -227,14 +221,6 @@
return currentValue !== initialValue
})
// Derived state for checking unsaved changes in AI settings
let hasAiSettingsChanges = $derived.by(() => {
if (tab !== 'ai') return false
const changes = getAiSettingsInitialAndModifiedValues()
if (!changes.savedValue || !changes.modifiedValue) return false
return hasUnsavedChanges(changes.savedValue, changes.modifiedValue)
})
// Derived state for checking unsaved changes in deployment settings
let hasDeploySettingsChanges = $derived.by(() => {
if (tab !== 'deploy_to') return false
@@ -320,8 +306,6 @@
$page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands'
)
let usingOpenaiClientCredentialsOauth = $state(false)
let loadedSettings = $state(false)
let oauths: Record<string, any> = $state({})
@@ -489,7 +473,17 @@
}
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const [settings, copilotSettingsState]: [
GetSettingsResponse,
GetCopilotSettingsStateResponse
] = await Promise.all([
WorkspaceService.getSettings({
workspace: $workspaceStore!
}),
WorkspaceService.getCopilotSettingsState({
workspace: $workspaceStore!
})
])
slack_team_name = settings.slack_name
teams_team_id = settings.teams_team_id
teams_team_name = settings.teams_team_name
@@ -508,23 +502,10 @@
workspaceToDeployTo = settings.deploy_to
webhook = settings.webhook
aiProviders = settings.ai_config?.providers ?? {}
defaultModel = settings.ai_config?.default_model?.model
codeCompletionModel = settings.ai_config?.code_completion_model?.model
customPrompts = settings.ai_config?.custom_prompts ?? {}
maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {}
for (const mode of Object.values(AIMode)) {
if (!(mode in customPrompts)) {
customPrompts[mode] = ''
}
}
// Store initial AI config state for unsaved changes detection
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
aiInitialConfig = settings.ai_config ?? {}
hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config
usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config
instanceAiSummary = copilotSettingsState.instance_ai_summary
const errorHandler = settings.error_handler as
| { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean }
| undefined
@@ -600,12 +581,6 @@
// Store initial success handler state for unsaved changes detection
initialSuccessHandlerScriptPath = successHandlerScriptPath
// check openai_client_credentials_oauth
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
workspace: $workspaceStore!,
path: 'openai_client_credentials_oauth'
})
loadedSettings = true
}
@@ -816,36 +791,6 @@
)
}
// Function to check if there are unsaved changes in AI settings
function getAiSettingsInitialAndModifiedValues() {
const savedValue = {
aiProviders: initialAiProviders,
defaultModel: initialDefaultModel,
codeCompletionModel: initialCodeCompletionModel,
customPrompts: initialCustomPrompts,
maxTokensPerModel: initialMaxTokensPerModel
}
const modifiedValue = {
aiProviders: aiProviders,
defaultModel: defaultModel,
codeCompletionModel: codeCompletionModel,
customPrompts: customPrompts,
maxTokensPerModel: maxTokensPerModel
}
return { savedValue, modifiedValue }
}
// Function to discard unsaved AI settings changes
function discardAiSettingsChanges() {
aiProviders = clone(initialAiProviders)
defaultModel = initialDefaultModel
codeCompletionModel = initialCodeCompletionModel
customPrompts = clone(initialCustomPrompts)
maxTokensPerModel = clone(initialMaxTokensPerModel)
}
// Function to check if there are unsaved changes in storage settings
function getStorageSettingsInitialAndModifiedValues() {
return {
@@ -1017,7 +962,9 @@
case 'windmill_data_tables':
return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} }
case 'ai':
return getAiSettingsInitialAndModifiedValues()
return hasAiSettingsChanges
? { savedValue: { changed: false }, modifiedValue: { changed: true } }
: { savedValue: {}, modifiedValue: {} }
case 'windmill_lfs':
return getStorageSettingsInitialAndModifiedValues()
case 'volume_storage':
@@ -1059,7 +1006,7 @@
function discardAllChanges() {
switch (tab) {
case 'ai':
discardAiSettingsChanges()
aiSettingsComponent?.discard()
break
case 'windmill_lfs':
discardStorageSettingsChanges()
@@ -1830,21 +1777,19 @@ export async function main(
/>
{:else if tab == 'ai'}
<AISettings
bind:aiProviders
bind:codeCompletionModel
bind:defaultModel
bind:customPrompts
bind:maxTokensPerModel
bind:usingOpenaiClientCredentialsOauth
hasUnsavedChanges={hasAiSettingsChanges}
onDiscard={discardAiSettingsChanges}
onSave={() => {
// Update initial state after successful save
initialAiProviders = clone(aiProviders)
initialDefaultModel = defaultModel
initialCodeCompletionModel = codeCompletionModel
initialCustomPrompts = clone(customPrompts)
initialMaxTokensPerModel = clone(maxTokensPerModel)
bind:this={aiSettingsComponent}
initialConfig={aiInitialConfig}
bind:hasUnsavedChanges={hasAiSettingsChanges}
{hasInstanceAiConfig}
{usesInstanceAiConfig}
{instanceAiSummary}
onSave={(copilotSettingsState) => {
if (!copilotSettingsState) {
return
}
hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config
usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config
instanceAiSummary = copilotSettingsState.instance_ai_summary
}}
/>
{:else if tab == 'windmill_data_tables'}
@@ -0,0 +1,359 @@
<script lang="ts">
import { type Job, JobService } from '$lib/gen'
import { base } from '$lib/base'
import Button from '$lib/components/common/button/Button.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { sendUserToast } from '$lib/toast'
import FlowMetadata from '$lib/components/FlowMetadata.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import Login from '$lib/components/Login.svelte'
import { AlertTriangle, ExternalLink } from 'lucide-svelte'
import { mergeSchema } from '$lib/common'
import { emptyString } from '$lib/utils'
import { Alert } from '$lib/components/common'
import { getUserExt } from '$lib/user'
import { setLicense } from '$lib/enterpriseUtils'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { page } from '$app/state'
$workspaceStore = page.params.workspace
let rd = page.url.href.replace(page.url.origin, '')
let token = page.url.searchParams.get('token') ?? undefined
let job: Job | undefined = $state(undefined)
let approvalInfo: any = $state(undefined)
let completed = $state(false)
let error: string | undefined = $state(undefined)
let default_payload: any = $state({})
let loading = $state(false)
let valid = $state(true)
let pollInterval: number | undefined = undefined
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
setLicense()
onMount(() => {
window.onunhandledrejection = (event: PromiseRejectionEvent) => {
event.preventDefault()
pollInterval && clearInterval(pollInterval)
if (event.reason?.message) {
const { message, body } = event.reason
if (body) {
sendUserToast(`${body}`, true)
error = body.toString()
} else {
sendUserToast(`${message}`, true)
error = message.toString()
}
}
}
loadData()
pollInterval = setInterval(loadData, 2000)
})
onDestroy(() => {
pollInterval && clearInterval(pollInterval)
})
async function loadData() {
try {
error = undefined
approvalInfo = await JobService.getApprovalInfo({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
token
})
} catch (e: any) {
error = e?.body ?? e?.message ?? 'Failed to load approval info'
pollInterval && clearInterval(pollInterval)
return
}
try {
job = (await JobService.getJob({
workspace: page.params.workspace ?? '',
id: page.params.job ?? ''
})) as Job
completed = job?.type === 'CompletedJob'
} catch {
// Job details are optional — page works with just approvalInfo
}
}
async function loadUser() {
userStore.set(await getUserExt(page.params.workspace ?? ''))
}
async function resume() {
loading = true
try {
await JobService.resumeSuspended({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
requestBody: {
payload: default_payload,
approval_token: token,
approved: true
}
})
sendUserToast('Flow approved')
pollInterval && clearInterval(pollInterval)
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
error = e?.body ?? e?.message
} finally {
loading = false
}
}
async function cancel() {
loading = true
try {
await JobService.resumeSuspended({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
requestBody: {
approval_token: token,
approved: false
}
})
sendUserToast('Flow denied!')
pollInterval && clearInterval(pollInterval)
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
error = e?.body ?? e?.message
} finally {
loading = false
}
}
let isWac = $derived(!!(job as any)?.workflow_as_code_status)
let filteredArgs = $derived.by(() => {
if (!job?.args) return job?.args
const args = { ...(job.args as any) }
delete args['_MODULES']
return args
})
let rawFormSchema = $derived(approvalInfo?.form_schema?.schema ?? approvalInfo?.form_schema ?? {})
let schema = $derived.by(() => {
if (
!rawFormSchema ||
typeof rawFormSchema !== 'object' ||
Object.keys(rawFormSchema).length === 0
)
return {}
// If the schema already has 'properties', use it as-is (classic flow format)
if ('properties' in rawFormSchema) return rawFormSchema
// Otherwise wrap as properties (WAC format: form.schema is a flat map of field definitions)
return { properties: rawFormSchema, order: Object.keys(rawFormSchema) }
})
let hasForm = $derived(schema && typeof schema === 'object' && Object.keys(schema).length > 0)
let selfApprovalDisabled = $derived(
approvalInfo?.approval_conditions?.self_approval_disabled ?? false
)
let isSelfApprovalBypass = $derived(
selfApprovalDisabled &&
$userStore &&
$userStore.email === (job as any)?.email &&
($userStore.is_admin || $userStore.is_super_admin)
)
$effect(() => {
if (approvalInfo?.user_auth_required && !$userStore) {
untrack(() => loadUser())
}
})
</script>
<ScheduleEditor bind:this={scheduleEditor} />
<CenteredModal
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
disableLogo
centerVertically={false}
>
{#if error}
<div class="space-y-6">
{#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Not Authorized</p>
</div>
<p class="text-sm">{error}</p>
<Login {rd} />
{:else if error.includes('Permission denied') || error.includes('Self-approval')}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Permission denied</p>
</div>
<p class="text-sm">{error}</p>
{:else}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Error</p>
</div>
<p class="text-sm">{error}</p>
{/if}
</div>
{:else if approvalInfo}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
<div class="w-full">
<h2 class="text-sm font-semibold text-emphasis">Approvers</h2>
<div class="mt-2 text-xs font-normal text-primary">
{#if approvalInfo.approvers?.length > 0}
<ul>
{#each approvalInfo.approvers as a}
<li>
<p>
{a.approver}
<Tooltip>Unique id of approval: {a.resume_id}</Tooltip>
</p>
</li>
{/each}
</ul>
{:else}
<p class="text-xs text-secondary">
No current approvers for this step (approval steps can require more than one approval)
</p>
{/if}
</div>
</div>
<div class="w-full">
{#if job && job.raw_flow}
<FlowMetadata {job} {scheduleEditor} />
{/if}
</div>
</div>
{#if !completed}
<h2 class="mt-4 mb-2 text-sm font-semibold text-emphasis">
{isWac ? 'Workflow' : 'Flow'} arguments
</h2>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={filteredArgs}
/>
{/if}
<div class="mt-8"></div>
<div class="p-4 rounded-md bg-surface-tertiary shadow-md">
{#if completed}
<Alert type="info" title="Flow completed">
The flow is not running anymore. You cannot cancel or resume it.
</Alert>
{/if}
{#if approvalInfo.description != undefined}
<DisplayResult noControls result={approvalInfo.description} />
{/if}
{#if hasForm && !completed}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
bind:isValid={valid}
schema={mergeSchema(schema, {})}
bind:args={default_payload}
/>
{/if}
{/if}
{#if !completed && approvalInfo.can_approve}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap">
{#if approvalInfo.hide_cancel !== true}
<Button
variant="accent"
destructive
onclick={cancel}
size="lg"
disabled={completed || loading}
>
Deny
</Button>
{:else}
<div></div>
{/if}
<Button
variant="accent"
onclick={resume}
size="lg"
disabled={completed || !valid || loading}
>
Approve
</Button>
</div>
{:else if !completed && !approvalInfo.can_approve}
{#if approvalInfo.user_auth_required && !$userStore}
<Login {rd} />
{:else}
<div class="text-sm text-secondary space-y-1">
<p>You are not authorized to approve this flow.</p>
{#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email}
<p class="text-yellow-600">Self-approval is disabled for this step.</p>
{/if}
{#if approvalInfo.approval_conditions?.user_groups_required?.length > 0}
<p
>Only members of the following groups can approve: <b
>{approvalInfo.approval_conditions.user_groups_required.join(', ')}</b
></p
>
{/if}
</div>
{/if}
{:else if completed}
<!-- already shown above -->
{/if}
{#if !completed && isSelfApprovalBypass}
<div class="mt-2">
<Alert type="warning" title="Warning">
As an administrator, by resuming or cancelling this stage of the flow, you bypass the
self-approval interdiction.
</Alert>
</div>
{/if}
</div>
<div class="mt-4 flex flex-row flex-wrap justify-between">
<a
class="text-accent text-xs"
target="_blank"
rel="noreferrer"
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
>
Open run details (require auth) <ExternalLink size={12} class="inline" />
</a>
</div>
{#if job && job.raw_flow && !completed}
<h2 class="mt-10 text-sm font-semibold text-emphasis mb-2">Flow details</h2>
<div class="rounded-md overflow-hidden">
<FlowGraphV2
workspace={job.workspace_id}
triggerNode={false}
earlyStop={job.raw_flow?.skip_expr !== undefined}
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
notSelectable
/>
</div>
{/if}
{:else}
<p class="text-sm text-secondary">Loading...</p>
{/if}
</CenteredModal>
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.663.0"
wmill = ">=1.664.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.663.0
version: 1.664.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.663.0'
ModuleVersion = '1.664.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.663.0"
version = "1.664.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+9 -2
View File
@@ -2463,7 +2463,7 @@ class WorkflowCtx:
)
async def _wait_for_approval(
self, timeout: int = 1800, form: dict | None = None
self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True
):
key = self._alloc_key("approval")
@@ -2479,6 +2479,7 @@ class WorkflowCtx:
"key": key,
"timeout": timeout,
"form": form,
"self_approval_disabled": not self_approval,
"steps": [],
})
@@ -2762,6 +2763,7 @@ async def sleep(seconds: int):
async def wait_for_approval(
timeout: int = 1800,
form: dict | None = None,
self_approval: bool = True,
) -> dict:
"""Suspend the workflow and wait for an external approval.
@@ -2770,6 +2772,11 @@ async def wait_for_approval(
Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
Args:
timeout: Approval timeout in seconds (default 1800).
form: Optional form schema for the approval page.
self_approval: Whether the user who triggered the flow can approve it (default True).
Example::
urls = await step("urls", lambda: get_resume_urls())
@@ -2778,7 +2785,7 @@ async def wait_for_approval(
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
return await ctx._wait_for_approval(timeout=timeout, form=form)
return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval)
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
+7 -2
View File
@@ -632,7 +632,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -1336,12 +1336,17 @@ async def sleep(seconds: int)
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+7 -2
View File
@@ -1605,7 +1605,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -2309,12 +2309,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+6 -1
View File
@@ -648,12 +648,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -481,7 +481,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -610,7 +610,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -608,7 +608,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -614,7 +614,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -575,7 +575,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -783,12 +783,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+3
View File
@@ -1577,6 +1577,7 @@ export class WorkflowCtx {
_waitForApproval(options?: {
timeout?: number;
form?: object;
selfApproval?: boolean;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const key = this._allocKey("approval");
@@ -1597,6 +1598,7 @@ export class WorkflowCtx {
key,
timeout: options?.timeout ?? 1800,
form: options?.form,
self_approval_disabled: !(options?.selfApproval ?? true),
steps: [],
});
}
@@ -1842,6 +1844,7 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
export function waitForApproval(options?: {
timeout?: number;
form?: object;
selfApproval?: boolean;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
if (!ctx) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.663.0",
"version": "1.664.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.663.0",
"version": "1.664.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.663.0
1.664.0