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:
parent
3abcecb944
commit
1aa32efa80
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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() : '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue