feat: run a linked AI agent's draft when testing a flow, and offer to deploy it (#10993)

* feat(frontend): run a linked agent's draft when testing a flow, and offer to deploy it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): settle an agent's autosave before reading it, and refresh its card on a draft save

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): deploy the agent draft that was validated, and make the draft-tools flag explicit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a stale agent deploy, and warn when a never-deployed agent is kept as a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* docs: record what inlining an agent draft puts in a preview job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): name the draft-changes dialog after what it lists

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a draft deploy when the draft row is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): apply the missing-draft refusal to raw apps too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): stop reading a deployed resource row as a draft on deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): do not mistake an outage or a vanished draft for a deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse an agent read whose pending draft save failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): space the trigger badges and right-align the agent actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): surface a failed agent-draft read instead of dropping it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the agent draft delete a baseline so a newer edit survives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): drop the agent draft cell locally instead of deleting twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pass the withDraft flag the guard tests were missing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* refactor(frontend): deploy agent drafts the way Review & Deploy does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the read-only flow graph its own linked-tools bucket

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): base the resource draft delete on the read that promoted it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refresh every step linking an agent when its draft is saved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): write nothing at all when a resource draft has gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pin that the resource draft delete follows its baseline seed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-07 19:04:38 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 48a56158c1
commit 7feaf619cf
23 changed files with 1369 additions and 339 deletions
+40 -8
View File
@@ -22,22 +22,54 @@ every workspace via the standard cached-resource-type sync, like other built-in
or flow expressions), so saving round-trips losslessly. Each host flow overrides what it
needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own
transforms) that overlay onto the matching tools at runtime. Editing on a linked step edits
the flow's use of the agent; editing under the "Editing" banner edits the agent itself.
the flow's use of the agent; editing in the agent editor edits the agent itself.
In the flow editor, the AI agent step's **Step Input** tab shows a single read-only card
(*linked to <path>*, with the inherited brain + tools and an explanatory tooltip) plus
*Edit* (fork into the editable step, Save changes upserts back and re-links) and *Unlink*
(fork the resolved config — including any `tool_inputs` — back into the step as a one-off).
While editing, the step is the only copy of the edits: Cancel drops them and re-links (asking
first when there is something to drop), and the unsaved-changes badge opens a diff against the
deployed agent whose Discard changes is Cancel without the question. What a fork is an edit of,
and the deployed baseline the edits are judged against, live in `agentEditStore` (in memory), so
a reload brings the step back as a standalone agent with no path to save back to.
*Edit*, which opens the agent editor over the flow, and *Unlink* (fork the resolved config —
including any `tool_inputs` — back into the step as a one-off).
A linked agent's tools appear as display-only graph tool nodes (clicking one selects the
agent step); below the step's inputs, each tool gets a section with the standard schema-aware
input editors (prop picker included) and a read-only view of its code — edits persist into
`tool_inputs`.
## Drafts
The agent editor edits the resource through a **per-user resource draft** (`draft` table,
`item_kind = 'resource'`), autosaved by `useAgentDraft` and deployed by the editor's own Deploy
button. It is the same draft row the generic resource editor writes and the Review & Deploy page
lists, so an agent can be deployed from any of them.
A flow does not wait for that deploy to see the draft:
- Testing the flow, or a single linked step, runs the draft. `runFlowPreview` and `ModuleTest`
substitute each linked step for the standalone step the draft would run as
(`linkedAgentDrafts.ts`): `agent` cleared, the draft's brain as static input transforms, the
draft's tools on the step, and the step's own `user_message`/`user_attachments` kept on top —
the same overlay order `ai_executor.rs` applies to a linked step. `tool_inputs` is untouched,
since the worker overlays it in both branches.
- The step's linked card and the graph's tool nodes show the draft, with a *Draft* badge, so the
editor describes what a test would run. Read-only surfaces (the deployed flow page, the run
viewer) stay on the deployed agent: they resolve tools through `publishLinkedAgentTools` without
the draft flag.
- Deploying the flow lists every linked agent that has a draft in the confirmation dialog, beside
the draft triggers. Deploying one writes the resource and drops the draft; leaving one out keeps
its draft untouched, and the flow runs the agent as currently deployed. That is the one place
the two kinds differ: an undeployed draft trigger is deleted, because it belongs to the flow,
while an agent draft belongs to a resource other flows also use.
Because a draft is per-user, a flow test can behave differently for two people looking at the same
flow. That is the same contract as a flow draft, and deploying the agent is what makes it shared.
Inlining has a consequence worth knowing: a preview job's `raw_flow` then carries the agent's
config, where a linked step used to carry only the path and leave the resolution to the worker. So
an agent's prompt and tool set are readable by whoever can read that preview job, which is a wider
set than whoever can read the resource when the agent sits in a more restricted folder than the
flow. No credential travels with it — the provider stays a `$res:` reference, resolved at run time
as the runner. The agent editor's own test pane has inlined the same way since drafts existed;
closing the gap would mean the preview carrying a draft *reference* the worker resolves, rather
than the config.
Sharing works through standard resource folder permissions (save agents under `f/...`).
Only the agent's brain is interpolated when the step runs. A tool's own `$res:`/`$var:` defaults are
+129 -21
View File
@@ -98,7 +98,18 @@
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { Trigger } from './triggers/utils'
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import DraftChangesConfirmationModal from './common/confirmationModal/DraftChangesConfirmationModal.svelte'
import {
agentDraftCanWrite,
linkedAgentPaths,
loadLinkedAgentDrafts,
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { agentDraftDeployRefusal } from './flows/agentDraft.svelte'
import { markAgentWritten } from './flows/agentEditorStore.svelte'
import { logReusableAgentUsage } from './flows/agentTelemetry'
import { deployDraft } from '$lib/utils_draft_deploy'
import { getUserExt } from '$lib/user'
import { Triggers } from './triggers/triggers.svelte'
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
@@ -192,8 +203,16 @@
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
let open: boolean = $state(false) // Is confirmation modal open
// Draft triggers confirmation modal
let draftTriggersModalOpen = $state(false)
// Draft changes (triggers + linked agents) confirmation modal
let draftChangesModalOpen = $state(false)
/** The unsaved agent drafts the pending deploy found. Loaded rather than derived: it takes a
* request per linked agent, so it is resolved when the deploy asks. */
let draftAgents = $state<LinkedAgentDraft[]>([])
let agentCanWrite = $state<Record<string, boolean>>({})
let agentRefusal = $state<Record<string, string | undefined>>({})
/** What the dialog's confirm hands back to `saveFlow`. */
type DraftChangesToDeploy = { triggers: Trigger[]; agents: LinkedAgentDraft[] }
// Top-bar responsive collapse. Measured via bind:clientWidth — we can't
// rely on viewport `md:` because the editor lives inside other panes
@@ -224,7 +243,7 @@
]
: []
)
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
let confirmDeploymentCallback: (toDeploy: DraftChangesToDeploy) => void = () => {}
// AI changes warning modal
let aiChangesWarningOpen = $state(false)
@@ -236,11 +255,57 @@
const job: Job | undefined = $derived(flowPreviewContent?.getJob())
let showJobStatus = $state(false)
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
async function handleDraftChangesConfirmed(
event: CustomEvent<{ selectedTriggers: Trigger[]; selectedAgents: LinkedAgentDraft[] }>
) {
const { selectedTriggers, selectedAgents } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
draftChangesModalOpen = false
confirmDeploymentCallback({ triggers: selectedTriggers, agents: selectedAgents })
}
/** Deploy each selected agent's draft, the same way the Review & Deploy page deploys the same
* row: hand the path to `deployDraft` and let it promote whatever the draft holds, with no
* re-read to check it still matches what the dialog listed. Agents left out keep their draft
* untouched. */
async function deployAgentDrafts(agents: LinkedAgentDraft[]) {
const ws = opWorkspace
if (!ws) return
for (const listed of draftAgents) {
// Only the rows the dialog gave a choice on. A `Read-only` or `Invalid config` agent can
// never be selected, so counting it as "kept" would record a decision the user was never
// offered and bias the pair towards keeping.
const selectable = agentCanWrite[listed.path] !== false && !agentRefusal[listed.path]
if (selectable && !agents.some((a) => a.path === listed.path)) {
logReusableAgentUsage('draft_kept_on_deploy')
}
}
for (const agent of agents) {
// Writes the resource, deletes the draft row, and clears the local hint and the workspace
// drafts cache. A failure aborts the flow save the way a failing trigger does, rather than
// deploying a flow against agents that were meant to change with it.
const deployed = await deployDraft('resource', agent.path, ws, {
draftOnly: agent.noDeployed
})
if (!deployed.success) {
throw new Error(`Could not deploy agent ${agent.path}: ${deployed.error}`)
}
// Nothing was promoted: the draft had gone by the time the helper read it. Saying the agent
// deployed would be a lie about the one thing the toggle decides.
if (deployed.noop) {
throw new Error(
`The draft for ${agent.path} was deployed or discarded elsewhere while this deploy ran, so nothing was written for it.`
)
}
// `deployDraft` deletes the server row but leaves any in-memory cell for this key, and that
// cell is what `agentDraftState` prefers — a still-mounted holder would otherwise keep
// feeding a phantom draft to the cards and to the next deploy dialog. Local only: `remove`
// would POST a second delete, debounced and past the baseline the first one cleared.
UserDraft.forgetLocal('resource', agent.path, { workspace: ws })
// Every linked card and the graph key on this to refetch the agent they display.
markAgentWritten(ws, agent.path)
logReusableAgentUsage('draft_deployed_with_flow')
}
}
// Inside an AI session pane (SessionEditorTarget injects an aiChatManager via
@@ -429,18 +494,53 @@
deployedBy = flow.edited_by
}
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
async function saveFlow(deploymentMsg?: string, toDeploy?: DraftChangesToDeploy): Promise<void> {
if (!toDeploy) {
// Draft triggers and drafts on the agents this flow links: both are unsaved changes the
// deploy would otherwise leave behind, so they are confirmed together.
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await saveFlow(deploymentMsg, triggersToDeploy)
try {
draftAgents = [
...(
await loadLinkedAgentDrafts(linkedAgentPaths(flowStore.val.value), opWorkspace)
).values()
]
} catch (err: any) {
// This runs before the try below, and `withAIChangesWarning` invokes its callback without
// awaiting, so a rejection here would be unhandled: the button would do nothing at all,
// with no toast and no `onDeployError`. Report it the way the rest of the save does.
// Deploying anyway is not the fallback — this throws only when an agent's unsaved changes
// cannot be read, which is exactly when the dialog must not claim there are none.
onDeployError?.({ error: err })
sendUserToast(`The flow could not be saved: ${err?.body ?? err}`, true)
return
}
agentCanWrite = {}
agentRefusal = {}
if (draftAgents.length > 0) {
// One lookup for the whole list: an agent lives in a folder, and the groups and admin
// flag that answer for it are per workspace, so the nav user would answer for the wrong
// membership when a session editor operates on another workspace.
const user = await getUserExt(opWorkspace ?? '').catch(() => undefined)
agentCanWrite = Object.fromEntries(
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.
agentRefusal = Object.fromEntries(
draftAgents.map((a) => [a.path, agentDraftDeployRefusal(a.state, a.path)])
)
}
if (draftTriggers.length > 0 || draftAgents.length > 0) {
draftChangesModalOpen = true
confirmDeploymentCallback = async (confirmed: DraftChangesToDeploy) => {
await saveFlow(deploymentMsg, confirmed)
}
return
}
}
const triggersToDeploy = toDeploy?.triggers
loadingSave = true
try {
@@ -471,6 +571,11 @@
// loadingSave = false // del
// return
// Ahead of the flow itself, as the update branch deploys its triggers: an agent is a
// resource of its own, so the flow should land on top of the agent set it was tested
// against rather than the other way round.
await deployAgentDrafts(toDeploy?.agents ?? [])
// `newFlow` comes from the embedder, and updating a path that has no
// deployed flow 404s. Confirm with the server before taking the update
// branch so a first deploy still lands.
@@ -648,7 +753,7 @@
const resolved = linkedAgentToolsForScope(to)
for (const [moduleId, agentPath] of linkedAgentEntries(linkedAgentRefs)) {
if (resolved[moduleId] === undefined) {
publishLinkedAgentTools(agentPath, ws, to, moduleId)
publishLinkedAgentTools(agentPath, ws, to, moduleId, true)
}
}
}
@@ -716,7 +821,7 @@
// writing overrides against — the tool ids of the agent that was just replaced.
claimLinkedToolsFetch(scope, moduleId)
clearLinkedAgentTools(scope, moduleId)
publishLinkedAgentTools(agentPath, ws, scope, moduleId)
publishLinkedAgentTools(agentPath, ws, scope, moduleId, true)
}
publishedAgentByModule = next
})
@@ -1343,14 +1448,17 @@
currentValue={flowStore.val}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
<DraftChangesConfirmationModal
bind:open={draftChangesModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
{draftAgents}
{agentCanWrite}
{agentRefusal}
isFlow={true}
on:canceled={() => {
draftTriggersModalOpen = false
draftChangesModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
on:confirmed={handleDraftChangesConfirmed}
/>
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
@@ -67,6 +67,14 @@
const dispatch = createEventDispatcher()
// A bucket of this viewer's own, never the editor's. Both are keyed by (workspace, flow path), so
// a viewer mounted over the flow being edited — the version-history drawer, which renders the
// same path — would otherwise publish its deployed tools into the editor's bucket and replace the
// drafted tool nodes there. The editor's graph has to keep showing the tools a preview would
// actually run, and nothing republishes when the drawer closes. `FlowStatusViewerInner` scopes
// itself the same way, with `job:<id>`.
let linkedToolsPath = $derived(`view:${flow?.path ?? ''}`)
// This read-only viewer doesn't run initFlowState, so linked agents' tools would otherwise never
// resolve. Resolve them for display, keyed by module id. Best-effort: publishLinkedAgentTools
// swallows access errors and publishes [], so an inaccessible agent simply shows no tool nodes
@@ -80,7 +88,15 @@
for (const m of modules) {
const value = m?.value as { type?: string; agent?: string } | undefined
if (value?.type === 'aiagent' && value.agent) {
publishLinkedAgentTools(value.agent, ws, linkedToolsScope(ws, flow?.path), m.id)
// Without the draft: this viewer shows a deployed flow or a past run, both of which
// used the deployed agent.
publishLinkedAgentTools(
value.agent,
ws,
linkedToolsScope(ws, linkedToolsPath),
m.id,
false
)
}
}
})
@@ -101,6 +117,7 @@
earlyStop={flow?.value?.skip_expr !== undefined}
cache={flow?.value?.cache_ttl !== undefined}
path={flow?.path}
{linkedToolsPath}
{download}
minHeight={fillAvailableHeight ? Math.max(minHeight, availableHeight) : minHeight}
{workspace}
@@ -5,6 +5,7 @@
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { runFlowPreview } from './flows/utils.svelte'
import { sendUserToast } from '$lib/toast'
import SchemaForm from './SchemaForm.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
import FlowProgressBar from './flows/FlowProgressBar.svelte'
@@ -101,15 +102,22 @@
// The preview flow holds only the loop body, so it inherits none of the flow's settings:
// carry the tag over so the iteration lands on the worker group the flow runs on.
const newFlow = { value: { modules }, summary: '', tag: flowStore.val.tag }
jobId = await runFlowPreview(
whileLoop ? withWhileLoopIter(args) : args,
newFlow,
$pathStore,
restartedFrom,
undefined,
undefined,
opWorkspace?.()
)
try {
jobId = await runFlowPreview(
whileLoop ? withWhileLoopIter(args) : args,
newFlow,
$pathStore,
restartedFrom,
undefined,
undefined,
opWorkspace?.()
)
} catch (err: any) {
// `runFlowPreview` resolves a linked agent's draft first and refuses when it cannot be read.
// Without this the rejection is unhandled and the button just does nothing.
sendUserToast(`Could not run preview: ${err?.body ?? err}`, true)
return
}
isRunning = true
}
@@ -294,7 +294,9 @@
untrack(() => {
for (const entry of refs ? refs.split('\u0001') : []) {
const [moduleId, agentPath] = entry.split('\u0000')
publishLinkedAgentTools(agentPath, ws, scope, moduleId)
// Without the draft: this viewer describes a run that has already happened, and it ran
// the deployed agent.
publishLinkedAgentTools(agentPath, ws, scope, moduleId, false)
}
})
})
+38 -2
View File
@@ -14,6 +14,14 @@
import JobLoader, { type Callbacks } from './JobLoader.svelte'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import { loadSchemaFromModule } from './flows/flowInfers'
import {
inlineAgentDraft,
loadLinkedAgentDrafts,
normalizeAgentRef,
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { sendUserToast } from '$lib/toast'
interface Props {
mod: FlowModule
@@ -138,7 +146,35 @@
} else if (val.type == 'aiagent') {
const { schema } = await loadSchemaFromModule(mod, opWs)
const agentVal = val
// A linked step whose agent has an unsaved draft is tested as the draft, the same way the
// whole-flow preview and the agent editor's own test pane run it. `inlineAgentDraft`
// clears `agent` and moves the draft's brain and tools onto the step, so the branches
// below then treat it as a standalone agent.
let draft: LinkedAgentDraft | undefined
if (val.agent) {
const linked = normalizeAgentRef(val.agent)
try {
draft = (await loadLinkedAgentDrafts([linked], opWs)).get(linked)
} catch (err: any) {
// The load refuses when the agent's unsaved changes cannot be read, and this function's
// caller neither awaits nor catches: without this the rejection is unhandled and the
// button appears to do nothing, with the test already marked as started.
sendUserToast(`Could not run test: ${err?.body ?? err}`, true)
// Guarded like every other access to it here: the entry is only created for steps the
// panel is tracking, and this runs on a path where it may never have been.
if (modulesTestStates.states[mod.id]) {
modulesTestStates.states[mod.id].loading = false
}
return
}
}
const agentVal = draft ? inlineAgentDraft(val, draft.args) : val
// `args` is built from the whole AI agent schema whatever the step is, so on a linked step
// it carries every brain key as undefined even though the form renders only the flow-local
// ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just
// supplied with nothing, so an inlined step takes only the inputs its form actually offers.
const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
// 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
@@ -150,7 +186,7 @@
? {}
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
...Object.fromEntries(
Object.keys(args).map((key) => [
formKeys.map((key) => [
key,
{
expr: `flow_input.${key}`,
@@ -102,7 +102,7 @@
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import DraftChangesConfirmationModal from './common/confirmationModal/DraftChangesConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
import WorkerTagSelect from './WorkerTagSelect.svelte'
@@ -1149,7 +1149,7 @@
currentValue={script}
/>
<DraftTriggersConfirmationModal
<DraftChangesConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
on:canceled={() => {
@@ -0,0 +1,323 @@
<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import { createEventDispatcher, untrack } from 'svelte'
import type { Trigger } from '$lib/components/triggers/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { Bot, Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import Badge from '../badge/Badge.svelte'
import type { LinkedAgentDraft } from '$lib/components/flows/linkedAgentDrafts'
interface Props {
open?: boolean
draftTriggers?: Trigger[]
/** Saved agents this flow links to that have an unsaved draft. Scripts pass none: only a
* flow step can link an agent. */
draftAgents?: LinkedAgentDraft[]
/** Whether this user may write each listed agent's resource, keyed by path. */
agentCanWrite?: Record<string, boolean>
/** Why an agent cannot be deployed, keyed by path, from the same rule the agent editor's own
* Deploy button follows — so this dialog cannot offer a write that would be rejected. Decided
* by the caller: this is a generic dialog the script editor mounts too, and agent validation
* has no business in its bundle. */
agentRefusal?: Record<string, string | undefined>
isFlow?: boolean
}
let {
open = $bindable(false),
draftTriggers = [],
draftAgents = [],
agentCanWrite = {},
agentRefusal = {},
isFlow = false
}: Props = $props()
let selectedTriggers: Trigger[] = $state(untrack(() => draftTriggers))
let selectedAgents: LinkedAgentDraft[] = $state([])
const dispatch = createEventDispatcher<{
canceled: void
confirmed: { selectedTriggers: Trigger[]; selectedAgents: LinkedAgentDraft[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
}
function toggleAgent(agent: LinkedAgentDraft, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
selectedAgents = selectedAgents.filter((a) => a.path !== agent.path)
} else if (!selectedAgents.some((a) => a.path === agent.path)) {
selectedAgents = [...selectedAgents, agent]
}
}
function checkSavePermissions(trigger: Trigger) {
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
function checkAgentPermissions(agent: LinkedAgentDraft): {
state: 'deploy' | 'read-only' | 'invalid-config'
reason?: string
} {
if (agentCanWrite[agent.path] === false) {
return { state: 'read-only' }
}
const refusal = agentRefusal[agent.path]
return refusal ? { state: 'invalid-config', reason: refusal } : { state: 'deploy' }
}
$effect(() => {
if (!open) return
selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy')
selectedAgents = [...draftAgents].filter((a) => checkAgentPermissions(a).state === 'deploy')
})
const runnable = $derived(isFlow ? 'flow' : 'script')
// Named after what is actually listed, so the title is not a second, vaguer copy of the section
// headings below it. "Unsaved changes detected" is taken by the leave-the-page guard, which means
// the opposite of this dialog: there, unlisted work is about to be lost.
const title = $derived.by(() => {
const triggers = draftTriggers.length > 0
const agents = draftAgents.length > 0
if (triggers && agents) return 'Draft triggers and agents detected'
if (agents) return 'Draft agents detected'
return 'Draft triggers detected'
})
</script>
<ConfirmationModal
{open}
{title}
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
on:canceled={() => dispatch('canceled')}
on:confirmed={() => dispatch('confirmed', { selectedTriggers, selectedAgents })}
>
<div class="flex flex-col w-full gap-8 pb-4">
{#if draftTriggers.length > 0}
<div class="flex flex-col gap-2">
<div class="text-secondary text-sm">
{`Your ${runnable} has draft triggers. Select which draft triggers to deploy with the ${runnable}. Undeployed draft triggers will be permanently deleted.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Triggers to deploy</th>
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftTriggers as trigger}
{@const SvelteComponent = triggerIconMap[trigger.type]}
{@const permission = checkSavePermissions(trigger)}
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
<tr
class={twMerge(
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<td class={twMerge('text-center py-1 px-4')}>
<div class="flex flex-row items-center gap-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={14}
class={isSelectedTrigger ? 'text-accent' : 'text-hint'}
/>
{#if trigger.isPrimary}
<Star size={8} class="absolute -mt-3 ml-3 text-accent" />
{/if}
</div>
<!-- `TriggerLabel` emits its badges as bare siblings, so the gap between them
is the container's to set, as the other call sites do. -->
<div class="flex grow min-w-0 items-center gap-2 text-left">
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
</div>
</div>
</td>
<td class="text-left py-1">
{#if permission === 'deploy'}
<div class="flex justify-start">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<Badge color="red">Admin only</Badge>
{:else if permission === 'invalid-config'}
<Badge color="red">Invalid config</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</DataTable>
</div>
</div>
{/if}
{#if draftAgents.length > 0}
<div class="flex flex-col gap-2">
<div class="text-secondary text-sm">
Saved agents this flow uses have unsaved changes. Select which ones to deploy with the
flow. An agent kept as a draft stays editable, and the flow runs the agent as currently
deployed.
</div>
<div class={draftAgents.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Agents to deploy</th>
<!-- Wider than the trigger table's: "Keep as draft" does not fit its w-32. -->
<th class="w-48 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftAgents as agent (agent.path)}
{@const permission = checkAgentPermissions(agent)}
{@const isSelectedAgent = selectedAgents.some((a) => a.path === agent.path)}
<tr
class={twMerge(
'transition-colors border-t border-gray-200 dark:border-gray-700',
permission.state === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<!-- No fixed height, unlike the trigger table's `h-12` row: a never-deployed agent
carries a second line of warning under the path, so the row grows with it. -->
<td class="py-2 px-4">
<div class="flex flex-row items-center gap-2">
<Bot size={14} class={isSelectedAgent ? 'text-accent' : 'text-hint'} />
<div class="flex grow min-w-0 flex-col text-left">
<div class="flex items-center gap-2 min-w-0">
<!-- Dimmed rather than struck through: keeping a draft destroys nothing, so the
trigger table's deletion styling would say the wrong thing. -->
<span
class={twMerge(
'truncate text-xs',
isSelectedAgent ? '' : 'text-tertiary'
)}
title={agent.path}
>
{agent.path}
</span>
{#if agent.noDeployed}
<Badge small color="indigo">Never deployed</Badge>
{/if}
</div>
{#if agent.noDeployed && !isSelectedAgent}
<!-- The section's promise — the flow falls back to the deployed agent — does not
hold for one that has never been deployed. There is nothing to fall back to,
so the flow would land naming a path that does not resolve. -->
<span class="text-xs text-red-600 dark:text-red-400 whitespace-normal">
Never deployed, so the flow will not run until this agent is deployed.
</span>
{/if}
</div>
</div>
</td>
<!-- Right-aligned, unlike the trigger column: this one is `w-48` to fit "Keep as
draft", so left-aligned content would float in the slack instead of lining up
with the trigger rows above it. -->
<td class="text-right py-1 pr-2">
{#if permission.state === 'deploy'}
<div class="flex justify-end">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedAgent ? 'deploy' : 'discard'}
on:selected={(e) => toggleAgent(agent, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label="Keep as draft"
value={'discard'}
{item}
small
class="justify-center"
/>
<ToggleButton
label={agent.noDeployed ? 'Deploy' : 'Update'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission.state === 'read-only'}
<Badge color="red" title="You do not have write access to this agent">
Read-only
</Badge>
{:else}
<Badge color="red" title={permission.reason}>Invalid config</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</DataTable>
</div>
</div>
{/if}
</div>
</ConfirmationModal>
@@ -1,168 +0,0 @@
<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import { createEventDispatcher, untrack } from 'svelte'
import type { Trigger } from '$lib/components/triggers/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import Badge from '../badge/Badge.svelte'
interface Props {
open?: boolean
draftTriggers?: Trigger[]
isFlow?: boolean
}
let { open = $bindable(false), draftTriggers = [], isFlow = false }: Props = $props()
let selectedTriggers: Trigger[] = $state(untrack(() => draftTriggers))
const dispatch = createEventDispatcher<{
canceled: void
confirmed: { selectedTriggers: Trigger[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
}
function checkSavePermissions(trigger: Trigger) {
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
$effect(() => {
open &&
(selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy'))
})
</script>
<ConfirmationModal
{open}
title="Draft triggers detected !"
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
on:canceled={() => dispatch('canceled')}
on:confirmed={() => dispatch('confirmed', { selectedTriggers })}
>
<div class="flex flex-col w-full gap-8 pb-4">
<div class="text-secondary text-sm">
{`${isFlow ? 'Your flow' : 'Your script'} has draft triggers. Select which draft triggers to deploy with the ${isFlow ? 'flow' : 'script'}. Undeployed
draft triggers will be permanently deleted.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Triggers to deploy</th>
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftTriggers as trigger}
{@const SvelteComponent = triggerIconMap[trigger.type]}
{@const permission = checkSavePermissions(trigger)}
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
<tr
class={twMerge(
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<td class={twMerge('text-center py-1 px-4')}>
<div class="flex flex-row items-center gap-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={14}
class={isSelectedTrigger ? 'text-accent' : 'text-hint'}
/>
{#if trigger.isPrimary}
<Star size={8} class="absolute -mt-3 ml-3 text-accent" />
{/if}
</div>
<div class="flex grow min-w-0 items-center text-left">
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
</div>
</div>
</td>
<td class="text-left py-1">
{#if permission === 'deploy'}
<div class="flex justify-start">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<Badge color="red">Admin only</Badge>
{:else if permission === 'invalid-config'}
<Badge color="red">Invalid config</Badge>
{/if}
</td>
</tr>
{/each}
{#if draftTriggers.length === 0}
<tr>
<td colspan="3" class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
No draft triggers found
</td>
</tr>
{/if}
</tbody>
</DataTable>
</div>
</div>
</ConfirmationModal>
@@ -6,6 +6,7 @@ import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
import { logReusableAgentUsage } from './agentTelemetry'
import {
@@ -84,6 +85,95 @@ export interface AgentResourceState {
wsSpecific: boolean
}
/**
* Why writing this draft to its resource would be refused, if it would. Shared with the flow's
* deploy dialog, which lists the drafts of the agents a flow links: an agent the editor's own
* Deploy button rejects must not be deployable from a flow either.
*
* `currentPath` is the path the draft is being deployed to; pass undefined where the caller has
* none of its own to compare against.
*/
export function agentDraftDeployRefusal(
state: AgentResourceState,
currentPath: string | undefined
): string | undefined {
// 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(state.args)
if (transformValued.length > 0) {
const fields = transformValued.map((key) => AGENT_BRAIN_LABELS[key] ?? key)
const many = fields.length > 1
return `${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.`
}
// 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(state.args)
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.
if (currentPath && state.path !== currentPath) {
return `This draft renames the agent to ${state.path}. Deploy it from the resource 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.
if (state.resource_type && state.resource_type !== 'ai_agent') {
return `This draft is a ${state.resource_type} resource, not an agent.`
}
return undefined
}
/**
* Write an agent to its resource from the state the editor holds, which can be ahead of the
* persisted draft row: the form stays editable while a deploy is in flight. Surfaces that deploy
* the row itself go through `deployDraft` instead.
*
* `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.
*/
type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?: true }
async function writeAgentResource(
workspace: string,
state: AgentResourceState,
noDeployed: boolean
): Promise<AgentWriteResult> {
const body = {
path: state.path,
value: state.args,
description: state.description,
labels: state.labels,
ws_specific: state.wsSpecific
}
try {
if (noDeployed) {
// A create needs a type, and every caller proved this path is an agent before offering it.
await ResourceService.createResource({
workspace,
requestBody: { ...body, resource_type: state.resource_type ?? 'ai_agent' }
})
} else {
// The type the caller proved is as old as its own 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 or the dialog stayed open.
const current = await ResourceService.getResource({ workspace, path: state.path })
const refused = agentEditorRefusal(state.path, current.resource_type)
if (refused) {
return { ok: false, error: refused, notAnAgent: true }
}
await ResourceService.updateResource({ workspace, path: state.path, requestBody: body })
}
} catch (err) {
return { ok: false, error: `Could not save agent: ${err}` }
}
return { ok: true }
}
export interface AgentDraftOptions {
/** The `ai_agent` resource being edited. */
path: () => string | undefined
@@ -219,6 +309,16 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
// config before the autosave lands.
state =
((r as any).draft as AgentResourceState | undefined) ?? structuredClone(deployedState)
// Adopt the row's timestamp as this tab's baseline. Without it the first save from
// each tab goes out with no `last_sync`, which the backend treats as unconditional
// and so silently overwrites another tab's newer draft. It also clears any parked
// conflict or failure for the key: a conflict is deliberately sticky (the retry
// keeps the same baseline), and nothing else mounts a resolver for `resource`
// drafts, so re-opening the agent is the only place it can be resolved.
UserDraftDbSyncer.recordRemoteSync(
{ workspace: ws, itemKind: 'resource', path },
(r as { draft_saved_at?: string }).draft_saved_at
)
loading = false
await sync.maybeRestore()
},
@@ -237,42 +337,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
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)
const refused = agentDraftDeployRefusal(s, opts.path())
if (refused) {
sendUserToast(refused, true)
return false
}
// The form stays editable while the request is in flight, so everything below works from a
@@ -280,39 +347,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
// 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' }
})
const written = await writeAgentResource(ws, 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.
if (written.notAnAgent) {
refuse(written.error)
} 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
})
sendUserToast(written.error, true)
}
} 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
@@ -6,6 +6,8 @@
* 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.
*/
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
export interface AgentEditorTarget {
path: string
/** The workspace the opener operates on; the nav workspace when absent. */
@@ -61,3 +63,25 @@ export function markAgentWritten(workspace: string | undefined, path: string) {
export function agentWriteCount(workspace: string | undefined, path: string | undefined): number {
return agentWrites[writeKey(workspace, path)] ?? 0
}
/** How many times each agent's DRAFT has been saved, for the surfaces that display an agent by
* fetching it. A draft write moves no deployed version, so `agentWriteCount` never sees it, and a
* card keyed on that alone would keep describing the config a test no longer runs. */
let agentDraftSaves = $state<Record<string, number>>({})
/** Every writer in this document goes through the draft syncer this editor, the generic resource
* editor so one subscription answers for them all, firing when the write lands rather than on
* each keystroke. `resource` is the item kind agent draft rows use. In-memory: a save in another
* tab never arrives here, so a card lags it until reload, while what a test runs is read live. */
UserDraftDbSyncer.onAnySaved(({ workspace, itemKind, path }) => {
if (itemKind !== 'resource') return
const key = writeKey(workspace, path)
agentDraftSaves[key] = (agentDraftSaves[key] ?? 0) + 1
})
export function agentDraftSaveCount(
workspace: string | undefined,
path: string | undefined
): number {
return agentDraftSaves[writeKey(workspace, path)] ?? 0
}
@@ -13,6 +13,10 @@ export type ReusableAgentEvent =
| 'linked'
/** A linked step was forked back into a standalone agent. */
| 'unlinked'
/** A linked agent's unsaved draft was deployed alongside the flow that uses it. */
| 'draft_deployed_with_flow'
/** A linked agent's unsaved draft was left as a draft when its flow was deployed. */
| 'draft_kept_on_deploy'
export function logReusableAgentUsage(event: ReusableAgentEvent): void {
logFeatureUsage('ai_agent', 'reusable', { key: event })
@@ -29,6 +29,7 @@
inputTransformsToAgentConfig,
type AIAgentConfig
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_TOOLS_ROW } from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
@@ -223,19 +224,6 @@
})
})
/** 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
@@ -183,7 +183,9 @@
const moduleIds = new Set(linkedModulesForAgent(scope, path))
moduleIds.add(at.host.moduleId)
return Promise.all(
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId))
// With the draft: a deploy leaves none, but a version restore leaves the draft standing and
// it is still what a test of the host step would run.
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId, true))
)
}
@@ -4,7 +4,7 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import Path from '$lib/components/Path.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ResourceService, type InputTransform } from '$lib/gen'
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
@@ -19,14 +19,24 @@
type AIAgentConfig,
type AgentTool
} from '../agentResourceUtils'
import { agentWriteCount, markAgentWritten, openAgentEditor } from '../agentEditorStore.svelte'
import {
agentDraftSaveCount,
agentWriteCount,
markAgentWritten,
openAgentEditor
} from '../agentEditorStore.svelte'
import {
setLinkedAgentTools,
clearLinkedAgentTools,
linkedModulesForAgent,
linkedToolsScope
} from '../linkedAgentToolsStore.svelte'
import { logReusableAgentUsage } from '../agentTelemetry'
import { claimLinkedToolsFetch } from '../flowState'
import { AgentDraftUnavailable, fetchAgentWithDraft } from '../linkedAgentDrafts'
import type { AgentResourceState } from '../agentDraft.svelte'
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import type { AgentTool as AgentToolStrict } from '../agentToolUtils'
import { resource } from 'runed'
import { untrack } from 'svelte'
@@ -65,6 +75,10 @@
// 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))
// Draft saves as well, for the link fetch: the card shows what a test of this step would run,
// and that is the draft. Only the deploy moves `writes`, so without this the card would keep
// describing the config the agent held before it was edited.
let draftSaves = $derived(agentDraftSaveCount(ws, agent))
let saveDrawer: Drawer | undefined = $state()
let newPath = $state('')
@@ -75,14 +89,18 @@
type LinkedInfo = {
// What this result was fetched for. runed's resource neither aborts nor tags a superseded
// 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.
// the current (ws, agent, writes, draftSaves). `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. `draftSaves` does the
// same for a draft save, which the card follows just as closely.
ws?: string
path?: string
writes: number
draftSaves: number
config: AIAgentConfig
tools: AgentTool[]
/** The config shown came from the agent's unsaved draft rather than the deployed resource. */
fromDraft: boolean
providerPath?: string
providerOk: boolean
}
@@ -90,14 +108,37 @@
// A linked agent is rigid and read-only: its brain and tools come from the resource. We
// 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).
// The draft when there is one, since that is what a test of this step runs.
let linkedResource = resource(
() => ({ ws, path: agent, writes }),
async ({ ws, path, writes }): Promise<LinkedInfo> => {
() => ({ ws, path: agent, writes, draftSaves }),
async ({ ws, path, writes, draftSaves }): Promise<LinkedInfo> => {
if (!ws || !path) {
return { ws, path, writes, config: {}, tools: [], providerOk: true }
return {
ws,
path,
writes,
draftSaves,
config: {},
tools: [],
fromDraft: false,
providerOk: true
}
}
let response: Resource
let draft: AgentResourceState | undefined
try {
;({ response, draft } = 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
response = await ResourceService.getResource({ workspace: ws, path })
}
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig & {
provider?: { resource?: string }
}
const res = await ResourceService.getResource({ workspace: ws, path })
const cfg = (res.value ?? {}) as AIAgentConfig & { provider?: { resource?: string } }
const tools = (cfg.tools ?? []) as AgentTool[]
const providerRef = cfg.provider?.resource
const providerPath =
@@ -116,8 +157,10 @@
ws,
path,
writes,
draftSaves,
config: cfg,
tools,
fromDraft: draft != undefined,
providerPath,
providerOk
}
@@ -129,7 +172,13 @@
let loadedInfo = $state<LinkedInfo | undefined>(undefined)
$effect(() => {
const current = linkedResource.current
if (current && current.ws === ws && current.path === agent && current.writes === writes) {
if (
current &&
current.ws === ws &&
current.path === agent &&
current.writes === writes &&
current.draftSaves === draftSaves
) {
loadedInfo = current
}
})
@@ -140,6 +189,12 @@
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
let providerPath = $derived(linkedInfo?.providerPath)
let providerOk = $derived(linkedInfo?.providerOk ?? true)
// 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.
let hasDraft = $derived(
getLocalDraftHint(ws, 'resource', agent ?? '') ?? linkedInfo?.fromDraft ?? false
)
/** The agent the card is about: the one this step links to, or the one being edited. */
let cardPath = $derived(agent)
// The version eval runs are recorded against. The resource does not hold it; its newest history
@@ -190,9 +245,18 @@
}
const loaded = linkedInfo
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[], agent)
// Every step of this flow linking this agent, not just this one. Tools belong to the agent,
// so the sibling steps show the same set, and only the selected step mounts this card:
// without them a draft saved from here leaves their nodes on what the flow load resolved,
// while a test of those steps runs the draft. Claimed like this card's own publish, so a
// sibling's in-flight fetch cannot land afterwards and put the old tools back.
const modules = new Set(linkedModulesForAgent(toolScope, agent))
modules.add(moduleId)
for (const id of modules) {
claimLinkedToolsFetch(toolScope, id)
// linkedResource types tools loosely; they are the same resource tools the store holds.
setLinkedAgentTools(toolScope, id, 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.
@@ -358,13 +422,15 @@
// `tools` is one array per module value, so it identifies the step itself — the path alone
// would not, since a replacement can carry the same link.
const stepMarker = tools
const res = await ResourceService.getResource({ workspace: ws, path })
// The draft, like the card above and like a test of this step: forking the deployed value
// while the card displays a drafted prompt would hand back something the user never saw.
const { response, draft } = await fetchAgentWithDraft(path, ws)
// The module may have been replaced while the fetch was in flight (undo, session drafts);
// applying a stale fork would overwrite the restored state.
if (agent !== path || tools !== stepMarker) {
return false
}
const cfg = (res.value ?? {}) as AIAgentConfig
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig
// Preserve the flow-local inputs already wired in the step.
const local: Record<string, InputTransform> = {}
for (const key of AGENT_FLOW_LOCAL_KEYS) {
@@ -448,6 +514,15 @@
v{version}
</Badge>
{/if}
{#if hasDraft}
<Tooltip class="inline-flex items-center shrink-0">
<Badge small color="indigo">Draft</Badge>
{#snippet text()}
This agent has unsaved changes. Testing this flow runs the draft, and deploying the
flow offers to deploy it.
{/snippet}
</Tooltip>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
{#if brainParams.length > 0 || inheritedTools.length > 0}
+29 -5
View File
@@ -5,6 +5,7 @@ import { get } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import { isFlowModuleTool, agentToolToFlowModule, type AgentTool } from './agentToolUtils'
import { linkedToolsScope, setLinkedAgentTools } from './linkedAgentToolsStore.svelte'
import { fetchAgentWithDraft, normalizeAgentRef } from './linkedAgentDrafts'
import { loadFlowModuleState } from './flowStateUtils.svelte'
import { emptyFlowModuleState } from './utils.svelte'
import type { StateStore } from '$lib/utils'
@@ -90,7 +91,9 @@ async function mapFlowModule(
// the graph can render its tool nodes. They are display-only (their inputs are edited in
// the step panel, which infers schemas itself), so no per-tool module state is loaded —
// resource tool ids are not flow-unique and must not key into the flow state.
await publishLinkedAgentTools(agentRef, workspace, scope, flowModule.id)
// Drafts included: every caller of `initFlowState` is a flow editor, where the graph has
// to show the tools a test would run. Read-only viewers publish for themselves.
await publishLinkedAgentTools(agentRef, workspace, scope, flowModule.id, true)
} 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.
@@ -119,11 +122,17 @@ export async function publishLinkedAgentTools(
agentRef: string,
workspace: string | undefined,
scope: string,
moduleId: string
moduleId: string,
/** Resolve from the agent's unsaved draft when there is one. Editors pass true so the graph
* shows the tool set a test would run; read-only viewers pass false, since a run they are
* displaying used the deployed agent. Required rather than defaulted: an editor call site that
* forgets it republishes the deployed tools over the drafted ones, which reads as the graph
* spontaneously reverting. */
withDraft: boolean
) {
const genKey = `${scope}:${moduleId}`
const gen = claimLinkedToolsFetch(scope, moduleId)
const tools = await resolveLinkedAgentTools(agentRef, workspace)
const tools = await resolveLinkedAgentTools(agentRef, workspace, withDraft)
if (linkedToolFetchGen.get(genKey) === gen) {
setLinkedAgentTools(scope, moduleId, tools, agentRef)
}
@@ -155,12 +164,27 @@ export function claimLinkedToolsFetch(scope: string, moduleId: string): number {
// resource is missing or inaccessible so a broken link never stalls the flow load.
export async function resolveLinkedAgentTools(
agentRef: string,
workspace?: string
workspace: string | undefined,
withDraft: boolean
): Promise<AgentTool[]> {
const ws = workspace ?? get(workspaceStore)
if (!ws) return []
const path = agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
const path = normalizeAgentRef(agentRef)
try {
if (withDraft) {
try {
const { response, draft } = await fetchAgentWithDraft(path, ws)
const value = (draft?.args ?? response.value) as { tools?: AgentTool[] } | undefined
return (value?.tools ?? []) as AgentTool[]
} catch {
// The draft read failed for any reason. This is a display, not a run, so fall through to
// the deployed tools rather than showing an agent with none: an empty node list reads as
// "the agent lost its tools" instead of "we could not reach the server". The paths that
// act on a draft — the previews and the deploy dialog — surface the failure instead.
// Not rethrowing anything here: the outer catch turns every throw into `[]`, so a
// rethrow would skip the very fallback this exists for.
}
}
const res = await ResourceService.getResource({ workspace: ws, path })
return ((res.value as { tools?: AgentTool[] } | undefined)?.tools ?? []) as AgentTool[]
} catch {
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
inlineAgentDraft,
inlineAgentDrafts,
loadLinkedAgentDrafts,
type LinkedAgentDraft
} from './linkedAgentDrafts'
import { ResourceService, type FlowModule, type FlowValue } from '$lib/gen'
type AiAgentValue = Extract<FlowModule['value'], { type: 'aiagent' }>
function linkedStep(input_transforms: Record<string, any>): AiAgentValue {
return {
type: 'aiagent',
agent: 'f/team/support',
tool_inputs: { t1: { query: { type: 'javascript', expr: 'flow_input.q' } } },
input_transforms
} as unknown as AiAgentValue
}
describe('inlineAgentDraft', () => {
// The overlay order is the worker's: the resource brain first, the step's flow-local inputs on
// top. Reversing it would run the agent author's own `user_message` instead of the flow's.
it('keeps the step wired to the flow while the brain comes from the draft', () => {
const inlined = inlineAgentDraft(
linkedStep({
user_message: { type: 'javascript', expr: 'flow_input.question' },
user_attachments: { type: 'static', value: [] }
}),
{
provider: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/team/openai' },
system_prompt: 'answer in french',
user_message: 'the default the agent carries',
tools: [{ id: 't1', summary: 'search' }]
} as any
)
expect(inlined.agent).toBeUndefined()
expect(inlined.tools).toEqual([{ id: 't1', summary: 'search' }])
expect(inlined.input_transforms).toEqual({
provider: {
type: 'static',
value: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/team/openai' }
},
system_prompt: { type: 'static', value: 'answer in french' },
user_message: { type: 'javascript', expr: 'flow_input.question' },
user_attachments: { type: 'static', value: [] }
})
// Host bindings are the step's, not the agent's, and the worker overlays them either way.
expect(inlined.tool_inputs).toEqual({
t1: { query: { type: 'javascript', expr: 'flow_input.q' } }
})
})
// A linked step carries only the flow-local inputs, but one persisted before linking existed can
// still hold stale brain transforms. They must not shadow the draft the test is meant to run.
it('drops brain transforms the step still carries', () => {
const inlined = inlineAgentDraft(
linkedStep({
user_message: { type: 'static', value: 'hi' },
system_prompt: { type: 'static', value: 'stale' }
}),
{ system_prompt: 'from the draft' } as any
)
expect(inlined.input_transforms).toEqual({
system_prompt: { type: 'static', value: 'from the draft' },
user_message: { type: 'static', value: 'hi' }
})
})
})
describe('inlineAgentDrafts', () => {
// The index is keyed on the bare path while a step may name its agent `$res:`-prefixed, and the
// walk has to reach inside branches and loops. Miss either and every preview silently runs the
// deployed agent — the failure this whole path exists to prevent, and a silent one.
it('reaches a $res:-prefixed link nested in a branch', () => {
const value = {
modules: [
{
id: 'b',
value: {
type: 'branchone',
default: [],
branches: [
{
modules: [
{
id: 'inner',
value: {
type: 'aiagent',
agent: '$res:f/team/support',
tools: [],
input_transforms: { user_message: { type: 'static', value: 'hi' } }
}
}
]
}
]
}
}
]
} as unknown as FlowValue
const drafts = new Map<string, LinkedAgentDraft>([
['f/team/support', { args: { system_prompt: 'drafted' } } as unknown as LinkedAgentDraft]
])
const inner = (inlineAgentDrafts(value, drafts).modules[0].value as any).branches[0].modules[0]
expect(inner.value.agent).toBeUndefined()
expect(inner.value.input_transforms.system_prompt).toEqual({
type: 'static',
value: 'drafted'
})
// The input the flow supplies survives the rewrite.
expect(inner.value.input_transforms.user_message).toEqual({ type: 'static', value: 'hi' })
})
})
// 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.
describe('loadLinkedAgentDrafts error handling', () => {
function failWith(status: number | undefined) {
return async () => {
const err: Error & { status?: number } = new Error('boom')
err.status = status
throw err
}
}
beforeEach(() => {
vi.restoreAllMocks()
})
it.each([401, 403, 404])('treats %i as no draft', async (status) => {
vi.spyOn(ResourceService, 'getResource').mockImplementation(failWith(status) as any)
await expect(loadLinkedAgentDrafts(['f/team/support'], 'ws')).resolves.toEqual(new Map())
})
it.each([500, undefined])('propagates %s rather than reporting no draft', async (status) => {
vi.spyOn(ResourceService, 'getResource').mockImplementation(failWith(status) as any)
await expect(loadLinkedAgentDrafts(['f/team/support'], 'ws')).rejects.toThrow(
'Could not load the agent f/team/support'
)
})
})
@@ -0,0 +1,229 @@
import {
ResourceService,
type FlowModule,
type FlowValue,
type InputTransform,
type Resource
} from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { canWrite } from '$lib/utils'
import type { UserExt } from '$lib/stores'
import { dfs } from './dfs'
import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils'
import type { AgentResourceState } from './agentDraft.svelte'
import type { AgentTool } from './agentToolUtils'
/** A step names its agent bare or as `$res:<path>`/`res://<path>`; all three are the same agent,
* and a draft index has to answer for a lookup written any of those ways. Same normalization as
* `linkedAgentToolsStore`, and as the `trim_start_matches` the worker applies. */
export function normalizeAgentRef(agentRef: string): string {
return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '')
}
/** Every `ai_agent` resource this flow links to, deduped. `dfs` walks agent tool nodes as well as
* branches and loops, so a nested linked agent tool is included. */
export function linkedAgentPaths(value: FlowValue | undefined): string[] {
if (!value?.modules) return []
const paths = new Set<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) {
paths.add(normalizeAgentRef(v.agent))
}
}
return [...paths]
}
/**
* The unsaved draft for an agent, freshest first: the cell an open agent editor is writing, then
* what a `get_draft` response carried.
*
* Only the live cell is reliably current, and only while an editor holds it: `releaseEntry` drops
* the cached write at refcount 0 on purpose, so once the agent editor closes the persisted row is
* the sole answer. Read through `fetchAgentWithDraft`, which settles that row first.
*/
export function agentDraftState(
response: { draft?: unknown },
path: string,
workspace: string | undefined
): AgentResourceState | undefined {
const live = UserDraft.get<AgentResourceState>('resource', path, { workspace })
return live ?? (response.draft as AgentResourceState | undefined)
}
/** A refusal that already names the agent and says what is wrong with it, so a caller wrapping it
* would only repeat itself. */
export class AgentDraftUnavailable extends Error {}
/**
* An agent's resource together with the draft a run of it would use.
*
* The flush is what makes the answer current. Autosave is debounced by 1.5s (10s ceiling), and
* closing the agent editor releases the in-memory cell without cancelling that pending POST so
* testing or deploying right after closing would otherwise read a row the last edits have not
* reached yet. `flush` replays the parked save and is a no-op when there is none.
*/
export async function fetchAgentWithDraft(
path: string,
workspace: string
): Promise<{ response: Resource; draft: AgentResourceState | undefined }> {
const query = { workspace, itemKind: 'resource' as const, path }
await UserDraftDbSyncer.flush(query)
// `flush` resolves whether or not the save actually landed: `postSave` catches network and
// server errors into its failure map, and answers a conflicting write by parking a snapshot,
// returning normally in both cases. The row about to be read is then older than the edit still
// held in the browser, and nothing downstream could tell. Running that row is a test of the
// wrong agent; deploying it is worse, because the deploy deletes the draft and takes the newer
// edit with it. Neither is recoverable from here, so refuse the read.
const failure = UserDraftDbSyncer.getState(query).failureMessage
if (failure) {
throw new AgentDraftUnavailable(`The unsaved changes to ${path} could not be saved: ${failure}`)
}
if (UserDraftDbSyncer.getConflict(query).conflict) {
throw new AgentDraftUnavailable(
`The unsaved changes to ${path} could not be saved because it was edited elsewhere. Open the agent to resolve it.`
)
}
const response = await ResourceService.getResource({ workspace, path, getDraft: true })
return { response, draft: agentDraftState(response, path, workspace) }
}
/** One linked agent whose resource the user has an unsaved draft for. */
export interface LinkedAgentDraft {
path: string
/** The draft's resource value: what a run of this agent would use. */
args: AIAgentConfig
/** The whole draft row, as the resource editors write it — the deploy payload. */
state: AgentResourceState
/** No deployed row at this path, so deploying has to create rather than update. */
noDeployed: boolean
/** Of the deployed resource, for `agentDraftCanWrite`. */
extraPerms: Record<string, boolean>
}
/** Whether `user` may write this agent's resource. Split from the load so that resolving the
* drafts of a whole flow costs no `whoami` only the deploy dialog needs the answer, and it
* looks the user up once for every agent it lists. */
export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | undefined): boolean {
return canWrite(draft.path, draft.extraPerms, user)
}
/** A link that cannot resolve for the user rather than because something went wrong: the agent was
* deleted, or sits in a folder they cannot read. Both are ordinary states of a rigid link, and
* 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 {
const status = (err as { status?: number } | null | undefined)?.status
return status === 401 || status === 403 || status === 404
}
/**
* The unsaved draft of every given `ai_agent` path, for the paths that have one.
*
* Throws when a path fails to load for any reason other than being missing or unreadable, so a
* caller cannot mistake an outage for an agent with nothing unsaved.
*/
export async function loadLinkedAgentDrafts(
paths: string[],
workspace: string | undefined
): Promise<Map<string, LinkedAgentDraft>> {
const out = new Map<string, LinkedAgentDraft>()
if (!workspace || paths.length === 0) return out
await Promise.all(
paths.map(async (path) => {
let response: Resource
let draft: AgentResourceState | undefined
try {
;({ response, draft } = await fetchAgentWithDraft(path, workspace))
} catch (err) {
if (isExpectedLinkFailure(err)) return
if (err instanceof AgentDraftUnavailable) throw err
throw new Error(`Could not load the agent ${path}: ${err}`)
}
if (!draft) return
out.set(path, {
path,
args: (draft.args ?? {}) as AIAgentConfig,
state: draft,
noDeployed: Boolean((response as { no_deployed?: boolean }).no_deployed),
extraPerms: response.extra_perms ?? {}
})
})
)
return out
}
/**
* Every argument a saved agent carries, as a static input transform. Not only the keys the agent
* form renders: a run reads them all, and an agent holding its own `user_message` answers with it
* when nothing overrides it. `tools` is the step's own roster rather than an input, so it rides on
* the module's `tools` key instead.
*/
export 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
}
type AiAgentValue = Extract<FlowModule['value'], { type: 'aiagent' }>
/**
* The standalone step a linked step's draft would run as: the draft's brain and tools inlined, with
* the step's own flow-local inputs kept on top.
*
* The overlay order is the worker's (`ai_executor.rs`): its linked branch interpolates the whole
* resource brain and only then writes `user_message`/`user_attachments` back from the step's own
* args. `tool_inputs` stays untouched the worker overlays it onto the tools in both branches, so
* an inlined step keeps the host flow's tool bindings.
*/
export function inlineAgentDraft(value: AiAgentValue, args: AIAgentConfig): AiAgentValue {
const { agent: _agent, ...rest } = value
return {
...rest,
tools: (args.tools ?? []) as AgentTool[],
input_transforms: {
...agentArgsToTransforms(args),
...flowLocalInputs(value.input_transforms as Record<string, InputTransform>)
}
} as AiAgentValue
}
/**
* Replace every linked agent step that has a draft with the draft's own configuration, so a preview
* runs what the agent editor is showing rather than the deployed resource. Returns a new value: the
* flow editor hands its live store object to previews.
*/
export function inlineAgentDrafts(
value: FlowValue,
drafts: Map<string, LinkedAgentDraft>
): FlowValue {
if (drafts.size === 0) return value
// JSON rather than `structuredClone`: the flow editor's value is a Svelte `$state` proxy, which
// `structuredClone` refuses outright. A flow value is JSON by definition — it is about to be
// posted as one — so the round trip loses nothing this preview would have carried.
const next = JSON.parse(JSON.stringify(value)) as FlowValue
for (const module of dfs(next.modules ?? [], (m) => m)) {
const v = module?.value as AiAgentValue | undefined
if (v?.type !== 'aiagent' || !v.agent) continue
const draft = drafts.get(normalizeAgentRef(v.agent))
if (!draft) continue
module.value = inlineAgentDraft(v, draft.args)
}
return next
}
/** Load the drafts this flow's linked agents have and inline them. The whole substitution, for a
* caller holding nothing but the value it is about to preview. */
export async function withAgentDrafts(
value: FlowValue,
workspace: string | undefined
): Promise<FlowValue> {
const paths = linkedAgentPaths(value)
if (paths.length === 0) return value
return inlineAgentDrafts(value, await loadLinkedAgentDrafts(paths, workspace))
}
@@ -8,7 +8,9 @@ vi.mock('./agentToolUtils', () => ({
isFlowModuleTool: () => false,
agentToolToFlowModule: (t: unknown) => t
}))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: (f: (v: string) => void) => (f('ws'), () => {}) } }))
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: (f: (v: string) => void) => (f('ws'), () => {}) }
}))
import {
claimLinkedToolsFetch,
@@ -38,8 +40,8 @@ describe('linked tools fetch guard', () => {
)
.mockResolvedValueOnce({ value: { tools: [tool('new')] } } as never)
const stale = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
await publishLinkedAgentTools('f/a/new', 'ws', scope, 'step')
const stale = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
await publishLinkedAgentTools('f/a/new', 'ws', scope, 'step', false)
release?.({ value: { tools: [tool('old')] } })
await stale
@@ -56,7 +58,7 @@ describe('linked tools fetch guard', () => {
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
setLinkedAgentTools(scope, 'step', [tool('kept')], 'u/admin/a')
invalidateLinkedToolsFetches(scope)
@@ -73,7 +75,7 @@ describe('linked tools fetch guard', () => {
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step')
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
claimLinkedToolsFetch(scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('direct')], 'u/admin/a')
release?.({ value: { tools: [tool('stale')] } })
@@ -17,6 +17,7 @@ import { get } from 'svelte/store'
import type { FlowModuleState } from './flowState'
import { type PickableProperties, dfs } from './previousResults'
import { forEachFlowModule } from './dfs'
import { withAgentDrafts } from './linkedAgentDrafts'
import { NEVER_TESTED_THIS_FAR } from './models'
import { sendUserToast } from '$lib/toast'
import type { ExtendedOpenFlow } from './types'
@@ -187,6 +188,12 @@ export function jobsToResults(jobs: Job[]) {
})
}
/**
* Run the flow the editor currently holds. A step linked to a saved agent runs that agent's
* unsaved draft when there is one (`withAgentDrafts`), so testing exercises what the agent editor
* is showing rather than the deployed resource the same rule the agent editor's own test pane
* follows. The value passed in is left alone; only what goes to the server is substituted.
*/
export async function runFlowPreview(
args: Record<string, any>,
flow: OpenFlow & { tag?: string },
@@ -198,14 +205,15 @@ export async function runFlowPreview(
// editor; falls back to the navigation workspace for full-page previews.
workspace?: string
) {
const newFlow = flow
const ws = workspace ?? get(workspaceStore) ?? ''
const value = await withAgentDrafts(flow.value, ws)
return await JobService.runFlowPreview({
workspace: workspace ?? get(workspaceStore) ?? '',
workspace: ws,
requestBody: {
args,
value: newFlow.value,
value,
path: path,
tag: newFlow.tag,
tag: flow.tag,
restarted_from: restartedFrom,
temp_script_refs: tempScriptRefs
},
+26
View File
@@ -349,6 +349,32 @@ export const UserDraft = {
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
},
/**
* Drop this key's in-memory cell and cached write WITHOUT touching the
* server. For callers that have already deleted the row by another route
* (a deploy) and only need the local mirror to stop answering `get`/`has`
* with a value that is gone.
*
* MUST be used instead of `remove` there. `remove` POSTs its own
* `value: null`, and that POST is debounced and carries whatever
* `last_sync` is left which a preceding successful delete has already
* cleared. The backend treats a delete with no `last_sync` as
* unconditional, so the second POST lands ~1.5s later with nothing to
* compare against and removes a draft saved in the meantime.
*/
forgetLocal(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Same as `remove`: clear the cell so live observers see the delete, and arm
// `skipNextSync` so the mirror does not turn that write into a POST of its own.
entry.skipNextSync = true
entry.state.val = undefined
}
writtenCache.delete(mk)
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
+68 -3
View File
@@ -7,7 +7,7 @@ vi.mock('$lib/gen', () => ({
DraftService: { deleteDraft: vi.fn() },
AppService: {},
VariableService: {},
ResourceService: {},
ResourceService: { getResource: vi.fn(), updateResource: vi.fn(), createResource: vi.fn() },
ScheduleService: {},
HttpTriggerService: {},
WebsocketTriggerService: {},
@@ -21,7 +21,9 @@ vi.mock('$lib/gen', () => ({
AzureTriggerService: {},
EmailTriggerService: {}
}))
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({ UserDraftDbSyncer: { save: vi.fn() } }))
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
UserDraftDbSyncer: { save: vi.fn(), recordRemoteSync: vi.fn() }
}))
vi.mock('$lib/workspaceDrafts.svelte', () => ({ invalidateWorkspaceDrafts: vi.fn() }))
vi.mock('$lib/workspaceComparison', () => ({ invalidateWorkspaceComparison: vi.fn() }))
vi.mock('$lib/localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
@@ -30,7 +32,8 @@ vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.
vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() }))
vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] }))
import { ScriptService, FlowService } from '$lib/gen'
import { ScriptService, FlowService, ResourceService } from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
// draftBaseIsStale compares a draft's base pointer against the deployed head
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
@@ -124,3 +127,65 @@ describe('deployDraft preserves on_behalf_of', () => {
)
})
})
// The resource branch reads the item again when the deploy lands, and falls back to the deployed
// row when the draft has gone. That row keeps its value under `value` and carries no `args` at
// all, so reading it as a draft (`value: d.args ?? {}`) would replace a live resource with `{}`.
describe('deployDraft: resource with no draft', () => {
beforeEach(() => vi.clearAllMocks())
it('writes nothing rather than `{}` over the deployed value', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' }
} as any)
// `noop` is what lets a caller deploying one specific draft tell "nothing to promote" apart
// from "deployed", instead of reporting an agent as deployed that was never written.
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({
success: true,
noop: true
})
expect(ResourceService.updateResource).not.toHaveBeenCalled()
expect(ResourceService.createResource).not.toHaveBeenCalled()
// Nor does it touch the draft row. There was none of this user's to delete, so the only row
// the cleanup could reach is one written after the read: an edit destroyed without ever
// having been deployed. Clearing the baseline would be the same bug by another route, since
// a delete with no baseline is the unconditional one.
expect(UserDraftDbSyncer.save).not.toHaveBeenCalled()
expect(UserDraftDbSyncer.recordRemoteSync).not.toHaveBeenCalled()
})
it('still deploys normally when the draft is there, and keys the cleanup to the row it read', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' },
draft_saved_at: '2026-01-01T00:00:00Z',
draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } }
} as any)
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true })
expect(ResourceService.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ value: { system_prompt: 'drafted' } })
})
)
// The draft delete that follows is conditional on this baseline. With no baseline the backend
// deletes unconditionally, destroying a draft saved between the read and the delete without
// ever having deployed it, so the timestamp has to be the one from the row just promoted.
expect(UserDraftDbSyncer.recordRemoteSync).toHaveBeenCalledWith(
{ workspace: 'ws', itemKind: 'resource', path: 'f/support/triage_agent' },
'2026-01-01T00:00:00Z'
)
expect(UserDraftDbSyncer.save).toHaveBeenCalledWith(
expect.objectContaining({ path: 'f/support/triage_agent', value: null, immediate: true })
)
// Order is the whole point: a delete issued before the seed carries whatever baseline the tab
// happened to hold, which for a caller that only read through a listing is none at all.
expect(vi.mocked(UserDraftDbSyncer.recordRemoteSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(UserDraftDbSyncer.save).mock.invocationCallOrder[0]
)
})
})
+47 -13
View File
@@ -435,8 +435,13 @@ export async function deployDraft(
path: string,
workspace: string,
opts: { draftOnly?: boolean; rawApp?: boolean; deploymentMessage?: string } = {}
): Promise<DeployResult> {
): Promise<DeployResult & { noop?: boolean }> {
const { draftOnly = false, rawApp = false, deploymentMessage } = opts
// Set when the branch found nothing to promote and wrote nothing. Success, because the item is
// already at the value a deploy would have left it at and its stale draft state still wants
// clearing — but a caller deploying one specific draft it showed the user has to be able to tell
// that apart from having deployed it.
let noop = false
try {
if (kind === 'raw_app' || (kind === 'app' && rawApp)) {
// Raw apps bundle their source files and deploy via the raw-app
@@ -579,10 +584,34 @@ export async function deployDraft(
}
void deployed
} else if (kind === 'resource') {
const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.resource!(workspace, path))
const overlay = await OVERLAY_GETTERS.resource!(workspace, path)
// Adopt the row this promote is based on as the baseline for the delete below. Without one
// the backend deletes unconditionally, so a draft saved between that read and the delete is
// destroyed having never been deployed — a caller that only ever read through a listing has
// no baseline of its own to supply. With it the delete is refused instead and the newer
// draft survives, which is the recoverable outcome of the two. Only ever seeded, never
// cleared: passing no timestamp drops whatever baseline the tab already held, which would
// turn that same delete back into an unconditional one.
if (overlay?.draft_saved_at) {
UserDraftDbSyncer.recordRemoteSync(
{ workspace, itemKind: kind, path },
overlay.draft_saved_at
)
}
const { deployed, draft: d, hasDraft } = splitOverlay(overlay)
// ResourceEditor's `ResourceState` draft shape:
// { path, description, args, resource_type?, labels?, wsSpecific }
if (draftOnly) {
// The deployed row is a different shape (`value`, `ws_specific`, no `args` at all), and
// `splitOverlay` hands it back as the draft side when the draft row has gone — deployed or
// discarded from another tab between the listing and this click. Reading it as a draft is
// what made `value: d.args ?? {}` replace a live resource with `{}`. Nothing to promote
// then, so write nothing and fall through to the cleanup below, which clears the stale
// local draft hint and the drafts listing. The item is already at the value a successful
// deploy would have left it at, so this reports success rather than an error, matching
// what the other kinds end up doing when their own draft is gone.
if (!hasDraft) {
noop = true
} else if (draftOnly) {
await ResourceService.createResource({
workspace,
requestBody: {
@@ -633,8 +662,8 @@ export async function deployDraft(
return { success: false, error: `Deploy not supported for draft kind ${kind}` }
}
// Delete the draft at its STORAGE path (the row key, = the `path` arg).
// Two reasons it must happen here for every kind, mirroring the editors'
// post-deploy `discardDraftAfterDeploy(draftPath)`:
// Two reasons it must happen here for every kind that promoted something,
// mirroring the editors' post-deploy `discardDraftAfterDeploy(draftPath)`:
// - Drawer kinds (variable / resource / triggers) aren't deleted by
// their create/update endpoints at all.
// - script/flow/app/raw_app DO delete server-side, but only the draft at
@@ -642,13 +671,18 @@ export async function deployDraft(
// synthetic `u/{user}/draft_{uuid}` storage path ≠ `d.path`, so its
// draft row survives the deploy and keeps listing. Deleting the
// storage-path draft removes it (a no-op when the server already did).
await UserDraftDbSyncer.save({
workspace,
itemKind: kind,
path,
value: null,
immediate: true
})
// Skipped when nothing was promoted: the read that set `noop` found no draft of this user's to
// delete, so the only row this could reach is one written after it — destroying an edit that
// was never deployed, and never even listed.
if (!noop) {
await UserDraftDbSyncer.save({
workspace,
itemKind: kind,
path,
value: null,
immediate: true
})
}
// Mutated the workspace's Server Drafts — refresh every mounted reader.
invalidateWorkspaceDrafts(workspace)
// The DEPLOYED state moved: cached fork comparisons involving this
@@ -659,7 +693,7 @@ export async function deployDraft(
// so the syncer-owned hint won't auto-clear — clear it explicitly.
// (Idempotent: the drawer-kind delete above already cleared it.)
setLocalDraftHint(workspace, kind, path, false)
return { success: true }
return noop ? { success: true, noop: true } : { success: true }
} catch (e: any) {
return { success: false, error: e?.body ?? e?.message ?? String(e) }
}