mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
Merge branch 'main' into change-f510874e
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 @@
|
||||
d6297e6844dc2aab4745fce328e32ccab508969f
|
||||
1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -110,6 +110,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)
|
||||
@@ -222,7 +223,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
@@ -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
|
||||
@@ -28727,6 +28861,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:
|
||||
@@ -33137,14 +33341,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:
|
||||
@@ -33195,7 +33402,7 @@ components:
|
||||
format: date-time
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
raw_app:
|
||||
type: boolean
|
||||
labels:
|
||||
@@ -35009,6 +35216,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:
|
||||
@@ -35017,6 +35235,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)))
|
||||
}
|
||||
|
||||
@@ -1594,7 +1594,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?.()}
|
||||
|
||||
@@ -1476,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(
|
||||
@@ -395,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 }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import { getContext, setContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import type { FlowInput } from '$lib/components/flows/types'
|
||||
import type { FlowEditorContext, FlowInputEditorState, FlowPanelDetachContext } from '../types'
|
||||
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import { initFlowState, type FlowState } from '../flowState'
|
||||
import { insertAgentTool } from '../flowStateUtils.svelte'
|
||||
import { initHistory } from '$lib/history.svelte'
|
||||
import { StepsInputArgs } from '../stepsInputArgs.svelte'
|
||||
import { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
|
||||
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
|
||||
import ModulePreview from '$lib/components/ModulePreview.svelte'
|
||||
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
|
||||
import AiAgentStepInputs from './AiAgentStepInputs.svelte'
|
||||
import AgentToolWrapper from './AgentToolWrapper.svelte'
|
||||
import { getStepPropPicker } from '../previousResults'
|
||||
import {
|
||||
AGENT_BRAIN_KEYS,
|
||||
flowLocalAgentSchema,
|
||||
inputTransformsToAgentConfig,
|
||||
type AIAgentConfig
|
||||
} from '../agentResourceUtils'
|
||||
import { AGENT_TOOLS_ROW } from '../agentFormFields'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft } from '../agentDraft.svelte'
|
||||
|
||||
interface Props {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
path: string
|
||||
workspace: string | undefined
|
||||
enableAi?: boolean
|
||||
/** The tool drilled into, if any. Owned by the caller so it can drive the breadcrumb. */
|
||||
toolId?: string | undefined
|
||||
onSelectTool?: (toolId: string | undefined) => void
|
||||
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
|
||||
onSaved?: (path: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
path,
|
||||
workspace,
|
||||
enableAi = false,
|
||||
toolId = undefined,
|
||||
onSelectTool = undefined,
|
||||
onSaved = undefined
|
||||
}: Props = $props()
|
||||
|
||||
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
|
||||
* schema per tool — the linked branch deliberately loads none. Reserved in `forbiddenIds`:
|
||||
* the root shares a flow-state map with the tools, so a tool of this id would lose its
|
||||
* schema to the root's. */
|
||||
const AGENT_ID = '__wm_agent_root'
|
||||
|
||||
const draft = useAgentDraft({ path: () => path, workspace: () => workspace })
|
||||
|
||||
/** Read access only. Everything that could write is blocked, down to the draft itself: an
|
||||
* autosave the server rejects would look like a save and lose the edit. Running the agent,
|
||||
* its history and its evals stay open, none of them being a write to the resource. */
|
||||
let readOnly = $derived(!draft.canWrite)
|
||||
|
||||
const flowStore = $state({
|
||||
val: {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
} as OpenFlow
|
||||
}) as StateStore<OpenFlow>
|
||||
const flowStateStore = $state({ val: {} }) as StateStore<FlowState>
|
||||
const previewArgs = $state({ val: {} })
|
||||
const stepsInputArgs = new StepsInputArgs()
|
||||
const modulesTestStates = new ModulesTestStates(() => {})
|
||||
const selectionManager = new SelectionManager()
|
||||
selectionManager.selectId(AGENT_ID)
|
||||
const history = initHistory(flowStore.val)
|
||||
// The agent's own path, naming a flow that exists only here. It must be a real workspace path:
|
||||
// `require_path_read_access_for_preview` rejects a first segment other than `u`/`f`/`hub` for
|
||||
// anyone who is not an admin. It is therefore not unique to this editor — a flow may carry the
|
||||
// same string — so telling the two apart reads `agentEditorHost` below, never the path.
|
||||
const pathStore = writable('')
|
||||
$effect(() => {
|
||||
pathStore.set(path)
|
||||
})
|
||||
|
||||
// Drilling into a tool is navigation in this editor, not a graph selection.
|
||||
selectionManager.setOnSelectIntent?.((id: string) => {
|
||||
if (id === AGENT_ID) {
|
||||
onSelectTool?.(undefined)
|
||||
return true
|
||||
}
|
||||
if (tools.some((t) => t?.id === id)) {
|
||||
onSelectTool?.(id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// The panel-placement control belongs to a step panel, not to this editor: left alone the card
|
||||
// header would draw a placement picker and an X that closes the panel behind us.
|
||||
setContext<FlowPanelDetachContext>('flowPanelDetach', {
|
||||
claim: () => () => {},
|
||||
modalOpen: () => false,
|
||||
close: () => {},
|
||||
enabled: () => false,
|
||||
preference: () => 'auto',
|
||||
setPreference: () => {}
|
||||
})
|
||||
|
||||
// Inherit the host's context where there is one (keeps "Edit the script's code" and the AI
|
||||
// chat's editor handle working), then override everything that must be private or the editor
|
||||
// would write into the host flow. Built once and mutated: Svelte snapshots the parent context
|
||||
// map per component on first access, so a later `setContext` is invisible to descendants.
|
||||
const outer = getContext<FlowEditorContext | undefined>('FlowEditorContext')
|
||||
setContext<FlowEditorContext>('FlowEditorContext', {
|
||||
scriptEditorDrawer: writable(undefined),
|
||||
workspaceScriptSettingsDrawer: writable(undefined),
|
||||
flowEditorDrawer: writable(undefined),
|
||||
initialPathStore: writable(''),
|
||||
fakeInitialPath: '',
|
||||
flowInputsStore: writable<FlowInput>({}),
|
||||
customUi: {},
|
||||
insertButtonOpen: writable(false),
|
||||
executionCount: writable(0),
|
||||
flowInputEditorState: writable<FlowInputEditorState>({
|
||||
selectedTab: undefined,
|
||||
editPanelSize: undefined,
|
||||
payloadData: undefined
|
||||
}),
|
||||
currentEditor: writable(undefined),
|
||||
outputPickerOpenFns: {},
|
||||
preserveOnBehalfOf: writable(false),
|
||||
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
|
||||
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
|
||||
...(outer ?? {}),
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
previewArgs,
|
||||
stepsInputArgs,
|
||||
modulesTestStates,
|
||||
selectionManager,
|
||||
history,
|
||||
pathStore,
|
||||
opWorkspace: () => workspace,
|
||||
agentEditorHost: () => path,
|
||||
saveDraft: () => {}
|
||||
} as FlowEditorContext)
|
||||
|
||||
// PropPickerWrapper destructures this without a guard, so it throws without it.
|
||||
setContext<PropPickerContext>('PropPickerContext', {
|
||||
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
|
||||
pickablePropertiesFiltered: writable(undefined),
|
||||
inModalPanel: () => true
|
||||
})
|
||||
|
||||
// Its own, empty, rather than the flow's: the copilot addresses fields by name alone, and this
|
||||
// editor is a descendant of the flow it was opened from, so a step input it fills would also
|
||||
// land in a field of the agent that happens to share the name.
|
||||
setContext<FlowCopilotContext>('FlowCopilotContext', {
|
||||
shouldUpdatePropertyType: writable({}),
|
||||
stepInputsLoading: writable(false),
|
||||
generatedExprs: writable({}),
|
||||
exprsToSet: writable({})
|
||||
})
|
||||
|
||||
let agentModule = $derived(flowStore.val.value.modules?.[0])
|
||||
let agentValue = $derived(
|
||||
agentModule?.value.type === 'aiagent' ? (agentModule.value as any) : undefined
|
||||
)
|
||||
// The resource's own array, never a copy: the tool drawer writes replacements back through
|
||||
// `tools[toolIndex]`, and a copy would take them instead of the value being edited. Only the
|
||||
// container is shape-checked — `tools` is JSON-authored, and a `.filter` on another shape
|
||||
// throws; entries are read with `?.` wherever they are dereferenced.
|
||||
let tools = $derived(Array.isArray(agentValue?.tools) ? (agentValue.tools as AgentTool[]) : [])
|
||||
let toolIndex = $derived(toolId ? tools.findIndex((t) => t?.id === toolId) : -1)
|
||||
let tool = $derived(toolIndex >= 0 ? tools[toolIndex] : undefined)
|
||||
|
||||
// The caller owns which tool is open, so the drawer follows it rather than holding that state
|
||||
// itself; closing it (Escape, the X, the overlay) reports back through `on:close`.
|
||||
let toolDrawer: Drawer | undefined = $state(undefined)
|
||||
$effect(() => {
|
||||
const open = tool !== undefined
|
||||
untrack(() => (open ? toolDrawer?.openDrawer() : toolDrawer?.closeDrawer()))
|
||||
})
|
||||
|
||||
// The config the synthetic module last carried, so the two directions below can tell an edit
|
||||
// apart from the echo of their own write.
|
||||
let lastArgs = $state<string | undefined>(undefined)
|
||||
|
||||
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
|
||||
// discard); an edit made in the form arrives here as its own echo and is skipped.
|
||||
$effect(() => {
|
||||
const args = draft.state?.args
|
||||
if (draft.loading || !args) return
|
||||
const serialized = JSON.stringify(args)
|
||||
untrack(() => {
|
||||
if (serialized === lastArgs) return
|
||||
lastArgs = serialized
|
||||
const built: FlowModule = {
|
||||
id: AGENT_ID,
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: (args.tools ?? []) as any,
|
||||
input_transforms: agentArgsToTransforms(args) as any
|
||||
} as any
|
||||
}
|
||||
flowStore.val.value.modules = [built]
|
||||
// Caught rather than left to float: a tool whose shape the schema loader cannot read
|
||||
// would otherwise reject into the global unhandled-rejection handler, which reports the
|
||||
// bare message and no stack — saying nothing about which agent or tool caused it. The
|
||||
// form still renders; only the inferred tool schemas are missing.
|
||||
initFlowState(flowStore.val as Flow, flowStateStore, workspace, path).catch((err) =>
|
||||
console.error('agent editor: could not infer tool schemas for', path, err)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/** Every argument the resource carries, as a static transform. `tools` is the roster rather than
|
||||
* a field, so it rides on the module's own key instead. Not only the keys the form renders: a
|
||||
* run reads them all, and an agent holding its own `user_message` answers with it when nothing
|
||||
* overrides it, so a test here has to run the configuration a linked step would. */
|
||||
function agentArgsToTransforms(args: AIAgentConfig): Record<string, InputTransform> {
|
||||
const it: Record<string, InputTransform> = {}
|
||||
for (const [key, value] of Object.entries(args ?? {})) {
|
||||
if (key === 'tools' || value === undefined) continue
|
||||
it[key] = { type: 'static', value } as InputTransform
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
/** Everything the form does not model. `inputTransformsToAgentConfig` rebuilds the value from
|
||||
* `AGENT_BRAIN_KEYS` alone, so a key this editor never renders — one a newer backend added, or
|
||||
* the `user_message` default a resource may carry, which the runtime does read when the step
|
||||
* supplies none — would be dropped into the draft merely by opening the agent. */
|
||||
const MODELLED_AGENT_KEYS = new Set<string>([...AGENT_BRAIN_KEYS, 'tools'])
|
||||
function unmodelledArgs(args: AIAgentConfig | undefined): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(args ?? {}).filter(([key]) => !MODELLED_AGENT_KEYS.has(key))
|
||||
)
|
||||
}
|
||||
|
||||
// module -> draft.args. `inputTransformsToAgentConfig` drops the `{static, undefined}`
|
||||
// placeholders `loadFlowModuleState` backfills, so the round trip is idempotent.
|
||||
$effect(() => {
|
||||
const v = agentValue
|
||||
if (draft.loading || readOnly || !v || !draft.state) return
|
||||
const next = {
|
||||
...unmodelledArgs(draft.state.args),
|
||||
...(inputTransformsToAgentConfig(
|
||||
v.input_transforms as Record<string, InputTransform>,
|
||||
(v.tools ?? []) as AgentTool[]
|
||||
) as AIAgentConfig)
|
||||
} as AIAgentConfig
|
||||
// A config written before this editor spells "unset" its own way: an explicit `null` on a
|
||||
// field, or no `tools` key at all. `inputTransformsToAgentConfig` writes neither, and that
|
||||
// difference alone would read as an edit and autosave a draft for an agent nobody touched.
|
||||
// Only where the two spellings mean the same thing — a value the user actually cleared is
|
||||
// absent from `next` with something else in the draft, and stays cleared.
|
||||
for (const [key, value] of Object.entries(draft.state.args ?? {})) {
|
||||
if (value === null && !(key in next)) {
|
||||
;(next as Record<string, unknown>)[key] = null
|
||||
}
|
||||
}
|
||||
if (next.tools?.length === 0 && draft.state.args?.tools === undefined) {
|
||||
delete next.tools
|
||||
}
|
||||
const serialized = JSON.stringify(next)
|
||||
untrack(() => {
|
||||
if (serialized === lastArgs) return
|
||||
if (draft.state && !deepEqual(draft.state.args, next)) {
|
||||
lastArgs = serialized
|
||||
draft.state.args = next
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let schema = $derived(flowStateStore.val[AGENT_ID]?.schema ?? {})
|
||||
const brainFilter = [...AGENT_BRAIN_KEYS, AGENT_TOOLS_ROW]
|
||||
|
||||
let stepPropPicker = $derived(
|
||||
agentModule
|
||||
? getStepPropPicker(
|
||||
flowStateStore.val,
|
||||
undefined,
|
||||
undefined,
|
||||
AGENT_ID,
|
||||
flowStore.val,
|
||||
previewArgs.val,
|
||||
false
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
|
||||
let testJob: Job | undefined = $state(undefined)
|
||||
let testIsLoading = $state(false)
|
||||
let scriptProgress = $state(undefined)
|
||||
|
||||
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
|
||||
// tool it just created is the only place the click can land.
|
||||
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
|
||||
if (!agentValue) return
|
||||
// Adding a tool is the explicit action that replaces a JSON-authored non-list; without this
|
||||
// the insert goes into a value that cannot hold it.
|
||||
if (!Array.isArray(agentValue.tools)) agentValue.tools = []
|
||||
const id = await insertAgentTool(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
agentValue,
|
||||
detail,
|
||||
workspace,
|
||||
!enableAi
|
||||
)
|
||||
if (id) onSelectTool?.(id)
|
||||
}
|
||||
|
||||
/** The mirror of `addTool`. These tools live in the resource rather than in a flow's module
|
||||
* list, so the graph's delete has nothing here to act on. */
|
||||
function deleteTool(id: string) {
|
||||
if (!agentValue) return
|
||||
const remaining = tools.filter((t) => t?.id !== id)
|
||||
if (remaining.length === tools.length) return
|
||||
agentValue.tools = remaining
|
||||
delete flowStateStore.val[id]
|
||||
if (toolId === id) onSelectTool?.(undefined)
|
||||
}
|
||||
|
||||
export function deploy(): Promise<boolean> {
|
||||
return draft.deploy().then(async (ok) => {
|
||||
// The path this editor opened, not the draft's live one: `deploy` refuses a renaming draft,
|
||||
// so the write always lands here, while the shared draft can be repointed by another tab
|
||||
// mid-request and would send the reconciliation after a resource nobody wrote.
|
||||
if (ok) await onSaved?.(path)
|
||||
return ok
|
||||
})
|
||||
}
|
||||
export function draftHandle() {
|
||||
return draft
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if draft.refusal}
|
||||
<div class="h-full flex items-center justify-center px-8">
|
||||
<Alert type="error" size="sm" title={draft.refusal} class="max-w-lg">
|
||||
Open it from the resources page to see what it holds. Any unsaved edits are kept as a draft
|
||||
there.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if draft.loading}
|
||||
<div class="h-full flex items-center justify-center text-xs text-tertiary">Loading agent...</div>
|
||||
{:else if agentModule && agentValue}
|
||||
<!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it
|
||||
otherwise targets is behind this dialog, and does not exist at all on the resources page. -->
|
||||
<div id="agent-editor" class="relative h-full min-h-0">
|
||||
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
|
||||
answer want opposite splits, and only the reader knows which they are on. -->
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={66} minSize={30}>
|
||||
<div class="h-full min-h-0 overflow-auto">
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
noPadding
|
||||
sidePane
|
||||
>
|
||||
<AiAgentStepInputs
|
||||
class="px-4 pb-8"
|
||||
{schema}
|
||||
filter={brainFilter}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
|
||||
{enableAi}
|
||||
{workspace}
|
||||
staticOnly
|
||||
visibilityKey={`agent:${path}`}
|
||||
{tools}
|
||||
{readOnly}
|
||||
onSelectTool={(id) => onSelectTool?.(id)}
|
||||
onAddTool={readOnly ? undefined : addTool}
|
||||
onDeleteTool={readOnly ? undefined : deleteTool}
|
||||
toolPickerPortal="#agent-editor"
|
||||
bind:args={
|
||||
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
|
||||
(v) => agentValue && (agentValue.input_transforms = v)
|
||||
}
|
||||
/>
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
</Pane>
|
||||
<!-- Laid out as the script editor's preview column is: what a run takes above what it
|
||||
produced, both alongside what is being edited. -->
|
||||
<Pane size={34} minSize={20}>
|
||||
<Splitpanes horizontal class="h-full">
|
||||
<Pane size={40} minSize={15}>
|
||||
<div class="h-full overflow-auto">
|
||||
<ModulePreview
|
||||
mod={agentModule as FlowModule}
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={60} minSize={20}>
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<!-- A tool is a whole step editor, so it gets a surface of its own rather than a level of the
|
||||
dialog: the agent stays visible behind it, along with the banner and Deploy that its edits
|
||||
feed. -->
|
||||
<Drawer bind:this={toolDrawer} size="1200px" on:close={() => onSelectTool?.(undefined)}>
|
||||
<DrawerContent
|
||||
title={(tool ? toolDisplayName(tool) : undefined) ?? 'Edit tool'}
|
||||
on:close={() => toolDrawer?.closeDrawer()}
|
||||
noPadding
|
||||
>
|
||||
{#if tool}
|
||||
<!-- Inert to a read-only viewer for the same reason the form above is, and opened on its
|
||||
configuration rather than on a test it cannot fill in. -->
|
||||
<div class="h-full min-h-0 flex flex-col {readOnly ? 'opacity-60' : ''}" inert={readOnly}>
|
||||
<AgentToolWrapper
|
||||
bind:tool={() => tools[toolIndex], (v) => (tools[toolIndex] = v)}
|
||||
parentModule={agentModule as FlowModule}
|
||||
{enableAi}
|
||||
staticOnly
|
||||
noToolNavigation
|
||||
forceTestTab={readOnly ? undefined : { [tool.id]: true }}
|
||||
siblingToolNames={tools.filter((t) => t?.id !== tool?.id).map((t) => t?.summary ?? '')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -0,0 +1,368 @@
|
||||
<script lang="ts">
|
||||
import { FlaskConical, History, Save } from 'lucide-svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
|
||||
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
|
||||
import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
|
||||
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
|
||||
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
|
||||
import { ResourceService, type AgentDraft } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
agentEditorTarget,
|
||||
agentWriteCount,
|
||||
type AgentEditorTarget,
|
||||
closeAgentEditor,
|
||||
markAgentWritten,
|
||||
showAgentEditorTool,
|
||||
showAgentEditorView
|
||||
} from '../agentEditorStore.svelte'
|
||||
import { agentConfigToInputTransforms } from '../agentResourceUtils'
|
||||
import { publishLinkedAgentTools } from '../flowState'
|
||||
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import AgentEditorHost from './AgentEditorHost.svelte'
|
||||
|
||||
interface Props {
|
||||
enableAi?: boolean
|
||||
/** Which targets this mount is responsible for. The target is module-global while several
|
||||
* flow editors can be alive at once (a session retains every tab it has visited), so without
|
||||
* this each of them would build a whole editor — fetching, inferring tool schemas, and
|
||||
* running its own two-way sync against the one draft row. Required rather than defaulted: a
|
||||
* mount that guesses wrong renders nothing, and silence is a poor way to find that out. */
|
||||
owns: (target: AgentEditorTarget) => boolean
|
||||
}
|
||||
|
||||
let { enableAi = false, owns }: Props = $props()
|
||||
|
||||
// Every target names the surface that opened it, and only a flow step or a resource row can:
|
||||
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
|
||||
// to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a
|
||||
// target opened from inside another editor.
|
||||
let target = $derived.by(() => {
|
||||
const t = agentEditorTarget()
|
||||
return t && owns(t) ? t : undefined
|
||||
})
|
||||
// The last agent this dialog showed. The version pane keeps working after `onRestore` returns —
|
||||
// it reloads its list keyed on the path it was given — while the restore handler has already
|
||||
// closed the dialog and dropped the target. Reading the live one there throws, and the throw
|
||||
// lands in the global unhandled-rejection handler as a bare toast with no stack.
|
||||
let lastShownAgent = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const p = target?.path
|
||||
if (p) untrack(() => (lastShownAgent = p))
|
||||
})
|
||||
|
||||
let ws = $derived(target?.workspace ?? $workspaceStore)
|
||||
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
|
||||
let versionDrawer: Drawer | undefined = $state(undefined)
|
||||
let saving = $state(false)
|
||||
|
||||
// Counted per agent, so a deploy that leaves the editor on the same agent still refetches the
|
||||
// version it just minted. Shared, so the step card behind the dialog refetches on the same signal.
|
||||
let writes = $derived(agentWriteCount(ws, target?.path))
|
||||
let versionResource = resource(
|
||||
() => ({ ws, path: target?.path, writes }),
|
||||
async ({ ws, path, writes }) => {
|
||||
if (!ws || !path) return { ws, path, writes }
|
||||
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
|
||||
return { ws, path, writes, version: history.versions?.[0]?.version }
|
||||
}
|
||||
)
|
||||
// `writes` as well as the identity: runed neither aborts nor tags a superseded request, so a
|
||||
// response from before a deploy can land after the one for it and relabel the badge with the
|
||||
// version that deploy replaced.
|
||||
let version = $derived.by(() => {
|
||||
const loaded = versionResource.current
|
||||
return loaded !== undefined &&
|
||||
loaded.ws === ws &&
|
||||
loaded.path === target?.path &&
|
||||
loaded.writes === writes
|
||||
? loaded.version
|
||||
: undefined
|
||||
})
|
||||
|
||||
let draft = $derived(host?.draftHandle())
|
||||
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
|
||||
// the dialog carries only the refusal the host renders.
|
||||
let refused = $derived(draft?.refusal != null)
|
||||
let inEvals = $derived(target?.view === 'evals' && !refused)
|
||||
let readOnly = $derived(draft ? !draft.canWrite : false)
|
||||
let draftOnly = $derived(draft?.noDeployed ?? false)
|
||||
|
||||
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
|
||||
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
|
||||
// one, and never that it is back at its root.
|
||||
let evalsLocation = $state<EvalsLocation | undefined>(undefined)
|
||||
let evalsKey = $derived(`${ws}:${target?.path}:${target?.view}`)
|
||||
$effect(() => {
|
||||
evalsKey
|
||||
untrack(() => (evalsLocation = undefined))
|
||||
})
|
||||
|
||||
const AGENT_DESCRIPTION = 'Changes here update the saved agent, and every flow that links to it.'
|
||||
|
||||
let root = $derived<ModalTrailSegment>({
|
||||
label: target?.path ?? 'Agent',
|
||||
onclick: inEvals ? () => showAgentEditorView(undefined) : undefined
|
||||
})
|
||||
let trail = $derived<ModalTrailSegment[]>(
|
||||
inEvals
|
||||
? [
|
||||
root,
|
||||
{ label: 'Evals', onclick: evalsLocation ? evalsLocation.back : undefined },
|
||||
...(evalsLocation ? [{ label: evalsLocation.label }] : [])
|
||||
]
|
||||
: [root]
|
||||
)
|
||||
// The root's alone: below it the header's second line is the way back, and what a level is for
|
||||
// belongs to that level rather than to the dialog's own name.
|
||||
let description = $derived(inEvals || refused ? undefined : AGENT_DESCRIPTION)
|
||||
|
||||
/** The unsaved edits, in the shape the server builds from a deployed config
|
||||
* (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to
|
||||
* the version it is later deployed as. */
|
||||
function editedConfig(): AgentDraft {
|
||||
const args = draft?.state?.args
|
||||
return {
|
||||
input_transforms: agentConfigToInputTransforms(args ?? {}) as Record<string, unknown>,
|
||||
tools: (args?.tools ?? []) as unknown[] as Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A resources-page row anchors itself in the hash on the way in, so closing has to clear it or
|
||||
* the URL keeps claiming the agent is open and a refresh reopens it. Anchored to that page, so
|
||||
* this is a no-op when the editor was opened from a flow. */
|
||||
function close() {
|
||||
closeAgentEditor()
|
||||
void clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
}
|
||||
|
||||
// The target is module-global and outlives this component: navigating away takes the mount that
|
||||
// owns it without passing through `close()`, and what it leaves behind reopens the dialog on the
|
||||
// way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going
|
||||
// down never drops a target another one is showing.
|
||||
onDestroy(() => {
|
||||
const t = agentEditorTarget()
|
||||
if (t && owns(t)) closeAgentEditor()
|
||||
})
|
||||
|
||||
/** What the flow behind a write has to be told about it, captured before the request rather than
|
||||
* read after: the dialog stays closable while one is in flight and can be pointed at another
|
||||
* agent by the time it lands, and the reactive `ws`/`target` would then name that one. */
|
||||
type WriteTarget = { ws: string | undefined; path: string; host: AgentEditorTarget['host'] }
|
||||
function currentWriteTarget(): WriteTarget | undefined {
|
||||
const t = target
|
||||
return t ? { ws, path: t.path, host: t.host } : undefined
|
||||
}
|
||||
/** Reconciling is best-effort: it refreshes what a write changed, and the write itself has
|
||||
* already landed. A rejection here must not surface as an unhandled one — the global handler
|
||||
* turns those into a bare toast with no stack, which says nothing about where it came from. */
|
||||
function reconcileQuietly(at: WriteTarget | undefined, path: string) {
|
||||
try {
|
||||
void Promise.resolve(reconcile(at, path)).catch((err) => {
|
||||
console.error('agent editor: could not refresh what links to', path, err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('agent editor: could not refresh what links to', path, err)
|
||||
}
|
||||
}
|
||||
|
||||
function reconcile(at: WriteTarget | undefined, path: string) {
|
||||
if (!at?.ws) return
|
||||
markAgentWritten(at.ws, path)
|
||||
if (!at.host) return
|
||||
// The host graph resolves a linked agent's tool nodes from the resource, so it has to re-read
|
||||
// what the write just changed. Every step of that flow linking this agent, not only the one
|
||||
// the editor was opened from: they all show tools the write may have moved.
|
||||
const scope = linkedToolsScope(at.ws, at.host.flowPath)
|
||||
const moduleIds = new Set(linkedModulesForAgent(scope, path))
|
||||
moduleIds.add(at.host.moduleId)
|
||||
return Promise.all(
|
||||
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId))
|
||||
)
|
||||
}
|
||||
|
||||
let deployingFor = $state<WriteTarget | undefined>(undefined)
|
||||
|
||||
async function onDeploy() {
|
||||
saving = true
|
||||
deployingFor = currentWriteTarget()
|
||||
try {
|
||||
await host?.deploy()
|
||||
} finally {
|
||||
saving = false
|
||||
deployingFor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** What a successful deploy has to reconcile. The path is the one it wrote, which `deploy` holds
|
||||
* to the one the editor opened: this editor does not rename. */
|
||||
async function onSaved(savedPath: string) {
|
||||
await reconcile(deployingFor ?? currentWriteTarget(), savedPath)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if target}
|
||||
{#key `${ws}:${target.path}`}
|
||||
<!-- Bound rather than held open: the store is what decides whether this is mounted, so every
|
||||
way out has to reach it. The close button and the backdrop only set `open`, and a
|
||||
dialog left closed over a target still set could never be opened again.
|
||||
|
||||
`paginated` throughout: the body is one strip of pages (the agent form, then evals), so
|
||||
the header keeps its height and only the pages move. -->
|
||||
<Modal
|
||||
bind:open={() => true, (open) => !open && close()}
|
||||
kind="X"
|
||||
fillHeight
|
||||
enterConfirms={false}
|
||||
paginated
|
||||
title={target.path}
|
||||
{trail}
|
||||
{description}
|
||||
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
|
||||
>
|
||||
{#snippet titleBadge()}
|
||||
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
|
||||
step panel has it. -->
|
||||
{#if version != undefined && !refused}
|
||||
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
|
||||
v{version}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet levelBadge()}
|
||||
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
|
||||
level deeper, where it would read as marking the run rather than the feature it
|
||||
belongs to. -->
|
||||
{#if inEvals && !evalsLocation}
|
||||
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
|
||||
dialog states for itself whether a run is against the deployed version or the edits. -->
|
||||
{#snippet settings()}
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
{#if !inEvals && !refused}
|
||||
{#if readOnly}
|
||||
<Badge
|
||||
color="gray"
|
||||
class="shrink-0"
|
||||
title="You do not have write access to this agent"
|
||||
>
|
||||
Read only
|
||||
</Badge>
|
||||
{/if}
|
||||
<!-- Evals run against the deployed agent, and a draft-only one has none: the
|
||||
backend's `require_agent` would reject every run. -->
|
||||
{#if !draftOnly}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: FlaskConical }}
|
||||
title="Run this agent against a dataset of cases"
|
||||
on:click={() => showAgentEditorView('evals')}
|
||||
>
|
||||
Evals
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: History }}
|
||||
iconOnly
|
||||
title="Version history"
|
||||
on:click={() => versionDrawer?.openDrawer()}
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Save }}
|
||||
loading={saving}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? 'You do not have write access to this agent' : undefined}
|
||||
on:click={onDeploy}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<!-- Full-bleed, as a banner is everywhere else: the dialog's own horizontal padding is
|
||||
cancelled so it spans the body. -->
|
||||
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
|
||||
<LocalDraftBanner
|
||||
show={draft?.sync.hasDraft ?? false}
|
||||
reserveSpace={draft?.sync.hasBaseline ?? false}
|
||||
getDeployed={() => draft?.deployed}
|
||||
getCurrent={() => draft?.state}
|
||||
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
|
||||
title="Deployed <> Unsaved agent changes"
|
||||
/>
|
||||
</div>
|
||||
<!-- Two pages of one strip, so opening evals slides in from the right the way its own
|
||||
levels do. No `onNavigate`: the arrow keys belong to whichever pane is on screen,
|
||||
and evals answers them for its own levels. -->
|
||||
<PagedContent
|
||||
class="flex-1 min-h-0"
|
||||
current={inEvals ? 'evals' : 'agent'}
|
||||
pages={[
|
||||
{ key: 'agent', content: agentPage },
|
||||
{ key: 'evals', content: evalsPage }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
{/key}
|
||||
|
||||
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
|
||||
and the config a draft run is offered on. -->
|
||||
{#snippet agentPage()}
|
||||
<AgentEditorHost
|
||||
bind:this={host}
|
||||
path={target.path}
|
||||
workspace={ws}
|
||||
{enableAi}
|
||||
toolId={target.toolId}
|
||||
onSelectTool={(id) => showAgentEditorTool(id)}
|
||||
{onSaved}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet evalsPage()}
|
||||
<EvalsPane
|
||||
agentPath={target.path}
|
||||
opWorkspace={ws}
|
||||
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
|
||||
bind:location={evalsLocation}
|
||||
active={inEvals}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Drawer bind:this={versionDrawer} size="1200px">
|
||||
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
|
||||
<ResourceVersionHistory
|
||||
path={target?.path ?? lastShownAgent ?? ''}
|
||||
workspace={ws}
|
||||
canRestore={!readOnly}
|
||||
onRestore={() => {
|
||||
versionDrawer?.closeDrawer()
|
||||
// A restore writes the resource as a deploy does, so the flow behind has to be told
|
||||
// the same way. Captured before closing, which drops the target this reads.
|
||||
const at = currentWriteTarget()
|
||||
// Close the editor too, as the generic resource editor does on a restore: it holds
|
||||
// a baseline captured before the restore, and any local draft on top of it, so
|
||||
// deploying from it afterwards would write the pre-restore value straight back over
|
||||
// the version just restored.
|
||||
close()
|
||||
if (at) reconcileQuietly(at, at.path)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -2,21 +2,15 @@
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ResourceService, type AgentDraft, type InputTransform } from '$lib/gen'
|
||||
import { ResourceService, type InputTransform } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Bot, ChevronDown, ChevronUp, FlaskConical, Save, Unlink, Pencil } from 'lucide-svelte'
|
||||
import AgentEvalModal from '$lib/components/aiEvals/AgentEvalModal.svelte'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { Value } from '$lib/utils'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
|
||||
import {
|
||||
AGENT_BRAIN_KEYS,
|
||||
AGENT_FLOW_LOCAL_KEYS,
|
||||
agentConfigAsEdited,
|
||||
agentConfigToInputTransforms,
|
||||
flowLocalInputs,
|
||||
inputTransformsToAgentConfig,
|
||||
@@ -25,12 +19,12 @@
|
||||
type AIAgentConfig,
|
||||
type AgentTool
|
||||
} from '../agentResourceUtils'
|
||||
import { agentWriteCount, markAgentWritten, openAgentEditor } from '../agentEditorStore.svelte'
|
||||
import {
|
||||
setLinkedAgentTools,
|
||||
clearLinkedAgentTools,
|
||||
linkedToolsScope
|
||||
} from '../linkedAgentToolsStore.svelte'
|
||||
import { getAgentEdit, getAgentEditingPath, setAgentEditingPath } from '../agentEditStore.svelte'
|
||||
import { logReusableAgentUsage } from '../agentTelemetry'
|
||||
import { claimLinkedToolsFetch } from '../flowState'
|
||||
import type { AgentTool as AgentToolStrict } from '../agentToolUtils'
|
||||
@@ -44,7 +38,8 @@
|
||||
toolInputs = $bindable(),
|
||||
moduleId,
|
||||
opWorkspace = undefined,
|
||||
flowPath = ''
|
||||
flowPath = '',
|
||||
fromAgentEditor = false
|
||||
}: {
|
||||
agent: string | undefined
|
||||
inputTransforms: Record<string, InputTransform>
|
||||
@@ -56,28 +51,36 @@
|
||||
opWorkspace?: string
|
||||
// Scope for the linked-agent tools store (the flow path); must match what the graph reads.
|
||||
flowPath?: string
|
||||
// Inside the agent editor, where an agent used as a tool stays part of the agent being
|
||||
// edited: it cannot be saved as a reusable agent of its own, and one already linked cannot be
|
||||
// opened here. Linking a saved agent to another agent is out of scope for this editor — the
|
||||
// backend supports it, but only a flow can author it, and a second editor over a second draft
|
||||
// is the wrong way in.
|
||||
fromAgentEditor?: boolean
|
||||
} = $props()
|
||||
|
||||
let ws = $derived(opWorkspace ?? $workspaceStore)
|
||||
|
||||
// How many times the linked agent has been written, from anywhere: this card's own save, or a
|
||||
// deploy from the agent editor mounted alongside it. Both reads below key on it, so neither
|
||||
// keeps naming the config and version a write has just replaced.
|
||||
let writes = $derived(agentWriteCount(ws, agent))
|
||||
|
||||
let saveDrawer: Drawer | undefined = $state()
|
||||
let newPath = $state('')
|
||||
let pathError = $state('')
|
||||
let description = $state('')
|
||||
let saving = $state(false)
|
||||
// The edit session of a step forked from a saved agent: the path "Save changes" upserts back
|
||||
// to, and the baselines the edits are judged against. Lives in an external store so it
|
||||
// survives this component unmounting — another node selected, the step's tab switched — keyed
|
||||
// by the forked `tools` identity so a stale entry can't resurface (see agentEditStore).
|
||||
let editing = $derived(getAgentEdit(tools))
|
||||
let editingPath = $derived(editing?.path)
|
||||
|
||||
type LinkedInfo = {
|
||||
// What this result was fetched for. runed's resource neither aborts nor tags a superseded
|
||||
// request, so a slow fetch for a previous link can land after a newer one: every consumer
|
||||
// gates on these matching the current (ws, agent).
|
||||
// request, so a slow fetch can land after a newer one: every consumer gates on these matching
|
||||
// the current (ws, agent, writes). `writes` is what covers a refetch of the *same* link after
|
||||
// a deploy — without it a pre-deploy response is indistinguishable from the current one, and
|
||||
// accepting it republishes the tools the deploy just replaced.
|
||||
ws?: string
|
||||
path?: string
|
||||
writes: number
|
||||
config: AIAgentConfig
|
||||
tools: AgentTool[]
|
||||
providerPath?: string
|
||||
@@ -88,10 +91,10 @@
|
||||
// load them here for display, and probe the provider resource so we can warn when it isn't
|
||||
// accessible in this workspace (the user then needs to unlink/fork or gain access).
|
||||
let linkedResource = resource(
|
||||
() => ({ ws, path: agent }),
|
||||
async ({ ws, path }): Promise<LinkedInfo> => {
|
||||
() => ({ ws, path: agent, writes }),
|
||||
async ({ ws, path, writes }): Promise<LinkedInfo> => {
|
||||
if (!ws || !path) {
|
||||
return { ws, path, config: {}, tools: [], providerOk: true }
|
||||
return { ws, path, writes, config: {}, tools: [], providerOk: true }
|
||||
}
|
||||
const res = await ResourceService.getResource({ workspace: ws, path })
|
||||
const cfg = (res.value ?? {}) as AIAgentConfig & { provider?: { resource?: string } }
|
||||
@@ -112,6 +115,7 @@
|
||||
return {
|
||||
ws,
|
||||
path,
|
||||
writes,
|
||||
config: cfg,
|
||||
tools,
|
||||
providerPath,
|
||||
@@ -125,7 +129,7 @@
|
||||
let loadedInfo = $state<LinkedInfo | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const current = linkedResource.current
|
||||
if (current && current.ws === ws && current.path === agent) {
|
||||
if (current && current.ws === ws && current.path === agent && current.writes === writes) {
|
||||
loadedInfo = current
|
||||
}
|
||||
})
|
||||
@@ -137,27 +141,31 @@
|
||||
let providerPath = $derived(linkedInfo?.providerPath)
|
||||
let providerOk = $derived(linkedInfo?.providerOk ?? true)
|
||||
/** The agent the card is about: the one this step links to, or the one being edited. */
|
||||
let cardPath = $derived(agent ?? editingPath)
|
||||
let evalsOpen = $state(false)
|
||||
// Bumped on every write to the resource, so a save that leaves the card on the same agent still
|
||||
// refetches the version it just minted.
|
||||
let writes = $state(0)
|
||||
let cardPath = $derived(agent)
|
||||
// The version eval runs are recorded against. The resource does not hold it; its newest history
|
||||
// entry does, since recording is a database trigger on every write.
|
||||
let versionResource = resource(
|
||||
() => ({ ws, path: cardPath, writes }),
|
||||
async ({ ws, path }): Promise<{ ws?: string; path?: string; version?: number }> => {
|
||||
async ({
|
||||
ws,
|
||||
path,
|
||||
writes
|
||||
}): Promise<{ ws?: string; path?: string; writes: number; version?: number }> => {
|
||||
if (!ws || !path) {
|
||||
return { ws, path }
|
||||
return { ws, path, writes }
|
||||
}
|
||||
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
|
||||
return { ws, path, version: history.versions?.[0]?.version }
|
||||
return { ws, path, writes, version: history.versions?.[0]?.version }
|
||||
}
|
||||
)
|
||||
// Guarded like the link above: a response for a previous agent must not label this one.
|
||||
// Guarded like the link above, `writes` included: a response for a previous agent must not label
|
||||
// this one, and one from before a deploy must not relabel it with the version it replaced.
|
||||
let version = $derived.by(() => {
|
||||
const loaded = versionResource.current
|
||||
return loaded !== undefined && loaded.ws === ws && loaded.path === cardPath
|
||||
return loaded !== undefined &&
|
||||
loaded.ws === ws &&
|
||||
loaded.path === cardPath &&
|
||||
loaded.writes === writes
|
||||
? loaded.version
|
||||
: undefined
|
||||
})
|
||||
@@ -184,7 +192,7 @@
|
||||
if (loaded) {
|
||||
claimLinkedToolsFetch(toolScope, moduleId)
|
||||
// linkedResource types tools loosely; they are the same resource tools the store holds.
|
||||
setLinkedAgentTools(toolScope, moduleId, loaded.tools as AgentToolStrict[])
|
||||
setLinkedAgentTools(toolScope, moduleId, loaded.tools as AgentToolStrict[], agent)
|
||||
publishedFor = agent
|
||||
} else if (publishedFor !== undefined && publishedFor !== agent) {
|
||||
// The link moved and the new agent hasn't resolved, so the stored tools are the old one's.
|
||||
@@ -203,7 +211,7 @@
|
||||
let showDetail = $state(false)
|
||||
|
||||
function openSave() {
|
||||
newPath = editingPath ?? ''
|
||||
newPath = ''
|
||||
pathError = ''
|
||||
description = ''
|
||||
saveDrawer?.openDrawer()
|
||||
@@ -260,12 +268,12 @@
|
||||
// every brain transform and the tools. Comparing the saved config instead would miss a
|
||||
// non-static brain edit, which the resource cannot hold yet linking still strips.
|
||||
const savedSnapshot = discardedOnLinkSnapshot()
|
||||
// If the edit session ends or changes while the requests below are in flight (Cancel, undo,
|
||||
// session-draft sync, a different agent opened for editing), the resource is still written but
|
||||
// the step must not be relinked/cleared. Pinning the path — not merely "some edit is active" —
|
||||
// is what distinguishes this session from a replacement one.
|
||||
// If the step is replaced while the requests below are in flight (undo, a session-draft sync),
|
||||
// the resource is still written but the step must not be relinked and emptied. The tools array
|
||||
// this save started from is what identifies it, and its link answers for the case where a
|
||||
// step keeps that array yet is pointed at an agent of its own meanwhile.
|
||||
const forkMarker = tools
|
||||
const savingEditPath = getAgentEditingPath(forkMarker)
|
||||
const startedUnlinkedFrom = agent
|
||||
const exists = await ResourceService.existsResource({ workspace: ws!, path })
|
||||
if (exists) {
|
||||
// The drawer's path check is debounced, so a fast save can reach here with an unrelated
|
||||
@@ -294,15 +302,8 @@
|
||||
}
|
||||
// The write minted a version, and nothing else the fetch keys on has to change for it to be
|
||||
// the one the card should now be naming.
|
||||
writes++
|
||||
// Editing: a content-preserving refresh may have re-anchored the marker onto a clone of
|
||||
// `tools`, which is still this session; a cleared or different path is not. Saving a
|
||||
// standalone step has no marker to track, so only the fork's own array identifies it.
|
||||
const sameSession =
|
||||
savingEditPath === undefined
|
||||
? tools === forkMarker
|
||||
: getAgentEditingPath(tools) === savingEditPath
|
||||
if (!sameSession) {
|
||||
markAgentWritten(ws, path)
|
||||
if (tools !== forkMarker || agent !== startedUnlinkedFrom) {
|
||||
// The resource is written either way; say so, or the drawer just closes with no outcome.
|
||||
sendUserToast(
|
||||
`Saved ${path}, but the step changed while saving, so it was not linked to the agent`,
|
||||
@@ -320,9 +321,6 @@
|
||||
return false
|
||||
}
|
||||
agent = path
|
||||
// Clear the edit entry while `tools` is still the fork's marker, before it's reassigned.
|
||||
setAgentEditingPath(tools, undefined)
|
||||
setAgentEditingPath(forkMarker, undefined)
|
||||
// The brain + tools now live in the resource; a linked step keeps only the flow-local inputs.
|
||||
tools = []
|
||||
inputTransforms = flowLocalInputs(inputTransforms)
|
||||
@@ -335,12 +333,11 @@
|
||||
}
|
||||
saving = true
|
||||
try {
|
||||
const updating = newPath === editingPath
|
||||
const linked = await persist(newPath, description)
|
||||
saveDrawer?.closeDrawer()
|
||||
if (linked) {
|
||||
logReusableAgentUsage(updating ? 'updated' : 'saved')
|
||||
sendUserToast(updating ? `Updated agent ${newPath}` : `Saved reusable agent ${newPath}`)
|
||||
logReusableAgentUsage('saved')
|
||||
sendUserToast(`Saved reusable agent ${newPath}`)
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to save agent: ${e}`, true)
|
||||
@@ -349,34 +346,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Save the forked-for-edit step back to the agent it came from, updating it in place.
|
||||
async function saveChanges() {
|
||||
if (!ws || !editingPath) {
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
const path = editingPath
|
||||
try {
|
||||
if (await persist(path)) {
|
||||
logReusableAgentUsage('updated')
|
||||
sendUserToast(`Updated agent ${path}`)
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to update agent: ${e}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the resource's brain + tools into the step, for Unlink (diverge here) and Edit (change the
|
||||
// saved agent). Unlink folds this flow's tool_inputs into the tools and clears them, so the
|
||||
// standalone step keeps its bindings; Edit must not fold, or those overrides would be promoted
|
||||
// into the shared agent instead of surviving the re-link.
|
||||
async function forkFromResource(
|
||||
foldOverrides: boolean
|
||||
): Promise<{ path: string; deployedConfig: string } | undefined> {
|
||||
// Copy the resource's brain + tools into the step, so it can diverge from the agent it was
|
||||
// linked to. This flow's tool_inputs are folded into the tools and then cleared, so the
|
||||
// standalone step keeps the bindings it was running with.
|
||||
// Returns false when the step changed under the fetch, so the caller can say nothing happened.
|
||||
async function forkFromResource(): Promise<boolean> {
|
||||
if (!ws || !agent) {
|
||||
return undefined
|
||||
return false
|
||||
}
|
||||
const path = agent
|
||||
// `tools` is one array per module value, so it identifies the step itself — the path alone
|
||||
@@ -384,9 +360,9 @@
|
||||
const stepMarker = tools
|
||||
const res = await ResourceService.getResource({ workspace: ws, path })
|
||||
// The module may have been replaced while the fetch was in flight (undo, session drafts);
|
||||
// applying a stale fork would overwrite the restored state and recreate the Editing target.
|
||||
// applying a stale fork would overwrite the restored state.
|
||||
if (agent !== path || tools !== stepMarker) {
|
||||
return undefined
|
||||
return false
|
||||
}
|
||||
const cfg = (res.value ?? {}) as AIAgentConfig
|
||||
// Preserve the flow-local inputs already wired in the step.
|
||||
@@ -398,30 +374,24 @@
|
||||
}
|
||||
const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local }
|
||||
const forkedTools = cfg.tools ?? []
|
||||
// The baseline edits are judged against, in the form `currentConfig` takes: comparing
|
||||
// against the resource's own JSON would count key order as an edit.
|
||||
const deployedConfig = JSON.stringify(agentConfigAsEdited(forkedInputs, forkedTools))
|
||||
inputTransforms = forkedInputs
|
||||
if (foldOverrides) {
|
||||
for (const tool of forkedTools) {
|
||||
const overrides = toolInputs?.[tool.id]
|
||||
if (overrides && tool.value?.input_transforms) {
|
||||
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
|
||||
}
|
||||
for (const tool of forkedTools) {
|
||||
const overrides = toolInputs?.[tool.id]
|
||||
if (overrides && tool.value?.input_transforms) {
|
||||
tool.value.input_transforms = { ...tool.value.input_transforms, ...overrides }
|
||||
}
|
||||
toolInputs = {}
|
||||
}
|
||||
toolInputs = {}
|
||||
tools = forkedTools
|
||||
agent = undefined
|
||||
return { path, deployedConfig }
|
||||
return true
|
||||
}
|
||||
|
||||
// Unlink forks the agent into this step so it can diverge here. It does not write back.
|
||||
async function unlink() {
|
||||
try {
|
||||
const fork = await forkFromResource(true)
|
||||
if (fork) {
|
||||
setAgentEditingPath(tools, undefined)
|
||||
const forked = await forkFromResource()
|
||||
if (forked) {
|
||||
logReusableAgentUsage('unlinked')
|
||||
sendUserToast('Forked agent. Its configuration was copied into this step')
|
||||
} else {
|
||||
@@ -432,93 +402,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Edit the saved agent itself: fork it into the step for editing, remembering the path so
|
||||
// "Save changes" writes back to it (updating every flow that links to it).
|
||||
async function editAgent() {
|
||||
try {
|
||||
const fork = await forkFromResource(false)
|
||||
if (fork) {
|
||||
const { path, ...baselines } = fork
|
||||
setAgentEditingPath(tools, path, baselines)
|
||||
sendUserToast(`Editing ${path}. Make changes, then Save changes to update it`)
|
||||
} else {
|
||||
sendUserToast('The step changed while loading the agent. Try Edit again', true)
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to edit agent: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** The edits as a run of them executes them: the step's brain transforms as authored
|
||||
* (expressions included) and its tools. Flow-local inputs are left out: a case supplies them,
|
||||
* and that is what lets the server recognise a run of later-deployed edits as that version. */
|
||||
function editedConfig(): AgentDraft {
|
||||
const brain: Record<string, InputTransform> = {}
|
||||
for (const [key, transform] of Object.entries(inputTransforms ?? {})) {
|
||||
if (!(AGENT_FLOW_LOCAL_KEYS as readonly string[]).includes(key)) {
|
||||
brain[key] = transform
|
||||
}
|
||||
}
|
||||
return {
|
||||
input_transforms: $state.snapshot(brain) as Record<string, unknown>,
|
||||
tools: $state.snapshot(tools) as Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** The configuration the step holds now, in the form it is compared with the deployed agent in.
|
||||
* Expressions count: the saved config cannot hold one, but a run of the edits executes it and
|
||||
* Cancel drops it. */
|
||||
let currentConfig = $derived(JSON.stringify(agentConfigAsEdited(inputTransforms, tools)))
|
||||
/** The agent as deployed, in the same form; kept on the edit session so an editor mounted
|
||||
* part-way through still has it. */
|
||||
let deployedConfig = $derived(editing?.deployedConfig)
|
||||
let edited = $derived(deployedConfig !== undefined && currentConfig !== deployedConfig)
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
// Both sides snapshotted at click time: the drawer would otherwise keep re-reading the live
|
||||
// step while the user types behind it.
|
||||
function showDiff() {
|
||||
if (!deployedConfig) return
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: JSON.parse(deployedConfig) as Value,
|
||||
current: $state.snapshot(agentConfigAsEdited(inputTransforms, tools)) as Value,
|
||||
title: 'Deployed <> Unsaved agent changes',
|
||||
button: {
|
||||
text: 'Discard changes',
|
||||
onClick: () => {
|
||||
// Asked for by name, from a drawer showing exactly what goes: no second question.
|
||||
const path = editingPath
|
||||
if (path) relink(path)
|
||||
diffDrawer?.closeDrawer()
|
||||
}
|
||||
}
|
||||
// Edit the saved agent itself. The step stays linked throughout: the edits live in the agent's
|
||||
// own resource draft, not in this step, so they survive leaving the flow and are the same edits
|
||||
// whichever flow — or the resources page — opened them.
|
||||
function editAgent() {
|
||||
if (!agent) return
|
||||
openAgentEditor({
|
||||
path: agent,
|
||||
workspace: ws,
|
||||
// Where to re-resolve this graph's tool nodes once the agent is deployed.
|
||||
host: { flowPath, moduleId }
|
||||
})
|
||||
}
|
||||
|
||||
let confirmCancel = $state(false)
|
||||
// Cancel drops the edits and re-links the step without saving anything, so it asks first when
|
||||
// there are edits to drop.
|
||||
function cancelEdit() {
|
||||
const path = editingPath
|
||||
if (!path) return
|
||||
if (edited) {
|
||||
confirmCancel = true
|
||||
return
|
||||
}
|
||||
relink(path)
|
||||
}
|
||||
|
||||
// Put the step back on the agent. Edit kept this flow's `tool_inputs` off the forked tools
|
||||
// rather than folding them in, so they survive the round trip as overrides.
|
||||
function relink(path: string) {
|
||||
// Clear the entry while `tools` is still the fork's array, which is what keys it.
|
||||
setAgentEditingPath(tools, undefined)
|
||||
agent = path
|
||||
tools = []
|
||||
inputTransforms = flowLocalInputs(inputTransforms)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="px-2 xl:px-4 py-1.5 border-b border-light">
|
||||
@@ -564,29 +459,19 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: FlaskConical }}
|
||||
title="Run this agent against a dataset of cases"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
evalsOpen = true
|
||||
}}
|
||||
>
|
||||
Evals
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Pencil }}
|
||||
iconOnly
|
||||
title="Edit the saved agent (updates it everywhere it's used)"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
editAgent()
|
||||
}}
|
||||
/>
|
||||
{#if !fromAgentEditor}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Pencil }}
|
||||
iconOnly
|
||||
title="Edit the saved agent (updates it everywhere it's used)"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
editAgent()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
@@ -630,73 +515,7 @@
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if editingPath}
|
||||
<div
|
||||
class="rounded-md border border-light bg-surface-tertiary px-3 py-1.5 flex flex-col gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Pencil size={16} class="text-primary shrink-0" />
|
||||
<div class="min-w-0 flex-1 flex flex-col">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="truncate text-xs font-medium" title={editingPath}>{editingPath}</span>
|
||||
{#if version != undefined}
|
||||
<Badge color="gray" class="shrink-0" title="The version these edits sit on">
|
||||
v{version}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if edited}
|
||||
<Badge
|
||||
color="yellow"
|
||||
class="shrink-0"
|
||||
clickable
|
||||
title="Show what differs from the deployed agent"
|
||||
onclick={showDiff}
|
||||
>
|
||||
unsaved changes
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-2xs text-secondary flex items-center gap-0.5">
|
||||
saving updates every flow using it<Tooltip small>
|
||||
{#snippet text()}
|
||||
The edits live in this step until you decide: Evals runs them as they are here, Save
|
||||
changes writes them to the agent, Cancel drops them and re-links the step.
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: FlaskConical }}
|
||||
title="Run these edits against a dataset of cases"
|
||||
disabled={saving}
|
||||
onclick={() => (evalsOpen = true)}
|
||||
>
|
||||
Evals
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button unifiedSize="sm" variant="default" disabled={saving} onclick={cancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={saving || !!providerSaveError}
|
||||
onclick={saveChanges}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if providerSaveError}
|
||||
<p class="text-2xs text-red-500 mt-1">
|
||||
{providerSaveError}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{:else if !fromAgentEditor}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
@@ -752,31 +571,3 @@
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Opened from the editing card, the edits are what is under test, read from the step when Run is
|
||||
pressed; from the linked card, the deployed agent is. -->
|
||||
<AgentEvalModal
|
||||
agentPath={cardPath}
|
||||
{opWorkspace}
|
||||
editedConfig={editingPath ? editedConfig : undefined}
|
||||
bind:open={evalsOpen}
|
||||
/>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} />
|
||||
|
||||
<ConfirmationModal
|
||||
open={confirmCancel}
|
||||
title="Drop these edits?"
|
||||
confirmationText="Drop edits"
|
||||
onConfirmed={() => {
|
||||
confirmCancel = false
|
||||
const path = editingPath
|
||||
if (path) relink(path)
|
||||
}}
|
||||
onCanceled={() => (confirmCancel = false)}
|
||||
>
|
||||
<span class="text-sm">
|
||||
The step goes back to {editingPath} as it is deployed, and the edits are not kept anywhere. Save
|
||||
changes writes them to the agent instead.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
import type { InputTransform, ScriptLang } from '$lib/gen'
|
||||
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import { Wrench } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronDown, ChevronRight, Wrench } from 'lucide-svelte'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import {
|
||||
agentToolToFlowModule,
|
||||
isFlowModuleTool,
|
||||
AI_AGENT_TOOL_AI_KEYS,
|
||||
type AgentTool,
|
||||
type FlowModuleTool
|
||||
} from '../agentToolUtils'
|
||||
@@ -129,14 +131,44 @@
|
||||
const v = tool.value as { type?: string; language?: ScriptLang; content?: string }
|
||||
return v.type === 'rawscript' && v.content ? v : undefined
|
||||
}
|
||||
|
||||
/** How many of this tool's inputs this flow binds. `toolInputs` holds the diff from the
|
||||
* resource, so its size is the count. */
|
||||
function overrideCount(tool: FlowModuleTool): number {
|
||||
return Object.keys(toolInputs?.[tool.id] ?? {}).length
|
||||
}
|
||||
|
||||
// Which sections the reader opened or closed. Absent means the default below, so a tool that
|
||||
// gains an override while untouched still opens on its own.
|
||||
let toggled = $state<Record<string, boolean>>({})
|
||||
/** Open where there is something to see: a tool this flow binds. The rest are the agent's own
|
||||
* business, and a linked agent's roster is mostly that. */
|
||||
function isOpen(tool: FlowModuleTool): boolean {
|
||||
return toggled[tool.id] ?? overrideCount(tool) > 0
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if flowTools.length > 0}
|
||||
<div class="flex flex-col gap-2 px-2 pb-8 xl:px-4">
|
||||
{#each flowTools as tool (tool.id)}
|
||||
{@const code = toolCode(tool)}
|
||||
{@const open = isOpen(tool)}
|
||||
<div class="rounded-md border border-light">
|
||||
<div class="flex items-center gap-1.5 border-b border-light px-3 py-1.5 text-xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
aria-expanded={open}
|
||||
onClick={() => (toggled[tool.id] = !open)}
|
||||
wrapperClasses="w-full"
|
||||
btnClasses="w-full !justify-start !text-left !px-3 !font-normal !text-xs !gap-1.5 !rounded-b-none {open
|
||||
? 'border-b border-light'
|
||||
: ''}"
|
||||
>
|
||||
{#if open}
|
||||
<ChevronDown size={14} class="shrink-0 text-tertiary" />
|
||||
{:else}
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
<Wrench size={14} class="shrink-0 text-tertiary" />
|
||||
<span class="font-medium">{tool.summary || tool.id}</span>
|
||||
{#if tool.description}
|
||||
@@ -144,8 +176,10 @@
|
||||
>{tool.description}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
|
||||
</Button>
|
||||
{#if !open}
|
||||
<!-- collapsed: the agent's own inputs, nothing this flow has changed -->
|
||||
{:else if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
|
||||
<InputTransformSchemaForm
|
||||
class="px-3 pt-1"
|
||||
schema={schemas[tool.id]}
|
||||
@@ -153,6 +187,9 @@
|
||||
{extraLib}
|
||||
{workspace}
|
||||
isAgentTool
|
||||
allowedAiTransforms={(tool.value as { type?: string }).type === 'aiagent'
|
||||
? AI_AGENT_TOOL_AI_KEYS
|
||||
: undefined}
|
||||
bind:args={
|
||||
() => localArgs[tool.id],
|
||||
(v) => {
|
||||
@@ -163,7 +200,7 @@
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-2xs text-tertiary">Loading inputs...</div>
|
||||
{/if}
|
||||
{#if code}
|
||||
{#if open && code}
|
||||
<details class="border-t border-light">
|
||||
<summary class="cursor-pointer px-3 py-1.5 text-2xs font-medium text-tertiary">
|
||||
Tool code (read-only)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight, Plus, Wrench, X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getToolNameError, toolDisplayName } from '../agentToolUtils'
|
||||
import type { AgentTool } from '../agentToolUtils'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import InsertModuleInner from '../map/InsertModuleInner.svelte'
|
||||
import { overlayPortalTarget } from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
interface Props {
|
||||
tools?: AgentTool[]
|
||||
/** Where clicking a tool goes. Without it the roster is a read-only list. */
|
||||
onSelectTool?: (toolId: string) => void
|
||||
/** Adds a tool. Without it the empty state only says where to add one. */
|
||||
onAddTool?: (detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) => void
|
||||
/** Removes a tool. Without it the rows carry no delete, as they must not for a linked
|
||||
* agent's tools, which belong to the agent rather than to the step showing them. */
|
||||
onDeleteTool?: (toolId: string) => void
|
||||
emptyMessage?: string
|
||||
/** Where the picker's popover belongs, when the roster is not inside the flow editor. */
|
||||
pickerPortal?: string
|
||||
}
|
||||
|
||||
let {
|
||||
tools = [],
|
||||
onSelectTool = undefined,
|
||||
onAddTool = undefined,
|
||||
onDeleteTool = undefined,
|
||||
emptyMessage = 'No tools yet. Add one from the agent on the flow graph.',
|
||||
pickerPortal = '#flow-editor'
|
||||
}: Props = $props()
|
||||
|
||||
let funcDesc = $state('')
|
||||
const portalTarget = overlayPortalTarget(() => pickerPortal)
|
||||
|
||||
function toolKind(tool: AgentTool): string | undefined {
|
||||
const value = tool?.value as Record<string, any>
|
||||
if (value?.type === 'aiagent') return 'agent'
|
||||
if (value?.tool_type === 'mcp') return 'MCP'
|
||||
if (value?.tool_type === 'websearch') return 'web search'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Only a flow module tool's summary is the name the model is given; an mcp or websearch
|
||||
* summary is never sent, so a blank one there is not an error. Same rule as `agentToolTree`. */
|
||||
function isNamed(tool: AgentTool): boolean {
|
||||
return tool?.value?.tool_type !== 'mcp' && tool?.value?.tool_type !== 'websearch'
|
||||
}
|
||||
|
||||
let siblingNames = $derived(tools.filter(isNamed).map((tool) => tool?.summary ?? ''))
|
||||
|
||||
function nameError(tool: AgentTool): string | undefined {
|
||||
if (!isNamed(tool)) return undefined
|
||||
return getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames)
|
||||
}
|
||||
|
||||
/** What to call a tool that has nothing to be called yet. A kind that carries no name says what
|
||||
* it is still missing instead; the rest are named by their problem, since the run cannot start
|
||||
* without one. There is room here for the full wording the graph node has to abbreviate. */
|
||||
function unnamedLabel(tool: AgentTool): string {
|
||||
if (tool?.value?.tool_type === 'mcp') return 'No MCP server selected'
|
||||
return 'Missing tool name'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet addToolButton()}
|
||||
<Popover
|
||||
portal={portalTarget()}
|
||||
contentClasses="p-2 max-w-full h-[400px] bg-surface"
|
||||
class="inline-block"
|
||||
usePointerDownOutside
|
||||
floatingConfig={{ placement: 'bottom-start', strategy: 'absolute', gutter: 8, flip: true }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="sm" startIcon={{ icon: Plus }}>Tool</Button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<!-- The same picker the graph's `+ Tool` opens, so both routes offer the same kinds and
|
||||
hand the same detail to the same insert. -->
|
||||
<InsertModuleInner
|
||||
bind:funcDesc
|
||||
toolMode
|
||||
on:close={close}
|
||||
on:new={(e) => (onAddTool?.(e.detail), close())}
|
||||
on:insert={(e) => (onAddTool?.(e.detail), close())}
|
||||
on:pickScript={(e) => (
|
||||
onAddTool?.({
|
||||
kind: e.detail.kind,
|
||||
script: {
|
||||
...e.detail,
|
||||
summary: e.detail.summary
|
||||
? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '')
|
||||
: e.detail.path.split('/').pop()
|
||||
}
|
||||
}),
|
||||
close()
|
||||
)}
|
||||
on:pickMcpTool={() => (onAddTool?.({ kind: 'mcpTool' }), close())}
|
||||
on:pickWebsearchTool={() => (onAddTool?.({ kind: 'websearchTool' }), close())}
|
||||
on:pickAiAgentTool={() => (onAddTool?.({ kind: 'aiAgentTool' }), close())}
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/snippet}
|
||||
|
||||
{#if tools.length === 0}
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="text-xs text-tertiary">
|
||||
{onAddTool ? 'No tools yet.' : emptyMessage}
|
||||
</div>
|
||||
{#if onAddTool}
|
||||
{@render addToolButton()}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col border rounded-md divide-y overflow-hidden">
|
||||
<!-- Keyed by position, not id: the list is JSON-authored, so two entries can carry the same
|
||||
id or none, and a duplicate key throws during render. The rows hold no state of their
|
||||
own, so position is identity enough here. -->
|
||||
{#each tools as tool, i (i)}
|
||||
{@const kind = toolKind(tool)}
|
||||
{@const error = nameError(tool)}
|
||||
<!-- The delete sits beside the row rather than inside it: the row is itself a button, and
|
||||
a button cannot hold another. -->
|
||||
<div class="flex flex-row items-stretch">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
disabled={!onSelectTool}
|
||||
onClick={() => onSelectTool?.(tool?.id)}
|
||||
wrapperClasses="grow min-w-0"
|
||||
btnClasses="w-full min-w-0 !justify-start !text-left !px-2 !font-normal !text-xs text-primary"
|
||||
>
|
||||
<Wrench size={13} class="shrink-0 text-tertiary" />
|
||||
<!-- A tool the worker will reject is named by its problem, not by an id no one
|
||||
chose: the id would read as a name and hide that the run cannot start. -->
|
||||
<span class={twMerge('truncate shrink min-w-0', error && 'text-red-400')}>
|
||||
{error
|
||||
? tool?.summary || unnamedLabel(tool)
|
||||
: (toolDisplayName(tool) ?? unnamedLabel(tool))}
|
||||
</span>
|
||||
{#if error && tool?.summary}
|
||||
<span class="truncate grow min-w-0 text-2xs text-red-400">{error}</span>
|
||||
{:else}
|
||||
<span class="grow"></span>
|
||||
{/if}
|
||||
{#if kind}
|
||||
<span class="text-2xs text-tertiary shrink-0">{kind}</span>
|
||||
{/if}
|
||||
{#if onSelectTool}
|
||||
<ChevronRight size={13} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
</Button>
|
||||
{#if onDeleteTool}
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
title="Delete {toolDisplayName(tool) ?? 'this tool'}"
|
||||
wrapperClasses="shrink-0 self-center pr-1"
|
||||
onclick={() => onDeleteTool?.(tool?.id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if onAddTool}
|
||||
<div class="mt-2">{@render addToolButton()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -15,6 +15,11 @@
|
||||
forceTestTab?: Record<string, boolean>
|
||||
highlightArg?: Record<string, string | undefined>
|
||||
siblingToolNames?: string[]
|
||||
/** See `FlowModuleComponent`: set when the tool belongs to a saved agent rather than to a
|
||||
* step of this flow. */
|
||||
staticOnly?: boolean
|
||||
/** See `FlowModuleComponent`: set where there is no graph to select a nested tool on. */
|
||||
noToolNavigation?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,15 +30,28 @@
|
||||
previousModule = undefined,
|
||||
forceTestTab,
|
||||
highlightArg,
|
||||
siblingToolNames = undefined
|
||||
siblingToolNames = undefined,
|
||||
staticOnly = false,
|
||||
noToolNavigation = false
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if isFlowModuleTool(tool)}
|
||||
<!-- FlowModule tool - use existing FlowModuleComponent -->
|
||||
<!-- "Save to workspace" and "Fork" replace the module wholesale, so the binding must be
|
||||
two-way or the deploy writes the pre-replacement tool. Merged rather than assigned: they
|
||||
build a plain step, which carries none of the fields only a tool has. -->
|
||||
<FlowModuleComponent
|
||||
{noEditor}
|
||||
flowModule={tool as FlowModule}
|
||||
bind:flowModule={
|
||||
() => tool as FlowModule,
|
||||
(v) =>
|
||||
(tool = {
|
||||
...tool,
|
||||
...v,
|
||||
value: { tool_type: tool.value?.tool_type, ...v.value }
|
||||
} as unknown as AgentTool)
|
||||
}
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
failureModule={false}
|
||||
@@ -45,6 +63,8 @@
|
||||
forceTestTab={forceTestTab?.[tool.id]}
|
||||
highlightArg={highlightArg?.[tool.id]}
|
||||
isAgentTool={true}
|
||||
{staticOnly}
|
||||
{noToolNavigation}
|
||||
bind:toolDescription={tool.description}
|
||||
{siblingToolNames}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<script module lang="ts">
|
||||
// A field added but not yet filled looks exactly like an unset one, so leaving the step and
|
||||
// coming back would drop the row. Remembering which fields are open per step keeps it. Bounded,
|
||||
// or a long-lived tab keeps one entry for every agent step it ever opened.
|
||||
const openFieldsByStep = new Map<string, string[]>()
|
||||
const MAX_REMEMBERED_STEPS = 50
|
||||
|
||||
function rememberOpenFields(key: string | undefined, keys: string[]) {
|
||||
if (!key) return
|
||||
openFieldsByStep.delete(key)
|
||||
openFieldsByStep.set(key, keys)
|
||||
while (openFieldsByStep.size > MAX_REMEMBERED_STEPS) {
|
||||
const oldest = openFieldsByStep.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
openFieldsByStep.delete(oldest)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Schema } from '$lib/common'
|
||||
import { type InputTransform } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { Button } from '$lib/components/common'
|
||||
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
|
||||
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
|
||||
import InputTransformPickers from '$lib/components/InputTransformPickers.svelte'
|
||||
import { useS3StorageConfigured } from '$lib/components/inputTransformEnv.svelte'
|
||||
import type ItemPicker from '$lib/components/ItemPicker.svelte'
|
||||
import type VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
import { Plus, X } from 'lucide-svelte'
|
||||
import type { PickableProperties } from '../previousResults'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import type { AgentTool } from '../agentToolUtils'
|
||||
import {
|
||||
AGENT_FIELDS,
|
||||
AGENT_FIELD_GROUPS,
|
||||
agentFieldAppliesTo,
|
||||
initialVisibleAgentFields,
|
||||
type AgentFieldGroup,
|
||||
type AgentFieldSpec
|
||||
} from '../agentFormFields'
|
||||
import AgentToolRoster from './AgentToolRoster.svelte'
|
||||
|
||||
interface Props {
|
||||
schema: Schema | { properties?: Record<string, any> }
|
||||
/** Required: every row below indexes it directly, and a first render against an unset one
|
||||
* would throw before the effect that normalises it could run. */
|
||||
args: Record<string, InputTransform | any>
|
||||
isValid?: boolean
|
||||
extraLib?: string
|
||||
previousModuleId?: string | undefined
|
||||
/** Restrict the form to these keys, for a surface that edits only part of an agent. */
|
||||
filter?: string[] | undefined
|
||||
pickableProperties?: PickableProperties | undefined
|
||||
enableAi?: boolean
|
||||
class?: string
|
||||
helperScript?: DynamicInputTypes.HelperScript
|
||||
isAgentTool?: boolean
|
||||
allowedAiTransforms?: string[] | undefined
|
||||
chatInputEnabled?: boolean
|
||||
workspace?: string | undefined
|
||||
/** Identifies the step, so the fields it has open survive leaving and coming back. */
|
||||
visibilityKey?: string
|
||||
tools?: AgentTool[]
|
||||
/** Offer only static values, for a surface whose store cannot hold anything else — a saved
|
||||
* agent's config is plain JSON, so an expression on a brain field would be dropped on save.
|
||||
* A step's own agent has no such limit: its transforms are evaluated per run. */
|
||||
staticOnly?: boolean
|
||||
/** Show the agent without letting anything about it be changed, for a viewer who has read
|
||||
* access only. The fields are made inert rather than merely losing their buttons: a field
|
||||
* left editable would look saved while every write behind it is rejected. */
|
||||
readOnly?: boolean
|
||||
/** Passed to every field, for a nested agent used as a tool: its brain has to stand on its
|
||||
* own as `staticOnly` does, but its `user_message` is still the agent's to fill. */
|
||||
noConnect?: boolean
|
||||
noJavascript?: boolean
|
||||
onSelectTool?: (toolId: string) => void
|
||||
/** Adds a tool to this agent. Without it the roster is read-only, as it is for a linked
|
||||
* agent, whose tools belong to the agent rather than to the step. */
|
||||
onAddTool?: (detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) => void
|
||||
/** Removes a tool from this agent. Same rule as `onAddTool`: absent where the tools are not
|
||||
* the step's to change. */
|
||||
onDeleteTool?: (toolId: string) => void
|
||||
/** Where the tool picker's popover belongs, for a surface that is not the flow editor. */
|
||||
toolPickerPortal?: string
|
||||
}
|
||||
|
||||
let {
|
||||
schema = $bindable(),
|
||||
args = $bindable(),
|
||||
isValid = $bindable(),
|
||||
extraLib = $bindable(),
|
||||
previousModuleId = undefined,
|
||||
filter = undefined,
|
||||
pickableProperties = undefined,
|
||||
enableAi = false,
|
||||
class: clazz = '',
|
||||
helperScript = undefined,
|
||||
isAgentTool = false,
|
||||
// Reproduces InputTransformSchemaForm's own default rather than the call site's value:
|
||||
// `undefined` lets any field become an AI transform, and InputTransformForm rewrites
|
||||
// `arg.type` to 'ai' on mount for every static-undefined field when it does.
|
||||
allowedAiTransforms = isAgentTool ? undefined : [],
|
||||
chatInputEnabled = false,
|
||||
workspace,
|
||||
visibilityKey = undefined,
|
||||
tools = [],
|
||||
staticOnly = false,
|
||||
readOnly = false,
|
||||
noConnect = false,
|
||||
noJavascript = false,
|
||||
onSelectTool = undefined,
|
||||
onAddTool = undefined,
|
||||
onDeleteTool = undefined,
|
||||
toolPickerPortal = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
|
||||
let inputCheck: { [id: string]: boolean } = $state({})
|
||||
|
||||
$effect(() => {
|
||||
isValid = allTrue(inputCheck) ?? false
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (args == undefined || typeof args !== 'object') {
|
||||
args = {}
|
||||
}
|
||||
})
|
||||
|
||||
export function setArgs(nargs: Record<string, InputTransform | any>) {
|
||||
args = nargs
|
||||
}
|
||||
|
||||
let pickForField: string | undefined = $state()
|
||||
let itemPicker: ItemPicker | undefined = $state(undefined)
|
||||
let variableEditor: VariableEditor | undefined = $state(undefined)
|
||||
|
||||
const s3Storage = useS3StorageConfigured(() => ws)
|
||||
|
||||
// The per-field copilot only ever writes a JavaScript transform, so it belongs only where one can
|
||||
// be stored. On a static-only field the write lands in a key the config drops on deploy, which
|
||||
// reports success over a visible edit, or persists an expression into a saved agent that every
|
||||
// flow linking it would then have to satisfy.
|
||||
let fieldAiEnabled = $derived(enableAi && !staticOnly && !noJavascript)
|
||||
|
||||
let schemaProperties = $derived((schema?.properties ?? {}) as Record<string, any>)
|
||||
|
||||
let scopedFields = $derived(
|
||||
AGENT_FIELDS.filter(
|
||||
(spec) =>
|
||||
agentFieldAppliesTo(spec, schemaProperties) && (!filter || filter.includes(spec.key))
|
||||
)
|
||||
)
|
||||
|
||||
let outputType = $derived.by(() => {
|
||||
const transform = args?.['output_type']
|
||||
return transform && transform.type === 'static' ? transform.value : undefined
|
||||
})
|
||||
let imageOutput = $derived(outputType === 'image')
|
||||
|
||||
// Which fields have a row. Never derived from `args`, or emptying a textbox would make its row
|
||||
// vanish under the cursor: this only ever grows, and the x is the one thing that shrinks it.
|
||||
// Read once, on purpose: the set is seeded here and grows from there, so re-reading the key would
|
||||
// be a rebuild that throws away everything the user has opened.
|
||||
let visible = $state(
|
||||
new SvelteSet<string>(openFieldsByStep.get(untrack(() => visibilityKey) ?? '') ?? [])
|
||||
)
|
||||
|
||||
// A field set from anywhere else — an undo, a schema that arrived late — brings its row back on
|
||||
// its own.
|
||||
$effect(() => {
|
||||
const set = initialVisibleAgentFields(args, schemaProperties)
|
||||
untrack(() => {
|
||||
for (const key of set) visible.add(key)
|
||||
})
|
||||
})
|
||||
|
||||
// The copilot hands its expressions to whichever row is mounted for the argument, so a field it
|
||||
// targets needs one before it can take the value. Watched here rather than left to the union
|
||||
// above, which only sees a field once something has already written to it.
|
||||
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
|
||||
let pendingExprKeys = $derived(
|
||||
exprsToSet ? Object.keys($exprsToSet ?? {}).filter((key) => $exprsToSet?.[key]) : []
|
||||
)
|
||||
$effect(() => {
|
||||
const keys = pendingExprKeys
|
||||
untrack(() => {
|
||||
for (const key of keys) {
|
||||
if (key in schemaProperties) visible.add(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const keys = [...visible]
|
||||
untrack(() => rememberOpenFields(visibilityKey, keys))
|
||||
})
|
||||
|
||||
function rowsIn(group: AgentFieldGroup): AgentFieldSpec[] {
|
||||
return scopedFields.filter(
|
||||
(spec) => spec.group === group && visible.has(spec.key) && !(imageOutput && spec.textOnly)
|
||||
)
|
||||
}
|
||||
|
||||
function addableIn(): AgentFieldSpec[] {
|
||||
return scopedFields.filter(
|
||||
(spec) =>
|
||||
!spec.core && !spec.virtual && !visible.has(spec.key) && !(imageOutput && spec.textOnly)
|
||||
)
|
||||
}
|
||||
|
||||
function addField(spec: AgentFieldSpec) {
|
||||
// `flowInfers` re-seeds every key on load, so adding cannot mean creating the key: it means
|
||||
// showing the row, seeded at what a run does today so the field opens on what it overrides.
|
||||
if (args) {
|
||||
args[spec.key] = { type: 'static', value: structuredClone(spec.implicit) }
|
||||
}
|
||||
visible.add(spec.key)
|
||||
}
|
||||
|
||||
function removeField(spec: AgentFieldSpec) {
|
||||
visible.delete(spec.key)
|
||||
if (args) {
|
||||
// Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind.
|
||||
// Never `delete args[key]`: the key returns on the next load, and the CLI linter requires
|
||||
// `user_message` to be present.
|
||||
args[spec.key] = { type: 'static', value: undefined }
|
||||
}
|
||||
// InputTransformSchemaForm leaks these on unmount, which would pin `isValid` false forever
|
||||
// once hiding a row is routine.
|
||||
delete inputCheck[spec.key]
|
||||
}
|
||||
|
||||
let emptyArgNames = $derived(
|
||||
[...visible].filter((key) => {
|
||||
if (!(key in schemaProperties)) return false
|
||||
const transform = args?.[key]
|
||||
if (!transform) return false
|
||||
return (
|
||||
(transform.type === 'static' && !transform.value) ||
|
||||
(transform.type === 'javascript' && !transform.expr)
|
||||
)
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet addFieldMenu()}
|
||||
{@const candidates = addableIn()}
|
||||
{#if candidates.length > 0}
|
||||
<DropdownV2 placement="bottom-start" customMenu class="justify-start">
|
||||
{#snippet buttonReplacement()}
|
||||
<Button variant="default" unifiedSize="md" startIcon={{ icon: Plus }}>Add a field</Button>
|
||||
{/snippet}
|
||||
{#snippet menu({ close })}
|
||||
<!-- Laid out as SelectDropdown lays out a resource list, so the two menus in this form
|
||||
behave the same: full-width rows, and a bordered heading per group. -->
|
||||
<div
|
||||
class="flex flex-col w-80 rounded-md bg-surface-input drop-shadow-base overflow-y-auto max-h-[50vh]"
|
||||
>
|
||||
{#each AGENT_FIELD_GROUPS as menuGroup (menuGroup.id)}
|
||||
{@const groupCandidates = candidates.filter((spec) => spec.group === menuGroup.id)}
|
||||
{#if groupCandidates.length > 0}
|
||||
<div class="px-4 pt-3 pb-1 text-2xs font-normal uppercase text-secondary">
|
||||
{menuGroup.label}
|
||||
</div>
|
||||
{#each groupCandidates as spec (spec.key)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
onClick={() => {
|
||||
addField(spec)
|
||||
close()
|
||||
}}
|
||||
wrapperClasses="w-full"
|
||||
btnClasses="w-full !h-auto !justify-start !text-left !py-2 !px-4 !font-normal !text-xs text-primary !rounded-none"
|
||||
>
|
||||
<div class="w-full">
|
||||
{spec.label}
|
||||
{#if spec.defaultHint}
|
||||
<div class="text-2xs text-secondary">{spec.defaultHint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="w-full mb-6 {clazz}">
|
||||
<!-- Not offered on a static-only surface, where what it fills a field with is a JavaScript
|
||||
expression such a store cannot hold; nor on a tool, whose empty fields are the ones the
|
||||
agent above fills at run time. -->
|
||||
{#if enableAi && !staticOnly && !isAgentTool && !readOnly}
|
||||
<div class="pt-2">
|
||||
<StepInputsGen {pickableProperties} argNames={emptyArgNames} {schema} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-8 pt-4">
|
||||
{#each AGENT_FIELD_GROUPS as group (group.id)}
|
||||
{@const rows = rowsIn(group.id)}
|
||||
{#if rows.length > 0}
|
||||
<div class="w-full flex flex-col">
|
||||
<h2 class="mb-1 text-2xs font-normal uppercase text-secondary">{group.label}</h2>
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each rows as spec (spec.key)}
|
||||
<ResizeTransitionWrapper innerClass="w-full" vertical>
|
||||
{#if spec.virtual}
|
||||
<AgentToolRoster
|
||||
{tools}
|
||||
{onSelectTool}
|
||||
{onAddTool}
|
||||
{onDeleteTool}
|
||||
pickerPortal={toolPickerPortal}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Inert rather than merely button-less: every control below writes into
|
||||
`args`, and a read-only viewer's edit is rejected by the server. Dimmed
|
||||
with it, so a field that ignores a click looks like it meant to. -->
|
||||
<div class="w-full {readOnly ? 'opacity-60' : ''}" inert={readOnly}>
|
||||
<InputTransformForm
|
||||
{previousModuleId}
|
||||
bind:arg={args[spec.key]}
|
||||
bind:schema
|
||||
argName={spec.key}
|
||||
label={spec.label}
|
||||
headerTooltip={spec.tooltip}
|
||||
hideDescription
|
||||
subtleControls
|
||||
argExtra={schemaProperties[spec.key] ?? {}}
|
||||
bind:inputCheck={
|
||||
() => inputCheck[spec.key] ?? false,
|
||||
(value) => (inputCheck[spec.key] = value)
|
||||
}
|
||||
bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)}
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
{pickableProperties}
|
||||
enableAi={fieldAiEnabled}
|
||||
{helperScript}
|
||||
{isAgentTool}
|
||||
{allowedAiTransforms}
|
||||
noDynamicToggle={staticOnly}
|
||||
noConnect={staticOnly || noConnect}
|
||||
noJavascript={staticOnly || noJavascript}
|
||||
s3StorageConfigured={s3Storage.current}
|
||||
{chatInputEnabled}
|
||||
{workspace}
|
||||
otherArgs={Object.fromEntries(
|
||||
Object.entries(args ?? {}).filter(([key]) => key !== spec.key)
|
||||
)}
|
||||
>
|
||||
{#snippet labelExtra()}
|
||||
{#if !spec.core && !readOnly}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
wrapperClasses="ml-1"
|
||||
title="Unset {spec.label}"
|
||||
on:click={() => removeField(spec)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</InputTransformForm>
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if !readOnly}
|
||||
{@render addFieldMenu()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputTransformPickers {args} {pickForField} {workspace} bind:itemPicker bind:variableEditor />
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
@@ -9,7 +10,6 @@
|
||||
reorderBranches as reorderBranchesOp,
|
||||
graphBranchIndex
|
||||
} from '../branchOps'
|
||||
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
function addBranch() {
|
||||
addBranchOp(flowModule.id, { flowStore, history })
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
function removeBranch(arrayIndex: number) {
|
||||
// The shared op counts branches the way the graph does; see graphBranchIndex.
|
||||
@@ -92,7 +92,7 @@
|
||||
flowStateStore,
|
||||
history
|
||||
})
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Tab, Tabs } from '$lib/components/common'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
@@ -9,7 +10,6 @@
|
||||
reorderBranches as reorderBranchesOp,
|
||||
graphBranchIndex
|
||||
} from '../branchOps'
|
||||
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
function addBranch() {
|
||||
addBranchOp(flowModule.id, { flowStore, history })
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
function removeBranch(arrayIndex: number) {
|
||||
// The shared op counts branches the way the graph does; see graphBranchIndex.
|
||||
@@ -99,7 +99,7 @@
|
||||
flowStateStore,
|
||||
history
|
||||
})
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
let runSettings: FlowRunSettings | undefined = $state(undefined)
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
savedModule={savedModuleById(savedFlow?.value.modules, flowModule.id)}
|
||||
{forceTestTab}
|
||||
{highlightArg}
|
||||
{flowModuleSchemaMap}
|
||||
/>
|
||||
{/each}
|
||||
{/key}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import { ButtonType } from '$lib/components/common/button/model'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
@@ -43,11 +44,11 @@
|
||||
import SideBarTab from '$lib/components/meltComponents/SideBarTab.svelte'
|
||||
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
|
||||
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
|
||||
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
|
||||
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AI_AGENT_SCHEMA } from '../flowInfers'
|
||||
import { agentStreamingEnabled } from '../agentFormFields'
|
||||
import { nextId } from '../flowModuleNextId'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
@@ -99,11 +100,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)
|
||||
})
|
||||
let showChatModeWarning = $state(false)
|
||||
let showAdditionalInputs = $state(false)
|
||||
@@ -890,7 +888,7 @@
|
||||
onAddNew={(argName) => {
|
||||
handleEditSchema('inputEditor')
|
||||
editableSchemaForm?.openField(argName)
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import StepSettingsBadges from './StepSettingsBadges.svelte'
|
||||
@@ -29,9 +30,11 @@
|
||||
import FlowPathViewer from './FlowPathViewer.svelte'
|
||||
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
|
||||
import AgentResourceBar from './AgentResourceBar.svelte'
|
||||
import AiAgentStepInputs from './AiAgentStepInputs.svelte'
|
||||
import AgentToolBindings from './AgentToolBindings.svelte'
|
||||
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
||||
import { flowLocalAgentSchema } from '../agentResourceUtils'
|
||||
import { AI_AGENT_TOOL_AI_KEYS } from '../agentToolUtils'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
import type { ButtonProp } from '$lib/components/diffEditorTypes'
|
||||
import { loadSchemaFromModule } from '../flowInfers'
|
||||
@@ -44,7 +47,6 @@
|
||||
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
|
||||
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import AssetsDropdownButton from '$lib/components/assets/AssetsDropdownButton.svelte'
|
||||
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
|
||||
@@ -82,10 +84,10 @@
|
||||
customUi,
|
||||
executionCount,
|
||||
opWorkspace,
|
||||
agentEditorHost,
|
||||
workspaceScriptSettingsDrawer
|
||||
} = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
const selectedId = $derived(selectionManager.getSelectedId())
|
||||
// The agent editor's save guard uses the `tools` array identity to tell "same step" from "step
|
||||
// replaced mid-save", so a module carrying no `tools` must read as one stable array — but a
|
||||
// distinct one per module value, or a wholesale edit that keeps the module id would look
|
||||
@@ -117,6 +119,17 @@
|
||||
forceTestTab?: boolean
|
||||
highlightArg?: string
|
||||
isAgentTool?: boolean
|
||||
/** Offer only values that stand on their own: no connect button, no expression option, no
|
||||
* prop picker column. For a tool of a saved agent, whose inputs are stored on the resource
|
||||
* and so cannot name any one flow's `flow_input` or `results`. A flow binds those on the
|
||||
* step instead, through `tool_inputs`. */
|
||||
staticOnly?: boolean
|
||||
/** Lets the agent's Tools section add a tool through the graph's own insert path. */
|
||||
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
|
||||
/** Drop the tool roster's drill-in. Selecting a tool means selecting its graph node, so a
|
||||
* surface without a graph — the agent editor, which addresses one tool at a time — would
|
||||
* offer a row whose click lands nowhere. */
|
||||
noToolNavigation?: boolean
|
||||
toolDescription?: string | undefined
|
||||
siblingToolNames?: string[]
|
||||
}
|
||||
@@ -135,6 +148,9 @@
|
||||
forceTestTab = false,
|
||||
highlightArg = undefined,
|
||||
isAgentTool = false,
|
||||
staticOnly = false,
|
||||
flowModuleSchemaMap = undefined,
|
||||
noToolNavigation = false,
|
||||
toolDescription = $bindable(undefined),
|
||||
siblingToolNames = undefined
|
||||
}: Props = $props()
|
||||
@@ -270,7 +286,8 @@
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
}
|
||||
}
|
||||
let inputTransformSchemaForm: InputTransformSchemaForm | undefined = $state(undefined)
|
||||
let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined =
|
||||
$state(undefined)
|
||||
|
||||
let reloadError: string | undefined = $state(undefined)
|
||||
async function reload(flowModule: FlowModule) {
|
||||
@@ -338,7 +355,7 @@
|
||||
let stepHistoryLoader = getStepHistoryLoaderContext()
|
||||
|
||||
function onSelectedIdChange() {
|
||||
if (!flowStateStore?.val?.[selectedId]?.schema && flowModule) {
|
||||
if (!flowStateStore?.val?.[flowModule.id]?.schema && flowModule) {
|
||||
reload(flowModule)
|
||||
}
|
||||
}
|
||||
@@ -395,7 +412,7 @@
|
||||
)
|
||||
|
||||
$effect.pre(() => {
|
||||
selectedId && untrack(() => onSelectedIdChange())
|
||||
flowModule.id && untrack(() => onSelectedIdChange())
|
||||
})
|
||||
let parentLoop = $derived(
|
||||
flowStore.val && flowModule ? checkIfParentLoop(flowStore.val, flowModule.id) : undefined
|
||||
@@ -487,7 +504,7 @@
|
||||
}
|
||||
|
||||
let preparedSqlQueries = usePreparedAssetSqlQueries(
|
||||
() => flowGraphAssetsCtx?.val.sqlQueries[selectedId],
|
||||
() => flowGraphAssetsCtx?.val.sqlQueries[flowModule.id],
|
||||
() => opWs
|
||||
)
|
||||
|
||||
@@ -843,7 +860,7 @@
|
||||
on:createScriptFromInlineScript={async () => {
|
||||
const [module, state] = await createScriptFromInlineScript(
|
||||
flowModule,
|
||||
selectedId,
|
||||
flowModule.id,
|
||||
flowStateStore.val[flowModule.id]?.schema,
|
||||
$pathStore,
|
||||
opWs
|
||||
@@ -937,13 +954,11 @@
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
selected = 'test'
|
||||
if (selectedId == flowModule.id) {
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
}}
|
||||
on:change={async (event) => {
|
||||
const content = event.detail
|
||||
@@ -1000,13 +1015,11 @@
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
selected = 'test'
|
||||
if (selectedId == flowModule.id) {
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
}}
|
||||
on:change={async (event) => {
|
||||
const content = event.detail
|
||||
@@ -1093,10 +1106,12 @@
|
||||
}}
|
||||
wrapperClass="shrink-0"
|
||||
>
|
||||
<!-- A tool's inputs are arguments of the tool, not of a step in the flow;
|
||||
`tool_inputs` is what the rest of the codebase calls them. -->
|
||||
{#if !preprocessorModule}
|
||||
<Tab value="inputs" label="Step Input" />
|
||||
<Tab value="inputs" label={isAgentTool ? 'Tool input' : 'Step Input'} />
|
||||
{/if}
|
||||
<Tab value="test" label="Test this step" />
|
||||
<Tab value="test" label={isAgentTool ? 'Test this tool' : 'Test this step'} />
|
||||
{#if canShowChatTab && flowModule.value.type === 'aiagent'}
|
||||
<Tab
|
||||
value="chat"
|
||||
@@ -1114,10 +1129,13 @@
|
||||
</Tabs>
|
||||
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent')}
|
||||
<div class="flex-1 overflow-auto" id="flow-editor-step-input">
|
||||
<!-- `sidePane` under `staticOnly`: that column only opens on a connect,
|
||||
and there is no connect button to open it. -->
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
error={failureModule}
|
||||
noPadding
|
||||
sidePane={staticOnly}
|
||||
>
|
||||
{#if reloadError}
|
||||
<div
|
||||
@@ -1132,6 +1150,7 @@
|
||||
moduleId={linkedToolsModuleId}
|
||||
opWorkspace={opWs}
|
||||
flowPath={$pathStore}
|
||||
fromAgentEditor={agentEditorHost?.() != undefined}
|
||||
bind:agent={
|
||||
() =>
|
||||
flowModule.value.type === 'aiagent'
|
||||
@@ -1177,39 +1196,84 @@
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
<InputTransformSchemaForm
|
||||
class="px-2 xl:px-4 pb-8"
|
||||
bind:this={inputTransformSchemaForm}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
schema={agentLinked
|
||||
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
|
||||
: (flowStateStore.val[selectedId]?.schema ?? {})}
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:args={
|
||||
() => {
|
||||
// @ts-ignore
|
||||
return flowModule?.value?.input_transforms
|
||||
},
|
||||
(v) => {
|
||||
if (
|
||||
typeof flowModule?.value === 'object' &&
|
||||
flowModule?.value !== null
|
||||
) {
|
||||
{#if flowModule.value.type === 'aiagent'}
|
||||
<AiAgentStepInputs
|
||||
class="px-2 xl:px-4 pb-8"
|
||||
bind:this={inputTransformSchemaForm}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
schema={agentLinked
|
||||
? flowLocalAgentSchema(
|
||||
flowStateStore.val[flowModule.id]?.schema ?? {}
|
||||
)
|
||||
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:args={
|
||||
() => {
|
||||
// @ts-ignore
|
||||
flowModule.value.input_transforms = v
|
||||
return flowModule?.value?.input_transforms
|
||||
},
|
||||
(v) => {
|
||||
if (
|
||||
typeof flowModule?.value === 'object' &&
|
||||
flowModule?.value !== null
|
||||
) {
|
||||
// @ts-ignore
|
||||
flowModule.value.input_transforms = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
{enableAi}
|
||||
{isAgentTool}
|
||||
allowedAiTransforms={isAgentTool && flowModule.value.type === 'aiagent'
|
||||
? ['user_message']
|
||||
: undefined}
|
||||
helperScript={retrieveDynCodeAndLang(flowModule.value)}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
/>
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
{enableAi}
|
||||
{isAgentTool}
|
||||
noConnect={staticOnly}
|
||||
noJavascript={staticOnly}
|
||||
allowedAiTransforms={isAgentTool ? AI_AGENT_TOOL_AI_KEYS : undefined}
|
||||
helperScript={retrieveDynCodeAndLang(flowModule.value)}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
visibilityKey={`${$pathStore}:${linkedToolsModuleId}`}
|
||||
tools={flowModule.value.tools ?? []}
|
||||
onSelectTool={noToolNavigation
|
||||
? undefined
|
||||
: (toolId) => selectionManager.selectId(toolId, { openPanel: true })}
|
||||
onAddTool={flowModuleSchemaMap
|
||||
? (detail) =>
|
||||
flowModuleSchemaMap?.addToolToAgent(flowModule.id, detail)
|
||||
: undefined}
|
||||
/>
|
||||
{:else}
|
||||
<InputTransformSchemaForm
|
||||
class="px-2 xl:px-4 pb-8"
|
||||
bind:this={inputTransformSchemaForm}
|
||||
pickableProperties={stepPropPicker.pickableProperties}
|
||||
schema={flowStateStore.val[flowModule.id]?.schema ?? {}}
|
||||
previousModuleId={previousModule?.id}
|
||||
bind:args={
|
||||
() => {
|
||||
// @ts-ignore
|
||||
return flowModule?.value?.input_transforms
|
||||
},
|
||||
(v) => {
|
||||
if (
|
||||
typeof flowModule?.value === 'object' &&
|
||||
flowModule?.value !== null
|
||||
) {
|
||||
// @ts-ignore
|
||||
flowModule.value.input_transforms = v
|
||||
}
|
||||
}
|
||||
}
|
||||
extraLib={stepPropPicker.extraLib}
|
||||
{enableAi}
|
||||
{isAgentTool}
|
||||
noConnect={staticOnly}
|
||||
noJavascript={staticOnly}
|
||||
allowedAiTransforms={undefined}
|
||||
helperScript={retrieveDynCodeAndLang(flowModule.value)}
|
||||
chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false}
|
||||
workspace={opWs}
|
||||
/>
|
||||
{/if}
|
||||
{#if agentLinked}
|
||||
<!-- Linked agent: the resource's tools with their inputs rebindable to this
|
||||
flow; overrides persist on the step as tool_inputs (diff from the resource). -->
|
||||
@@ -1264,8 +1328,8 @@
|
||||
mod={flowModule}
|
||||
{noEditor}
|
||||
schema={agentLinked
|
||||
? flowLocalAgentSchema(flowStateStore.val[selectedId]?.schema ?? {})
|
||||
: (flowStateStore.val[selectedId]?.schema ?? {})}
|
||||
? flowLocalAgentSchema(flowStateStore.val[flowModule.id]?.schema ?? {})
|
||||
: (flowStateStore.val[flowModule.id]?.schema ?? {})}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
@@ -1298,7 +1362,7 @@
|
||||
{isAgentTool}
|
||||
{parentModule}
|
||||
{previousModule}
|
||||
{selectedId}
|
||||
selectedId={flowModule.id}
|
||||
{referencedConcurrentLimit}
|
||||
referencedConcurrencyTimeWindowS={referencedScriptSettings.settings
|
||||
?.concurrency_time_window_s}
|
||||
@@ -1391,7 +1455,7 @@
|
||||
onUpdateMock={(detail) => {
|
||||
flowModule.mock = detail
|
||||
flowModule = flowModule
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}}
|
||||
{testJob}
|
||||
{scriptProgress}
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
forceTestTab?: Record<string, boolean>
|
||||
highlightArg?: Record<string, string | undefined>
|
||||
isAgentTool?: boolean
|
||||
/** Lets an agent step add a tool through the same path as the graph's `+ Tool`. */
|
||||
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -60,7 +62,8 @@
|
||||
previousModule = undefined,
|
||||
forceTestTab,
|
||||
highlightArg,
|
||||
isAgentTool = false
|
||||
isAgentTool = false,
|
||||
flowModuleSchemaMap = undefined
|
||||
}: Props = $props()
|
||||
|
||||
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
|
||||
@@ -222,6 +225,7 @@
|
||||
forceTestTab={forceTestTab?.[flowModule.id]}
|
||||
highlightArg={highlightArg?.[flowModule.id]}
|
||||
{isAgentTool}
|
||||
{flowModuleSchemaMap}
|
||||
/>
|
||||
{/if}
|
||||
{:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'}
|
||||
@@ -232,6 +236,7 @@
|
||||
child
|
||||
)}
|
||||
<FlowModuleWrapper
|
||||
{flowModuleSchemaMap}
|
||||
{noEditor}
|
||||
bind:flowModule={slot.get, slot.set}
|
||||
bind:parentModule={flowModule}
|
||||
@@ -261,6 +266,7 @@
|
||||
child
|
||||
)}
|
||||
<FlowModuleWrapper
|
||||
{flowModuleSchemaMap}
|
||||
{noEditor}
|
||||
bind:flowModule={slot.get, slot.set}
|
||||
bind:parentModule={flowModule}
|
||||
@@ -285,6 +291,7 @@
|
||||
{#each branch.modules as child, index (child.id ?? index)}
|
||||
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
|
||||
<FlowModuleWrapper
|
||||
{flowModuleSchemaMap}
|
||||
{noEditor}
|
||||
bind:flowModule={slot.get, slot.set}
|
||||
bind:parentModule={flowModule}
|
||||
@@ -305,6 +312,7 @@
|
||||
{#each branch.modules as child, index (child.id ?? index)}
|
||||
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
|
||||
<FlowModuleWrapper
|
||||
{flowModuleSchemaMap}
|
||||
{noEditor}
|
||||
bind:flowModule={slot.get, slot.set}
|
||||
bind:parentModule={flowModule}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
|
||||
@@ -8,7 +9,6 @@ import type { StateStore } from '$lib/utils'
|
||||
import { createDeletePlan, removeDeletePlanTools, type DeletePlan } from './flowDeleteUtils'
|
||||
import type { FlowState } from './flowState'
|
||||
import { deleteFlowStateById } from './flowStateUtils.svelte'
|
||||
import { refreshFlowStateStore } from './flowStoreRefresh.svelte'
|
||||
|
||||
export type PreparedDeleteRequest = {
|
||||
plan: DeletePlan
|
||||
@@ -74,7 +74,7 @@ export function executeDeletePlan(
|
||||
deleteFlowStateById(id, args.flowStateStore)
|
||||
}
|
||||
|
||||
refreshFlowStateStore(args.flowStore)
|
||||
refreshStateStore(args.flowStore)
|
||||
|
||||
if (plan.inputIds.length === 1) {
|
||||
args.onDelete?.(plan.targets[0].id)
|
||||
|
||||
@@ -15,29 +15,31 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
output_type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The type of output the AI agent will generate (text or image). Image output requires a configured workspace S3 storage, will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.',
|
||||
'Whether the answer is text or an image. An image needs S3 storage on the workspace, and ignores tools.',
|
||||
enum: ['text', 'image'],
|
||||
default: 'text'
|
||||
},
|
||||
user_message: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The message to give as input to the AI agent. Optional when messages array is provided. You can turn on chat input mode on the input interface to link this field to the message sent by the user.'
|
||||
description: 'The message sent to the agent as the user turn.'
|
||||
},
|
||||
system_prompt: {
|
||||
type: 'string',
|
||||
description: 'The system prompt to give as input to the AI agent.'
|
||||
description: 'Sets how the agent behaves. Sent ahead of everything else.',
|
||||
// The one field people write paragraphs into, so it opens as a text area.
|
||||
minRows: 5,
|
||||
placeholder:
|
||||
"You are a support agent.\nLook up an answer with your tools before replying.\nCite what you used, and say you don't know rather than guessing."
|
||||
},
|
||||
streaming: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to stream the output of the AI agent.',
|
||||
description: 'Stream the answer as it is produced.',
|
||||
default: true,
|
||||
showExpr: "fields.output_type === 'text'"
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Configure how conversation memory is managed. Choose "auto" to let Windmill automatically store and load messages (up to N last messages), or "manual" to provide an explicit array of conversation messages. The system_prompt and user_message are added to the messages if provided.',
|
||||
description: 'History sent between the system message and the user message.',
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
@@ -133,18 +135,17 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
required: ['kind', 'messages']
|
||||
}
|
||||
],
|
||||
showExpr: "fields.output_type === 'text'"
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
},
|
||||
output_schema: {
|
||||
type: 'object',
|
||||
description: 'JSON schema that the AI agent will follow for its response format.',
|
||||
description: 'A JSON schema the answer has to follow.',
|
||||
format: 'json-schema',
|
||||
showExpr: "fields.output_type === 'text'"
|
||||
showExpr: "fields.output_type !== 'image'"
|
||||
},
|
||||
user_attachments: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Array of files (images or PDFs) to give as input to the AI agent. Requires a configured workspace S3 storage.',
|
||||
description: 'Images or PDFs sent with the message. Needs S3 storage on the workspace.',
|
||||
items: {
|
||||
type: 'object',
|
||||
resourceType: 's3object'
|
||||
@@ -152,22 +153,21 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
},
|
||||
max_completion_tokens: {
|
||||
type: 'number',
|
||||
description: 'The maximum number of output tokens.'
|
||||
description: 'The most tokens the answer may use.'
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).',
|
||||
showExpr: "fields.output_type === 'text'"
|
||||
description: 'How random the generation is, from 0 for deterministic up to 2.'
|
||||
},
|
||||
max_iterations: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Limits how many times the agent can loop through reasoning and tool use. Range: 1-1000.',
|
||||
description: 'How many times the agent may loop over calling the model and running tools.',
|
||||
default: 10
|
||||
}
|
||||
},
|
||||
required: ['provider', 'output_type'],
|
||||
// `output_type` defaults to text on the backend, so leaving it unset is valid: the form drops
|
||||
// the row rather than showing a field whose value a run would ignore.
|
||||
required: ['provider'],
|
||||
type: 'object',
|
||||
order: [
|
||||
'provider',
|
||||
|
||||
@@ -92,8 +92,11 @@ async function mapFlowModule(
|
||||
// resource tool ids are not flow-unique and must not key into the flow state.
|
||||
await publishLinkedAgentTools(agentRef, workspace, scope, flowModule.id)
|
||||
} else {
|
||||
// Shape-checked because `tools` is JSON-authored: throwing here would skip the agent's
|
||||
// own state below, leaving it with no schema rather than with no tool schemas.
|
||||
const tools = Array.isArray(value.tools) ? value.tools : []
|
||||
await Promise.all(
|
||||
(value.tools ?? []).filter(isFlowModuleTool).map(async (tool) => {
|
||||
tools.filter(isFlowModuleTool).map(async (tool) => {
|
||||
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool), workspace)
|
||||
})
|
||||
)
|
||||
@@ -122,7 +125,7 @@ export async function publishLinkedAgentTools(
|
||||
const gen = claimLinkedToolsFetch(scope, moduleId)
|
||||
const tools = await resolveLinkedAgentTools(agentRef, workspace)
|
||||
if (linkedToolFetchGen.get(genKey) === gen) {
|
||||
setLinkedAgentTools(scope, moduleId, tools)
|
||||
setLinkedAgentTools(scope, moduleId, tools, agentRef)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,19 @@ import { findNextAvailablePath } from '$lib/path'
|
||||
import type { ExtendedOpenFlow } from './types'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import { loadStoredConfig } from '../aiProviderStorage'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { stepSettingDefaults } from './flowStepSettings'
|
||||
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
|
||||
import {
|
||||
agentToolToFlowModule,
|
||||
createAiAgentTool,
|
||||
createMcpTool,
|
||||
createWebsearchTool,
|
||||
newFlowModuleAgentTool,
|
||||
SPECIAL_TOOL_KINDS,
|
||||
type AgentTool,
|
||||
type SpecialToolKind
|
||||
} from './agentToolUtils'
|
||||
|
||||
export async function loadFlowModuleState(
|
||||
flowModule: FlowModule,
|
||||
@@ -190,7 +203,7 @@ export async function createAiAgent(
|
||||
const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' }
|
||||
|
||||
// A step linked to a saved agent reads its brain and tools from the resource, so it carries only
|
||||
// the flow-local inputs: seeding `provider`/`output_type` would leave transforms it never reads.
|
||||
// the flow-local inputs: seeding `provider` would leave a transform it never reads.
|
||||
const aiAgentFlowModules: FlowModule = {
|
||||
id,
|
||||
value: {
|
||||
@@ -201,8 +214,7 @@ export async function createAiAgent(
|
||||
...(agentPath
|
||||
? {}
|
||||
: {
|
||||
provider: { type: 'static', value: providerValue },
|
||||
output_type: { type: 'static', value: 'text' }
|
||||
provider: { type: 'static', value: providerValue }
|
||||
}),
|
||||
user_message: { type: 'static', value: undefined }
|
||||
}
|
||||
@@ -442,3 +454,157 @@ export async function insertNewFailureModule(
|
||||
|
||||
flowStateStore.val[module.id] = state
|
||||
}
|
||||
|
||||
/** Create a new FlowModule without inserting it into any array. */
|
||||
export async function createNewModule(
|
||||
flowStore: StateStore<OpenFlow>,
|
||||
flowStateStore: StateStore<FlowState>,
|
||||
kind: InsertKind,
|
||||
wsScript?: { path: string; summary: string; hash: string | undefined },
|
||||
wsFlow?: { path: string; summary: string },
|
||||
inlineScript?: InlineScript,
|
||||
agentPath?: string,
|
||||
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
|
||||
workspace?: string,
|
||||
disableAi?: boolean
|
||||
): Promise<FlowModule> {
|
||||
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
|
||||
let state = emptyFlowModuleState()
|
||||
flowStateStore.val[module.id] = state
|
||||
if (wsFlow) {
|
||||
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id, workspace)
|
||||
} else if (wsScript) {
|
||||
;[module, state] = await pickScript(
|
||||
wsScript.path,
|
||||
wsScript.summary,
|
||||
module.id,
|
||||
wsScript.hash,
|
||||
kind,
|
||||
workspace
|
||||
)
|
||||
} else if (kind == 'forloop') {
|
||||
;[module, state] = await createLoop(module.id, !disableAi && get(copilotInfo).enabled)
|
||||
} else if (kind == 'whileloop') {
|
||||
;[module, state] = await createWhileLoop(module.id)
|
||||
} else if (kind == 'branchone') {
|
||||
;[module, state] = await createBranches(module.id)
|
||||
} else if (kind == 'branchall') {
|
||||
;[module, state] = await createBranchAll(module.id)
|
||||
} else if (kind == 'aiagent') {
|
||||
;[module, state] = await createAiAgent(module.id, agentPath)
|
||||
} else if (inlineScript) {
|
||||
const { language, kind, subkind, summary } = inlineScript
|
||||
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
|
||||
flowStateStore.val[module.id] = state
|
||||
if (kind == 'trigger') {
|
||||
module.summary = 'Trigger'
|
||||
} else if (kind == 'approval') {
|
||||
module.summary = 'Approval'
|
||||
}
|
||||
}
|
||||
flowStateStore.val[module.id] = state
|
||||
|
||||
if (kind == 'approval') {
|
||||
module.suspend = stepSettingDefaults('suspend')
|
||||
} else if (kind == 'trigger') {
|
||||
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
|
||||
} else if (kind == 'end') {
|
||||
module.summary = 'Terminate flow'
|
||||
module.stop_after_if = stepSettingDefaults('early-stop', 'end')
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
|
||||
/** Create a module and splice it into `modules`, as a step or as one of an agent's tools. */
|
||||
export async function insertNewModuleAtIndex(
|
||||
flowStore: StateStore<OpenFlow>,
|
||||
flowStateStore: StateStore<FlowState>,
|
||||
modules: FlowModule[] | AgentTool[],
|
||||
index: number,
|
||||
kind: InsertKind,
|
||||
wsScript?: { path: string; summary: string; hash: string | undefined },
|
||||
wsFlow?: { path: string; summary: string },
|
||||
inlineScript?: InlineScript,
|
||||
toolKind?: SpecialToolKind | 'flowmoduleTool',
|
||||
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
|
||||
workspace?: string,
|
||||
disableAi?: boolean
|
||||
): Promise<FlowModule[] | AgentTool[]> {
|
||||
const module = await createNewModule(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
kind,
|
||||
wsScript,
|
||||
wsFlow,
|
||||
inlineScript,
|
||||
undefined,
|
||||
workspace,
|
||||
disableAi
|
||||
)
|
||||
|
||||
if (!modules) return [module]
|
||||
|
||||
if (toolKind === 'mcpTool') {
|
||||
// Create MCP AgentTool
|
||||
const mcpTool = createMcpTool(module.id)
|
||||
;(modules as AgentTool[]).splice(index, 0, mcpTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'websearchTool') {
|
||||
// Create Websearch AgentTool
|
||||
const websearchTool = createWebsearchTool(module.id)
|
||||
;(modules as AgentTool[]).splice(index, 0, websearchTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'aiAgentTool') {
|
||||
// Create AI Agent tool (nested agent)
|
||||
const aiAgentTool = createAiAgentTool(module.id)
|
||||
flowStateStore.val[module.id] = await loadFlowModuleState(
|
||||
agentToolToFlowModule(aiAgentTool),
|
||||
workspace
|
||||
)
|
||||
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'flowmoduleTool') {
|
||||
const agentTool = newFlowModuleAgentTool(module)
|
||||
;(modules as AgentTool[]).splice(index, 0, agentTool)
|
||||
return modules as AgentTool[]
|
||||
} else {
|
||||
// Standard FlowModule insertion
|
||||
modules.splice(index, 0, module)
|
||||
return modules
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one tool to an agent's `tools`, from the picker's detail, and return its id. Shared by the
|
||||
* flow graph and the agent editor, which hold the same tools in different stores.
|
||||
*/
|
||||
export async function insertAgentTool(
|
||||
flowStore: StateStore<OpenFlow>,
|
||||
flowStateStore: StateStore<FlowState>,
|
||||
agentValue: { tools?: AgentTool[] },
|
||||
detail: { kind: string; script?: any; flow?: any; inlineScript?: any },
|
||||
workspace?: string,
|
||||
disableAi?: boolean
|
||||
): Promise<string | undefined> {
|
||||
// `tools` is optional, so a module authored without it starts undefined here and would
|
||||
// silently swallow its first tool.
|
||||
agentValue.tools ??= []
|
||||
const tools = agentValue.tools
|
||||
await insertNewModuleAtIndex(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
tools,
|
||||
tools.length,
|
||||
detail.kind as InsertKind,
|
||||
detail.script,
|
||||
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
|
||||
detail.inlineScript,
|
||||
(SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
|
||||
? (detail.kind as SpecialToolKind)
|
||||
: 'flowmoduleTool',
|
||||
workspace,
|
||||
disableAi
|
||||
)
|
||||
return tools[tools.length - 1]?.id
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import { reanchorAgentEditsAcross } from './agentEditStore.svelte'
|
||||
|
||||
// Use at content-preserving refresh sites (structural edits, schema/failure/mock changes): a bare
|
||||
// refreshStateStore clone would break the agent edit marker's array identity. Content-changing
|
||||
// replacements (undo, YAML/diff/AI apply, session restores) must stay bare so stale edit state
|
||||
// keeps invalidating.
|
||||
export function refreshFlowStateStore(flowStore: StateStore<OpenFlow>) {
|
||||
reanchorAgentEditsAcross(
|
||||
() => flowStore.val.value?.modules,
|
||||
() => refreshStateStore(flowStore)
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export const forbiddenIds: string[] = [
|
||||
'in',
|
||||
'failure',
|
||||
'preprocessor',
|
||||
// The agent editor's synthetic root module. It shares one flow-state map with the agent's
|
||||
// tools, so a tool of this id would take the root's slot and show the agent's own schema.
|
||||
'__wm_agent_root',
|
||||
'as',
|
||||
'Input',
|
||||
'Result',
|
||||
|
||||
@@ -6,6 +6,28 @@ import type { AgentTool } from './agentToolUtils'
|
||||
// keeps flows shown at the same time (an editor and an embedded preview) from aliasing each other.
|
||||
let byScope = $state<Record<string, Record<string, AgentTool[]>>>({})
|
||||
|
||||
// Which agent each entry resolved from, so a write to one agent can be reflected on every step that
|
||||
// links it rather than only the one that triggered the write. Plain, not reactive: it is read
|
||||
// imperatively when reconciling a deploy, never rendered.
|
||||
const agentRefByEntry = new Map<string, string>()
|
||||
|
||||
/** A step may name its agent bare or as `$res:<path>`; both are the same agent, and the index has
|
||||
* to answer for a lookup written either way. */
|
||||
function normalizeAgentRef(agentRef: string): string {
|
||||
return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
|
||||
}
|
||||
|
||||
function entryKey(scope: string, moduleId: string): string {
|
||||
return `${scope}\u0000${moduleId}`
|
||||
}
|
||||
|
||||
function forgetScopeRefs(scope: string) {
|
||||
const prefix = `${scope}\u0000`
|
||||
for (const key of agentRefByEntry.keys()) {
|
||||
if (key.startsWith(prefix)) agentRefByEntry.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// A long-lived tab would otherwise keep every flow it ever visited (raw tool script contents
|
||||
// included). Past this cap the least recently used scope is evicted, skipping any a mounted view
|
||||
// still holds.
|
||||
@@ -61,6 +83,7 @@ function evictOverCap(protect?: string) {
|
||||
}
|
||||
scopeOrder = scopeOrder.filter((s) => s !== victim)
|
||||
delete byScope[victim]
|
||||
forgetScopeRefs(victim)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +108,16 @@ export function linkedToolsScope(
|
||||
// track this to re-run when a link resolves after the initial render, e.g. right after linking.
|
||||
let version = $state(0)
|
||||
|
||||
export function setLinkedAgentTools(scope: string, moduleId: string, tools: AgentTool[]) {
|
||||
export function setLinkedAgentTools(
|
||||
scope: string,
|
||||
moduleId: string,
|
||||
tools: AgentTool[],
|
||||
/** The agent these tools came from. Taken here rather than recorded separately so the index and
|
||||
* the tools cannot drift: every writer has the ref in hand, and one that forgot to file it left
|
||||
* a step invisible to `linkedModulesForAgent`. */
|
||||
agentRef: string
|
||||
) {
|
||||
agentRefByEntry.set(entryKey(scope, moduleId), normalizeAgentRef(agentRef))
|
||||
// Publishers re-run and hand us a fresh-but-equal array each time; only mutate on a real change,
|
||||
// else the version bump would retrigger the graph recompute in a loop.
|
||||
if (deepEqual(byScope[scope]?.[moduleId], tools)) return
|
||||
@@ -94,12 +126,29 @@ export function setLinkedAgentTools(scope: string, moduleId: string, tools: Agen
|
||||
version++
|
||||
}
|
||||
|
||||
/** Every module in this scope resolved from `agentRef`. A saved agent can be linked by more than
|
||||
* one step, and all of them show tools that a write to it has just changed. */
|
||||
export function linkedModulesForAgent(scope: string, agentRef: string): string[] {
|
||||
const prefix = `${scope}\u0000`
|
||||
const wanted = normalizeAgentRef(agentRef)
|
||||
const out: string[] = []
|
||||
for (const [key, ref] of agentRefByEntry) {
|
||||
if (key.startsWith(prefix) && ref === wanted) out.push(key.slice(prefix.length))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Move one scope's resolutions into another: used when a rename moves readers to a new scope,
|
||||
* and to sweep republished data (keyed by the flow doc's path) into the live-edited scope. The
|
||||
* source bucket carries the newer resolution in both cases, so it wins the merge. */
|
||||
export function migrateLinkedAgentToolsScope(oldScope: string, newScope: string) {
|
||||
if (oldScope === newScope || byScope[oldScope] === undefined) return
|
||||
byScope[newScope] = { ...(byScope[newScope] ?? {}), ...(byScope[oldScope] ?? {}) }
|
||||
for (const moduleId of Object.keys(byScope[oldScope] ?? {})) {
|
||||
const ref = agentRefByEntry.get(entryKey(oldScope, moduleId))
|
||||
if (ref !== undefined) agentRefByEntry.set(entryKey(newScope, moduleId), ref)
|
||||
}
|
||||
forgetScopeRefs(oldScope)
|
||||
delete byScope[oldScope]
|
||||
pendingMigration = newScope
|
||||
// Mark the new key most-recently-used but don't evict here: the next publish or release enforces
|
||||
@@ -109,6 +158,7 @@ export function migrateLinkedAgentToolsScope(oldScope: string, newScope: string)
|
||||
}
|
||||
|
||||
export function clearLinkedAgentTools(scope: string, moduleId: string) {
|
||||
agentRefByEntry.delete(entryKey(scope, moduleId))
|
||||
if (byScope[scope]?.[moduleId] === undefined) return
|
||||
const rest = { ...byScope[scope] }
|
||||
delete rest[moduleId]
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearLinkedAgentTools,
|
||||
getLinkedAgentTools,
|
||||
linkedModulesForAgent,
|
||||
linkedToolsScope,
|
||||
migrateLinkedAgentToolsScope,
|
||||
releaseLinkedToolsScope,
|
||||
@@ -26,7 +27,7 @@ function freshScope(name = 'flow') {
|
||||
function fillPastCap(exclude: string) {
|
||||
for (let i = 0; i <= MAX_SCOPES; i++) {
|
||||
const filler = linkedToolsScope('filler', `${exclude}-${seq}-${i}`)
|
||||
setLinkedAgentTools(filler, 'm', [tool(`f${i}`)])
|
||||
setLinkedAgentTools(filler, 'm', [tool(`f${i}`)], 'u/admin/a')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +35,15 @@ describe('linkedAgentToolsStore', () => {
|
||||
it('keeps each scope module map separate', () => {
|
||||
const a = freshScope()
|
||||
const b = freshScope()
|
||||
setLinkedAgentTools(a, 'step', [tool('x')])
|
||||
setLinkedAgentTools(b, 'step', [tool('y')])
|
||||
setLinkedAgentTools(a, 'step', [tool('x')], 'u/admin/a')
|
||||
setLinkedAgentTools(b, 'step', [tool('y')], 'u/admin/a')
|
||||
expect(getLinkedAgentTools(a, 'step').map((t) => t.id)).toEqual(['x'])
|
||||
expect(getLinkedAgentTools(b, 'step').map((t) => t.id)).toEqual(['y'])
|
||||
})
|
||||
|
||||
it('evicts an unretained scope once past the cap', () => {
|
||||
const victim = freshScope()
|
||||
setLinkedAgentTools(victim, 'step', [tool('x')])
|
||||
setLinkedAgentTools(victim, 'step', [tool('x')], 'u/admin/a')
|
||||
fillPastCap(victim)
|
||||
expect(getLinkedAgentTools(victim, 'step')).toEqual([])
|
||||
})
|
||||
@@ -50,7 +51,7 @@ describe('linkedAgentToolsStore', () => {
|
||||
// A run viewer holds one scope per mounted nested job; those must not evict what is on screen.
|
||||
it('never evicts a retained scope', () => {
|
||||
const held = freshScope()
|
||||
setLinkedAgentTools(held, 'step', [tool('x')])
|
||||
setLinkedAgentTools(held, 'step', [tool('x')], 'u/admin/a')
|
||||
retainLinkedToolsScope(held)
|
||||
fillPastCap(held)
|
||||
expect(getLinkedAgentTools(held, 'step').map((t) => t.id)).toEqual(['x'])
|
||||
@@ -63,7 +64,7 @@ describe('linkedAgentToolsStore', () => {
|
||||
const held: string[] = []
|
||||
for (let i = 0; i <= MAX_SCOPES; i++) {
|
||||
const scope = linkedToolsScope('retained', `${seq}-${i}`)
|
||||
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
|
||||
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)], 'u/admin/a')
|
||||
retainLinkedToolsScope(scope)
|
||||
held.push(scope)
|
||||
}
|
||||
@@ -84,7 +85,7 @@ describe('linkedAgentToolsStore', () => {
|
||||
it('carries tools across a rename without evicting the new scope', () => {
|
||||
const from = freshScope('old')
|
||||
const to = freshScope('new')
|
||||
setLinkedAgentTools(from, 'step', [tool('x')])
|
||||
setLinkedAgentTools(from, 'step', [tool('x')], 'u/admin/a')
|
||||
retainLinkedToolsScope(from)
|
||||
migrateLinkedAgentToolsScope(from, to)
|
||||
expect(getLinkedAgentTools(to, 'step').map((t) => t.id)).toEqual(['x'])
|
||||
@@ -98,13 +99,13 @@ describe('linkedAgentToolsStore', () => {
|
||||
const held: string[] = []
|
||||
for (let i = 0; i < MAX_SCOPES; i++) {
|
||||
const scope = linkedToolsScope('rename-fill', `${seq}-${i}`)
|
||||
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)])
|
||||
setLinkedAgentTools(scope, 'step', [tool(`t${i}`)], 'u/admin/a')
|
||||
retainLinkedToolsScope(scope)
|
||||
held.push(scope)
|
||||
}
|
||||
const from = freshScope('before')
|
||||
const to = freshScope('after')
|
||||
setLinkedAgentTools(from, 'step', [tool('x')])
|
||||
setLinkedAgentTools(from, 'step', [tool('x')], 'u/admin/a')
|
||||
retainLinkedToolsScope(from)
|
||||
|
||||
migrateLinkedAgentToolsScope(from, to)
|
||||
@@ -121,10 +122,39 @@ describe('linkedAgentToolsStore', () => {
|
||||
|
||||
it('clears one module without disturbing its siblings', () => {
|
||||
const scope = freshScope()
|
||||
setLinkedAgentTools(scope, 'a', [tool('x')])
|
||||
setLinkedAgentTools(scope, 'b', [tool('y')])
|
||||
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/a')
|
||||
setLinkedAgentTools(scope, 'b', [tool('y')], 'u/admin/a')
|
||||
clearLinkedAgentTools(scope, 'a')
|
||||
expect(getLinkedAgentTools(scope, 'a')).toEqual([])
|
||||
expect(getLinkedAgentTools(scope, 'b').map((t) => t.id)).toEqual(['y'])
|
||||
})
|
||||
|
||||
// A deploy has to refresh every step showing the agent it wrote, so this index is what makes the
|
||||
// difference between one step's nodes updating and all of them.
|
||||
describe('linkedModulesForAgent', () => {
|
||||
it('finds every module linked to one agent, and no others', () => {
|
||||
const scope = freshScope()
|
||||
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/one')
|
||||
setLinkedAgentTools(scope, 'b', [tool('y')], 'u/admin/one')
|
||||
setLinkedAgentTools(scope, 'c', [tool('z')], 'u/admin/other')
|
||||
expect(linkedModulesForAgent(scope, 'u/admin/one').sort()).toEqual(['a', 'b'])
|
||||
expect(linkedModulesForAgent(scope, 'u/admin/other')).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('reads a $res-prefixed link and a bare path as the same agent', () => {
|
||||
const scope = freshScope()
|
||||
setLinkedAgentTools(scope, 'a', [tool('x')], '$res:u/admin/one')
|
||||
expect(linkedModulesForAgent(scope, 'u/admin/one')).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('drops a module that was cleared, and stays scoped', () => {
|
||||
const scope = freshScope()
|
||||
const other = freshScope()
|
||||
setLinkedAgentTools(scope, 'a', [tool('x')], 'u/admin/one')
|
||||
setLinkedAgentTools(other, 'a', [tool('x')], 'u/admin/one')
|
||||
clearLinkedAgentTools(scope, 'a')
|
||||
expect(linkedModulesForAgent(scope, 'u/admin/one')).toEqual([])
|
||||
expect(linkedModulesForAgent(other, 'u/admin/one')).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('linked tools fetch guard', () => {
|
||||
)
|
||||
|
||||
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
|
||||
setLinkedAgentTools(scope, 'step', [tool('kept')])
|
||||
setLinkedAgentTools(scope, 'step', [tool('kept')], 'u/admin/a')
|
||||
|
||||
invalidateLinkedToolsFetches(scope)
|
||||
release?.({ value: { tools: [tool('stale')] } })
|
||||
@@ -75,7 +75,7 @@ describe('linked tools fetch guard', () => {
|
||||
|
||||
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
|
||||
claimLinkedToolsFetch(scope, 'step')
|
||||
setLinkedAgentTools(scope, 'step', [tool('direct')])
|
||||
setLinkedAgentTools(scope, 'step', [tool('direct')], 'u/admin/a')
|
||||
release?.({ value: { tools: [tool('stale')] } })
|
||||
await inFlight
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { FlowDiffManager } from '../flowDiffManager.svelte'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Bug, X } from 'lucide-svelte'
|
||||
@@ -7,7 +8,6 @@
|
||||
import { insertNewFailureModule } from '$lib/components/flows/flowStateUtils.svelte'
|
||||
import type { RawScript, ScriptLang } from '$lib/gen'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import DiffActionBar from './DiffActionBar.svelte'
|
||||
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
selectionManager.selectId('failure')
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
|
||||
function deleteFailureModule() {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { createEventDispatcher, getContext, tick } from 'svelte'
|
||||
import {
|
||||
createInlineScriptModule,
|
||||
createBranchAll,
|
||||
createBranches,
|
||||
createLoop,
|
||||
createWhileLoop,
|
||||
emptyModule,
|
||||
pickScript,
|
||||
pickFlow,
|
||||
insertNewPreprocessorModule,
|
||||
createAiAgent
|
||||
createNewModule as createNewModuleIn,
|
||||
insertNewModuleAtIndex as insertNewModuleAt,
|
||||
insertAgentTool
|
||||
} from '$lib/components/flows/flowStateUtils.svelte'
|
||||
import type { FlowModule, Job, ScriptLang } from '$lib/gen'
|
||||
import { emptyFlowModuleState } from '../utils.svelte'
|
||||
import { stepSettingDefaults } from '../flowStepSettings'
|
||||
|
||||
import { dfs } from '../dfs'
|
||||
import { nextId, copyId } from '../flowModuleNextId'
|
||||
@@ -27,7 +21,6 @@
|
||||
|
||||
import { locateModules, groupByParent } from '../multiSelectUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
|
||||
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
|
||||
import { replaceId } from '../flowStore.svelte'
|
||||
@@ -38,23 +31,12 @@
|
||||
import { addBranch as addBranchOp, removeBranch as removeBranchOp } from '../branchOps'
|
||||
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
|
||||
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
|
||||
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
|
||||
import type { GraphModuleState } from '$lib/components/graph'
|
||||
import FlowStickyNode from './FlowStickyNode.svelte'
|
||||
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import {
|
||||
type AgentTool,
|
||||
type SpecialToolKind,
|
||||
newFlowModuleAgentTool,
|
||||
createMcpTool,
|
||||
createWebsearchTool,
|
||||
createAiAgentTool,
|
||||
SPECIAL_TOOL_KINDS,
|
||||
agentToolToFlowModule
|
||||
} from '../agentToolUtils'
|
||||
import { loadFlowModuleState } from '../flowStateUtils.svelte'
|
||||
import { type AgentTool, type SpecialToolKind } from '../agentToolUtils'
|
||||
import type { DeletePlan } from '../flowDeleteUtils'
|
||||
import { executeDeletePlan, prepareDeleteRequest } from '../flowDeleteController'
|
||||
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
|
||||
@@ -161,62 +143,56 @@
|
||||
})
|
||||
|
||||
/** Create a new FlowModule without inserting it into any array */
|
||||
async function createNewModule(
|
||||
function createNewModule(
|
||||
kind: InsertKind,
|
||||
wsScript?: { path: string; summary: string; hash: string | undefined },
|
||||
wsFlow?: { path: string; summary: string },
|
||||
inlineScript?: InlineScript,
|
||||
agentPath?: string
|
||||
): Promise<FlowModule> {
|
||||
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
|
||||
let state = emptyFlowModuleState()
|
||||
flowStateStore.val[module.id] = state
|
||||
if (wsFlow) {
|
||||
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id, opWs)
|
||||
} else if (wsScript) {
|
||||
;[module, state] = await pickScript(
|
||||
wsScript.path,
|
||||
wsScript.summary,
|
||||
module.id,
|
||||
wsScript.hash,
|
||||
kind,
|
||||
opWs
|
||||
)
|
||||
} else if (kind == 'forloop') {
|
||||
;[module, state] = await createLoop(module.id, !disableAi && $copilotInfo.enabled)
|
||||
} else if (kind == 'whileloop') {
|
||||
;[module, state] = await createWhileLoop(module.id)
|
||||
} else if (kind == 'branchone') {
|
||||
;[module, state] = await createBranches(module.id)
|
||||
} else if (kind == 'branchall') {
|
||||
;[module, state] = await createBranchAll(module.id)
|
||||
} else if (kind == 'aiagent') {
|
||||
;[module, state] = await createAiAgent(module.id, agentPath)
|
||||
} else if (inlineScript) {
|
||||
const { language, kind, subkind, summary } = inlineScript
|
||||
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
|
||||
flowStateStore.val[module.id] = state
|
||||
if (kind == 'trigger') {
|
||||
module.summary = 'Trigger'
|
||||
} else if (kind == 'approval') {
|
||||
module.summary = 'Approval'
|
||||
}
|
||||
}
|
||||
flowStateStore.val[module.id] = state
|
||||
|
||||
if (kind == 'approval') {
|
||||
module.suspend = stepSettingDefaults('suspend')
|
||||
} else if (kind == 'trigger') {
|
||||
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
|
||||
} else if (kind == 'end') {
|
||||
module.summary = 'Terminate flow'
|
||||
module.stop_after_if = stepSettingDefaults('early-stop', 'end')
|
||||
}
|
||||
|
||||
return module
|
||||
return createNewModuleIn(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
kind,
|
||||
wsScript,
|
||||
wsFlow,
|
||||
inlineScript,
|
||||
agentPath,
|
||||
opWs,
|
||||
disableAi
|
||||
)
|
||||
}
|
||||
|
||||
export async function insertNewModuleAtIndex(
|
||||
/**
|
||||
* Add a tool to an agent, from the graph's own `+ Tool` or from a surface that has no graph
|
||||
* node to click — the agent step's Tools section. Kept here because it needs the map's history,
|
||||
* id allocation and `flowStateStore` seeding.
|
||||
*/
|
||||
export async function addToolToAgent(
|
||||
agentId: string,
|
||||
detail: { kind: string; script?: any; flow?: any; inlineScript?: any }
|
||||
) {
|
||||
push(history, flowStore.val)
|
||||
const agentMod = findModuleInFlow(flowStore.val.value, agentId)
|
||||
const agentValue = agentMod?.value as { tools?: AgentTool[] } | undefined
|
||||
if (agentValue) {
|
||||
const id = await insertAgentTool(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
agentValue,
|
||||
detail,
|
||||
opWs,
|
||||
disableAi
|
||||
)
|
||||
// Reveal the new tool's config right away — in modal (unanchored) panel mode its editor
|
||||
// is otherwise hidden behind the graph.
|
||||
if (id) selectionManager.selectId(id, { openPanel: true })
|
||||
}
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
}
|
||||
|
||||
export function insertNewModuleAtIndex(
|
||||
modules: FlowModule[] | AgentTool[],
|
||||
index: number,
|
||||
kind: InsertKind,
|
||||
@@ -226,38 +202,19 @@
|
||||
toolKind?: SpecialToolKind | 'flowmoduleTool'
|
||||
): Promise<FlowModule[] | AgentTool[]> {
|
||||
push(history, flowStore.val)
|
||||
const module = await createNewModule(kind, wsScript, wsFlow, inlineScript)
|
||||
|
||||
if (!modules) return [module]
|
||||
|
||||
if (toolKind === 'mcpTool') {
|
||||
// Create MCP AgentTool
|
||||
const mcpTool = createMcpTool(module.id)
|
||||
;(modules as AgentTool[]).splice(index, 0, mcpTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'websearchTool') {
|
||||
// Create Websearch AgentTool
|
||||
const websearchTool = createWebsearchTool(module.id)
|
||||
;(modules as AgentTool[]).splice(index, 0, websearchTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'aiAgentTool') {
|
||||
// Create AI Agent tool (nested agent)
|
||||
const aiAgentTool = createAiAgentTool(module.id)
|
||||
flowStateStore.val[module.id] = await loadFlowModuleState(
|
||||
agentToolToFlowModule(aiAgentTool),
|
||||
opWs
|
||||
)
|
||||
;(modules as AgentTool[]).splice(index, 0, aiAgentTool)
|
||||
return modules as AgentTool[]
|
||||
} else if (toolKind === 'flowmoduleTool') {
|
||||
const agentTool = newFlowModuleAgentTool(module)
|
||||
;(modules as AgentTool[]).splice(index, 0, agentTool)
|
||||
return modules as AgentTool[]
|
||||
} else {
|
||||
// Standard FlowModule insertion
|
||||
modules.splice(index, 0, module)
|
||||
return modules
|
||||
}
|
||||
return insertNewModuleAt(
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
modules,
|
||||
index,
|
||||
kind,
|
||||
wsScript,
|
||||
wsFlow,
|
||||
inlineScript,
|
||||
toolKind,
|
||||
opWs,
|
||||
disableAi
|
||||
)
|
||||
}
|
||||
|
||||
let sidebarMode: 'list' | 'graph' = 'graph'
|
||||
@@ -409,7 +366,7 @@
|
||||
parentArr.splice(lastIndex + 1, 0, ...clones)
|
||||
}
|
||||
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectByIds(allCloneIds)
|
||||
}
|
||||
|
||||
@@ -669,7 +626,7 @@
|
||||
selectionManager.selectId(movingId)
|
||||
}
|
||||
moveManager.clearMoving()
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
}
|
||||
|
||||
@@ -705,48 +662,19 @@
|
||||
instructions: detail.inlineScript?.instructions
|
||||
})
|
||||
}
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
return
|
||||
}
|
||||
|
||||
// Agent tool inserts operate on the FlowModule's tools array directly
|
||||
if (detail.agentId) {
|
||||
await addToolToAgent(detail.agentId, detail)
|
||||
return
|
||||
}
|
||||
|
||||
push(history, flowStore.val)
|
||||
|
||||
const isAgentInsert = !!detail.agentId
|
||||
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert
|
||||
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
|
||||
? (detail.kind as SpecialToolKind)
|
||||
: 'flowmoduleTool'
|
||||
: undefined
|
||||
|
||||
// Agent tool inserts operate on the FlowModule's tools array directly
|
||||
if (isAgentInsert) {
|
||||
const agentMod = findModuleInFlow(flowStore.val.value, detail.agentId!)
|
||||
const agentValue = agentMod?.value as { tools?: AgentTool[] } | undefined
|
||||
if (agentValue) {
|
||||
// `tools` is optional, so a module authored without it starts undefined here and
|
||||
// would silently swallow its first tool.
|
||||
agentValue.tools ??= []
|
||||
const tools = agentValue.tools
|
||||
await insertNewModuleAtIndex(
|
||||
tools,
|
||||
tools.length,
|
||||
detail.kind as InsertKind,
|
||||
detail.script,
|
||||
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
|
||||
detail.inlineScript,
|
||||
toolKind
|
||||
)
|
||||
const id = tools[tools.length - 1].id
|
||||
// Reveal the new tool's config right away — in modal (unanchored)
|
||||
// panel mode its editor is otherwise hidden behind the graph.
|
||||
selectionManager.selectId(id, { openPanel: true })
|
||||
}
|
||||
refreshFlowStateStore(flowStore)
|
||||
dispatch('change')
|
||||
return
|
||||
}
|
||||
|
||||
// Regular module insert: create the module, then insert a leaf node via tree mutation
|
||||
const module = await createNewModule(
|
||||
detail.kind as InsertKind,
|
||||
@@ -823,13 +751,13 @@
|
||||
if (['branchone', 'branchall'].includes(detail.kind)) {
|
||||
await addBranch(module.id)
|
||||
}
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('change')
|
||||
}}
|
||||
onNewBranch={async (id) => {
|
||||
if (id) {
|
||||
await addBranch(id)
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}
|
||||
}}
|
||||
onSelect={(id) => {
|
||||
@@ -883,13 +811,13 @@
|
||||
}
|
||||
flowStateStore.val[newId] = flowStateStore.val[id]
|
||||
delete flowStateStore.val[id]
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectId(newId)
|
||||
}}
|
||||
onDeleteBranch={async ({ id, index }) => {
|
||||
if (id) {
|
||||
await removeBranch(id, index)
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectId(id)
|
||||
}
|
||||
}}
|
||||
@@ -928,7 +856,7 @@
|
||||
})
|
||||
|
||||
targetModules.splice(targetIndex + 1, 0, clone)
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
selectionManager.selectId(clone.id, { openPanel: true })
|
||||
}}
|
||||
onUpdateMock={(detail) => {
|
||||
@@ -937,7 +865,7 @@
|
||||
throw new Error(`Node ${detail.id} not found`)
|
||||
}
|
||||
module.mock = $state.snapshot(detail.mock)
|
||||
refreshFlowStateStore(flowStore)
|
||||
refreshStateStore(flowStore)
|
||||
}}
|
||||
{onTestFlow}
|
||||
{isRunning}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user