mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
* 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>
207 lines
8.0 KiB
TypeScript
207 lines
8.0 KiB
TypeScript
import type { Schema } from '$lib/common'
|
|
import type { Flow, FlowModule } from '$lib/gen'
|
|
import { ResourceService } from '$lib/gen'
|
|
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'
|
|
|
|
export type FlowModuleState = {
|
|
schema?: Schema
|
|
previewResult?: any
|
|
previewArgs?: any
|
|
previewJobId?: string
|
|
previewSuccess?: boolean
|
|
previewLogs?: string
|
|
}
|
|
|
|
export type FlowState = Record<string, FlowModuleState>
|
|
|
|
// Latest linked-tool fetch per (scope, agent module); see the publish guard below.
|
|
const linkedToolFetchGen = new Map<string, number>()
|
|
|
|
/**
|
|
* flowStateStore represents the local state of each module indexed by its id.
|
|
* It contains data loaded that are not contained in a Flow object i.e. schemas.
|
|
* We also hold the data of the results of a test job, ran by the user.
|
|
*/
|
|
|
|
export async function initFlowState(
|
|
flow: Flow,
|
|
flowStateStore: StateStore<FlowState>,
|
|
// The acting workspace when the flow editor runs in an AI session; else the nav workspace.
|
|
workspace: string | undefined,
|
|
// Flow path half of the linked-agent tools scope; keeps agents that share a module id across
|
|
// simultaneously-shown flows from aliasing each other. Required so call sites can't silently
|
|
// publish into the '' bucket while the graph reads the real flow path.
|
|
flowPath: string
|
|
) {
|
|
const modulesState: FlowState = {}
|
|
|
|
const ws = workspace ?? get(workspaceStore)
|
|
const scope = linkedToolsScope(ws, flowPath)
|
|
await mapFlowModules(flow.value.modules, modulesState, workspace, scope)
|
|
|
|
const failureModule = flow.value.failure_module
|
|
? await loadFlowModuleState(flow.value.failure_module, workspace)
|
|
: emptyFlowModuleState()
|
|
|
|
flowStateStore.val = {
|
|
...modulesState,
|
|
failure: failureModule
|
|
}
|
|
}
|
|
|
|
/**
|
|
* mapFlowModule recursively explore the flow, following deeply nested loop and branches modules
|
|
* to build the initial state.
|
|
*/
|
|
async function mapFlowModule(
|
|
flowModule: FlowModule,
|
|
modulesState: FlowState,
|
|
workspace?: string,
|
|
scope: string = ''
|
|
) {
|
|
const value = flowModule.value
|
|
if (value.type === 'forloopflow' || value.type === 'whileloopflow') {
|
|
await mapFlowModules(value.modules, modulesState, workspace, scope)
|
|
}
|
|
|
|
if (value.type === 'branchone') {
|
|
await mapFlowModules(value.default, modulesState, workspace, scope)
|
|
}
|
|
|
|
if (value.type === 'branchone' || value.type === 'branchall') {
|
|
await Promise.all(
|
|
value.branches.map(
|
|
(branchModule: { summary?: string; skip_failure?: boolean; modules: Array<FlowModule> }) =>
|
|
mapFlowModules(branchModule.modules, modulesState, workspace, scope)
|
|
)
|
|
)
|
|
}
|
|
|
|
if (value.type === 'aiagent') {
|
|
const agentRef = (value as { agent?: string }).agent
|
|
if (agentRef) {
|
|
// A linked step's tools come from the resource (its own `tools` is empty); resolve them so
|
|
// 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.
|
|
// 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.
|
|
const tools = Array.isArray(value.tools) ? value.tools : []
|
|
await Promise.all(
|
|
tools.filter(isFlowModuleTool).map(async (tool) => {
|
|
modulesState[tool.id] = await loadFlowModuleState(agentToolToFlowModule(tool), workspace)
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
if (value.type === 'identity') {
|
|
modulesState[flowModule.id] = emptyFlowModuleState()
|
|
} else {
|
|
const flowModuleState = await loadFlowModuleState(flowModule, workspace)
|
|
modulesState[flowModule.id] = flowModuleState
|
|
}
|
|
}
|
|
|
|
// Resolve a linked agent's tools and publish them into the store. Concurrent re-resolutions of the
|
|
// same (scope, module) race — un-awaited re-inits from session-draft sync, or a link swapped while
|
|
// the previous fetch is in flight — so only the latest may publish, else a superseded agent's tools
|
|
// overwrite the newer one.
|
|
export async function publishLinkedAgentTools(
|
|
agentRef: string,
|
|
workspace: string | undefined,
|
|
scope: 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, withDraft)
|
|
if (linkedToolFetchGen.get(genKey) === gen) {
|
|
setLinkedAgentTools(scope, moduleId, tools, agentRef)
|
|
}
|
|
}
|
|
|
|
/** Supersede every in-flight fetch for a scope. A rename moves the bucket to a new key, so a fetch
|
|
* still running against the old one would publish there and then be swept forward over tools that
|
|
* resolved later under the new key. */
|
|
export function invalidateLinkedToolsFetches(scope: string) {
|
|
const prefix = `${scope}:`
|
|
for (const key of linkedToolFetchGen.keys()) {
|
|
if (key.startsWith(prefix)) {
|
|
linkedToolFetchGen.set(key, (linkedToolFetchGen.get(key) ?? 0) + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Supersede any in-flight fetch for this (scope, module) and return the new generation. Anything
|
|
* that publishes or clears tools outside `publishLinkedAgentTools` must claim first, or an older
|
|
* fetch still passes its own check and overwrites the newer result. */
|
|
export function claimLinkedToolsFetch(scope: string, moduleId: string): number {
|
|
const genKey = `${scope}:${moduleId}`
|
|
const gen = (linkedToolFetchGen.get(genKey) ?? 0) + 1
|
|
linkedToolFetchGen.set(genKey, gen)
|
|
return gen
|
|
}
|
|
|
|
// Fetch a linked agent's tool set from its `ai_agent` resource. Degrades to no tools when the
|
|
// resource is missing or inaccessible so a broken link never stalls the flow load.
|
|
export async function resolveLinkedAgentTools(
|
|
agentRef: string,
|
|
workspace: string | undefined,
|
|
withDraft: boolean
|
|
): Promise<AgentTool[]> {
|
|
const ws = workspace ?? get(workspaceStore)
|
|
if (!ws) return []
|
|
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 {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function mapFlowModules(
|
|
flowModules: FlowModule[],
|
|
modulesState: FlowState,
|
|
workspace?: string,
|
|
scope: string = ''
|
|
) {
|
|
await Promise.all(
|
|
flowModules.map((flowModule: FlowModule) =>
|
|
mapFlowModule(flowModule, modulesState, workspace, scope)
|
|
)
|
|
)
|
|
}
|