Node Generations — Results Architecture Redesign

Date: 2026-06-06 Status: Design approved, pending spec review Scope: web/ only. No backend changes required for v1.

Problem

A node’s “result” is represented three different ways today, with no single read model:

  1. 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.
  2. useNodeResultHistory — DB-backed assets.list({node_id}): every saved asset for a node across all runs. The durable, server-side generation list (media only).
  3. workflowResultHydration — a bridge copying assets into outputResults[hydrated].

Consequences:

Target model: the node owns a timeline of generations

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 = one accessor, source hidden

“Single source of truth” means one read API, not one physical bucket. The accessor merges two role-separated backings and hides which is which:

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.

Selected generation (the “pick the best” feature)

WS message roles, cleanly separated

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.

Component / module changes

New

Changed

Removed

Build sequence

  1. Accessor + live buffer. Build 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.
  2. Selection. Add selected_generation to node data; wire NodeHistoryViewer < >/grid to write it; accessor honors it.
  3. Re-point readers. Move display hooks + run hooks onto the accessor. Verify previews persist across per-node runs and downstream uses the pick.
  4. Writers cleanup. output_update → output-node buffer only; tool_result_update → artifacts; stop populating the old value paths.
  5. Delete. workflowResultHydration, upstreamResult, readNodeResult, dead outputResults/focused-job paths. Full test pass.

Edge cases & decisions

Testing

Out of scope (v1)