Draw and wire storage nodes as resources, not steps

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 (<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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-28 12:23:08 +02:00
parent 3abcecb944
commit 1aa32efa80
12 changed files with 90 additions and 7 deletions

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

@ -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 @@
}
</div>
<div class="llm-node-id">{{ nodeIdLabel }}</div>
@if (isResourceNode()) {
<div class="llm-lane-chip" title="Not a step: outputs connected to its targets are written there, its views are read by the steps they feed">
<i class="bi bi-database"></i>
<span>Storage · not a step</span>
</div>
}
@if (laneBadge; as lane) {
<div class="llm-lane-chip" [style.border-color]="lane.color || '#94a3b8'">
<i class="bi bi-diagram-3"></i>

View File

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

View File

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

View File

@ -6,7 +6,8 @@
<div class="llm-node"
[class.llm-node--human]="isHumanNode()"
[class.llm-node-skipped]="isSkipped() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node--resource]="isResourceNode()"
[class.llm-node-skipped]="isSkipped() && !isResourceNode() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-running]="isRunning() && !hasExecutionErrors()"
[class.llm-node-subflow-running]="isSubflowRunning() && !hasExecutionErrors()"
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"

View File

@ -509,6 +509,11 @@ export class TaskStepNodeComponent {
?? DEFAULT_NODE_CAPABILITIES;
}
/** A storage node: it has no step and no status of its own, only the steps on its edges. */
isResourceNode(): boolean {
return this.typeCapabilities().visualRole === 'RESOURCE' || this.blockConfiguration?.['__resourceNode'] === true;
}
isBiasCapable(): boolean {
return this.resolvedTypeCapabilities()?.biasAnnotationsAllowed === true;
}

View File

@ -597,10 +597,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
}
// A storage node never has a step, so it is drawn from the flow: its edges are the reads and
// writes of the steps at their other end, and they would otherwise point at nothing.
for (const sourceNode of useSourceGraphFallback
? [...(sourceFlow?.blocks ?? []), ...(sourceFlow?.containers ?? [])]
: []) {
: (sourceFlow?.blocks ?? []).filter((block) => 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';
}

View File

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

View File

@ -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<HFSchemes>, runtime: ReteRuntimeContext, created: NewConnection) {
// A storage node's target takes every step that writes there. Its view parameters, named
// <view>.<parameter>, 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<HFSchemes>, runtime: ReteRuntimeContext, c
const outgoing = new Map<string, string[]>();
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<string>();
@ -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<HFSchemes>, 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<HFSchemes>, 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;