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;