Date: 2026-06-03 Status: Design approved; implementation pending
Let the same workflow run multiple times concurrently. The editor canvas shows one run’s execution at a time — a “lens” the user selects from the queue overlay — while node results and outputs accumulate across all runs so they can be compared.
Today the backend forces same-workflow runs to be sequential (hasActiveJobForWorkflow),
and the frontend keys all node-level state by workflowId:nodeId, so two runs of one
workflow would clobber each other. This redesign lifts the serialization and splits
node-level state into two categories with different lifetimes.
A prerequisite bug fix already landed: WorkflowRunner.run() now returns the id of the
run it initiated (queued or fresh) instead of void, and the sketch/timeline/mini-app
hooks use that id rather than re-reading the stale runnerStore.job_id. Without it, a
second concurrent run subscribed to the previous run’s job and stranded its updates.
Every backend message already carries both workflow_id and job_id (stamped in
unified-websocket-runner.ts streamJobMessages). We use job_id to split node-level
state into two categories:
result / outputs.
Auto-saved media (image/audio/video) is persisted as Asset records keyed by
(workflow_id, node_id, job_id) and kept in the DB until the user deletes it; the
across-run gallery reads it back via assets.list, so it survives reloads with no
run cap. Non-media results (text, numbers, dicts, intermediate values) aren’t
persisted today and stay in the in-memory per-job layer. Either way products render
across all runs, never swapped by focus.focusedJobId : workflowId → jobId // which run the canvas is "watching"
UnifiedWebSocketRunner.runJob (packages/websocket/src/unified-websocket-runner.ts)
currently queues a run when hasActiveJobForWorkflow(req.workflow_id) is true. Drop that
condition from the gate so same-workflow runs start immediately, bounded only by the
global MAX_CONCURRENT_JOBS cap; runs beyond the cap still queue FIFO and drain as slots
free (drainQueue). drainQueue’s hasActiveJobForWorkflow filter is likewise removed.
No message-shape changes: jobs already have isolated ProcessingContexts and every
outbound message is stamped with job_id + workflow_id. Persistence, cancel, and
reconnect are already per-job_id.
With the cap set to 1, behavior is identical to today (full serialization) — a safe operational fallback.
| State | Source message | Store today | New keying |
|---|---|---|---|
| Node status | node_update.status, prediction |
StatusStore wf:node |
wf:job:node |
| Edge activity | edge_update |
ResultsStore.edges wf:edge |
wf:job:edge |
| Progress | node_progress |
ResultsStore.progress wf:node |
wf:job:node |
| Execution time | derived from node_update |
ExecutionTimeStore wf:node |
wf:job:node |
| Node error | node_update.error |
ErrorStore wf:node |
wf:job:node |
| Chunks / tasks / tool calls / planning | streaming updates | ResultsStore.* wf:node |
wf:job:node |
| Provider cost | node_update.provider_cost |
ResultsStore wf:node |
wf:job:node |
| Result / outputs — media | node_update.result, output_update (auto-saved) |
ResultsStore wf:node (live) |
DB Asset by (wf, node, job); in-memory = live mirror |
| Result / outputs — non-media | same | ResultsStore wf:node |
wf:node → { job: value } (in-memory, ephemeral) |
Execution-telemetry maps gain a job segment in their key; canvas selectors resolve it
through focusedJobId. Durable-product maps keep the node key but hold a per-job
collection, surfaced across runs.
A new per-workflow registry tracks the live set of runs and the focus:
useWorkflowRunsStore (new Zustand store) — workflowId → { jobId → RunMeta } and
workflowId → focusedJobId. RunMeta = { jobId, state, startedAt, label, progress }.
Populated by handleUpdate from job_update/node_update. label is derived from the
run’s distinguishing params (the prompt, a seed…), falling back to deriveJobTitle.handleUpdate reworkweb/src/stores/workflowUpdates.ts currently routes everything through a single
per-workflow runnerStore and uses isRunnerJob to decide whether an update may drive
runner state. Under concurrency:
data.job_id directly — no
isRunnerJob gating, no dependence on runnerStore.job_id.job_update (lifecycle) and node_update (per-node
progress/status), independent of which run the editor “started”.runnerStore’s role shrinks to connection management, starting runs, notifications,
and the editor toolbar’s Run/Stop target (now focusedJobId). It no longer keys node
state.WorkflowRunner.run() today clears the whole workflow’s node state on start
(clearStatuses(wf), clearResults(wf), …). Under concurrency a new run must not
wipe siblings or the accumulated gallery. A fresh jobId has an empty execution slice,
so the broad clears are removed; per-job execution state simply starts empty.
PropertyValidationStore.clearWorkflow (pre-flight highlights) stays workflow-level.
focusedJobId.web/src/components/panels/QueueOverlay.tsx is reused as the focus selector. It keeps its
jobs.list source (useRunningJobs), collapse/expand, and Running/Enqueued/Cancelled
sections, and gains:
focusedJobId (the focused card shows an accent bar + “On canvas” indicator);
clicking a card for another workflow switches to that workflow first;focusedJobId; an overlay card’s ✕ targets that card’s job
(already trpcClient.jobs.cancel({ id })).assets.list (DB) independently — only live execution
telemetry needs the job reconnect.Assets keyed
by (workflow_id, node_id, job_id) — kept until the user deletes them. The across-run
gallery reads them via assets.list filtered by workflow + node (tagged by job), so it
survives reloads with no run cap and paginates from the DB. Non-media results
(intermediate text/numbers/dicts) aren’t persisted today and remain in the in-memory
per-job layer for the session/connection. An in-flight run shows a live tile from the
in-memory result until its asset lands, at which point the ["assets"] invalidation
swaps in the persisted one.focusedJobId is that run and every surface
behaves exactly as before — zero new UI, no overlay focus chrome.SketchGenerationStore, TimelineGenerationStore) —
already job-keyed; they benefit from the backend gate lift without structural change.focusedJobId selectors; focus default/fallback
transitions; product accumulation + 10-run eviction; handleUpdate writes by
data.job_id; clear-on-run no longer wipes siblings.MAX_CONCURRENT_JOBS=1 reproduces full serialization.packages/websocket/src/unified-websocket-runner.ts — runJob / drainQueue gate.web/src/stores/StatusStore.ts, ErrorStore.ts, ExecutionTimeStore.ts,
ResultsStore.ts — job segment for telemetry; per-job collections for products.web/src/stores/workflowUpdates.ts — route by data.job_id; update registry.web/src/stores/WorkflowRunner.ts — Run/Stop target = focusedJobId; drop broad clears;
multi-job reconnect.web/src/stores/WorkflowRunsStore.ts (new) — runs registry + focusedJobId.web/src/components/panels/QueueOverlay.tsx — focus selection, labels, Done section.web/src/serverState/useJobAssets.ts + a per-node assets hook — gallery source; may
extend the assets.list tRPC input to filter by node_id + workflow_id (the Asset
model already supports it).