Date: 2026-06-06
Status: Design approved, pending spec review
Scope: web/ only. No backend changes required for v1.
A node’s “result” is represented three different ways today, with no single read model:
useResultsStore — live in-memory results (node_complete envelope,
{handle: value}) and outputResults (bare/accumulated values from
output_update, tool_result_update, hydration). Keyed wf:job:node.useNodeResultHistory — DB-backed assets.list({node_id}): every saved
asset for a node across all runs. The durable, server-side generation list
(media only).workflowResultHydration — a bridge copying assets into
outputResults[hydrated].Consequences:
outputResults ?? results
and then unwraps an envelope-vs-bare shape. Readers that got it wrong silently
showed nothing (the run-path and display-path bugs fixed earlier this session).orderedRunJobIds search across runs) is a patch over the wrong
primary key.NodeHistoryViewer already pages a node’s
asset history with < >, but the index is local useState — view-only, with
no effect on what downstream consumes.Invert the primary key. The node owns an ordered list of generations; a run is just the event that appends one. “What shows / feeds downstream” is answered by picking a generation, not a run.
A generation is one output instance of a node (one asset, or one run’s value for non-media). “Hydrated vs live” is invisible to consumers.
interface Generation {
id: string; // stable: assetId (persisted) or jobId (live)
jobId: string | null; // provenance
createdAt: number; // ordering
outputs: Record<string, unknown>; // handle -> value, normalized ONCE at write
status: "running" | "completed" | "error";
cost?: ProviderCost;
error?: string;
assetId?: string; // present for persisted media generations
}
“Single source of truth” means one read API, not one physical bucket. The accessor merges two role-separated backings and hides which is which:
node_id, refetched on run
completion). This is the server-side persistence (already exists).node_update for the current
session’s runs. Also the only home for non-media outputs (text, numbers,
dataframes), which never become assets and are therefore session-scoped.Merge rule: take all persisted generations (one per asset), plus live
generations whose jobId is not yet represented in the persisted set. A run’s
live generation is superseded once its assets land (a batch run that saved 4
images becomes 4 persisted generations, replacing the single in-flight one).
Result is one time-ordered list.
// reactive (display)
useNodeGenerations(wf, node): {
generations: Generation[];
current: Generation | undefined; // selected ?? latest
select(generationId): void;
}
// sync (run path / selectors)
getNodeGenerations(wf, node): Generation[];
getCurrentOutput(wf, node, handle?): unknown; // current.outputs[handle]
This accessor replaces upstreamResult.ts
(readNodeResult / resolveNodeResultAcrossRuns / orderedRunJobIds /
makeUpstreamResultGetter) — those were the focused-run patch and retire here.
The accessor is the migration seam: consumers already call into that module, so
they re-point rather than rewrite.
ui_properties.selected_generation,
saved with the workflow). References assetId for media (stable across reload);
a session id for non-media.getCurrentOutput(upstream), which honors the
pointer. So a deliberate pick feeds downstream and survives reload for media.| Message | New role |
|---|---|
node_update |
The only creator/finalizer of a generation (value, status, cost, error) — for every node. running → append a running generation; completed → finalize outputs; error → error. |
output_update |
Output nodes only. Feeds an ephemeral live-stream display buffer for progressive rendering during a run. Not a value, not a generation source. |
tool_result_update |
Moves to the artifact channel beside toolCalls (it pairs with tool_call_update for the agent-node log). Not a node output. |
New
web/src/utils/nodeGenerations.ts — pure merge (durable + live, dedup by
jobId), getNodeGenerations, getCurrentOutput, selection resolution
(selected ?? latest).web/src/hooks/nodes/useNodeGenerations.ts — reactive hook over the workflow
asset store + live buffer + node selected pointer.Changed
ResultsStore — results becomes the live generation buffer (keyed by
node, generations by jobId, carrying status/cost/error). outputResults
demoted to the output-node stream buffer only. tool_result_update →
toolCalls/artifacts.workflowUpdates.ts — rewrite writers per the message-roles table.useNodeIO.ts, useNodeExecState.ts, SketchNode.tsx, the run hooks
(useRunFromHere, useRunSingleNode, useRunSelectedNodes) — read via the
generations accessor instead of focused-run lookups.NodeHistoryViewer.tsx — currentIndex useState → the persisted selected
pointer (read/write node data); grid + < > set the current generation.OutputNode.tsx — reads the output-node stream buffer for live display, the
generation for the settled value.Removed
workflowResultHydration.ts (assets read directly via the accessor).upstreamResult.ts and ResultsStore.readNodeResult (superseded).WorkflowRunsStore.focusedJob stays only for
edge animation / active-run highlighting, not result resolution.nodeGenerations.ts + useNodeGenerations
over the existing asset store; add the live generation buffer to ResultsStore
(write from node_update in parallel with current writers, behind the new
accessor). No consumer change yet. Unit-test merge/dedup/selection.selected_generation to node data; wire
NodeHistoryViewer < >/grid to write it; accessor honors it.output_update → output-node buffer only;
tool_result_update → artifacts; stop populating the old value paths.workflowResultHydration, upstreamResult, readNodeResult,
dead outputResults/focused-job paths. Full test pass.jobId (the concrete assets replace the in-flight placeholder).ui_properties.selected_generation persistence — verify the backend
passes unknown ui_properties keys through round-trip (it is a UI blob).selected ?? latest,
getCurrentOutput by handle, non-media session-only path.< > selection
feeds downstream; selection persists across reload for media; output node live
stream still renders; tool-result no longer leaks into a node’s value.node_id/job_id; selection rides node
data).