Collect (Fan-In) Node — Design Spec
Note: This node shipped as
reduce. This design doc uses its original working name, “collect”.
Date: 2026-05-21
Status: Proposed
Phase: 1 of 2 — Phase 2 (parallelize executeNodeForList) lives in a separate spec.
Summary
Add a collect node that consumes the listResults of an upstream fanned-out node and produces a single aggregated output via a pluggable strategy (pick-best-via-LLM, concat, count, first-non-empty, vote, merge-json). Closes the fan-out/fan-in asymmetry in the DAG without touching the acyclic invariant: List/Loop → fanned-out node → Collect → continue.
Goals
- First-class “generate N → reduce to 1 → continue” pattern as a 2-node thing instead of a custom downstream node or out-of-band pipeline.
- Strategy registry that ships new aggregation modes additively (mirrors
parameter-picker-registry) — each strategy declares its config schema and its result-rendering metadata. - UI that reads as “N inputs → 1 output” without lying about what happened (no “lost jobs” perception).
- No changes to the credit-guard hot path; no changes to executor concurrency semantics; no new cycles in the DAG.
Non-Goals
- Parallelize
executeNodeForList— separate spec, requires dedup-fingerprint concurrency-safety pass (Phase 2). - Multi-source Collect (merging two parallel fan-out branches via zip/cartesian/pad) — defer to v2.
- List-output strategies (top-K, filter, sort that emit a smaller list) — defer to v2; scalar output only in v1.
- Nested fan-out (
list → loop → list) — already-deferred, not in scope. - While-loops / cycles / backward edges — out of scope by architectural decision.
Design
1. Node shape
Data type — added to frontend/src/types/nodes.ts:
interface CollectNodeData extends BaseNodeData {
type: "collect"
strategyId: CollectStrategyId // discriminant
strategyConfig: Record<string, unknown> // shape per-strategy, validated at runtime
}
Handles:
- One input handle
in(accepts upstreamlistoutput OR direct connection to a fanned-out node — see §4 for resolution). - One output handle
out— type depends on strategy (text | number | image-url | video-url | audio-url | json).
Category in add-node-popup.tsx: “Workflow” — the category that already holds router, sub-workflow-input, sub-workflow-output, sub-workflow, teleport-send, teleport-receive. (List/Loop themselves live in the “Data” category since they’re data sources; Collect is control-flow, so it belongs with Router. Collect makes no provider calls except pick-best-llm, and even that is a control-plane LLM judge, not a generative output.)
2. Strategy registry
Shared registry at packages/shared/src/collect-strategy-registry.ts (single source of truth for backend + frontend):
export type CollectStrategy<TConfig = unknown, TResult = unknown> = {
readonly id: string
readonly label: string // human-readable, used in dropdown
readonly description: string // short tooltip
readonly configSchema: ZodType<TConfig> // validated in route + UI
readonly defaultConfig: TConfig
readonly outputType: OutputType // "image" | "video" | "audio" | "text" | "data" — from packages/shared/src/presentation-utils.ts
readonly creditCostKey: string // e.g. "collect:pick-best-llm" — looked up in STATIC_CREDIT_COSTS
}
export type CollectStrategyId = typeof COLLECT_STRATEGIES[number]["id"]
export const COLLECT_STRATEGIES = [
PICK_BEST_LLM_STRATEGY,
CONCAT_STRATEGY,
FIRST_NON_EMPTY_STRATEGY,
COUNT_STRATEGY,
VOTE_STRATEGY,
MERGE_JSON_STRATEGY,
] as const
export function getStrategy(id: CollectStrategyId): CollectStrategy
Input shape — string[], not NodeOutput[]. NodeOutput.listResults is string[] (backend/src/services/workflow-engine/types.ts:41) and extractNodeOutputAsList() returns string[]. Image/video/audio inputs come through as URL strings; the strategy decides how to interpret them (pick-best-llm sends URLs to Sonnet which can see images; merge-json parses each string as JSON).
Backend execution side — strategy implementations live in backend/src/services/collect-strategies/<id>.ts, each exporting:
export async function execute(
items: string[],
config: TConfig,
ctx: { userId: string; jobId: string; logger: Logger }
): Promise<{ result: string | number; meta: ResultMeta }>
export type ResultMeta = {
readonly selectedIndex?: number // pick-best, first-non-empty
readonly reasoning?: string // pick-best LLM rationale
readonly summary: string // 1-line human-readable
}
A central dispatcher backend/src/services/collect-strategies/index.ts maps strategyId → execute() and is the only thing the route imports. Adding a strategy = new file + 1 line in the dispatcher + 1 entry in the shared registry.
Pattern note: one-file-per-strategy is a new convention for this codebase. Prior pluggable registries (
parameter-picker-registry,combine-transitions,audio-crossfade-curves) centralize their config in a single file. Collect splits because each strategy has meaningful execution code (especiallypick-best-llm), not just static config. If subsequent registries adopt this pattern, extract a shared convention in a follow-up.
3. v1 strategies
| Strategy | Config | Output type | Behavior | Credit cost key |
|---|---|---|---|---|
pick-best-llm |
{ criteria: string; inputKind: "text" \| "image-url" } |
string (URL or text — matches input) | Sonnet judges all N items against criteria, returns chosen item + reasoning. For inputKind: "image-url" Sonnet sees images via URL; video pick-best deferred to v2 (needs frame extraction). |
collect:pick-best-llm (TBD, suggest 3 cr) |
concat |
{ separator: string } (default "\n\n") |
text |
Joins all string items with separator | collect:concat (0 cr) |
first-non-empty |
{} |
string (matches input) | Returns first item that is non-null/non-empty | collect:first-non-empty (0 cr) |
count |
{} |
"data" (value is a number stringified for transport) |
Returns valid.length (count of survivors after empty-string filter) |
collect:count (0 cr) |
vote |
{ caseSensitive?: boolean } |
text |
Returns most-common string (ties → first) | collect:vote (0 cr) |
merge-json |
{ strategy: "deep" \| "shallow" } |
json (serialized) |
Parses each item as JSON, merges into one object, returns JSON string | collect:merge-json (0 cr) |
All “Behavior” descriptions above operate on survivors only — empty strings from failed upstream iterations are filtered out before strategy execution (see §4 Failure handling). E.g.
countreturns survivor count, not attempt count;concatjoins survivors without empty separators.
Pick-best-llm prompt template (in backend/src/services/collect-strategies/pick-best-llm.ts):
You are judging N candidate outputs against criteria.
Criteria: {criteria}
Candidates:
[1] {item_1}
[2] {item_2}
...
Reply with JSON: { "chosen_index": <1-based>, "reasoning": "<one sentence>" }
Uses llmComplete from backend/src/lib/llm-client.ts with model id claude-sonnet-4.6 (DOT form — canonical for the unified LLM client per backend/CLAUDE.md “Unified LLM Client” table). NOT to be confused with claude-sonnet-4-6 (hyphen) used by ee/pipelines/llms/call-llm.ts, which is a separate code path for pipeline-specific critics.
Image content block handling — implementation decision required. llmComplete tries KIE’s messages proxy first and falls back to direct Anthropic SDK only when directFallbackModel is set. The direct-Anthropic path accepts { type: "image", source: { type: "url", url: item } } content blocks natively (verified at llm-client.ts:144 — the client converts the shape correctly). KIE’s proxy behavior with image content blocks is NOT verified — it may strip image blocks, error, or work silently incorrectly.
For inputKind: "image-url", the implementer must pick ONE of:
- Force direct-Anthropic path: pass
directFallbackModel: "claude-sonnet-4-6"on every call (verified atpackages/shared/src/llm-models.ts:70— this is the current direct-Anthropic SDK id for the Sonnet model that pairs with the KIE-sideclaude-sonnet-4.6; the dot vs hyphen difference is real, one is KIE’s id and one is Anthropic SDK’s). Skips KIE entirely. Simplest, but loses KIE cost/rate-limit pooling. - Verify KIE supports image content blocks via direct test against KIE before implementation. If yes, route normally; if no, fall back to option 1.
- Borrow the
ee/pipelines/llms/image-critic.tspattern (usescallLLM, which has battle-tested image support). Requires either moving pick-best-llm into ee/ OR extracting a shared core helper. Heavier, but it’s the same problem image-critic already solved.
Recommended default: option 1 for v1 (smallest blast radius, fully predictable). Revisit if pick-best-llm volume justifies KIE routing.
Output type: all strategies return string (URLs are strings) or number (count). Downstream type-routing follows the same per-item URL regex classification already used by input-resolver.ts:117-128 (mixed photo/video lists handled via VIDEO_URL_RE).
4. DAG execution semantics
The core problem: today’s executeNodeForList (frontend/src/components/editor/workflow-editor/list-execution.ts:20) detects an upstream List/Loop and fans out the consuming node, running it N times. A Collect node connected to that same fan-out source must NOT itself be fanned out — it needs the whole listResults array as a single input.
Existing precedent — ARRAY_ACCUMULATING_TYPES. backend/src/services/workflow-engine/input-resolver.ts:661 already declares:
const ARRAY_ACCUMULATING_TYPES = new Set(["combine-videos", "mix-audio", "combine-audio"])
…and at line 111-116 handles the fan-in case by routing each item individually through routeOutput() into type-specific media arrays (videoUrls, audioUrls). This is the closest existing pattern: nodes that consume a list rather than being fanned out.
Why a new sibling set, not joining ARRAY_ACCUMULATING_TYPES. ARRAY_ACCUMULATING_TYPES routes items into typed media arrays via routeVideoOutput/routeAudioOutput. Collect doesn’t care about media type — it wants the raw string[]. Different routing target, so:
// backend/src/services/workflow-engine/input-resolver.ts (sibling of ARRAY_ACCUMULATING_TYPES at line 661)
// Match the existing convention: plain Set<string> with string literals, NOT a literal-union type
const FAN_IN_NODE_TYPES = new Set(["collect"])
Resolution rule — in getListInputForNode (backend/src/services/workflow-engine/input-resolver.ts:369):
Before walking upstream for a fan-out source, check
FAN_IN_NODE_TYPES.has(node.type). If true, the node is itself the consumer — returnnullfor fan-out detection (no fan-out happens). In the per-item-routing branch (around line 105-116), add a parallel branch forFAN_IN_NODE_TYPESthat setsinputs.listInputs = filtered(fullstring[]) instead of routing items individually.
New input-resolver behavior — single-result wrapping. Today the resolver pulls state?.output?.listResults (line 85-90 area); if upstream wasn’t fanned out, this is undefined and Collect has no input. We need a fallback: when target is in FAN_IN_NODE_TYPES and listResults is missing, wrap the upstream’s primary output: effectiveListResults = listResults ?? (output != null ? [output] : []). This is new logic to add to the resolver.
Frontend mirror: same check in list-execution.ts:executeNodeForList (line 20) — skip fan-out if the current node is in the frontend’s FAN_IN_NODE_TYPES set. Read upstream’s __listResults directly and pass as string[] to a single execute call.
Result: the graph List → GenerateImage → Collect produces:
GenerateImageruns N times (fan-out, existing behavior)Collectruns ONCE withitems: string[] = listResults- Downstream of
Collectsees a single output (existing behavior)
Cycle invariant: unchanged. Kahn topo sort still works — Collect is just another node with a deterministic input → output relationship.
Failure handling — dense listResults with empty-string failures. executeNodeForList allocates new Array(items.length).fill("") at frontend/src/components/editor/workflow-editor/list-execution.ts:106 and writes results in-place per iteration. Failed iterations remain "". Collect therefore receives a dense array including empty strings for failures — NOT a sparse array, and NOT explicit nulls. Define the contract:
- All strategies normalize inputs first:
const valid = items.filter((s) => s !== "")at the top of everyexecute(). This gives the strategy the survivor countvalid.lengthand avoids feeding empty strings into Sonnet / JSON.parse / vote tallies. - Strategies that accept any count:
concat(joins survivors only — no empty separators),count(returnsvalid.length, NOTitems.length— count is “successes”, not “attempts”),first-non-empty(already trivially correct),vote(counts survivors). - Strategies that need ≥1 survivor:
pick-best-llm,merge-json. Whenvalid.length === 0, these throwEmptyInputError; route returns 400 with{ code: "no_valid_inputs", message: "All upstream iterations failed; nothing to collect." }. - Collect’s node status reflects strategy result: if
valid.length === 0AND the strategy throws, Collect itself goes tofailed. Ifvalid.length < items.lengthand the strategy succeeds, Collect succeeds with whatever items came through;meta.summaryreflects the count ("Collected from 3 of 5 inputs (2 upstream failures)"). The orchestrator’s node-failure cascade is unchanged — Collect failure stops downstream as usual. - Implementation note: factor the
items.filter((s) => s !== "")normalization into a shared helper atbackend/src/services/collect-strategies/_normalize.tsso all six strategies stay consistent.
5. Credit pricing
- Per-strategy keys in
STATIC_CREDIT_COSTS(backend/src/ee/billing/credits.ts) using the composite-key pattern already established forgpt-image:high:"collect:pick-best-llm": 3"collect:concat": 0"collect:first-non-empty": 0"collect:count": 0"collect:vote": 0"collect:merge-json": 0
CREDIT_COSTSentry forcollectresolves to the strategy-specific key viadata.strategyId.- Migration
NNN_collect_node_pricing.sqlinserts the 6 composite keys intomodel_pricing(per CLAUDE.md pitfall 3 — admin UI reads DB, notSTATIC_CREDIT_COSTS).
6. Frontend UX
Node component (frontend/src/components/nodes/collect-node.tsx):
- Standard node frame.
- “N → 1” pill in the header — counts items from upstream
listResultslength (live, updates when fan-out completes). Pill grey when idle/awaiting, brand-pink when execution complete. - Strategy label as subtitle (e.g. “Pick best (LLM)”).
Config panel (frontend/src/components/editor/config-panels/collect-configs.tsx):
- Layout: shadcn
<Tabs>(primitive at@/components/ui/tabs, already imported byimage-configs.tsx:17for the provider multi-picker — established precedent). Two tabs: Config (default) and Inputs. The Inputs tab is only enabled whennodeState.status === "completed"AND upstreamlistResults.length > 0; otherwise it shows a disabled hint (“Run the workflow to inspect inputs”). - Config tab: strategy picker dropdown over
COLLECT_STRATEGIES+ per-strategy form rendered fromgetStrategy(id).configSchema(criteria textarea for pick-best-llm, separator input for concat, deep/shallow radio for merge-json, etc.). - Inputs tab — visible when the node has been executed (i.e.
NodeOutput.listResultsis populated on the input edge):- Renders N input items as a vertical list of thumbnail/chip rows.
- Highlights
meta.selectedIndexwith a brand-pink ring + “selected” badge (for pick-best-llm, first-non-empty). - Renders
meta.reasoningas a blockquote below the highlighted item (pick-best-llm only). - For
concat/count/vote/merge-json: shows all N items unstyled + a summary chip up top (“Joined 5 items”, “Counted 5”, “Winner: ‘option_a’ (3/5 votes)”, “Merged 5 JSON objects”).
- Reuses existing inspection patterns from the fan-out side; no new canvas wiring.
Skip: per-iteration connector visual (one edge per clone into Collect). It fights the current “one source edge → Collect” mental model in React Flow and isn’t worth the canvas-layer work. The pill + inputs tab + existing expandLoopResults clones on the fan-out side already carry the legibility load.
7. Backend
Route (backend/src/routes/collect.ts):
POST /v1/collect— concrete Zod schema, mirroringroutes/ai-writer.tsandroutes/qa-check.ts(both inline-LLM routes):
const collectBody = z.object({
strategyId: z.enum(COLLECT_STRATEGY_IDS), // discriminant; from shared registry
strategyConfig: z.record(z.unknown()), // per-strategy shape; validated at dispatcher via getStrategy(id).configSchema
inputs: z.array(z.string()), // the upstream listResults — dense array, empty strings for failures (see §4)
workflowExecutionId: z.string().uuid().optional(), // audit trail + dedup-fingerprint differentiator (see below)
})
- Pattern: thin Fastify route →
creditGuard→ dispatcher → returns aggregated result. creditGuardresolver readsreq.body.strategyIdfrom the raw body (before Zod strips), sameresolveLlmCreditId()-style pattern used byai-writer.ts:9andqa-check.ts. The resolver returns the composite key"collect:" + strategyId(e.g."collect:pick-best-llm") whichcreditGuardlooks up inSTATIC_CREDIT_COSTS/model_pricing.- Dedup-fingerprint bypass — REQUIRED. The 2026-05-20 dedup-fingerprint middleware hashes
req.url + JSON.stringify(req.body)and collapses identical POSTs within a 10s window. Two distinct workflow runs that produce identical upstream outputs (e.g. cached results, deterministic prompts, or rapid re-runs) would collide on Collect — silently returning the first run’s job id and leaving the second run hanging. Mitigation:
app.post("/v1/collect", {
preHandler: creditGuard(
(req) => `collect:${(req.body as Record<string,unknown>).strategyId}`,
{ dedup: false } // identical inputs across workflow runs MUST NOT collapse
),
}, ...)
{ dedup: false } is the established escape hatch (precedent: routes/voice-clones.ts uses it for both POST handlers). The optional workflowExecutionId in the body is a belt-and-suspenders measure but is NOT sufficient on its own — pass dedup: false.
- Inline execution (no BullMQ queue) — strategies are either pure functions (0-credit ones) or a single
llmCompletecall (pick-best-llm). The worker pattern is overkill; the LLM call is bounded byllm-client.ts:16(LLM_TIMEOUT_MS = 120s) which is well inside the 30-minNODE_TIMEOUT_MS. If pick-best-llm latency becomes an issue at scale, promote to the queue then. - Returns
{ jobId, output, meta }—outputisstring(for url/text strategies) or stringifiednumber(forcount);metais theResultMetashape from §2. Matches existingNodeOutputconventions so frontendexecuteNodeintegration is symmetric.
Strategy implementations in backend/src/services/collect-strategies/:
pick-best-llm.ts(usesllmCompletewithclaude-sonnet-4.6).concat.ts,first-non-empty.ts,count.ts,vote.ts,merge-json.ts(pure functions).index.ts— central dispatcher (mapstrategyId → execute).
EE consideration: pick-best-llm consumes paid LLM credits — its credit cost is gated through the standard creditGuard middleware shim (which dispatches to ee/lib/credit-guard-impl only when hasCredits()). No new ee/ placement needed; the strategy itself lives in core, the credit gating is already at the middleware layer.
MCP exposure: add collect tool at backend/src/lib/mcp/tools/collect.ts (the existing MCP tool directory — apps.ts, characters.ts, components.ts, etc. live here) mirroring the route. Scope: workflows:execute (consistent with other workflow-execution tools). Out of scope for v1 ship — add as a follow-up after the node lands.
8. Registration checklist
Cross-references the New Node Registration checklist in the root CLAUDE.md. All standard steps apply. Steps with non-standard content:
- Step 3 (
STATIC_CREDIT_COSTS) — 6 composite keys (one per strategy), not a single key. Resolver inCREDIT_COSTSreadsdata.strategyIdto pick the key. Existing precedent for composite keys:gpt-image:high,flux:2K(perbackend/CLAUDE.md“Variable Credit Pricing” table). - Step 9 (sidebar list) — Collect lives in the Workflow category in
add-node-popup.tsxalongside list/loop/router/sub-workflow-input/sub-workflow-output/sub-workflow/teleport-send/teleport-receive. - Step 15 (executable gate) — the actual gate is
isExecutableNode(node)atfrontend/src/components/editor/workflow-editor/types.ts:262, backed by anEXECUTABLE_TYPESset in that file (root CLAUDE.md refers to this asEXECUTABLE_NODE_TYPES— the spec there is mildly stale on the name, but the concept is real). Add"collect"toEXECUTABLE_TYPES. The Collect node IS executable (unlike List/Loop which are sources inSOURCE_NODE_TYPES). - Step 16 (DAG execution block) — branch must NOT call
executeNodeForList; instead resolve the upstream’s__listResults(viaextractNodeOutputAsList()) and callexecuteCollectNodeonce. New helper. - Step 18 (
node-input-resolver.tsfrontend /input-resolver.tsbackend) — see §4: special-case Collect via the newFAN_IN_NODE_TYPESset, plus the single-result wrapping fallback. New logic, not just a registration step. - Step 19 (
NODE_REGISTRYatbackend/src/lib/node-registry.ts) — descriptor’sinputSchemadescribes the common fields only (thestrategyIdpicker UI hint + a genericstrategyConfigslot); per-strategy config validation is deferred to the dispatcher viagetStrategy(id).configSchema.capabilitieslists per-strategy info for theGET /v1/nodesdiscovery API.
Two new sets to add (NOT in the existing 19-step list):
FAN_IN_NODE_TYPESinbackend/src/services/workflow-engine/input-resolver.ts(sibling ofARRAY_ACCUMULATING_TYPESat line 661) —Set<"collect">for v1.- Same set in
frontend/src/components/editor/workflow-editor/types.tsfor parity with the frontend executor.
These are net-new concepts; flag for inclusion in the root CLAUDE.md registration checklist once this lands.
Additional verification points (during implementation):
output-extractor.ts— REQUIRED: addcase "collect"togetPrimaryOutput()atbackend/src/services/workflow-engine/output-extractor.ts:267. Verified during audit that this function dispatches on node type with ~15 existing cases (sub-workflow, suno-voice, voice-design, qa-check, router, etc.) and currently has no fallthrough for Collect’s{ result, meta }shape — without an explicit case Collect’s downstream consumers would receiveundefined. Returnoutput.result(string or stringified number).payload-builder.ts— Collect uses inline sync execution (not BullMQ); confirm the orchestrator’s sync-HTTP execution branch (perbackend/CLAUDE.md“Execution Categories” table — same path asai-writer,lottie-overlay) covers Collect, or whether a new inline branch is needed.- Clone persistence — Collect runs ONCE per workflow execution (not per list item). It does NOT participate in
expandLoopResults/collapseExpandedClones. The frontend’s clone-detection (__expandedCloneflag +_iter_\d+$ID pattern) should skip Collect naturally because Collect is not on the fan-out source side. - Run from here / Run selected — since Collect is in
EXECUTABLE_TYPES, these patterns work out of the box. No special case needed; standard topo-sort handles it.
9. Public docs
Per CLAUDE.md “Public Docs Maintenance Rule”:
- Create
docs/nodes/workflow/collect.md— describes the 6 strategies, when to use each, pricing per strategy, worked examples (especially for pick-best-llm criteria writing). - Add row to
docs/nodes/README.md. - Cross-check examples in docs against tests in
backend/src/services/collect-strategies/*.test.ts.
10. SDK & client
- Add
collecttool to@nodaro/sdknodesresource (auto-generated fromNODE_REGISTRYif that path exists; otherwise manual). - Add usage example to
docs/sdk-quickstart.md.
Tests
Shared (packages/shared/src/__tests__/collect-strategy-registry.test.ts):
- Snapshot the registry’s strategy IDs (catches silent renames/drops — same pattern as
combine-transitions.test.ts). - Each strategy’s
configSchemaacceptsdefaultConfig. getStrategy(id)returns the right strategy; unknown id throws.
Backend (backend/src/services/collect-strategies/__tests__/):
- One test file per strategy (
pick-best-llm.test.ts,concat.test.ts, etc.). pick-best-llm: mockllmComplete, assert prompt template + chosen-index parsing + reasoning surfacing; tests for malformed LLM responses (fall back to index 0 withreasoning: "fallback"); tests for emptyitems.concat: separator handling, empty items skipped, mixed-type input coerced to string.vote: tie-breaking (returns first), case-sensitive flag, empty items.merge-json: deep vs shallow, conflict resolution (last-write-wins), non-JSON input → typed error.- Each strategy: empty
items→ typed error (returnnulloutput +meta.summary: "No inputs").
Backend route (backend/src/routes/__tests__/collect.test.ts):
- Auth required, strategyId validated against registry, configSchema validated per-strategy.
- Credit reservation via composite key.
- Returns
{ jobId, output, meta }shape. - Dedup-fingerprint bypass verification: the route is registered with
{ dedup: false }(see §7). Tests: (a) two POSTs with identical body within 10s both create distinct jobs (nodeduped: trueshort-circuit), (b) a 10-iteration workflow with deterministic upstream still creates 10 Collect jobs across re-runs, not 1.
Backend DAG (backend/src/services/workflow-engine/__tests__/):
execution-graph.test.ts:LIST_CONSUMER_NODE_TYPESdoes not fan out.input-resolver.test.ts: Collect’sinhandle resolves to upstream’slistResultsor[singleOutput].- End-to-end:
List → GenerateImage → Collect(pick-best-llm) → DownstreamNode— GenerateImage fans out 3×, Collect runs 1×, downstream sees Collect’s single output.
Frontend (frontend/src/components/__tests__/collect-node.test.tsx):
- Renders “N → 1” pill correctly when upstream has listResults.
- Pill is grey when idle, brand-pink when complete.
- Config panel renders correct UI per
strategyId. - Inputs tab highlights
meta.selectedIndexand rendersmeta.reasoning.
Total estimate: ~50 tests across 8-10 files.
Open questions
None blocking. The single-vs-multi-source question was resolved in conversation (single-source for v1, multi-source as v2). The strategy list is finalized at 6 for v1. The credit cost for pick-best-llm is a suggested 3 cr — to be finalized with the cost-model owner during PR review.
Phase 2 preview (separate spec)
Once Collect ships and proves the shape:
- Parallelize
executeNodeForList— concurrency knob on Loop/List nodes (default4, configurable up to16). - Dedup-fingerprint × fan-out concurrency-safety pass — either
dedup: falsefromexecuteNodeForList(option already exists oncreditGuard, used byvoice-clones.ts) or mix iteration index into fingerprint payload. Settle before flipping parallel on. - Credit reservation race audit — verify
FOR UPDATElocks on credit RPC survive parallel fan-out load; add a load test if not already covered.
v2 additions (no spec yet):
- Multi-source Collect (zip / pad / cartesian).
- List-output strategies (top-K, filter, sort).
- Nested fan-out (
list → loop → list).
Verified codebase references (2026-05-21)
Every architectural claim in this spec is anchored to a real file. Implementers should navigate from these, not from CLAUDE.md alone:
| Concept | Path | Line | Notes |
|---|---|---|---|
NodeOutput.listResults: string[] |
backend/src/services/workflow-engine/types.ts |
41 | Strings only — strategies parse URLs/JSON from strings |
getListInputForNode |
backend/src/services/workflow-engine/input-resolver.ts |
369 | Fan-out detection — must early-return for FAN_IN_NODE_TYPES |
ARRAY_ACCUMULATING_TYPES (sibling pattern) |
backend/src/services/workflow-engine/input-resolver.ts |
661 | Set(["combine-videos", "mix-audio", "combine-audio"]) — closest precedent; routes per-item to typed media arrays |
| Per-item routing branch | backend/src/services/workflow-engine/input-resolver.ts |
111-116 | Where FAN_IN_NODE_TYPES branch must hook in |
| Mixed media-type per-item classification | backend/src/services/workflow-engine/input-resolver.ts |
117-128 | VIDEO_URL_RE precedent for typing strings |
executeNodeForList |
frontend/src/components/editor/workflow-editor/list-execution.ts |
20 | Fan-out engine — must skip when target is in FAN_IN_NODE_TYPES |
expandLoopResults |
frontend/src/components/editor/workflow-editor/list-execution.ts |
188 | Visual clone expansion — unchanged, lives on fan-out side |
isExecutableNode / EXECUTABLE_TYPES |
frontend/src/components/editor/workflow-editor/types.ts |
262 | Step-15 gate — add "collect" to the set |
STATIC_CREDIT_COSTS (composite keys) |
backend/src/ee/billing/credits.ts |
~96+ | Pattern: "gpt-image:high", "flux:2K" — use "collect:<strategy>" |
| Composite-key resolver helper | frontend/src/components/editor/config-panels/helpers.ts |
— | buildCreditModelIdentifier() — Collect frontend equivalent reads data.strategyId |
llmComplete (unified LLM client) |
backend/src/lib/llm-client.ts |
56 | claude-sonnet-4.6 (DOT) — canonical for this path |
| MCP tools directory | backend/src/lib/mcp/tools/ |
— | NOT ee/mcp/tools/ |
NODE_REGISTRY |
backend/src/lib/node-registry.ts |
— | Discovery API descriptor |
creditGuard core shim |
backend/src/middleware/credit-guard.ts |
— | Stays in core, dispatches to ee/lib/credit-guard-impl.ts |
Net-new logic flagged for review
These are NOT just registration changes — they are net-new code paths that need their own test coverage:
FAN_IN_NODE_TYPESset + per-item-routing branch ininput-resolver.ts(~10 lines). Mirror in frontendlist-execution.ts.- Single-result wrapping fallback — when target is in
FAN_IN_NODE_TYPESand upstream has nolistResults, wrap the primary output as[output]. Currently no analogous behavior in the resolver; needs unit test for both array and non-array upstream cases. extractNodeOutputAsList()consumption path — verify the frontend helper (perfrontend/CLAUDE.md“List / Loop / Skip Node Patterns”) returnsstring[]consistently across all upstream node types; Collect depends on this contract.llmCompleteimage-URL content block usage — pick-best-llm sends{ type: "image", source: { type: "url", ... } }content. Verify the unified client’s messages API path supports this shape (theee/pipelines/llms/image-critic.tsdoes similar work viacallLLM; thellmCompletepath may need a thin extension).