Draw a loop's way back, and let its limit be set on it

A connection from a router back to where a loop starts is picked out
with the same rule the server uses, drawn underneath the nodes in violet
with its iteration limit, and edited in place when selected. The limit
travels on the connection as its loop settings.

An input used to hold one connection, and Rete dropped the old one before
asking. The entry of a loop takes two - from before the loop, and back
from the router - so inputs now accept several and the editor itself
replaces the old one, except when exactly one of the two leads back.

Connections keep their saved id across loads instead of getting a new one
each time, so settings and selections stay attached. Node capabilities
now carry routesExclusively through to the client.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 11:38:39 +02:00
parent f9491e0904
commit fbd27c4344
11 changed files with 610 additions and 38 deletions

View File

@ -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 = {

View File

@ -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);
});

View File

@ -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 });
}
}

View File

@ -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,

View File

@ -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)
};
}

View File

@ -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: `
<svg data-testid="connection" [attr.data-connection-id]="connectionId">
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null">
<path
[attr.d]="path"
[attr.d]="drawnPath"
[attr.stroke]="strokeColor"
[attr.stroke-width]="strokeWidth"
[attr.stroke-dasharray]="strokeDasharray"
@ -22,7 +28,35 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
(pointerdown)="selectConnection($event)"
(click)="selectConnection($event)">
</path>
@if (isSelected) {
@if (isLoopBack) {
<foreignObject
class="loop-label-wrap"
[attr.x]="loopLabelX"
[attr.y]="loopLabelY"
width="190"
height="34">
<div xmlns="http://www.w3.org/1999/xhtml" class="loop-label" (pointerdown)="selectConnection($event)">
<span class="loop-glyph" aria-hidden="true">↻</span>
@if (isSelected && !isReadonly) {
<label class="loop-limit">
max
<input
type="number"
min="1"
[attr.max]="maxAllowed"
[value]="maxIterations"
title="How many times the flow may go round before the execution fails"
(pointerdown)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
(change)="changeLimit($event)" />
</label>
} @else {
<span title="Goes round at most this many times; reaching the limit fails the execution">max {{ maxIterations }}</span>
}
</div>
</foreignObject>
}
@if (isSelected && !isReadonly) {
<foreignObject
class="connection-delete-wrap"
[attr.x]="deleteButtonX"
@ -84,6 +118,47 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
overflow: visible;
pointer-events: auto;
}
:host .loop-label-wrap {
overflow: visible;
pointer-events: auto;
}
:host .loop-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: #f5f3ff;
border: 1px solid #c4b5fd;
color: #5b21b6;
font: 600 12px/1.4 system-ui, sans-serif;
box-shadow: 0 4px 12px rgba(91, 33, 182, 0.14);
cursor: pointer;
white-space: nowrap;
}
:host .loop-glyph {
font-size: 15px;
line-height: 1;
}
:host .loop-limit {
display: inline-flex;
align-items: center;
gap: 4px;
}
:host .loop-limit input {
width: 52px;
padding: 1px 4px;
border: 1px solid #a78bfa;
border-radius: 6px;
font: inherit;
color: inherit;
background: #fff;
}
`]
})
export class CustomConnectionComponent {
@ -102,6 +177,61 @@ export class CustomConnectionComponent {
return String(this.data?.id ?? '');
}
private get loopData(): LoopAwareConnection | undefined {
return this.data as LoopAwareConnection | undefined;
}
/** Leads back round a loop, as the editor worked out from the graph. */
get isLoopBack(): boolean {
return this.loopData?.__loopBack === true;
}
get isReadonly(): boolean {
return this.loopData?.__readonly === true;
}
get maxIterations(): number {
return this.loopData?.loop?.maxIterations ?? DEFAULT_LOOP_MAX_ITERATIONS;
}
readonly maxAllowed = MAX_LOOP_MAX_ITERATIONS;
/**
* A connection leading back runs from a node's output to one drawn before it, so the usual curve
* would double back through both nodes. It goes round underneath them instead.
*/
get drawnPath(): string {
if (!this.isLoopBack || !this.start || !this.end) return this.path;
const drop = this.loopDrop;
const { x: sx, y: sy } = this.start;
const { x: ex, y: ey } = this.end;
const bottom = Math.max(sy, ey) + drop;
return `M ${sx} ${sy} C ${sx + 140} ${sy}, ${sx + 140} ${bottom}, ${sx} ${bottom}`
+ ` L ${ex} ${bottom} C ${ex - 140} ${bottom}, ${ex - 140} ${ey}, ${ex} ${ey}`;
}
private get loopDrop(): number {
return 150;
}
get loopLabelX(): number {
return ((this.start?.x ?? 0) + (this.end?.x ?? 0)) / 2 - 60;
}
get loopLabelY(): number {
return Math.max(this.start?.y ?? 0, this.end?.y ?? 0) + this.loopDrop - 17;
}
changeLimit(event: Event) {
const raw = (event.target as HTMLInputElement).value.trim();
const parsed = raw === '' ? null : Math.round(Number(raw));
const limit = parsed == null || Number.isNaN(parsed)
? null
: Math.min(MAX_LOOP_MAX_ITERATIONS, Math.max(1, parsed));
(event.target as HTMLInputElement).value = String(limit ?? DEFAULT_LOOP_MAX_ITERATIONS);
this.graphSelection.requestLoopLimit(this.connectionId, limit);
}
get isSelected(): boolean {
return this.graphSelection.selectedConnectionId() === this.connectionId;
}
@ -116,6 +246,7 @@ export class CustomConnectionComponent {
get strokeColor(): string {
if (this.isSelected) return '#f97316';
if (this.isBiasRoutingChange) return '#b45309';
if (this.isLoopBack) return '#7c3aed';
return this.isDependencyConnection ? '#7c8ba1' : '#4682b4';
}
@ -126,6 +257,7 @@ export class CustomConnectionComponent {
}
get strokeDasharray(): string | null {
if (this.isLoopBack) return '12 7';
return this.isDependencyConnection ? '6 6' : null;
}

View File

@ -42,6 +42,7 @@ describe('ReteEditor', () => {
provide: GraphSelectionService,
useValue: {
deleteConnectionRequestTick: vi.fn(),
loopLimitRequest: vi.fn().mockReturnValue(null),
selectedConnectionId: vi.fn().mockReturnValue(null),
clearConnectionSelection: vi.fn()
}

View File

@ -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<ElementRef>('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;

View File

@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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);
});
});

View File

@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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<string> {
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<string>();
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<string>, 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<string>[] {
const inScope = new Set(nodeIds);
const outgoing = new Map<string, string[]>();
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<string, number>();
const lowLink = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const components: Set<string>[] = [];
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<string>();
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;
}

View File

@ -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<HFSchemes>;
const editorSockets = new WeakMap<NodeEditor<HFSchemes>, Map<string, ClassicPreset.Socket>>();
@ -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<HFSchemes>) {
};
});
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<T>(runtime: ReteRuntimeContext | undefined, restore: () => Promise<T>): Promise<T> {
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<HFSchemes>, 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<HFSchemes>, 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<string, string[]>();
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<string>();
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<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
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<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
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<HFSchemes>, type: string) {