feat: rename saved agents from the agent editor and flag broken links (#11147)

* feat: list flows that link a saved agent and flag broken agent links

* feat: rename saved agents from the agent editor and repoint the flow

* fix: show an unreadable linked agent as not accessible, not missing

* fix: address review nits on agent rename and missing-agent state

* fix: open content search above modals and keep Escape for it

* fix: register content search on the opener's overlay stack

* docs: scope the global search z-index comment to the bases it clears

* refactor: show linked agents' rename warning as for scripts and flows

* fix: keep the failed-lookup rename warning to resources
This commit is contained in:
hugocasa
2026-09-16 10:45:01 +02:00
committed by GitHub
parent b9b5988ebd
commit 57a99f66a8
26 changed files with 482 additions and 123 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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;
@@ -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;
+1 -1
View File
@@ -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)
+30 -2
View File
@@ -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<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Vec<String>> {
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) {
@@ -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,
+19
View File
@@ -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
@@ -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();
@@ -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)])
)
+44 -25
View File
@@ -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 @@
</ul>
</Alert>
{/if}
{:else if displayPathChangedWarning && kind == 'resource'}
{@render renameMayBreakWarning()}
{/if}
{:catch}
<!-- A resource's references beyond linked agents are never looked up, so a failed lookup
still leaves it with the generic warning. -->
{#if displayPathChangedWarning && kind == 'resource'}
{@render renameMayBreakWarning()}
{/if}
{/await}
{:else if displayPathChangedWarning}
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
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.
<div class="flex pt-2">
<Button
variant="default"
on:click={() => {
openSearchWithPrefilledText('#')
}}
startIcon={{ icon: SearchCode }}
>
Search
</Button>
</div>
</Alert>
{@render renameMayBreakWarning()}
{/if}
</div>
{#snippet renameMayBreakWarning()}
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
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.
<div class="flex pt-2">
<Button
variant="default"
on:click={() => {
openSearchWithPrefilledText('#', searchStack)
}}
startIcon={{ icon: SearchCode }}
>
Search
</Button>
</div>
</Alert>
{/snippet}
@@ -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 @@
<AgentEditorModal
enableAi={!disableAi}
owns={(t) => t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace}
onRenamed={(from, to) => repointLinkedAgent(flowStore.val.value, from, to)}
/>
@@ -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<AgentWriteResult> {
@@ -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<boolean>
/** 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<string | undefined>
}
/**
@@ -333,21 +337,23 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
})
})
async function deploy(): Promise<boolean> {
async function deploy(): Promise<string | undefined> {
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 {
@@ -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<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
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 @@
<Splitpanes class="h-full">
<Pane size={66} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<div class="px-4 pt-4">
<Label label="Path">
<Path
bind:path={
() => draft.state?.path,
(v) => {
if (draft.state && v !== undefined) draft.state.path = v
}
}
bind:error={pathError}
initialPath={path}
namePlaceholder="agent"
kind="resource"
workspaceOverride={workspace}
autofocus={false}
disabled={readOnly}
/>
</Label>
</div>
<PropPickerWrapper
pickableProperties={stepPropPicker?.pickableProperties}
noPadding
@@ -19,6 +19,7 @@
type AgentEditorTarget,
closeAgentEditor,
markAgentWritten,
openAgentEditor,
showAgentEditorTool,
showAgentEditorView
} from '../agentEditorStore.svelte'
@@ -35,9 +36,12 @@
* 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
/** 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
}
</script>
@@ -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<LinkedInfo> => {
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}
</span>
{/if}
{#if !fromAgentEditor}
{#if !fromAgentEditor && !unavailable}
<Button
unifiedSize="sm"
variant="default"
@@ -547,17 +583,19 @@
}}
/>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Unlink }}
iconOnly
title="Unlink (fork an editable copy into just this step)"
onclick={(e) => {
e.stopPropagation()
unlink()
}}
/>
{#if !unavailable}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Unlink }}
iconOnly
title="Unlink (fork an editable copy into just this step)"
onclick={(e) => {
e.stopPropagation()
unlink()
}}
/>
{/if}
</div>
</div>
{#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)}
@@ -581,7 +619,32 @@
</dl>
{/if}
</div>
{#if !providerOk}
{#if unavailable === 'forbidden'}
<div class="mt-1">
<Alert type="warning" size="xs" title="Agent not accessible">
You don't have access to <span class="font-medium">{agent}</span>, so its configuration
can't be shown or edited here.
</Alert>
</div>
{:else if unavailable === 'missing'}
<div class="mt-1">
<Alert type="error" size="xs" title="Agent not found">
No saved agent exists at <span class="font-medium">{agent}</span>. It may have been
renamed or deleted. Remove the link to configure the step here, or add the agent again
from Saved agents.
<div class="flex pt-2">
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Unlink }}
onclick={removeLink}
>
Remove link
</Button>
</div>
</Alert>
</div>
{:else if !providerOk}
<div class="mt-1">
<Alert type="error" size="xs" title="Model provider not accessible">
This agent's model provider{#if providerPath}
@@ -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.
@@ -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
}
@@ -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 @@
<div
class={twMerge(
`fixed top-0 bottom-0 left-0 right-0 transition-all duration-50 flex items-start justify-center`,
' bg-black bg-opacity-40',
'z-[1100]'
' bg-black bg-opacity-40'
)}
style="z-index: {zIndexes.globalSearch}"
>
<div
class="{maxModalWidth(tab)} w-full mt-36 bg-surface rounded-lg relative"
+4
View File
@@ -5,6 +5,10 @@ export const zIndexes = {
colorInput: 1002,
disposables: 1100, // Modals and Drawers
aiChat: 1200,
// Above the modal and drawer bases (`disposables`, or `aiChat + 1` while the chat is open) and the
// chat panel: it is opened from inside modals (the rename warning's content search) and takes no
// z-index from their stack. A disposable raised past it with `minZIndex` still covers it.
globalSearch: 1500,
svelteSelectOptions: 5000,
popover: 5001,
contextMenu: 6000,
@@ -659,8 +659,11 @@
}
}
function openSearchModal(text?: string): void {
globalSearchModal?.openSearchWithPrefilledText(text)
function openSearchModal(
text?: string,
stack?: import('$lib/components/common/overlayHost.svelte').OverlayStack
): void {
globalSearchModal?.openSearchWithPrefilledText(text, stack)
}
setContext('openSearchWithPrefilledText', openSearchModal)
@@ -1567,6 +1567,18 @@
this route's JavaScript and none of what the resources table needs. -->
{#if agentEditorTarget()}
{#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }}
<AgentEditorModal enableAi={$copilotInfo.enabled} owns={(t) => t.host === undefined} />
<AgentEditorModal
enableAi={$copilotInfo.enabled}
owns={(t) => 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}