diff --git a/backend/.sqlx/query-00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236.json b/backend/.sqlx/query-0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63.json similarity index 78% rename from backend/.sqlx/query-00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236.json rename to backend/.sqlx/query-0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63.json index f950c5bf57..da25d2fcf8 100644 --- a/backend/.sqlx/query-00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236.json +++ b/backend/.sqlx/query-0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3", + "query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3", "describe": { "columns": [ { @@ -20,5 +20,5 @@ false ] }, - "hash": "00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236" + "hash": "0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63" } diff --git a/backend/.sqlx/query-dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314.json b/backend/.sqlx/query-139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08.json similarity index 76% rename from backend/.sqlx/query-dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314.json rename to backend/.sqlx/query-139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08.json index 37115e4ab7..780fb75ea9 100644 --- a/backend/.sqlx/query-dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314.json +++ b/backend/.sqlx/query-139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3", + "query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3", "describe": { "columns": [ { @@ -20,5 +20,5 @@ false ] }, - "hash": "dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314" + "hash": "139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08" } diff --git a/backend/.sqlx/query-2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887.json b/backend/.sqlx/query-2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887.json new file mode 100644 index 0000000000..919d930f70 --- /dev/null +++ b/backend/.sqlx/query-2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887" +} diff --git a/backend/.sqlx/query-46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf.json b/backend/.sqlx/query-46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf.json new file mode 100644 index 0000000000..9b361b8f53 --- /dev/null +++ b/backend/.sqlx/query-46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru\n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf" +} diff --git a/backend/.sqlx/query-a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e.json b/backend/.sqlx/query-a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e.json deleted file mode 100644 index 4e3b39403f..0000000000 --- a/backend/.sqlx/query-a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e" -} diff --git a/backend/.sqlx/query-fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71.json b/backend/.sqlx/query-fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71.json new file mode 100644 index 0000000000..6a4b3fe6de --- /dev/null +++ b/backend/.sqlx/query-fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71" +} diff --git a/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.down.sql b/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.down.sql new file mode 100644 index 0000000000..c749fbe1b7 --- /dev/null +++ b/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.down.sql @@ -0,0 +1,9 @@ +DELETE FROM workspace_runnable_dependencies WHERE runnable_is_agent; + +DROP INDEX flow_workspace_without_hash_unique_idx; + +CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx + ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, workspace_id) + WHERE script_hash IS NULL; + +ALTER TABLE workspace_runnable_dependencies DROP COLUMN runnable_is_agent; diff --git a/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.up.sql b/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.up.sql new file mode 100644 index 0000000000..eab08e5c17 --- /dev/null +++ b/backend/migrations/20260915111928_record_linked_agents_as_runnable_dependencies.up.sql @@ -0,0 +1,21 @@ +-- A flow step linked to a saved agent (an `ai_agent` resource) is recorded next to the scripts and +-- subflows the flow runs, so renaming the agent can name the flows it would break. An agent row is +-- neither a script nor a flow: readers of script usages have to exclude it. +ALTER TABLE workspace_runnable_dependencies + ADD COLUMN runnable_is_agent BOOLEAN NOT NULL DEFAULT false; + +-- A script step and a linked agent can share a path. Without the flag in the key, the second +-- insert's ON CONFLICT DO NOTHING would silently drop one of the two rows. +DROP INDEX flow_workspace_without_hash_unique_idx; + +CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx + ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) + WHERE script_hash IS NULL; + +-- The worker only records a flow when it is next deployed, so seed the ones already linking an +-- agent from their current value. +INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) +SELECT DISTINCT f.path, agent_ref #>> '{}', false, true, f.workspace_id +FROM flow f +CROSS JOIN LATERAL jsonb_path_query(f.value, 'lax $.** ? (@.type == "aiagent" && @.agent.type() == "string").agent') AS agent_ref +ON CONFLICT DO NOTHING; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index b93a4bf6fc..1e27ee1ce4 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -232,7 +232,7 @@ workspace_key: workspace_id(char), kind(workspace_key_kind), key(char) FK: (workspace_id) -> workspace(id) workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts) 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), id(bigint) +workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool) 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), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int) FK: (workspace_id) -> workspace(id) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 2093ac5fec..525e3cca46 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -77,6 +77,10 @@ pub fn workspaced_service() -> Router { "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) + .route( + "/list_paths_linking_agent/{*path}", + get(list_paths_linking_agent), + ) .route("/history_update/v/{version}", post(update_flow_history)) .route("/get/v/{version}", get(get_flow_version_by_id)) .route("/get/v/{version}/p/{*path}", get(get_flow_version)) @@ -508,7 +512,7 @@ async fn list_paths_from_workspace_runnable( FROM workspace_runnable_dependencies wru JOIN flow f ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id - WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, + WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#, path, matches!(runnable_kind, RunnableKind::Flow), w_id @@ -521,7 +525,7 @@ async fn list_paths_from_workspace_runnable( FROM workspace_runnable_dependencies wru JOIN flow f ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id - WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, + WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#, path, matches!(runnable_kind, RunnableKind::Flow), w_id @@ -534,6 +538,30 @@ async fn list_paths_from_workspace_runnable( Ok(Json(runnables)) } +/// Flows with a step linked to the `ai_agent` resource at `path`, as of their last deploy. +async fn list_paths_linking_agent( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("flows:read:agent/{}", path))?; + let mut tx = user_db.begin(&authed).await?; + let flows = sqlx::query_scalar!( + r#"SELECT DISTINCT f.path + FROM workspace_runnable_dependencies wru + JOIN flow f + ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id + WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2"#, + path, + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(flows)) +} + async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { #[cfg(not(feature = "enterprise"))] if new_flow.ws_error_handler_muted.is_some_and(|val| val) { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3330ed697f..3e48e7baca 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7471,8 +7471,8 @@ async fn clone_workspace_runnable_dependencies( ) -> Result<()> { // Clone workspace_runnable_dependencies sqlx::query!( - "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path) - SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path + "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path) + SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path FROM workspace_runnable_dependencies WHERE workspace_id = $2", target_workspace_id, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c88a298646..8f188bf763 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12103,6 +12103,25 @@ paths: items: type: string + /w/{workspace}/flows/list_paths_linking_agent/{path}: + get: + summary: list flow paths with a step linked to a saved agent + operationId: listFlowPathsLinkingAgent + tags: + - flow + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: paths of the flows linking the `ai_agent` resource, as of their last deploy + content: + application/json: + schema: + type: array + items: + type: string + /w/{workspace}/flows/get/v/{version}: get: summary: get flow version diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 1f3b9f1d56..03150beb00 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1795,6 +1795,17 @@ async fn lock_modules( agent, tool_inputs, } => { + if let Some(agent_path) = agent.as_deref().filter(|_| !skip_flow_update) { + sqlx::query!( + "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING", + job_path, + agent_path, + job.workspace_id, + ) + .execute(db) + .await?; + } + // Extract FlowModules from tools and track their original indices // MCP tools don't need locking, so we filter them out let mut flow_modules = Vec::new(); diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6888c61f2d..97b871921c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -510,8 +510,8 @@ draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)]) ) // The path is passed, so a draft that renames the agent is refused here too: a rename is - // the resource editor's to deploy, and this dialog lists the agent under the path the - // flow links. + // the agent editor's to deploy, and this dialog lists the agent under the path the flow + // links. agentRefusal = Object.fromEntries( draftAgents.map((a) => [a.path, agentDraftDeployRefusal(a.state, a.path)]) ) diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index b45d980246..ee87cc9511 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -30,6 +30,7 @@ import { createEventDispatcher, getContext, untrack } from 'svelte' import { writable } from 'svelte/store' import { Alert, Button } from './common' + import { overlayStack, type OverlayStack } from './common/overlayHost.svelte' import { random_adj } from './random_positive_adjetive' import { ChevronDown, Copy, SearchCode } from 'lucide-svelte' import Tooltip from './Tooltip.svelte' @@ -405,9 +406,11 @@ } }) - const openSearchWithPrefilledText: (t?: string) => void = getContext( + const openSearchWithPrefilledText: (t?: string, stack?: OverlayStack) => void = getContext( 'openSearchWithPrefilledText' ) + // Handed to the search so it stacks above the modal or drawer this field sits in. + const searchStack = overlayStack() $effect.pre(() => { ;[meta?.name, meta?.owner, meta?.ownerKind] @@ -451,14 +454,18 @@ initialPath !== path ) let pathUsageInFlowsPromise = $derived( - (kind == 'script' || kind == 'flow') && - ws && - initialPath && - FlowService.listFlowPathsFromWorkspaceRunnable({ - workspace: ws, - path: initialPath, - runnableKind: kind - }) + ws && initialPath + ? kind == 'script' || kind == 'flow' + ? FlowService.listFlowPathsFromWorkspaceRunnable({ + workspace: ws, + path: initialPath, + runnableKind: kind + }) + : kind == 'resource' + ? // Only steps linked to a saved agent are tracked; other `$res:` references are not. + FlowService.listFlowPathsLinkingAgent({ workspace: ws, path: initialPath }) + : undefined + : undefined ) let pathUsageInAppsPromise = $derived( (kind == 'script' || kind == 'flow') && @@ -647,24 +654,36 @@ {/if} + {:else if displayPathChangedWarning && kind == 'resource'} + {@render renameMayBreakWarning()} + {/if} + {:catch} + + {#if displayPathChangedWarning && kind == 'resource'} + {@render renameMayBreakWarning()} {/if} {/await} {:else if displayPathChangedWarning} - - You are renaming an item that may be depended upon by other items. This may break apps, flows - or resources. Find if it used elsewhere using the content search. Note that linked variables - and resources (having the same path) are automatically moved together. -
- -
-
+ {@render renameMayBreakWarning()} {/if} + +{#snippet renameMayBreakWarning()} + + You are renaming an item that may be depended upon by other items. This may break apps, flows or + resources. Find if it used elsewhere using the content search. Note that linked variables and + resources (having the same path) are automatically moved together. +
+ +
+
+{/snippet} diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 73482817ec..bf25a1dcda 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -4,6 +4,7 @@ import FlowEditorPanel from './content/FlowEditorPanel.svelte' import { agentEditorTarget, type AgentEditorTarget } from './agentEditorStore.svelte' import AgentEditorModal from './content/AgentEditorModal.svelte' + import { repointLinkedAgent } from './linkedAgentDrafts' import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte' import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte' import WindmillIcon from '../icons/WindmillIcon.svelte' @@ -551,4 +552,5 @@ t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace} + onRenamed={(from, to) => repointLinkedAgent(flowStore.val.value, from, to)} /> diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index d4f8fa35de..5ee65cd98a 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -112,12 +112,11 @@ export function agentDraftDeployRefusal( if (blocked) { return blocked } - // Renaming is not the agent 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. + // A rename is the agent editor's to deploy: it repoints the steps of the flow it was opened from. + // Deployed from anywhere that names the path it writes to (a flow's deploy dialog, which lists the + // agent under the path the flow links), it would move the agent out from under that flow. if (currentPath && state.path !== currentPath) { - return `This draft renames the agent to ${state.path}. Deploy it from the resource editor instead.` + return `This draft renames the agent to ${state.path}. Deploy it from the agent editor instead.` } // 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. @@ -132,6 +131,9 @@ export function agentDraftDeployRefusal( * persisted draft row: the form stays editable while a deploy is in flight. Surfaces that deploy * the row itself go through `deployDraft` instead. * + * `fromPath` is the path the editor loaded; `state.path` differs from it when the draft renames the + * agent, and the update then moves the resource there. + * * `notAnAgent` separates the one failure that invalidates the caller's whole view of the path, its * holding something else now, from a write that merely failed. */ @@ -139,6 +141,7 @@ type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?: async function writeAgentResource( workspace: string, + fromPath: string, state: AgentResourceState, noDeployed: boolean ): Promise { @@ -161,12 +164,12 @@ async function writeAgentResource( // 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 or the dialog stayed open. - const current = await ResourceService.getResource({ workspace, path: state.path }) - const refused = agentEditorRefusal(state.path, current.resource_type) + const current = await ResourceService.getResource({ workspace, path: fromPath }) + const refused = agentEditorRefusal(fromPath, current.resource_type) if (refused) { return { ok: false, error: refused, notAnAgent: true } } - await ResourceService.updateResource({ workspace, path: state.path, requestBody: body }) + await ResourceService.updateResource({ workspace, path: fromPath, requestBody: body }) } } catch (err) { return { ok: false, error: `Could not save agent: ${err}` } @@ -193,8 +196,9 @@ export interface AgentDraftHandle { /** 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 + /** Write the current state to the resource and drop the draft. Resolves to the path written, + * which differs from the one loaded when the draft renames the agent, or undefined on failure. */ + deploy: () => Promise } /** @@ -333,21 +337,23 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { }) }) - async function deploy(): Promise { + async function deploy(): Promise { const ws = opts.workspace() + const fromPath = opts.path() const s = state - if (!ws || !s) return false - const refused = agentDraftDeployRefusal(s, opts.path()) + if (!ws || !fromPath || !s) return undefined + // No path to hold the draft to: renaming is this editor's to deploy. + const refused = agentDraftDeployRefusal(s, undefined) if (refused) { sendUserToast(refused, true) - return false + return undefined } // 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 written = await writeAgentResource(ws, submitted, noDeployed) + const written = await writeAgentResource(ws, fromPath, submitted, noDeployed) if (!written.ok) { // A path that is no longer an agent tears this editor down; anything else is a plain error // the user can retry from the form as it stands. @@ -356,29 +362,31 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { } else { sendUserToast(written.error, true) } - return false + return undefined } // 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 + const renamed = submitted.path !== fromPath // 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)) { + // unsaved change over the version just deployed, so it keeps its draft and its banner. Not + // after a rename: the draft is keyed on a path that no longer names the agent. + if (!renamed && !deepEqual($state.snapshot(state), submitted)) { sendUserToast(`Saved agent ${submitted.path}. Later edits are still unsaved`) loadedFor = `${ws}:${submitted.path}` - return true + return submitted.path } // `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) + sync.discard(fromPath, 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 + sendUserToast(renamed ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`) + return submitted.path } return { diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 9fd562692c..b6a98160be 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -33,6 +33,9 @@ import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields' import { toolDisplayName, type AgentTool } from '../agentToolUtils' import { useAgentDraft } from '../agentDraft.svelte' + import Path from '$lib/components/Path.svelte' + import Label from '$lib/components/Label.svelte' + import { sendUserToast } from '$lib/toast' interface Props { /** The `ai_agent` resource being edited. */ @@ -320,13 +323,19 @@ if (toolId === id) onSelectTool?.(undefined) } + /** The path field's own verdict (a taken path, an invalid name), which the server would otherwise + * only report after the request. */ + let pathError = $state('') + export function deploy(): Promise { - 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 + if (pathError) { + sendUserToast(`Cannot deploy the agent: ${pathError}`, true) + return Promise.resolve(false) + } + return draft.deploy().then(async (written) => { + // The path the write landed on, which a rename moves off the one this editor opened. + if (written) await onSaved?.(written) + return written !== undefined }) } export function draftHandle() { @@ -352,6 +361,25 @@
+
+ +
boolean + /** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface + * that opened the editor: a flow's own steps, a page's URL. */ + onRenamed?: (from: string, to: string) => void } - let { enableAi = false, owns }: Props = $props() + let { enableAi = false, owns, onRenamed = undefined }: 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 @@ -178,9 +182,10 @@ 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. + // the editor was opened from: they all show tools the write may have moved. Looked up under + // the path the editor opened, since a rename is about to move those steps off it. const scope = linkedToolsScope(at.ws, at.host.flowPath) - const moduleIds = new Set(linkedModulesForAgent(scope, path)) + const moduleIds = new Set(linkedModulesForAgent(scope, at.path)) moduleIds.add(at.host.moduleId) return Promise.all( // With the draft: a deploy leaves none, but a version restore leaves the draft standing and @@ -202,10 +207,21 @@ } } - /** 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. */ + /** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename + * moves off the one the editor opened. */ async function onSaved(savedPath: string) { - await reconcile(deployingFor ?? currentWriteTarget(), savedPath) + const at = deployingFor ?? currentWriteTarget() + // Before the rename is announced: it finds the steps to refresh under the old path. + const reconciled = reconcile(at, savedPath) + if (at && savedPath !== at.path) { + onRenamed?.(at.path, savedPath) + // The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it + // still shows the one deployed: it can be closed or pointed elsewhere mid-request. + if (target?.path === at.path) { + openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host }) + } + } + await reconciled } diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 96548b10af..fafe69ebb4 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -33,7 +33,11 @@ } from '../linkedAgentToolsStore.svelte' import { logReusableAgentUsage } from '../agentTelemetry' import { claimLinkedToolsFetch } from '../flowState' - import { AgentDraftUnavailable, fetchAgentWithDraft } from '../linkedAgentDrafts' + import { + AgentDraftUnavailable, + fetchAgentWithDraft, + isExpectedLinkFailure + } from '../linkedAgentDrafts' import type { AgentResourceState } from '../agentDraft.svelte' import { getLocalDraftHint } from '$lib/localDraftHints.svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' @@ -103,6 +107,30 @@ fromDraft: boolean providerPath?: string providerOk: boolean + /** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been + * renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder + * they cannot read included, which says nothing about whether a run of the flow can read it. + * Returned rather than thrown so it is guarded like any result. */ + unavailable?: 'missing' | 'forbidden' + } + + async function fetchLinkedAgent( + path: string, + ws: string + ): Promise<{ response: Resource; draft: AgentResourceState | undefined }> { + try { + return await fetchAgentWithDraft(path, ws) + } catch (err) { + // Only the DRAFT was unreadable. This card is a display, so fall back to the deployed + // agent rather than rendering one with no brain and no tools, which reads as "the agent + // is empty" while the Draft badge still says it has unsaved changes. Same fallback the + // graph's tool nodes take; the paths that run or deploy the draft still refuse. + if (!(err instanceof AgentDraftUnavailable)) throw err + return { + response: await ResourceService.getResource({ workspace: ws, path }), + draft: undefined + } + } } // A linked agent is rigid and read-only: its brain and tools come from the resource. We @@ -112,29 +140,27 @@ let linkedResource = resource( () => ({ ws, path: agent, writes, draftSaves }), async ({ ws, path, writes, draftSaves }): Promise => { + const empty = { + ws, + path, + writes, + draftSaves, + config: {}, + tools: [], + fromDraft: false, + providerOk: true + } if (!ws || !path) { - return { - ws, - path, - writes, - draftSaves, - config: {}, - tools: [], - fromDraft: false, - providerOk: true - } + return empty } let response: Resource let draft: AgentResourceState | undefined try { - ;({ response, draft } = await fetchAgentWithDraft(path, ws)) + ;({ response, draft } = await fetchLinkedAgent(path, ws)) } catch (err) { - // Only the DRAFT was unreadable. This card is a display, so fall back to the deployed - // agent rather than rendering one with no brain and no tools, which reads as "the agent - // is empty" while the Draft badge still says it has unsaved changes. Same fallback the - // graph's tool nodes take; the paths that run or deploy the draft still refuse. - if (!(err instanceof AgentDraftUnavailable)) throw err - response = await ResourceService.getResource({ workspace: ws, path }) + if (!isExpectedLinkFailure(err)) throw err + const status = (err as { status?: number }).status + return { ...empty, unavailable: status === 404 ? 'missing' : 'forbidden' } } const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig & { provider?: { resource?: string } @@ -189,6 +215,7 @@ let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config)) let providerPath = $derived(linkedInfo?.providerPath) let providerOk = $derived(linkedInfo?.providerOk ?? true) + let unavailable = $derived(linkedInfo?.unavailable ?? false) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written // elsewhere, which no editor here has published an opinion about. @@ -468,6 +495,15 @@ } } + // A link naming nothing readable has nothing to fork. Dropping it leaves a standalone step with its + // flow-local inputs, to configure here or replace with a saved agent; the tool overrides were + // keyed by the missing agent's tools, so they go with it. + function removeLink() { + toolInputs = {} + agent = undefined + sendUserToast('Removed the link to the missing agent') + } + // 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. @@ -534,7 +570,7 @@ {/if} {/if} - {#if !fromAgentEditor} + {#if !fromAgentEditor && !unavailable}
{#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)} @@ -581,7 +619,32 @@ {/if} - {#if !providerOk} + {#if unavailable === 'forbidden'} +
+ + You don't have access to {agent}, so its configuration + can't be shown or edited here. + +
+ {:else if unavailable === 'missing'} +
+ + No saved agent exists at {agent}. It may have been + renamed or deleted. Remove the link to configure the step here, or add the agent again + from Saved agents. +
+ +
+
+
+ {:else if !providerOk}
This agent's model provider{#if providerPath} diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts index 563007615f..fa72aaba5c 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.test.ts @@ -4,6 +4,7 @@ import { inlineAgentDraft, inlineAgentDrafts, loadLinkedAgentDrafts, + repointLinkedAgent, type LinkedAgentDraft } from './linkedAgentDrafts' import { ResourceService, type FlowModule, type FlowValue } from '$lib/gen' @@ -118,6 +119,38 @@ describe('inlineAgentDrafts', () => { }) }) +describe('repointLinkedAgent', () => { + // A rename from the agent editor moves every step of the host flow onto the new path, nested ones + // included. Miss one and it silently stays linked to a path that no longer exists. + it('repoints linked steps at any depth and leaves other agents alone', () => { + const value = { + modules: [ + { id: 'a', value: { type: 'aiagent', agent: 'f/team/support', tools: [] } }, + { + id: 'b', + value: { + type: 'branchall', + branches: [ + { + modules: [ + { id: 'c', value: { type: 'aiagent', agent: 'f/team/support', tools: [] } }, + { id: 'd', value: { type: 'aiagent', agent: 'f/team/other', tools: [] } } + ] + } + ] + } + } + ] + } as unknown as FlowValue + + expect(repointLinkedAgent(value, 'f/team/support', 'f/team/helpdesk')).toEqual(['a', 'c']) + const branch = (value.modules[1].value as any).branches[0].modules + expect((value.modules[0].value as any).agent).toBe('f/team/helpdesk') + expect(branch[0].value.agent).toBe('f/team/helpdesk') + expect(branch[1].value.agent).toBe('f/team/other') + }) +}) + // A link the user cannot resolve is an ordinary state and must not block the flow; anything else is // an outage, and answering "no draft" to one would silently test or deploy against the deployed // agent while the editor shows the draft. diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.ts index 8da732e7ff..65624d4429 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.ts @@ -35,6 +35,25 @@ export function linkedAgentPaths(value: FlowValue | undefined): string[] { return [...paths] } +/** Point every step of this flow linked to `from` at `to`, for an agent renamed from inside it. + * Returns the ids of the steps it moved. */ +export function repointLinkedAgent( + value: FlowValue | undefined, + from: string, + to: string +): string[] { + if (!value?.modules) return [] + const moved: string[] = [] + for (const module of dfs(value.modules, (m) => m)) { + const v = module?.value as { type?: string; agent?: string } | undefined + if (v?.type === 'aiagent' && v.agent === from) { + v.agent = to + moved.push(module.id) + } + } + return moved +} + /** * The unsaved draft for an agent, freshest first: the cell an open agent editor is writing, then * what a `get_draft` response carried. @@ -114,7 +133,7 @@ export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | unde * neither should stop the caller — the flow still tests and deploys, against the deployed agent. * Every other failure is an outage, and answering "no draft" to one would quietly run or deploy * the wrong configuration, which is the whole thing this module exists to prevent. */ -function isExpectedLinkFailure(err: unknown): boolean { +export function isExpectedLinkFailure(err: unknown): boolean { const status = (err as { status?: number } | null | undefined)?.status return status === 401 || status === 403 || status === 404 } diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index 084ad9d492..b94e0faefa 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -29,6 +29,8 @@ WandSparkles } from 'lucide-svelte' import Portal from '$lib/components/Portal.svelte' + import { zIndexes } from '$lib/zIndexes' + import { overlayStack } from '$lib/components/common/overlayHost.svelte' import { twMerge } from 'tailwind-merge' import ContentSearchInner from '../ContentSearchInner.svelte' @@ -367,6 +369,7 @@ async function handleKeydown(event: KeyboardEvent) { if ((!isMac() ? event.ctrlKey : event.metaKey) && event.key === 'k') { event.preventDefault() + if (!open) openedOn = undefined await openModal() } if (open) { @@ -450,6 +453,24 @@ mouseMoved = true } + // On the overlay stack while open: a modal or drawer it was opened from arbitrates Escape on that + // stack, and would otherwise close itself on the key meant for the search above it. The opener + // names its stack, because a pane hosting an editor (a sessions tab) keeps its own. + const globalStack = overlayStack() + let openedOn: import('$lib/components/common/overlayHost.svelte').OverlayStack | undefined = + $state(undefined) + const STACK_ID = 'global-search' + $effect(() => { + if (!open) return + const stack = openedOn ?? globalStack + untrack(() => stack.val.push(STACK_ID)) + return () => { + untrack(() => { + stack.val = stack.val.filter((id) => id !== STACK_ID) + }) + } + }) + onMount(() => { window.addEventListener('keydown', handleKeydown) window.addEventListener('mousemove', handleMouseMove) @@ -559,7 +580,11 @@ } } - export async function openSearchWithPrefilledText(text?: string) { + export async function openSearchWithPrefilledText( + text?: string, + stack?: import('$lib/components/common/overlayHost.svelte').OverlayStack + ) { + openedOn = stack await openModal() searchTerm = text ?? searchTerm await handleSearch() @@ -618,9 +643,9 @@
{#if agentEditorTarget()} {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} - t.host === undefined} /> + t.host === undefined} + onRenamed={(from, to) => { + void loadResources() + // Only while the dialog still shows the agent: closed mid-request, it already cleared the + // anchor, and writing it back would reopen the editor on refresh. + if (agentEditorTarget()?.path !== from) return + // Claimed first, as a row click does, so the deep-link effect does not reopen it. + handledHash = `#/resource/${to}` + setPageDrawerAnchor(RESOURCES_PATH, to) + }} + /> {/await} {/if}