mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
Merge remote-tracking branch 'origin/main' into dbt-column-lineage-index
This commit is contained in:
+214
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "slack_command_script",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "teams_command_script",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "slack_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "slack_oauth_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "slack_oauth_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "customer_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "webhook",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "ai_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "dbt_warehouses",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "ducklake",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "git_sync",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "default_scripts",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "git_app_installations",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "auto_invite",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "error_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "error_handler_fallback_to_instance_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2"
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO guest_activity (email, workspace_id, day)\n VALUES ($1, $2, CURRENT_DATE)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET last_seen_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5"
|
||||
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "since",
|
||||
"type_info": "Date"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "guest_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "guest_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "guest_enabled_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "workspace_count!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR label <> 'guest_session')\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726"
|
||||
"hash": "d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007"
|
||||
"hash": "e2eee8de61337b7d093f38e3e393e3620111119abf8dda4bde5b835ff934e4f1"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
|
||||
}
|
||||
Generated
+2
@@ -14791,6 +14791,7 @@ dependencies = [
|
||||
"tikv-jemallocator",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-cookies",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -14802,6 +14803,7 @@ dependencies = [
|
||||
"windmill-api-client",
|
||||
"windmill-api-scripts",
|
||||
"windmill-api-settings",
|
||||
"windmill-api-users",
|
||||
"windmill-autoscaling",
|
||||
"windmill-common",
|
||||
"windmill-dep-map",
|
||||
|
||||
@@ -351,6 +351,8 @@ windmill-trigger-sqs.workspace = true
|
||||
windmill-trigger-gcp.workspace = true
|
||||
windmill-trigger-azure.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
tower-cookies.workspace = true
|
||||
windmill-api-users.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
windmill-api-client.workspace = true
|
||||
|
||||
@@ -1 +1 @@
|
||||
3b89bfc11314a326a191101cfe3ef65f6f7f82a8
|
||||
fb1c5c109846d6c47aff70ab6cc631f4fd773678
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS guest_activity;
|
||||
ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Guest app access: a workspace-level switch, off by default. An app whose policy says
|
||||
-- `execution_mode: guest` only admits guests where this is on, and the check runs where
|
||||
-- the guest session is minted -- an app definition carries its policy, so git-sync and
|
||||
-- the CLI push `guest` past every UI gate.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN guest_access_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- A guest leaves no `usr` or `password` row, which is what keeps them off every seat
|
||||
-- counter, so this is the only durable record that one was here: a row per guest,
|
||||
-- workspace and day, written when the session is minted.
|
||||
--
|
||||
-- Deliberately not the audit log. The seat scan is served by a partial index whose
|
||||
-- predicate names the login operations literally, and `audit_partitioned` is a
|
||||
-- partitioned table, where `CREATE INDEX CONCURRENTLY` is unsupported -- adding a
|
||||
-- guest operation to that predicate means a locking rebuild on the largest table an
|
||||
-- instance has. Guest logins still write `users.login_guest` for the audit trail;
|
||||
-- nothing counts them from there.
|
||||
CREATE TABLE guest_activity (
|
||||
email VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
day DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (email, workspace_id, day)
|
||||
);
|
||||
|
||||
-- The retention delete filters on day alone; the PK only reaches it through two
|
||||
-- other columns.
|
||||
CREATE INDEX idx_guest_activity_day ON guest_activity (day);
|
||||
@@ -29,6 +29,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -38,6 +42,11 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/drive"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/drive"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -47,6 +56,13 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/gmail.send"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.compose",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -56,6 +72,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar.events"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.events.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -65,6 +87,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/forms"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/forms",
|
||||
"https://www.googleapis.com/auth/forms.body",
|
||||
"https://www.googleapis.com/auth/forms.body.readonly",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -74,6 +102,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/cloud-platform.read-only"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -88,6 +120,15 @@
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit"
|
||||
],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/admin.directory.user",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.group",
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
|
||||
@@ -1937,6 +1937,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting old feature_usage rows: {e}");
|
||||
}
|
||||
|
||||
// Guest sign-ins, kept a month longer than the seat window they feed so a late
|
||||
// telemetry send still sees a whole month.
|
||||
if let Err(e) = sqlx::query!("DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60")
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting old guest_activity rows: {e}");
|
||||
}
|
||||
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
|
||||
)
|
||||
|
||||
@@ -112,6 +112,7 @@ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), ch
|
||||
FK: (workspace_id, folder_name) -> folder(workspace_id, name)
|
||||
gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[])
|
||||
global_settings: name(char), value(jsonb), updated_at(ts)
|
||||
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz)
|
||||
group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
|
||||
@@ -224,7 +225,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The guest allowance: free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the
|
||||
//! trailing window. Past it, a hard-capped instance (Community, Pro) refuses a stranger
|
||||
//! and lets a returning guest back in; a metered one (Enterprise) admits everyone and
|
||||
//! counts seats. Its own binary: the plan is read from a process-wide key id that this
|
||||
//! test flips, which no test sharing the process could tolerate.
|
||||
//!
|
||||
//! Users from the `base` fixture:
|
||||
//! test-user (admin, token SECRET_TOKEN)
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::{FREE_GUESTS_PER_WINDOW, GUEST_WINDOW_DAYS};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const APP_PATH: &str = "u/test-user/guest_app";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Community and Pro are capped, Enterprise is metered. Only a build with both
|
||||
/// `private` (the key id) and `enterprise` (the plan read) can meter; every other build
|
||||
/// is capped whatever this says.
|
||||
fn set_plan(pro: bool) {
|
||||
#[cfg(feature = "private")]
|
||||
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(
|
||||
if pro { "test_pro" } else { "" }.to_string(),
|
||||
));
|
||||
let _ = pro;
|
||||
}
|
||||
|
||||
async fn mint(db: &Pool<Postgres>, email: &str) -> windmill_common::error::Result<String> {
|
||||
let mut tx = db.begin().await.unwrap();
|
||||
let minted = windmill_api_users::users::create_guest_session_token(
|
||||
email,
|
||||
"test-workspace",
|
||||
APP_PATH,
|
||||
&mut tx,
|
||||
tower_cookies::Cookies::default(),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await.unwrap();
|
||||
minted
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "guest", "triggerables_v2": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
// The whole allowance, used yesterday: still in the window, and a day the mint
|
||||
// does not write, so a row dated today can only be the mint's own.
|
||||
sqlx::query(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE - 1
|
||||
FROM generate_series(1, $1) AS i",
|
||||
)
|
||||
.bind(FREE_GUESTS_PER_WINDOW)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
set_plan(true);
|
||||
let refused = mint(&db, "stranger@example.com").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&refused, windmill_common::error::Error::PermissionDenied(m)
|
||||
if m.contains(&format!("limit of {FREE_GUESTS_PER_WINDOW} guests over {GUEST_WINDOW_DAYS} days"))),
|
||||
"a stranger past the allowance is refused with the message the visitor reads: {refused:?}"
|
||||
);
|
||||
mint(&db, "g1@example.com")
|
||||
.await
|
||||
.expect("a guest already in the window is let back in");
|
||||
let recorded: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guest_activity
|
||||
WHERE email = 'g1@example.com' AND workspace_id = 'test-workspace'
|
||||
AND day = CURRENT_DATE)",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
recorded,
|
||||
"the mint writes today's guest_activity row, the allowance's unit"
|
||||
);
|
||||
|
||||
let list: serde_json::Value = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/users/guests?per_page=5"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(list["usage"]["guest_count"], FREE_GUESTS_PER_WINDOW);
|
||||
assert_eq!(list["usage"]["metered"], false);
|
||||
assert_eq!(list["usage"]["guest_seats"], 0);
|
||||
assert_eq!(list["guests"].as_array().map(Vec::len), Some(5));
|
||||
assert_eq!(list["guests"][0]["workspaces"], json!(["test-workspace"]));
|
||||
let usage: serde_json::Value = authed(
|
||||
client().get(format!("{ws}/workspaces/guest_usage")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW);
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
set_plan(false);
|
||||
mint(&db, "stranger@example.com")
|
||||
.await
|
||||
.expect("a metered plan admits past the allowance");
|
||||
let usage: serde_json::Value = authed(
|
||||
client().get(format!("{ws}/workspaces/guest_usage")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW + 1);
|
||||
assert_eq!(usage["metered"], true);
|
||||
assert_eq!(usage["billable_guests"], 1);
|
||||
assert_eq!(
|
||||
usage["guest_seats"], 1,
|
||||
"one guest past the allowance is a whole seat"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
// These pin language-independent behaviour, and Python is the cheapest runtime that still
|
||||
// generates a real lock out of relative imports, so the whole file needs that feature.
|
||||
#![cfg(feature = "python")]
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_api_client::types::NewScript;
|
||||
@@ -6,21 +10,27 @@ use windmill_test_utils::*;
|
||||
|
||||
const W: &str = "test-workspace";
|
||||
|
||||
const A: &str = r#"export async function main() { return "a" }"#;
|
||||
const A_COMMENTED: &str = r#"// same dependencies, different content
|
||||
export async function main() { return "a" }"#;
|
||||
const A_WITH_LODASH: &str = r#"import _ from "lodash@4.17.21";
|
||||
export async function main() { return _.trim(" a ") }"#;
|
||||
const B: &str = r#"import { main as a } from "/f/rel/a.ts";
|
||||
export async function main() { return "b" + (await a()) }"#;
|
||||
const C: &str = r#"import { main as b } from "/f/rel/b.ts";
|
||||
export async function main() { return "c" + (await b()) }"#;
|
||||
/// Budget for one wait below, counted completions and drain together. Even against a cold cache
|
||||
/// these settle in a few seconds, so it only bounds a step that is stuck, and it stays under the
|
||||
/// 60s cap `in_test_worker` puts on the whole body so the panic names what was being waited on
|
||||
/// rather than surfacing as a worker timeout.
|
||||
const WAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
fn bun_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
/// How often the waits below re-check. The drain reads the queue once per completion it waits
|
||||
/// this long for, so it doubles as the floor on one turn of that loop.
|
||||
const POLL: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
|
||||
const A: &str = "def main():\n return 'a'\n";
|
||||
const A_COMMENTED: &str = "# same dependencies, different content\ndef main():\n return 'a'\n";
|
||||
const A_WITH_TINY: &str = "import tiny\n\ndef main():\n return 'a'\n";
|
||||
const B: &str = "from f.rel.a import main as a\n\ndef main():\n return 'b' + a()\n";
|
||||
const C: &str = "from f.rel.b import main as b\n\ndef main():\n return 'c' + b()\n";
|
||||
|
||||
fn py_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
NewScript {
|
||||
draft_only: None,
|
||||
content: content.into(),
|
||||
language: windmill_api_client::types::ScriptLang::Bun,
|
||||
language: windmill_api_client::types::ScriptLang::Python3,
|
||||
lock: None,
|
||||
parent_hash,
|
||||
path: path.into(),
|
||||
@@ -96,17 +106,33 @@ async fn dependency_jobs_since(
|
||||
}
|
||||
|
||||
async fn wait_for_jobs(
|
||||
db: &Pool<Postgres>,
|
||||
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
|
||||
count: usize,
|
||||
) {
|
||||
for _ in 0..count {
|
||||
completed.next().await;
|
||||
let deadline = tokio::time::Instant::now() + WAIT_BUDGET;
|
||||
for i in 0..count {
|
||||
tokio::time::timeout_at(deadline, completed.next())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("only {i} of {count} jobs completed"));
|
||||
}
|
||||
// Then let anything else that was queued run out, so a job the assertions say must not
|
||||
// exist would have shown up here.
|
||||
while let Ok(Some(_)) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), completed.next()).await
|
||||
{}
|
||||
// exist would have shown up here. A dependency job queues its fan-out before it completes,
|
||||
// so an empty queue is a fixpoint rather than a lull.
|
||||
loop {
|
||||
while let Ok(Some(_)) = tokio::time::timeout(POLL, completed.next()).await {}
|
||||
let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job_queue")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
if queued == 0 {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"the queue never emptied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A redeploy of an imported script whose dependencies did not move relocks its importer,
|
||||
@@ -128,10 +154,10 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
// importer whose edges are recorded is what a later relock of it can skip on.
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B), ("f/rel/c", C)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.create_script(W, &py_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
}
|
||||
let b_before = versions(&db, "f/rel/b").await;
|
||||
let c_before = versions(&db, "f/rel/c").await;
|
||||
@@ -144,11 +170,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
wait_for_jobs(&db, &mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -181,11 +207,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_WITH_LODASH, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
wait_for_jobs(&db, &mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -203,7 +229,7 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
);
|
||||
assert!(vs[0].created_at < vs[1].created_at, "{path}: lineage order");
|
||||
assert!(
|
||||
vs[1].lock.as_deref().unwrap_or("").contains("lodash"),
|
||||
vs[1].lock.as_deref().unwrap_or("").contains("tiny"),
|
||||
"{path}: the new version carries the new lock: {:?}",
|
||||
vs[1].lock
|
||||
);
|
||||
@@ -233,10 +259,10 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
async {
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.create_script(W, &py_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
}
|
||||
|
||||
// A deploy of b that holds its head's row lock for as long as this transaction lives.
|
||||
@@ -251,14 +277,15 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// b's relock skips generation and reaches its commit, where it waits on the lock.
|
||||
let deadline = std::time::Instant::now() + WAIT_BUDGET;
|
||||
let mut waiting = false;
|
||||
for _ in 0..300 {
|
||||
while !waiting && std::time::Instant::now() < deadline {
|
||||
waiting = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||||
@@ -267,10 +294,9 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
if waiting {
|
||||
break;
|
||||
if !waiting {
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(waiting, "b's relock never reached the row lock");
|
||||
|
||||
@@ -284,7 +310,7 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
deploy.commit().await.unwrap();
|
||||
|
||||
// a's own job, the relock that waited, and the relock it queued for the successor.
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
wait_for_jobs(&db, &mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -324,7 +350,6 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
|
||||
/// A multi-file importer: on a skipped relock each module gets its own last lock back, not the
|
||||
/// parent script's, so an import's content-only redeploy leaves the importer alone as well.
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
|
||||
@@ -332,8 +357,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
|
||||
let py = |path: &str, content: &str, parent_hash: Option<String>, with_module: bool| {
|
||||
let mut ns = bun_script(path, content, parent_hash);
|
||||
ns.language = windmill_api_client::types::ScriptLang::Python3;
|
||||
let mut ns = py_script(path, content, parent_hash);
|
||||
if with_module {
|
||||
ns.modules = Some(std::collections::HashMap::from([(
|
||||
"helper.py".to_string(),
|
||||
@@ -363,7 +387,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
.create_script(W, &py("f/rel/pa", "def main():\n return 'a'\n", None, false))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
@@ -376,7 +400,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
let lock_before = module_lock(&db).await;
|
||||
assert!(lock_before.is_some(), "the module got a lock of its own on deploy");
|
||||
|
||||
@@ -394,7 +418,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
wait_for_jobs(&db, &mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
|
||||
@@ -138,12 +138,28 @@ impl AuthCache {
|
||||
w_id: Option<String>,
|
||||
token: &str,
|
||||
) -> Option<OptJobAuthed> {
|
||||
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id, token).await?;
|
||||
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id.clone(), token).await?;
|
||||
// Single source of truth: mirror the resolved job_id onto the authed so
|
||||
// every consumer (require_super_admin, ...) sees that this identity came
|
||||
// from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed
|
||||
// predates this field.
|
||||
opt_job_authed.authed.job_id = opt_job_authed.job_id;
|
||||
// The workspace's guest switch is enforced here, once, for every guest request
|
||||
// — not per handler, where each guest-reachable route would have to remember
|
||||
// it. Uncached, so turning guests off takes effect on the next request of every
|
||||
// guest session and every token derived from one.
|
||||
if crate::scopes::has_guest_sentinel(opt_job_authed.authed.scopes.as_deref()) {
|
||||
let Some(w_id) = w_id else { return None };
|
||||
let email = &opt_job_authed.authed.email;
|
||||
match windmill_common::workspaces::guest_session_stands(&self.db, &w_id, email).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest session check failed for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(opt_job_authed)
|
||||
}
|
||||
|
||||
@@ -427,6 +443,8 @@ impl AuthCache {
|
||||
}
|
||||
(_, Some(email), super_admin, scopes, label, read_only) => {
|
||||
let is_session_token = is_session_label(label.as_deref());
|
||||
let is_guest_session =
|
||||
windmill_common::auth::is_guest_session_label(label.as_deref());
|
||||
let (username_override, username_override_is_token_label) =
|
||||
username_override_from_label(label);
|
||||
if w_id.is_some() {
|
||||
@@ -509,6 +527,36 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A guest session: IdP-authenticated, member of
|
||||
// nothing. No `usr` lookup, groups or folders, so
|
||||
// every ACL denies it and the token's scopes are
|
||||
// its whole grant. After the superadmin arm, so
|
||||
// that token is never demoted into this one.
|
||||
None if is_guest_session => {
|
||||
// The server-minted label is the grant, never
|
||||
// the `guest` scope (a user-minted token's
|
||||
// scopes are whatever the caller typed); the
|
||||
// sentinel is pinned on here so every guest
|
||||
// control downstream sees a guest regardless.
|
||||
let scopes = Some(crate::scopes::with_guest_sentinel(
|
||||
scopes.unwrap_or_default(),
|
||||
));
|
||||
Some(ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override,
|
||||
username_override_is_token_label,
|
||||
is_session_token,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
@@ -934,10 +982,17 @@ pub(crate) fn username_override_from_label(label: Option<String>) -> (Option<Str
|
||||
{
|
||||
(Some(label), true)
|
||||
}
|
||||
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => (
|
||||
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
|
||||
true,
|
||||
),
|
||||
Some(label)
|
||||
if label != "ephemeral-script"
|
||||
&& label != "session"
|
||||
&& label != windmill_common::auth::GUEST_SESSION_LABEL
|
||||
&& !label.is_empty() =>
|
||||
{
|
||||
(
|
||||
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
_ => (None, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +500,24 @@ pub fn check_route_access(
|
||||
}
|
||||
}
|
||||
|
||||
// A guest session carries the same broad read scopes as an embed token and for
|
||||
// the same handful of routes, so it gets the same default-deny.
|
||||
if has_guest_sentinel(Some(token_scopes)) {
|
||||
if let Some(suffix) = route_suffix.as_deref() {
|
||||
if guest_route_denied(required_domain, suffix) {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"a guest session cannot access {route_path}"
|
||||
)));
|
||||
}
|
||||
// Same rationale as the embed branch: re-running a component supersedes
|
||||
// its in-flight run, and `cancel_job_api` confines this to the caller's
|
||||
// own jobs.
|
||||
if suffix.starts_with("jobs_u/queue/cancel/") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each declared scope must grant what its prompt said and no more:
|
||||
// `jobs:run` only deployed runnables, `users:read` only the viewer's identity.
|
||||
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
|
||||
@@ -753,6 +771,28 @@ pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
|
||||
}
|
||||
|
||||
/// Sentinel in a guest session token: someone the identity provider authenticated
|
||||
/// who is a member of no workspace. Grants nothing itself — it only confines the
|
||||
/// session to the app surface, the same way `app_embed` does. What makes a session a
|
||||
/// guest at all is the server-minted label
|
||||
/// [`windmill_common::auth::GUEST_SESSION_LABEL`]; a forged sentinel here can only
|
||||
/// narrow its own token.
|
||||
pub const GUEST_SENTINEL: &str = "guest";
|
||||
|
||||
/// True if a token is a guest session, whose scopes are its entire grant: it has no ACL
|
||||
/// of its own, so every ACL check denies it unaided.
|
||||
pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
|
||||
}
|
||||
|
||||
/// `scopes` with the guest sentinel present exactly once.
|
||||
pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
|
||||
if !scopes.iter().any(|x| x == GUEST_SENTINEL) {
|
||||
scopes.push(GUEST_SENTINEL.to_string());
|
||||
}
|
||||
scopes
|
||||
}
|
||||
|
||||
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
||||
/// to narrow the declared scopes to what the viewer's prompt promised.
|
||||
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
||||
@@ -815,6 +855,19 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool {
|
||||
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
|
||||
}
|
||||
|
||||
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
|
||||
/// mint. A guest session is the *embedder* — the viewer's own browser rendering the
|
||||
/// app page — not the app's own JS, and the page mints the iframe's token from it.
|
||||
///
|
||||
/// Everything else stays default-denied, so a guest reaches the app it was let in
|
||||
/// for and nothing around it.
|
||||
fn guest_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
||||
if domain == ScopeDomain::Apps && suffix.starts_with("apps_u/embed_token") {
|
||||
return false;
|
||||
}
|
||||
app_embed_route_denied(domain, suffix)
|
||||
}
|
||||
|
||||
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
|
||||
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
|
||||
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
|
||||
|
||||
@@ -657,15 +657,17 @@ async fn logout(
|
||||
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
||||
|
||||
let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT {
|
||||
sqlx::query_scalar!(
|
||||
// A guest's browser session is a session too: this is its one user-driven revocation.
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"WITH email_lookup AS (
|
||||
SELECT email FROM token WHERE token_hash = $1
|
||||
)
|
||||
DELETE FROM token
|
||||
WHERE email = (SELECT email FROM email_lookup) AND label = 'session'
|
||||
WHERE email = (SELECT email FROM email_lookup)
|
||||
AND label IN ('session', 'guest_session')
|
||||
RETURNING email",
|
||||
t_hash
|
||||
)
|
||||
.bind(&t_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
@@ -745,7 +747,30 @@ async fn whoami(
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
) -> JsonResult<UserInfo> {
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
|
||||
// A guest would otherwise fall through to the non-member branch below and be
|
||||
// handed a `superadmin` role. Answer it here, as the operator-shaped identity it is.
|
||||
if is_guest {
|
||||
return Ok(Json(UserInfo {
|
||||
workspace_id: w_id,
|
||||
email,
|
||||
username,
|
||||
name: None,
|
||||
is_admin: false,
|
||||
is_super_admin: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
groups: vec![],
|
||||
operator: true,
|
||||
disabled: false,
|
||||
role: Some("guest".to_string()),
|
||||
folders_read: vec![],
|
||||
folders: vec![],
|
||||
folders_owners: vec![],
|
||||
is_service_account: false,
|
||||
non_member: true,
|
||||
}));
|
||||
}
|
||||
let user = get_user(&w_id, &username, &db).await?;
|
||||
// Only treat the row as "this user is a member" when its email matches; the
|
||||
// derived username is instance-unique so a match on a different email should
|
||||
@@ -2882,7 +2907,12 @@ pub async fn create_session_token<'c>(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
|
||||
set_session_cookie(&cookies, &token, *MAX_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn set_session_cookie(cookies: &Cookies, token: &str, validity_seconds: i64) {
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.to_string());
|
||||
cookie.set_secure(IS_SECURE.load(std::sync::atomic::Ordering::Relaxed));
|
||||
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
@@ -2892,9 +2922,150 @@ pub async fn create_session_token<'c>(
|
||||
}
|
||||
|
||||
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
|
||||
expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS);
|
||||
expire += time::Duration::seconds(validity_seconds);
|
||||
cookie.set_expires(expire);
|
||||
cookies.add(cookie);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// A guest session is the only credential held by someone with no account, so
|
||||
/// there is nothing to disable when the workspace revokes guest access or the
|
||||
/// identity provider removes them — the expiry is the revocation. Much shorter
|
||||
/// than a member session for that reason.
|
||||
static ref GUEST_SESSION_VALIDITY_SECONDS: i64 = std::env::var("GUEST_SESSION_VALIDITY_SECONDS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i64>().ok())
|
||||
.unwrap_or(8 * 60 * 60);
|
||||
}
|
||||
|
||||
/// Scopes a guest session carries. Mirrors `APP_EMBED_SCOPES` — the same broad-looking
|
||||
/// reads narrowed to a route allowlist by the sentinel (`guest_route_denied`) — plus the
|
||||
/// two path-scoped app grants minted per app. With no ACL of its own, this list is the
|
||||
/// whole of what a guest can do.
|
||||
///
|
||||
/// The `guest` sentinel here only narrows. What makes the session a guest at all is the
|
||||
/// server-minted label ([`windmill_common::auth::GUEST_SESSION_LABEL`]).
|
||||
fn guest_session_scopes(app_path: &str) -> Result<Vec<String>> {
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes, \
|
||||
and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(vec![
|
||||
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{app_path}"),
|
||||
format!("apps:run:{app_path}"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Mint a browser session for someone the identity provider authenticated who is a
|
||||
/// member of no workspace, so they can open one guest-mode app. Writes no `password`
|
||||
/// and no `usr` row: that absence is what keeps a guest off every seat counter, so
|
||||
/// nothing here may be "helpfully" upgraded into provisioning.
|
||||
///
|
||||
/// Pinned to `w_id` (`AuthCache` matches on `token.workspace_id`): without the pin an
|
||||
/// `apps:run:<path>` scope would unlock a same-path app elsewhere. So a guest cannot
|
||||
/// authenticate on any workspace-less route (`/api/users/*`, `/api/settings/*`); a
|
||||
/// page that needs one for a guest must become workspace-scoped, not loosen the pin.
|
||||
///
|
||||
/// Refuses unless every gate says yes (`guest_app_admits`, then the allowance in
|
||||
/// `guest_admission`), so no caller can mint where a guest is not wanted, whatever it
|
||||
/// believed when it decided to call. All that is left to the caller is the
|
||||
/// authentication of `email`.
|
||||
pub async fn create_guest_session_token<'c>(
|
||||
email: &str,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
cookies: Cookies,
|
||||
) -> Result<String> {
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
|
||||
|
||||
let token = rd_string(32);
|
||||
let t_hash = windmill_common::auth::hash_token(&token);
|
||||
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
||||
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
|
||||
None
|
||||
} else {
|
||||
Some(&token)
|
||||
};
|
||||
let scopes = guest_session_scopes(app_path)?;
|
||||
|
||||
// No account at all (see `ExecutionMode::Guest`): a deactivated `password` row
|
||||
// counts, since the sign-in path's own lookup filters on `disabled = false` and a
|
||||
// SCIM-offboarded account would otherwise read as absent; so does a `usr` row in
|
||||
// any workspace, which is what a service account has instead of a password.
|
||||
let has_account: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
|
||||
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if has_account {
|
||||
return Err(Error::NotAuthorized(
|
||||
"an existing account cannot hold a guest session".to_string(),
|
||||
));
|
||||
}
|
||||
if !windmill_common::workspaces::guest_app_admits(&mut **tx, w_id, app_path).await? {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
windmill_common::workspaces::guest_admission(&mut **tx, email).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO token
|
||||
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
t_hash,
|
||||
t_prefix,
|
||||
plaintext as Option<&str>,
|
||||
email,
|
||||
windmill_common::auth::GUEST_SESSION_LABEL,
|
||||
&GUEST_SESSION_VALIDITY_SECONDS.to_string(),
|
||||
&scopes,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// The only durable record that a guest was here, and the set the allowance is
|
||||
// counted on; not the audit log, see the migration. Idempotent per email,
|
||||
// workspace and day.
|
||||
sqlx::query!(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
VALUES ($1, $2, CURRENT_DATE)
|
||||
ON CONFLICT (email, workspace_id, day)
|
||||
DO UPDATE SET last_seen_at = now()",
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut **tx,
|
||||
&AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
username_override: None,
|
||||
token_prefix: Some(t_prefix.to_string()),
|
||||
},
|
||||
"users.login_guest",
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_session_cookie(&cookies, &token, *GUEST_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -3159,9 +3330,13 @@ async fn update_token_scopes(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// A guest-labelled token is never rescoped: its scopes are its whole confinement,
|
||||
// and after promotion the same email owns an account that could otherwise strip
|
||||
// them from the still-valid guest credential. Same shape as the relabel guard.
|
||||
let updated: Option<String> = sqlx::query_scalar!(
|
||||
"UPDATE token SET scopes = $1
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR label <> 'guest_session')
|
||||
RETURNING token_prefix",
|
||||
req.scopes.as_deref(),
|
||||
&authed.email,
|
||||
@@ -3172,7 +3347,7 @@ async fn update_token_scopes(
|
||||
|
||||
let prefix = updated.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"token {token_prefix} not found or not owned by user"
|
||||
"token {token_prefix} not found, not owned by user, or not rescopable"
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -3242,6 +3417,7 @@ async fn update_token_label(
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR (
|
||||
label <> 'session'
|
||||
AND label <> 'guest_session'
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
|
||||
@@ -151,6 +151,8 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||||
.route("/edit_default_app", post(edit_default_app))
|
||||
.route("/edit_guest_access", post(edit_guest_access))
|
||||
.route("/guest_usage", get(get_guest_usage))
|
||||
.route("/default_app", get(get_default_app))
|
||||
.route(
|
||||
"/default_scripts",
|
||||
@@ -317,6 +319,9 @@ pub struct WorkspaceSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
pub error_handler_fallback_to_instance_alerts: bool,
|
||||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
|
||||
/// own `execution_mode: guest` is inert while this is off.
|
||||
pub guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||||
@@ -339,6 +344,9 @@ pub struct WorkspacePublicSettings {
|
||||
pub teams_team_guid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
/// Not sensitive, and the app editor needs it to say whether the guest rung is
|
||||
/// live -- an app can be set to `guest` while the workspace has guests off.
|
||||
pub guest_access_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deploy_ui: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1073,7 +1081,8 @@ async fn get_settings(
|
||||
error_handler,
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute,
|
||||
error_handler_fallback_to_instance_alerts
|
||||
error_handler_fallback_to_instance_alerts,
|
||||
guest_access_enabled
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -1112,6 +1121,7 @@ async fn get_public_settings(
|
||||
teams_team_name,
|
||||
teams_team_guid,
|
||||
mute_critical_alerts,
|
||||
guest_access_enabled,
|
||||
deploy_ui,
|
||||
large_file_storage,
|
||||
datatable
|
||||
@@ -1132,6 +1142,18 @@ async fn get_public_settings(
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
/// The instance's standing against the guest allowance: counts only, no emails, so any
|
||||
/// member may read it. Instance-wide, since a licence is per instance and one email is
|
||||
/// one guest however many workspaces it opens; the settings card and the editor's
|
||||
/// Guests rung show it so nobody discovers the cap from a visitor's complaint.
|
||||
async fn get_guest_usage(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> JsonResult<windmill_common::workspaces::GuestUsage> {
|
||||
Ok(Json(windmill_common::workspaces::guest_usage(&db).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GitSyncDeployModeQuery {
|
||||
/// The branch the caller would push.
|
||||
@@ -4595,6 +4617,50 @@ async fn edit_default_app(
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestAccess {
|
||||
guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Turn guest sessions on or off for this workspace. Off by default, and off is
|
||||
/// authoritative and immediate: the switch is re-read where a guest session is
|
||||
/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app
|
||||
/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on
|
||||
/// the next request, sessions already issued included.
|
||||
async fn edit_guest_access(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
guest_access_enabled,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_access",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&guest_access_enabled.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Guest access set to {guest_access_enabled} for workspace {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn edit_default_scripts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -187,6 +187,13 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating guest_activity table");
|
||||
sqlx::query("UPDATE guest_activity SET workspace_id = $1 WHERE workspace_id = $2")
|
||||
.bind(&rw.new_id)
|
||||
.bind(&old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_invite table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
@@ -1112,6 +1119,13 @@ pub(crate) async fn delete_workspace(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Unlike the rest of this list, this also moves an instance-wide figure: the guest
|
||||
// allowance and the seats past it are counted over every workspace's rows.
|
||||
sqlx::query("DELETE FROM guest_activity WHERE workspace_id = $1")
|
||||
.bind(&w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -861,6 +861,34 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/ExternalJwtToken"
|
||||
|
||||
/users/guests:
|
||||
get:
|
||||
summary: list the distinct guests of the trailing window (superadmin only)
|
||||
description: >-
|
||||
The set the guest allowance is counted on: every distinct email that held a
|
||||
guest session in the last `window_days`, with the workspaces it opened and the
|
||||
days it was first and last seen, most recently seen first. `usage` is the
|
||||
instance's standing against the allowance and the meter.
|
||||
operationId: listGuests
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: the guests of the window and the allowance they count against
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestList"
|
||||
|
||||
/users/onboarding:
|
||||
post:
|
||||
summary: Submit user onboarding data
|
||||
@@ -3667,8 +3695,12 @@ paths:
|
||||
$ref: "#/components/schemas/WorkspaceDeployUISettings"
|
||||
mute_critical_alerts:
|
||||
type: boolean
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
required:
|
||||
- workspace_id
|
||||
- guest_access_enabled
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
get:
|
||||
@@ -3750,6 +3782,9 @@ paths:
|
||||
error_handler_fallback_to_instance_alerts:
|
||||
type: boolean
|
||||
description: Report failed jobs to the instance critical alert channels when no workspace error handler is set.
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -5739,6 +5774,60 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_access:
|
||||
post:
|
||||
summary: enable or disable guest sessions for this workspace
|
||||
description: >-
|
||||
Guests are people the identity provider authenticates who have no Windmill
|
||||
account; the `guest` app execution mode admits them. Off by default. Re-read
|
||||
where a guest session is minted and at the auth door on every guest request, so
|
||||
turning it off takes effect immediately, for sessions already issued and for
|
||||
apps whose policy already says `guest`.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Whether guest sessions are admitted
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- guest_access_enabled
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/guest_usage:
|
||||
get:
|
||||
summary: the instance's standing against the guest allowance
|
||||
description: >-
|
||||
Instance-wide, since a licence is per instance and one email is one guest however
|
||||
many workspaces it opens. Read by workspace admins and app publishers to see how
|
||||
close the cap (Community and Pro) or the meter (Enterprise) is.
|
||||
operationId: getGuestUsage
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: guest usage
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestUsage"
|
||||
|
||||
/w/{workspace}/workspaces/default_scripts:
|
||||
post:
|
||||
summary: edit default scripts for workspace
|
||||
@@ -8806,6 +8895,27 @@ paths:
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps_u/guest_entry_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: whether the app behind a custom path admits guests
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`.
|
||||
Returns the workspace too, since a custom URL may not carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/CustomPath"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/apps_u/public_app_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get public app by custom path
|
||||
@@ -12966,6 +13076,30 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps_u/guest_entry/{path}:
|
||||
get:
|
||||
summary: whether the app behind a share secret admits guests
|
||||
description: >-
|
||||
Unauthenticated: what a signed-out visitor reads to learn that signing in
|
||||
would let them in. 404 unless the app's execution mode is `guest` AND the
|
||||
workspace has `guest_access_enabled` AND the instance has not set the
|
||||
`guest_access_disabled` global setting, so it says nothing about apps that
|
||||
are not open to guests. Discloses only the app path, to a caller already
|
||||
holding the share secret.
|
||||
operationId: getGuestEntry
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/w/{workspace}/apps_u/public_app/{path}:
|
||||
get:
|
||||
summary: get public app by secret
|
||||
@@ -28859,6 +28993,76 @@ components:
|
||||
- is_operator
|
||||
- last_used_at
|
||||
|
||||
GuestUsage:
|
||||
type: object
|
||||
description: >-
|
||||
Guests are free up to `free_allowance` distinct emails over the trailing
|
||||
`window_days`. Past that an Enterprise plan meters them (`metered`, four guests
|
||||
to one seat: `billable_guests`, `guest_seats`); every other plan and build
|
||||
admits no new email until the count drops. `instance_enabled` is the superadmin
|
||||
switch (`guest_access_disabled` global setting) every workspace switch sits under.
|
||||
properties:
|
||||
instance_enabled:
|
||||
type: boolean
|
||||
guest_count:
|
||||
type: integer
|
||||
format: int64
|
||||
window_days:
|
||||
type: integer
|
||||
free_allowance:
|
||||
type: integer
|
||||
format: int64
|
||||
metered:
|
||||
type: boolean
|
||||
billable_guests:
|
||||
type: integer
|
||||
format: int64
|
||||
guest_seats:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- instance_enabled
|
||||
- guest_count
|
||||
- window_days
|
||||
- free_allowance
|
||||
- metered
|
||||
- billable_guests
|
||||
- guest_seats
|
||||
|
||||
GuestActivity:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
workspaces:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
first_seen:
|
||||
type: string
|
||||
format: date
|
||||
last_seen:
|
||||
type: string
|
||||
format: date
|
||||
required:
|
||||
- email
|
||||
- workspaces
|
||||
- first_seen
|
||||
- last_seen
|
||||
|
||||
GuestList:
|
||||
type: object
|
||||
properties:
|
||||
usage:
|
||||
$ref: "#/components/schemas/GuestUsage"
|
||||
guests:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GuestActivity"
|
||||
required:
|
||||
- usage
|
||||
- guests
|
||||
|
||||
NewToken:
|
||||
type: object
|
||||
properties:
|
||||
@@ -33269,14 +33473,17 @@ components:
|
||||
type: string
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
description: >-
|
||||
Who the app's runnables execute as. Optional, and what omitting it
|
||||
means depends on the operation: creating an app defaults it to
|
||||
`publisher` (runs on behalf of the app's publisher and requires an
|
||||
authenticated viewer), while updating one keeps the mode the app is
|
||||
already deployed under. Either way `anonymous`, which makes the app
|
||||
publicly executable, is never assumed
|
||||
Who may open the app, and who its runnables execute as. Optional, and
|
||||
what omitting it means depends on the operation: creating an app
|
||||
defaults it to `publisher` (runs on behalf of the app's publisher and
|
||||
requires an authenticated viewer), while updating one keeps the mode
|
||||
the app is already deployed under. Neither `anonymous`, which makes
|
||||
the app publicly executable, nor `guest`, which opens it to anyone the
|
||||
identity provider authenticates, is ever assumed. A guest is only
|
||||
admitted where the workspace also has `guest_access_enabled`, which is
|
||||
checked when the session is minted and again on every guest request
|
||||
on_behalf_of:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
@@ -33327,7 +33534,7 @@ components:
|
||||
format: date-time
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
raw_app:
|
||||
type: boolean
|
||||
labels:
|
||||
@@ -35141,6 +35348,17 @@ components:
|
||||
description: Configuration of protection restrictions
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleKind"
|
||||
GuestEntry:
|
||||
type: object
|
||||
description: What a signed-out visitor needs to start a guest sign-in.
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
app_path:
|
||||
type: string
|
||||
required:
|
||||
- workspace_id
|
||||
- app_path
|
||||
ProtectionRuleKind:
|
||||
type: string
|
||||
enum:
|
||||
@@ -35149,6 +35367,7 @@ components:
|
||||
- RestrictDeployToDeployers
|
||||
- RestrictAnonymousAppDeployment
|
||||
- RestrictPublicRunSharing
|
||||
- RestrictGuestAppDeployment
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
|
||||
@@ -170,6 +170,7 @@ pub fn unauthed_service() -> Router {
|
||||
)
|
||||
.route("/load_csv_preview/{*path}", get(app_load_csv_preview))
|
||||
.route("/public_app/{secret}", get(get_public_app_by_secret))
|
||||
.route("/guest_entry/{secret}", get(get_guest_entry))
|
||||
.route("/embed_token/{secret}", get(get_app_embed_token))
|
||||
.route("/public_resource/{*path}", get(get_public_resource))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
@@ -288,6 +289,15 @@ pub type AllowUserResources = Vec<String>;
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecutionMode {
|
||||
Anonymous,
|
||||
/// Login required, workspace membership not: anyone the instance's identity
|
||||
/// provider authenticates may open the app, and the runnables execute as the
|
||||
/// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a
|
||||
/// guest session: an identity with no account at all (no `password` row, no `usr`
|
||||
/// row anywhere), which is what keeps it off every row-based seat counter; what a
|
||||
/// guest costs instead is the allowance in `windmill_common::workspaces`. Honored
|
||||
/// only where `workspace_settings.guest_access_enabled` is on, re-read at the auth
|
||||
/// door on every guest request.
|
||||
Guest,
|
||||
/// Default for a policy that omits `execution_mode`. It MUST stay a mode
|
||||
/// that requires an authenticated viewer: an omitted field must never be
|
||||
/// able to publish an app anonymously (publicly executable).
|
||||
@@ -296,6 +306,136 @@ pub enum ExecutionMode {
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
/// The serialized form, matching this enum's `rename_all = "lowercase"`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ExecutionMode::Anonymous => "anonymous",
|
||||
ExecutionMode::Guest => "guest",
|
||||
ExecutionMode::Publisher => "publisher",
|
||||
ExecutionMode::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The protection rule gating a *transition into* `mode`, if any. Anonymous and
|
||||
/// guest each widen who may open an app past the workspace's own members, so each
|
||||
/// carries its own rule; the two member-only modes are ungated.
|
||||
fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
|
||||
match mode {
|
||||
ExecutionMode::Anonymous => Some(ProtectionRuleKind::RestrictAnonymousAppDeployment),
|
||||
ExecutionMode::Guest => Some(ProtectionRuleKind::RestrictGuestAppDeployment),
|
||||
ExecutionMode::Publisher | ExecutionMode::Viewer => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest session is scoped to its app by path, so an app whose path the scope
|
||||
/// grammar cannot hold as one literal (`is_scope_literal_path`) can never admit a
|
||||
/// guest; refuse the mode at deploy time rather than advertise an app nobody enters.
|
||||
/// `path` is where the app ends up: on a rename, the destination.
|
||||
fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
|
||||
if matches!(mode, ExecutionMode::Guest) && !windmill_common::auth::is_scope_literal_path(path) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"app {path} cannot be set to Guests: a path with `:`, `,` or `*`, or a leading `/`, \
|
||||
cannot be scoped"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gate a viewer on the app's `execution_mode`, as far as can be decided without an
|
||||
/// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest
|
||||
/// admits anyone signed in; `Ok(false)` means the caller is a member and still owes
|
||||
/// the read-access check its caller performs.
|
||||
///
|
||||
/// A guest is authorized by its token's scope and never by an ACL probe: it holds no
|
||||
/// `usr` row, so RLS finds nothing for it and every guest would read as having no
|
||||
/// access. That scope is also what keeps a guest session to the one app it was minted
|
||||
/// for, even though the mode itself admits anyone signed in. The workspace's guest
|
||||
/// switch is not checked here: `AuthCache` enforces it for every guest request.
|
||||
pub fn authorize_non_member_viewer(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<bool> {
|
||||
if matches!(mode, ExecutionMode::Anonymous) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
};
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
if matches!(mode, ExecutionMode::Guest) {
|
||||
if is_guest {
|
||||
check_scopes(authed, || format!("apps:read:{}", app_path))?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if is_guest {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Confines a guest to its app once the app's mode is known; a no-op for every other
|
||||
/// caller. An anonymous app is open to anyone, so the guest stays the caller there,
|
||||
/// as itself: the run and the reads that follow it (job results, S3 provenance) must
|
||||
/// carry one identity. Anywhere else a mismatch is refused.
|
||||
fn guest_caller_for_mode(
|
||||
opt_authed: Option<ApiAuthed>,
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
) -> Result<Option<ApiAuthed>> {
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
|
||||
|| matches!(mode, ExecutionMode::Anonymous)
|
||||
{
|
||||
return Ok(opt_authed);
|
||||
}
|
||||
check_scopes(authed, || format!("apps:run:{app_path}"))
|
||||
.or_else(|_| check_scopes(authed, || format!("apps:read:{app_path}")))?;
|
||||
Ok(opt_authed)
|
||||
}
|
||||
|
||||
/// [`authorize_non_member_viewer`] plus the member read-access probe, for the
|
||||
/// entry points that address an app by id.
|
||||
async fn authorize_app_viewer(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
app_id: i64,
|
||||
w_id: &str,
|
||||
user_db: &UserDB,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<()> {
|
||||
if authorize_non_member_viewer(mode, app_path, opt_authed)? {
|
||||
return Ok(());
|
||||
}
|
||||
let authed = opt_authed
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::internal_err("authorize_app_viewer: unauthenticated".to_string()))?;
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
app_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct PolicyTriggerableInputs {
|
||||
static_inputs: StaticFields,
|
||||
@@ -1218,29 +1358,15 @@ async fn get_public_app_by_secret(
|
||||
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
if opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
} else {
|
||||
let authed = opt_authed.unwrap();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
authorize_app_viewer(
|
||||
policy.execution_mode(),
|
||||
&app.path,
|
||||
id,
|
||||
&w_id,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Compute bundle_secret for raw apps
|
||||
if app.raw_app {
|
||||
@@ -1356,9 +1482,18 @@ async fn mint_raw_app_sdk_token(
|
||||
ensure_scopes_within_caller(authed, Some(scopes))?;
|
||||
let mut scopes = scopes.to_vec();
|
||||
scopes.push(windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string());
|
||||
let expiration = chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let requested_exp =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let (label, expiration) =
|
||||
match guest_derived_token_constraints(db, authed, requested_exp).await? {
|
||||
Some((label, exp)) => {
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("sdk_app:{app_path}"), requested_exp),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("sdk_app:{app_path}")),
|
||||
Some(label),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
@@ -1373,6 +1508,39 @@ async fn mint_raw_app_sdk_token(
|
||||
Ok((token, expiration))
|
||||
}
|
||||
|
||||
/// Label and expiry a token minted *by* a guest session must carry, or `None` for a
|
||||
/// non-guest minter. The label is what lets it resolve; the caller pushes the `guest`
|
||||
/// sentinel so every guest control still applies; the expiry is capped at the parent's,
|
||||
/// since that expiry is a guest's only revocation short of logging out.
|
||||
async fn guest_derived_token_constraints(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
requested: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Option<(String, chrono::DateTime<chrono::Utc>)>> {
|
||||
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
return Ok(None);
|
||||
}
|
||||
// The minter is known by prefix only; MIN over a (theoretical) prefix collision is
|
||||
// the conservative side.
|
||||
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
||||
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
|
||||
)
|
||||
.bind(authed.token_prefix.as_deref().unwrap_or(""))
|
||||
.bind(&authed.email)
|
||||
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let Some(parent_exp) = parent.flatten() else {
|
||||
return Err(Error::NotAuthorized(
|
||||
"guest session not found or has no expiry".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(Some((
|
||||
windmill_common::auth::GUEST_SESSION_LABEL.to_string(),
|
||||
requested.min(parent_exp),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Shared tail of the three embed-token endpoints: which credential the viewer
|
||||
/// gets. Sandboxed low-code gets the embed token; a sandboxed raw app declaring
|
||||
/// `frontend_sdk_scopes` gets the SDK token once `sdk_consent` is set — the
|
||||
@@ -1400,7 +1568,22 @@ pub async fn build_embed_token_response(
|
||||
&& opt_authed.is_some()
|
||||
&& !policy.frontend_sdk_scopes.is_empty()
|
||||
{
|
||||
Some(policy.frontend_sdk_scopes.clone())
|
||||
// An SDK token runs as the viewer, and a guest's session is the ceiling on what
|
||||
// it may delegate — the mint enforces that. Advertise only what a guest can
|
||||
// actually be granted, so the consent prompt never promises a scope the mint
|
||||
// would then refuse.
|
||||
let declared = policy.frontend_sdk_scopes.clone();
|
||||
let offered = match opt_authed {
|
||||
Some(a) if windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()) => {
|
||||
let held = a.scopes.as_deref().unwrap_or_default();
|
||||
declared
|
||||
.into_iter()
|
||||
.filter(|sc| held.iter().any(|h| h == sc))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
_ => declared,
|
||||
};
|
||||
(!offered.is_empty()).then_some(offered)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1556,9 +1739,13 @@ pub async fn mint_app_embed_token(
|
||||
"App embed tokens cannot mint or renew embed tokens".to_string(),
|
||||
));
|
||||
}
|
||||
let expiration =
|
||||
let requested_exp =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES
|
||||
.iter()
|
||||
.filter(|s| **s != windmill_api_auth::scopes::APP_EMBED_SENTINEL)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
|
||||
// which the in-workspace sandboxed viewer uses) — but no other app's. The
|
||||
// public viewer fetches via apps_u/public_app and doesn't rely on this.
|
||||
@@ -1569,10 +1756,22 @@ pub async fn mint_app_embed_token(
|
||||
scopes.push(format!("apps:run:{app_path}"));
|
||||
// A scope-restricted caller token must not bootstrap a broader-scoped
|
||||
// embed token (`create_token_internal` deliberately does not check this
|
||||
// itself). No-op for unscoped sessions — the normal embed flow.
|
||||
// itself). Checked on the real scopes only: a sentinel is a one-part string
|
||||
// that `ScopeDefinition::from_scope_string` rejects, so leaving it in the
|
||||
// requested set makes this fail outright for any scoped caller — which a
|
||||
// guest session is. `mint_raw_app_sdk_token` has the same shape.
|
||||
ensure_scopes_within_caller(authed, Some(&scopes))?;
|
||||
scopes.push(windmill_api_auth::scopes::APP_EMBED_SENTINEL.to_string());
|
||||
let (label, expiration) =
|
||||
match guest_derived_token_constraints(db, authed, requested_exp).await? {
|
||||
Some((label, exp)) => {
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("embed_app:{app_path}"), requested_exp),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("embed_app:{app_path}")),
|
||||
Some(label),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
@@ -1601,6 +1800,40 @@ pub async fn mint_app_embed_token(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GuestEntry {
|
||||
/// The workspace and app path to name when starting a guest sign-in. The
|
||||
/// workspace is redundant on the secret route and load-bearing on the custom-path
|
||||
/// one, which may not carry it in its URL.
|
||||
pub workspace_id: String,
|
||||
pub app_path: String,
|
||||
}
|
||||
|
||||
/// Whether the app behind this share secret admits guests, and under what path.
|
||||
///
|
||||
/// Unauthenticated on purpose: it is what a signed-out visitor reads to learn that
|
||||
/// signing in would get them in. It discloses only the app's path, to a caller who
|
||||
/// already holds the share secret — the secret is the capability here. A 404 when the
|
||||
/// app is not open to guests, so it says nothing about apps that are not.
|
||||
async fn get_guest_entry(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<GuestEntry> {
|
||||
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
|
||||
let app = sqlx::query!(
|
||||
"SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let app = not_found_if_none(app, "App", id.to_string())?;
|
||||
if !windmill_common::workspaces::guest_app_admits(&db, &w_id, &app.path).await? {
|
||||
return Err(Error::NotFound("App is not open to guests".to_string()));
|
||||
}
|
||||
Ok(Json(GuestEntry { workspace_id: w_id, app_path: app.path }))
|
||||
}
|
||||
|
||||
/// Issue an embed token for a public app addressed by its (secret) share id.
|
||||
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
|
||||
/// reachable without auth, otherwise the caller must be logged in and have read
|
||||
@@ -1646,29 +1879,18 @@ async fn get_app_embed_token(
|
||||
|
||||
let authed_for_token = if policy.anonymous_execution {
|
||||
// Anonymous app: still mint a scoped token if the viewer happens to be
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous.
|
||||
opt_authed
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous. A
|
||||
// guest's session names another app and cannot contain this one's scopes,
|
||||
// so it renders anonymously here rather than being refused.
|
||||
opt_authed.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
} else {
|
||||
let authed = opt_authed.ok_or_else(|| {
|
||||
Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Some(authed)
|
||||
let mode = if policy.guest_execution {
|
||||
ExecutionMode::Guest
|
||||
} else {
|
||||
ExecutionMode::Publisher
|
||||
};
|
||||
authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
|
||||
opt_authed
|
||||
};
|
||||
|
||||
let resp = build_embed_token_response(
|
||||
@@ -1694,6 +1916,9 @@ async fn get_app_embed_token(
|
||||
/// strictest access interpretation.
|
||||
pub struct EmbedPolicyView {
|
||||
pub anonymous_execution: bool,
|
||||
/// Open to anyone the identity provider authenticates. Like
|
||||
/// `anonymous_execution`, an unknown mode reads as `false` — the strict side.
|
||||
pub guest_execution: bool,
|
||||
pub sandbox: bool,
|
||||
/// Raw apps: author-declared scopes for the frontend SDK token; empty when
|
||||
/// the app doesn't use the frontend SDK (non-string entries are ignored).
|
||||
@@ -1704,6 +1929,7 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
|
||||
Ok(EmbedPolicyView {
|
||||
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
|
||||
guest_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("guest"),
|
||||
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
frontend_sdk_scopes: v
|
||||
.get("frontend_sdk_scopes")
|
||||
@@ -2287,10 +2513,11 @@ async fn create_app_internal<'a>(
|
||||
// Pin the mode the app is created under, so the stored policy states one
|
||||
// even when the caller did not.
|
||||
app.policy.set_execution_mode(app.policy.execution_mode());
|
||||
if matches!(app.policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?;
|
||||
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3201,6 +3428,28 @@ async fn update_app_internal<'a>(
|
||||
if npath != path {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
|
||||
// The destination is what a guest session would be scoped to. A rename
|
||||
// that carries no policy keeps the deployed mode, read under the row
|
||||
// lock so a policy update landing alongside cannot slip a guest app
|
||||
// onto a path it cannot be scoped to.
|
||||
let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
|
||||
Some(mode) => mode,
|
||||
None => sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT policy->>'execution_mode' FROM app
|
||||
WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.and_then(|m| {
|
||||
serde_json::from_value::<ExecutionMode>(serde_json::Value::String(m)).ok()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
refuse_unscopable_guest_app(npath, mode)?;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
npath,
|
||||
@@ -3308,21 +3557,26 @@ async fn update_app_internal<'a>(
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if matches!(npolicy.execution_mode(), ExecutionMode::Anonymous) && !authed.is_admin {
|
||||
// Restricted users may keep deploying an app that is already
|
||||
// public, but flipping an app to anonymous (public) access is
|
||||
// gated by the RestrictAnonymousAppDeployment protection rule.
|
||||
// An unreadable deployed policy reads as not-anonymous, the
|
||||
// strict direction.
|
||||
let already_anonymous = deployed
|
||||
refuse_unscopable_guest_app(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
)?;
|
||||
if let Some(rule) =
|
||||
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
|
||||
{
|
||||
// Restricted users may keep deploying an app that is already open
|
||||
// to this audience, but widening one is gated by the matching
|
||||
// protection rule. An unreadable deployed policy reads as not
|
||||
// already-widened, the strict direction.
|
||||
let already_in_mode = deployed
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("execution_mode"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("anonymous");
|
||||
if !already_anonymous {
|
||||
== Some(npolicy.execution_mode().as_str());
|
||||
if !already_in_mode {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3561,6 +3815,21 @@ async fn get_on_behalf_details_from_policy_and_authed(
|
||||
policy: &Policy,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<(String, String, String)> {
|
||||
// A guest acts only through an app open to guests — or to everyone. A members-only
|
||||
// mode means the policy changed after the session was issued. Decided here, in the
|
||||
// one resolver every on-behalf path (runs, S3 reads, uploads) goes through.
|
||||
if opt_authed
|
||||
.as_ref()
|
||||
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
&& !matches!(
|
||||
policy.execution_mode(),
|
||||
ExecutionMode::Guest | ExecutionMode::Anonymous
|
||||
)
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"this app is not open to guests".to_string(),
|
||||
));
|
||||
}
|
||||
let (username, permissioned_as, email) = match policy.execution_mode() {
|
||||
ExecutionMode::Anonymous => {
|
||||
let username = opt_authed
|
||||
@@ -3570,7 +3839,9 @@ async fn get_on_behalf_details_from_policy_and_authed(
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Publisher => {
|
||||
// Guest runs as the publisher exactly as Publisher does; the two differ only
|
||||
// in who is let through the door, which is settled before we get here.
|
||||
ExecutionMode::Publisher | ExecutionMode::Guest => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
@@ -3655,8 +3926,12 @@ async fn execute_component(
|
||||
// Authorize before touching the payload: the route layer is resource-blind, so a
|
||||
// path-scoped caller (app embed token, or a picker-minted `apps:run|write:<path>`)
|
||||
// is confined to its own app only here. No-op for unscoped callers; anonymous ones
|
||||
// are policy-gated below.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
// are policy-gated below, and a guest's confinement waits for the app's mode
|
||||
// (`guest_caller_for_mode`).
|
||||
if let Some(authed) = opt_authed
|
||||
.as_ref()
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
check_scopes(authed, || format!("apps:run:{}", path))?;
|
||||
}
|
||||
// Only honor temp_script_refs for the inline-script preview path:
|
||||
@@ -3873,8 +4148,16 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
// Rate limit for executions by callers the workspace does not know: anonymous
|
||||
// viewers, and guests — on an instance whose provider accepts any consumer
|
||||
// account, "anyone the IdP authenticates" is close to the anonymous population,
|
||||
// and each run costs a job as the publisher.
|
||||
let is_guest_caller = opt_authed
|
||||
.as_ref()
|
||||
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()));
|
||||
if (matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none())
|
||||
|| is_guest_caller
|
||||
{
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
@@ -3882,6 +4165,8 @@ async fn execute_component(
|
||||
}
|
||||
}
|
||||
|
||||
let opt_authed = guest_caller_for_mode(opt_authed, policy.execution_mode(), path)?;
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref())
|
||||
@@ -4217,8 +4502,11 @@ async fn upload_s3_file_from_app(
|
||||
request: axum::extract::Request,
|
||||
) -> JsonResult<AppUploadFileResponse> {
|
||||
// Same path confinement as `execute_component`: without it a token scoped to app A
|
||||
// could drive app B's upload policy.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
// could drive app B's upload policy. A guest's waits for the app's mode, below.
|
||||
if let Some(authed) = opt_authed
|
||||
.as_ref()
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
check_scopes(authed, || format!("apps:run:{}", path.to_path()))?;
|
||||
}
|
||||
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
|
||||
@@ -4271,6 +4559,14 @@ async fn upload_s3_file_from_app(
|
||||
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
|
||||
.transpose()?
|
||||
};
|
||||
let opt_authed = guest_caller_for_mode(
|
||||
opt_authed,
|
||||
policy
|
||||
.as_ref()
|
||||
.map(Policy::execution_mode)
|
||||
.unwrap_or_default(),
|
||||
path.to_path(),
|
||||
)?;
|
||||
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
@@ -4428,8 +4724,12 @@ async fn upload_s3_file_from_app(
|
||||
}
|
||||
} else {
|
||||
// backward compatibility (no policy)
|
||||
// if no policy but logged in, use the user's auth to get the s3 resource
|
||||
if let Some(authed) = opt_authed {
|
||||
// if no policy but logged in, use the user's auth to get the s3 resource. A guest
|
||||
// has no standing of its own to upload with, so without a policy it is refused
|
||||
// exactly as an anonymous caller is.
|
||||
if let Some(authed) = opt_authed
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
let file_key = query
|
||||
.file_key
|
||||
.unwrap_or_else(|| get_random_file_name(query.file_extension));
|
||||
@@ -4687,6 +4987,7 @@ async fn get_on_behalf_authed_from_app(
|
||||
})
|
||||
};
|
||||
|
||||
let opt_authed = guest_caller_for_mode(opt_authed.clone(), policy.execution_mode(), path)?;
|
||||
let (username, permissioned_as, email) =
|
||||
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
|
||||
|
||||
@@ -4843,6 +5144,10 @@ fn check_app_s3_read_scope(opt_authed: &Option<ApiAuthed>, path: &str) -> Result
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
// A guest's confinement waits for the app's mode (`get_on_behalf_authed_from_app`).
|
||||
if windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
check_scopes(authed, || format!("apps:run:{}", path))
|
||||
.or_else(|_| check_scopes(authed, || format!("apps:read:{}", path)))
|
||||
}
|
||||
|
||||
@@ -1628,7 +1628,11 @@ pub(crate) async fn require_job_read_access(
|
||||
// this token, and letting it reach any job merely visible to the viewer would
|
||||
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
|
||||
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
// A guest stops here too: it has no membership behind it, so a share token whose
|
||||
// audience is the workspace's members must not read for it either.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
|
||||
|| windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
|
||||
{
|
||||
return Err(Error::NotFound(format!("Job {job_id} not found")));
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub fn global_service() -> Router {
|
||||
.route("/rename/{user}", post(rename_user))
|
||||
.route("/onboarding", post(submit_onboarding_data))
|
||||
.route("/ext_jwt_tokens", get(list_ext_jwt_tokens))
|
||||
.route("/guests", get(list_guests))
|
||||
.route(
|
||||
"/offboard_preview/{user}",
|
||||
get(crate::offboarding::global_offboard_preview),
|
||||
@@ -141,6 +142,58 @@ async fn list_ext_jwt_tokens(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, sqlx::FromRow)]
|
||||
pub struct GuestActivity {
|
||||
pub email: String,
|
||||
pub workspaces: Vec<String>,
|
||||
pub first_seen: chrono::NaiveDate,
|
||||
pub last_seen: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GuestList {
|
||||
pub usage: windmill_common::workspaces::GuestUsage,
|
||||
pub guests: Vec<GuestActivity>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListGuestsQuery {
|
||||
page: Option<usize>,
|
||||
per_page: Option<usize>,
|
||||
}
|
||||
|
||||
/// The distinct guests of the trailing window, the set the allowance is counted on,
|
||||
/// most recently seen first.
|
||||
async fn list_guests(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Query(query): Query<ListGuestsQuery>,
|
||||
) -> Result<Json<GuestList>> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
|
||||
let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination {
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
});
|
||||
let usage = windmill_common::workspaces::guest_usage(&db).await?;
|
||||
let guests = sqlx::query_as::<_, GuestActivity>(
|
||||
"SELECT email, array_agg(DISTINCT workspace_id) AS workspaces,
|
||||
MIN(day) AS first_seen, MAX(day) AS last_seen
|
||||
FROM guest_activity
|
||||
WHERE day > CURRENT_DATE - $3
|
||||
GROUP BY email
|
||||
ORDER BY MAX(day) DESC, email
|
||||
LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(per_page as i64)
|
||||
.bind(offset as i64)
|
||||
.bind(windmill_common::workspaces::GUEST_WINDOW_DAYS)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(GuestList { usage, guests }))
|
||||
}
|
||||
|
||||
async fn set_password(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
@@ -36,6 +36,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
|
||||
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
|
||||
l != "session"
|
||||
&& l != GUEST_SESSION_LABEL
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
@@ -56,9 +57,40 @@ pub fn is_server_minted_label(label: &str) -> bool {
|
||||
|| label.starts_with("ephemeral-script-end-user-")
|
||||
|| label == "ephemeral-script"
|
||||
|| label == "session"
|
||||
|| label == GUEST_SESSION_LABEL
|
||||
|| label.starts_with("mcp-oauth-")
|
||||
}
|
||||
|
||||
/// Label on a guest session (the `guest` app execution mode). This is the *grant*:
|
||||
/// `AuthCache` will resolve a token carrying it into an identity with no account behind
|
||||
/// it, which nothing else can do. It must therefore stay unforgeable, which is what
|
||||
/// listing it in [`is_server_minted_label`] buys — `/users/tokens/create` refuses it.
|
||||
///
|
||||
/// Do not move this test onto the token's scopes. Scopes on a user-minted token are
|
||||
/// caller-supplied and only ever *narrow* (`app_embed`, `raw_app_sdk`), so a scope
|
||||
/// that granted non-member access would be free for anyone to declare.
|
||||
pub const GUEST_SESSION_LABEL: &str = "guest_session";
|
||||
|
||||
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
|
||||
///
|
||||
/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former
|
||||
/// gates relabelling, and a user token that could be relabelled *into* this
|
||||
/// namespace would become a guest session with no workspace pin — one that
|
||||
/// authenticates everywhere.
|
||||
pub fn is_guest_session_label(label: Option<&str>) -> bool {
|
||||
label == Some(GUEST_SESSION_LABEL)
|
||||
}
|
||||
|
||||
/// Whether `path` can be spliced into a scope as one literal resource. The scope
|
||||
/// grammar reserves three characters: `:` separates the parts, `,` separates
|
||||
/// resources, `*` is a wildcard. App paths are otherwise free-form (spaces, `@`). A
|
||||
/// leading `/` is refused too: routes strip it, so the scope would never match.
|
||||
pub fn is_scope_literal_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& !path.chars().any(|c| matches!(c, ':' | ',' | '*'))
|
||||
}
|
||||
|
||||
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
|
||||
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
|
||||
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
|
||||
|
||||
@@ -65,6 +65,10 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics";
|
||||
pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics";
|
||||
pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
|
||||
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
|
||||
/// Superadmin switch over guest sessions for the whole instance, above the per-workspace
|
||||
/// one. Read from the table, uncached, by the same gates that read the workspace switch;
|
||||
/// the superadmin Guests list writes it through `/settings/global/{key}` by this name.
|
||||
pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled";
|
||||
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
|
||||
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
|
||||
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
|
||||
|
||||
@@ -71,6 +71,7 @@ bitflags::bitflags! {
|
||||
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
|
||||
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
|
||||
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
|
||||
const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ pub enum ProtectionRuleKind {
|
||||
RestrictDeployToDeployers,
|
||||
RestrictAnonymousAppDeployment,
|
||||
RestrictPublicRunSharing,
|
||||
RestrictGuestAppDeployment,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
@@ -103,6 +105,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +126,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
"Sharing a run publicly (readable without login) is restricted in this workspace"
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
"Opening an app to guests (anyone who can sign in) is restricted in this workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +775,185 @@ pub struct BillableSeats {
|
||||
pub seats: i64,
|
||||
}
|
||||
|
||||
/// Guests are free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the trailing
|
||||
/// `GUEST_WINDOW_DAYS`. Past that, an Enterprise plan meters them, `GUESTS_PER_SEAT`
|
||||
/// guests to one seat, while every other plan and build stops admitting new emails.
|
||||
pub const GUEST_WINDOW_DAYS: i32 = 30;
|
||||
pub const FREE_GUESTS_PER_WINDOW: i64 = 100;
|
||||
pub const GUESTS_PER_SEAT: i64 = 4;
|
||||
|
||||
/// Whether guests past the allowance are metered (Enterprise plan) rather than refused.
|
||||
/// A build without `enterprise` has no plan and is capped, like a Pro key.
|
||||
pub async fn guests_are_metered() -> bool {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
matches!(
|
||||
crate::ee_oss::get_license_plan().await,
|
||||
crate::ee_oss::LicensePlan::Enterprise
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Seats the guests past the free allowance consume: `ceil(billable / GUESTS_PER_SEAT)`.
|
||||
pub fn guest_seats(distinct_guests: i64) -> i64 {
|
||||
let billable = (distinct_guests - FREE_GUESTS_PER_WINDOW).max(0);
|
||||
(billable + GUESTS_PER_SEAT - 1) / GUESTS_PER_SEAT
|
||||
}
|
||||
|
||||
/// Distinct guest emails over the trailing window, today included.
|
||||
pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
) -> Result<i64> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $1",
|
||||
)
|
||||
.bind(GUEST_WINDOW_DAYS)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("counting guests: {e:#}")))
|
||||
}
|
||||
|
||||
/// The instance's standing against the guest allowance, as every surface reports it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GuestUsage {
|
||||
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
|
||||
/// switch sits under.
|
||||
pub instance_enabled: bool,
|
||||
/// Distinct guest emails over the trailing `window_days`.
|
||||
pub guest_count: i64,
|
||||
pub window_days: i32,
|
||||
pub free_allowance: i64,
|
||||
/// Enterprise plan: guests past the allowance take `guest_seats`. Otherwise no new
|
||||
/// email is admitted past it.
|
||||
pub metered: bool,
|
||||
pub billable_guests: i64,
|
||||
pub guest_seats: i64,
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The
|
||||
/// setting is read as text before the cast so `true` and `"true"` both count.
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
format!(
|
||||
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
|
||||
WHERE name = '{}'), false)",
|
||||
crate::global_settings::GUEST_ACCESS_DISABLED_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}"))
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
|
||||
let guest_count = guest_count_in_window(db).await?;
|
||||
let metered = guests_are_metered().await;
|
||||
let billable_guests = if metered {
|
||||
(guest_count - FREE_GUESTS_PER_WINDOW).max(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GuestUsage {
|
||||
instance_enabled,
|
||||
guest_count,
|
||||
window_days: GUEST_WINDOW_DAYS,
|
||||
free_allowance: FREE_GUESTS_PER_WINDOW,
|
||||
metered,
|
||||
billable_guests,
|
||||
guest_seats: if metered { guest_seats(guest_count) } else { 0 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `email` may be admitted as a guest right now. Checked once, where a session
|
||||
/// is minted: a returning guest (already in the window) is always let back in, so the
|
||||
/// cap only ever refuses a stranger, and a metered instance refuses nobody.
|
||||
///
|
||||
/// Must run inside the transaction that then records the guest in `guest_activity`:
|
||||
/// it takes a transaction-scoped lock so concurrent strangers count each other, and the
|
||||
/// lock is what keeps the cap exact rather than approximate.
|
||||
pub async fn guest_admission(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
|
||||
if guests_are_metered().await {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('guest_allowance'))")
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("locking the guest allowance: {e:#}")))?;
|
||||
let (in_window, count): (bool, i64) = sqlx::query_as(
|
||||
"SELECT
|
||||
EXISTS(SELECT 1 FROM guest_activity WHERE email = $1 AND day > CURRENT_DATE - $2),
|
||||
(SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $2)",
|
||||
)
|
||||
.bind(email)
|
||||
.bind(GUEST_WINDOW_DAYS)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("checking the guest allowance: {e:#}")))?;
|
||||
if in_window || count < FREE_GUESTS_PER_WINDOW {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::PermissionDenied(format!(
|
||||
"This instance has reached its limit of {FREE_GUESTS_PER_WINDOW} guests over \
|
||||
{GUEST_WINDOW_DAYS} days. Guest sign-in beyond that needs an Enterprise license."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Whether a guest session for `email` in `w_id` still stands: the instance and the
|
||||
/// workspace admit guests, and the email still has no account. Read at the auth door on
|
||||
/// every guest request, so turning either switch off, or an account provisioned after
|
||||
/// the mint (or racing it), ends the session on its next request.
|
||||
pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result<bool> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let stands: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT guest_access_enabled
|
||||
AND {instance_admits}
|
||||
AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2)
|
||||
AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2)
|
||||
FROM workspace_settings WHERE workspace_id = $1"
|
||||
))
|
||||
.bind(w_id)
|
||||
.bind(email)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("checking the guest session of {email}: {e:#}")))?;
|
||||
Ok(stands.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Every switch at once: the instance's, the workspace's, and `app_path` being in
|
||||
/// `guest` execution mode. The single answer to "may a guest session be minted for this
|
||||
/// app", used by the mint itself and by the sign-in branch that decides whether to call
|
||||
/// it. A missing app or a policy with no stated mode reads as "no". The allowance is
|
||||
/// `guest_admission`.
|
||||
pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
) -> Result<bool> {
|
||||
// The mint refuses a path it cannot scope, so discovery must not advertise one.
|
||||
if !crate::auth::is_scope_literal_path(app_path) {
|
||||
return Ok(false);
|
||||
}
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let admits: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false)
|
||||
AND {instance_admits}
|
||||
FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id
|
||||
WHERE app.workspace_id = $1 AND app.path = $2"
|
||||
))
|
||||
.bind(w_id)
|
||||
.bind(app_path)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}"))
|
||||
})?;
|
||||
Ok(admits.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
|
||||
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
|
||||
///
|
||||
@@ -2804,3 +2991,17 @@ pub async fn dbt_warehouse_resource(
|
||||
.map(|t| t.to_string());
|
||||
Ok((path, target))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guest_allowance_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guest_seats_round_up_past_the_allowance() {
|
||||
assert_eq!(guest_seats(0), 0);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW), 0);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + 1), 1);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT), 1);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT + 1), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
/// The registry JSON may also carry `scope_options`, a frontend-only pick
|
||||
/// list for the connect dialog; it is deliberately not modelled here.
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
|
||||
@@ -1099,7 +1099,7 @@ pub async fn run_agent(
|
||||
// For non-Anthropic providers, response_format is handled by the query builder
|
||||
}
|
||||
|
||||
let user_wants_streaming = args.streaming.unwrap_or(false);
|
||||
let user_wants_streaming = streaming_requested(args.streaming);
|
||||
*has_stream = user_wants_streaming && is_text_output;
|
||||
|
||||
let mut final_events_str = String::new();
|
||||
@@ -1701,6 +1701,17 @@ pub async fn run_agent(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
|
||||
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
|
||||
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
|
||||
///
|
||||
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
|
||||
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
|
||||
/// connection times out. So this default is half of a contract, not a local preference.
|
||||
fn streaming_requested(streaming: Option<bool>) -> bool {
|
||||
streaming.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1713,6 +1724,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritten_streaming_field_streams() {
|
||||
assert!(streaming_requested(None));
|
||||
assert!(streaming_requested(Some(true)));
|
||||
assert!(!streaming_requested(Some(false)));
|
||||
}
|
||||
|
||||
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
|
||||
/// trip per run and silently leaves that step with no prompt caching at all.
|
||||
#[test]
|
||||
|
||||
+27
-11
@@ -24,6 +24,7 @@ import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
|
||||
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
|
||||
|
||||
export interface AppFile {
|
||||
guests?: boolean;
|
||||
value: any;
|
||||
public?: boolean;
|
||||
summary: string;
|
||||
@@ -110,6 +111,28 @@ export function replaceInlineScripts(
|
||||
export function isExecutionModeAnonymous(app: any) {
|
||||
return app?.["policy"]?.["execution_mode"] == "anonymous";
|
||||
}
|
||||
export function isExecutionModeGuest(app: any) {
|
||||
return app?.["policy"]?.["execution_mode"] == "guest";
|
||||
}
|
||||
export type AppExecutionMode = "anonymous" | "guest" | "publisher";
|
||||
/** The access mode is the one policy field a tracked app keeps, as `public` (anonymous)
|
||||
* or `guests` (guest); the rest of the policy is regenerated on push. */
|
||||
export function markAccessFromPolicy(app: any) {
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
} else if (isExecutionModeGuest(app)) {
|
||||
app.guests = true;
|
||||
}
|
||||
}
|
||||
export function executionModeFromAppFile(app: any): AppExecutionMode {
|
||||
if (app?.["public"] ?? isExecutionModeAnonymous(app)) {
|
||||
return "anonymous";
|
||||
}
|
||||
if (app?.["guests"] ?? isExecutionModeGuest(app)) {
|
||||
return "guest";
|
||||
}
|
||||
return "publisher";
|
||||
}
|
||||
export async function pushApp(
|
||||
workspace: string,
|
||||
remotePath: string,
|
||||
@@ -140,9 +163,7 @@ export async function pushApp(
|
||||
remoteOnBehalfOfEmail = app.policy.on_behalf_of_email;
|
||||
}
|
||||
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
// console.log(app);
|
||||
if (app) {
|
||||
app.policy = undefined;
|
||||
@@ -155,12 +176,7 @@ export async function pushApp(
|
||||
const localApp = (await yamlParseFile(path)) as AppFile;
|
||||
|
||||
replaceInlineScripts(localApp.value, localPath, true);
|
||||
await generatingPolicy(
|
||||
localApp,
|
||||
remotePath,
|
||||
localApp?.["public"] ??
|
||||
localApp?.["policy"]?.["execution_mode"] == "anonymous"
|
||||
);
|
||||
await generatingPolicy(localApp, remotePath, executionModeFromAppFile(localApp));
|
||||
|
||||
const preserveFields: { preserve_on_behalf_of?: boolean } = {};
|
||||
if (permissionedAsContext?.userIsAdminOrDeployer) {
|
||||
@@ -230,12 +246,12 @@ export async function pushApp(
|
||||
export async function generatingPolicy(
|
||||
app: any,
|
||||
path: string,
|
||||
publicApp: boolean
|
||||
executionMode: AppExecutionMode
|
||||
) {
|
||||
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
|
||||
try {
|
||||
app.policy = await windmillUtils.updatePolicy(app.value, undefined);
|
||||
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
|
||||
app.policy.execution_mode = executionMode;
|
||||
} catch (e) {
|
||||
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
|
||||
throw e;
|
||||
|
||||
@@ -15,7 +15,13 @@ import { readdir } from "node:fs/promises";
|
||||
import { GlobalOptions, isSuperset } from "../../types.ts";
|
||||
import { deepEqual, readTextFile } from "../../utils/utils.ts";
|
||||
|
||||
import { replaceInlineScripts, repopulateFields } from "./app.ts";
|
||||
import {
|
||||
type AppExecutionMode,
|
||||
executionModeFromAppFile,
|
||||
markAccessFromPolicy,
|
||||
replaceInlineScripts,
|
||||
repopulateFields,
|
||||
} from "./app.ts";
|
||||
import { createBundle, detectFrameworks } from "./bundle.ts";
|
||||
import { APP_BACKEND_FOLDER, RECORDINGS_FOLDER } from "./app_metadata.ts";
|
||||
import { writeIfChanged } from "../../utils/utils.ts";
|
||||
@@ -27,6 +33,7 @@ import {
|
||||
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
|
||||
|
||||
export interface AppFile {
|
||||
guests?: boolean;
|
||||
runnables?: any;
|
||||
custom_path?: string;
|
||||
public?: boolean;
|
||||
@@ -369,9 +376,7 @@ export async function pushRawApp(
|
||||
} catch {
|
||||
//ignore
|
||||
}
|
||||
if (app?.["policy"]?.["execution_mode"] == "anonymous") {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
// console.log(app);
|
||||
if (app) {
|
||||
app.policy = undefined;
|
||||
@@ -422,7 +427,7 @@ export async function pushRawApp(
|
||||
await generatingPolicy(
|
||||
appForPolicy,
|
||||
remotePath,
|
||||
localApp?.["public"] ?? false,
|
||||
executionModeFromAppFile(localApp),
|
||||
);
|
||||
|
||||
const files = await collectAppFiles(localPath);
|
||||
@@ -526,7 +531,7 @@ export async function pushRawApp(
|
||||
export async function generatingPolicy(
|
||||
app: any,
|
||||
path: string,
|
||||
publicApp: boolean,
|
||||
executionMode: AppExecutionMode,
|
||||
) {
|
||||
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
|
||||
try {
|
||||
@@ -534,7 +539,7 @@ export async function generatingPolicy(
|
||||
app.runnables,
|
||||
app.policy,
|
||||
);
|
||||
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
|
||||
app.policy.execution_mode = executionMode;
|
||||
} catch (e) {
|
||||
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
|
||||
throw e;
|
||||
|
||||
@@ -142,7 +142,7 @@ import {
|
||||
extractCurrentMapping,
|
||||
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
|
||||
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
|
||||
import { isExecutionModeAnonymous } from "../app/app.ts";
|
||||
import { markAccessFromPolicy } from "../app/app.ts";
|
||||
import {
|
||||
APP_BACKEND_FOLDER,
|
||||
generateAppLocksInternal,
|
||||
@@ -1393,9 +1393,7 @@ export function ZipFSElement(
|
||||
};
|
||||
}
|
||||
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
app.policy = undefined;
|
||||
yield {
|
||||
isDirectory: false,
|
||||
@@ -1413,9 +1411,7 @@ export function ZipFSElement(
|
||||
log.error(`Failed to parse app.yaml at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
if (rawApp?.["policy"]?.["execution_mode"] == "anonymous") {
|
||||
rawApp.public = true;
|
||||
}
|
||||
markAccessFromPolicy(rawApp);
|
||||
// console.log("rawApp", rawApp);
|
||||
rawApp.policy = undefined;
|
||||
// custom_path is derived from the file path, don't store it
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
executionModeFromAppFile,
|
||||
generatingPolicy,
|
||||
markAccessFromPolicy,
|
||||
} from "../src/commands/app/app.ts";
|
||||
|
||||
// The access mode is the one policy field a tracked app keeps; a pull then a push must
|
||||
// deploy the mode that was pulled, guest included, not a default.
|
||||
test("the access mode survives the app.yaml round trip", async () => {
|
||||
const guest: any = { policy: { execution_mode: "guest" }, value: {} };
|
||||
markAccessFromPolicy(guest);
|
||||
guest.policy = undefined;
|
||||
expect(guest.guests).toBe(true);
|
||||
expect(guest.public).toBeUndefined();
|
||||
expect(executionModeFromAppFile(guest)).toBe("guest");
|
||||
await generatingPolicy(guest, "u/test/app", executionModeFromAppFile(guest));
|
||||
expect(guest.policy.execution_mode).toBe("guest");
|
||||
|
||||
const anonymous: any = { policy: { execution_mode: "anonymous" }, value: {} };
|
||||
markAccessFromPolicy(anonymous);
|
||||
anonymous.policy = undefined;
|
||||
expect(anonymous.public).toBe(true);
|
||||
expect(executionModeFromAppFile(anonymous)).toBe("anonymous");
|
||||
|
||||
expect(executionModeFromAppFile({ policy: { execution_mode: "publisher" } })).toBe("publisher");
|
||||
expect(executionModeFromAppFile({})).toBe("publisher");
|
||||
});
|
||||
@@ -35,6 +35,8 @@ export interface SchemaProperty {
|
||||
}
|
||||
min?: number
|
||||
max?: number
|
||||
/** Height a string field's text area opens at, in rows. */
|
||||
minRows?: number
|
||||
currency?: string
|
||||
currencyLocale?: string
|
||||
multiselect?: boolean
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
|
||||
import type { AIProvider, ProviderConfig } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import ToggleButtonMore from './common/toggleButton-v2/ToggleButtonMore.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
|
||||
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
|
||||
@@ -17,9 +13,20 @@
|
||||
value: ProviderConfig | undefined
|
||||
disabled?: boolean
|
||||
actions?: Snippet
|
||||
/** The workspace the surface operates on, which a session or fork editor sets to something
|
||||
* other than the one being navigated. Resources and the models read off them are per
|
||||
* workspace, so without it this offers what the wrong one holds. */
|
||||
workspace?: string | undefined
|
||||
}
|
||||
|
||||
let { value: _uncheckedValue = $bindable(), disabled = false, actions }: Props = $props()
|
||||
let {
|
||||
value: _uncheckedValue = $bindable(),
|
||||
disabled = false,
|
||||
actions,
|
||||
workspace = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
|
||||
|
||||
let value = $derived.by(() => {
|
||||
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
|
||||
@@ -30,7 +37,13 @@
|
||||
let availableModels = $state<string[]>([])
|
||||
let filterText = $state('')
|
||||
|
||||
let modelsCache = new Map<AIProvider, string[]>()
|
||||
// Keyed by provider *and* path: two `customai` resources point at different base URLs, so they
|
||||
// do not share a model list.
|
||||
let modelsCache = new Map<string, string[]>()
|
||||
|
||||
// The resource picker offers every provider type at once and the pick is what names the kind.
|
||||
// One string for the component's life: it is what the picker queries with.
|
||||
const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',')
|
||||
|
||||
if (!_uncheckedValue) {
|
||||
_uncheckedValue = {
|
||||
@@ -57,12 +70,6 @@
|
||||
return r
|
||||
})
|
||||
|
||||
// Provider options for the toggle button group
|
||||
const providerOptions = Object.entries(AI_PROVIDERS).map(([key, details]) => ({
|
||||
value: key as AIProvider,
|
||||
label: details.label
|
||||
}))
|
||||
|
||||
async function loadModels(signal?: AbortSignal) {
|
||||
const provider = value?.kind
|
||||
const resourceValue = value?.resource
|
||||
@@ -73,20 +80,20 @@
|
||||
}
|
||||
|
||||
loading = true
|
||||
if (modelsCache.has(provider)) {
|
||||
availableModels = modelsCache.get(provider) || []
|
||||
const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}`
|
||||
if (modelsCache.has(cacheKey)) {
|
||||
availableModels = modelsCache.get(cacheKey) || []
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const workspace = get(workspaceStore) || ''
|
||||
const models = await fetchAvailableModels(resourcePath, workspace, provider, signal)
|
||||
const models = await fetchAvailableModels(resourcePath, effectiveWorkspace, provider, signal)
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
availableModels = models
|
||||
modelsCache.set(provider, models)
|
||||
modelsCache.set(cacheKey, models)
|
||||
} catch (e) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
@@ -101,15 +108,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle provider selection
|
||||
function onProviderChange(selectedProvider: AIProvider) {
|
||||
if (value) {
|
||||
value.kind = selectedProvider
|
||||
value.resource = ''
|
||||
value.model = ''
|
||||
// Reasoning effort is model-specific; reset it with the model.
|
||||
value.reasoning_effort = undefined
|
||||
/**
|
||||
* The provider kind follows the resource that was picked. Driven by the pick rather than by an
|
||||
* effect on the picker's `valueType`, which also resolves for the value the field was opened on
|
||||
* and would rewrite a saved config just for being looked at.
|
||||
*/
|
||||
function onResourcePicked(_path: string | undefined, type: string | undefined) {
|
||||
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
|
||||
// says nothing about the provider, so the kind stands.
|
||||
if (!value || !type || !(type in AI_PROVIDERS)) {
|
||||
return
|
||||
}
|
||||
if (value.kind === type) {
|
||||
return
|
||||
}
|
||||
value.kind = type as AIProvider
|
||||
// Models are per provider, and a reasoning token is per model.
|
||||
value.model = ''
|
||||
value.reasoning_effort = undefined
|
||||
}
|
||||
|
||||
// Helper functions to handle $res: prefix like ObjectResourceInput does
|
||||
@@ -165,97 +181,74 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="w-full flex flex-col gap-1 border rounded-md p-4">
|
||||
<!-- Provider Selection -->
|
||||
<ToggleButtonGroup
|
||||
selected={value?.kind}
|
||||
onSelected={onProviderChange}
|
||||
{disabled}
|
||||
wrap
|
||||
tabListClass="w-full"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#each providerOptions.slice(0, 3) as option}
|
||||
<ToggleButton value={option.value} label={option.label} {item} />
|
||||
{/each}
|
||||
<ToggleButtonMore
|
||||
class="ml-auto"
|
||||
btnText={providerOptions.findIndex((p) => p.value === value?.kind) >= 3 ? '' : 'More'}
|
||||
togglableItems={providerOptions.slice(3)}
|
||||
{item}
|
||||
bind:selected={() => value?.kind, (v) => v && onProviderChange(v)}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<!-- Resource Selection -->
|
||||
<div class="flex flex-col rounded-md pt-2 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-normal text-primary">resource</p>
|
||||
<ResourcePicker
|
||||
bind:value={
|
||||
() => resourceValueToPath(value?.resource),
|
||||
(v) => {
|
||||
if (value) {
|
||||
value.resource = pathToResourceValue(v) ?? ''
|
||||
}
|
||||
<div class="w-full flex flex-col gap-3 border rounded-md p-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Resource</span>
|
||||
<!-- No auto-select: this picker spans every provider type, so a single candidate means "the
|
||||
only AI resource in the workspace" rather than "the only one of the kind this agent uses".
|
||||
Taking it would redefine the agent's provider and drop its model, on open and unasked. -->
|
||||
<ResourcePicker
|
||||
bind:value={
|
||||
() => resourceValueToPath(value?.resource),
|
||||
(v) => {
|
||||
if (value) {
|
||||
value.resource = pathToResourceValue(v) ?? ''
|
||||
}
|
||||
}
|
||||
resourceType={value?.kind}
|
||||
disabled={disabled || !value?.kind}
|
||||
placeholder="Select resource"
|
||||
selectFirst={true}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
resourceType={providerResourceTypes}
|
||||
{disabled}
|
||||
{workspace}
|
||||
placeholder="Select an AI provider resource"
|
||||
selectFirst={false}
|
||||
onValueChange={onResourcePicked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-normal text-secondary">Model</span>
|
||||
<Select
|
||||
{items}
|
||||
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
|
||||
placeholder="Select model"
|
||||
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
|
||||
onCreateItem={(r) => {
|
||||
availableModels.push(r)
|
||||
if (value) value.model = r
|
||||
}}
|
||||
createText="Press enter to use custom model"
|
||||
{loading}
|
||||
clearable={false}
|
||||
noItemsMsg={'No models available'}
|
||||
bind:filterText
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if value?.model}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-normal text-primary">model</p>
|
||||
<Select
|
||||
{items}
|
||||
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
|
||||
placeholder="Select model"
|
||||
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
|
||||
onCreateItem={(r) => {
|
||||
availableModels.push(r)
|
||||
if (value) value.model = r
|
||||
}}
|
||||
createText="Press enter to use custom model"
|
||||
{loading}
|
||||
clearable={false}
|
||||
noItemsMsg={'No models available'}
|
||||
bind:filterText
|
||||
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
|
||||
<AIReasoningEffortPicker
|
||||
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
|
||||
providerConfig={value}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reasoning Effort (shown once a model is selected) -->
|
||||
{#if value?.model}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-normal text-primary">reasoning effort</p>
|
||||
<AIReasoningEffortPicker
|
||||
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
|
||||
providerConfig={value}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Use as Default Checkbox -->
|
||||
<div class="flex justify-end pt-1">
|
||||
<Toggle
|
||||
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
|
||||
bind:checked={useAsDefault}
|
||||
options={{ right: 'Use as personal default for other new agents' }}
|
||||
size="xs"
|
||||
on:change={(e) => {
|
||||
if (!e.detail) {
|
||||
removeConfig()
|
||||
} else {
|
||||
saveConfig(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Toggle
|
||||
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
|
||||
bind:checked={useAsDefault}
|
||||
options={{ right: 'Use as personal default for other new agents' }}
|
||||
size="xs"
|
||||
on:change={(e) => {
|
||||
if (!e.detail) {
|
||||
removeConfig()
|
||||
} else {
|
||||
saveConfig(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{@render actions?.()}
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
} from '$lib/gen'
|
||||
import { emptyString, truncateRev, urlize } from '$lib/utils'
|
||||
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
|
||||
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy, tick } from 'svelte'
|
||||
import Path from './Path.svelte'
|
||||
import { Button, RadioCard, Skeleton } from './common'
|
||||
import { ListRow, RadioCard, Skeleton } from './common'
|
||||
import { useListHighlight } from './common/listRow/listHighlight.svelte'
|
||||
import ApiConnectForm from './ApiConnectForm.svelte'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
import WhitelistIp from './WhitelistIp.svelte'
|
||||
@@ -42,7 +43,6 @@
|
||||
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
||||
import Label from './Label.svelte'
|
||||
import ResourcePathHint from './ResourcePathHint.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
step?: number
|
||||
@@ -1027,15 +1027,8 @@
|
||||
// Both lists start undefined and render skeletons; "nothing found" only means something
|
||||
// once they have landed.
|
||||
let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined)
|
||||
let highlightedIndex = $state(-1)
|
||||
const rowDomId = (index: number) => `resource-type-row-${index}`
|
||||
|
||||
// Set at hover time rather than up front, so only the descriptions the row actually cut
|
||||
// off carry a tooltip.
|
||||
function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) {
|
||||
const el = e.currentTarget
|
||||
el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : ''
|
||||
}
|
||||
const oauthRowOffset = $derived(customKeys.length)
|
||||
const otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0))
|
||||
|
||||
@@ -1054,53 +1047,23 @@
|
||||
return best
|
||||
}
|
||||
|
||||
// Filtering reshuffles the rows under the highlight: point it at the best match so Enter
|
||||
// takes the top hit, and drop it entirely once the filter is cleared.
|
||||
$effect(() => {
|
||||
navItems
|
||||
filter
|
||||
untrack(() => (highlightedIndex = searching ? bestMatchIndex() : -1))
|
||||
const highlight = useListHighlight({
|
||||
count: () => navItems.length,
|
||||
rowId: rowDomId,
|
||||
// Sections are rendered in a fixed order, so the best match is not necessarily the
|
||||
// first row; Enter should still take the top hit.
|
||||
restingIndex: () => (searching ? bestMatchIndex() : -1),
|
||||
onActivate: (index) => {
|
||||
const item = navItems[index]
|
||||
if (!item) return
|
||||
item.oauth ? connectOauth(item.key) : selectFromOthers(item.key)
|
||||
},
|
||||
activateEnterFrom: [SEARCH_INPUT_ID]
|
||||
})
|
||||
|
||||
// Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each one,
|
||||
// which would drag the highlight back under the cursor as the arrow keys move it. Only a
|
||||
// real pointer move hands the highlight back to the mouse.
|
||||
let pointerOwnsHighlight = $state(true)
|
||||
|
||||
function highlightHovered(index: number) {
|
||||
if (pointerOwnsHighlight) highlightedIndex = index
|
||||
}
|
||||
|
||||
function moveHighlight(delta: number) {
|
||||
const count = navItems.length
|
||||
if (count === 0) return
|
||||
pointerOwnsHighlight = false
|
||||
// Rows are tabbable buttons, so focus can sit on one. Enter then activates whatever is
|
||||
// focused, which has to stay the highlighted row.
|
||||
const rowWasFocused = document.activeElement?.id?.startsWith('resource-type-row-') ?? false
|
||||
highlightedIndex =
|
||||
highlightedIndex < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: count - 1
|
||||
: (highlightedIndex + delta + count) % count
|
||||
const row = document.getElementById(rowDomId(highlightedIndex))
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
if (rowWasFocused) row?.focus()
|
||||
}
|
||||
|
||||
function onListKeydown(e: KeyboardEvent) {
|
||||
if (step !== 1) return
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
moveHighlight(e.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (e.key === 'Enter' && (e.target as HTMLElement)?.id === SEARCH_INPUT_ID) {
|
||||
// A focused row activates itself on Enter; this covers Enter typed in the search field.
|
||||
const item = navItems[highlightedIndex]
|
||||
if (!item) return
|
||||
e.preventDefault()
|
||||
item.oauth ? connectOauth(item.key) : selectFromOthers(item.key)
|
||||
}
|
||||
highlight.onKeydown(e)
|
||||
}
|
||||
|
||||
let editScopes = $state(false)
|
||||
@@ -1132,7 +1095,7 @@
|
||||
<div
|
||||
class="flex flex-col h-full min-h-0"
|
||||
onkeydown={onListKeydown}
|
||||
onpointermove={() => (pointerOwnsHighlight = true)}
|
||||
onpointermove={highlight.pointerMoved}
|
||||
>
|
||||
<div class="shrink-0 pb-4">
|
||||
<div class="relative w-full">
|
||||
@@ -1146,28 +1109,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet resourceRow(key: string)}
|
||||
<div class="flex flex-row items-center gap-4 w-full min-w-0 text-left">
|
||||
<div class="shrink-0">
|
||||
<IconedResourceType name={key} silent width="20px" height="20px" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<div class="flex flex-row items-baseline gap-2 min-w-0">
|
||||
<span class="truncate leading-5">{resourceTypeDisplayName(key)}</span>
|
||||
<span class="shrink-0 font-mono text-2xs font-normal text-hint">{key}</span>
|
||||
</div>
|
||||
{#if resourceTypeDescriptions[key]}
|
||||
<span
|
||||
class="truncate text-xs font-normal leading-4 text-secondary"
|
||||
onmouseenter={titleIfTruncated}
|
||||
>
|
||||
{plainDescription(resourceTypeDescriptions[key])}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet sectionHeading(title: string, count: number)}
|
||||
<h2 class="mb-3 text-2xs font-normal uppercase text-secondary">
|
||||
{title}{#if searching}<span class="ml-2 text-hint">{count}</span>{/if}
|
||||
@@ -1175,26 +1116,29 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet resourceButton(key: string, index: number, oauth: boolean)}
|
||||
<Button
|
||||
{#snippet icon()}
|
||||
<IconedResourceType name={key} silent width="20px" height="20px" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{resourceTypeDisplayName(key)}</span>
|
||||
<span class="shrink-0 font-mono text-2xs font-normal text-hint">{key}</span>
|
||||
{/snippet}
|
||||
{#snippet subtitle()}
|
||||
{plainDescription(resourceTypeDescriptions[key])}
|
||||
{/snippet}
|
||||
<!-- `highlighted`: the pointer moves the same highlight the arrow keys move, so
|
||||
the row's own hover is off — two lit rows at once would be ambiguous. -->
|
||||
<ListRow
|
||||
id={rowDomId(index)}
|
||||
aiId={`app-connect-inner-${oauth ? 'oauth-' : ''}${key}`}
|
||||
aiDescription={`Connect to ${key}${oauth ? ' with the instance OAuth client' : ''}`}
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
btnClasses={twMerge(
|
||||
'justify-start px-3 h-auto py-3 scroll-my-2',
|
||||
// The pointer moves the same highlight the arrow keys move, so the variant's
|
||||
// own hover is off: two lit rows at once would be ambiguous.
|
||||
'hover:bg-transparent',
|
||||
// `!` so the highlight also wins on the row the pointer is over, whose own
|
||||
// hover was turned off just above.
|
||||
index === highlightedIndex ? '!bg-surface-hover' : ''
|
||||
)}
|
||||
on:mouseenter={() => highlightHovered(index)}
|
||||
on:click={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
|
||||
>
|
||||
{@render resourceRow(key)}
|
||||
</Button>
|
||||
{icon}
|
||||
{title}
|
||||
subtitle={resourceTypeDescriptions[key] ? subtitle : undefined}
|
||||
highlighted={index === highlight.index}
|
||||
onMouseEnter={() => highlight.hovered(index)}
|
||||
onClick={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
@@ -1213,7 +1157,7 @@
|
||||
{#if customKeys.length > 0}
|
||||
<section>
|
||||
{@render sectionHeading('Custom resource types', customKeys.length)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each customKeys as key, i}
|
||||
{@render resourceButton(key, i, false)}
|
||||
{/each}
|
||||
@@ -1227,7 +1171,7 @@
|
||||
'Instance-configured OAuth APIs',
|
||||
rankedConnects?.length ?? 0
|
||||
)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if rankedConnects}
|
||||
{#each rankedConnects as { key }, i}
|
||||
{@render resourceButton(key, oauthRowOffset + i, true)}
|
||||
@@ -1259,7 +1203,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if rankedConnectsManual}
|
||||
{#each otherKeys as key, i}
|
||||
{@render resourceButton(key, otherRowOffset + i, false)}
|
||||
@@ -1532,7 +1476,7 @@
|
||||
>
|
||||
|
||||
{#if editScopes}
|
||||
<OauthScopes bind:scopes />
|
||||
<OauthScopes bind:scopes options={registryEntry()?.scope_options} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each scopes as scope}
|
||||
|
||||
@@ -1447,7 +1447,7 @@
|
||||
{showSchemaExplorer}
|
||||
/>
|
||||
{:else if inputCat == 'ai-provider'}
|
||||
<AIProviderPicker bind:value {disabled} {actions} />
|
||||
<AIProviderPicker bind:value {disabled} {actions} {workspace} />
|
||||
{:else if inputCat == 'email'}
|
||||
<input
|
||||
{autofocus}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
|
||||
import FlowModuleSchemaMap from './flows/map/FlowModuleSchemaMap.svelte'
|
||||
import FlowEditorPanel from './flows/content/FlowEditorPanel.svelte'
|
||||
import AgentEditorModal from './flows/content/AgentEditorModal.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { findModuleInFlow } from './flows/flowDiff'
|
||||
import { writable } from 'svelte/store'
|
||||
@@ -1310,5 +1311,8 @@
|
||||
</Splitpanes>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Outside the panel, which is keyed on the selection: the dialog stays on the step it was
|
||||
opened for while the graph is used behind it. -->
|
||||
<AgentEditorModal enableAi owns={() => true} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import FlowRestartButton from './FlowRestartButton.svelte'
|
||||
import { useNestedRestartState } from './useNestedRestartState.svelte'
|
||||
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
|
||||
import { agentStreamingEnabled } from './flows/agentFormFields'
|
||||
|
||||
interface Props {
|
||||
previewMode: 'upTo' | 'whole'
|
||||
@@ -163,11 +164,8 @@
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flowStore.val.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
return (
|
||||
lastModule?.value?.type === 'aiagent' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.value === true
|
||||
)
|
||||
if (lastModule?.value?.type !== 'aiagent') return false
|
||||
return agentStreamingEnabled(lastModule.value)
|
||||
})
|
||||
|
||||
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
label?: string
|
||||
/** Replaces the label header, so a setting's own toggle can name the field. */
|
||||
header?: Snippet
|
||||
/** Renders after the label: a button to unset the field, a badge. */
|
||||
labelExtra?: Snippet
|
||||
/** Drop the schema's description paragraph, for a form that carries it in a tooltip. */
|
||||
hideDescription?: boolean
|
||||
/** Keep the connect and transform controls out of the way until the row is reached, unless
|
||||
* the field already holds something the controls are needed to read. */
|
||||
subtleControls?: boolean
|
||||
/** The kind this field always holds, for a value that doesn't carry a `type` of its
|
||||
* own — a flow predicate is stored as a bare `{ expr }`. */
|
||||
argType?: InputTransform['type']
|
||||
@@ -76,6 +83,14 @@
|
||||
/** Hide the static/expression switch, for a field that only ever holds one kind.
|
||||
* The connect button and the AI helper stay. */
|
||||
noDynamicToggle?: boolean
|
||||
/** Hide the connect button, for a surface with nothing to connect to. Distinct from
|
||||
* `noDynamicToggle`, which a field forced to an expression also sets. */
|
||||
noConnect?: boolean
|
||||
/** Drop the expression option, and every affordance that writes one: an expression reaching
|
||||
* such a field is stored and deployed like any other, whichever control put it there. The
|
||||
* rest of the switch stays, so a field can still be AI-filled or static. A field already
|
||||
* holding an expression keeps the option, or it could not be switched off it. */
|
||||
noJavascript?: boolean
|
||||
/** Replaces the default StepInputGen, for a field with its own AI helper. That
|
||||
* helper drives `suggestion` (its ghost text) and `aiOnKeyUp` (Tab to accept),
|
||||
* which the built-in one reaches through `stepInputGen` instead. */
|
||||
@@ -104,6 +119,9 @@
|
||||
argName = $bindable(),
|
||||
label = undefined,
|
||||
header = undefined,
|
||||
labelExtra = undefined,
|
||||
hideDescription = false,
|
||||
subtleControls = false,
|
||||
argType = undefined,
|
||||
collapsed = false,
|
||||
animateAppear = false,
|
||||
@@ -118,6 +136,8 @@
|
||||
variableEditor = undefined,
|
||||
itemPicker = undefined,
|
||||
noDynamicToggle = false,
|
||||
noConnect = false,
|
||||
noJavascript = false,
|
||||
aiGen = undefined,
|
||||
suggestion = $bindable(),
|
||||
focused = $bindable(),
|
||||
@@ -183,6 +203,11 @@
|
||||
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
|
||||
)
|
||||
|
||||
// A `${}` field is static text that interpolates JavaScript, so it is only on offer where
|
||||
// expressions are. Elsewhere the same field is plain static: labelled `static`, edited in the
|
||||
// ordinary input, with no `${...}` hint promising an escape hatch that isn't there.
|
||||
let staticTemplateOffered = $derived(isStaticTemplate(inputCat) && !noJavascript)
|
||||
|
||||
// `argType` wins over whatever the value carries: a predicate has no `type` field, so
|
||||
// inferring would land it on the static input instead of the expression editor.
|
||||
const argKind = $derived(argType ?? arg?.type)
|
||||
@@ -250,7 +275,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (isCodeInjection(rawValue)) {
|
||||
// `${...}` becomes a JavaScript transform, so it is only read as one where such a transform
|
||||
// can be stored — the same condition `staticTemplateOffered` renders under. Elsewhere the
|
||||
// text stays what was typed, rather than turning into code the store then drops or, worse,
|
||||
// keeps pointing at a flow context this value will never be evaluated in.
|
||||
if (isCodeInjection(rawValue) && !noJavascript) {
|
||||
arg.expr = getDefaultExpr(
|
||||
argName,
|
||||
previousModuleId,
|
||||
@@ -273,7 +302,12 @@
|
||||
|
||||
let codeInjectionDetected = $state(false)
|
||||
|
||||
function checkCodeInjection(rawValue: string) {
|
||||
// A static value is whatever JSON the field holds, so it need not be a string, and the caller
|
||||
// runs inside an effect: throwing here would take the whole form down rather than one field.
|
||||
function checkCodeInjection(rawValue: unknown): { word: string; value: string }[] | undefined {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
if (!arg || !rawValue || rawValue.length < 3 || !dynamicTemplateRegexPairs) {
|
||||
return undefined
|
||||
}
|
||||
@@ -307,6 +341,7 @@
|
||||
isStaticTemplate(inputCat) &&
|
||||
propertyType == 'static' &&
|
||||
!noDynamicToggle &&
|
||||
!noJavascript &&
|
||||
codeInjectionDetected
|
||||
) {
|
||||
setJavaScriptExpr(arg.value)
|
||||
@@ -561,8 +596,16 @@
|
||||
untrack(() => handleFieldVisibility(schema, arg, otherArgs))
|
||||
})
|
||||
let connecting = $derived($propPickerConfig?.propName == argName)
|
||||
let fieldDescription = $derived(
|
||||
hideDescription ? undefined : schema?.properties?.[argName]?.description
|
||||
)
|
||||
// Fading the controls away is only safe while the row itself says what it holds. An expression
|
||||
// or an AI-filled value is only legible from the toggle, so those keep it on screen.
|
||||
let controlsPinned = $derived(connecting || propertyType !== 'static' || Boolean(suggestion))
|
||||
// Its picker builds an expression, so it goes with the expression option.
|
||||
let shouldShowS3ArrayHelper = $derived(
|
||||
inputCat === 'list' &&
|
||||
!noJavascript &&
|
||||
['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType)
|
||||
)
|
||||
|
||||
@@ -600,7 +643,9 @@
|
||||
type={schema.properties?.[argName]?.type}
|
||||
/>
|
||||
|
||||
{#if isStaticTemplate(inputCat)}
|
||||
{@render labelExtra?.()}
|
||||
|
||||
{#if staticTemplateOffered}
|
||||
<div>
|
||||
<span
|
||||
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
|
||||
@@ -616,13 +661,17 @@
|
||||
</div>
|
||||
<!-- Nothing to connect to or switch while collapsed: there is no value yet. -->
|
||||
<div
|
||||
class="flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors {collapsed
|
||||
? 'hidden'
|
||||
: ''}"
|
||||
class={twMerge(
|
||||
'flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors',
|
||||
collapsed ? 'hidden' : '',
|
||||
subtleControls && !controlsPinned
|
||||
? 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{#if aiGen}
|
||||
{@render aiGen()}
|
||||
{:else if enableAi}
|
||||
{:else if enableAi && !noJavascript}
|
||||
<StepInputGen
|
||||
bind:this={stepInputGen}
|
||||
{focused}
|
||||
@@ -639,7 +688,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if propPickerWrapperContext}
|
||||
{#if propPickerWrapperContext && !noConnect}
|
||||
<FlowPlugConnect
|
||||
wrapperClasses={twMerge(
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
@@ -704,6 +753,11 @@
|
||||
if (arg) {
|
||||
arg.value = codeToStaticTemplate(arg.expr)
|
||||
arg.expr = undefined
|
||||
// Stated here, as the other branches state it. `setPropertyType`
|
||||
// only writes a type when the text is an interpolation, so leaving
|
||||
// it to that call means a field switched off `ai` keeps carrying
|
||||
// `ai` and reads straight back as it on the next render.
|
||||
arg.type = 'static'
|
||||
}
|
||||
setPropertyType(arg?.value)
|
||||
} else if (inputCat == 'list' || inputCat == 'object') {
|
||||
@@ -727,22 +781,33 @@
|
||||
arg.expr = undefined
|
||||
}
|
||||
}
|
||||
// On a field the agent can fill, "static with no value" is itself the
|
||||
// AI state (see `getPropertyType`), so leaving the value unset reads
|
||||
// this choice straight back as AI and the field can never be typed
|
||||
// into. An empty value of the field's own kind is what makes "I will
|
||||
// supply this one" representable.
|
||||
if (fieldAllowsAi && arg && arg.value === undefined) {
|
||||
arg.value = isStaticTemplate(inputCat) ? '' : null
|
||||
}
|
||||
propertyType = 'static'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
{#if fieldAllowsAi}
|
||||
<!-- `h-full`, as its siblings have: the group is a row shorter than a `sm`
|
||||
button, and without it this one stands proud of the others. -->
|
||||
<ToggleButton
|
||||
small
|
||||
size="sm"
|
||||
label="AI"
|
||||
value="ai"
|
||||
tooltip="Let the AI agent fill this field dynamically"
|
||||
{item}
|
||||
class="h-full text-xs"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isStaticTemplate(inputCat)}
|
||||
{#if staticTemplateOffered}
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
tooltip={`Write text or surround javascript with \`\$\{\` and \`\}\`. Use \`results\` to connect to another node\'s output.`}
|
||||
@@ -761,7 +826,9 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if codeInjectionDetected && propertyType == 'static'}
|
||||
{#if noJavascript && propertyType !== 'javascript'}
|
||||
<!-- nothing: the expression option is not offered here -->
|
||||
{:else if codeInjectionDetected && propertyType == 'static'}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
@@ -847,19 +914,19 @@
|
||||
This field will be filled by the AI agent dynamically
|
||||
</span>
|
||||
</div>
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
{#if fieldDescription}
|
||||
<div class="text-xs italic py-1 text-hint">
|
||||
<pre class="font-main whitespace-normal">
|
||||
{schema.properties[argName].description}
|
||||
{fieldDescription}
|
||||
</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
{:else if staticTemplateOffered && propertyType == 'static' && !noDynamicToggle}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
{#if fieldDescription}
|
||||
<div class="text-xs text-secondary">
|
||||
<pre class="font-main whitespace-normal">
|
||||
{schema.properties[argName].description}
|
||||
{fieldDescription}
|
||||
</pre>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -868,6 +935,8 @@
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
minRows={schema?.properties?.[argName]?.minRows}
|
||||
placeholder={schema?.properties?.[argName]?.placeholder}
|
||||
on:focus={onFocus}
|
||||
on:blur={() => {
|
||||
focused = false
|
||||
@@ -898,7 +967,13 @@
|
||||
}}
|
||||
label={argName}
|
||||
bind:editor={monaco}
|
||||
bind:description={schema.properties[argName].description}
|
||||
bind:description={
|
||||
() => fieldDescription,
|
||||
(v) => {
|
||||
const property = schema.properties?.[argName]
|
||||
if (!hideDescription && property) property.description = v
|
||||
}
|
||||
}
|
||||
bind:value={arg.value}
|
||||
type={schema.properties[argName].type}
|
||||
oneOf={schema.properties[argName].oneOf}
|
||||
@@ -995,11 +1070,9 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if argName && schema?.properties?.[argName]?.description}
|
||||
{#if fieldDescription}
|
||||
<div class="text-xs italic py-1 text-secondary">
|
||||
<pre class="font-main whitespace-normal"
|
||||
>{schema.properties[argName].description}</pre
|
||||
>
|
||||
<pre class="font-main whitespace-normal">{fieldDescription}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { VariableService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { watch } from 'runed'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { Button } from './common'
|
||||
import ItemPicker from './ItemPicker.svelte'
|
||||
import VariableEditor from './VariableEditor.svelte'
|
||||
|
||||
interface Props {
|
||||
/** The transforms being edited. A picked variable is written into `pickForField`'s. */
|
||||
args?: Record<string, any>
|
||||
/** Which field the open picker is picking for, set by whichever row opened it. */
|
||||
pickForField?: string | undefined
|
||||
workspace?: string | undefined
|
||||
/** Handed back so every field's form can open them. */
|
||||
itemPicker?: ItemPicker | undefined
|
||||
variableEditor?: VariableEditor | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
args = {},
|
||||
pickForField = undefined,
|
||||
workspace = undefined,
|
||||
itemPicker = $bindable(),
|
||||
variableEditor = $bindable()
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
|
||||
watch(
|
||||
() => ws,
|
||||
() => itemPicker?.reloadItems()
|
||||
)
|
||||
</script>
|
||||
|
||||
<ItemPicker
|
||||
bind:this={itemPicker}
|
||||
pickCallback={(path, _) => {
|
||||
if (pickForField) {
|
||||
args[pickForField].value = '$var:' + path
|
||||
}
|
||||
}}
|
||||
itemName="Variable"
|
||||
extraField="path"
|
||||
loadItems={async () =>
|
||||
(await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({
|
||||
name: x.path,
|
||||
...x
|
||||
}))}
|
||||
>
|
||||
{#snippet submission()}
|
||||
<div class="flex flex-row-reverse w-full border-t border-gray-200 rounded-bl-lg rounded-br-lg">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
variableEditor?.initNew?.()
|
||||
}}
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ItemPicker>
|
||||
|
||||
<VariableEditor bind:this={variableEditor} workspace={ws} />
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Schema } from '$lib/common'
|
||||
import { CancelError, VariableService, WorkspaceService, type InputTransform } from '$lib/gen'
|
||||
import { type InputTransform } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import { resource, watch } from 'runed'
|
||||
import { Button } from './common'
|
||||
import StepInputsGen from './copilot/StepInputsGen.svelte'
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
import InputTransformForm from './InputTransformForm.svelte'
|
||||
import ItemPicker from './ItemPicker.svelte'
|
||||
import VariableEditor from './VariableEditor.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import InputTransformPickers from './InputTransformPickers.svelte'
|
||||
import { useS3StorageConfigured } from './inputTransformEnv.svelte'
|
||||
import type ItemPicker from './ItemPicker.svelte'
|
||||
import type VariableEditor from './VariableEditor.svelte'
|
||||
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +21,9 @@
|
||||
previousModuleId?: string | undefined
|
||||
filter?: string[] | undefined
|
||||
noDynamicToggle?: boolean
|
||||
/** Passed to every field. See `InputTransformForm` for what each one drops. */
|
||||
noConnect?: boolean
|
||||
noJavascript?: boolean
|
||||
pickableProperties?: PickableProperties | undefined
|
||||
enableAi?: boolean
|
||||
class?: string
|
||||
@@ -40,6 +42,8 @@
|
||||
previousModuleId = undefined,
|
||||
filter = undefined,
|
||||
noDynamicToggle = false,
|
||||
noConnect = false,
|
||||
noJavascript = false,
|
||||
pickableProperties = undefined,
|
||||
enableAi = false,
|
||||
class: clazz = '',
|
||||
@@ -82,37 +86,7 @@
|
||||
let itemPicker: ItemPicker | undefined = $state(undefined)
|
||||
let variableEditor: VariableEditor | undefined = $state(undefined)
|
||||
|
||||
const settings = resource(
|
||||
() => ws,
|
||||
async (ws, _previousWs, { onCleanup }) => {
|
||||
if (!ws) return undefined
|
||||
const req = WorkspaceService.getPublicSettings({ workspace: ws })
|
||||
// `resource` keeps whatever lands last: cancel a superseded request so a slow
|
||||
// reply for a workspace we have left cannot overwrite the current one.
|
||||
onCleanup(() => req.cancel())
|
||||
try {
|
||||
return { ws, settings: await req }
|
||||
} catch (err) {
|
||||
if (!(err instanceof CancelError)) {
|
||||
console.error('Failed to fetch workspace settings:', err)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
// Assume configured until this workspace's own answer lands: the warning must not
|
||||
// linger from the previous workspace, nor appear merely because the fetch failed.
|
||||
let s3StorageConfigured = $derived.by(() => {
|
||||
const loaded = settings.current
|
||||
return loaded && loaded.ws === ws
|
||||
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
|
||||
: true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ws,
|
||||
() => itemPicker?.reloadItems()
|
||||
)
|
||||
const s3Storage = useS3StorageConfigured(() => ws)
|
||||
|
||||
let keys: string[] = $state([])
|
||||
$effect(() => {
|
||||
@@ -125,7 +99,10 @@
|
||||
</script>
|
||||
|
||||
<div class="w-full mb-6 {clazz}">
|
||||
{#if enableAi}
|
||||
<!-- Not offered on a tool: the fields it fills are exactly the ones left empty for the agent
|
||||
to fill at run time, and filling one rewrites it into an expression, which takes it out of
|
||||
the schema the model is given. -->
|
||||
{#if enableAi && !isAgentTool}
|
||||
<div class="pt-2">
|
||||
<StepInputsGen
|
||||
{pickableProperties}
|
||||
@@ -160,12 +137,14 @@
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
{noDynamicToggle}
|
||||
{noConnect}
|
||||
{noJavascript}
|
||||
{pickableProperties}
|
||||
{enableAi}
|
||||
{helperScript}
|
||||
{isAgentTool}
|
||||
{allowedAiTransforms}
|
||||
{s3StorageConfigured}
|
||||
s3StorageConfigured={s3Storage.current}
|
||||
{chatInputEnabled}
|
||||
{workspace}
|
||||
otherArgs={Object.fromEntries(
|
||||
@@ -180,35 +159,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ItemPicker
|
||||
bind:this={itemPicker}
|
||||
pickCallback={(path, _) => {
|
||||
if (pickForField) {
|
||||
args[pickForField].value = '$var:' + path
|
||||
}
|
||||
}}
|
||||
itemName="Variable"
|
||||
extraField="path"
|
||||
loadItems={async () =>
|
||||
(await VariableService.listVariable({ workspace: ws ?? '' })).map((x) => ({
|
||||
name: x.path,
|
||||
...x
|
||||
}))}
|
||||
>
|
||||
{#snippet submission()}
|
||||
<div class="flex flex-row-reverse w-full border-t border-gray-200 rounded-bl-lg rounded-br-lg">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
variableEditor?.initNew?.()
|
||||
}}
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ItemPicker>
|
||||
|
||||
<VariableEditor bind:this={variableEditor} workspace={ws} />
|
||||
<InputTransformPickers {args} {pickForField} {workspace} bind:itemPicker bind:variableEditor />
|
||||
|
||||
@@ -1059,7 +1059,11 @@
|
||||
<li>instance base URL</li>
|
||||
<li>login type usage (login type, count)</li>
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li>user usage (author count, operator count)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, the distinct guests of the last 30 days,
|
||||
the seats they add past the free allowance, and the workspaces that allow
|
||||
guests)</li
|
||||
>
|
||||
<li>superadmin email addresses</li>
|
||||
<li>development instance status</li>
|
||||
</ul>
|
||||
@@ -1120,7 +1124,11 @@
|
||||
<li>job usage (language, total duration, count)</li>
|
||||
<li>login type usage (login type, count)</li>
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li>user usage (author count, operator count)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, the distinct guests of the last 30 days,
|
||||
the seats they add past the free allowance, and the workspaces that allow
|
||||
guests)</li
|
||||
>
|
||||
<li>development instance status</li>
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
|
||||
@@ -63,10 +63,18 @@
|
||||
firstTime?: boolean
|
||||
autoRedirect?: boolean
|
||||
onLoginSuccess?: () => void
|
||||
/** A refusal the popup relayed back, in the server's words. */
|
||||
onLoginError?: (message: string) => void
|
||||
preview?: LoginPreview
|
||||
/** Reports the instance's login options once loaded, so the page around the card can
|
||||
* adapt its heading: a third-party login also creates the account on first use. */
|
||||
onOptionsLoaded?: (options: { hasThirdParty: boolean }) => void
|
||||
/** `<workspace>/<app_path>` when this sign-in is someone opening an app that is
|
||||
* open to guests. A third-party login then mints a guest session -- no account,
|
||||
* no seat -- instead of creating a user. Omitting it is what promotion is: the
|
||||
* same sign-in without this, which provisions them for real. Password login
|
||||
* ignores it: a guest has no stored credential to check. */
|
||||
guestApp?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -78,8 +86,10 @@
|
||||
firstTime = false,
|
||||
autoRedirect = true,
|
||||
onLoginSuccess = undefined,
|
||||
onLoginError = undefined,
|
||||
preview = undefined,
|
||||
onOptionsLoaded = undefined
|
||||
onOptionsLoaded = undefined,
|
||||
guestApp = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// The harness never takes effect in a production bundle, whatever a caller passes.
|
||||
@@ -477,7 +487,9 @@
|
||||
|
||||
function processPopupData(data) {
|
||||
if (data.type === 'error') {
|
||||
clearPendingLoginMethod()
|
||||
sendUserToast(data.error, true)
|
||||
onLoginError?.(data.error)
|
||||
} else if (data.type === 'success') {
|
||||
finishOauthFlow('postMessage')
|
||||
}
|
||||
@@ -539,7 +551,11 @@
|
||||
if (previewConfig) return true
|
||||
markLoginMethodPending({ kind: 'oauth', provider })
|
||||
persistRd()
|
||||
let url = base + '/api/oauth/login/' + provider + (popup ? '?close=true' : '')
|
||||
const params = new URLSearchParams()
|
||||
if (popup) params.set('close', 'true')
|
||||
if (guestApp) params.set('guest_app', guestApp)
|
||||
const query = params.size > 0 ? '?' + params.toString() : ''
|
||||
let url = base + '/api/oauth/login/' + provider + query
|
||||
console.log('storeRedirect', popup, url)
|
||||
|
||||
if (popup) {
|
||||
@@ -585,8 +601,14 @@
|
||||
console.log('oauth: popup closed before login completed')
|
||||
return
|
||||
}
|
||||
// A guest session is pinned to its workspace and cannot answer the global
|
||||
// probe; an ordinary session for a non-member cannot answer the workspace
|
||||
// one. Either answering means the popup signed someone in.
|
||||
const guestWorkspace = guestApp?.split('/')[0]
|
||||
const probes: Promise<unknown>[] = [UserService.getCurrentEmail()]
|
||||
if (guestWorkspace) probes.push(UserService.whoami({ workspace: guestWorkspace }))
|
||||
try {
|
||||
await UserService.getCurrentEmail()
|
||||
await Promise.any(probes)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -610,7 +632,17 @@
|
||||
// full URLs (e.g. the page URL from /a/[...path]) are reduced to their
|
||||
// path component first. The backend re-validates. Cross-origin or
|
||||
// otherwise unsafe values fall through to the localStorage fallback.
|
||||
const safePath = toSameOriginRelativePath(rd)
|
||||
// A guest entry rides in the same RelayState as a `guest_app` query parameter
|
||||
// the ACS lifts out: SAML never passes through `/api/oauth/login/<client>`,
|
||||
// where the OAuth path hands its target to the server.
|
||||
let safePath = toSameOriginRelativePath(rd)
|
||||
if (guestApp && safePath) {
|
||||
const hashAt = safePath.indexOf('#')
|
||||
const pathAndQuery = hashAt === -1 ? safePath : safePath.slice(0, hashAt)
|
||||
const hash = hashAt === -1 ? '' : safePath.slice(hashAt)
|
||||
const sep = pathAndQuery.includes('?') ? '&' : '?'
|
||||
safePath = `${pathAndQuery}${sep}guest_app=${encodeURIComponent(guestApp)}${hash}`
|
||||
}
|
||||
if (safePath) {
|
||||
try {
|
||||
const url = new URL(saml)
|
||||
@@ -621,6 +653,13 @@
|
||||
console.error('Could not set SAML RelayState', e)
|
||||
}
|
||||
}
|
||||
if (guestApp && !relayStateSet) {
|
||||
// Without the target the callback provisions an account, so a guest
|
||||
// sign-in that cannot carry it does not start.
|
||||
clearPendingLoginMethod()
|
||||
sendUserToast('Could not start sign-in, please try again.', true)
|
||||
return false
|
||||
}
|
||||
// Only use the localStorage fallback when RelayState is NOT carrying the
|
||||
// deep link. With RelayState the ACS redirects straight to the target and
|
||||
// /user/login never consumes/clears the key, so a persisted value would
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { getResourceTypes } from './resourceTypesStore'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { AGENT_FIELDS, initialVisibleAgentFields } from './flows/agentFormFields'
|
||||
|
||||
interface Props {
|
||||
schema: Schema | { properties?: Record<string, any>; required?: string[] }
|
||||
@@ -43,15 +44,45 @@
|
||||
isValid = allTrue(inputCheck) ?? false
|
||||
})
|
||||
|
||||
/** An agent asks for the same fields here that its own form shows: a setting the step leaves
|
||||
* unset is not something a run needs told, and listing all eleven buries the message under the
|
||||
* configuration. What the step configures stays, as it does on any other step. A schema key the
|
||||
* field registry doesn't know is kept, so a new one is never silently dropped. A run input is
|
||||
* kept whatever the step holds: this form has no add-field control, so hiding one would leave
|
||||
* no way at all to supply it. */
|
||||
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
|
||||
|
||||
let visibleKeys = $derived.by(() => {
|
||||
const all = schemaKeys
|
||||
if ((mod.value as { type?: string })?.type !== 'aiagent') return all
|
||||
const transforms = (mod.value as { input_transforms?: Record<string, unknown> })
|
||||
?.input_transforms
|
||||
const visible = initialVisibleAgentFields(transforms, schema?.properties)
|
||||
const known = new Set(AGENT_FIELDS.filter((f) => !f.runInput).map((f) => f.key))
|
||||
return all.filter((key) => !known.has(key) || visible.has(key))
|
||||
})
|
||||
|
||||
let keys: string[] = $state([])
|
||||
$effect(() => {
|
||||
let lkeys = Object.keys(schema?.properties ?? {})
|
||||
let lkeys = visibleKeys
|
||||
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
|
||||
keys = lkeys
|
||||
untrack(() => stepsInputArgs?.removeExtraKey(mod.id, keys))
|
||||
// Pruned against the schema rather than against what is shown. What a run was given for a
|
||||
// field lives only here, so dropping it when the field merely stops being displayed would
|
||||
// discard it: an agent hides the settings its step leaves unset, and clearing one in the
|
||||
// Inputs tab hides it.
|
||||
untrack(() => stepsInputArgs?.removeExtraKey(mod.id, schemaKeys))
|
||||
}
|
||||
})
|
||||
|
||||
/** Whether re-evaluating has anything to restore. A field the step configures nothing for
|
||||
* evaluates to blank, so the control would only clear what was typed to run with. */
|
||||
function hasConfiguredInput(argName: string): boolean {
|
||||
const transform = (mod.value as any)?.input_transforms?.[argName]
|
||||
if (!transform) return false
|
||||
return transform.type === 'javascript' ? !!transform.expr : transform.value !== undefined
|
||||
}
|
||||
|
||||
function plugIt(argName: string) {
|
||||
stepsInputArgs?.setEvaluatedStepArg(
|
||||
mod.id,
|
||||
@@ -158,7 +189,7 @@
|
||||
workspace={opWs}
|
||||
>
|
||||
{#snippet fieldHeaderActions()}
|
||||
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName)}
|
||||
{#if stepsInputArgs?.isArgManuallySet(mod.id, argName) && hasConfiguredInput(argName)}
|
||||
<Button
|
||||
on:click={() => {
|
||||
plugIt(argName)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ScriptService,
|
||||
type AiAgent,
|
||||
type FlowModule,
|
||||
type InputTransform,
|
||||
type JavascriptTransform,
|
||||
type Job
|
||||
} from '$lib/gen'
|
||||
@@ -43,6 +44,8 @@
|
||||
opWorkspace
|
||||
} = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let previewBase = $derived($pathStore ?? '')
|
||||
|
||||
// Acting workspace when the flow editor runs in an AI session; else the nav workspace.
|
||||
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
@@ -96,7 +99,10 @@
|
||||
}
|
||||
if (val.type == 'rawscript') {
|
||||
await jobLoader?.runPreview(
|
||||
val.path ?? ($pathStore ?? '') + '/' + mod.id,
|
||||
// An empty base stays empty: `'' + '/' + id` is an absolute path, which
|
||||
// `require_path_read_access_for_preview` rejects outright. A flow with no path yet
|
||||
// previews unnamed instead.
|
||||
val.path ?? (previewBase ? previewBase + '/' + mod.id : ''),
|
||||
val.content,
|
||||
val.language,
|
||||
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
|
||||
@@ -104,7 +110,7 @@
|
||||
undefined,
|
||||
undefined,
|
||||
callbacks,
|
||||
$pathStore,
|
||||
previewBase,
|
||||
undefined,
|
||||
devTempScriptRefs?.(),
|
||||
timeout
|
||||
@@ -122,7 +128,7 @@
|
||||
script.lock,
|
||||
val.hash ?? script.hash,
|
||||
callbacks,
|
||||
$pathStore,
|
||||
previewBase,
|
||||
undefined,
|
||||
undefined,
|
||||
timeout
|
||||
@@ -132,18 +138,28 @@
|
||||
} else if (val.type == 'aiagent') {
|
||||
const { schema } = await loadSchemaFromModule(mod, opWs)
|
||||
|
||||
const inputTransforms: { [key: string]: JavascriptTransform } = Object.fromEntries(
|
||||
Object.keys(args).map((key) => [
|
||||
key,
|
||||
{
|
||||
expr: `flow_input.${key}`,
|
||||
type: 'javascript'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const agentVal = val
|
||||
|
||||
// The test form only covers the schema it was given, and for a standalone agent that may be
|
||||
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
|
||||
// brain from the module as authored and let the form's own keys win over it, so an edit made
|
||||
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
|
||||
// the server reads its brain from the resource.
|
||||
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
|
||||
...(agentVal.agent
|
||||
? {}
|
||||
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
|
||||
...Object.fromEntries(
|
||||
Object.keys(args).map((key) => [
|
||||
key,
|
||||
{
|
||||
expr: `flow_input.${key}`,
|
||||
type: 'javascript'
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
await jobLoader?.runFlowPreview(
|
||||
args,
|
||||
{
|
||||
@@ -168,7 +184,7 @@
|
||||
schema
|
||||
},
|
||||
callbacks,
|
||||
$pathStore
|
||||
previewBase
|
||||
)
|
||||
} else {
|
||||
throw Error('Not supported module type')
|
||||
|
||||
@@ -1,51 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import Checkbox from './common/checkbox/Checkbox.svelte'
|
||||
import { Minus, Plus } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
scopes?: string[]
|
||||
/** Scopes the provider is known to accept, offered as checkboxes. Anything
|
||||
* not in this list stays editable as free text below them. */
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
let { scopes = $bindable() }: Props = $props()
|
||||
let { scopes = $bindable(), options = [] }: Props = $props()
|
||||
|
||||
// Ticked options and free-text rows are kept apart from `scopes` (the only
|
||||
// value the parent binds) so a row can pass through an option's exact value
|
||||
// while typing (`…/calendar` on the way to `…/calendar.acls`) without ticking
|
||||
// or unticking anything. `lastWritten` tells a parent-side reset apart from
|
||||
// the echo of our own write.
|
||||
let ticked: string[] = $state([])
|
||||
let custom: string[] = $state([])
|
||||
let lastWritten: string | undefined = undefined
|
||||
|
||||
$effect.pre(() => {
|
||||
if (!scopes) {
|
||||
scopes = []
|
||||
}
|
||||
const json = JSON.stringify([scopes, options])
|
||||
if (json != lastWritten) {
|
||||
lastWritten = json
|
||||
ticked = scopes.filter((v) => options.includes(v))
|
||||
custom = scopes.filter((v) => !options.includes(v))
|
||||
}
|
||||
})
|
||||
|
||||
function write(nextTicked: string[], rows: string[]) {
|
||||
ticked = nextTicked
|
||||
custom = rows
|
||||
scopes = [...nextTicked.filter((o) => !rows.includes(o)), ...rows]
|
||||
lastWritten = JSON.stringify([scopes, options])
|
||||
}
|
||||
|
||||
// Ticking an option absorbs a free-text row holding the same value. The
|
||||
// target state comes from `ticked`, not the DOM: `Checkbox` re-asserts its
|
||||
// `checked` prop on every click, so the input already reads the old value
|
||||
// again by the time `change` fires.
|
||||
function toggle(option: string, on: boolean) {
|
||||
const rest = ticked.filter((o) => o != option)
|
||||
write(on ? [...rest, option] : rest, on ? custom.filter((r) => r != option) : custom)
|
||||
}
|
||||
|
||||
function setRow(i: number, value: string) {
|
||||
const rows = [...custom]
|
||||
rows[i] = value
|
||||
write(ticked, rows)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if scopes && Array.isArray(scopes)}
|
||||
{#each scopes as v, i}
|
||||
<div class="flex flex-row max-w-md mb-2">
|
||||
<input type="text" bind:value={scopes[i]} />
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
btnClasses="mx-6"
|
||||
on:click={() => {
|
||||
scopes = scopes?.filter((el) => el != v)
|
||||
}}
|
||||
startIcon={{ icon: Minus }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if options.length > 0}
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
{#each options as option (option)}
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={ticked.includes(option)}
|
||||
onChange={() => toggle(option, !ticked.includes(option))}
|
||||
/>
|
||||
<span class="font-mono break-all">{option}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-secondary">Custom scopes</span>
|
||||
{/if}
|
||||
|
||||
{#each custom as v, i (i)}
|
||||
<div class="flex flex-row max-w-md mb-2">
|
||||
<TextInput
|
||||
value={v}
|
||||
size="sm"
|
||||
inputProps={{ oninput: (e) => setRow(i, e.currentTarget.value) }}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
btnClasses="mx-6"
|
||||
onclick={() => {
|
||||
write(
|
||||
ticked,
|
||||
custom.filter((_, j) => j != i)
|
||||
)
|
||||
}}
|
||||
startIcon={{ icon: Minus }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex items-center mt-1">
|
||||
<Button
|
||||
variant="default"
|
||||
hover="yo"
|
||||
size="xs"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
scopes = (scopes ?? []).concat('')
|
||||
onclick={() => {
|
||||
write(ticked, [...custom, ''])
|
||||
}}
|
||||
>
|
||||
Add item
|
||||
</Button>
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{#if custom.length > 0}
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({custom.length} item{custom.length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
defaultValues?: Record<string, any> | undefined
|
||||
workspace?: string | undefined
|
||||
selected?: string | undefined
|
||||
/** Show the value as JSON rather than as the resource type's form. Bindable so a caller can
|
||||
* choose the view a given resource opens on, and the in-form toggle still works. */
|
||||
viewJsonSchema?: boolean
|
||||
/** Notifies the parent drawer whether a local draft for the selected
|
||||
* workspace diverges from the deployed baseline, so it can show the
|
||||
* "unsaved changes" banner below its header. */
|
||||
@@ -43,6 +46,7 @@
|
||||
defaultValues = undefined,
|
||||
workspace = undefined,
|
||||
selected: selectedProp = $bindable(),
|
||||
viewJsonSchema = $bindable(),
|
||||
onDraftStateChange,
|
||||
onCanWriteChange
|
||||
}: Props = $props()
|
||||
@@ -106,7 +110,6 @@
|
||||
|
||||
let isValid = $state(true)
|
||||
let jsonError = $state('')
|
||||
let viewJsonSchema = $state(false)
|
||||
let perWsValid: Record<string, boolean> = $state({})
|
||||
|
||||
const deployToResource = resource(
|
||||
@@ -320,7 +323,9 @@
|
||||
current.path = npath
|
||||
}
|
||||
|
||||
export async function save(): Promise<void> {
|
||||
/** Whether the write landed. It toasts its own failure, so most callers ignore this;
|
||||
* one that follows the save with bookkeeping of its own has to know not to. */
|
||||
export async function save(): Promise<boolean> {
|
||||
const dirty = dirtyWorkspaces
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
@@ -368,8 +373,10 @@
|
||||
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
|
||||
)
|
||||
dispatch('refresh', current?.path ?? path)
|
||||
return true
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -391,7 +398,7 @@
|
||||
bind:args={current.args}
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
bind:isValid
|
||||
bind:viewJsonSchema
|
||||
bind:viewJsonSchema={() => viewJsonSchema ?? false, (v) => (viewJsonSchema = v)}
|
||||
bind:jsonError
|
||||
{initialPath}
|
||||
{hidePath}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
|
||||
let path: string | undefined = $state(undefined)
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let viewJsonSchema = $state(false)
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
// The editor renders whichever workspace-specific variant `selected` points at, so history has
|
||||
@@ -65,10 +66,29 @@
|
||||
historyWorkspace === $workspaceStore && isOwner(path ?? '', $userStore, $workspaceStore)
|
||||
)
|
||||
|
||||
export async function initEdit(p: string): Promise<void> {
|
||||
// A close reaches `on:close` on a later flush, by which point a caller that closed this drawer to
|
||||
// open another editor has already anchored the new one. Clearing then would strip that anchor.
|
||||
let keepAnchorOnClose = false
|
||||
|
||||
/** Shut this drawer without going through its own close button, for a caller opening the other
|
||||
* editor over the same list. `keepAnchor` when that caller anchors what it opens instead. */
|
||||
export function close(opts?: { keepAnchor?: boolean }): void {
|
||||
keepAnchorOnClose = opts?.keepAnchor ?? false
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
/** `json` opens on the JSON editor instead of the resource type's form. For a type with a
|
||||
* dedicated editor elsewhere: the generic form would render its configuration field by field,
|
||||
* and materialize a default into every one the value leaves out. */
|
||||
export async function initEdit(p: string, opts?: { json?: boolean }): Promise<void> {
|
||||
// A `close({ keepAnchor })` on an already-closed drawer emits no close event, so the flag
|
||||
// would still be standing when the next drawer session ends and would swallow that one's
|
||||
// anchor clear. Every session starts having to clear its own.
|
||||
keepAnchorOnClose = false
|
||||
resource_type = undefined
|
||||
path = p
|
||||
selected = effectiveWorkspace
|
||||
viewJsonSchema = opts?.json ?? false
|
||||
drawer?.openDrawer?.()
|
||||
setPageDrawerAnchor(RESOURCES_PATH, p)
|
||||
}
|
||||
@@ -77,10 +97,15 @@
|
||||
resourceType: string,
|
||||
nDefaultValues?: Record<string, any>
|
||||
): Promise<void> {
|
||||
keepAnchorOnClose = false
|
||||
path = undefined
|
||||
resource_type = resourceType
|
||||
defaultValues = nDefaultValues
|
||||
selected = effectiveWorkspace
|
||||
// This drawer outlives what it opens on, so the view has to be set by every entry point
|
||||
// rather than left where the last one put it: a new resource is a typed form, whoever was
|
||||
// looking at JSON before.
|
||||
viewJsonSchema = false
|
||||
drawer?.openDrawer?.()
|
||||
}
|
||||
|
||||
@@ -97,7 +122,13 @@
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
|
||||
on:close={() => {
|
||||
if (keepAnchorOnClose) {
|
||||
keepAnchorOnClose = false
|
||||
return
|
||||
}
|
||||
clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
||||
@@ -121,6 +152,7 @@
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
bind:selected
|
||||
bind:viewJsonSchema
|
||||
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
||||
onCanWriteChange={(v) => (canWriteSelected = v)}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
datatableAsPgResource?: boolean
|
||||
workspace?: string | undefined
|
||||
disableChatOffset?: boolean
|
||||
/** Fires when this picker sets a resource, with the type it carries, and with `undefined` on
|
||||
* clear. Unlike an effect on `valueType` it never fires for the value the picker was opened
|
||||
* on, but `selectFirst` choosing the only candidate during a load does count as setting one. */
|
||||
onValueChange?: (path: string | undefined, type: string | undefined) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -54,7 +58,8 @@
|
||||
excludedValues = undefined,
|
||||
datatableAsPgResource = false,
|
||||
workspace = undefined,
|
||||
disableChatOffset = false
|
||||
disableChatOffset = false,
|
||||
onValueChange = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
@@ -155,6 +160,7 @@
|
||||
if (collection.length == 1 && selectFirst && (value == undefined || value == '')) {
|
||||
value = collection[0].value
|
||||
valueType = collection[0].type
|
||||
onValueChange?.(value, valueType)
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Failed to load resource types', true)
|
||||
@@ -196,6 +202,7 @@
|
||||
await loadResources(resourceType)
|
||||
value = e.detail
|
||||
valueType = collection.find((x) => x?.value == value)?.type
|
||||
onValueChange?.(value, valueType)
|
||||
}}
|
||||
bind:this={appConnect}
|
||||
{expressOAuthSetup}
|
||||
@@ -211,6 +218,7 @@
|
||||
if (e.detail) {
|
||||
value = e.detail
|
||||
valueType = collection.find((x) => x?.value == value)?.type
|
||||
onValueChange?.(value, valueType)
|
||||
// valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
|
||||
}
|
||||
}}
|
||||
@@ -237,12 +245,14 @@
|
||||
(v) => {
|
||||
value = v
|
||||
valueType = collection.find((x) => x?.value == v)?.type
|
||||
onValueChange?.(value, valueType)
|
||||
}
|
||||
}
|
||||
onClear={() => {
|
||||
initialValue = undefined
|
||||
value = undefined
|
||||
valueType = undefined
|
||||
onValueChange?.(undefined, undefined)
|
||||
onClear?.()
|
||||
}}
|
||||
items={collection}
|
||||
@@ -281,6 +291,7 @@
|
||||
<div class="flex bg-surface border-t divide-x">
|
||||
{#if resourceType?.includes(',')}
|
||||
<DropdownV2
|
||||
class="flex-1 justify-stretch"
|
||||
enableFlyTransition
|
||||
items={resourceType?.split(',').map((rt) => ({
|
||||
displayName: `${rt} resource`,
|
||||
@@ -294,7 +305,7 @@
|
||||
color="light"
|
||||
variant="contained"
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="rounded-none mt-0.5"
|
||||
btnClasses="rounded-none"
|
||||
size="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
|
||||
>
|
||||
{#if description}
|
||||
<div class="text-xs text-primary mt-1 mb-2">{@html description}</div>
|
||||
<div class="text-xs text-secondary mt-1 mb-2">{@html description}</div>
|
||||
{/if}
|
||||
<div class={twMerge('flex flex-col gap-6 grow min-h-0', headless ? '' : 'mt-4')}>
|
||||
<div class={twMerge('grow min-h-0', clazz)}>
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
/** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */
|
||||
const CHANGE_TIMEOUT = 200
|
||||
|
||||
/** Gap between the line numbers and the first character. Zero puts them flush,
|
||||
* so a two-digit line reads as one token with the code. */
|
||||
const LINE_DECORATIONS_WIDTH = 6
|
||||
|
||||
let changeTimeoutId: number | undefined = undefined
|
||||
// Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without
|
||||
// this an authoritative overwrite reads as a user edit on the `input` event.
|
||||
@@ -112,7 +116,8 @@
|
||||
minHeight = 1000,
|
||||
renderLineHighlight = 'none',
|
||||
suggestion,
|
||||
leadingChangeSync = false
|
||||
leadingChangeSync = false,
|
||||
lineNumbersMinChars = 3
|
||||
}: {
|
||||
lang: string
|
||||
code?: string
|
||||
@@ -149,6 +154,9 @@
|
||||
* `code`; leave it off where each extra sync costs work downstream (an app
|
||||
* code input feeding an autoRefresh runnable re-runs a job per sync). */
|
||||
leadingChangeSync?: boolean
|
||||
/** Width of the line-number gutter, in characters. Same name, and same
|
||||
* default, as `Editor`, so the two render line numbers alike. */
|
||||
lineNumbersMinChars?: number
|
||||
} = $props()
|
||||
|
||||
let yPadding = MONACO_Y_PADDING
|
||||
@@ -312,10 +320,12 @@
|
||||
if (model.getLanguageId() !== lang) {
|
||||
const currentCode = model.getValue()
|
||||
const uri = `file:///${hash}.${langToExt(lang)}`
|
||||
const oldModel = model
|
||||
const newModel = meditor.createModel(currentCode, lang, mUri.parse(uri))
|
||||
editor?.setModel(newModel)
|
||||
oldModel.dispose()
|
||||
// The old model goes first: `langToExt` maps anything it does not know to
|
||||
// `unknown`, so the new uri is usually the one this model already holds,
|
||||
// and creating over an occupied uri throws ("model already exists").
|
||||
editor?.setModel(null)
|
||||
model.dispose()
|
||||
editor?.setModel(meditor.createModel(currentCode, lang, mUri.parse(uri)))
|
||||
}
|
||||
|
||||
// Update editor options for suggestions, validation decorations, and line numbers
|
||||
@@ -334,8 +344,8 @@
|
||||
snippetsPreventQuickSuggestions: disableSuggestions
|
||||
},
|
||||
lineNumbers: hideLineNumbers ? 'off' : 'on',
|
||||
lineDecorationsWidth: hideLineNumbers ? 0 : 6,
|
||||
lineNumbersMinChars: hideLineNumbers ? 0 : 2,
|
||||
lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH,
|
||||
lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars,
|
||||
// Hide validation squiggles and decorations
|
||||
renderValidationDecorations: disableLinting ? 'off' : 'on',
|
||||
// Hide the validation margin indicators
|
||||
@@ -397,8 +407,11 @@
|
||||
...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}),
|
||||
readOnly,
|
||||
renderLineHighlight,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 2,
|
||||
// Same conditional as `updateModelAndOptions`: created correct rather than
|
||||
// created wide and narrowed a tick later, which a caller hiding the gutter
|
||||
// would see as a flash of indent.
|
||||
lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH,
|
||||
lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars,
|
||||
fontSize: fontSize,
|
||||
quickSuggestions: disableSuggestions
|
||||
? { other: false, comments: false, strings: false }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { UserService, type GlobalUserInfo, type ExternalJwtToken, SettingService } from '$lib/gen'
|
||||
import type { GuestList } from '$lib/gen'
|
||||
import { Tab, Tabs } from '$lib/components/common'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
@@ -54,6 +55,8 @@
|
||||
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
|
||||
import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte'
|
||||
import ExternalJwtTokens from './instanceSettings/ExternalJwtTokens.svelte'
|
||||
import GuestActivityList from './instanceSettings/GuestActivityList.svelte'
|
||||
import { Alert, Skeleton } from '$lib/components/common'
|
||||
|
||||
let filter = $state('')
|
||||
|
||||
@@ -103,7 +106,30 @@
|
||||
listUsers(activeOnly)
|
||||
})
|
||||
|
||||
let usersSubTab: 'users' | 'ext_jwt' = $state('users')
|
||||
let usersSubTab: 'users' | 'ext_jwt' | 'guests' = $state('users')
|
||||
let guestList: GuestList | undefined = $state(undefined)
|
||||
let guestHasMore = $state(true)
|
||||
let guestLoading = $state(false)
|
||||
const guestPerPage = 50
|
||||
|
||||
async function loadGuestPage(nextPage: number): Promise<boolean> {
|
||||
guestLoading = true
|
||||
try {
|
||||
const res = await UserService.listGuests({ page: nextPage, perPage: guestPerPage })
|
||||
guestList =
|
||||
nextPage === 1 || !guestList
|
||||
? res
|
||||
: { usage: res.usage, guests: [...guestList.guests, ...res.guests] }
|
||||
guestHasMore = res.guests.length === guestPerPage
|
||||
return true
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to load guests: ${e}`, true)
|
||||
return false
|
||||
} finally {
|
||||
guestLoading = false
|
||||
}
|
||||
}
|
||||
loadGuestPage(1)
|
||||
let extJwtTokens: ExternalJwtToken[] = $state([])
|
||||
let extJwtHasMore = $state(true)
|
||||
let extJwtLoading = $state(false)
|
||||
@@ -339,14 +365,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if extJwtTokens.length > 0}
|
||||
<Tabs bind:selected={usersSubTab} class="mb-4">
|
||||
<Tab value="users" label="Users" />
|
||||
<Tabs bind:selected={usersSubTab} class="mb-4">
|
||||
<Tab value="users" label="Users" />
|
||||
{#if extJwtTokens.length > 0}
|
||||
<Tab value="ext_jwt" label="External JWTs" />
|
||||
</Tabs>
|
||||
{/if}
|
||||
{/if}
|
||||
<Tab value="guests" label="Guests" />
|
||||
</Tabs>
|
||||
|
||||
{#if usersSubTab === 'users' || extJwtTokens.length === 0}
|
||||
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
|
||||
<SettingsPageHeader
|
||||
title="Instance users ({users.length})"
|
||||
description="Manage all users across your Windmill instance."
|
||||
@@ -712,6 +739,26 @@
|
||||
loadExtJwtPage(1)
|
||||
}}
|
||||
/>
|
||||
{:else if usersSubTab === 'guests' && !guestList}
|
||||
{#if guestLoading}
|
||||
<Skeleton layout={[[2], 1, [8]]} />
|
||||
{:else}
|
||||
<Alert type="error" title="Could not load guests">
|
||||
<Button unifiedSize="sm" variant="default" onclick={() => loadGuestPage(1)}
|
||||
>Retry</Button
|
||||
>
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else if usersSubTab === 'guests' && guestList}
|
||||
<GuestActivityList
|
||||
usage={guestList.usage}
|
||||
guests={guestList.guests}
|
||||
hasMore={guestHasMore}
|
||||
loading={guestLoading}
|
||||
onLoadMore={() =>
|
||||
loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)}
|
||||
onInstanceSwitch={() => loadGuestPage(1)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -392,6 +392,10 @@
|
||||
fontSize?: number
|
||||
loadAsync?: boolean
|
||||
class?: string | undefined
|
||||
/** Height the editor opens at, in lines, for a field that expects more than a phrase. */
|
||||
minRows?: number
|
||||
/** Shown over the empty editor. Monaco has no placeholder of its own. */
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -403,13 +407,25 @@
|
||||
fixedOverflowWidgets = true,
|
||||
fontSize,
|
||||
loadAsync = false,
|
||||
class: clazz = ''
|
||||
class: clazz = '',
|
||||
minRows = undefined,
|
||||
placeholder = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let effectiveFontSize = $derived(fontSize ?? editorFontSize.regular)
|
||||
|
||||
let yPadding = MONACO_Y_PADDING
|
||||
|
||||
// Monaco derives an unset line height as 1.5x the font size, rounded. Reproducing it here lets
|
||||
// the box stand at its final height before the editor exists, so opening a field does not jump.
|
||||
let minHeightPx = $derived(
|
||||
minRows ? minRows * Math.round(1.5 * effectiveFontSize) + yPadding * 2 : 0
|
||||
)
|
||||
|
||||
/** Whether the live model is empty, for the placeholder. Tracked apart from `code`, which the
|
||||
* editor only writes back on a debounce. */
|
||||
let editorEmpty = $state(true)
|
||||
|
||||
if (typeof code != 'string') {
|
||||
code = ''
|
||||
}
|
||||
@@ -526,6 +542,9 @@
|
||||
}
|
||||
|
||||
editor.onDidChangeModelContent((event) => {
|
||||
// Undebounced, unlike `code`: the placeholder sits over the editor, so waiting would leave
|
||||
// it covering the first characters typed into an empty field.
|
||||
editorEmpty = (editor?.getModel()?.getValue() ?? '') === ''
|
||||
timeoutModel && clearTimeout(timeoutModel)
|
||||
timeoutModel = setTimeout(() => {
|
||||
updateCode()
|
||||
@@ -538,7 +557,7 @@
|
||||
const updateHeight = () => {
|
||||
const contentHeight = Math.min(1000, editor?.getContentHeight() ?? 0)
|
||||
if (divEl) {
|
||||
divEl.style.height = `${contentHeight}px`
|
||||
divEl.style.height = `${Math.max(contentHeight, minHeightPx)}px`
|
||||
}
|
||||
}
|
||||
editor.onDidContentSizeChange(updateHeight)
|
||||
@@ -709,16 +728,31 @@
|
||||
<EditorTheme />
|
||||
|
||||
<div
|
||||
class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)}
|
||||
class={twMerge(
|
||||
inputBorderClass({ forceFocus: isFocus }),
|
||||
'relative rounded-md overflow-auto pl-2',
|
||||
clazz
|
||||
)}
|
||||
style={minHeightPx ? `min-height: ${minHeightPx}px` : undefined}
|
||||
>
|
||||
{#if !editor}
|
||||
<FakeMonacoPlaceHolder autoheight showNumbers={false} {code} fontSize={effectiveFontSize} />
|
||||
{/if}
|
||||
<div
|
||||
bind:this={divEl}
|
||||
style="height: 18px;"
|
||||
style="height: {Math.max(18, minHeightPx)}px;"
|
||||
class="template nonmain-editor rounded-md overflow-clip {!editor ? 'hidden' : ''}"
|
||||
></div>
|
||||
{#if placeholder && !code && (!editor || editorEmpty)}
|
||||
<div
|
||||
class="absolute inset-0 px-2 pointer-events-none whitespace-pre-wrap text-hint font-mono"
|
||||
style="font-size: {effectiveFontSize}px; line-height: {Math.round(
|
||||
1.5 * effectiveFontSize
|
||||
)}px; padding-top: {yPadding}px;"
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import type { AgentDraft } from '$lib/gen'
|
||||
import type { EvalsLocation } from './evalUtils'
|
||||
import EvalsPane from './EvalsPane.svelte'
|
||||
|
||||
let {
|
||||
agentPath = undefined,
|
||||
open = $bindable(),
|
||||
opWorkspace = undefined,
|
||||
editedConfig = undefined
|
||||
}: {
|
||||
/** The agent under test. A dataset and its runs belong to a saved agent. */
|
||||
agentPath?: string
|
||||
open: boolean
|
||||
/** The workspace the opening editor operates on, which differs from the nav workspace in
|
||||
* fork and session editors. Every read and write targets it. */
|
||||
opWorkspace?: string
|
||||
/** Opened from an agent being edited: the edits, as the step holds them, are what a run is
|
||||
* offered on. Everywhere else the agent is what is deployed. */
|
||||
editedConfig?: () => AgentDraft
|
||||
} = $props()
|
||||
|
||||
const TITLE = 'Evals'
|
||||
let location = $state<EvalsLocation | undefined>(undefined)
|
||||
let trail = $derived(
|
||||
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
|
||||
<Modal
|
||||
title={TITLE}
|
||||
{trail}
|
||||
paginated
|
||||
enterConfirms={false}
|
||||
bind:open
|
||||
kind="X"
|
||||
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
|
||||
fillHeight
|
||||
>
|
||||
{#snippet titleBadge()}
|
||||
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
|
||||
{/snippet}
|
||||
<div class="h-full min-h-0">
|
||||
{#if agentPath}
|
||||
<!-- Keyed: the resources page keeps one dialog for every row, so a different agent must
|
||||
start from nothing rather than inherit the runs and the open run of the last one. -->
|
||||
{#key `${opWorkspace ?? ''}:${agentPath}`}
|
||||
<EvalsPane {agentPath} {opWorkspace} {editedConfig} bind:location />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">Evals run against a saved agent</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
This agent is written into the flow step rather than saved as its own agent, so there is
|
||||
nothing for a dataset and its runs to belong to. Save it as a reusable agent from the
|
||||
step, and its evals start there.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -64,7 +64,8 @@
|
||||
agentPath,
|
||||
opWorkspace = undefined,
|
||||
editedConfig = undefined,
|
||||
location = $bindable()
|
||||
location = $bindable(),
|
||||
active = true
|
||||
}: {
|
||||
/** The agent under test. A dataset and its runs belong to an agent. */
|
||||
agentPath: string
|
||||
@@ -77,6 +78,10 @@
|
||||
/** The level the pane is on and the way out of it, reported up so the surface holding it
|
||||
* can put both in its header. Undefined at the root, which that surface already names. */
|
||||
location?: EvalsLocation
|
||||
/** False while the pane is parked off screen by a surface that keeps it mounted. Its own
|
||||
* pages answer the arrow keys at `window`, which a parked instance would take from
|
||||
* whatever is actually on screen. */
|
||||
active?: boolean
|
||||
} = $props()
|
||||
|
||||
let ws = $derived(opWorkspace ?? $workspaceStore)
|
||||
@@ -666,19 +671,21 @@
|
||||
warm
|
||||
class="grow min-h-0"
|
||||
current={!viewingRun || !loaded ? 'list' : 'run'}
|
||||
onNavigate={(key) => {
|
||||
// Right opens the run under the highlight, falling back to whichever was open before;
|
||||
// left is the way back, the same as the breadcrumb.
|
||||
if (key === 'run') {
|
||||
// Both branches go through `openRun`: it is what brings the run's own dataset back,
|
||||
// and the fallback run may be of a dataset the list has since moved off.
|
||||
const id = highlightedRunId ?? experimentId
|
||||
if (id) openRun(id)
|
||||
} else if (key === 'list') {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
}
|
||||
}}
|
||||
onNavigate={!active
|
||||
? undefined
|
||||
: (key) => {
|
||||
// Right opens the run under the highlight, falling back to whichever was open before;
|
||||
// left is the way back, the same as the breadcrumb.
|
||||
if (key === 'run') {
|
||||
// Both branches go through `openRun`: it is what brings the run's own dataset back,
|
||||
// and the fallback run may be of a dataset the list has since moved off.
|
||||
const id = highlightedRunId ?? experimentId
|
||||
if (id) openRun(id)
|
||||
} else if (key === 'list') {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
}
|
||||
}}
|
||||
pages={[
|
||||
{ key: 'list', content: listPage },
|
||||
{ key: 'run', content: runPage }
|
||||
@@ -727,7 +734,7 @@
|
||||
{datasets}
|
||||
{caseProgress}
|
||||
{loaded}
|
||||
active={!viewingRun}
|
||||
active={active && !viewingRun}
|
||||
{deployedHash}
|
||||
{currentVersion}
|
||||
onOpen={(e) => openRun(e.id)}
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { AppService, SettingService } from '$lib/gen'
|
||||
import { AppService, SettingService, WorkspaceService } from '$lib/gen'
|
||||
import type { GuestUsage } from '$lib/gen'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { base } from '$lib/base'
|
||||
@@ -90,6 +93,51 @@
|
||||
(rulesetsLoaded &&
|
||||
canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined))
|
||||
)
|
||||
let canSetGuest = $derived(
|
||||
!!$userStore?.is_admin ||
|
||||
!!$userStore?.is_super_admin ||
|
||||
(rulesetsLoaded &&
|
||||
canUserBypassRuleKind('RestrictGuestAppDeployment', $userStore ?? undefined))
|
||||
)
|
||||
// The three rungs of the access control, widest last. `viewer` is a fourth
|
||||
// execution mode that this control never sets (it runs components as the viewer,
|
||||
// which a guest cannot be), so an app in it shows as members-only here.
|
||||
let accessMode = $derived(
|
||||
policy.execution_mode == 'anonymous'
|
||||
? 'anonymous'
|
||||
: policy.execution_mode == 'guest'
|
||||
? 'guest'
|
||||
: 'publisher'
|
||||
)
|
||||
// Undefined until loaded. An app can be set to `guest` while the workspace has
|
||||
// guests off, in which case the mode is stored but inert -- say so rather than
|
||||
// letting the publisher believe the app is open.
|
||||
let guestAccessEnabled: boolean | undefined = $state(undefined)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
|
||||
$effect(() => {
|
||||
const ws = opWs
|
||||
if (ws === undefined) return
|
||||
untrack(() => {
|
||||
WorkspaceService.getPublicSettings({ workspace: ws })
|
||||
.then((s) => (guestAccessEnabled = s.guest_access_enabled))
|
||||
.catch(() => (guestAccessEnabled = undefined))
|
||||
WorkspaceService.getGuestUsage({ workspace: ws })
|
||||
.then((u) => (guestUsage = u))
|
||||
.catch(() => (guestUsage = undefined))
|
||||
})
|
||||
})
|
||||
|
||||
function onAccessModeChange(mode: string | undefined) {
|
||||
if (mode === undefined || mode === accessMode) return
|
||||
policy.execution_mode = mode
|
||||
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so
|
||||
// `setPublishState` would 404. The mode is carried by the first deploy's
|
||||
// policy; persist incrementally only once the app exists.
|
||||
if (savedApp && !newApp) {
|
||||
setPublishState()
|
||||
}
|
||||
}
|
||||
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
|
||||
let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email)
|
||||
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
|
||||
@@ -389,34 +437,78 @@
|
||||
{/if}
|
||||
|
||||
{#if !hideSecretUrl}
|
||||
<h2>Public URL</h2>
|
||||
<h2>Access</h2>
|
||||
|
||||
<div class="my-6">
|
||||
{#if rulesetsLoaded && !canSetAnonymous}
|
||||
{#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Making this app publicly accessible without login is restricted to workspace admins and
|
||||
bypass users by a workspace protection rule
|
||||
Opening this app to anyone with the link is restricted to workspace admins and bypass users
|
||||
by a workspace protection rule
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
|
||||
protection rule
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require login and read-access`,
|
||||
right: `No login required`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so
|
||||
// `setPublishState` would 404. The mode is carried by the first
|
||||
// deploy's policy; persist incrementally only once the app exists.
|
||||
if (savedApp && !newApp) {
|
||||
setPublishState()
|
||||
}
|
||||
}}
|
||||
disabled={!savedApp || (!canSetAnonymous && policy.execution_mode != 'anonymous')}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
selected={accessMode}
|
||||
on:selected={(e) => onAccessModeChange(e.detail)}
|
||||
disabled={!savedApp}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
label="Members"
|
||||
value="publisher"
|
||||
tooltip="Workspace members with read access on this app."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="Guests"
|
||||
value="guest"
|
||||
disabled={!canSetGuest && policy.execution_mode != 'guest'}
|
||||
tooltip="Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="Public"
|
||||
value="anonymous"
|
||||
disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'}
|
||||
tooltip="Anyone with the secret URL. No login."
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mb-3">
|
||||
{#if policy.execution_mode == 'anonymous'}
|
||||
Anyone holding the secret URL below can open this app without signing in.
|
||||
{:else if policy.execution_mode == 'guest'}
|
||||
{#if guestUsage && !guestUsage.instance_enabled}
|
||||
A superadmin has turned guests off for this instance, so this app still admits members
|
||||
only.
|
||||
{:else if guestAccessEnabled === undefined}
|
||||
Checking whether this workspace allows guests…
|
||||
{:else if guestAccessEnabled === false}
|
||||
Guests are turned off for this workspace, so this app still admits members only. A
|
||||
workspace admin can turn them on in the workspace settings.
|
||||
{:else}
|
||||
Anyone your identity provider authenticates can open this app without a Windmill account.
|
||||
They join no workspace. Members of this workspace can open it too.
|
||||
{#if guestUsage}
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
|
||||
? 'every four guests count as one seat'
|
||||
: 'new guests are refused until the count drops'}.
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
Only workspace members with read access on this app can open it.
|
||||
{/if}
|
||||
</div>
|
||||
{#if !savedApp || newApp}
|
||||
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
notExists,
|
||||
noPermission,
|
||||
jwtError,
|
||||
guestAppPath = undefined,
|
||||
onLoginSuccess,
|
||||
app,
|
||||
workspace,
|
||||
@@ -37,6 +38,9 @@
|
||||
notExists: boolean
|
||||
noPermission: boolean
|
||||
jwtError: boolean
|
||||
/** Set when this app is open to guests: signing in gets the visitor in without
|
||||
* an account. Undefined means the ordinary "you need read access" dead end. */
|
||||
guestAppPath?: string | undefined
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
@@ -128,17 +132,31 @@
|
||||
</Alert></div
|
||||
>
|
||||
{:else if noPermission}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> This app requires read access </div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{effectiveWorkspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{#if guestAppPath && !$userStore}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> Sign in to open this app </div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{effectiveWorkspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
{#if !jwtError}
|
||||
<Login {onLoginSuccess} popup rd={page.url.pathname + page.url.search + page.url.hash} />
|
||||
<Login
|
||||
{onLoginSuccess}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if app}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
|
||||
*/
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import { OpenAPI, UserService } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
|
||||
import { Alert, Skeleton } from '$lib/components/common'
|
||||
@@ -49,7 +49,9 @@
|
||||
fetchEmbedToken,
|
||||
onViewerReady,
|
||||
viewer,
|
||||
viewerUrl
|
||||
viewerUrl,
|
||||
guestAppPath = undefined,
|
||||
guestEntry = 'none'
|
||||
}: {
|
||||
/** Embedder-side: validate access + mint the scoped token. Throws with a
|
||||
* `.status` of 401 (login required) or 404 (not found). Pass
|
||||
@@ -68,6 +70,16 @@
|
||||
* (`/apps/get`, auth-gated, with chrome) differs from the cookieless,
|
||||
* chrome-less viewer route (`/app_embed`). */
|
||||
viewerUrl?: string
|
||||
/** `<workspace>/<app_path>` when this app is open to guests. The embedder's
|
||||
* login gate fires before the page's own load, so the page must resolve this
|
||||
* up front and pass it down — otherwise a signed-out visitor is offered an
|
||||
* ordinary sign-in that creates an account and still cannot open the app. */
|
||||
guestAppPath?: string | undefined
|
||||
/** Whether the app admits guests, as far as the page has found out. The sign-in
|
||||
* card waits while `pending` (a configured auto-login would otherwise start an
|
||||
* ordinary sign-in) and refuses to offer one on `error` — a transient fault must
|
||||
* not become an account and a seat. Nothing else waits on it. */
|
||||
guestEntry?: 'pending' | 'none' | 'guest' | 'error'
|
||||
} = $props()
|
||||
|
||||
const EMBED_PARAM = 'wm_embed'
|
||||
@@ -194,7 +206,48 @@
|
||||
}
|
||||
|
||||
// ---------------------------- embedder mode ----------------------------
|
||||
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkPrompt' = $state('loading')
|
||||
type FrameStatus = 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkPrompt'
|
||||
let status = $state<FrameStatus>('loading')
|
||||
/** Whether the visitor holds an account session, probed whenever the app denies
|
||||
* them. An account this app still refuses is not something signing in again can
|
||||
* fix — an identity with an account is never given a guest session — so the card
|
||||
* gives way to an explanation. */
|
||||
let accountSession = $state<'unknown' | 'none' | 'held'>('unknown')
|
||||
/** What the sign-in refused with, shown above the card until the next attempt. */
|
||||
let signInError: string | undefined = $state(undefined)
|
||||
let deniedStatus: number | undefined = $state(undefined)
|
||||
/** The sign-in card belongs on a 401, and on a 403 unless discovery has settled
|
||||
* that the app is not open to guests: a 403 on a guest app is a session for another
|
||||
* app of the workspace, which a fresh sign-in replaces. True while discovery is
|
||||
* pending, so the skeleton shows rather than a flash of "Not found". */
|
||||
let offerSignIn = $derived(
|
||||
status === 'noPermission' ||
|
||||
(status === 'notExists' && deniedStatus === 403 && guestEntry !== 'none')
|
||||
)
|
||||
let signInDidNotHelp = $derived(offerSignIn && accountSession === 'held')
|
||||
$effect(() => {
|
||||
if (offerSignIn && accountSession === 'unknown') {
|
||||
// Workspace-less, so it answers for an account and never for a guest
|
||||
// (pinned to its workspace) or for nobody.
|
||||
UserService.getCurrentEmail()
|
||||
.then(() => (accountSession = 'held'))
|
||||
.catch(() => (accountSession = 'none'))
|
||||
}
|
||||
})
|
||||
|
||||
// The stale guest session must be gone before the card mounts: it still
|
||||
// authenticates here, so the popup's success poll would see it and complete the
|
||||
// sign-in before the new session lands. The card waits on `staleGuestCleared`; a
|
||||
// failed logout fails closed rather than offering a sign-in that cannot complete.
|
||||
let staleGuestCleared = $state(false)
|
||||
let staleGuestLogoutFailed = $state(false)
|
||||
$effect(() => {
|
||||
if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) {
|
||||
UserService.logout()
|
||||
.then(() => (staleGuestCleared = true))
|
||||
.catch(() => (staleGuestLogoutFailed = true))
|
||||
}
|
||||
})
|
||||
let embedToken: string | null = $state(null)
|
||||
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
|
||||
|
||||
@@ -293,6 +346,10 @@
|
||||
}
|
||||
finishReady()
|
||||
} catch (e: any) {
|
||||
// 401: no session. 403 on an app that admits guests: a guest session for a
|
||||
// different app of this workspace, which a fresh sign-in replaces. Either
|
||||
// way the sign-in card is the answer; anything else is not found.
|
||||
deniedStatus = e?.status
|
||||
status = e?.status === 401 ? 'noPermission' : 'notExists'
|
||||
}
|
||||
}
|
||||
@@ -514,7 +571,7 @@
|
||||
{/if}
|
||||
{:else if status === 'loading'}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if status === 'notExists'}
|
||||
{:else if status === 'notExists' && !offerSignIn}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Not found">
|
||||
There was an error loading the app, is the url correct?
|
||||
@@ -529,17 +586,58 @@
|
||||
onContinue={onSdkConsentContinue}
|
||||
onDecline={onSdkConsentDecline}
|
||||
/>
|
||||
{:else if status === 'noPermission'}
|
||||
{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Could not check access">
|
||||
The app could not be reached to find out who may open it. Reload to try again.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if offerSignIn}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => initEmbedder()}
|
||||
popup
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{#if signInDidNotHelp}
|
||||
<!-- Offering the same sign-in again would loop: they are signed in, and this
|
||||
app still will not open for them. Say why and stop. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
You are signed in, but this app is not open to you
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
It is open to the people it was shared with{guestAppPath
|
||||
? ', and to guests who have no Windmill account'
|
||||
: ''}. Ask the person who shared it to give your account access.
|
||||
</div>
|
||||
{:else}
|
||||
{#if guestAppPath}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
{/if}
|
||||
{#if signInError}
|
||||
<div class="px-2 mx-auto mt-8 max-w-xl w-full">
|
||||
<Alert type="error" title="Could not sign you in">{signInError}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
signInError = undefined
|
||||
accountSession = 'unknown'
|
||||
initEmbedder()
|
||||
}}
|
||||
onLoginError={(message) => (signInError = message)}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if unsandboxed}
|
||||
<!-- Same-origin (full session): the app was not opted into sandbox isolation
|
||||
(the default). Rendered directly here; RawAppPreview reads
|
||||
|
||||
@@ -201,6 +201,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the file chooser without the dropzone being clicked, for a caller whose
|
||||
* affordance is a button elsewhere. The component is still what reads and filters
|
||||
* the files, so the two paths cannot drift. */
|
||||
export function openPicker() {
|
||||
input?.click()
|
||||
}
|
||||
|
||||
export function clearFiles() {
|
||||
files = undefined
|
||||
dispatchChange()
|
||||
|
||||
@@ -20,6 +20,7 @@ export { default as TabFade } from './tabs/TabFade.svelte'
|
||||
export { default as Tabs } from './tabs/Tabs.svelte'
|
||||
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
|
||||
export { default as FileInput } from './fileInput/FileInput.svelte'
|
||||
export { default as ListRow } from './listRow/ListRow.svelte'
|
||||
export { default as RadioCard } from './radioCard/RadioCard.svelte'
|
||||
export { default as Section } from '../Section.svelte'
|
||||
export { default as Url } from './Url.svelte'
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<!--
|
||||
@component
|
||||
One row of a selectable list: an optional leading icon, a label line, one line of
|
||||
secondary text under it, and optionally controls at the end that act on their own.
|
||||
|
||||
Borderless — rows sit in a `flex flex-col gap-1` and light up on hover rather than
|
||||
living in a bordered card, which reads as heavy once a list runs to dozens of rows.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
trailing,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
highlighted,
|
||||
id,
|
||||
aiId,
|
||||
aiDescription,
|
||||
class: clazz = ''
|
||||
}: {
|
||||
/** The label line. A snippet so a caller can highlight a search match, or set a
|
||||
* second identifier beside the name. */
|
||||
title: Snippet
|
||||
/** One line under the title, truncated. */
|
||||
subtitle?: Snippet
|
||||
icon?: Snippet
|
||||
/** Controls at the end of the row that act on their own — a switch, a menu. A row
|
||||
* that has them is a div with the label as an inner button, since a button cannot
|
||||
* nest inside a button and a click would otherwise fire both. */
|
||||
trailing?: Snippet
|
||||
onClick?: () => void
|
||||
onMouseEnter?: () => void
|
||||
/** Given, the caller owns which row is lit — it moves the highlight with the
|
||||
* keyboard — and the row's own hover is off: two lit rows at once are ambiguous. */
|
||||
highlighted?: boolean
|
||||
id?: string
|
||||
aiId?: string
|
||||
aiDescription?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let ownHover = $derived(highlighted === undefined)
|
||||
|
||||
// Set at hover time rather than up front, so only the rows that actually cut their
|
||||
// text off carry a tooltip.
|
||||
function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) {
|
||||
const el = e.currentTarget
|
||||
el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
<div class="flex flex-row items-center gap-4 w-full min-w-0 text-left">
|
||||
{#if icon}
|
||||
<div class="shrink-0">{@render icon()}</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-0.5 min-w-0 grow">
|
||||
<div class="flex flex-row items-baseline gap-2 min-w-0">
|
||||
{@render title()}
|
||||
</div>
|
||||
{#if subtitle}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="truncate text-xs font-normal leading-4 text-secondary"
|
||||
onmouseenter={titleIfTruncated}
|
||||
>
|
||||
{@render subtitle()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if trailing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
{id}
|
||||
class={twMerge(
|
||||
'flex items-center gap-4 rounded-md px-3 py-3 scroll-my-2 transition-colors text-xs font-medium',
|
||||
// Only a row that opens something lights up: a hover on a row whose label does
|
||||
// nothing reads as an affordance that isn't there.
|
||||
onClick ? (ownHover ? 'hover:bg-surface-hover' : highlighted ? 'bg-surface-hover' : '') : '',
|
||||
clazz
|
||||
)}
|
||||
onmouseenter={onMouseEnter}
|
||||
>
|
||||
{#if onClick}
|
||||
<!-- Its own padding and hover are off: both belong to the row around it, which
|
||||
is what lights up and what the trailing controls sit inside. -->
|
||||
<Button
|
||||
type="button"
|
||||
{aiId}
|
||||
{aiDescription}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
wrapperClasses="grow shrink min-w-0"
|
||||
btnClasses="w-full min-w-0 justify-start p-0 h-auto bg-transparent hover:bg-transparent"
|
||||
{onClick}
|
||||
>
|
||||
{@render body()}
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- No `onClick`: a button here would be a tab stop that does nothing. -->
|
||||
<div class="flex grow min-w-0 text-left">{@render body()}</div>
|
||||
{/if}
|
||||
{@render trailing()}
|
||||
</div>
|
||||
{:else if !onClick}
|
||||
<!-- Nothing to click and nothing trailing: a row that only reports. A `Button` here
|
||||
would be a tab stop that lights up on hover and does nothing. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div {id} class={twMerge('px-3 py-3 text-xs font-medium', clazz)} onmouseenter={onMouseEnter}>
|
||||
{@render body()}
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
type="button"
|
||||
{id}
|
||||
{aiId}
|
||||
{aiDescription}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
btnClasses={twMerge(
|
||||
'justify-start px-3 h-auto py-3 scroll-my-2',
|
||||
ownHover ? '' : 'hover:bg-transparent',
|
||||
// `!` so the highlight also wins on the row the pointer is over, whose own
|
||||
// hover was turned off just above.
|
||||
highlighted ? '!bg-surface-hover' : '',
|
||||
clazz
|
||||
)}
|
||||
on:mouseenter={() => onMouseEnter?.()}
|
||||
{onClick}
|
||||
>
|
||||
{@render body()}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
/**
|
||||
* The highlighted row of a searchable list: the one the arrow keys move and Enter
|
||||
* activates, rendered by passing `highlighted` to `ListRow`.
|
||||
*
|
||||
* Pairs with a search field above the list — the arrows and Enter are answered while
|
||||
* focus stays in it, so a query and a choice are one uninterrupted sequence.
|
||||
*/
|
||||
export function useListHighlight(opts: {
|
||||
/** How many rows the list holds right now. */
|
||||
count: () => number
|
||||
/** The DOM id of the row at this index — the same `id` given to its `ListRow`. */
|
||||
rowId: (index: number) => string
|
||||
/** Where the highlight belongs when the list changes underneath it: the top hit while
|
||||
* a search is on, and typically -1 (nothing lit) when it is not. */
|
||||
restingIndex: () => number
|
||||
/** Open the row at this index. */
|
||||
onActivate: (index: number) => void
|
||||
/** Ids of the elements whose Enter also activates the highlighted row — the search
|
||||
* field. A focused row activates itself, so it is not one of these. */
|
||||
activateEnterFrom?: string[]
|
||||
}) {
|
||||
let index = $state(-1)
|
||||
// Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each
|
||||
// one, which would drag the highlight back under the cursor as the arrow keys move it.
|
||||
// Only a real pointer move hands the highlight back to the mouse.
|
||||
let pointerOwns = $state(true)
|
||||
|
||||
// Filtering reshuffles the rows under the highlight, so it goes back where the caller
|
||||
// says it belongs rather than staying on a position that now means another row.
|
||||
$effect(() => {
|
||||
opts.count()
|
||||
const resting = opts.restingIndex()
|
||||
untrack(() => (index = resting))
|
||||
})
|
||||
|
||||
function move(delta: number) {
|
||||
const count = opts.count()
|
||||
if (count === 0) return
|
||||
pointerOwns = false
|
||||
// Rows are tabbable, so focus can sit on one. Enter then activates whatever is
|
||||
// focused, which has to stay the highlighted row — so any row counts, not just
|
||||
// the lit one. Tab from the search field lands on the first row while the
|
||||
// highlight rests on the best match, and testing only the lit row would leave
|
||||
// focus behind and activate the wrong one.
|
||||
const focusedId = document.activeElement?.id
|
||||
const rowWasFocused =
|
||||
!!focusedId && Array.from({ length: count }, (_, i) => opts.rowId(i)).includes(focusedId)
|
||||
index = index < 0 ? (delta > 0 ? 0 : count - 1) : (index + delta + count) % count
|
||||
const row = document.getElementById(opts.rowId(index))
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
if (rowWasFocused) row?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
get index() {
|
||||
return index
|
||||
},
|
||||
/** Wire to each row's `onMouseEnter`. */
|
||||
hovered(i: number) {
|
||||
if (pointerOwns) index = i
|
||||
},
|
||||
/** Wire to the list container's `onpointermove`. */
|
||||
pointerMoved() {
|
||||
pointerOwns = true
|
||||
},
|
||||
/** Wire to the container that holds the search field and the rows, so the keys are
|
||||
* answered whichever of the two has focus. */
|
||||
onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
move(e.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (
|
||||
e.key === 'Enter' &&
|
||||
opts.activateEnterFrom?.includes((e.target as HTMLElement)?.id) &&
|
||||
index >= 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
opts.onActivate(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,11 @@
|
||||
style?: string
|
||||
cancelText?: string | undefined
|
||||
kind?: 'button' | 'X'
|
||||
/** Where you are inside the dialog, as a breadcrumb replacing the title: the whole path,
|
||||
* the dialog's own root first. A dialog at its root passes nothing (or one level) and keeps
|
||||
* its plain title; ancestors with an `onclick` are the way back, which Escape also takes. */
|
||||
/** Where you are inside the dialog: the whole path, the dialog's own root first. Below the
|
||||
* root the last level becomes the heading and the ones above it the line under it, with a
|
||||
* back control for the nearest. A dialog at its root passes nothing (or one level) and
|
||||
* keeps its plain title; ancestors with an `onclick` are the way back, which Escape also
|
||||
* takes. */
|
||||
trail?: ModalTrailSegment[]
|
||||
/** A line under the title saying what the dialog is for; in the header so it does not
|
||||
* scroll away with the body. */
|
||||
@@ -63,9 +65,12 @@
|
||||
* side panel when it is open. Pass an explicit value to stack above other
|
||||
* surfaces (e.g. a modal opened over the /sessions preview-pane editor). */
|
||||
minZIndex?: number
|
||||
/** Rendered against the dialog's own name, before any level below it: what it marks is the
|
||||
* dialog rather than wherever in it you have navigated to. */
|
||||
/** Rendered against the dialog's own name, wherever that name is: the heading at the root,
|
||||
* the first level of the way back below it. */
|
||||
titleBadge?: import('svelte').Snippet
|
||||
/** Rendered against the level you are on, which below the root is the heading. Nothing at
|
||||
* the root, where that level is the dialog and `titleBadge` already names it. */
|
||||
levelBadge?: import('svelte').Snippet
|
||||
settings?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
actions?: import('svelte').Snippet
|
||||
@@ -85,6 +90,7 @@
|
||||
enterConfirms = true,
|
||||
minZIndex: minZIndexProp = undefined,
|
||||
titleBadge,
|
||||
levelBadge,
|
||||
settings,
|
||||
children: children_render,
|
||||
actions
|
||||
@@ -101,7 +107,9 @@
|
||||
|
||||
// A trail of one level is the dialog at its root, which the plain title already shows.
|
||||
const crumbs = $derived(trail && trail.length > 1 ? trail : undefined)
|
||||
// The level under the one you are on: what Escape and the back chevron return to.
|
||||
// The level you are on, which is the heading below the root.
|
||||
const current = $derived(crumbs?.[crumbs.length - 1])
|
||||
// The level under it: what Escape and the back control return to.
|
||||
const back = $derived(crumbs?.[crumbs.length - 2])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -164,22 +172,19 @@
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
<!-- The level you would return to wears the back chevron; there is no separate back control. -->
|
||||
{#snippet crumb(segment: ModalTrailSegment, isBack: boolean)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
title={isBack ? `Back to ${segment.label}` : undefined}
|
||||
onClick={segment.onclick}
|
||||
wrapperClasses="min-w-0 shrink"
|
||||
btnClasses="group !px-0 !font-normal text-secondary hover:text-emphasis hover:!bg-transparent gap-0.5 min-w-0"
|
||||
<!-- The heading names the level you are on; where it sits within the dialog goes under it. -->
|
||||
<!-- `oneLine` for a level of the trail, which shares its row with the way back and the actions.
|
||||
The dialog's own title wraps instead, as it did before there was a trail: it is the whole row,
|
||||
and titles carrying a path or a sentence are long by nature. -->
|
||||
{#snippet heading(label: string, badge: import('svelte').Snippet | undefined, oneLine: boolean)}
|
||||
<h3
|
||||
class="text-emphasis text-lg font-semibold leading-7 flex items-center gap-1 {oneLine
|
||||
? 'min-w-0'
|
||||
: ''}"
|
||||
>
|
||||
{#if isBack}
|
||||
<!-- Pulled left so the label stays about where the title sits at the root. -->
|
||||
<ChevronLeft size={18} class="shrink-0 -ml-1" />
|
||||
{/if}
|
||||
<span class="truncate group-hover:underline">{segment.label}</span>
|
||||
</Button>
|
||||
<span class={oneLine ? 'truncate' : ''}>{label}</span>
|
||||
{@render badge?.()}
|
||||
</h3>
|
||||
{/snippet}
|
||||
|
||||
<Disposable bind:open bind:this={disposable} preventEscape {minZIndex}>
|
||||
@@ -234,72 +239,69 @@
|
||||
<!-- min-w-0: without it this flex item takes its content's min-content width and
|
||||
stretches the modal past its max-width instead of letting content shrink. -->
|
||||
<div class="text-left flex-1 min-w-0 {fillHeight ? 'flex flex-col min-h-0' : ''}">
|
||||
{#if crumbs}
|
||||
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a
|
||||
long trail would otherwise run under it. -->
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-2 min-w-0 {kind ===
|
||||
'X'
|
||||
? 'pr-8'
|
||||
: ''}"
|
||||
>
|
||||
<!-- leading-7 on the row and the heading alike, so the header is the same
|
||||
height at the root as one level in. -->
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex flex-row items-center gap-1 min-w-0 text-lg font-semibold leading-7"
|
||||
>
|
||||
{#each crumbs as segment, i (i)}
|
||||
{#if i === 1}
|
||||
{@render titleBadge?.()}
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
|
||||
<ChevronRight size={18} class="text-tertiary shrink-0" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if i === 0}
|
||||
<!-- flex: an inline child holding an icon sits on the baseline and adds
|
||||
descender room, which would make the row taller than at the root. -->
|
||||
<h3
|
||||
class="shrink-0 leading-7 flex items-center {segment.onclick
|
||||
? ''
|
||||
: 'text-emphasis'}"
|
||||
>
|
||||
{#if segment.onclick}
|
||||
{@render crumb(segment, i === crumbs.length - 2)}
|
||||
{:else}
|
||||
{segment.label}
|
||||
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a long
|
||||
heading or the header actions would otherwise run under it. -->
|
||||
<div
|
||||
class="flex flex-row items-start justify-between gap-2 min-w-0 {kind === 'X'
|
||||
? 'pr-8'
|
||||
: ''}"
|
||||
>
|
||||
{#if crumbs && current}
|
||||
<div class="flex flex-row items-start gap-1 min-w-0">
|
||||
{#if back?.onclick}
|
||||
<!-- Pulled left so the heading lands about where the title sits at the
|
||||
root, and up so it reads against that heading rather than against
|
||||
the two lines together. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: ChevronLeft }}
|
||||
title="Back to {back.label}"
|
||||
onClick={back.onclick}
|
||||
wrapperClasses="shrink-0 -ml-1 -mt-0.5"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0">
|
||||
{@render heading(current.label, levelBadge, true)}
|
||||
<!-- The way back in full: the control above carries one level of it. -->
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex flex-row items-center gap-0.5 min-w-0 text-xs text-secondary"
|
||||
>
|
||||
{#each crumbs.slice(0, -1) as segment, i (i)}
|
||||
{#if i > 0}
|
||||
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
|
||||
<ChevronRight size={12} class="text-tertiary shrink-0" />
|
||||
</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{:else if segment.onclick}
|
||||
{@render crumb(segment, i === crumbs.length - 2)}
|
||||
{:else}
|
||||
<span
|
||||
class="text-emphasis truncate"
|
||||
aria-current="page"
|
||||
in:fade={{ duration: 150 }}
|
||||
>
|
||||
{segment.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h3
|
||||
class="text-emphasis text-lg font-semibold {titleBadge
|
||||
? 'flex items-center gap-1'
|
||||
: ''}"
|
||||
>
|
||||
{title}
|
||||
{@render titleBadge?.()}
|
||||
</h3>
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.onclick}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
onClick={segment.onclick}
|
||||
wrapperClasses="min-w-0 shrink"
|
||||
btnClasses="!px-0 !font-normal !text-xs text-secondary hover:text-emphasis hover:underline hover:!bg-transparent min-w-0"
|
||||
>
|
||||
<span class="truncate">{segment.label}</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="truncate" in:fade={{ duration: 150 }}
|
||||
>{segment.label}</span
|
||||
>
|
||||
{/if}
|
||||
{#if i === 0}
|
||||
{@render titleBadge?.()}
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render heading(title, titleBadge, false)}
|
||||
{/if}
|
||||
{@render settings?.()}
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="mt-1 text-xs text-secondary">{description}</p>
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
* and clicks "outside" the child would otherwise propagate
|
||||
* here and close the underlying modal. */
|
||||
closeOnOutsideClick?: boolean
|
||||
/** Close on Escape. Default true. Every open modal listens on the
|
||||
* window, so a stacked pair would both close on one press; set it
|
||||
* false on the underlying modal while its child is up. */
|
||||
closeOnEscape?: boolean
|
||||
/** Wider side padding and a lighter title, for a dialog whose body is a form rather
|
||||
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
|
||||
formStyling?: boolean
|
||||
@@ -46,6 +50,7 @@
|
||||
fixedHeight = 'md',
|
||||
contentClasses = '',
|
||||
closeOnOutsideClick = true,
|
||||
closeOnEscape = true,
|
||||
formStyling = false,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
@@ -80,7 +85,7 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isOpen) return
|
||||
if (!isOpen || !closeOnEscape) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
* around this owns it, and a page component that swallowed it would stop the dialog from
|
||||
* closing. Without this prop the pages are still navigable, just not from the keyboard —
|
||||
* the caller owns `current` either way.
|
||||
*
|
||||
* A host that stays mounted while hidden must withhold it while hidden: the arrows are
|
||||
* answered at `window`, so a parked instance would take the key off the visible one.
|
||||
*/
|
||||
onNavigate?: (key: string) => void
|
||||
/**
|
||||
@@ -80,6 +83,9 @@
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
// A control on the page that already answered the key keeps it: the arrows move focus
|
||||
// inside a toggle group, a menu, a slider, and those handlers run before this one.
|
||||
if (event.defaultPrevented) return
|
||||
if (!ownsKeyboard(event.target)) return
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (step === 0) return
|
||||
|
||||
@@ -55,12 +55,13 @@ export function overlayStack(): OverlayStack {
|
||||
* Reads context, so call it during component initialisation; call the returned getter
|
||||
* where the target is used, to stay reactive as the host element mounts.
|
||||
*/
|
||||
export function overlayPortalTarget(fallback: string): () => HTMLElement | string {
|
||||
export function overlayPortalTarget(fallback: string | (() => string)): () => HTMLElement | string {
|
||||
const host = getOverlayHost()
|
||||
return () => {
|
||||
const selector = typeof fallback === 'function' ? fallback() : fallback
|
||||
const el = host?.el()
|
||||
if (!el) return fallback
|
||||
return el.querySelector<HTMLElement>(fallback) ?? el
|
||||
if (!el) return selector
|
||||
return el.querySelector<HTMLElement>(selector) ?? el
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ComponentType } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
@@ -32,7 +33,10 @@
|
||||
let { groups, selectedId, onNavigate, class: className = '' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-col gap-6', className)}>
|
||||
<!-- Up/Down walks the items, wrapping at the ends. On the wrapper rather than on each
|
||||
`<nav>` so the arrows cross group boundaries the way reading down the list does; the
|
||||
Y axis also leaves Left/Right to the paginated dialogs this can sit inside. -->
|
||||
<div class={twMerge('flex flex-col gap-6', className)} {@attach arrowTabNav()}>
|
||||
{#each groups as group (group.title)}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if group.title}
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
import AIChatModelSettings from './AIChatModelSettings.svelte'
|
||||
import McpConnections from './McpConnections.svelte'
|
||||
import SkillsPicker from './SkillsPicker.svelte'
|
||||
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
|
||||
import { SkillsMenu } from './skills/skillsMenu.svelte'
|
||||
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -207,8 +208,11 @@
|
||||
} = $props()
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
let mcpConnections: McpConnections | undefined = $state()
|
||||
let skillsPicker: SkillsPicker | undefined = $state()
|
||||
let assistantSettings: AssistantSettingsModal | undefined = $state()
|
||||
// The "+" menu's skill and MCP rows: enough state to check and flip one, with
|
||||
// everything else about them behind the assistant settings modal.
|
||||
const skillsMenu = new SkillsMenu(aiChatManager, () => assistantSettings?.open('skills'))
|
||||
const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp'))
|
||||
let plusMenuOpen = $state(false)
|
||||
let editingMessageIndex = $state<number | null>(null)
|
||||
|
||||
@@ -959,8 +963,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
const closeMenu = () => (plusMenuOpen = false)
|
||||
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
|
||||
const [skillItems, mcpItems] = await Promise.all([
|
||||
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined,
|
||||
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined
|
||||
inGlobal ? skillsMenu.items(closeMenu) : undefined,
|
||||
inGlobal ? mcpMenu.items(closeMenu) : undefined
|
||||
])
|
||||
return [
|
||||
{
|
||||
@@ -1143,10 +1147,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<DatatableCreationPolicy />
|
||||
{/if}
|
||||
<ContextUsageIndicator />
|
||||
<AIChatModelSettings />
|
||||
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
|
||||
and `sessionRuntime` locks a session to GLOBAL, where the settings
|
||||
modal's Instructions section owns the prompt entries. -->
|
||||
<AIChatModelSettings promptSettings={false} />
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
<SkillsPicker bind:this={skillsPicker} />
|
||||
<McpConnections bind:this={mcpConnections} />
|
||||
<AssistantSettingsModal bind:this={assistantSettings} />
|
||||
{/if}
|
||||
|
||||
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
|
||||
@@ -2123,7 +2123,10 @@ export class AIChatManager {
|
||||
// pipeline surface when a /pipeline editor has registered helpers. Centralized
|
||||
// so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent —
|
||||
// each rebuild would otherwise drop the pipeline augmentation the others added.
|
||||
private configureGlobalMode = () => {
|
||||
//
|
||||
// Public because it is purely local, unlike `changeMode(GLOBAL)`, which also
|
||||
// fires the three network refreshes.
|
||||
configureGlobalMode = () => {
|
||||
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
user: this.globalIdentity,
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
type ReasoningProviderModel
|
||||
} from '../reasoningRegistry'
|
||||
|
||||
let {
|
||||
/** Whether this dropdown carries the custom-prompt entries. Off where the surface
|
||||
* has an assistant settings modal — its Instructions section owns them there, and
|
||||
* two ways in would drift. The home composer has no such modal, so it keeps them. */
|
||||
promptSettings = true
|
||||
}: { promptSettings?: boolean } = $props()
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
|
||||
|
||||
@@ -335,7 +342,9 @@
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
|
||||
{#if promptSettings}
|
||||
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
|
||||
{/if}
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
|
||||
@@ -409,21 +418,24 @@
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
<!-- Only where the entries that open it are rendered. -->
|
||||
{#if promptSettings}
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<!--
|
||||
@component
|
||||
The Files & folders section of the assistant settings modal: everything linked to this
|
||||
chat, whether it is readable, and the way to unlink it.
|
||||
|
||||
Attaching happens in the composer — this is where what is already attached is accounted
|
||||
for, which is the one place the difference between "attached" and "readable" is visible.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, ListRow, Section } from '$lib/components/common'
|
||||
import EmptyState from '$lib/components/common/emptyState/EmptyState.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { FileText, Folder, Paperclip, Unlink } from 'lucide-svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { attachmentStatusLabel, countReadyAttachments } from './agentContext'
|
||||
|
||||
let {
|
||||
count = $bindable(),
|
||||
blocksClose = $bindable()
|
||||
}: {
|
||||
/** Attachments the assistant can actually read, for the sidebar badge. */
|
||||
count: number
|
||||
/** True while the confirmation is up, so the modal leaves the keys to it. */
|
||||
blocksClose: boolean
|
||||
} = $props()
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
let folders = $derived(aiChatManager.attachedFiles.folders)
|
||||
// Files dropped on a past message are listed alongside the session's own: the
|
||||
// composer's bar hides them because their chip lives on that message, but the
|
||||
// assistant reads them like any other row.
|
||||
let files = $derived([
|
||||
...aiChatManager.attachedFiles.standalone,
|
||||
...aiChatManager.attachedFiles.messageAttached
|
||||
])
|
||||
let messageScoped = $derived(
|
||||
new Set(aiChatManager.attachedFiles.messageAttached.map((f) => f.id))
|
||||
)
|
||||
let total = $derived(folders.length + files.length)
|
||||
let ready = $derived(countReadyAttachments(folders, files))
|
||||
|
||||
let pendingDetach = $state<{ kind: 'file' | 'folder'; name: string } | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
count = ready
|
||||
})
|
||||
$effect(() => {
|
||||
blocksClose = pendingDetach !== undefined
|
||||
})
|
||||
|
||||
function detach(target: { kind: 'file' | 'folder'; name: string }) {
|
||||
if (target.kind === 'folder') aiChatManager.attachedFiles.removeFolder(target.name)
|
||||
else aiChatManager.attachedFiles.removeFile(target.name)
|
||||
pendingDetach = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section
|
||||
label="Files & folders"
|
||||
description="Files and folders linked to this chat, which the assistant can open and search."
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
{#snippet action()}
|
||||
{#if total > 0}
|
||||
<!-- Both numbers when they differ: one count under a heading about what the
|
||||
assistant can use would hide the rows it cannot open. -->
|
||||
<span class="shrink-0 text-2xs text-secondary tabular-nums">
|
||||
{ready === total ? `${total} usable` : `${ready} of ${total} usable`}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if total === 0}
|
||||
<EmptyState
|
||||
icon={Paperclip}
|
||||
title="Nothing attached"
|
||||
description="Drop a file or a folder on the chat, or use the paperclip in the composer, and the assistant can open and search it."
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each folders as folder (folder.name)}
|
||||
{#snippet icon()}
|
||||
<Folder size={16} class="text-tertiary" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{folder.name}</span>
|
||||
{@const label = attachmentStatusLabel(folder.status)}
|
||||
{#if label}
|
||||
<span class="shrink-0 text-2xs font-normal text-hint">{label}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet subtitle()}
|
||||
{folder.files.length}
|
||||
{folder.files.length === 1 ? 'file' : 'files'}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Unlink }}
|
||||
iconOnly
|
||||
title="Unlink folder"
|
||||
onClick={() => (pendingDetach = { kind: 'folder', name: folder.name })}
|
||||
/>
|
||||
{/snippet}
|
||||
<ListRow {icon} {title} {subtitle} {trailing} />
|
||||
{/each}
|
||||
{#each files as file (file.id ?? file.name)}
|
||||
{#snippet icon()}
|
||||
<FileText size={16} class="text-tertiary" />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{file.name}</span>
|
||||
{@const label = attachmentStatusLabel(file.status)}
|
||||
{#if label}
|
||||
<span class="shrink-0 text-2xs font-normal text-hint">{label}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet subtitle()}
|
||||
Attached to a message
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Unlink }}
|
||||
iconOnly
|
||||
title="Unlink file"
|
||||
onClick={() => (pendingDetach = { kind: 'file', name: file.name })}
|
||||
/>
|
||||
{/snippet}
|
||||
<!-- A message-scoped row has no unlink here: those are rebuilt from the
|
||||
transcript on every load, so `removeFile` refuses them and the chip on the
|
||||
message is what actually drops one. -->
|
||||
<ListRow
|
||||
{icon}
|
||||
{title}
|
||||
subtitle={messageScoped.has(file.id) ? subtitle : undefined}
|
||||
trailing={messageScoped.has(file.id) ? undefined : trailing}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingDetach !== undefined}
|
||||
title={pendingDetach?.kind === 'folder' ? 'Unlink folder' : 'Unlink file'}
|
||||
confirmationText="Unlink"
|
||||
onConfirmed={() => pendingDetach && detach(pendingDetach)}
|
||||
onCanceled={() => (pendingDetach = undefined)}
|
||||
>
|
||||
<span class="text-xs text-primary">
|
||||
The assistant loses access to <span class="font-semibold">{pendingDetach?.name}</span>. Nothing
|
||||
is deleted from your disk, and you can attach it again from the composer.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
@@ -0,0 +1,354 @@
|
||||
<!--
|
||||
@component
|
||||
The Instructions section of the assistant settings modal: the two blocks of custom
|
||||
instructions the system prompt carries — the workspace one an admin sets for everyone,
|
||||
and the user one stored in this browser — a tab each, both editable in place. One Save
|
||||
writes whichever of the two changed, including the tab that is not on screen.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, Section, Tab, TabContent, Tabs } from '$lib/components/common'
|
||||
import Description from '$lib/components/Description.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import {
|
||||
copilotInfo,
|
||||
getUserCustomPrompts,
|
||||
setCopilotInfo,
|
||||
setUserCustomPrompts
|
||||
} from '$lib/aiStore'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { userStore, type UserExt } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$lib/base'
|
||||
import { Building2, ExternalLink, User } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
let {
|
||||
ws,
|
||||
active,
|
||||
blocksClose = $bindable()
|
||||
}: {
|
||||
/** The workspace the chat operates on, which is not always the one on screen. */
|
||||
ws: string
|
||||
/** Whether this is the panel on screen. The workspace half costs a settings read,
|
||||
* so it is fetched when someone looks at instructions rather than on every open
|
||||
* of the modal. */
|
||||
active: boolean
|
||||
/** True while this section is in the middle of something the modal must not
|
||||
* close under — here, instructions typed and not yet saved. */
|
||||
blocksClose: boolean
|
||||
} = $props()
|
||||
|
||||
const MAX_PROMPT_LENGTH = 5000
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
// The section acts on `ws`, which in a session is not the nav workspace, so the link
|
||||
// has to name it or it opens settings the user may not administer.
|
||||
let aiSettingsHref = $derived(`${base}/workspace_settings?workspace=${ws}&tab=ai`)
|
||||
|
||||
let mode = $derived(aiChatManager.mode)
|
||||
let tab = $state<'workspace' | 'user'>('workspace')
|
||||
let note = $derived(
|
||||
tab === 'workspace'
|
||||
? `Applies to everyone in ${ws}.`
|
||||
: 'Stored in this browser and sent in every workspace, so they follow you rather than the workspace.'
|
||||
)
|
||||
// `$userStore.is_admin` is the role in the nav workspace, not necessarily in `ws`, so
|
||||
// the resolved role is keyed to the workspace it was read for, and an unresolved one
|
||||
// reads as no admin: offering the field and taking it away on resolve would discard
|
||||
// whatever was typed in between. Superadmin holds everywhere.
|
||||
let targetRole = $state<{ workspace: string; user: UserExt | undefined } | undefined>(undefined)
|
||||
let roleRead = $derived(targetRole?.workspace === ws ? targetRole : undefined)
|
||||
let navIsTarget = $derived($userStore?.workspace_id === ws)
|
||||
let roleForTarget = $derived(roleRead?.user)
|
||||
let roleResolved = $derived(roleRead !== undefined || navIsTarget)
|
||||
// A read that came back with no user: read-only like a non-admin, but said differently,
|
||||
// since a failed lookup is not evidence of the role it failed to read.
|
||||
let roleUnknown = $derived(roleRead !== undefined && !roleRead.user && !navIsTarget)
|
||||
let isAdmin = $derived(
|
||||
Boolean(
|
||||
$userStore?.is_super_admin ||
|
||||
(roleForTarget ? roleForTarget.is_admin : navIsTarget && $userStore?.is_admin)
|
||||
)
|
||||
)
|
||||
|
||||
// A session whose fork is still staged has no workspace of its own yet, so `ws`
|
||||
// resolves to the PARENT. Saving here would edit the live parent's shared AI config,
|
||||
// which every chat still in it would then be given.
|
||||
function pendingForkParent(): string | undefined {
|
||||
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
|
||||
}
|
||||
let forkPending = $derived(pendingForkParent() !== undefined)
|
||||
|
||||
// What is on screen, against what was loaded. Two pairs rather than one: the halves
|
||||
// are stored in different places and save through different calls, and only the one
|
||||
// that changed should be written.
|
||||
let workspaceDraft = $state('')
|
||||
let workspaceSaved = $state('')
|
||||
let userDraft = $state('')
|
||||
let userSaved = $state('')
|
||||
let loading = $state(false)
|
||||
let saving = $state(false)
|
||||
|
||||
// True when the workspace has no AI providers of its own (it uses instance defaults).
|
||||
// In that case the backend never makes workspace custom_prompts effective, so a saved
|
||||
// workspace prompt would be dead config — mirror the settings page and show it read-only.
|
||||
let workspaceMissingProviders = $state(false)
|
||||
let workspaceReadOnly = $derived(!isAdmin || workspaceMissingProviders || forkPending)
|
||||
let readOnlyReason = $derived(
|
||||
forkPending
|
||||
? `This session has not created its workspace yet, so these would be saved to "${pendingForkParent()}" and given to every chat already in it. Send a message first.`
|
||||
: !roleResolved
|
||||
? `Checking your access to ${ws}.`
|
||||
: roleUnknown
|
||||
? `Could not read your access to ${ws}. Reopen this section to try again.`
|
||||
: !isAdmin
|
||||
? 'Only workspace admins can edit the workspace instructions.'
|
||||
: 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
|
||||
)
|
||||
|
||||
let workspaceChanged = $derived(!workspaceReadOnly && workspaceDraft !== workspaceSaved)
|
||||
let userChanged = $derived(userDraft !== userSaved)
|
||||
let dirty = $derived(workspaceChanged || userChanged)
|
||||
|
||||
$effect(() => {
|
||||
blocksClose = dirty
|
||||
})
|
||||
|
||||
// Loaded on the first look and again on a workspace switch: B's instructions must not
|
||||
// be saved over A's. A reload drops a draft, so it waits until one is saved or
|
||||
// reverted — hence `dirty` tracked, and `loadedWorkspace` to pick the wait up against
|
||||
// whatever `ws` is by then (a staged fork commits into a workspace of its own).
|
||||
let loadSeq = 0
|
||||
let loadedWorkspace: string | undefined = undefined
|
||||
$effect(() => {
|
||||
const target = ws
|
||||
const shown = active
|
||||
const clean = !dirty
|
||||
untrack(() => {
|
||||
// Parked: read again on the next visit, since the workspace half is editable
|
||||
// from the settings page too.
|
||||
if (!shown) {
|
||||
loadedWorkspace = undefined
|
||||
return
|
||||
}
|
||||
if (!target || !clean || target === loadedWorkspace) return
|
||||
loadedWorkspace = target
|
||||
void load(target)
|
||||
})
|
||||
})
|
||||
|
||||
async function load(target: string) {
|
||||
const seq = ++loadSeq
|
||||
loading = true
|
||||
try {
|
||||
const resolved = await getUserExt(target).catch(() => undefined)
|
||||
if (seq !== loadSeq) return
|
||||
targetRole = { workspace: target, user: resolved }
|
||||
const user = getUserCustomPrompts()[mode] ?? ''
|
||||
// Seeded from the same source `saveWorkspace` writes to (the raw workspace
|
||||
// ai_config), which also says whether the workspace has providers of its own.
|
||||
// Non-admins cannot read raw settings, so they get the effective prompt.
|
||||
let workspace = $copilotInfo.customPrompts?.[mode] ?? ''
|
||||
let missingProviders = false
|
||||
if (isAdmin) {
|
||||
try {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: target })
|
||||
missingProviders = Object.keys(settings?.ai_config?.providers ?? {}).length === 0
|
||||
workspace = settings?.ai_config?.custom_prompts?.[mode] ?? ''
|
||||
} catch (err) {
|
||||
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
|
||||
}
|
||||
}
|
||||
if (seq !== loadSeq) return
|
||||
workspaceMissingProviders = missingProviders
|
||||
// Read before the assignments below move the baseline they compare against.
|
||||
// Each half is then seeded only while it still holds what it was loaded with:
|
||||
// the effect that starts this checks `dirty` before the request, not after it,
|
||||
// so an admin who opens the tab and types straight away would otherwise have
|
||||
// the settings response land on top of the text they are in the middle of.
|
||||
const keepWorkspaceDraft = workspaceChanged
|
||||
const keepUserDraft = userChanged
|
||||
workspaceSaved = workspace
|
||||
if (!keepWorkspaceDraft) workspaceDraft = workspace
|
||||
userSaved = user
|
||||
if (!keepUserDraft) userDraft = user
|
||||
} finally {
|
||||
if (seq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape puts the fields back rather than closing the modal: `blocksClose` holds the
|
||||
* modal shut while there is unsaved text, so this is the way out of a draft, and a
|
||||
* second press then closes. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// Only while this is the section on screen: every section stays mounted, so an
|
||||
// Escape meant for another one would revert these drafts with nothing visible
|
||||
// to say that it had.
|
||||
if (!active || event.key !== 'Escape' || !dirty) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
workspaceDraft = workspaceSaved
|
||||
userDraft = userSaved
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true
|
||||
try {
|
||||
if (userChanged) saveUser(userDraft.trim())
|
||||
if (workspaceChanged) await saveWorkspace(workspaceDraft.trim())
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveUser(value: string) {
|
||||
const prompts = getUserCustomPrompts()
|
||||
if (value) {
|
||||
prompts[mode] = value
|
||||
} else {
|
||||
delete prompts[mode]
|
||||
}
|
||||
setUserCustomPrompts(prompts)
|
||||
// These live in localStorage, which nothing observes, so the chat is told rather
|
||||
// than left to pick them up on its next send. `update_user_instructions` already
|
||||
// rebuilds this way when the assistant edits the same block.
|
||||
aiChatManager.rebuildGlobalSystemMessage()
|
||||
userSaved = value
|
||||
userDraft = value
|
||||
sendUserToast('User instructions were saved')
|
||||
}
|
||||
|
||||
async function saveWorkspace(value: string) {
|
||||
const parent = pendingForkParent()
|
||||
if (parent !== undefined) {
|
||||
sendUserToast(
|
||||
`This session has not created its workspace yet, so the instructions would be saved to "${parent}". Send a message first.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
// Pinned across both awaits: the read and the write have to land on one workspace.
|
||||
const target = ws
|
||||
try {
|
||||
// Saving prompts requires a full ai_config round-trip; fetch the current config
|
||||
// so we don't clobber providers/models/etc.
|
||||
const settings = await WorkspaceService.getSettings({ workspace: target })
|
||||
const config = settings.ai_config ?? {}
|
||||
const custom_prompts = { ...(config.custom_prompts ?? {}) }
|
||||
if (value) {
|
||||
custom_prompts[mode] = value
|
||||
} else {
|
||||
delete custom_prompts[mode]
|
||||
}
|
||||
const response = await WorkspaceService.editCopilotConfig({
|
||||
workspace: target,
|
||||
requestBody: { ...config, custom_prompts }
|
||||
})
|
||||
setCopilotInfo(response.effective_ai_config)
|
||||
workspaceSaved = value
|
||||
workspaceDraft = value
|
||||
sendUserToast('Workspace instructions were saved')
|
||||
} catch (err) {
|
||||
// The field keeps what was typed, so the save can be retried.
|
||||
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#snippet field(p: { value: string; readOnly: boolean; onInput: (v: string) => void })}
|
||||
<div class="flex flex-col gap-1">
|
||||
<TextInput
|
||||
value={p.value}
|
||||
underlyingInputEl="textarea"
|
||||
size="sm"
|
||||
class="min-h-24 resize-y"
|
||||
inputProps={{
|
||||
placeholder: p.readOnly ? '' : 'Anything the assistant should always keep in mind',
|
||||
rows: 4,
|
||||
maxlength: MAX_PROMPT_LENGTH,
|
||||
// Also while saving: the write spans two requests and its success path puts the
|
||||
// submitted value back, so text typed in between would be swallowed. `load`
|
||||
// keeps a newer draft instead, because it starts on its own and must not block
|
||||
// someone who opened the tab to type.
|
||||
readonly: p.readOnly || saving,
|
||||
oninput: (e) => p.onInput(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
{#if !p.readOnly}
|
||||
<span class="self-end text-2xs text-hint">
|
||||
{p.value.length}/{MAX_PROMPT_LENGTH} characters
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Section
|
||||
label="Instructions"
|
||||
description="Text added to every system prompt in this workspace, on top of the assistant's own. Both blocks are sent, the workspace one first."
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
{#snippet action()}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if isAdmin}
|
||||
<Button
|
||||
href={aiSettingsHref}
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
AI settings
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!dirty || saving || loading}
|
||||
onClick={save}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Tabs values={['workspace', 'user']} bind:selected={tab}>
|
||||
<Tab value="workspace" label="Workspace" icon={Building2} />
|
||||
<Tab value="user" label="User (you)" icon={User} />
|
||||
{#snippet content()}
|
||||
<!-- The note belongs to the panel rather than to either tab: one element that
|
||||
follows the selection, so switching tabs does not rebuild it and the space
|
||||
under the tab row is the same on both. The wrapper carries no padding of its
|
||||
own — `Tabs` renders the row and this content as two roots, so the Section's
|
||||
`gap-4` is already the space between them. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Description>{note}</Description>
|
||||
{#if workspaceReadOnly && tab === 'workspace'}
|
||||
<Alert type="info" title="These are read-only for you" size="xs">
|
||||
{readOnlyReason}
|
||||
</Alert>
|
||||
{/if}
|
||||
<!-- `alwaysMounted`: Save writes whichever half changed, including the tab that
|
||||
is not on screen, so a field left with unsaved text has to go on holding it. -->
|
||||
<TabContent value="workspace" alwaysMounted>
|
||||
{@render field({
|
||||
value: workspaceDraft,
|
||||
readOnly: workspaceReadOnly,
|
||||
onInput: (v) => (workspaceDraft = v)
|
||||
})}
|
||||
</TabContent>
|
||||
<TabContent value="user" alwaysMounted>
|
||||
{@render field({
|
||||
value: userDraft,
|
||||
readOnly: false,
|
||||
onInput: (v) => (userDraft = v)
|
||||
})}
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</Section>
|
||||
@@ -0,0 +1,632 @@
|
||||
<!--
|
||||
@component
|
||||
The MCP connections section of the assistant settings modal: the form that connects
|
||||
an external MCP server to this chat, and the servers already connected, each with the
|
||||
switch that decides whether this chat carries its tools.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, ListRow, Section } from '$lib/components/common'
|
||||
import EmptyState from '$lib/components/common/emptyState/EmptyState.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import McpConnect from '$lib/components/mcp/McpConnect.svelte'
|
||||
import ResourceEditor from '$lib/components/ResourceEditor.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
|
||||
import { loadProviderIcon } from '$lib/components/mcp/providerIcon'
|
||||
import {
|
||||
cachedProviderKey,
|
||||
forgetProviderKey,
|
||||
rememberProviderKey
|
||||
} from '$lib/components/mcp/iconCache'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { Component } from 'svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { ArrowLeft, Loader2, Pencil, Plug, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { draftValuesEqual } from '$lib/userDraft.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { clearMcpToolsCache } from './global/mcpTools'
|
||||
|
||||
let {
|
||||
ws,
|
||||
active,
|
||||
count = $bindable(),
|
||||
blocksClose = $bindable()
|
||||
}: {
|
||||
/** The workspace the chat operates on, which is not always the one on screen. */
|
||||
ws: string
|
||||
/** Whether this is the panel on screen. Gates the connect page's build, which
|
||||
* costs a read of the instance OAuth connects — not worth paying for on a modal
|
||||
* opened for one of the other three sections. */
|
||||
active: boolean
|
||||
/** Number of connected servers, for the sidebar badge. */
|
||||
count: number
|
||||
/** True while this section is in the middle of something the modal must not
|
||||
* close under: its confirmation, or the connect form holding a half-filled
|
||||
* connection. */
|
||||
blocksClose: boolean
|
||||
} = $props()
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// A session whose fork is still staged has no workspace of its own yet, so `ws`
|
||||
// resolves to the PARENT. Connecting or editing here would write the resource and
|
||||
// its token into the live parent, and a switch would be stored under it and quietly
|
||||
// stop applying the moment the first send commits the fork. Read at use, not once:
|
||||
// the fork commits mid-session.
|
||||
function pendingForkParent(): string | undefined {
|
||||
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
|
||||
}
|
||||
let forkPending = $state(false)
|
||||
function refreshForkPending() {
|
||||
forkPending = pendingForkParent() !== undefined
|
||||
}
|
||||
/** Guards every mutating action. Returns true when the caller must not proceed. */
|
||||
function blockedByPendingFork(): boolean {
|
||||
const parent = pendingForkParent()
|
||||
if (parent === undefined) return false
|
||||
refreshForkPending()
|
||||
sendUserToast(
|
||||
`This session has not created its workspace yet, so the connection would be written to "${parent}" instead. Send a message first.`,
|
||||
true
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let servers = $state<
|
||||
{
|
||||
path: string
|
||||
description?: string
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
}[]
|
||||
>([])
|
||||
let loading = $state(false)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let pendingDelete = $state<string | undefined>(undefined)
|
||||
// A row's overflow menu is portaled out of the modal, so a click on one of its items
|
||||
// is a click outside the modal. Tracking it here keeps the modal from closing under
|
||||
// the action the item is about to run.
|
||||
let rowMenuOpen = $state<Record<string, boolean>>({})
|
||||
// The connect form is the second page of this panel, not a surface over it.
|
||||
let connectOpen = $state(false)
|
||||
// Bumped as the connect page is left, which is what clears the form for the next
|
||||
// connection: the page stays mounted once built, so nothing else would. Bumped on
|
||||
// the way out rather than on the way in so the rebuild happens while the page is
|
||||
// parked and invisible, leaving every visit an already-built form.
|
||||
let connectSeq = $state(0)
|
||||
|
||||
// The row being edited, on the third page. `ResourceEditor` is the same editor the
|
||||
// resource drawer opens, so a connection is edited here the way it is edited
|
||||
// anywhere else — url, token, description, path.
|
||||
// Kept when the detail page is left so the Right arrow steps back into it.
|
||||
let editing = $state<{ path: string; enabled: boolean } | undefined>(undefined)
|
||||
let detailOpen = $state(false)
|
||||
// The path the editor holds, which is not `editing.path` once someone renames it.
|
||||
let editingPath = $state('')
|
||||
let canSaveEditing = $state(false)
|
||||
let resourceEditor: ResourceEditor | undefined = $state(undefined)
|
||||
|
||||
// What the form holds, which is what Save is measured against — not the stored value
|
||||
// the editor loaded. The schema form fills in a value for every property the resource
|
||||
// type declares, so an `mcp` resource saved without `headers` is holding
|
||||
// `headers: null` by the time it is on screen, and measuring against the stored value
|
||||
// would call that an edit the moment a connection is opened. The trigger editors take
|
||||
// their baseline from their own form for the same reason.
|
||||
//
|
||||
// It follows the form until the form is first focused, rather than being taken at a
|
||||
// moment judged to be after the fields have filled themselves in: those writes land
|
||||
// over several flushes and a snapshot timed against them is a guess, while nothing
|
||||
// here can be edited without focus reaching the form first.
|
||||
let editingBaseline = $state<unknown>(undefined)
|
||||
let editingTouched = $state(false)
|
||||
$effect(() => {
|
||||
const editor: ResourceEditor | undefined = resourceEditor
|
||||
// Snapshotting is also the deep read that makes this run again on every field.
|
||||
const settled = editor === undefined ? undefined : $state.snapshot(editor.localDraftCurrent())
|
||||
untrack(() => {
|
||||
if (editingTouched || settled === undefined) return
|
||||
editingBaseline = settled
|
||||
})
|
||||
})
|
||||
let editingChanged = $derived.by(() => {
|
||||
const editor: ResourceEditor | undefined = resourceEditor
|
||||
return !draftValuesEqual(editor?.localDraftCurrent(), editingBaseline)
|
||||
})
|
||||
// Bumped per visit: `ResourceEditor` reads its path once, on mount, and this page
|
||||
// stays mounted, so without a remount the second server opened would be the first.
|
||||
let detailSeq = $state(0)
|
||||
|
||||
let page = $derived(connectOpen ? 'connect' : detailOpen ? 'detail' : 'list')
|
||||
|
||||
$effect(() => {
|
||||
count = servers.length
|
||||
})
|
||||
$effect(() => {
|
||||
blocksClose =
|
||||
pendingDelete !== undefined ||
|
||||
connectOpen ||
|
||||
detailOpen ||
|
||||
Object.values(rowMenuOpen).some(Boolean)
|
||||
})
|
||||
|
||||
// Parked with the section: a page left open behind another section would go on
|
||||
// reporting `blocksClose`, and the modal would refuse to close with nothing on
|
||||
// screen explaining why. Set directly rather than through `closeConnect`, whose
|
||||
// rebuild would throw away a half-filled connect form.
|
||||
$effect(() => {
|
||||
if (!active) {
|
||||
detailOpen = false
|
||||
connectOpen = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Escape steps back to the list rather than closing the whole modal: `blocksClose`
|
||||
* stops the modal's own handler, so this is the only thing left to answer the key. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// Every section stays mounted while the modal is open, and `stopPropagation`
|
||||
// does nothing between listeners on `window`: without this, a key aimed at the
|
||||
// section on screen is answered by the four behind it too.
|
||||
if (!active || event.key !== 'Escape' || page === 'list' || pendingDelete !== undefined) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (connectOpen) closeConnect()
|
||||
else closeDetail()
|
||||
}
|
||||
|
||||
function openConnect() {
|
||||
if (blockedByPendingFork()) return
|
||||
connectOpen = true
|
||||
}
|
||||
|
||||
function closeConnect() {
|
||||
connectOpen = false
|
||||
connectSeq++
|
||||
}
|
||||
|
||||
function openServer(server: { path: string; enabled: boolean }) {
|
||||
editing = { path: server.path, enabled: server.enabled }
|
||||
editingPath = server.path
|
||||
// Dropped with the editor it was taken from: the next one settles on its own
|
||||
// connection, and comparing against the previous one's would call every field
|
||||
// an edit.
|
||||
editingBaseline = undefined
|
||||
editingTouched = false
|
||||
detailSeq++
|
||||
detailOpen = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailOpen = false
|
||||
}
|
||||
|
||||
/** Left and Right step between the pages, which is `PagedContent` answering the
|
||||
* arrows once it is given this. Each forward step only goes somewhere it has
|
||||
* something to show: the detail page holds a connection only once one was opened.
|
||||
*
|
||||
* The three pages are a strip and the arrows walk it by position, so stepping back
|
||||
* from the connect form asks for the detail page. Both of those are levels below the
|
||||
* list rather than a sequence, so backwards out of either lands on the list. */
|
||||
function navigate(key: string) {
|
||||
if (key === 'list' || connectOpen) {
|
||||
connectOpen = false
|
||||
detailOpen = false
|
||||
} else if (key === 'connect') {
|
||||
openConnect()
|
||||
} else if (editing) {
|
||||
detailOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditing() {
|
||||
if (blockedByPendingFork()) return
|
||||
const server = editing
|
||||
if (!server) return
|
||||
// Pinned alongside the row, like `toggle` and `deleteConnection`: the enablement
|
||||
// writes below run after the save returns, and must land under the workspace the
|
||||
// connection was edited in rather than whichever one is current by then.
|
||||
const target = ws
|
||||
// A failed save leaves the connection exactly as it was, so none of the
|
||||
// bookkeeping below may run: moving the enablement then would turn a server
|
||||
// that still exists off, and turn on a path that was never created.
|
||||
if (!(await resourceEditor?.save())) return
|
||||
// Enablement is keyed by path, so a rename would leave the switch on the path
|
||||
// that no longer exists and the server itself off.
|
||||
if (editingPath && editingPath !== server.path) {
|
||||
setMcpEnabled(target, server.path, false)
|
||||
forgetProviderKey(target, server.path)
|
||||
setMcpEnabled(target, editingPath, server.enabled)
|
||||
}
|
||||
closeDetail()
|
||||
await refresh(target)
|
||||
}
|
||||
|
||||
// Rows describe one workspace. A switch while the section is open must not leave
|
||||
// A's rows on screen while the actions below target B: same path, different
|
||||
// server, and a delete would remove the wrong one.
|
||||
let loadSeq = 0
|
||||
$effect(() => {
|
||||
const target = ws
|
||||
untrack(() => {
|
||||
servers = []
|
||||
pendingDelete = undefined
|
||||
// Back to the list too: the editor holds one workspace's resource, and the
|
||||
// path it is on names a different server in the workspace switched to.
|
||||
editing = undefined
|
||||
detailOpen = false
|
||||
connectOpen = false
|
||||
void loadServers(target)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadServers(target = ws) {
|
||||
refreshForkPending()
|
||||
// Checked before the sequence is taken, not only after the await: a stale
|
||||
// action calling refresh(A) would otherwise claim the newest sequence and make
|
||||
// the legitimate load for B discard its own result.
|
||||
if (!target || target !== ws) return
|
||||
const seq = ++loadSeq
|
||||
loading = true
|
||||
loadError = undefined
|
||||
try {
|
||||
const resources = await ResourceService.listResource({
|
||||
workspace: target,
|
||||
resourceType: 'mcp',
|
||||
perPage: 100
|
||||
})
|
||||
if (seq !== loadSeq) return
|
||||
servers = resources.map((r) => ({
|
||||
path: r.path,
|
||||
description: r.description,
|
||||
editedAt: r.edited_at,
|
||||
enabled: isMcpEnabled(target, r.path)
|
||||
}))
|
||||
// Seeded rather than filled by the bindings: an unset entry would hand
|
||||
// DropdownV2 an `undefined` open state instead of a closed one.
|
||||
rowMenuOpen = Object.fromEntries(resources.map((r) => [r.path, false]))
|
||||
void loadIcons(target, seq)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return
|
||||
// Without this the section would render the empty state, which reads as
|
||||
// "you have no connections" rather than "we could not load them".
|
||||
loadError = e.body ?? e.message
|
||||
} finally {
|
||||
if (seq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(path: string, enabled: boolean) {
|
||||
if (blockedByPendingFork()) return
|
||||
// Pinned for the whole call: the selection is stored per workspace, and the
|
||||
// refresh below must not hand these servers to a chat that has since moved on.
|
||||
const target = ws
|
||||
// Local preference only: nothing to re-read from the API, and the cached
|
||||
// tool lists stay valid because the servers are unchanged. Checked, like the
|
||||
// skills switch: a refused write leaves the chat carrying a different set than
|
||||
// the switch shows, and nothing else would ever say so.
|
||||
if (!setMcpEnabled(target, path, enabled)) {
|
||||
sendUserToast('Could not save the selection for this account.', true)
|
||||
return
|
||||
}
|
||||
const server = servers.find((s) => s.path === path)
|
||||
if (server) server.enabled = enabled
|
||||
if (target !== ws) return
|
||||
await aiChatManager.refreshMcpServers(target)
|
||||
}
|
||||
|
||||
// Deleting a resource also deletes every variable its value references, and an
|
||||
// mcp resource's token is usually the credential of the resource it was created
|
||||
// from (the github one). Drop the reference first so deleting the connection can
|
||||
// never destroy a credential something else still uses; the variable is left for
|
||||
// the user to remove from the Variables page.
|
||||
async function deleteConnection(path: string) {
|
||||
if (blockedByPendingFork()) return
|
||||
// Pinned for the whole sequence: a switch midway would strip and delete the
|
||||
// resource that happens to share this path in the workspace switched to.
|
||||
const target = ws
|
||||
try {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: target,
|
||||
path
|
||||
})
|
||||
const { token: _token, ...withoutToken } = (resource.value ?? {}) as Record<string, unknown>
|
||||
await ResourceService.updateResource({
|
||||
workspace: target,
|
||||
path,
|
||||
requestBody: { value: withoutToken }
|
||||
})
|
||||
await ResourceService.deleteResource({ workspace: target, path })
|
||||
// A later resource at this path is a different server; it must be turned
|
||||
// on deliberately rather than inherit this one's enablement.
|
||||
setMcpEnabled(target, path, false)
|
||||
forgetProviderKey(target, path)
|
||||
sendUserToast(`Deleted ${path}. Its token variable was kept.`)
|
||||
await refresh(target)
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to delete ${path}: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
pendingDelete = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// A row whose provider is already cached paints from the cache; the rest cost
|
||||
// one read each, and a long list stops asking rather than firing a request
|
||||
// storm at a screen nobody is reading that far down.
|
||||
const MAX_ICON_LOOKUPS = 20
|
||||
async function loadIcons(target: string, seq: number) {
|
||||
let lookups = 0
|
||||
await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
let key = cachedProviderKey(target, server.path, server.editedAt)
|
||||
if (key === undefined) {
|
||||
if (lookups >= MAX_ICON_LOOKUPS) return
|
||||
lookups++
|
||||
try {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: target,
|
||||
path: server.path
|
||||
})
|
||||
key = rememberProviderKey(
|
||||
target,
|
||||
server.path,
|
||||
(resource.value as { url?: unknown } | undefined)?.url,
|
||||
server.editedAt
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const icon = await loadProviderIcon(key)
|
||||
if (seq !== loadSeq) return
|
||||
server.icon = icon
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function refresh(target = ws) {
|
||||
// A path can be reconnected to a different server, so the cached tool list
|
||||
// (and the readOnlyHint the confirmation gate reads) must not survive.
|
||||
clearMcpToolsCache()
|
||||
await loadServers(target)
|
||||
// `refreshMcpServers` blanks the list when the workspace it is handed is not
|
||||
// the one the chat is on, so a refresh landing after a switch would take B's
|
||||
// servers out of the prompt entirely.
|
||||
if (target !== ws) return
|
||||
// Re-register the chat's MCP tools so a connection made here is usable in
|
||||
// the next message without a reload.
|
||||
await aiChatManager.refreshMcpServers(target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- The list, a connection and the connect form are levels of one panel, so moving
|
||||
between them slides rather than cuts — the same shape the Skills panel uses for
|
||||
its editor. Warmed once this panel is on screen so the connect form is built
|
||||
before the click rather than inside the transition; the detail page holds a
|
||||
`ResourceEditor` for one path and only builds once a row is opened. -->
|
||||
<PagedContent
|
||||
warm={active}
|
||||
class="grow min-h-0"
|
||||
current={page}
|
||||
onNavigate={active ? navigate : undefined}
|
||||
pages={[
|
||||
{ key: 'list', content: listPage },
|
||||
{ key: 'detail', content: detailPage },
|
||||
{ key: 'connect', content: connectPage }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#snippet listPage()}
|
||||
<div class="grow min-h-0 overflow-y-auto pr-2">
|
||||
<Section
|
||||
label="MCP connections"
|
||||
description="External MCP servers this chat can call. Their tools run with your own credentials, so the chat can only reach what you can."
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
{#snippet action()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={forkPending}
|
||||
onClick={openConnect}
|
||||
>
|
||||
Connect a server
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if forkPending}
|
||||
<Alert type="info" title="This session has no workspace yet" size="xs" class="mb-4">
|
||||
Connections are read-only until the first message creates this session's fork. Connecting
|
||||
or selecting one now would apply to the parent workspace and stop applying once the fork
|
||||
is created.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-4"><Loader2 class="animate-spin" /></div>
|
||||
{:else if loadError}
|
||||
<div class="text-xs text-red-600 dark:text-red-400">
|
||||
Failed to load MCP connections: {loadError}
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<EmptyState
|
||||
icon={Plug}
|
||||
title="No MCP server connected"
|
||||
description="Connect an external MCP server and the chat can call its tools with your own credentials."
|
||||
action={{
|
||||
label: 'Connect a server',
|
||||
icon: Plus,
|
||||
onClick: openConnect,
|
||||
disabled: forkPending
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each servers as server (server.path)}
|
||||
{#snippet icon()}
|
||||
{#if server.icon}
|
||||
{@const Icon = server.icon}
|
||||
<Icon width="16px" height="16px" />
|
||||
{:else}
|
||||
<Plug size={16} class="text-tertiary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{server.path}</span>
|
||||
{/snippet}
|
||||
{#snippet subtitle()}{server.description}{/snippet}
|
||||
{#snippet trailing()}
|
||||
<Toggle
|
||||
size="sm"
|
||||
disabled={forkPending}
|
||||
checked={server.enabled}
|
||||
on:change={async (e) => await toggle(server.path, e.detail)}
|
||||
/>
|
||||
<DropdownV2
|
||||
size="sm"
|
||||
bind:open={rowMenuOpen[server.path]}
|
||||
items={[
|
||||
{
|
||||
displayName: 'Manage connection',
|
||||
icon: Pencil,
|
||||
action: () => openServer(server)
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
disabled: forkPending,
|
||||
action: () => (pendingDelete = server.path)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/snippet}
|
||||
<ListRow
|
||||
{icon}
|
||||
{title}
|
||||
{trailing}
|
||||
subtitle={server.description ? subtitle : undefined}
|
||||
onClick={() => openServer(server)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet detailPage()}
|
||||
<div class="grow min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Sticky so the way back is always one click away, however far the page scrolls. -->
|
||||
<div class="flex sticky top-0 z-10 bg-surface pb-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
btnClasses="text-secondary"
|
||||
onClick={closeDetail}
|
||||
>
|
||||
MCP connections
|
||||
</Button>
|
||||
</div>
|
||||
<!-- `headerClass` keeps a long path on one line: the Save button shares the header
|
||||
row and would otherwise wrap the title under itself. -->
|
||||
<!-- Titled by the path the editor holds rather than the row's, so a rename is
|
||||
visible as it is typed and the header does not empty out while the page
|
||||
slides away. -->
|
||||
<Section label={editingPath} wrapperClass="mt-1" headerClass="min-w-0 truncate pr-2 font-mono">
|
||||
{#snippet action()}
|
||||
<div class="flex justify-end shrink-0">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!canSaveEditing || !editingChanged}
|
||||
onClick={saveEditing}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<!-- Freezes the baseline above: from the first focus on, what the form holds is
|
||||
the user's doing rather than its own. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onfocusin={() => (editingTouched = true)}>
|
||||
{#key detailSeq}
|
||||
{#if detailSeq > 0}
|
||||
<!-- `editingPath` is seeded with the row's path before this remounts, which
|
||||
is how the editor is told which resource to load: it reads its path once,
|
||||
on mount, and reports a rename back through the same binding. -->
|
||||
<ResourceEditor
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave={canSaveEditing}
|
||||
bind:path={editingPath}
|
||||
workspace={ws}
|
||||
resource_type="mcp"
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet connectPage()}
|
||||
<!-- The form takes the panel over rather than opening on top of it: a form stacked
|
||||
on the settings modal leaves two surfaces arguing over which one a click or an
|
||||
Escape belongs to. `McpConnect` carries its own heading, so there is no Section
|
||||
around it. -->
|
||||
<div class="grow min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Sticky so the way back is always one click away, however far the page scrolls. -->
|
||||
<div class="flex sticky top-0 z-10 bg-surface pb-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
btnClasses="text-secondary"
|
||||
onClick={closeConnect}
|
||||
>
|
||||
MCP connections
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{#key connectSeq}
|
||||
<McpConnect
|
||||
bordered={false}
|
||||
workspace={ws}
|
||||
onConnected={async (connectedWs, path) => {
|
||||
// Connecting one is the act of choosing it, and it is keyed on where
|
||||
// it was created rather than on what is on screen now: a switch
|
||||
// during the popup would otherwise enable the path in a workspace
|
||||
// that has no such connection.
|
||||
if (!setMcpEnabled(connectedWs, path, true)) {
|
||||
sendUserToast(`Connected ${path}, but could not turn it on. Toggle it here.`, true)
|
||||
}
|
||||
closeConnect()
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingDelete !== undefined}
|
||||
title="Delete MCP connection"
|
||||
confirmationText="Delete"
|
||||
onConfirmed={() => {
|
||||
if (pendingDelete) void deleteConnection(pendingDelete)
|
||||
}}
|
||||
onCanceled={() => (pendingDelete = undefined)}
|
||||
>
|
||||
<span class="text-xs text-primary">
|
||||
This deletes the resource at <span class="font-semibold">{pendingDelete}</span>, so the chat and
|
||||
any flow pointing at it lose the server. Its token variable is kept. To stop this chat from
|
||||
using the server without deleting it, turn its switch off instead.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
@@ -0,0 +1,238 @@
|
||||
<!--
|
||||
@component
|
||||
Everything the chat carries into a turn beyond the conversation itself: its tools, the
|
||||
skills and MCP servers turned on for it, and the custom instructions in force. Each of
|
||||
those outlives the moment it was set — a skill turned on last week and an instruction
|
||||
written once both go on steering every answer — so this is the one place they are all
|
||||
accounted for and changed.
|
||||
|
||||
The trigger sits in the composer next to the model pill; `open(section)` is for the
|
||||
callers that already know which section they mean, such as the "+" menu's Manage entries.
|
||||
-->
|
||||
<script lang="ts" module>
|
||||
export type AssistantSettingsSection = 'tools' | 'skills' | 'instructions' | 'mcp' | 'files'
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { BookOpen, Boxes, Paperclip, Plug, ScrollText, SlidersHorizontal } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { summarizeTools } from './agentContext'
|
||||
import AssistantToolsSection from './AssistantToolsSection.svelte'
|
||||
import AssistantSkillsSection from './AssistantSkillsSection.svelte'
|
||||
import AssistantInstructionsSection from './AssistantInstructionsSection.svelte'
|
||||
import AssistantMcpSection from './AssistantMcpSection.svelte'
|
||||
import AssistantFilesSection from './AssistantFilesSection.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
let isOpen = $state(false)
|
||||
let section = $state<AssistantSettingsSection>('tools')
|
||||
let skillCount = $state(0)
|
||||
let mcpCount = $state(0)
|
||||
let fileCount = $state(0)
|
||||
// A section is mid-something the modal must not close under: a confirmation or a
|
||||
// popover portaled to the body (where a click inside reads as a click outside this
|
||||
// modal), or an editor holding text nobody has saved yet. While one is up, Escape
|
||||
// and an outside click belong to the section, which answers them itself.
|
||||
let toolsBusy = $state(false)
|
||||
let skillsBusy = $state(false)
|
||||
let instructionsBusy = $state(false)
|
||||
let mcpBusy = $state(false)
|
||||
let filesBusy = $state(false)
|
||||
let blocksClose = $derived(toolsBusy || skillsBusy || instructionsBusy || mcpBusy || filesBusy)
|
||||
// Which section is holding the modal open. The section on screen wins whenever it is
|
||||
// itself blocking: it is the one that will answer the key, and preferring any other
|
||||
// would take Escape away from the surface the user is looking at. Otherwise it is
|
||||
// whichever section blocks from behind — in practice Instructions holding unsaved
|
||||
// text, since the page-based sections park themselves when navigated away from.
|
||||
let busyBySection = $derived<Record<AssistantSettingsSection, boolean>>({
|
||||
tools: toolsBusy,
|
||||
skills: skillsBusy,
|
||||
instructions: instructionsBusy,
|
||||
mcp: mcpBusy,
|
||||
files: filesBusy
|
||||
})
|
||||
let blockingSection = $derived<AssistantSettingsSection | undefined>(
|
||||
busyBySection[section]
|
||||
? section
|
||||
: (Object.keys(busyBySection) as AssistantSettingsSection[]).find((k) => busyBySection[k])
|
||||
)
|
||||
|
||||
/** Escape with something blocking behind another section would look like a dead key:
|
||||
* `Modal2` ignores it, and the section that answers it is not on screen. Show that
|
||||
* section instead, so the reason the modal will not close is in front of the user.
|
||||
*
|
||||
* `stopImmediatePropagation` because this listener is the parent's and runs before
|
||||
* the sections': showing a section makes it `active` within this same dispatch, and
|
||||
* it would then answer the key it was revealed by — reverting the very draft the
|
||||
* user was brought here to see. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen || event.key !== 'Escape') return
|
||||
const blocker = blockingSection
|
||||
if (!blocker || blocker === section) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
select(blocker)
|
||||
}
|
||||
|
||||
// A session chat operates on its own (possibly forked) workspace without switching
|
||||
// `workspaceStore`, and that is the workspace every list here is read under.
|
||||
//
|
||||
// `operatingWorkspace` is a plain getter over untracked state, so the store is read
|
||||
// unconditionally rather than behind `??`: short-circuiting it would leave this
|
||||
// derived with no dependency at all, frozen on the workspace it first saw.
|
||||
let ws = $derived.by(() => {
|
||||
const active = $workspaceStore
|
||||
return aiChatManager.operatingWorkspace ?? active ?? ''
|
||||
})
|
||||
|
||||
// Exactly what the chat loop sends — plan mode's transition tool is registered
|
||||
// alongside `tools` there, so listing only `tools` would under-report.
|
||||
let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools]))
|
||||
|
||||
// `SidebarNavigation`'s item shape, which is what the instance and workspace settings
|
||||
// navs are built from too.
|
||||
let sections = $derived([
|
||||
{ id: 'tools', label: 'Tools', icon: Boxes, count: tools.length },
|
||||
{ id: 'skills', label: 'Skills', icon: BookOpen, count: skillCount },
|
||||
{ id: 'instructions', label: 'Instructions', icon: ScrollText },
|
||||
{ id: 'mcp', label: 'MCP connections', icon: Plug, count: mcpCount },
|
||||
// The count is what is readable rather than what is attached: a locked folder under
|
||||
// a heading about what the assistant can use would say the opposite of the truth.
|
||||
{ id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount }
|
||||
])
|
||||
|
||||
/** Opens on `target`, or back on whichever section was last read. */
|
||||
export function open(target: AssistantSettingsSection = section) {
|
||||
section = target
|
||||
isOpen = true
|
||||
refresh()
|
||||
record('open')
|
||||
record(target)
|
||||
}
|
||||
|
||||
// `context_panel` is the kind registered in `feature_usage_ee.rs`; an unregistered
|
||||
// pair is dropped silently, so the name is fixed there rather than here.
|
||||
//
|
||||
// The key vocabulary is the closed set in this signature and nothing else — a
|
||||
// skill path or server path here would be workspace-authored text.
|
||||
function record(key: 'open' | AssistantSettingsSection) {
|
||||
logFeatureUsage('ai_session', 'context_panel', { key, workspace: ws })
|
||||
}
|
||||
|
||||
function select(target: AssistantSettingsSection) {
|
||||
if (target === section) return
|
||||
section = target
|
||||
record(target)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
// `globalSkills` and `mcpServers` are per-manager snapshots, and the enabled
|
||||
// sets they derive from are shared: toggling a skill in one session leaves
|
||||
// every other warm session's copy behind until its next send refreshes it.
|
||||
// Opening this modal is the one moment the tool list has to be true, so it
|
||||
// refreshes the same way the send path does — the active chat only.
|
||||
//
|
||||
// Never while a turn is in flight, though. These refreshes carry generation
|
||||
// counters, so starting one invalidates the send's own: its `Promise.all`
|
||||
// would return without applying results, and this one would then rewrite the
|
||||
// tools and system prompt underneath a running turn. Mid-turn it shows the
|
||||
// values the turn was actually given, which is the honest answer anyway.
|
||||
if (!aiChatManager.loading && !aiChatManager.sendInFlight) {
|
||||
void aiChatManager.refreshGlobalSkills()
|
||||
void aiChatManager.refreshMcpServers()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<Tooltip small placement="top">
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: SlidersHorizontal }}
|
||||
aria-label="Assistant settings"
|
||||
onClick={() => open()}
|
||||
/>
|
||||
{#snippet text()}
|
||||
<div class="max-w-64 text-xs">
|
||||
<p class="font-semibold">Assistant settings</p>
|
||||
<p class="mt-1">The tools, skills, instructions and MCP servers this chat can use.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen
|
||||
title="Assistant settings"
|
||||
fixedWidth="md"
|
||||
fixedHeight="lg"
|
||||
closeOnOutsideClick={!blocksClose}
|
||||
closeOnEscape={!blocksClose}
|
||||
>
|
||||
{#snippet headerLeft()}
|
||||
<p class="pl-3 pt-1 text-xs text-secondary truncate">
|
||||
What the assistant can see and use in this session.
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<div class="w-full flex min-h-0 gap-4">
|
||||
<div class="w-52 shrink-0 flex flex-col border-r border-border-light pr-3">
|
||||
<SidebarNavigation
|
||||
groups={[{ items: sections }]}
|
||||
selectedId={section}
|
||||
onNavigate={(id) => select(id as AssistantSettingsSection)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow min-w-0 flex flex-col min-h-0">
|
||||
<!-- Every section stays mounted while the modal is open: the sidebar badges
|
||||
count what each one loaded, so hiding is display-only. Tools, Skills and MCP
|
||||
own their own scrolling — each is a list and a detail page laid over each
|
||||
other — so only Instructions scrolls here. -->
|
||||
<div class="{section === 'tools' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
|
||||
<AssistantToolsSection {tools} active={section === 'tools'} bind:blocksClose={toolsBusy} />
|
||||
</div>
|
||||
<!-- Skills owns its own scrolling: its list and its editor are PagedContent pages
|
||||
laid over each other, and each keeps a scroll position of its own. -->
|
||||
<div class="{section === 'skills' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
|
||||
<AssistantSkillsSection
|
||||
{ws}
|
||||
active={section === 'skills'}
|
||||
bind:count={skillCount}
|
||||
bind:blocksClose={skillsBusy}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="{section === 'instructions' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2"
|
||||
>
|
||||
<AssistantInstructionsSection
|
||||
{ws}
|
||||
active={section === 'instructions'}
|
||||
bind:blocksClose={instructionsBusy}
|
||||
/>
|
||||
</div>
|
||||
<!-- Like Skills, MCP owns its own scrolling: its list and its connect form are
|
||||
PagedContent pages laid over each other. -->
|
||||
<div class="{section === 'mcp' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
|
||||
<AssistantMcpSection
|
||||
{ws}
|
||||
active={section === 'mcp'}
|
||||
bind:count={mcpCount}
|
||||
bind:blocksClose={mcpBusy}
|
||||
/>
|
||||
</div>
|
||||
<div class="{section === 'files' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2">
|
||||
<AssistantFilesSection bind:count={fileCount} bind:blocksClose={filesBusy} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
+477
-382
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
<!--
|
||||
@component
|
||||
The Tools section of the assistant settings modal: every tool definition this chat
|
||||
sends with each turn. The list carries the model-facing name and description; opening
|
||||
one shows the description in full and the arguments it takes. Read-only — tools are not
|
||||
individually switchable, they follow the mode and the connected servers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, ListRow, Section } from '$lib/components/common'
|
||||
import { useListHighlight } from '$lib/components/common/listRow/listHighlight.svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
import type { ToolSummary } from './agentContext'
|
||||
|
||||
let {
|
||||
tools,
|
||||
active,
|
||||
blocksClose = $bindable()
|
||||
}: {
|
||||
tools: ToolSummary[]
|
||||
/** Whether this is the panel on screen. Gates the detail page's build, which pulls
|
||||
* in the schema table and its syntax highlighter. */
|
||||
active: boolean
|
||||
/** True while the detail page is open, so the modal leaves Escape to this section. */
|
||||
blocksClose: boolean
|
||||
} = $props()
|
||||
|
||||
type Marked = ToolSummary & { marked?: string }
|
||||
|
||||
let filter = $state('')
|
||||
// The same fuzzy search the home list and the pickers use, run twice: `marked` covers
|
||||
// whatever the haystack was, and the name and the description are two fields on the
|
||||
// row rather than one string, so each needs a pass of its own to be highlighted.
|
||||
let nameHits = $state<Marked[] | undefined>(undefined)
|
||||
let descriptionHits = $state<Marked[] | undefined>(undefined)
|
||||
// The tool the detail page shows, kept when the page is left so the Right arrow
|
||||
// steps back into it.
|
||||
let selected = $state<ToolSummary | undefined>(undefined)
|
||||
let detailOpen = $state(false)
|
||||
|
||||
const SEARCH_INPUT_ID = 'assistant-tools-search'
|
||||
const rowDomId = (index: number) => `assistant-tool-row-${index}`
|
||||
|
||||
let searching = $derived(filter.trim().length > 0)
|
||||
// Name matches first, then the tools that only matched on what they do. Within each
|
||||
// half the order is the one uFuzzy ranked.
|
||||
let rows: { tool: ToolSummary; name?: string; description?: string }[] = $derived.by(() => {
|
||||
if (!searching) return tools.map((tool) => ({ tool }))
|
||||
const byName = nameHits ?? []
|
||||
const named = new Set(byName.map((t) => t.name))
|
||||
return [
|
||||
...byName.map((t) => ({ tool: t, name: t.marked })),
|
||||
...(descriptionHits ?? [])
|
||||
.filter((t) => !named.has(t.name))
|
||||
.map((t) => ({ tool: t, description: t.marked }))
|
||||
]
|
||||
})
|
||||
|
||||
// The arrows and Enter are answered while focus stays in the search field, the way the
|
||||
// resource-type picker does it: type a few letters, step down the hits, open one.
|
||||
const highlight = useListHighlight({
|
||||
count: () => rows.length,
|
||||
rowId: rowDomId,
|
||||
// The top hit while searching — uFuzzy already ranked it there — and nothing lit
|
||||
// once the field is cleared, when every tool is on screen and none is the answer.
|
||||
restingIndex: () => (searching && rows.length > 0 ? 0 : -1),
|
||||
onActivate: (index) => open(rows[index]?.tool),
|
||||
activateEnterFrom: [SEARCH_INPUT_ID]
|
||||
})
|
||||
|
||||
function open(tool: ToolSummary | undefined) {
|
||||
if (!tool) return
|
||||
selected = tool
|
||||
detailOpen = true
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
blocksClose = detailOpen
|
||||
})
|
||||
// Parked with the section: a detail page left open behind another section would go
|
||||
// on reporting `blocksClose`, and the modal would refuse to close with nothing on
|
||||
// screen explaining why.
|
||||
$effect(() => {
|
||||
if (!active) detailOpen = false
|
||||
})
|
||||
|
||||
/** Escape steps back to the list rather than closing the whole modal: `blocksClose`
|
||||
* stops the modal's own handler, so this is the only thing left to answer the key. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// Every section stays mounted while the modal is open, and `stopPropagation`
|
||||
// does nothing between listeners on `window`: without this, a key aimed at the
|
||||
// section on screen is answered by the four behind it too.
|
||||
if (!active || event.key !== 'Escape' || !detailOpen) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
detailOpen = false
|
||||
}
|
||||
|
||||
/** Left and Right step between the two pages, which is `PagedContent` answering the
|
||||
* arrows once it is given this. Forward only goes somewhere once a tool has been
|
||||
* opened: the detail page has nothing to show before that. */
|
||||
function navigate(key: string) {
|
||||
if (key === 'list') detailOpen = false
|
||||
else if (selected) detailOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<SearchItems {filter} items={tools} bind:filteredItems={nameHits} f={(t) => t.name} />
|
||||
<SearchItems {filter} items={tools} bind:filteredItems={descriptionHits} f={(t) => t.description} />
|
||||
|
||||
<!-- The list and one tool are levels of one panel, the same shape the Skills and MCP
|
||||
panels use. Warmed once this panel is on screen so the schema table and its
|
||||
highlighter are built before the click rather than inside the transition. -->
|
||||
<PagedContent
|
||||
warm={active}
|
||||
class="grow min-h-0"
|
||||
current={detailOpen ? 'detail' : 'list'}
|
||||
onNavigate={active ? navigate : undefined}
|
||||
pages={[
|
||||
{ key: 'list', content: listPage },
|
||||
{ key: 'detail', content: detailPage }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#snippet listPage()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- Arrow keys and Enter are caught here so they work whether the search field or a row
|
||||
holds focus. -->
|
||||
<div
|
||||
class="grow min-h-0 overflow-y-auto pr-2"
|
||||
onkeydown={highlight.onKeydown}
|
||||
onpointermove={highlight.pointerMoved}
|
||||
>
|
||||
<Section
|
||||
label="Tools"
|
||||
description="What the assistant can call in this session: the built-in tools, plus whatever the connected MCP servers expose."
|
||||
>
|
||||
<!-- Sticks to the top of the scrolling panel so a 70-row list stays searchable. -->
|
||||
<div class="sticky top-0 z-10 bg-surface pb-2">
|
||||
<TextInput
|
||||
bind:value={filter}
|
||||
size="sm"
|
||||
inputProps={{ placeholder: 'Search tools', id: SEARCH_INPUT_ID }}
|
||||
/>
|
||||
</div>
|
||||
{#if rows.length === 0}
|
||||
<div class="py-2 text-xs text-hint">
|
||||
{tools.length === 0 ? 'This chat carries no tools.' : 'No tool matches this search.'}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Borderless rows on their own hover, the shape the resource-type picker uses:
|
||||
a card and dividers around 70 rows read as heavier than the list is. -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each rows as row, index (row.tool.name)}
|
||||
{#snippet title()}
|
||||
<span class="truncate font-mono leading-5">
|
||||
{#if row.name}{@html row.name}{:else}{row.tool.name}{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet subtitle()}
|
||||
{#if row.description}{@html row.description}{:else}{row.tool.description}{/if}
|
||||
{/snippet}
|
||||
<ListRow
|
||||
id={rowDomId(index)}
|
||||
{title}
|
||||
subtitle={row.tool.description ? subtitle : undefined}
|
||||
highlighted={index === highlight.index}
|
||||
onMouseEnter={() => highlight.hovered(index)}
|
||||
onClick={() => open(row.tool)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet detailPage()}
|
||||
<div class="grow min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Sticky so the way back is always one click away, however far down the arguments go. -->
|
||||
<div class="flex sticky top-0 z-10 bg-surface pb-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
btnClasses="text-secondary"
|
||||
onClick={() => (detailOpen = false)}
|
||||
>
|
||||
Tools
|
||||
</Button>
|
||||
</div>
|
||||
<!-- `headerClass` keeps a long tool name on one line. -->
|
||||
<Section
|
||||
label={selected?.name ?? ''}
|
||||
wrapperClass="mt-1"
|
||||
headerClass="min-w-0 truncate pr-2 font-mono"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
{#if selected?.description}
|
||||
<!-- In full, unlike the row, which truncates to one line: most of these run to
|
||||
several sentences, and this page is where the rest of one lives. -->
|
||||
<div class="text-xs text-secondary whitespace-pre-line">{selected.description}</div>
|
||||
{/if}
|
||||
<SchemaViewer schema={selected?.parameters} />
|
||||
</Section>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -61,6 +61,7 @@
|
||||
)}
|
||||
onclick={onToggle}
|
||||
disabled={!toggleable}
|
||||
aria-expanded={toggleable ? expanded : undefined}
|
||||
>
|
||||
{#if shimmer}
|
||||
<span class="shimmer inline-flex items-center min-w-0">
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, Drawer } from '$lib/components/common'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import McpConnect from '$lib/components/mcp/McpConnect.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
|
||||
import { loadProviderIcon } from '$lib/components/mcp/providerIcon'
|
||||
import {
|
||||
cachedProviderKey,
|
||||
forgetProviderKey,
|
||||
rememberProviderKey
|
||||
} from '$lib/components/mcp/iconCache'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { Component } from 'svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { List, Loader2, Plug, Plus, Trash2 } from 'lucide-svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { clearMcpToolsCache } from './global/mcpTools'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
// A session chat operates on its own (possibly forked) workspace without
|
||||
// switching `workspaceStore`, and that is the workspace the chat reads the
|
||||
// enabled set under. Key everything here the same way or a toggle lands under
|
||||
// a key nothing reads.
|
||||
//
|
||||
// `operatingWorkspace` is a plain getter over untracked state, so the store is
|
||||
// read unconditionally rather than behind `??`: short-circuiting it would leave
|
||||
// this derived with no dependency at all, frozen on the workspace it first saw.
|
||||
let ws = $derived.by(() => {
|
||||
const active = $workspaceStore
|
||||
return aiChatManager.operatingWorkspace ?? active!
|
||||
})
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
// Connecting is what the drawer is for, so the card is always up; remounting it
|
||||
// after a connection is what clears the fields for the next one.
|
||||
let connectSeq = $state(0)
|
||||
let servers = $state<
|
||||
{
|
||||
path: string
|
||||
description?: string
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
}[]
|
||||
>([])
|
||||
let loading = $state(false)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let pendingDisconnect = $state<string | undefined>(undefined)
|
||||
|
||||
// Rows describe one workspace. A switch while the drawer is open must not leave
|
||||
// A's rows on screen while the actions below target B: same path, different
|
||||
// server, and disconnect would delete the wrong one. Dropping them (and the
|
||||
// confirmation standing over one of them) is all this does: this component
|
||||
// mounts with the chat toolbar, so loading here would list resources for every
|
||||
// user who never opens the menu. The two entry points load what they need.
|
||||
let loadSeq = 0
|
||||
$effect(() => {
|
||||
const target = ws
|
||||
untrack(() => {
|
||||
loadSeq++
|
||||
servers = []
|
||||
pendingDisconnect = undefined
|
||||
// A drawer already on screen is neither entry point, and would sit there
|
||||
// reporting that the new workspace has no connections.
|
||||
if (drawer?.isOpen()) void loadServers(target)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadServers(target = ws) {
|
||||
if (!target) return
|
||||
const seq = ++loadSeq
|
||||
loading = true
|
||||
loadError = undefined
|
||||
try {
|
||||
const resources = await ResourceService.listResource({
|
||||
workspace: target,
|
||||
resourceType: 'mcp',
|
||||
perPage: 100
|
||||
})
|
||||
if (seq !== loadSeq) return
|
||||
servers = resources.map((r) => ({
|
||||
path: r.path,
|
||||
description: r.description,
|
||||
editedAt: r.edited_at,
|
||||
enabled: isMcpEnabled(target, r.path)
|
||||
}))
|
||||
void loadIcons(target, seq)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return
|
||||
// Without this the drawer would render the empty state, which reads as
|
||||
// "you have no connections" rather than "we could not load them".
|
||||
loadError = e.body ?? e.message
|
||||
} finally {
|
||||
if (seq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function open() {
|
||||
drawer?.openDrawer()
|
||||
await loadServers()
|
||||
}
|
||||
|
||||
// A menu is a shortcut, not a directory: past this many the list stops being
|
||||
// scannable, so the rest are reached through the drawer rather than dropped.
|
||||
const MAX_MENU_SERVERS = 8
|
||||
|
||||
/** Rows for the chat's "+" menu: one per connected server, checked when it is
|
||||
* on, then the way to add another. Loaded on open so the checks are current. */
|
||||
export async function menuItems(closeMenu?: () => void): Promise<Item[]> {
|
||||
// The menu opens on what is already known and refreshes behind it: waiting on
|
||||
// a round trip would stall the whole `+` menu, attachments included.
|
||||
if (servers.length === 0) {
|
||||
await loadServers()
|
||||
} else {
|
||||
void loadServers()
|
||||
}
|
||||
// Enabled first: those are the ones a quick visit is most likely about.
|
||||
const ordered = [...servers].sort(
|
||||
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
|
||||
)
|
||||
const shown = ordered.slice(0, MAX_MENU_SERVERS)
|
||||
return [
|
||||
...shown.map(({ path }) => ({
|
||||
displayName: path,
|
||||
// Getters, not snapshots: the menu stays open across a click, and it has
|
||||
// to read through the live list rather than the row captured here, since
|
||||
// a reload replaces every row object and a getter bound to the old one
|
||||
// would go on reporting the state it was built with.
|
||||
get icon() {
|
||||
// Plug where the provider is unknown, so one nameless server does not
|
||||
// pull its label out of line with the rest.
|
||||
return row(path)?.icon ?? Plug
|
||||
},
|
||||
// Provider icons take css lengths and ignore lucide's `size`, so without
|
||||
// this one of them renders at its 24px default among 14px menu icons.
|
||||
get iconProps() {
|
||||
return row(path)?.icon ? { width: '14px', height: '14px' } : undefined
|
||||
},
|
||||
get toggle() {
|
||||
return row(path)?.enabled ?? false
|
||||
},
|
||||
action: () => toggle(path, !row(path)?.enabled)
|
||||
})),
|
||||
...(ordered.length > shown.length
|
||||
? [
|
||||
{
|
||||
displayName: `Show all ${ordered.length}`,
|
||||
icon: List,
|
||||
action: () => {
|
||||
closeMenu?.()
|
||||
void open()
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Connect a server',
|
||||
icon: Plus,
|
||||
separatorTop: servers.length > 0,
|
||||
action: () => {
|
||||
closeMenu?.()
|
||||
void open()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function row(path: string) {
|
||||
return servers.find((s) => s.path === path)
|
||||
}
|
||||
|
||||
async function toggle(path: string, enabled: boolean) {
|
||||
// Local preference only: nothing to re-read from the API, and the cached
|
||||
// tool lists stay valid because the servers are unchanged.
|
||||
setMcpEnabled(ws, path, enabled)
|
||||
const server = servers.find((s) => s.path === path)
|
||||
if (server) server.enabled = enabled
|
||||
await aiChatManager.refreshMcpServers()
|
||||
}
|
||||
|
||||
// Deleting a resource also deletes every variable its value references, and an
|
||||
// mcp resource's token is usually the credential of the resource it was created
|
||||
// from (the github one). Drop the reference before deleting so disconnecting
|
||||
// here can never destroy a credential something else still uses; the variable
|
||||
// is left for the user to remove from the Variables page.
|
||||
async function disconnect(path: string) {
|
||||
// Pinned for the whole sequence: a switch midway would strip and delete the
|
||||
// resource that happens to share this path in the workspace switched to.
|
||||
const target = ws
|
||||
try {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: target,
|
||||
path
|
||||
})
|
||||
const { token: _token, ...withoutToken } = (resource.value ?? {}) as Record<string, unknown>
|
||||
await ResourceService.updateResource({
|
||||
workspace: target,
|
||||
path,
|
||||
requestBody: { value: withoutToken }
|
||||
})
|
||||
await ResourceService.deleteResource({ workspace: target, path })
|
||||
// A later resource at this path is a different server; it must be turned
|
||||
// on deliberately rather than inherit this one's enablement.
|
||||
setMcpEnabled(target, path, false)
|
||||
forgetProviderKey(target, path)
|
||||
sendUserToast(`Disconnected ${path}. Its token variable was kept.`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to disconnect ${path}: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
pendingDisconnect = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// A row whose provider is already cached paints from the cache; the rest cost
|
||||
// one read each, and a long list stops asking rather than firing a request
|
||||
// storm at a screen nobody is reading that far down.
|
||||
const MAX_ICON_LOOKUPS = 20
|
||||
async function loadIcons(target: string, seq: number) {
|
||||
let lookups = 0
|
||||
await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
let key = cachedProviderKey(target, server.path, server.editedAt)
|
||||
if (key === undefined) {
|
||||
if (lookups >= MAX_ICON_LOOKUPS) return
|
||||
lookups++
|
||||
try {
|
||||
const resource = await ResourceService.getResource({
|
||||
workspace: target,
|
||||
path: server.path
|
||||
})
|
||||
key = rememberProviderKey(
|
||||
target,
|
||||
server.path,
|
||||
(resource.value as { url?: unknown } | undefined)?.url,
|
||||
server.editedAt
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const icon = await loadProviderIcon(key)
|
||||
if (seq !== loadSeq) return
|
||||
server.icon = icon
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
// A path can be reconnected to a different server, so the cached tool list
|
||||
// (and the readOnlyHint the confirmation gate reads) must not survive.
|
||||
clearMcpToolsCache()
|
||||
await loadServers()
|
||||
// Re-register the chat's MCP tools so a connection made here is usable in
|
||||
// the next message without a reload.
|
||||
await aiChatManager.refreshMcpServers()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="700px">
|
||||
<DrawerContent
|
||||
title="MCP connections"
|
||||
on:close={() => drawer?.closeDrawer()}
|
||||
tooltip="Connect an external MCP server to this chat. The chat calls its tools with your own credentials, so it can only reach what you can."
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
{#key connectSeq}
|
||||
<McpConnect
|
||||
workspace={ws}
|
||||
onConnected={async (connectedWs, path) => {
|
||||
// Connecting one is the act of choosing it, and it is keyed on where
|
||||
// it was created rather than on what is on screen now: a switch
|
||||
// during the popup would otherwise enable the path in a workspace
|
||||
// that has no such connection.
|
||||
if (!setMcpEnabled(connectedWs, path, true)) {
|
||||
sendUserToast(`Connected ${path}, but could not turn it on. Toggle it here.`, true)
|
||||
}
|
||||
connectSeq++
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-4"><Loader2 class="animate-spin" /></div>
|
||||
{:else if loadError}
|
||||
<div class="text-xs text-red-600 dark:text-red-400">
|
||||
Failed to load MCP connections: {loadError}
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<div class="text-xs text-secondary">No MCP server connected yet.</div>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y border rounded-md bg-surface-tertiary">
|
||||
{#each servers as server (server.path)}
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
{#if server.icon}
|
||||
{@const Icon = server.icon}
|
||||
<Icon width="16px" height="16px" class="shrink-0" />
|
||||
{:else}
|
||||
<Plug size={16} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
<div class="min-w-0 grow">
|
||||
<div class="text-xs font-semibold text-emphasis truncate">{server.path}</div>
|
||||
{#if server.description}
|
||||
<div class="text-xs text-secondary truncate">{server.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={server.enabled}
|
||||
on:change={async (e) => await toggle(server.path, e.detail)}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
title="Disconnect"
|
||||
onClick={() => (pendingDisconnect = server.path)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingDisconnect !== undefined}
|
||||
title="Disconnect MCP server"
|
||||
confirmationText="Disconnect"
|
||||
onConfirmed={() => {
|
||||
if (pendingDisconnect) void disconnect(pendingDisconnect)
|
||||
}}
|
||||
onCanceled={() => (pendingDisconnect = undefined)}
|
||||
>
|
||||
<span class="text-xs text-primary">
|
||||
This deletes the resource at <span class="font-semibold">{pendingDisconnect}</span>, so the chat
|
||||
and any flow pointing at it lose the server. Its token variable is kept.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { attachmentStatusLabel, countReadyAttachments, summarizeTools } from './agentContext'
|
||||
import type { Tool } from './shared'
|
||||
|
||||
const tool = (name: string, description?: string, parameters?: Record<string, any>) =>
|
||||
({ def: { type: 'function', function: { name, description, parameters } } }) as unknown as Tool<{}>
|
||||
|
||||
describe('summarizeTools', () => {
|
||||
// The modal re-derives this on every turn, so an unsorted list would reshuffle
|
||||
// under the reader as tools come and go.
|
||||
it('sorts by name and tolerates a tool with no description', () => {
|
||||
expect(summarizeTools([tool('run_script', 'Run it.'), tool('deploy')])).toEqual([
|
||||
{ name: 'deploy', description: '', parameters: { required: [] } },
|
||||
{ name: 'run_script', description: 'Run it.', parameters: { required: [] } }
|
||||
])
|
||||
})
|
||||
|
||||
// `SchemaViewer` renders no argument table at all without `required`, and a tool
|
||||
// whose arguments are all optional legitimately ships without it.
|
||||
it('defaults required without dropping the declared schema', () => {
|
||||
const [summary] = summarizeTools([
|
||||
tool('open_page', 'Open it.', { type: 'object', properties: { page: { type: 'string' } } })
|
||||
])
|
||||
expect(summary.parameters).toEqual({
|
||||
required: [],
|
||||
type: 'object',
|
||||
properties: { page: { type: 'string' } }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('countReadyAttachments', () => {
|
||||
// A folder's own status is an aggregate, and `readyFiles()` filters out the
|
||||
// placeholder row an empty or all-binary folder keeps — so counting on the folder
|
||||
// would claim files the assistant cannot open, under a heading about what it can.
|
||||
it('counts a folder by its readable children, not its own status', () => {
|
||||
const folders = [
|
||||
{ files: [{ status: 'indexing' as const }, { status: 'ready' as const }] },
|
||||
{ files: [] },
|
||||
{ files: [{ status: 'locked' as const }] }
|
||||
]
|
||||
expect(countReadyAttachments(folders, [])).toBe(1)
|
||||
})
|
||||
|
||||
it('counts loose files on their own status', () => {
|
||||
const files = [
|
||||
{ status: 'ready' as const },
|
||||
{ status: 'error' as const },
|
||||
{ status: 'ready' as const }
|
||||
]
|
||||
expect(countReadyAttachments([], files)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachmentStatusLabel', () => {
|
||||
// Every unreadable status has to say why: the file tools operate on `readyFiles()`,
|
||||
// so a row that reads like the usable ones is the one place the panel could claim
|
||||
// something the assistant cannot open.
|
||||
it('labels every status the file tools cannot read, and only those', () => {
|
||||
expect(attachmentStatusLabel('ready')).toBeUndefined()
|
||||
for (const status of ['locked', 'unavailable', 'indexing', 'error'] as const) {
|
||||
expect(attachmentStatusLabel(status)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Tool } from './shared'
|
||||
import type { AttachedFileStatus } from './files/attachedFiles.svelte'
|
||||
|
||||
/** One tool as the settings modal lists it — the model-facing name, description and
|
||||
* argument schema, which is exactly what the tool definition sends. */
|
||||
export type ToolSummary = {
|
||||
name: string
|
||||
description: string
|
||||
/** The JSON Schema of the tool's arguments. `required` is defaulted because
|
||||
* `SchemaViewer` reads it to mark the rows and renders nothing without it, and a
|
||||
* tool whose arguments are all optional legitimately omits it. */
|
||||
parameters: Record<string, any>
|
||||
}
|
||||
|
||||
/** Tool definitions as the modal lists them: name-sorted, so a list of dozens is
|
||||
* scannable and stays put as the set changes between turns. */
|
||||
export function summarizeTools(tools: readonly Tool<any>[]): ToolSummary[] {
|
||||
return tools
|
||||
.map((t) => ({
|
||||
name: t.def.function.name,
|
||||
description: t.def.function.description ?? '',
|
||||
parameters: { required: [], ...(t.def.function.parameters ?? {}) }
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Why an attachment is not reachable, or undefined when it is. The file tools operate
|
||||
* on `readyFiles()`, so every other status is attached-but-unreadable and has to say so
|
||||
* — a row that looks like the readable ones is the one place this could claim something
|
||||
* the assistant cannot actually open. */
|
||||
export function attachmentStatusLabel(status: AttachedFileStatus): string | undefined {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return undefined
|
||||
case 'locked':
|
||||
return 'needs access'
|
||||
case 'unavailable':
|
||||
return 'unavailable'
|
||||
case 'indexing':
|
||||
return 'indexing…'
|
||||
case 'error':
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/** How many attachments the assistant can actually read, mirroring `readyFiles()`.
|
||||
*
|
||||
* A folder is counted on its children, never on its own status: that status is an
|
||||
* aggregate, so one indexing child would hide the readable rest, while an empty or
|
||||
* all-binary folder keeps a `ready` placeholder that `readyFiles()` filters out and
|
||||
* reads as usable while exposing nothing. */
|
||||
export function countReadyAttachments(
|
||||
folders: readonly { files: readonly { status: AttachedFileStatus }[] }[],
|
||||
files: readonly { status: AttachedFileStatus }[]
|
||||
): number {
|
||||
const isReady = (f: { status: AttachedFileStatus }) => f.status === 'ready'
|
||||
return folders.filter((d) => d.files.some(isReady)).length + files.filter(isReady).length
|
||||
}
|
||||
@@ -165,7 +165,7 @@
|
||||
|
||||
<Modal bind:open={expanded} title="Diagram" kind="X" fillHeight class="sm:max-w-none w-[92vw]">
|
||||
{#snippet settings()}
|
||||
<div class="flex flex-row gap-1 mr-8">
|
||||
<div class="flex flex-row gap-1">
|
||||
<Button
|
||||
onclick={() => zoomBy(1 / 1.3)}
|
||||
color="light"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BookOpen, List, Plus } from 'lucide-svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { AIChatManager } from '../AIChatManager.svelte'
|
||||
import { isSkillEnabled, setSkillEnabled } from './enabledSkills'
|
||||
import { ambiguousSkillNames, listSkillResources, type SkillResource } from './skillResources'
|
||||
|
||||
type Row = SkillResource & { enabled: boolean }
|
||||
|
||||
// A menu is a shortcut, not a directory: past this many the list stops being
|
||||
// scannable, so the rest are reached through the settings modal rather than dropped.
|
||||
const MAX_MENU_SKILLS = 8
|
||||
|
||||
/**
|
||||
* The chat "+" menu's Skills submenu: one row per skill, checked when it is on,
|
||||
* then the way to manage them. Everything a skill is beyond turning it on and off
|
||||
* lives in the assistant settings modal, which `onManage` opens.
|
||||
*/
|
||||
export class SkillsMenu {
|
||||
#manager: AIChatManager
|
||||
#onManage: () => void
|
||||
#seq = 0
|
||||
/** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */
|
||||
#rows = $state<Row[]>([])
|
||||
#rowsWorkspace: string | undefined = undefined
|
||||
|
||||
constructor(manager: AIChatManager, onManage: () => void) {
|
||||
this.#manager = manager
|
||||
this.#onManage = onManage
|
||||
}
|
||||
|
||||
// A session chat operates on its own (possibly forked) workspace without
|
||||
// switching `workspaceStore`, and that is the workspace the chat reads the
|
||||
// enabled set under. Key everything here the same way or a toggle lands under
|
||||
// a key nothing reads. Read per call rather than derived: the menu is built
|
||||
// on open, so there is no stale snapshot to keep current between opens.
|
||||
get #ws(): string | undefined {
|
||||
return this.#manager.operatingWorkspace ?? get(workspaceStore) ?? undefined
|
||||
}
|
||||
|
||||
async #load(ws: string) {
|
||||
const seq = ++this.#seq
|
||||
try {
|
||||
// The truncation flag is for the settings modal, which is where a partial
|
||||
// read is explained; the menu shows what it got.
|
||||
const { skills: found } = await listSkillResources(ws, get(userStore) ?? undefined)
|
||||
if (seq !== this.#seq) return
|
||||
this.#rows = found.map((s) => ({ ...s, enabled: isSkillEnabled(ws, s.path) }))
|
||||
this.#rowsWorkspace = ws
|
||||
} catch {
|
||||
// The menu's other entries still work; a skills submenu that failed to load
|
||||
// is better empty than blocking the whole "+" menu behind an error.
|
||||
if (seq !== this.#seq) return
|
||||
this.#rows = []
|
||||
this.#rowsWorkspace = ws
|
||||
}
|
||||
}
|
||||
|
||||
#row(path: string) {
|
||||
return this.#rows.find((s) => s.path === path)
|
||||
}
|
||||
|
||||
async #toggle(ws: string, path: string, enabled: boolean) {
|
||||
// A session whose fork is still staged has no workspace of its own yet, so `ws`
|
||||
// is the PARENT: the selection would be stored under it and quietly stop
|
||||
// applying the moment the first send commits the fork.
|
||||
const pendingForkOf = this.#manager.sessionContextResolver?.()?.pendingForkOf
|
||||
if (pendingForkOf !== undefined) {
|
||||
sendUserToast(
|
||||
`This session has not created its workspace yet, so the selection would be stored under "${pendingForkOf}". Send a message first.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!setSkillEnabled(ws, path, enabled)) {
|
||||
sendUserToast('Could not save the selection for this account.', true)
|
||||
return
|
||||
}
|
||||
const row = this.#row(path)
|
||||
if (row) row.enabled = enabled
|
||||
// Whether people select skills at all. Never the skill itself: a path is
|
||||
// workspace-authored text.
|
||||
logFeatureUsage('ai_session', 'skill_toggle', { key: enabled ? 'on' : 'off', workspace: ws })
|
||||
// The prompt lists exactly the enabled skills, so it has to be rebuilt
|
||||
// before the next message rather than on the next mode change.
|
||||
await this.#manager.refreshGlobalSkills(ws)
|
||||
}
|
||||
|
||||
/** Loaded on open so the checks are current. */
|
||||
async items(closeMenu?: () => void): Promise<Item[]> {
|
||||
const ws = this.#ws
|
||||
if (!ws) return []
|
||||
// The menu opens on what is already known and refreshes behind it: awaited
|
||||
// inline it would stall the whole "+" menu, attachments included. Rows for
|
||||
// another workspace are not "already known" — same path, different skill.
|
||||
if (this.#rowsWorkspace !== ws) {
|
||||
this.#rows = []
|
||||
await this.#load(ws)
|
||||
} else {
|
||||
void this.#load(ws)
|
||||
}
|
||||
const ambiguous = ambiguousSkillNames(this.#rows)
|
||||
// Enabled first: those are the ones a quick visit is most likely about.
|
||||
const ordered = [...this.#rows].sort(
|
||||
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
|
||||
)
|
||||
const shown = ordered.slice(0, MAX_MENU_SKILLS)
|
||||
const manage = () => {
|
||||
closeMenu?.()
|
||||
this.#onManage()
|
||||
}
|
||||
// Bound out here because the getters below sit on plain object literals,
|
||||
// where `this` is the item rather than this menu.
|
||||
const row = (path: string) => this.#row(path)
|
||||
return [
|
||||
...shown.map(({ path, name }) => ({
|
||||
// Ambiguous names are shown by path — two rows reading `deploy` would
|
||||
// leave the choice between them to chance.
|
||||
displayName: ambiguous.has(name) ? path : name,
|
||||
icon: BookOpen,
|
||||
// Getters, not snapshots: the menu stays open across a click, and it has
|
||||
// to read through the live list rather than the row captured here, since
|
||||
// a reload replaces every row object and a getter bound to the old one
|
||||
// would go on reporting the state it was built with.
|
||||
get toggle() {
|
||||
return row(path)?.enabled ?? false
|
||||
},
|
||||
action: () => this.#toggle(ws, path, !row(path)?.enabled)
|
||||
})),
|
||||
...(ordered.length > shown.length
|
||||
? [{ displayName: `Show all ${ordered.length}`, icon: List, action: manage }]
|
||||
: []),
|
||||
{
|
||||
displayName: this.#rows.length > 0 ? 'Manage skills' : 'Add a skill',
|
||||
icon: Plus,
|
||||
separatorTop: this.#rows.length > 0,
|
||||
action: manage
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
|
||||
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
|
||||
import { agentEditorTarget, type AgentEditorTarget } from './agentEditorStore.svelte'
|
||||
import AgentEditorModal from './content/AgentEditorModal.svelte'
|
||||
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
||||
@@ -14,7 +16,7 @@
|
||||
import { useFlowPanelMode } from './flowPanelMode.svelte'
|
||||
import { useFlowPanelPlacementTelemetry } from './flowEditorTelemetry'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import { get, writable } from 'svelte/store'
|
||||
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
|
||||
import type { PickableProperties } from '$lib/components/flows/previousResults'
|
||||
import type { Flow, Job } from '$lib/gen'
|
||||
@@ -37,8 +39,16 @@
|
||||
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
const { flowStore, selectionManager, pathStore, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
|
||||
// editors can hold the same path at once. Both halves are needed to tell them apart.
|
||||
let editorWorkspace = $derived(opWorkspace?.() ?? $workspaceStore)
|
||||
function targetWorkspace(t: AgentEditorTarget): string | undefined {
|
||||
return t.workspace ?? $workspaceStore
|
||||
}
|
||||
|
||||
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
|
||||
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
|
||||
|
||||
@@ -231,7 +241,19 @@
|
||||
setContext<PropPickerContext>('PropPickerContext', {
|
||||
flowPropPickerConfig,
|
||||
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined),
|
||||
inModalPanel: () => panelMode === 'modal'
|
||||
// The agent editor is a dialog over the same graph, so a connect started inside it has the
|
||||
// same closure hazard as one started from the modal panel. Only this flow's own, on the same
|
||||
// rule the mount below claims one by: a session keeps every visited tab alive, and a target
|
||||
// belonging to another of them is not a dialog over this graph.
|
||||
inModalPanel: () => {
|
||||
if (panelMode === 'modal') return true
|
||||
const t = agentEditorTarget()
|
||||
return (
|
||||
t !== undefined &&
|
||||
t.host?.flowPath === get(pathStore) &&
|
||||
targetWorkspace(t) === editorWorkspace
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Read by graph step items (VirtualItem) to show a per-step "explore" hint on hover,
|
||||
@@ -524,3 +546,12 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Disposable>
|
||||
|
||||
<!-- Mounted here rather than in the panel, which is keyed on the selection and would take the
|
||||
dialog down with it the moment the graph selection moved. Claims only agents opened from this
|
||||
flow: a session keeps every visited tab alive, and each would otherwise build its own editor
|
||||
over the same draft. -->
|
||||
<AgentEditorModal
|
||||
enableAi={!disableAi}
|
||||
owns={(t) => t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
|
||||
import { logReusableAgentUsage } from './agentTelemetry'
|
||||
import {
|
||||
AGENT_BRAIN_LABELS,
|
||||
agentEditorRefusal,
|
||||
transformValuedBrainKeys,
|
||||
type AIAgentConfig
|
||||
} from './agentResourceUtils'
|
||||
// Lives here rather than beside the other config helpers: `agentResourceUtils` is a leaf, and
|
||||
// importing tool-name validation into it would cycle back through `flowInfers` and pull the whole
|
||||
// editor into every module that reads a brain key.
|
||||
import { getToolNameError } from './agentToolUtils'
|
||||
|
||||
/**
|
||||
* Why a run of this config would fail before it started, if it would. Only the conditions the
|
||||
* worker itself rejects: a provider it cannot call, or a tool name it cannot put in the schema it
|
||||
* gives the model. Everything else an agent may legitimately leave unset.
|
||||
*/
|
||||
function agentConfigRunError(args: Record<string, any> | undefined): string | undefined {
|
||||
const provider = args?.provider
|
||||
if (!provider?.resource || !provider?.model) {
|
||||
return 'Select a provider resource and model before deploying.'
|
||||
}
|
||||
// `kind` is what the worker deserializes the provider as, and a config written through the JSON
|
||||
// editor can leave it out entirely. Presence only: the list of kinds lives behind the provider
|
||||
// SDKs, and an unrecognised one already fails the same way at the worker.
|
||||
if (typeof provider.kind !== 'string' || provider.kind === '') {
|
||||
return 'The provider is missing its kind. Pick the resource again to set it.'
|
||||
}
|
||||
// Reported rather than iterated: the editor renders a JSON-authored non-list as an empty
|
||||
// roster, so this is the only place left that would meet the raw value, and a bare `.filter`
|
||||
// on it throws past the caller. `null` is how the API spells "unset".
|
||||
if (args?.tools != null && !Array.isArray(args.tools)) {
|
||||
return 'Tools must be a list. Fix it in the resource editor before deploying.'
|
||||
}
|
||||
// Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or
|
||||
// websearch summary, so a blank one there is not an error and must not count as a sibling.
|
||||
// Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces.
|
||||
const tools = (args?.tools ?? []) as Record<string, any>[]
|
||||
const named = tools.filter(
|
||||
(t) => t?.value?.tool_type !== 'mcp' && t?.value?.tool_type !== 'websearch'
|
||||
)
|
||||
const siblingNames = named.map((t) => t?.summary ?? '')
|
||||
for (const tool of named) {
|
||||
const err = getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames)
|
||||
if (err) return `${err}. Name every tool before deploying.`
|
||||
}
|
||||
// An MCP tool is named by its server rather than by a summary, and it starts with none. The
|
||||
// worker loads every MCP config before the model runs, so one unresolved path fails every run
|
||||
// of the agent rather than just the call that needed it.
|
||||
if (tools.some((t) => t?.value?.tool_type === 'mcp' && !t?.value?.resource_path)) {
|
||||
return 'Pick a server for every MCP tool before deploying.'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft shape every resource editor writes, and the only one the review/deploy page knows how
|
||||
* to canonicalize and deploy (`utils_draft_deploy.ts`, `canonicalizeDraftDiffValue` /
|
||||
* `deployDraft`). The agent editor is a different *form* over this same row, never a parallel
|
||||
* draft: writing anything else here would take agent drafts off the deploy page and out of the
|
||||
* generic editor.
|
||||
*
|
||||
* `args` is the resource value. `description`, `labels` and `wsSpecific` are round-tripped
|
||||
* untouched — the agent form has no place for them, and dropping them would rewrite the resource
|
||||
* on deploy.
|
||||
*/
|
||||
export interface AgentResourceState {
|
||||
path: string
|
||||
description: string
|
||||
args: AIAgentConfig
|
||||
/** Present only for a path with no deployed row, whose type the draft is the sole record of.
|
||||
* Elsewhere it is left out to match the generic resource editor, which has no field for it. */
|
||||
resource_type?: string
|
||||
labels?: string[]
|
||||
wsSpecific: boolean
|
||||
}
|
||||
|
||||
export interface AgentDraftOptions {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
path: () => string | undefined
|
||||
workspace: () => string | undefined
|
||||
}
|
||||
|
||||
export interface AgentDraftHandle {
|
||||
/** The form's live state, draft included. Bind the editor to `state.args`. */
|
||||
readonly state: AgentResourceState | undefined
|
||||
/** The deployed baseline, for the banner's diff. */
|
||||
readonly deployed: AgentResourceState | undefined
|
||||
readonly loading: boolean
|
||||
/** No deployed row at this path, so a deploy has to create rather than update. */
|
||||
readonly noDeployed: boolean
|
||||
/** Whether this user may write the resource. False makes the editor a read-only view. */
|
||||
readonly canWrite: boolean
|
||||
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
|
||||
readonly refusal: string | undefined
|
||||
readonly sync: TriggerDraftSync
|
||||
/** Write the current state to the resource and drop the draft. */
|
||||
deploy: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-autosave wiring for an `ai_agent` resource, over the shared drawer-kind draft sync. Holding
|
||||
* a live handle is what makes a write from elsewhere — another tab, the generic resource editor on
|
||||
* the same path — land in the open agent editor.
|
||||
*/
|
||||
export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
let state = $state<AgentResourceState | undefined>(undefined)
|
||||
let deployed = $state<AgentResourceState | undefined>(undefined)
|
||||
let loading = $state(true)
|
||||
let noDeployed = $state(false)
|
||||
let canWriteResource = $state(true)
|
||||
/** Guards the load against a path that changed under a slow response. */
|
||||
let loadedFor = $state<string | undefined>(undefined)
|
||||
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
|
||||
* path stays, so a resource this editor refuses can neither restore its draft into the form nor
|
||||
* have one autosaved from it. Set it through `refuse`, which also clears what was loaded. */
|
||||
let refusal = $state<string | undefined>(undefined)
|
||||
|
||||
const sync = useTriggerDraftSync({
|
||||
itemKind: 'resource',
|
||||
path: () => opts.path() ?? '',
|
||||
workspace: () => opts.workspace(),
|
||||
drawerLoading: () => loading || refusal != null,
|
||||
// `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args`
|
||||
// changes. Returning the object itself only subscribes to the reference, and every edit the
|
||||
// form makes is a nested one.
|
||||
getCfg: () => (state ? ($state.snapshot(state) as Record<string, any>) : undefined),
|
||||
applyCfg: (cfg) => {
|
||||
state = cfg as AgentResourceState
|
||||
},
|
||||
deployed: () => deployed as Record<string, any> | undefined
|
||||
})
|
||||
|
||||
function refuse(reason: string) {
|
||||
loading = false
|
||||
refusal = reason
|
||||
state = undefined
|
||||
deployed = undefined
|
||||
noDeployed = false
|
||||
canWriteResource = false
|
||||
sendUserToast(reason, true)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const path = opts.path()
|
||||
const ws = opts.workspace()
|
||||
untrack(() => {
|
||||
if (!path || !ws) {
|
||||
state = undefined
|
||||
deployed = undefined
|
||||
loadedFor = undefined
|
||||
refusal = undefined
|
||||
return
|
||||
}
|
||||
const key = `${ws}:${path}`
|
||||
if (loadedFor === key) return
|
||||
loadedFor = key
|
||||
loading = true
|
||||
refusal = undefined
|
||||
// The user alongside the resource, as the generic resource editor loads it: a session or
|
||||
// fork editor operates on a workspace that is not the one being navigated, and groups,
|
||||
// folders and the admin flag are all per workspace, so the nav user would answer for the
|
||||
// wrong membership in both directions.
|
||||
Promise.all([
|
||||
ResourceService.getResource({ workspace: ws, path, getDraft: true }),
|
||||
getUserExt(ws).catch(() => undefined)
|
||||
])
|
||||
// The rejection handler is `then`'s second argument rather than a trailing `catch`, so
|
||||
// it answers for the fetch alone: a throw in the body below leaves a loaded editor
|
||||
// standing instead of tearing it down as an unreadable resource.
|
||||
.then(
|
||||
async ([r, user]) => {
|
||||
// A slower response for a path we have left must not overwrite the current one.
|
||||
if (loadedFor !== key) return
|
||||
// A step's `agent` is caller-authored, so it can name a resource of any type, and a
|
||||
// deploy from here would replace that resource's whole value while keeping its type.
|
||||
const refused = agentEditorRefusal(path, r.resource_type)
|
||||
if (refused) {
|
||||
refuse(refused)
|
||||
return
|
||||
}
|
||||
noDeployed = Boolean((r as any).no_deployed)
|
||||
const deployedState: AgentResourceState = {
|
||||
path: r.path,
|
||||
description: r.description ?? '',
|
||||
args: (r.value ?? {}) as AIAgentConfig,
|
||||
// Only where nothing else answers for the type: with a deployed row both the
|
||||
// create below and the review page's `deployDraft` read it from there, and
|
||||
// carrying it would make every draft the generic resource editor writes — which
|
||||
// has no field for it — differ from this baseline on that key alone.
|
||||
...(noDeployed ? { resource_type: r.resource_type } : {}),
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
// Same rule the generic resource editor applies. The backend refuses the write
|
||||
// either way, but without this the editor would invite edits it cannot save and
|
||||
// autosave a draft on every keystroke against a resource the reader cannot deploy.
|
||||
canWriteResource = canWrite(
|
||||
r.path,
|
||||
r.extra_perms ?? {},
|
||||
user ?? get(userStore) ?? undefined
|
||||
)
|
||||
// An agent that exists only as a draft has no deployed value to compare against or
|
||||
// fall back to, and the response's is a synthetic echo of the draft. Leaving the
|
||||
// baseline unset is what suppresses the unsaved-changes banner for it, exactly as
|
||||
// `useTriggerDraftSync` intends: its Discard would otherwise reset to that synthetic
|
||||
// value and delete the one row the agent lives in.
|
||||
deployed = noDeployed ? undefined : deployedState
|
||||
// Open on the draft when there is one, so the editor never flashes the deployed
|
||||
// config before the autosave lands.
|
||||
state =
|
||||
((r as any).draft as AgentResourceState | undefined) ?? structuredClone(deployedState)
|
||||
loading = false
|
||||
await sync.maybeRestore()
|
||||
},
|
||||
(err) => {
|
||||
// A failed load knows neither the resource's type nor its value, so it refuses:
|
||||
// clearing `loading` alone would let the sync restore a persisted draft into a form
|
||||
// that would then deploy over a resource nobody read.
|
||||
if (loadedFor !== key) return
|
||||
refuse(`Could not load agent ${path}: ${err}`)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function deploy(): Promise<boolean> {
|
||||
const ws = opts.workspace()
|
||||
const s = state
|
||||
if (!ws || !s) return false
|
||||
// The editor offers only values, but a transform can arrive from a step that was forked
|
||||
// before this existed, or from the generic resource editor: say so rather than writing it.
|
||||
const transformValued = transformValuedBrainKeys(s.args)
|
||||
if (transformValued.length > 0) {
|
||||
const fields = transformValued.map((key) => AGENT_BRAIN_LABELS[key] ?? key)
|
||||
const many = fields.length > 1
|
||||
sendUserToast(
|
||||
`${fields.join(', ')} ${many ? 'are' : 'is'} set to an expression or an AI-filled value, which a saved agent cannot store. Replace ${many ? 'them' : 'it'} with a plain value before deploying.`,
|
||||
true
|
||||
)
|
||||
return false
|
||||
}
|
||||
// The resource endpoint takes any JSON, so nothing downstream stops an agent that cannot run:
|
||||
// the worker needs a provider to call and rejects a tool whose name it cannot pass to the
|
||||
// model. Deploying one would break every flow linking it, so it is refused here.
|
||||
const blocked = agentConfigRunError(s.args)
|
||||
if (blocked) {
|
||||
sendUserToast(blocked, true)
|
||||
return false
|
||||
}
|
||||
// Renaming is not this editor's to do: moving the resource leaves every step that links to it
|
||||
// naming a path that no longer exists, and reconciling those is a feature of its own. A
|
||||
// renamed path can still reach here, the generic editor writing the same draft row and
|
||||
// offering a path field, so refuse it rather than performing half of a rename.
|
||||
const currentPath = opts.path()
|
||||
if (currentPath && s.path !== currentPath) {
|
||||
sendUserToast(
|
||||
`This draft renames the agent to ${s.path}. Deploy it from the resource editor instead.`,
|
||||
true
|
||||
)
|
||||
return false
|
||||
}
|
||||
// Only a draft naming another type: the load refuses a resource that is not an agent, while a
|
||||
// draft the generic resource editor wrote names no type at all and inherits the loaded one.
|
||||
if (s.resource_type && s.resource_type !== 'ai_agent') {
|
||||
sendUserToast(`This draft is a ${s.resource_type} resource, not an agent.`, true)
|
||||
return false
|
||||
}
|
||||
// The form stays editable while the request is in flight, so everything below works from a
|
||||
// snapshot taken now. Adopting the live state as `deployed` afterwards would count an edit
|
||||
// made during the request as saved, and the banner would clear on a value the server never
|
||||
// received; against the snapshot it stays a draft, which is what it is.
|
||||
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
|
||||
const body = {
|
||||
path: submitted.path,
|
||||
value: submitted.args,
|
||||
description: submitted.description,
|
||||
labels: submitted.labels,
|
||||
ws_specific: submitted.wsSpecific
|
||||
}
|
||||
try {
|
||||
if (noDeployed) {
|
||||
await ResourceService.createResource({
|
||||
workspace: ws,
|
||||
// A create needs a type, and the load proved this path is an agent before opening.
|
||||
requestBody: { ...body, resource_type: submitted.resource_type ?? 'ai_agent' }
|
||||
})
|
||||
} else {
|
||||
// The type this editor proved is as old as the load, and an update carries no type of
|
||||
// its own: were the path deleted and recreated as something else meanwhile, this write
|
||||
// would put an agent config inside that resource. Reading it again narrows the window
|
||||
// to the request rather than to however long the editor stayed open.
|
||||
const current = await ResourceService.getResource({ workspace: ws, path: submitted.path })
|
||||
const refused = agentEditorRefusal(submitted.path, current.resource_type)
|
||||
if (refused) {
|
||||
refuse(refused)
|
||||
return false
|
||||
}
|
||||
await ResourceService.updateResource({
|
||||
workspace: ws,
|
||||
path: submitted.path,
|
||||
requestBody: body
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not save agent: ${err}`, true)
|
||||
return false
|
||||
}
|
||||
// The counter the step card's write-back used to report, from the surface that now owns the
|
||||
// write: a deploy here reaches every flow linking this agent.
|
||||
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
|
||||
deployed = submitted
|
||||
noDeployed = false
|
||||
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
|
||||
// what it is given, and the apply-effect copies that back over the form: against an edit made
|
||||
// while the request was in flight that would erase it, draft and all. Such an edit is a real
|
||||
// unsaved change over the version just deployed, so it keeps its draft and its banner.
|
||||
if (!deepEqual($state.snapshot(state), submitted)) {
|
||||
sendUserToast(`Saved agent ${submitted.path}. Later edits are still unsaved`)
|
||||
loadedFor = `${ws}:${submitted.path}`
|
||||
return true
|
||||
}
|
||||
// `discard`, not `remove`: it resets the handle's cell to what was just saved, so the
|
||||
// apply-effect cannot bounce the form back to the now-stale draft.
|
||||
sync.discard(opts.path()!, submitted)
|
||||
// A rename moves the row, so the next load must not reuse the old key.
|
||||
loadedFor = `${ws}:${submitted.path}`
|
||||
sendUserToast(`Saved agent ${submitted.path}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
get deployed() {
|
||||
return deployed
|
||||
},
|
||||
get loading() {
|
||||
return loading
|
||||
},
|
||||
get noDeployed() {
|
||||
return noDeployed
|
||||
},
|
||||
get canWrite() {
|
||||
return canWriteResource
|
||||
},
|
||||
get refusal() {
|
||||
return refusal
|
||||
},
|
||||
sync,
|
||||
deploy
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// The agent an AI agent step is being edited-in-place against (set by "Edit" on a linked agent),
|
||||
// with what the editor needs to judge the edits. Lives outside AgentResourceBar so the "Editing
|
||||
// <path>" mode survives that component unmounting — another node selected, or the step's own tab
|
||||
// switched — while the session goes on.
|
||||
//
|
||||
// Entries are looked up by the forked step's `tools` array identity, not by any location key:
|
||||
// Edit assigns a fresh array (unique per fork, including nested editors reusing a module id),
|
||||
// in-place edits keep it, and wholesale state replacement (undo, session drafts) yields new
|
||||
// objects that simply never match — a stale entry can't resurface as a phantom Editing banner.
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
export type AgentEdit = {
|
||||
/** The agent the fork is an edit of, which "Save changes" writes back to. */
|
||||
path: string
|
||||
/** The agent as deployed, in the form the editor compares edits in. Kept here rather than in
|
||||
* the editor, which can unmount mid-session and would otherwise lose the baseline that tells
|
||||
* an edit from the deployed value. */
|
||||
deployedConfig?: string
|
||||
}
|
||||
|
||||
type Entry = AgentEdit & { marker: object }
|
||||
|
||||
// Capped: abandoned forks (editor closed without Save/Cancel) leave dead entries behind.
|
||||
const MAX_ENTRIES = 20
|
||||
|
||||
let entries = $state<Entry[]>([])
|
||||
|
||||
export function getAgentEdit(marker: object | undefined): AgentEdit | undefined {
|
||||
const entry = marker ? entries.find((e) => e.marker === marker) : undefined
|
||||
if (!entry) return undefined
|
||||
// Without the marker: a caller that hands the edit back to `setAgentEditingPath` for another
|
||||
// marker must not smuggle the old one along.
|
||||
return { path: entry.path, deployedConfig: entry.deployedConfig }
|
||||
}
|
||||
|
||||
export function getAgentEditingPath(marker: object | undefined): string | undefined {
|
||||
return getAgentEdit(marker)?.path
|
||||
}
|
||||
|
||||
export function setAgentEditingPath(
|
||||
marker: object | undefined,
|
||||
path: string | undefined,
|
||||
details: Omit<AgentEdit, 'path'> = {}
|
||||
) {
|
||||
if (!marker) return
|
||||
const rest = entries.filter((e) => e.marker !== marker)
|
||||
entries = path ? [...rest.slice(-(MAX_ENTRIES - 1)), { marker, path, ...details }] : rest
|
||||
}
|
||||
|
||||
// Carry edit state across a wholesale clone of the flow: the clone replaces every `tools` array
|
||||
// (breaking marker identity) while preserving content verbatim, so re-keying entries to the
|
||||
// cloned arrays by module id is safe there — unlike undo or session restores, which change
|
||||
// content and must keep invalidating.
|
||||
export function reanchorAgentEditsAcross(
|
||||
getModules: () => FlowModule[] | undefined,
|
||||
refresh: () => void
|
||||
) {
|
||||
const anchors: { key: string; edit: AgentEdit; marker: object }[] = []
|
||||
forEachAgentFork(getModules(), (key, tools) => {
|
||||
const edit = getAgentEdit(tools)
|
||||
if (edit) anchors.push({ key, edit, marker: tools })
|
||||
})
|
||||
refresh()
|
||||
if (anchors.length === 0) return
|
||||
const byKey = new Map<string, object>()
|
||||
forEachAgentFork(getModules(), (key, tools) => byKey.set(key, tools))
|
||||
for (const a of anchors) {
|
||||
setAgentEditingPath(a.marker, undefined)
|
||||
const tools = byKey.get(a.key)
|
||||
if (tools) {
|
||||
const { path, ...details } = a.edit
|
||||
setAgentEditingPath(tools, path, details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Visit every standalone (unlinked) agent's tools array, including nested agent tools. Keys are
|
||||
// ancestry-qualified (`hostId/toolId`): resource-imported tool ids are not flow-global, so a
|
||||
// nested agent may share a bare id with a flow module and must not alias it.
|
||||
function forEachAgentFork(
|
||||
modules: FlowModule[] | undefined,
|
||||
visit: (key: string, tools: object) => void,
|
||||
prefix: string = ''
|
||||
) {
|
||||
for (const mod of modules ?? []) {
|
||||
const v = mod.value as FlowModule['value'] | undefined
|
||||
if (!v) continue
|
||||
if (v.type === 'forloopflow' || v.type === 'whileloopflow') {
|
||||
forEachAgentFork(v.modules, visit, prefix)
|
||||
} else if (v.type === 'branchone') {
|
||||
forEachAgentFork(v.default, visit, prefix)
|
||||
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
|
||||
} else if (v.type === 'branchall') {
|
||||
for (const b of v.branches ?? []) forEachAgentFork(b.modules, visit, prefix)
|
||||
} else if (v.type === 'aiagent') {
|
||||
const key = `${prefix}${mod.id}`
|
||||
if (!v.agent && v.tools) {
|
||||
visit(key, v.tools)
|
||||
}
|
||||
forEachAgentFork(v.tools as FlowModule[] | undefined, visit, `${key}/`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import {
|
||||
getAgentEdit,
|
||||
getAgentEditingPath,
|
||||
setAgentEditingPath,
|
||||
reanchorAgentEditsAcross
|
||||
} from './agentEditStore.svelte'
|
||||
|
||||
function tool(id: string): object {
|
||||
return { id, value: { type: 'rawscript', tool_type: 'flowmodule', input_transforms: {} } }
|
||||
}
|
||||
|
||||
function agentFork(id: string, tools: object[]): FlowModule {
|
||||
return { id, value: { type: 'aiagent', tools, input_transforms: {} } } as unknown as FlowModule
|
||||
}
|
||||
|
||||
describe('reanchorAgentEditsAcross', () => {
|
||||
it('re-keys edit entries to the cloned tools arrays by module id', () => {
|
||||
const toolsA: object[] = [tool('t1')]
|
||||
const toolsB: object[] = [tool('t2')]
|
||||
let modules = [agentFork('a', toolsA), agentFork('b', toolsB)]
|
||||
setAgentEditingPath(toolsA, 'f/agents/one')
|
||||
setAgentEditingPath(toolsB, 'f/agents/two')
|
||||
|
||||
reanchorAgentEditsAcross(
|
||||
() => modules,
|
||||
() => {
|
||||
// stand-in for refreshStateStore's $state.snapshot deep clone
|
||||
modules = JSON.parse(JSON.stringify(modules))
|
||||
}
|
||||
)
|
||||
|
||||
expect(getAgentEditingPath(toolsA)).toBeUndefined()
|
||||
expect(getAgentEditingPath((modules[0].value as any).tools)).toBe('f/agents/one')
|
||||
expect(getAgentEditingPath((modules[1].value as any).tools)).toBe('f/agents/two')
|
||||
})
|
||||
|
||||
it('keeps host and nested-agent entries distinct when a nested tool reuses the host id', () => {
|
||||
// Resource tool ids are not flow-global: a nested agent tool may share the host step's id.
|
||||
const nestedTools: object[] = [tool('t2')]
|
||||
const nested = agentFork('a', nestedTools)
|
||||
const hostTools: object[] = [tool('t1'), nested]
|
||||
let modules = [agentFork('a', hostTools)]
|
||||
setAgentEditingPath(hostTools, 'f/agents/parent')
|
||||
setAgentEditingPath(nestedTools, 'f/agents/nested')
|
||||
|
||||
reanchorAgentEditsAcross(
|
||||
() => modules,
|
||||
() => {
|
||||
modules = JSON.parse(JSON.stringify(modules))
|
||||
}
|
||||
)
|
||||
|
||||
const newHost = (modules[0].value as any).tools
|
||||
const newNested = newHost[1].value.tools
|
||||
expect(getAgentEditingPath(newHost)).toBe('f/agents/parent')
|
||||
expect(getAgentEditingPath(newNested)).toBe('f/agents/nested')
|
||||
})
|
||||
|
||||
it('carries the deployed baseline with the path', () => {
|
||||
const tools: object[] = [tool('t1')]
|
||||
let modules = [agentFork('a', tools)]
|
||||
setAgentEditingPath(tools, 'f/agents/one', { deployedConfig: '{"d":1}' })
|
||||
|
||||
reanchorAgentEditsAcross(
|
||||
() => modules,
|
||||
() => {
|
||||
modules = JSON.parse(JSON.stringify(modules))
|
||||
}
|
||||
)
|
||||
|
||||
expect(getAgentEdit((modules[0].value as any).tools)).toEqual({
|
||||
path: 'f/agents/one',
|
||||
deployedConfig: '{"d":1}'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the entry when the module is gone after the refresh', () => {
|
||||
const tools: object[] = [tool('t1')]
|
||||
let modules = [agentFork('a', tools)]
|
||||
setAgentEditingPath(tools, 'f/agents/one')
|
||||
|
||||
reanchorAgentEditsAcross(
|
||||
() => modules,
|
||||
() => {
|
||||
modules = []
|
||||
}
|
||||
)
|
||||
|
||||
expect(getAgentEditingPath(tools)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Which saved agent the agent editor is showing, and which of its tools.
|
||||
*
|
||||
* The editor edits the `ai_agent` resource through its own draft, so this holds a resource path
|
||||
* rather than a flow node: the same agent opens the same editor from a flow step and from the
|
||||
* resources page. Module-level rather than a context value because what opens it — a step's card,
|
||||
* a list row — unmounts the moment the selection moves.
|
||||
*/
|
||||
export interface AgentEditorTarget {
|
||||
path: string
|
||||
/** The workspace the opener operates on; the nav workspace when absent. */
|
||||
workspace?: string
|
||||
toolId?: string
|
||||
/** A level of the editor that is not the form. Mutually exclusive with `toolId`. */
|
||||
view?: 'evals'
|
||||
/** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a
|
||||
* real flow sets it: the agent editor offers no way to open a second editor from inside itself. */
|
||||
host?: { flowPath: string; moduleId: string }
|
||||
}
|
||||
|
||||
let target = $state<AgentEditorTarget | undefined>(undefined)
|
||||
|
||||
export function openAgentEditor(open: Omit<AgentEditorTarget, 'toolId' | 'view'>) {
|
||||
target = { ...open }
|
||||
}
|
||||
|
||||
export function closeAgentEditor() {
|
||||
target = undefined
|
||||
}
|
||||
|
||||
/** Navigate between the agent and one of its tools. Passing undefined returns to the agent. */
|
||||
export function showAgentEditorTool(toolId: string | undefined) {
|
||||
if (!target) return
|
||||
target = { ...target, toolId, view: undefined }
|
||||
}
|
||||
|
||||
/** Navigate between the agent and one of its own levels. Passing undefined returns to the agent. */
|
||||
export function showAgentEditorView(view: AgentEditorTarget['view']) {
|
||||
if (!target) return
|
||||
target = { ...target, view, toolId: undefined }
|
||||
}
|
||||
|
||||
export function agentEditorTarget(): AgentEditorTarget | undefined {
|
||||
return target
|
||||
}
|
||||
|
||||
/** How many times each agent has been written, so a surface that reads the resource can refetch it.
|
||||
* The editor and the step card that opens it are separate components over one resource, neither
|
||||
* owning the other, and a deploy that leaves the path alone changes nothing they otherwise key on. */
|
||||
let agentWrites = $state<Record<string, number>>({})
|
||||
|
||||
function writeKey(workspace: string | undefined, path: string | undefined): string {
|
||||
return `${workspace ?? ''}:${path ?? ''}`
|
||||
}
|
||||
|
||||
export function markAgentWritten(workspace: string | undefined, path: string) {
|
||||
const key = writeKey(workspace, path)
|
||||
agentWrites[key] = (agentWrites[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
export function agentWriteCount(workspace: string | undefined, path: string | undefined): number {
|
||||
return agentWrites[writeKey(workspace, path)] ?? 0
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AI_AGENT_SCHEMA } from './flowInfers'
|
||||
import {
|
||||
AGENT_FIELD_BY_KEY,
|
||||
AGENT_FIELDS,
|
||||
agentFieldIsSet,
|
||||
agentStreamingEnabled,
|
||||
initialVisibleAgentFields
|
||||
} from './agentFormFields'
|
||||
|
||||
const schemaProperties = AI_AGENT_SCHEMA.properties ?? {}
|
||||
|
||||
function set(key: string, transform: unknown): boolean {
|
||||
return agentFieldIsSet(AGENT_FIELD_BY_KEY[key], transform)
|
||||
}
|
||||
|
||||
describe('agentFieldIsSet', () => {
|
||||
it('reads the seeded placeholder as unset', () => {
|
||||
expect(set('temperature', { type: 'static', value: undefined })).toBe(false)
|
||||
expect(set('temperature', undefined)).toBe(false)
|
||||
// The same placeholder after a round trip through the API.
|
||||
expect(set('temperature', { type: 'static', value: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a value the runtime cannot tell from absence as unset', () => {
|
||||
expect(set('output_type', { type: 'static', value: 'text' })).toBe(false)
|
||||
expect(set('max_iterations', { type: 'static', value: 10 })).toBe(false)
|
||||
expect(set('memory', { type: 'static', value: { kind: 'off' } })).toBe(false)
|
||||
expect(set('user_attachments', { type: 'static', value: [] })).toBe(false)
|
||||
})
|
||||
|
||||
it('reads streaming as unset while it is on, which is what an absent key does', () => {
|
||||
expect(schemaProperties.streaming?.default).toBe(true)
|
||||
expect(set('streaming', { type: 'static', value: true })).toBe(false)
|
||||
expect(set('streaming', { type: 'static', value: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('reads anything the user authored as set', () => {
|
||||
expect(set('temperature', { type: 'static', value: 0 })).toBe(true)
|
||||
expect(set('output_type', { type: 'static', value: 'image' })).toBe(true)
|
||||
expect(set('memory', { type: 'static', value: { kind: 'auto', context_length: 5 } })).toBe(true)
|
||||
expect(set('max_iterations', { type: 'javascript', expr: 'flow_input.loops' })).toBe(true)
|
||||
expect(set('max_iterations', { type: 'javascript', expr: '' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialVisibleAgentFields', () => {
|
||||
it('shows only the core fields for a step that materialized every default', () => {
|
||||
// What the pre-redesign form wrote on first render of a blank agent step.
|
||||
const legacy = {
|
||||
provider: { type: 'static', value: { kind: 'openai', resource: '$res:u/admin/openai' } },
|
||||
output_type: { type: 'static', value: 'text' },
|
||||
user_message: { type: 'static', value: undefined },
|
||||
system_prompt: { type: 'static', value: undefined },
|
||||
streaming: { type: 'static', value: true },
|
||||
memory: { type: 'static', value: { kind: 'off' } },
|
||||
output_schema: { type: 'static', value: null },
|
||||
user_attachments: { type: 'static', value: [] },
|
||||
max_completion_tokens: { type: 'static', value: null },
|
||||
temperature: { type: 'static', value: null },
|
||||
max_iterations: { type: 'static', value: 10 }
|
||||
}
|
||||
expect([...initialVisibleAgentFields(legacy, schemaProperties)].sort()).toEqual([
|
||||
'provider',
|
||||
'system_prompt',
|
||||
'tools',
|
||||
'user_message'
|
||||
])
|
||||
})
|
||||
|
||||
it('collapses a linked step to its flow-local inputs', () => {
|
||||
const linkedSchema = {
|
||||
user_message: schemaProperties.user_message,
|
||||
user_attachments: schemaProperties.user_attachments
|
||||
}
|
||||
expect([...initialVisibleAgentFields({}, linkedSchema)]).toEqual(['user_message'])
|
||||
})
|
||||
|
||||
it('covers every schema key, so no field can only be reached through the raw doc', () => {
|
||||
const registered = new Set(AGENT_FIELDS.map((f) => f.key))
|
||||
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Three chat surfaces decide whether to consume a stream from this, and the worker decides whether
|
||||
// to send one from `streaming.unwrap_or(true)`. They agree only while absent means on here.
|
||||
describe('agentStreamingEnabled', () => {
|
||||
const step = (input_transforms: Record<string, any>, rest: Record<string, any> = {}) => ({
|
||||
type: 'aiagent',
|
||||
input_transforms,
|
||||
...rest
|
||||
})
|
||||
|
||||
it('reads an unwritten field as streaming', () => {
|
||||
expect(agentStreamingEnabled(step({}))).toBe(true)
|
||||
// What the API returns for the `{"type":"static"}` placeholder the schema backfill seeds.
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: null } }))).toBe(true)
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: true } }))).toBe(true)
|
||||
})
|
||||
|
||||
it('only an explicit false holds the answer back', () => {
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: false } }))).toBe(false)
|
||||
})
|
||||
|
||||
it('reads off what the step cannot answer for', () => {
|
||||
// An image answer never streams, whatever `streaming` says.
|
||||
expect(
|
||||
agentStreamingEnabled(
|
||||
step({
|
||||
streaming: { type: 'static', value: true },
|
||||
output_type: { type: 'static', value: 'image' }
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
// A linked step carries no brain: the agent's own `streaming: false` is invisible here.
|
||||
expect(agentStreamingEnabled(step({}, { agent: 'u/admin/a' }))).toBe(false)
|
||||
// An expression has no value until the run it would decide is already under way, on either
|
||||
// of the two fields the answer depends on.
|
||||
expect(
|
||||
agentStreamingEnabled(step({ streaming: { type: 'javascript', expr: 'flow_input.s' } }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
agentStreamingEnabled(step({ output_type: { type: 'javascript', expr: 'flow_input.o' } }))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
|
||||
/**
|
||||
* How the AI agent form presents `AI_AGENT_SCHEMA`: which group a field belongs to, what it is
|
||||
* called, and what leaving it unset actually does at runtime.
|
||||
*
|
||||
* Kept apart from the schema because none of it is JSON Schema: the schema stays the contract with
|
||||
* the backend, this is the contract with the reader. It imports nothing from `flowInfers.ts`, so
|
||||
* the two cannot cycle.
|
||||
*/
|
||||
|
||||
export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output'
|
||||
|
||||
/** Groups in the order a run assembles the request: pick a model, build the messages, let it call
|
||||
* tools, shape what comes back. */
|
||||
export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [
|
||||
{ id: 'model', label: 'Model' },
|
||||
{ id: 'messages', label: 'Messages' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'output', label: 'Output' }
|
||||
]
|
||||
|
||||
/** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key.
|
||||
* It lives in the registry so the groups keep a single ordering. */
|
||||
export const AGENT_TOOLS_ROW = 'tools'
|
||||
|
||||
export interface AgentFieldSpec {
|
||||
key: string
|
||||
group: AgentFieldGroup
|
||||
/** Names the field wherever it appears: its row, the add menu, a linked agent's summary. */
|
||||
label: string
|
||||
tooltip?: string
|
||||
/** Always rendered, and not removable. */
|
||||
core?: boolean
|
||||
/** Rendered by the form itself rather than as an `input_transforms` row. */
|
||||
virtual?: boolean
|
||||
/** The value a run cannot tell apart from an absent key, which is what makes holding it mean
|
||||
* "unset". Read off the backend rather than `schema.default`, which the two have disagreed on
|
||||
* before. Also what the add menu seeds the field with, so a new row opens showing what it
|
||||
* overrides. */
|
||||
implicit?: unknown
|
||||
/** The same value written for a reader, shown under the field's name in the add menu. */
|
||||
defaultHint?: string
|
||||
/** Ignored for image output, so the field hides while `output_type` is `'image'`. */
|
||||
textOnly?: boolean
|
||||
/** Filled in per run rather than configured on the step, so a form that is collecting a run's
|
||||
* inputs shows it whether or not the step wrote anything for it. The step's own form still
|
||||
* treats it as optional: there it is one of the fields the add menu offers. */
|
||||
runInput?: boolean
|
||||
}
|
||||
|
||||
export const AGENT_FIELDS: AgentFieldSpec[] = [
|
||||
{
|
||||
key: 'provider',
|
||||
group: 'model',
|
||||
label: 'Provider',
|
||||
core: true
|
||||
},
|
||||
// Not text-only, unlike the fields around it: image output runs through an ordinary chat model
|
||||
// (OpenAI's `image_generation` tool, OpenRouter's `modalities`) that samples at this
|
||||
// temperature, so hiding it would hide a setting the run still uses.
|
||||
{
|
||||
key: 'temperature',
|
||||
group: 'model',
|
||||
label: 'Temperature',
|
||||
tooltip: 'How random the generation is, from 0 for deterministic up to 2.',
|
||||
defaultHint: 'Default: the provider decides'
|
||||
},
|
||||
{
|
||||
key: 'max_completion_tokens',
|
||||
group: 'model',
|
||||
label: 'Max output tokens',
|
||||
tooltip: 'The most tokens the model may produce in its answer.',
|
||||
defaultHint: 'Default: the provider decides'
|
||||
},
|
||||
{
|
||||
key: 'system_prompt',
|
||||
group: 'messages',
|
||||
label: 'System message',
|
||||
tooltip: 'Sets how the agent should behave. Sent ahead of everything else.',
|
||||
core: true
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
group: 'messages',
|
||||
label: 'Memory',
|
||||
tooltip:
|
||||
'History sent between the system message and the user message. Windmill can keep it for you, or you can supply the messages yourself.',
|
||||
implicit: { kind: 'off' },
|
||||
defaultHint: 'Default: off',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: 'user_message',
|
||||
group: 'messages',
|
||||
label: 'User message',
|
||||
tooltip:
|
||||
"The user turn, sent after the system message and any history. Turn on chat input on the flow's input interface to feed it from the chat.",
|
||||
core: true
|
||||
},
|
||||
{
|
||||
key: 'user_attachments',
|
||||
group: 'messages',
|
||||
label: 'Attachments',
|
||||
tooltip: 'Images or PDFs sent along with the user message. Needs S3 storage on the workspace.',
|
||||
implicit: [],
|
||||
defaultHint: 'Default: none',
|
||||
runInput: true
|
||||
},
|
||||
{
|
||||
key: AGENT_TOOLS_ROW,
|
||||
group: 'tools',
|
||||
label: 'Tools',
|
||||
core: true,
|
||||
virtual: true
|
||||
},
|
||||
{
|
||||
key: 'max_iterations',
|
||||
group: 'tools',
|
||||
label: 'Max iterations',
|
||||
tooltip:
|
||||
'How many times the agent may go round the loop of calling the model and running the tools it asks for. One iteration can run several tools. If it is still calling tools at the last one, the step fails and returns the messages so far. Between 1 and 1000.',
|
||||
implicit: 10,
|
||||
defaultHint: 'Default: 10'
|
||||
},
|
||||
{
|
||||
key: 'output_type',
|
||||
group: 'output',
|
||||
label: 'Output type',
|
||||
tooltip:
|
||||
'Image output needs S3 storage on the workspace, ignores tools, and works with OpenAI, Google AI and the OpenRouter gemini-image-preview model.',
|
||||
implicit: 'text',
|
||||
defaultHint: 'Default: text'
|
||||
},
|
||||
{
|
||||
key: 'output_schema',
|
||||
group: 'output',
|
||||
label: 'Output schema',
|
||||
tooltip: 'A JSON schema the answer has to follow.',
|
||||
defaultHint: 'Default: none',
|
||||
textOnly: true
|
||||
},
|
||||
{
|
||||
key: 'streaming',
|
||||
group: 'output',
|
||||
label: 'Stream the response',
|
||||
tooltip: 'Send the answer back as it is generated, rather than once it is complete.',
|
||||
implicit: true,
|
||||
defaultHint: 'Default: on',
|
||||
textOnly: true
|
||||
}
|
||||
]
|
||||
|
||||
export const AGENT_FIELD_BY_KEY: Record<string, AgentFieldSpec> = Object.fromEntries(
|
||||
AGENT_FIELDS.map((f) => [f.key, f])
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether a transform holds something a run would do differently from an absent key. Core fields
|
||||
* are always set: they are what an agent is.
|
||||
*/
|
||||
export function agentFieldIsSet(
|
||||
spec: AgentFieldSpec,
|
||||
transform: InputTransform | any | undefined
|
||||
): boolean {
|
||||
if (spec.core) return true
|
||||
if (!transform || typeof transform !== 'object') return false
|
||||
if (transform.type === 'javascript') return Boolean(transform.expr)
|
||||
// An AI-filled field is the agent tool case: the value arrives at runtime, so it is set.
|
||||
if (transform.type === 'ai') return true
|
||||
const value = transform.value
|
||||
// `null` as well as `undefined`: a placeholder transform serializes as `{"type":"static"}` and
|
||||
// comes back from the API with an explicit `"value": null`, so the two shapes are the same field.
|
||||
if (value === undefined || value === null) return false
|
||||
if (spec.implicit !== undefined && deepEqual(value, spec.implicit)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of this step would stream its answer, mirroring the worker's
|
||||
* `has_stream = user_wants_streaming && is_text_output`. Absence means on
|
||||
* (`args.streaming.unwrap_or(true)`), so an unwritten field streams.
|
||||
*
|
||||
* A caller that reads this wrong does not merely mislabel the run: a chat surface that opens a
|
||||
* stream for an answer the worker sends in one piece re-runs the flow when its connection times
|
||||
* out. So the rule is that anything this cannot settle from the step alone reads as off, the cost
|
||||
* of being wrong that way being a live answer arriving at the end instead of as it is written.
|
||||
* Unsettled means either of the two fields holding an expression, whose value exists only once the
|
||||
* run it decides is already under way, or a linked agent, whose brain lives in the resource where
|
||||
* this has no sight of it at all.
|
||||
*/
|
||||
export function agentStreamingEnabled(value: Record<string, any> | undefined): boolean {
|
||||
if (value?.agent) return false
|
||||
const transforms = value?.input_transforms as Record<string, InputTransform | any> | undefined
|
||||
const settled = (t: InputTransform | any | undefined) => t == undefined || t.type === 'static'
|
||||
const outputType = transforms?.output_type
|
||||
const streaming = transforms?.streaming
|
||||
if (!settled(outputType) || !settled(streaming)) return false
|
||||
// An image answer never streams, whatever `streaming` says.
|
||||
if (outputType?.value === 'image') return false
|
||||
return streaming?.value !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current schema carries this field at all. A linked step's schema is reduced to the
|
||||
* flow-local inputs, which is what collapses its form to the Messages group on its own.
|
||||
*/
|
||||
export function agentFieldAppliesTo(
|
||||
spec: AgentFieldSpec,
|
||||
schemaProperties: Record<string, any> | undefined
|
||||
): boolean {
|
||||
// A virtual row has no schema key to look for, so it keys off the brain being editable here.
|
||||
if (spec.virtual) return Boolean(schemaProperties && 'provider' in schemaProperties)
|
||||
return Boolean(schemaProperties && spec.key in schemaProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields to render when a step is first opened. Visibility is sticky from here on: the form
|
||||
* only ever adds to this set, so emptying a textbox never makes its row vanish under the cursor.
|
||||
*/
|
||||
export function initialVisibleAgentFields(
|
||||
args: Record<string, InputTransform | any> | undefined,
|
||||
schemaProperties: Record<string, any> | undefined
|
||||
): Set<string> {
|
||||
const visible = new Set<string>()
|
||||
for (const spec of AGENT_FIELDS) {
|
||||
if (!agentFieldAppliesTo(spec, schemaProperties)) continue
|
||||
if (agentFieldIsSet(spec, args?.[spec.key])) visible.add(spec.key)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
@@ -1,15 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
agentConfigAsEdited,
|
||||
agentConfigToInputTransforms,
|
||||
agentEditorRefusal,
|
||||
flowLocalInputs,
|
||||
inputTransformsToAgentConfig,
|
||||
nonStaticBrainKeys,
|
||||
summarizeAgentBrain,
|
||||
toolInputOverrides
|
||||
toolInputOverrides,
|
||||
transformValuedBrainKeys
|
||||
} from './agentResourceUtils'
|
||||
|
||||
describe('transformValuedBrainKeys', () => {
|
||||
// The tag is the whole test: `{type:'ai'}` carries no payload key to look for.
|
||||
it('flags every transform variant, whatever it carries', () => {
|
||||
expect(
|
||||
transformValuedBrainKeys({
|
||||
system_prompt: { type: 'ai' },
|
||||
temperature: { type: 'javascript', expr: 'flow_input.t' },
|
||||
max_iterations: { type: 'static', value: 3 }
|
||||
})
|
||||
).toEqual(['system_prompt', 'temperature', 'max_iterations'])
|
||||
})
|
||||
|
||||
it('leaves the brain values that are legitimately objects', () => {
|
||||
expect(
|
||||
transformValuedBrainKeys({
|
||||
output_schema: { type: 'object', properties: {} },
|
||||
memory: { kind: 'auto', context_length: 20 },
|
||||
provider: { kind: 'openai', model: 'gpt-4o' },
|
||||
system_prompt: 'hi'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEditorRefusal', () => {
|
||||
it('opens an agent', () => {
|
||||
expect(agentEditorRefusal('u/admin/agent', 'ai_agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
// A path with no deployed row answers with its own draft, and the generic resource editor writes
|
||||
// no type: assuming `ai_agent` there would create that path as an agent on deploy.
|
||||
it('refuses an unknown type rather than assuming one', () => {
|
||||
expect(agentEditorRefusal('u/admin/db', undefined)).toContain('no deployed agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeAgentBrain', () => {
|
||||
it('returns only set fields, in brain-key order, formatted', () => {
|
||||
const rows = summarizeAgentBrain({
|
||||
@@ -20,9 +57,9 @@ describe('summarizeAgentBrain', () => {
|
||||
max_iterations: 10
|
||||
})
|
||||
expect(rows).toEqual([
|
||||
{ label: 'Model', value: 'openai · gpt-4o' },
|
||||
{ label: 'System prompt', value: 'You are helpful' },
|
||||
{ label: 'Streaming', value: 'on' },
|
||||
{ label: 'Provider', value: 'openai · gpt-4o' },
|
||||
{ label: 'System message', value: 'You are helpful' },
|
||||
{ label: 'Stream the response', value: 'on' },
|
||||
{ label: 'Temperature', value: '0.7' },
|
||||
{ label: 'Max iterations', value: '10' }
|
||||
])
|
||||
@@ -70,24 +107,6 @@ describe('inputTransformsToAgentConfig', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentConfigAsEdited', () => {
|
||||
it('keeps an expression as itself, where the saved config would drop it', () => {
|
||||
const transforms = {
|
||||
system_prompt: { type: 'static', value: 'hi' },
|
||||
temperature: { type: 'javascript', expr: 'flow_input.t' },
|
||||
max_iterations: { type: 'static', value: undefined },
|
||||
user_message: { type: 'static', value: 'hello' }
|
||||
} as any
|
||||
expect(agentConfigAsEdited(transforms, [])).toEqual({
|
||||
tools: [],
|
||||
system_prompt: 'hi',
|
||||
temperature: { type: 'javascript', expr: 'flow_input.t' }
|
||||
})
|
||||
// The saved shape is blind to the expression: that is the difference this exists for.
|
||||
expect(inputTransformsToAgentConfig(transforms, [])).toEqual({ tools: [], system_prompt: 'hi' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentConfigToInputTransforms', () => {
|
||||
it('wraps brain values as static transforms and round-trips through the config', () => {
|
||||
const config = { provider: { kind: 'openai' }, system_prompt: 'hi', tools: [{ id: 't1' }] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
import { AGENT_FIELDS } from './agentFormFields'
|
||||
|
||||
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs
|
||||
// (user_message/user_attachments) are intentionally excluded — they are supplied per-flow.
|
||||
@@ -19,6 +20,21 @@ export const AGENT_FLOW_LOCAL_KEYS = ['user_message', 'user_attachments'] as con
|
||||
|
||||
export type AgentTool = Record<string, any>
|
||||
|
||||
/**
|
||||
* Why this resource cannot be edited as an agent, if it cannot. A path with no deployed row answers
|
||||
* with its own draft, whose type the generic resource editor never writes, so an unknown type is
|
||||
* refused rather than assumed: deploying it would create that path as an agent.
|
||||
*/
|
||||
export function agentEditorRefusal(
|
||||
path: string,
|
||||
resourceType: string | undefined
|
||||
): string | undefined {
|
||||
if (resourceType === 'ai_agent') return undefined
|
||||
return resourceType
|
||||
? `${path} is a ${resourceType} resource, not an agent.`
|
||||
: `${path} has no deployed agent to edit.`
|
||||
}
|
||||
|
||||
/** Brain keys whose step transform is non-static and would be dropped by a save-as-agent snapshot. */
|
||||
export function nonStaticBrainKeys(
|
||||
inputTransforms: Record<string, InputTransform> | undefined
|
||||
@@ -87,35 +103,17 @@ export function inputTransformsToAgentConfig(
|
||||
const config: AIAgentConfig = { tools: tools ?? [] }
|
||||
for (const key of AGENT_BRAIN_KEYS) {
|
||||
const t = inputTransforms?.[key] as any
|
||||
if (t && t.type === 'static' && t.value !== undefined) {
|
||||
// `null` as well as `undefined`: a placeholder transform is `{"type":"static"}`, and it comes
|
||||
// back from the API — and from a schema backfill — with an explicit null. Writing it through
|
||||
// would put `memory: null` in the saved agent and show as a change against a config that
|
||||
// simply omits the key.
|
||||
if (t && t.type === 'static' && t.value !== undefined && t.value !== null) {
|
||||
;(config as any)[key] = t.value
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration as the step holds it, in the shape of the saved config: a static transform as
|
||||
* its value, any other transform as itself. The saved config keeps static values only, so a
|
||||
* comparison in that shape is blind to an expression — which the step runs, and linking strips.
|
||||
*/
|
||||
export function agentConfigAsEdited(
|
||||
inputTransforms: Record<string, InputTransform> | undefined,
|
||||
tools: AgentTool[] | undefined
|
||||
): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = { tools: tools ?? [] }
|
||||
for (const key of AGENT_BRAIN_KEYS) {
|
||||
const t = inputTransforms?.[key] as any
|
||||
if (!t) continue
|
||||
if (t.type === 'static') {
|
||||
if (t.value !== undefined) config[key] = t.value
|
||||
} else {
|
||||
config[key] = t
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the AI agent schema to only the flow-local inputs. Used when a step is linked to a saved
|
||||
* agent: the brain fields come from the resource, so only user_message/user_attachments stay editable.
|
||||
@@ -138,16 +136,26 @@ export function flowLocalAgentSchema(schema: any): any {
|
||||
}
|
||||
}
|
||||
|
||||
const AGENT_BRAIN_LABELS: Record<string, string> = {
|
||||
provider: 'Model',
|
||||
output_type: 'Output type',
|
||||
system_prompt: 'System prompt',
|
||||
streaming: 'Streaming',
|
||||
memory: 'Memory',
|
||||
output_schema: 'Output schema',
|
||||
max_completion_tokens: 'Max tokens',
|
||||
temperature: 'Temperature',
|
||||
max_iterations: 'Max iterations'
|
||||
/** Read off the form's own registry, so a linked agent's summary cannot name a field differently
|
||||
* from the form that edits it. */
|
||||
export const AGENT_BRAIN_LABELS: Record<string, string> = Object.fromEntries(
|
||||
AGENT_FIELDS.map((f) => [f.key, f.label])
|
||||
)
|
||||
|
||||
/**
|
||||
* Brain keys set to an input transform rather than to a value, which a saved agent's plain JSON
|
||||
* cannot hold. Matched on the tag so the whole `InputTransform` union is covered: a payload-key
|
||||
* test would miss `{"type":"ai"}`, which carries none.
|
||||
*/
|
||||
export function transformValuedBrainKeys(args: Record<string, any> | undefined): string[] {
|
||||
return AGENT_BRAIN_KEYS.filter((key) => {
|
||||
const v = args?.[key]
|
||||
return (
|
||||
v !== null &&
|
||||
typeof v === 'object' &&
|
||||
(v.type === 'javascript' || v.type === 'ai' || v.type === 'static')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Flatten a saved agent's brain config into human-readable label/value rows for a read-only
|
||||
|
||||
@@ -21,6 +21,11 @@ export function getToolNameError(
|
||||
if (kind === 'mcp') {
|
||||
return name.length > 0 ? undefined : 'Tool name must not be empty'
|
||||
}
|
||||
// Ahead of the pattern, which an empty name also fails: "must only contain letters" reads as a
|
||||
// complaint about characters that are not there.
|
||||
if (name.length === 0) {
|
||||
return 'Tool name must not be empty'
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||
return 'Tool name must only contain letters, numbers and underscores'
|
||||
}
|
||||
@@ -60,6 +65,9 @@ export type WebsearchTool = AgentTool & {
|
||||
* Type guard to check if a tool is a FlowModule tool
|
||||
*/
|
||||
export function isFlowModuleTool(tool: AgentTool): tool is FlowModuleTool {
|
||||
// `value` must be there, not merely lack a `tool_type`: the tool list is JSON-authored, and an
|
||||
// entry without one is not a flowmodule tool for the callers that go on to read its script.
|
||||
if (tool?.value == undefined) return false
|
||||
return tool.value.tool_type === undefined || tool.value.tool_type === 'flowmodule'
|
||||
}
|
||||
|
||||
@@ -67,14 +75,28 @@ export function isFlowModuleTool(tool: AgentTool): tool is FlowModuleTool {
|
||||
* Type guard to check if a tool is an MCP tool
|
||||
*/
|
||||
export function isMcpTool(tool: AgentTool): tool is McpTool {
|
||||
return tool.value.tool_type === 'mcp'
|
||||
return tool?.value?.tool_type === 'mcp'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a tool is a Websearch tool
|
||||
*/
|
||||
export function isWebsearchTool(tool: AgentTool): tool is WebsearchTool {
|
||||
return tool.value.tool_type === 'websearch'
|
||||
return tool?.value?.tool_type === 'websearch'
|
||||
}
|
||||
|
||||
/** The only input a nested agent used as a tool has the calling agent fill: the rest is its own
|
||||
* configuration, not something to generate. Mirrors the server, which offers such a tool a schema
|
||||
* of `{user_message}` and nothing else (`AI_AGENT_TOOL_SCHEMA` in `ai_executor.rs`); anything else
|
||||
* left AI-filled here is dropped from that schema and never reaches the model. */
|
||||
export const AI_AGENT_TOOL_AI_KEYS = ['user_message']
|
||||
|
||||
/** What a tool is called wherever it is named: its own name, else what it points at. Never its id,
|
||||
* which is internal. Undefined when it has nothing to be called yet — an MCP tool with no server
|
||||
* picked is unnamed rather than misnamed, so each surface words that for itself. */
|
||||
export function toolDisplayName(tool: AgentTool): string | undefined {
|
||||
const value = tool?.value as Record<string, any>
|
||||
return tool?.summary || value?.path || value?.resource_path || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user