mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
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:
co-authored by
Claude Opus 5
parent
48a56158c1
commit
7feaf619cf
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
+323
@@ -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>
|
||||
-168
@@ -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}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user