From 1aa32efa80de1330a794571df8ae66cbda3ebda2 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 28 Sep 2026 12:23:08 +0200 Subject: [PATCH] Draw and wire storage nodes as resources, not steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A storage node comes from the server with the RESOURCE role. In the editor it is drawn apart from the steps - dashed, teal, labelled 'Storage · not a step' - and its targets take a connection from every step that writes there; a view parameter (.) still takes one. Its edges are left out when the editor works out which connections lead back round a loop: a write followed by a read of the same node is not a cycle, the same view the server takes. The execution view draws the node from the flow, since it never has a step, with no status of its own, so the reads and writes on its edges still have somewhere to go. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/models/flow.ts | 3 ++- .../services/shared/flow-node-mapping.spec.ts | 1 + src/app/services/shared/flow-node-mapping.ts | 2 +- .../nodes/generic-node/generic-node.css | 11 ++++++++++ .../nodes/generic-node/generic-node.html | 7 +++++++ .../shared/nodes/generic-node/generic-node.ts | 5 +++++ .../nodes/task-step-node/task-step-node.css | 11 ++++++++++ .../nodes/task-step-node/task-step-node.html | 3 ++- .../nodes/task-step-node/task-step-node.ts | 5 +++++ .../task-execution-viewer.ts | 14 +++++++++++-- src/app/utilities/rete-editor.spec.ts | 15 +++++++++++++- src/app/utilities/rete-editor.ts | 20 ++++++++++++++++++- 12 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 2e1dd36..01f7aad 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -86,7 +86,8 @@ export type BlockInteractionContract = { supportsPartialResult: boolean; }; -export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER'; +/** RESOURCE: not a step - a storage the steps on its edges read and write. */ +export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER' | 'RESOURCE'; export type NodeTypeCapabilities = { visualRole: NodeVisualRole; diff --git a/src/app/services/shared/flow-node-mapping.spec.ts b/src/app/services/shared/flow-node-mapping.spec.ts index 8d5aaf0..e263650 100644 --- a/src/app/services/shared/flow-node-mapping.spec.ts +++ b/src/app/services/shared/flow-node-mapping.spec.ts @@ -147,6 +147,7 @@ describe('toNodeCapabilities', () => { it('reads that a type routes exclusively, which is what lets it guard a loop', () => { expect(toNodeCapabilities({ visualRole: 'DECISION', routesExclusively: true }).routesExclusively).toBe(true); + expect(toNodeCapabilities({ visualRole: 'RESOURCE' }).visualRole).toBe('RESOURCE'); }); it('falls back per-field for missing booleans and an unknown visualRole', () => { diff --git a/src/app/services/shared/flow-node-mapping.ts b/src/app/services/shared/flow-node-mapping.ts index 7efbc37..940cd53 100644 --- a/src/app/services/shared/flow-node-mapping.ts +++ b/src/app/services/shared/flow-node-mapping.ts @@ -93,7 +93,7 @@ export function toPorts( }); } -const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER']; +const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER', 'RESOURCE']; function toVisualRole(value: unknown): NodeVisualRole { const candidate = typeof value === 'string' ? value.toUpperCase() : ''; diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index d31d457..39e5bc4 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -988,3 +988,14 @@ color: #64748b; font-size: 12px; } + +/* A storage node: something the steps read and write, not a step - drawn apart from them. */ +.llm-node--resource { + border: 2px dashed #0f766e; + background: linear-gradient(180deg, #f8fffd 0%, #ecfdf5 100%); +} + +.llm-node--resource .llm-header { + background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%); + border-bottom-color: #99f6e4; +} diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index c1dff1b..79180f0 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -11,6 +11,7 @@ [class.llm-node-error]="hasUpdateBlockError()" [class.llm-node-delete-pending]="deleteConfirmOpen" [class.llm-node--human]="isHumanNode()" + [class.llm-node--resource]="isResourceNode()" [class.llm-node--focus]="focusOpen" (pointerdown)="focusOpen && $event.stopPropagation()" (click)="focusOpen && $event.stopPropagation()"> @@ -52,6 +53,12 @@ }
{{ nodeIdLabel }}
+ @if (isResourceNode()) { +
+ + Storage · not a step +
+ } @if (laneBadge; as lane) {
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 2d6a521..9afc4c4 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -610,6 +610,11 @@ export class GenericNodeComponent implements OnDestroy { return isHumanInteractiveNode(this.blockDescriptor?.interactionContract); } + /** A storage node: not a step, but what the steps connected to it read and write. */ + isResourceNode(): boolean { + return this.blockDescriptor?.capabilities?.visualRole === 'RESOURCE'; + } + isConditionalNode(): boolean { return isConditionalByPorts(this.resolvePorts('output')); } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 0127a8e..75cafbb 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -830,3 +830,14 @@ button.llm-node-bias-summary:focus-visible { } .llm-bias-impact-trigger:hover:not(:disabled) { background: #dbeafe; } .llm-bias-impact-trigger:disabled { cursor: not-allowed; opacity: .5; } + +/* A storage node: something the steps read and write, not a step - drawn apart from them. */ +.llm-node--resource { + border: 2px dashed #0f766e; + background: linear-gradient(180deg, #f8fffd 0%, #ecfdf5 100%); +} + +.llm-node--resource .llm-header { + background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%); + border-bottom-color: #99f6e4; +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 445b322..fca5882 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -6,7 +6,8 @@
isStorageNodeBlock(block))) { if (renderedNodeIds.has(sourceNode.id)) continue; + const resource = isStorageNodeBlock(sourceNode); const connectedInputs = (sourceFlow?.connections ?? []) .filter((connection) => connection.targetId === sourceNode.id) @@ -616,7 +619,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { __executionNodeId: sourceNode.id, __executionStatus: this.execution()?.context.status ?? null, __executionStatusGroup: executionStatusGroup, - __stepStatus: 'SKIPPED', + __stepStatus: resource ? 'RESOURCE' : 'SKIPPED', + __resourceNode: resource, __isWaitingStep: false, __executionInputs: {}, __connectedInputs: connectedInputs, @@ -1907,3 +1911,9 @@ export class TaskExecutionViewerComponent implements OnDestroy { } } + +/** The server's storage node: a resource of the flow, with no step of its own. */ +function isStorageNodeBlock(node: FlowNode): boolean { + return (node as { typeName?: string }).typeName === 'Storage' + || (node.specificConfiguration as { type?: string } | undefined)?.type === 'StorageNodeConfiguration'; +} diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index 2ef4df5..7176be8 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { HFNode } from '@models/nodes'; import { vi } from 'vitest'; -import { resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor'; +import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor'; function makeNode(typeName: string, nodeFamily: 'block' | 'container'): HFNode { return { data: { typeName, nodeFamily } } as unknown as HFNode; @@ -70,3 +70,16 @@ describe('resolveNodeCapabilities', () => { expect(peekBlockType).not.toHaveBeenCalled(); }); }); + +describe('isResourceNode', () => { + it('is true for a storage node, whose edges are reads and writes rather than a path between steps', () => { + const peekBlockType = vi.fn((type: string) => type === 'Storage' + ? { type, capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: 'RESOURCE' as const, canDependOnOtherNodes: false, canHaveDependentNodes: false } } + : { type, capabilities: DEFAULT_NODE_CAPABILITIES }); + const runtime = makeRuntime({ blocksService: { peekBlockType } as any }); + + expect(isResourceNode(runtime, makeNode('Storage', 'block'))).toBe(true); + expect(isResourceNode(runtime, makeNode('LLMBlock', 'block'))).toBe(false); + expect(isResourceNode(undefined, makeNode('Storage', 'block'))).toBe(false); + }); +}); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index ede9e3a..7bc71be 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -743,6 +743,11 @@ type NewConnection = { source: string; sourceOutput: string; target: string; tar * new one replaces it, as dropping onto a taken input always did. */ async function makeRoomOnInput(editor: NodeEditor, runtime: ReteRuntimeContext, created: NewConnection) { + // A storage node's target takes every step that writes there. Its view parameters, named + // ., take one value like any input. + if (isResourceNode(runtime, editor.getNode(created.target) as HFNode | undefined) && !created.targetInput.includes('.')) { + return false; + } const newLeadsBack = leadsBack(editor, runtime, created, null); const existing = editor.getConnections().filter((connection) => connection.target === created.target && connection.targetInput === created.targetInput); @@ -761,6 +766,8 @@ function leadsBack(editor: NodeEditor, runtime: ReteRuntimeContext, c const outgoing = new Map(); for (const candidate of editor.getConnections()) { if (candidate.id === ignoredConnectionId) continue; + // A storage node is not a step: writing to it and reading from it is not a path round. + if (touchesResource(editor, runtime, candidate)) continue; outgoing.set(candidate.source, [...(outgoing.get(candidate.source) ?? []), candidate.target]); } const seen = new Set(); @@ -788,7 +795,9 @@ export async function refreshLoopMarkers( 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) => ({ + // Storage nodes left out, and with them their edges: they are not steps, and a write followed + // by a read of the same node is not a loop. + nodes: editor.getNodes().filter((node) => !isResourceNode(resolvedRuntime, node as HFNode)).map((node) => ({ id: node.id, routesExclusively: resolveNodeCapabilities(resolvedRuntime, node as HFNode).routesExclusively === true })), @@ -859,6 +868,15 @@ function getSocket(editor: NodeEditor, type: string) { return map.get(type)!; } +export function isResourceNode(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): boolean { + return resolveNodeCapabilities(runtime, node).visualRole === 'RESOURCE'; +} + +function touchesResource(editor: NodeEditor, runtime: ReteRuntimeContext, connection: { source: string; target: string }) { + return isResourceNode(runtime, editor.getNode(connection.source) as HFNode | undefined) + || isResourceNode(runtime, editor.getNode(connection.target) as HFNode | undefined); +} + export function resolveNodeCapabilities(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): NodeTypeCapabilities { if (node?.data?.capabilities) return node.data.capabilities;