Compare commits

...

3 Commits

Author SHA1 Message Date
centdix
9c7c7b6f1e refactor: stabilize flow delete execution
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-13 11:23:28 +02:00
centdix
a837cb03cc refactor: unify flow delete planning
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 16:56:19 +02:00
centdix
15e6a60f7a refactor: extract flow delete helpers
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-07 15:57:39 +02:00
10 changed files with 1094 additions and 351 deletions

View File

@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import type { FlowModule } from '$lib/gen'
import {
collectFlowNodeIds,
findAgentToolOwner,
removeAgentToolOwner
} from './agentToolTree'
function makeRawModule(id: string): FlowModule {
return {
id,
summary: id,
value: { type: 'rawscript', content: '', language: 'python3', input_transforms: {} } as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('findAgentToolOwner', () => {
it('finds a direct tool owner in an ai agent', () => {
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(makeRawModule('lookup_user'))])
expect(findAgentToolOwner([rootAgent], 'lookup_user')).toMatchObject({
agentId: 'root_agent',
toolIndex: 0,
depth: 1
})
})
it('finds a nested tool owner inside a nested ai agent tool', () => {
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
expect(findAgentToolOwner([rootAgent], 'create_ticket')).toMatchObject({
agentId: 'support_agent',
toolIndex: 0,
depth: 2
})
})
})
describe('removeAgentToolOwner', () => {
it('removes the matched tool and returns its subtree ids', () => {
const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(nestedAgent)
])
const owner = findAgentToolOwner([rootAgent], 'support_agent')
expect(owner).toBeDefined()
expect(removeAgentToolOwner(owner!)).toEqual({
tool: expect.objectContaining({ id: 'support_agent' }),
removedIds: ['support_agent', 'create_ticket']
})
expect((rootAgent.value as any).tools).toHaveLength(1)
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual(['lookup_user'])
})
})
describe('collectFlowNodeIds', () => {
it('includes ai agent tool ids when deleting an ai agent flow module', () => {
const agent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
])
expect(collectFlowNodeIds(agent)).toEqual([
'root_agent',
'lookup_user',
'support_agent',
'create_ticket'
])
})
})

View File

@@ -0,0 +1,162 @@
import type { FlowModule } from '$lib/gen'
import { isFlowModuleTool, type AgentTool, type FlowModuleTool } from './agentToolUtils'
type FlowNodeLike = Pick<FlowModule, 'id' | 'value'>
export type AgentToolOwner = {
agentId: string
tools: AgentTool[]
toolIndex: number
tool: AgentTool
depth: number
}
export type RemovedAgentTool = {
tool: AgentTool
removedIds: string[]
}
export function findAgentToolOwner(
modules: FlowModule[],
toolId: string
): AgentToolOwner | undefined {
return findAgentToolOwnerInModules(modules, toolId, 0)
}
export function removeAgentToolOwner(owner: AgentToolOwner): RemovedAgentTool | undefined {
const candidate = owner.tools[owner.toolIndex]
if (!candidate || candidate.id !== owner.tool.id) {
return undefined
}
owner.tools.splice(owner.toolIndex, 1)
return {
tool: candidate,
removedIds: collectAgentToolIds(candidate)
}
}
export function collectFlowNodeIds(module: FlowModule): string[] {
return collectFlowNodeIdsFromNode(module)
}
export function collectAgentToolIds(tool: AgentTool): string[] {
return collectFlowNodeIdsFromNode(tool as FlowModuleTool)
}
function findAgentToolOwnerInModules(
modules: FlowModule[],
toolId: string,
depth: number
): AgentToolOwner | undefined {
for (const module of modules) {
const owner = findAgentToolOwnerInNode(module, toolId, depth)
if (owner) {
return owner
}
}
return undefined
}
function findAgentToolOwnerInNode(
node: FlowNodeLike,
toolId: string,
depth: number
): AgentToolOwner | undefined {
if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') {
return findAgentToolOwnerInModules(node.value.modules, toolId, depth)
}
if (node.value.type === 'branchall') {
for (const branch of node.value.branches) {
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
if (owner) {
return owner
}
}
return undefined
}
if (node.value.type === 'branchone') {
const defaultOwner = findAgentToolOwnerInModules(node.value.default, toolId, depth)
if (defaultOwner) {
return defaultOwner
}
for (const branch of node.value.branches) {
const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth)
if (owner) {
return owner
}
}
return undefined
}
if (node.value.type !== 'aiagent') {
return undefined
}
const toolIndex = node.value.tools.findIndex((tool) => tool.id === toolId)
if (toolIndex !== -1) {
return {
agentId: node.id,
tools: node.value.tools,
toolIndex,
tool: node.value.tools[toolIndex],
depth: depth + 1
}
}
for (const tool of node.value.tools) {
if (!isFlowModuleTool(tool)) {
continue
}
const owner = findAgentToolOwnerInNode(tool as FlowNodeLike, toolId, depth + 1)
if (owner) {
return owner
}
}
return undefined
}
function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] {
const ids = [node.id]
if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') {
for (const module of node.value.modules) {
ids.push(...collectFlowNodeIds(module))
}
return ids
}
if (node.value.type === 'branchall') {
for (const branch of node.value.branches) {
for (const module of branch.modules) {
ids.push(...collectFlowNodeIds(module))
}
}
return ids
}
if (node.value.type === 'branchone') {
for (const module of node.value.default) {
ids.push(...collectFlowNodeIds(module))
}
for (const branch of node.value.branches) {
for (const module of branch.modules) {
ids.push(...collectFlowNodeIds(module))
}
}
return ids
}
if (node.value.type === 'aiagent') {
for (const tool of node.value.tools) {
ids.push(...collectAgentToolIds(tool))
}
}
return ids
}

View File

@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import type { FlowModule } from '$lib/gen'
import { removeAgentToolByIdDeep } from './agentToolUtils'
function makeRawModule(id: string): FlowModule {
return {
id,
summary: id,
value: { type: 'rawscript', content: '', language: 'python3' } as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('removeAgentToolByIdDeep', () => {
it('removes a direct tool from an ai agent', () => {
const tool = makeFlowModuleTool(makeRawModule('lookup_user'))
const agent = makeAiAgent('agent', [tool])
const removed: string[] = []
expect(removeAgentToolByIdDeep([agent], 'lookup_user', (x) => removed.push(x.id))).toBe(true)
expect((agent.value as any).tools).toEqual([])
expect(removed).toEqual(['lookup_user'])
})
it('removes a nested tool from a nested ai agent tool', () => {
const nestedTool = makeFlowModuleTool(makeRawModule('create_ticket'))
const nestedAgent = makeAiAgent('support_agent', [nestedTool])
const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)])
const removed: string[] = []
expect(removeAgentToolByIdDeep([rootAgent], 'create_ticket', (x) => removed.push(x.id))).toBe(
true
)
expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([])
expect(removed).toEqual(['create_ticket'])
})
})

View File

@@ -108,66 +108,6 @@ export function createWebsearchTool(id: string): WebsearchTool {
}
}
/**
* Remove an AI agent tool by id, recursively traversing nested modules and nested AI agents.
* Returns true when a matching tool was found and removed.
*/
export function removeAgentToolByIdDeep(
modules: FlowModule[],
id: string,
onRemove?: (tool: AgentTool) => void
): boolean {
for (const module of modules) {
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (removeAgentToolByIdDeep(module.value.modules, id, onRemove)) {
return true
}
continue
}
if (module.value.type === 'branchall') {
for (const branch of module.value.branches) {
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
return true
}
}
continue
}
if (module.value.type === 'branchone') {
if (removeAgentToolByIdDeep(module.value.default, id, onRemove)) {
return true
}
for (const branch of module.value.branches) {
if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) {
return true
}
}
continue
}
if (module.value.type !== 'aiagent') {
continue
}
const toolIndex = module.value.tools.findIndex((tool) => tool.id === id)
if (toolIndex !== -1) {
const [removed] = module.value.tools.splice(toolIndex, 1)
onRemove?.(removed)
return true
}
const nestedToolModules = module.value.tools
.filter(isFlowModuleTool)
.map((tool) => agentToolToFlowModule(tool))
if (removeAgentToolByIdDeep(nestedToolModules, id, onRemove)) {
return true
}
}
return false
}
/**
* Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc.
* Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import {
GroupedModulesProxy,
type ExtendedOpenFlow
} from '$lib/components/graph/groupedModulesProxy.svelte'
import type { FlowModule, OpenFlow } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import { executeDeletePlan, prepareDeleteRequest } from './flowDeleteController'
function makeRawModule(id: string, expr?: string): FlowModule {
return {
id,
summary: id,
value: {
type: 'rawscript',
content: '',
language: 'python3',
input_transforms: expr
? {
user_input: {
type: 'javascript',
expr
}
}
: {}
} as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('flowDeleteController', () => {
it('prepares confirmations and executes grouped ai-agent deletes end to end', () => {
const agent = makeAiAgent('agent_step', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(
makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
)
])
const dependent = makeRawModule('dependent_step', 'results.create_ticket?.value')
const flowStore = {
val: {
summary: 'Flow',
value: {
modules: [agent, dependent],
groups: [
{
summary: 'Agent group',
start_id: 'agent_step',
end_id: 'agent_step'
}
]
}
} as OpenFlow
} satisfies StateStore<OpenFlow>
const proxy = new GroupedModulesProxy(flowStore as unknown as StateStore<ExtendedOpenFlow>)
const displayState = new GroupDisplayState(() => flowStore.val.value.groups ?? [])
const request = prepareDeleteRequest({
ids: ['agent_step'],
flow: flowStore.val,
tree: proxy.items,
proxy,
displayState
})
expect(request?.needsDependencyConfirmation).toBe(true)
expect(request?.plan.affectedGroups).toHaveLength(1)
expect(request?.plan.affectedGroups[0]).toMatchObject({
summary: 'Agent group',
start_id: 'agent_step',
end_id: 'agent_step'
})
const selectionManager = {
clearSelection: vi.fn(),
selectId: vi.fn()
}
const flowStateStore = {
val: {
agent_step: {},
lookup_user: {},
support_agent: {},
create_ticket: {},
dependent_step: {}
}
} as StateStore<any>
const onDelete = vi.fn()
const result = executeDeletePlan(request!.plan, {
flowStore,
flowStateStore,
selectionManager,
proxy,
displayState,
onDelete
})
expect(result.removedStateIds).toEqual([
'agent_step',
'lookup_user',
'support_agent',
'create_ticket'
])
expect(flowStore.val.value.modules.map((module) => module.id)).toEqual(['dependent_step'])
expect(flowStore.val.value.groups ?? []).toEqual([])
expect(Object.keys(flowStateStore.val)).toEqual(['dependent_step'])
expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step')
expect(onDelete).toHaveBeenCalledWith('agent_step')
})
})

View File

@@ -0,0 +1,135 @@
import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
import type { FlowModule, OpenFlow } from '$lib/gen'
import { push, type History } from '$lib/history.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import { collectFlowNodeIds } from './agentToolTree'
import {
createDeletePlan,
removeDeletePlanTools,
type DeletePlan,
type DeleteTarget
} from './flowDeleteUtils'
import type { FlowState } from './flowState'
import { deleteFlowStateById } from './flowStateUtils.svelte'
import { dfsByModule } from './previousResults'
export type PreparedDeleteRequest = {
plan: DeletePlan
needsDependencyConfirmation: boolean
}
export type DeletePlanExecutionContext = {
history?: History<OpenFlow>
flowStore: StateStore<OpenFlow>
flowStateStore: StateStore<FlowState>
selectionManager: Pick<SelectionManager, 'clearSelection' | 'selectId'>
proxy: GroupedModulesProxy
displayState: GroupDisplayState
onDelete?: (id: string) => void
}
export type DeletePlanExecutionResult = {
removedStateIds: string[]
removedToolStateIds: string[]
}
export function prepareDeleteRequest(args: {
ids: string[]
flow: OpenFlow
tree: FlowStructureNode[]
proxy: GroupedModulesProxy
displayState: GroupDisplayState
}): PreparedDeleteRequest | undefined {
const plan = createDeletePlan(args)
if (!plan) {
return undefined
}
return {
plan,
needsDependencyConfirmation: Object.keys(plan.dependents).length > 0
}
}
export function executeDeletePlan(
plan: DeletePlan,
args: DeletePlanExecutionContext
): DeletePlanExecutionResult {
push(args.history, args.flowStore.val)
const hasPreprocessor = Boolean(args.flowStore.val.value.preprocessor_module)
const removedStructureStateIds = resolveLiveStructureStateIds(
plan.targets,
args.flowStore.val.value.modules,
hasPreprocessor
)
if (plan.selection.kind === 'clear') {
args.selectionManager.clearSelection()
} else {
args.selectionManager.selectId(plan.selection.id)
}
if (plan.targets.some((target) => target.kind === 'preprocessor')) {
args.flowStore.val.value.preprocessor_module = undefined
}
if (plan.structureIds.length > 0) {
const structureDelete = args.proxy.prepareDelete(plan.structureIds, {
displayState: args.displayState
})
structureDelete.commit({ removeDuplicates: plan.removeDuplicates })
}
const removedToolStateIds = removeDeletePlanTools(plan.targets, args.flowStore.val.value.modules)
const removedStateIds = uniqueIds([...removedStructureStateIds, ...removedToolStateIds])
for (const id of removedStateIds) {
deleteFlowStateById(id, args.flowStateStore)
}
refreshStateStore(args.flowStore)
if (plan.inputIds.length === 1) {
args.onDelete?.(plan.targets[0]?.id ?? plan.inputIds[0])
}
return {
removedStateIds,
removedToolStateIds
}
}
function resolveLiveStructureStateIds(
targets: DeleteTarget[],
modules: FlowModule[],
hasPreprocessor: boolean
): string[] {
const removedIds: string[] = []
for (const target of targets) {
if (target.kind === 'preprocessor') {
if (hasPreprocessor) {
removedIds.push(target.id)
}
continue
}
if (target.kind !== 'structure_node') {
continue
}
const module = dfsByModule(target.id, modules)[0]
if (module) {
removedIds.push(...collectFlowNodeIds(module))
}
}
return uniqueIds(removedIds)
}
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids)]
}

View File

@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../aiProviderStorage', () => ({
loadStoredConfig: () => undefined
}))
vi.mock('./flowInfers', () => ({
AI_AGENT_SCHEMA: { properties: {} }
}))
import type { FlowModule, OpenFlow } from '$lib/gen'
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
import {
createDeletePlan,
removeDeletePlanTools,
resolveDeleteTargets
} from './flowDeleteUtils'
function makeRawModule(id: string, expr?: string): FlowModule {
return {
id,
summary: id,
value: {
type: 'rawscript',
content: '',
language: 'python3',
input_transforms: expr
? {
user_input: {
type: 'javascript',
expr
}
}
: {}
} as any
} as FlowModule
}
function makeAiAgent(id: string, tools: any[]): FlowModule {
return {
id,
summary: id,
value: {
type: 'aiagent',
tools,
input_transforms: {}
} as any
} as FlowModule
}
function makeFlowModuleTool(module: FlowModule) {
return {
id: module.id,
summary: module.summary,
value: {
tool_type: 'flowmodule',
...module.value
}
}
}
describe('resolveDeleteTargets', () => {
it('resolves structure nodes, preprocessor, and ai tools while pruning nested descendants', () => {
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
])
const tree: FlowStructureNode[] = [{ id: 'root_agent', kind: 'leaf', branches: [] }]
const { targets, missingIds } = resolveDeleteTargets(
tree,
[rootAgent],
['preprocessor', 'root_agent', 'create_ticket', 'missing_tool'],
true
)
expect(targets.map((target) => target.kind)).toEqual(['preprocessor', 'structure_node'])
expect(targets[1].stateIds).toEqual([
'root_agent',
'lookup_user',
'support_agent',
'create_ticket'
])
expect(missingIds).toEqual(['missing_tool'])
})
})
describe('createDeletePlan', () => {
it('collects subtree dependents and routes structure deletes through the proxy helper', () => {
const agent = makeAiAgent('agent_step', [makeFlowModuleTool(makeRawModule('lookup_user'))])
const dependent = makeRawModule('dependent_step', 'results.lookup_user?.value')
const flow: OpenFlow = {
summary: 'Flow',
value: {
modules: [agent, dependent]
}
}
const tree: FlowStructureNode[] = [
{ id: 'agent_step', kind: 'leaf', branches: [] },
{ id: 'dependent_step', kind: 'leaf', branches: [] }
]
const commit = vi.fn()
const prepareDelete = vi.fn(() => ({
affectedGroups: [],
duplicateGroups: [],
commit
}))
const proxy = {
prepareDelete
} as unknown as GroupedModulesProxy
const plan = createDeletePlan({
ids: ['agent_step'],
flow,
tree,
proxy,
displayState: new GroupDisplayState(() => [])
})
expect(prepareDelete).toHaveBeenCalledWith(['agent_step'], expect.any(Object))
expect(plan?.plannedStateIds).toEqual(['agent_step', 'lookup_user'])
expect(plan?.structureIds).toEqual(['agent_step'])
expect(plan?.affectedGroups).toEqual([])
expect(plan?.dependents).toEqual({
dependent_step: ['results.lookup_user?.value']
})
expect(plan?.selection).toEqual({ kind: 'select', id: 'dependent_step' })
})
})
describe('removeDeletePlanTools', () => {
it('removes nested tools before their parents and returns every removed id', () => {
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
])
const tree: FlowStructureNode[] = []
const { targets } = resolveDeleteTargets(
tree,
[rootAgent],
['support_agent', 'create_ticket'],
false
)
expect(removeDeletePlanTools(targets, [rootAgent])).toEqual(['support_agent', 'create_ticket'])
expect((rootAgent.value as any).tools).toEqual([])
})
it('re-resolves live owners before removing planned tool targets', () => {
const rootAgent = makeAiAgent('root_agent', [
makeFlowModuleTool(makeRawModule('lookup_user')),
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
])
const tree: FlowStructureNode[] = []
const { targets } = resolveDeleteTargets(tree, [rootAgent], ['support_agent'], false)
;(rootAgent.value as any).tools.unshift(makeFlowModuleTool(makeRawModule('new_lookup')))
expect(removeDeletePlanTools(targets, [rootAgent])).toEqual(['support_agent', 'create_ticket'])
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual([
'new_lookup',
'lookup_user'
])
})
})

View File

@@ -0,0 +1,274 @@
import type { FlowGroup, GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
import { findInStructure, type FlowStructureNode } from '$lib/components/graph/flowStructure'
import type { FlowModule, OpenFlow } from '$lib/gen'
import {
collectAgentToolIds,
collectFlowNodeIds,
findAgentToolOwner,
removeAgentToolOwner,
type AgentToolOwner
} from './agentToolTree'
import type { AgentTool } from './agentToolUtils'
import { dfs } from './dfs'
import { getDependentComponents } from './flowExplorer'
import { dfsByModule } from './previousResults'
export type DeleteSelection =
| { kind: 'clear' }
| {
kind: 'select'
id: string
}
type DeleteTargetBase = {
id: string
stateIds: string[]
}
export type PreprocessorDeleteTarget = DeleteTargetBase & {
kind: 'preprocessor'
}
export type StructureDeleteTarget = DeleteTargetBase & {
kind: 'structure_node'
}
export type AgentToolDeleteTarget = DeleteTargetBase & {
kind: 'agent_tool'
expectedOwnerAgentId: string
}
export type DeleteTarget =
| PreprocessorDeleteTarget
| StructureDeleteTarget
| AgentToolDeleteTarget
export type DeletePlan = {
inputIds: string[]
targets: DeleteTarget[]
plannedStateIds: string[]
dependents: Record<string, string[]>
selection: DeleteSelection
structureIds: string[]
affectedGroups: FlowGroup[]
removeDuplicates: boolean
}
export type ResolvedDeleteTargets = {
targets: DeleteTarget[]
missingIds: string[]
}
export function resolveDeleteTargets(
tree: FlowStructureNode[],
modules: FlowModule[],
ids: string[],
hasPreprocessor: boolean
): ResolvedDeleteTargets {
const targets: DeleteTarget[] = []
const missingIds: string[] = []
const seenIds = new Set<string>()
for (const id of ids) {
if (seenIds.has(id)) {
continue
}
seenIds.add(id)
if (id === 'preprocessor') {
if (hasPreprocessor) {
targets.push({ kind: 'preprocessor', id, stateIds: [id] })
} else {
missingIds.push(id)
}
continue
}
if (findInStructure(tree, id)) {
const module = findFlowModuleById(id, modules)
if (module) {
targets.push({
kind: 'structure_node',
id,
stateIds: collectFlowNodeIds(module)
})
continue
}
}
const owner = findAgentToolOwner(modules, id)
if (owner) {
targets.push({
kind: 'agent_tool',
id,
expectedOwnerAgentId: owner.agentId,
stateIds: collectAgentToolIds(owner.tool)
})
continue
}
missingIds.push(id)
}
return {
targets: pruneNestedTargets(targets),
missingIds
}
}
export function createDeletePlan(args: {
ids: string[]
flow: OpenFlow
tree: FlowStructureNode[]
proxy: GroupedModulesProxy
displayState: GroupDisplayState
}): DeletePlan | undefined {
const { targets } = resolveDeleteTargets(
args.tree,
args.flow.value.modules,
args.ids,
Boolean(args.flow.value.preprocessor_module)
)
if (targets.length === 0) {
return undefined
}
const structureIds = targets
.filter((target): target is StructureDeleteTarget => target.kind === 'structure_node')
.map((target) => target.id)
const structureDelete =
structureIds.length > 0
? args.proxy.prepareDelete(structureIds, { displayState: args.displayState })
: undefined
const plannedStateIds = uniqueIds(targets.flatMap((target) => target.stateIds))
return {
inputIds: args.ids,
targets,
plannedStateIds,
dependents: collectDeleteDependents(plannedStateIds, args.flow),
selection: getDeleteSelection(args.ids, plannedStateIds, args.flow.value.modules),
structureIds,
affectedGroups: structureDelete?.affectedGroups ?? [],
removeDuplicates: Boolean(structureDelete?.duplicateGroups.length)
}
}
export function removeDeletePlanTools(
targets: DeleteTarget[],
modules: FlowModule[],
onRemove?: (tool: AgentTool) => void
): string[] {
const removedIds = new Set<string>()
const toolTargets = targets
.filter((target): target is AgentToolDeleteTarget => target.kind === 'agent_tool')
.map((target) => {
const owner = findAgentToolOwner(modules, target.id)
return owner ? { owner, target } : undefined
})
.filter((entry): entry is { owner: AgentToolOwner; target: AgentToolDeleteTarget } =>
Boolean(entry)
)
.sort((left, right) => {
if (left.owner.depth !== right.owner.depth) {
return right.owner.depth - left.owner.depth
}
if (left.owner.tools === right.owner.tools) {
return right.owner.toolIndex - left.owner.toolIndex
}
return 0
})
for (const { owner } of toolTargets) {
const removed = removeAgentToolOwner(owner)
if (!removed) {
continue
}
onRemove?.(removed.tool)
for (const id of removed.removedIds) {
removedIds.add(id)
}
}
return [...removedIds]
}
function findFlowModuleById(id: string, modules: FlowModule[]): FlowModule | undefined {
return dfsByModule(id, modules)[0]
}
function pruneNestedTargets(targets: DeleteTarget[]): DeleteTarget[] {
const descendantIds = new Set<string>()
for (const target of targets) {
for (const stateId of target.stateIds) {
if (stateId !== target.id) {
descendantIds.add(stateId)
}
}
}
return targets.filter((target) => !descendantIds.has(target.id))
}
function collectDeleteDependents(ids: string[], flow: OpenFlow): Record<string, string[]> {
const deletingSet = new Set(ids)
const dependents: Record<string, string[]> = {}
for (const id of ids) {
const dependencies = getDependentComponents(id, flow)
for (const [dependentId, expressions] of Object.entries(dependencies)) {
if (deletingSet.has(dependentId)) {
continue
}
dependents[dependentId] = [...(dependents[dependentId] ?? []), ...expressions]
}
}
return dependents
}
function getDeleteSelection(
ids: string[],
deletedIds: string[],
modules: FlowModule[]
): DeleteSelection {
if (ids.length !== 1) {
return { kind: 'clear' }
}
const [id] = ids
if (id === 'preprocessor') {
return { kind: 'select', id: 'Input' }
}
const orderedIds = dfs(modules, (module) => module.id)
const index = orderedIds.indexOf(id)
const deletedSet = new Set(deletedIds)
for (let i = index - 1; i >= 0; i--) {
if (!deletedSet.has(orderedIds[i])) {
return { kind: 'select', id: orderedIds[i] }
}
}
for (let i = index + 1; i < orderedIds.length; i++) {
if (!deletedSet.has(orderedIds[i])) {
return { kind: 'select', id: orderedIds[i] }
}
}
if (index === -1) {
return { kind: 'select', id: 'settings-metadata' }
}
return { kind: 'select', id: 'settings-metadata' }
}
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids)]
}

View File

@@ -23,7 +23,7 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { getAllModules, getDependentComponents } from '../flowExplorer'
import { getAllModules } from '../flowExplorer'
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
@@ -50,21 +50,24 @@
createWebsearchTool,
createAiAgentTool,
SPECIAL_TOOL_KINDS,
agentToolToFlowModule,
removeAgentToolByIdDeep
agentToolToFlowModule
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import type { DeletePlan } from '../flowDeleteUtils'
import { executeDeletePlan, prepareDeleteRequest } from '../flowDeleteController'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import {
GroupedModulesProxy,
type ExtendedOpenFlow
} from '$lib/components/graph/groupedModulesProxy.svelte'
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import {
GroupDisplayState,
type FlowGroup
} from '$lib/components/graph/groupEditor.svelte'
import {
type FlowStructureNode,
matchStructureNode,
dfsStructure,
findInStructure,
moduleToStructureNode
} from '$lib/components/graph/flowStructure'
@@ -253,66 +256,12 @@
}
}
/**
* Helper function to remove an AgentTool by id from the tools array
* Tools are always leaf nodes, so we just need to delete their state directly
*/
function removeAgentToolById(tools: AgentTool[], id: string): AgentTool[] {
const index = tools.findIndex((tool) => tool.id === id)
if (index != -1) {
const [removed] = tools.splice(index, 1)
deleteFlowStateById(removed.id, flowStateStore)
}
return tools
}
export function removeAtId(modules: FlowModule[], id: string): FlowModule[] {
const index = modules.findIndex((mod) => mod.id == id)
if (index != -1) {
const [removed] = modules.splice(index, 1)
const leaves = dfs([removed], (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId, flowStateStore))
return modules
}
return modules.map((mod) => {
if (mod.value.type == 'forloopflow' || mod.value.type == 'whileloopflow') {
mod.value.modules = removeAtId(mod.value.modules, id)
} else if (mod.value.type == 'branchall') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
} else if (mod.value.type == 'branchone') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
mod.value.default = removeAtId(mod.value.default, id)
} else if (mod.value.type == 'aiagent') {
mod.value.tools = removeAgentToolById(mod.value.tools, id)
}
return mod
})
}
let sidebarMode: 'list' | 'graph' = 'graph'
let minHeight = $state(0)
let flowPaneWidth = $state(0)
let compactTopbar = $derived(flowPaneWidth < 700)
export function selectNextId(id: any) {
if (flowStore.val.value.modules) {
let allIds = dfs(flowStore.val.value.modules, (mod) => mod.id)
if (allIds.length > 1) {
const idx = allIds.indexOf(id)
selectionManager.selectId(idx == 0 ? allIds[0] : allIds[idx - 1])
} else {
selectionManager.selectId('settings-metadata')
}
}
}
function findModuleById(id: string) {
return dfsByModule(id, flowStore.val.value.modules)[0]
}
@@ -354,15 +303,19 @@
}
}
let deleteCallback: (() => void) | undefined = $state(undefined)
let dependents: Record<string, string[]> = $state({})
type PendingDeleteConfirmation = {
plan: DeletePlan
}
/** Confirmation gate for actions that would empty or duplicate groups */
let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] =
$state([])
let affectedGroupsAction: (() => void) | undefined = $state(undefined)
let affectedGroupsCancel: (() => void) | undefined = $state(undefined)
let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete')
type PendingGroupAction = {
groups: FlowGroup[]
label: 'delete' | 'move'
confirm: () => void
cancel?: () => void
}
let pendingDeleteConfirmation: PendingDeleteConfirmation | undefined = $state(undefined)
let pendingGroupAction: PendingGroupAction | undefined = $state(undefined)
let graph: FlowGraphV2 | undefined = $state(undefined)
let noteMode = $state(false)
@@ -383,77 +336,53 @@
noteMode = !noteMode
}
export function deleteMultiple(ids: string[]) {
const structureIds: string[] = []
const toolIds: string[] = []
for (const id of ids) {
if (findInStructure(proxy.items, id)) {
structureIds.push(id)
} else {
toolIds.push(id)
}
}
const deletingSet = new Set(ids)
const allDeps: Record<string, string[]> = {}
for (const id of ids) {
const deps = getDependentComponents(id, flowStore.val)
for (const [depId, exprs] of Object.entries(deps)) {
if (!deletingSet.has(depId)) {
allDeps[depId] = [...(allDeps[depId] ?? []), ...exprs]
}
}
}
function applyDeletePlan(plan: DeletePlan) {
executeDeletePlan(plan, {
history,
flowStore,
flowStateStore,
selectionManager,
proxy,
displayState: groupDisplayState,
onDelete
})
}
const opts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } =
structureIds.length > 0
? proxy.prepareMutation((tree) => {
for (const id of structureIds) {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}
}, opts)
: {
emptiedGroups: [],
duplicateGroups: [],
commit: () => {}
}
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
commit({ removeDuplicates: duplicateGroups.length > 0 })
for (const id of toolIds) {
removeAgentToolByIdDeep(flowStore.val.value.modules, id, (removed) => {
deleteFlowStateById(removed.id, flowStateStore)
})
}
for (const id of ids) {
delete flowStateStore.val[id]
}
selectionManager.clearSelection()
refreshStateStore(flowStore)
function requestDelete(ids: string[]) {
const request = prepareDeleteRequest({
ids,
flow: flowStore.val,
tree: proxy.items,
proxy,
displayState: groupDisplayState
})
if (!request) {
return
}
const proceed = () => {
if (Object.keys(allDeps).length > 0) {
dependents = allDeps
deleteCallback = cb
if (request.needsDependencyConfirmation) {
pendingDeleteConfirmation = { plan: request.plan }
} else {
cb()
applyDeletePlan(request.plan)
}
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'delete'
affectedGroupsAction = proceed
if (request.plan.affectedGroups.length > 0) {
pendingGroupAction = {
groups: request.plan.affectedGroups,
label: 'delete',
confirm: proceed
}
} else {
proceed()
}
}
export function deleteMultiple(ids: string[]) {
requestDelete(ids)
}
// Operates directly on the flat module array (not the structure tree).
// Cloned modules are inserted after the originals, intentionally outside any group.
export function duplicateMultiple(ids: string[]) {
@@ -562,21 +491,21 @@
<ConfirmationModal
title="Confirm deleting step with dependents"
confirmationText="Delete step"
open={Boolean(deleteCallback)}
open={Boolean(pendingDeleteConfirmation)}
on:confirmed={() => {
if (deleteCallback) {
deleteCallback()
deleteCallback = undefined
if (pendingDeleteConfirmation) {
applyDeletePlan(pendingDeleteConfirmation.plan)
pendingDeleteConfirmation = undefined
}
}}
on:canceled={() => {
deleteCallback = undefined
pendingDeleteConfirmation = undefined
}}
>
<div class="text-primary pb-2"
>Found the following steps that will require changes after this step is deleted:</div
>
{#each Object.entries(dependents) as [k, v]}
{#each Object.entries(pendingDeleteConfirmation?.plan.dependents ?? {}) as [k, v]}
<div class="pb-3">
<h3 class="text-secondary font-semibold">{k}</h3>
<ul class="text-sm">
@@ -589,36 +518,32 @@
</ConfirmationModal>
<ConfirmationModal
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
open={affectedGroupsPending.length > 0}
title={pendingGroupAction?.groups.length === 1 ? 'Remove group?' : 'Remove groups?'}
confirmationText={pendingGroupAction?.label === 'delete' ? 'Delete step' : 'Move step'}
open={Boolean(pendingGroupAction)}
on:confirmed={() => {
affectedGroupsAction?.()
affectedGroupsPending = []
affectedGroupsAction = undefined
affectedGroupsCancel = undefined
pendingGroupAction?.confirm()
pendingGroupAction = undefined
}}
on:canceled={() => {
affectedGroupsCancel?.()
affectedGroupsPending = []
affectedGroupsAction = undefined
affectedGroupsCancel = undefined
pendingGroupAction?.cancel?.()
pendingGroupAction = undefined
}}
>
{#if affectedGroupsPending.length === 1}
{@const group = affectedGroupsPending[0]}
{#if pendingGroupAction?.groups.length === 1}
{@const group = pendingGroupAction.groups[0]}
<p
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
Are you sure you want to {affectedGroupsActionLabel} the step?</p
Are you sure you want to {pendingGroupAction.label} the step?</p
>
{:else}
<p>The following groups will be removed (empty or duplicate):</p>
<ul class="list-disc pl-4 mt-1">
{#each affectedGroupsPending as group}
{#each pendingGroupAction?.groups ?? [] as group}
<li>{group.summary || `${group.start_id} ${group.end_id}`}</li>
{/each}
</ul>
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
<p class="mt-2">Are you sure you want to {pendingGroupAction?.label} the step?</p>
{/if}
</ConfirmationModal>
</Portal>
@@ -680,78 +605,7 @@
suspendStatus={suspendStatus.val}
{flowHasChanged}
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
onDelete={(id) => {
dependents = getDependentComponents(id, flowStore.val)
if (id === 'preprocessor') {
const cb = () => {
push(history, flowStore.val)
selectionManager.selectId('Input')
flowStore.val.value.preprocessor_module = undefined
refreshStateStore(flowStore)
onDelete?.(id)
delete flowStateStore.val[id]
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
return
}
if (!findInStructure(proxy.items, id)) {
const cb = () => {
push(history, flowStore.val)
selectNextId(id)
const removed = removeAgentToolByIdDeep(flowStore.val.value.modules, id, (tool) => {
deleteFlowStateById(tool.id, flowStateStore)
})
if (!removed) return
refreshStateStore(flowStore)
onDelete?.(id)
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
return
}
const dsOpts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}, dsOpts)
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
selectNextId(id)
commit({ removeDuplicates: duplicateGroups.length > 0 })
refreshStateStore(flowStore)
onDelete?.(id)
delete flowStateStore.val[id]
}
const proceed = () => {
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'delete'
affectedGroupsAction = proceed
} else {
proceed()
}
}}
onDelete={(id) => requestDelete([id])}
onInsert={async (detail) => {
if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return
await tick()
@@ -830,10 +684,12 @@
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'move'
affectedGroupsAction = doMove
affectedGroupsCancel = () => moveManager.clearMoving()
pendingGroupAction = {
groups: affectedGroups,
label: 'move',
confirm: doMove,
cancel: () => moveManager.clearMoving()
}
} else {
doMove()
}

View File

@@ -13,6 +13,7 @@ import {
findDuplicateGroups,
removeDuplicateGroups,
flattenStructureIds,
findInStructure,
type FlowStructureNode
} from './flowStructure'
@@ -25,6 +26,12 @@ export type ExtendedOpenFlow = {
[key: string]: any
}
export type PreparedStructureDelete = {
affectedGroups: FlowGroup[]
duplicateGroups: FlowGroup[]
commit: (commitOpts?: { removeDuplicates?: boolean }) => void
}
/**
* Reactive read-only view of the flow structure tree.
* The tree is always derived from flowStore (single source of truth).
@@ -112,6 +119,30 @@ export class GroupedModulesProxy {
return { emptiedGroups, duplicateGroups, commit }
}
prepareDelete(
ids: string[],
opts?: {
displayState?: import('./groupEditor.svelte').GroupDisplayState
}
): PreparedStructureDelete {
const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation((tree) => {
for (const id of ids) {
const found = findInStructure(tree, id)
if (!found) {
continue
}
found.parentChildren.splice(found.index, 1)
}
}, opts)
return {
affectedGroups: [...emptiedGroups, ...duplicateGroups],
duplicateGroups,
commit
}
}
/**
* Convenience: prepare + auto-commit. Only use for mutations that cannot
* empty groups (e.g. inserts). Throws if groups are unexpectedly emptied.