diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 4a649ba..2e1dd36 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -96,6 +96,12 @@ export type NodeTypeCapabilities = { allowsOutgoingConnections: boolean; canDependOnOtherNodes: boolean; canHaveDependentNodes: boolean; + /** + * Produces exactly one of its outputs per run. Declared by the node's type, so a routing block + * added to the server later can guard a loop here without this client learning its name. + * Optional because older servers do not send it. + */ + routesExclusively?: boolean; }; export const DEFAULT_NODE_CAPABILITIES: NodeTypeCapabilities = { @@ -106,6 +112,7 @@ export const DEFAULT_NODE_CAPABILITIES: NodeTypeCapabilities = { allowsOutgoingConnections: true, canDependOnOtherNodes: true, canHaveDependentNodes: true, + routesExclusively: false, }; export type BlockType = { @@ -225,12 +232,24 @@ export type FlowPort = { fileConstraints?: FileInputConstraints | null; }; +/** What an author has set on a connection that leads back round a loop. */ +export type FlowLoopEdgeSettings = { + maxIterations?: number | null; +}; + +/** Rounds a loop may go when its author has not said; the server's Loop container uses the same. */ +export const DEFAULT_LOOP_MAX_ITERATIONS = 10; +/** The most a connection leading back may allow. */ +export const MAX_LOOP_MAX_ITERATIONS = 100; + export type FlowBlockConnection = { id: string; sourceId: string; sourceName: string; targetId: string; targetName: string; + /** Present only on a connection leading back round a loop whose author has changed its settings. */ + loop?: FlowLoopEdgeSettings; }; export type FlowNodeDependency = { diff --git a/src/app/services/blocks/blocks-call.spec.ts b/src/app/services/blocks/blocks-call.spec.ts index 0c0d82b..8bda408 100644 --- a/src/app/services/blocks/blocks-call.spec.ts +++ b/src/app/services/blocks/blocks-call.spec.ts @@ -129,7 +129,8 @@ describe('BlocksCallService', () => { allowsIncomingConnections: true, allowsOutgoingConnections: false, canDependOnOtherNodes: false, - canHaveDependentNodes: false + canHaveDependentNodes: false, + routesExclusively: false }); expect(blockTypes.find((type) => type.type === 'LLMBlock')?.capabilities).toEqual(DEFAULT_NODE_CAPABILITIES); }); diff --git a/src/app/services/graph-selection/graph-selection.ts b/src/app/services/graph-selection/graph-selection.ts index b30e151..7de9e48 100644 --- a/src/app/services/graph-selection/graph-selection.ts +++ b/src/app/services/graph-selection/graph-selection.ts @@ -21,4 +21,14 @@ export class GraphSelectionService { if (!this.selectedConnectionId()) return; this.deleteConnectionRequestTick.update((value) => value + 1); } + + /** + * A new iteration limit for a connection leading back round a loop, for the editor to apply. + * Null asks for the default. A fresh object each time, so the same value set twice still arrives. + */ + readonly loopLimitRequest = signal<{ connectionId: string; maxIterations: number | null } | null>(null); + + requestLoopLimit(connectionId: string, maxIterations: number | null) { + this.loopLimitRequest.set({ connectionId, maxIterations }); + } } diff --git a/src/app/services/shared/flow-node-mapping.spec.ts b/src/app/services/shared/flow-node-mapping.spec.ts index 45b23fc..8d5aaf0 100644 --- a/src/app/services/shared/flow-node-mapping.spec.ts +++ b/src/app/services/shared/flow-node-mapping.spec.ts @@ -140,10 +140,15 @@ describe('toNodeCapabilities', () => { allowsIncomingConnections: true, allowsOutgoingConnections: false, canDependOnOtherNodes: false, - canHaveDependentNodes: false + canHaveDependentNodes: false, + routesExclusively: false }); }); + it('reads that a type routes exclusively, which is what lets it guard a loop', () => { + expect(toNodeCapabilities({ visualRole: 'DECISION', routesExclusively: true }).routesExclusively).toBe(true); + }); + it('falls back per-field for missing booleans and an unknown visualRole', () => { expect(toNodeCapabilities({ visualRole: 'NOT_A_ROLE', terminal: true })).toEqual({ ...DEFAULT_NODE_CAPABILITIES, diff --git a/src/app/services/shared/flow-node-mapping.ts b/src/app/services/shared/flow-node-mapping.ts index d6e0e61..7efbc37 100644 --- a/src/app/services/shared/flow-node-mapping.ts +++ b/src/app/services/shared/flow-node-mapping.ts @@ -115,6 +115,7 @@ export function toNodeCapabilities(raw: unknown): NodeTypeCapabilities { allowsIncomingConnections: toBooleanCapability(value['allowsIncomingConnections'], DEFAULT_NODE_CAPABILITIES.allowsIncomingConnections), allowsOutgoingConnections: toBooleanCapability(value['allowsOutgoingConnections'], DEFAULT_NODE_CAPABILITIES.allowsOutgoingConnections), canDependOnOtherNodes: toBooleanCapability(value['canDependOnOtherNodes'], DEFAULT_NODE_CAPABILITIES.canDependOnOtherNodes), - canHaveDependentNodes: toBooleanCapability(value['canHaveDependentNodes'], DEFAULT_NODE_CAPABILITIES.canHaveDependentNodes) + canHaveDependentNodes: toBooleanCapability(value['canHaveDependentNodes'], DEFAULT_NODE_CAPABILITIES.canHaveDependentNodes), + routesExclusively: toBooleanCapability(value['routesExclusively'], DEFAULT_NODE_CAPABILITIES.routesExclusively ?? false) }; } diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index f2b330a..844c3f2 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -4,7 +4,13 @@ import { Component, inject, Input } from '@angular/core'; import { ClassicPreset } from 'rete'; -import { FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow'; +import { + DEFAULT_LOOP_MAX_ITERATIONS, + FLOW_DEPENDANT_PORT_KEY, + FLOW_DEPENDENCY_PORT_KEY, + MAX_LOOP_MAX_ITERATIONS +} from '@models/flow'; +import type { LoopAwareConnection } from '@utilities/rete-editor'; import { GraphSelectionService } from '@services/graph-selection/graph-selection'; import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state'; @@ -12,9 +18,9 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v selector: 'app-custom-connection', standalone: true, template: ` - + - @if (isSelected) { + @if (isLoopBack) { + +
+ + @if (isSelected && !isReadonly) { + + } @else { + max {{ maxIterations }} + } +
+
+ } + @if (isSelected && !isReadonly) { { provide: GraphSelectionService, useValue: { deleteConnectionRequestTick: vi.fn(), + loopLimitRequest: vi.fn().mockReturnValue(null), selectedConnectionId: vi.fn().mockReturnValue(null), clearConnectionSelection: vi.fn() } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 1d96d29..e276437 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -10,7 +10,7 @@ import { ContainersService } from '@services/containers/containers'; import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag'; import { GraphSelectionService } from '@services/graph-selection/graph-selection'; import { EditorStateHolder } from '@stores/flow-editor'; -import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, RETE_ZOOM_RANGE, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor'; +import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, RETE_ZOOM_RANGE, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes, setLoopMaxIterations } from '@utilities/rete-editor'; import { firstValueFrom } from 'rxjs'; import { SWIMLANES_ENABLED } from '@shared/feature-flags'; @@ -44,6 +44,18 @@ export class ReteEditor implements OnChanges, OnDestroy { if (!connectionId || !rete || isReadonly) return; void this.deleteSelectedConnection(connectionId); }); + effect(() => { + const request = this.graphSelection.loopLimitRequest(); + const rete = untracked(() => this.rete); + const isReadonly = untracked(() => this.readonly()); + if (!request || !rete || isReadonly) return; + void this.applyLoopLimit(rete, request.connectionId, request.maxIterations); + }); + } + + private async applyLoopLimit(rete: ReteEditorInstance, connectionId: string, maxIterations: number | null) { + if (!await setLoopMaxIterations(rete.editor, rete.area, connectionId, maxIterations)) return; + this.flowState.updateData(exportGraph(rete.editor), { structural: true }); } readonly container = viewChild.required('editor'); @@ -267,6 +279,10 @@ export class ReteEditor implements OnChanges, OnDestroy { onWindowKeydown(event: KeyboardEvent) { if (this.readonly()) return; if (event.key !== 'Delete' && event.key !== 'Backspace') return; + // Typing in a field - a loop's iteration limit sits on its connection - is not a request to + // delete that connection. + const target = event.target as Element | null; + if (target?.closest('input, textarea, select, [contenteditable="true"]')) return; const connectionId = this.graphSelection.selectedConnectionId(); if (!connectionId || !this.rete) return; diff --git a/src/app/utilities/flow-loops.spec.ts b/src/app/utilities/flow-loops.spec.ts new file mode 100644 index 0000000..fc45391 --- /dev/null +++ b/src/app/utilities/flow-loops.spec.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { findLoopBackEdgeIds, LoopGraph } from './flow-loops'; + +// The same shapes the server's FlowLoopsTest pins, so the two keep agreeing on which connection +// leads back. +describe('findLoopBackEdgeIds', () => { + const activity = (id: string) => ({ id, routesExclusively: false }); + const router = (id: string) => ({ id, routesExclusively: true }); + const connect = (id: string, sourceId: string, targetId: string, hasLoopSettings = false) => + ({ id, sourceId, targetId, hasLoopSettings }); + + it('finds the connection a router goes round by', () => { + const graph: LoopGraph = { + nodes: [activity('source'), activity('draft'), router('check'), activity('end')], + connections: [ + connect('in', 'source', 'draft'), + connect('to-check', 'draft', 'check'), + connect('back', 'check', 'draft'), + connect('done', 'check', 'end') + ] + }; + + expect([...findLoopBackEdgeIds(graph)]).toEqual(['back']); + }); + + it('finds nothing in a flow without cycles', () => { + const graph: LoopGraph = { + nodes: [activity('a'), router('b')], + connections: [connect('ab', 'a', 'b')] + }; + + expect(findLoopBackEdgeIds(graph).size).toBe(0); + }); + + it('finds nothing in a cycle no router closes', () => { + const graph: LoopGraph = { + nodes: [activity('a'), activity('b')], + connections: [connect('ab', 'a', 'b'), connect('ba', 'b', 'a')] + }; + + expect(findLoopBackEdgeIds(graph).size).toBe(0); + }); + + it('treats a router connected to itself as a loop of one', () => { + const graph: LoopGraph = { + nodes: [activity('source'), router('retry')], + connections: [connect('in', 'source', 'retry'), connect('again', 'retry', 'retry')] + }; + + expect([...findLoopBackEdgeIds(graph)]).toEqual(['again']); + }); + + it('is not confused by a branch inside the loop', () => { + const graph: LoopGraph = { + nodes: [activity('source'), activity('draft'), router('triage'), activity('trim'), activity('expand'), + activity('rejoin'), router('check')], + connections: [ + connect('in', 'source', 'draft'), + connect('d-t', 'draft', 'triage'), + connect('short', 'triage', 'trim'), + connect('long', 'triage', 'expand'), + connect('t-r', 'trim', 'rejoin'), + connect('e-r', 'expand', 'rejoin'), + connect('r-c', 'rejoin', 'check'), + connect('back', 'check', 'draft') + ] + }; + + expect([...findLoopBackEdgeIds(graph)]).toEqual(['back']); + }); + + it('picks the connection into where the loop is entered when two routers could close it', () => { + const graph: LoopGraph = { + nodes: [activity('source'), activity('draft'), router('screen'), router('check')], + connections: [ + connect('in', 'source', 'draft'), + connect('d-s', 'draft', 'screen'), + connect('pass', 'screen', 'check'), + connect('back', 'check', 'draft') + ] + }; + + expect([...findLoopBackEdgeIds(graph)]).toEqual(['back']); + }); + + it('lets the author decide when the shape alone cannot', () => { + const ring = (marked: boolean): LoopGraph => ({ + nodes: [router('screen'), router('check')], + connections: [connect('pass', 'screen', 'check'), connect('back', 'check', 'screen', marked)] + }); + + expect(findLoopBackEdgeIds(ring(false)).size).toBe(0); + expect([...findLoopBackEdgeIds(ring(true))]).toEqual(['back']); + }); + + it('finds none where two loops share nodes', () => { + const graph: LoopGraph = { + nodes: [activity('draft'), router('first'), router('second')], + connections: [ + connect('d-f', 'draft', 'first'), + connect('f-again', 'first', 'draft'), + connect('f-next', 'first', 'second'), + connect('s-again', 'second', 'draft') + ] + }; + + expect(findLoopBackEdgeIds(graph).size).toBe(0); + }); +}); diff --git a/src/app/utilities/flow-loops.ts b/src/app/utilities/flow-loops.ts new file mode 100644 index 0000000..ed25833 --- /dev/null +++ b/src/app/utilities/flow-loops.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +/** + * Which connections lead back round a loop, worked out from the shape of the flow. + * + * This mirrors the server's FlowLoops, which is the authority: it decides whether a loop can run + * and says why not. The editor needs only the half that picks out the connection a loop goes round + * by - to draw it as one, and to let its settings be edited - so that is all this repeats. Kept to + * the same rule, the two agree on which connection is which; the reasons a loop is refused come + * from the server's validation. + * + * A loop is a set of nodes that can all reach one another. It goes round through a connection from + * a node whose type routes exclusively; if several such connections could close it, the one whose + * author set loop settings wins, then the one leading to where the loop is entered from outside. + */ + +export type LoopGraphNode = { + id: string; + routesExclusively: boolean; +}; + +export type LoopGraphConnection = { + id: string; + sourceId: string; + targetId: string; + hasLoopSettings?: boolean; +}; + +export type LoopGraphDependency = { + sourceId: string; + targetId: string; +}; + +export type LoopGraph = { + nodes: LoopGraphNode[]; + connections: LoopGraphConnection[]; + dependencies?: LoopGraphDependency[]; +}; + +type Edge = { sourceId: string; targetId: string; connection: LoopGraphConnection | null }; + +/** Ids of the connections each loop goes round by. */ +export function findLoopBackEdgeIds(graph: LoopGraph): Set { + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const edges: Edge[] = [ + ...graph.connections + .filter((connection) => nodes.has(connection.sourceId) && nodes.has(connection.targetId)) + .map((connection) => ({ sourceId: connection.sourceId, targetId: connection.targetId, connection })), + ...(graph.dependencies ?? []) + .filter((dependency) => nodes.has(dependency.sourceId) && nodes.has(dependency.targetId)) + .map((dependency) => ({ sourceId: dependency.sourceId, targetId: dependency.targetId, connection: null })) + ]; + + const backEdges = new Set(); + for (const component of cyclicComponents([...nodes.keys()], edges)) { + const internal = edges.filter((edge) => component.has(edge.sourceId) && component.has(edge.targetId)); + const closing = internal + .filter((edge) => edge.connection !== null && nodes.get(edge.sourceId)?.routesExclusively === true) + .filter((candidate) => cyclicComponents([...component], internal.filter((edge) => edge !== candidate)).length === 0); + const backEdge = pickBackEdge(closing, component, edges); + if (backEdge?.connection) backEdges.add(backEdge.connection.id); + } + return backEdges; +} + +function pickBackEdge(closing: Edge[], component: Set, edges: Edge[]): Edge | null { + if (closing.length === 1) return closing[0]; + const marked = closing.filter((edge) => edge.connection?.hasLoopSettings === true); + if (marked.length === 1) return marked[0]; + const enteredFromOutside = new Set( + edges.filter((edge) => !component.has(edge.sourceId) && component.has(edge.targetId)).map((edge) => edge.targetId) + ); + const intoAnEntry = closing.filter((edge) => enteredFromOutside.has(edge.targetId)); + return intoAnEntry.length === 1 ? intoAnEntry[0] : null; +} + +/** Tarjan's strongly connected components, keeping those that are a cycle. */ +function cyclicComponents(nodeIds: string[], edges: Edge[]): Set[] { + const inScope = new Set(nodeIds); + const outgoing = new Map(); + for (const edge of edges) { + if (!inScope.has(edge.sourceId) || !inScope.has(edge.targetId)) continue; + outgoing.set(edge.sourceId, [...(outgoing.get(edge.sourceId) ?? []), edge.targetId]); + } + const index = new Map(); + const lowLink = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const components: Set[] = []; + let counter = 0; + + const visit = (node: string) => { + index.set(node, counter); + lowLink.set(node, counter); + counter++; + stack.push(node); + onStack.add(node); + const targets = outgoing.get(node) ?? []; + for (const target of targets) { + if (!index.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node)!, lowLink.get(target)!)); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node)!, index.get(target)!)); + } + } + if (lowLink.get(node) === index.get(node)) { + const component = new Set(); + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.add(member); + } while (member !== node); + if (component.size > 1 || targets.includes(node)) components.push(component); + } + }; + + for (const node of nodeIds) { + if (!index.has(node)) visit(node); + } + return components; +} diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index fcf061a..6879d52 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -18,6 +18,7 @@ import { FlowData, FlowGlobalInput, FlowLane, + FlowLoopEdgeSettings, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, FLOW_DEPENDENCY_SOCKET_TYPE, @@ -35,6 +36,7 @@ import { CustomSocket } from "@shared/custom-socket/custom-socket"; import { CustomConnectionComponent } from "@shared/custom-connection/custom-connection"; import { deleteSchemaValueByPath, setSchemaValueByPath } from "@shared/nodes/schema-driven-fields"; import { firstValueFrom } from "rxjs"; +import { findLoopBackEdgeIds } from "./flow-loops"; type AreaExtra = AngularArea2D; const editorSockets = new WeakMap, Map>(); @@ -60,6 +62,21 @@ export type ReteRuntimeContext = { readonly: boolean; globalInputs: FlowGlobalInput[]; lanes: FlowLane[]; + /** + * Set while connections are being put back programmatically - loading a flow, replacing a node - + * when what is added is already a decided graph and nothing it adds should displace anything. + */ + restoringConnections?: number; +}; + +/** + * What the editor keeps on a Rete connection beyond Rete's own fields. `loop` travels to and from + * the flow; `__loopBack` and `__readonly` are worked out here for the connection component to draw. + */ +export type LoopAwareConnection = HFSchemes['Connection'] & { + loop?: FlowLoopEdgeSettings; + __loopBack?: boolean; + __readonly?: boolean; }; export async function createEditor( @@ -112,7 +129,7 @@ export async function createEditor( }, }) ); - editor.addPipe((context) => { + editor.addPipe(async (context) => { if (context.type !== "connectioncreate") return context; const sourceNode = editor.getNode(context.data.source) as HFNode | undefined; @@ -138,8 +155,24 @@ export async function createEditor( normalizeFlowPortValueKinds(sourcePort), normalizeFlowPortValueKinds(targetPort) ); + if (!compatible) return undefined; - return compatible ? context : undefined; + if (!runtime.restoringConnections) { + const leadsBackRoundALoop = await makeRoomOnInput(editor, runtime, context.data); + // Drawn closing a cycle from a router, this is the connection its author means to go round + // by. Saying so settles the rare shape where the graph alone could not tell which one it is. + if (leadsBackRoundALoop) { + (context.data as LoopAwareConnection).loop ??= {}; + } + } + return context; + }); + + editor.addPipe((context) => { + if (context.type === "connectioncreated" || context.type === "connectionremoved") { + void refreshLoopMarkers(editor, area, runtime); + } + return context; }); connection.addPreset(ConnectionPresets.classic.setup()); @@ -165,6 +198,7 @@ export async function createEditor( if (flowData) await loadFlowData(editor, area, flowData, runtime); + await refreshLoopMarkers(editor, area, runtime); AreaExtensions.zoomAt(area, editor.getNodes()); return { editor, area }; @@ -203,14 +237,18 @@ export function exportGraph(editor: NodeEditor) { }; }); - const allConnections = editor.getConnections().map((c) => ({ - id: String(c.id), - sourceId: nodeIdToBlockId.get(c.source) ?? c.source, - sourceName: c.sourceOutput, - targetId: nodeIdToBlockId.get(c.target) ?? c.target, - targetName: c.targetInput, - kind: getGraphConnectionKind(c.sourceOutput, c.targetInput) - })); + const allConnections = editor.getConnections().map((c) => { + const loop = (c as LoopAwareConnection).loop; + return { + id: String(c.id), + sourceId: nodeIdToBlockId.get(c.source) ?? c.source, + sourceName: c.sourceOutput, + targetId: nodeIdToBlockId.get(c.target) ?? c.target, + targetName: c.targetInput, + ...(loop ? { loop: cloneValue(loop) } : {}), + kind: getGraphConnectionKind(c.sourceOutput, c.targetInput) + }; + }); return { blocks: nodes.filter((node): node is FlowBlock => node.nodeFamily === 'block'), @@ -468,7 +506,8 @@ export async function addBlockToEditor( source: connection.source, sourceOutput: connection.sourceOutput, target: connection.target, - targetInput: connection.targetInput + targetInput: connection.targetInput, + loop: (connection as LoopAwareConnection).loop })); const currentPosition = (node.data?.position ?? position ?? createdBlock.position) as { x: number; y: number } | undefined; const replacementNode = await addBlockToEditor( @@ -510,9 +549,10 @@ export async function addBlockToEditor( if (connection.target === node.id && !replacementInputNames.has(targetInput)) continue; try { - await editor.addConnection( - new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput) - ); + const restored = new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput) as LoopAwareConnection; + restored.id = connection.id; + if (connection.loop) restored.loop = cloneValue(connection.loop); + await withRestoredConnections(resolvedRuntime, () => editor.addConnection(restored)); } catch (error) { console.warn('Failed to restore connection after node replacement', { connection, @@ -579,7 +619,9 @@ export async function addBlockToEditor( } for (const input of block.inputs ?? []) { - node.addInput(input.name, new ClassicPreset.Input(getSocket(editor, input.type ?? "ANY"))); + // Multiple so that Rete does not drop the input's connection itself before asking: the entry of + // a loop takes a second one, from the way back. makeRoomOnInput keeps every other input to one. + node.addInput(input.name, new ClassicPreset.Input(getSocket(editor, input.type ?? "ANY"), undefined, true)); } await editor.addNode(node); @@ -616,27 +658,135 @@ async function loadFlowData( nodeMapping.set(block.id, node.id); } - for (const c of flowData.connections ?? []) { - if (!nodeMapping.has(c.sourceId) || !nodeMapping.has(c.targetId)) continue; + await withRestoredConnections(runtime, async () => { + for (const c of flowData.connections ?? []) { + if (!nodeMapping.has(c.sourceId) || !nodeMapping.has(c.targetId)) continue; - const sourceNode = editor.getNode(nodeMapping.get(c.sourceId)) as any; - const targetNode = editor.getNode(nodeMapping.get(c.targetId)) as any; + const sourceNode = editor.getNode(nodeMapping.get(c.sourceId)) as any; + const targetNode = editor.getNode(nodeMapping.get(c.targetId)) as any; - await editor.addConnection( - new ClassicPreset.Connection(sourceNode, c.sourceName, targetNode, c.targetName) - ); + const connection = new ClassicPreset.Connection(sourceNode, c.sourceName, targetNode, c.targetName) as LoopAwareConnection; + // The saved id, not a fresh one: settings and selections refer to a connection by it, and a + // new id on every load would detach them. + if (c.id) connection.id = c.id; + if (c.loop) connection.loop = cloneValue(c.loop); + await editor.addConnection(connection); + } + + for (const dependency of flowData.dependencies ?? []) { + if (!nodeMapping.has(dependency.sourceId) || !nodeMapping.has(dependency.targetId)) continue; + + const sourceNode = editor.getNode(nodeMapping.get(dependency.sourceId)) as any; + const targetNode = editor.getNode(nodeMapping.get(dependency.targetId)) as any; + + await editor.addConnection( + new ClassicPreset.Connection(sourceNode, FLOW_DEPENDANT_PORT_KEY, targetNode, FLOW_DEPENDENCY_PORT_KEY) + ); + } + }); +} + +async function withRestoredConnections(runtime: ReteRuntimeContext | undefined, restore: () => Promise): Promise { + if (!runtime) return restore(); + runtime.restoringConnections = (runtime.restoringConnections ?? 0) + 1; + try { + return await restore(); + } finally { + runtime.restoringConnections -= 1; } +} - for (const dependency of flowData.dependencies ?? []) { - if (!nodeMapping.has(dependency.sourceId) || !nodeMapping.has(dependency.targetId)) continue; +type NewConnection = { source: string; sourceOutput: string; target: string; targetInput: string }; - const sourceNode = editor.getNode(nodeMapping.get(dependency.sourceId)) as any; - const targetNode = editor.getNode(nodeMapping.get(dependency.targetId)) as any; - - await editor.addConnection( - new ClassicPreset.Connection(sourceNode, FLOW_DEPENDANT_PORT_KEY, targetNode, FLOW_DEPENDENCY_PORT_KEY) - ); +/** + * Keeps an input to one connection, as it always was, with one exception: the entry of a loop, + * which takes one connection from before the loop and one leading back from the router that + * decides whether to go round. A connection "leads back" here when it comes from a node that routes + * exclusively and its target can already reach that node - it closes a cycle. + * + * So an existing connection stays when exactly one of it and the new one leads back; otherwise the + * new one replaces it, as dropping onto a taken input always did. + */ +async function makeRoomOnInput(editor: NodeEditor, runtime: ReteRuntimeContext, created: NewConnection) { + const newLeadsBack = leadsBack(editor, runtime, created, null); + const existing = editor.getConnections().filter((connection) => + connection.target === created.target && connection.targetInput === created.targetInput); + for (const connection of existing) { + const existingLeadsBack = leadsBack(editor, runtime, connection, connection.id); + if (newLeadsBack !== existingLeadsBack) continue; + await editor.removeConnection(connection.id); } + return newLeadsBack; +} + +function leadsBack(editor: NodeEditor, runtime: ReteRuntimeContext, connection: NewConnection, + ignoredConnectionId: string | null): boolean { + const source = editor.getNode(connection.source) as HFNode | undefined; + if (resolveNodeCapabilities(runtime, source).routesExclusively !== true) return false; + const outgoing = new Map(); + for (const candidate of editor.getConnections()) { + if (candidate.id === ignoredConnectionId) continue; + outgoing.set(candidate.source, [...(outgoing.get(candidate.source) ?? []), candidate.target]); + } + const seen = new Set(); + const pending = [connection.target]; + while (pending.length) { + const next = pending.pop()!; + if (next === connection.source) return true; + if (seen.has(next)) continue; + seen.add(next); + pending.push(...(outgoing.get(next) ?? [])); + } + return false; +} + +/** + * Marks which connections lead back round a loop, for the connection component to draw as such. + * Worked out with the same rule the server uses, from the editor's graph as it now stands. + */ +export async function refreshLoopMarkers( + editor: NodeEditor, + area: AreaPlugin, + runtime?: ReteRuntimeContext +) { + const resolvedRuntime = runtime ?? editorRuntime.get(editor); + const connections = editor.getConnections() as LoopAwareConnection[]; + const data = connections.filter((c) => getGraphConnectionKind(c.sourceOutput, c.targetInput) === 'data'); + const backEdges = findLoopBackEdgeIds({ + nodes: editor.getNodes().map((node) => ({ + id: node.id, + routesExclusively: resolveNodeCapabilities(resolvedRuntime, node as HFNode).routesExclusively === true + })), + connections: data.map((c) => ({ id: c.id, sourceId: c.source, targetId: c.target, hasLoopSettings: c.loop != null })), + dependencies: connections + .filter((c) => getGraphConnectionKind(c.sourceOutput, c.targetInput) === 'dependency') + .map((c) => ({ sourceId: c.source, targetId: c.target })) + }); + for (const connection of data) { + const loopBack = backEdges.has(connection.id); + const readonly = resolvedRuntime?.readonly === true; + if (connection.__loopBack === loopBack && connection.__readonly === readonly) continue; + connection.__loopBack = loopBack; + connection.__readonly = readonly; + await area.update('connection', connection.id); + } +} + +/** + * Sets a loop connection's iteration limit; null goes back to the default. The settings stay on the + * connection either way, so it remains the one its author marked as leading back. + */ +export async function setLoopMaxIterations( + editor: NodeEditor, + area: AreaPlugin, + connectionId: string, + maxIterations: number | null +) { + const connection = editor.getConnections().find((c) => String(c.id) === connectionId) as LoopAwareConnection | undefined; + if (!connection) return false; + connection.loop = maxIterations == null ? {} : { maxIterations }; + await area.update('connection', connection.id); + return true; } function getSocket(editor: NodeEditor, type: string) {