W3 — Chat workflow transport: extraction map
Working document forfeat/workflows-w3-chat. Line numbers are against the
branch base (9cf7cc26ce, staging @ 2026-07-18, route = 4,305 lines). This is
the reconnaissance for the plan’s “one unavoidable mechanical move”
(docs/plans/vercel-workflows-rollout.md §W3): the chat turn leaves
app/api/claude/route.ts so a workflow file can call it — workflow files
cannot import route modules (route files may only export HTTP methods; same
constraint that shaped W1’s lib/radar-worker-core.ts).
REVISION — as-built seam (2026-07-20, supersedes the tables below where they differ)
The reconnaissance above assumed several pieces lived in prep; the cut showed they were already inside the ReadableStream body. As implemented:- Route keeps ONLY: auth → request validation → token gate → portfolio
gate → message extraction (
userMessage,turnHasAttachments) → raindropeventIdmint → canonicalrunIdderivation → transport fork → (inline path only) raindropbegin→ controller sink +runChatTurn. - Everything else moved into the turn and runs identically on BOTH transports: session-history reconstruction, the duplicate-run replay branch, model routing, token trimming, the loop, completion, error handling.
- Duplicate replay is deliberately IN-TURN, not pre-fork (review discussion): hoisting it into prep would add a DB read before the first byte of EVERY request (a TTFB regression — today the check runs inside the stream, after the response has started). Running it in-turn keeps TTFB unchanged and both transports byte-identical; the only cost is that a rare duplicate retry on the workflow path spins a cheap workflow run to serve its replay (no model calls).
- Canonical
runId(review fix, Critical):start()can be accepted even when the dispatcher observes an exception — the inline fallback then runs concurrently with the workflow. The route derives ONE run id (client’s when a valid UUID, else fresh) before the fork; both the envelope and the inline deps carry it, so the turn’s duplicate-run machinery can collapse the two executions instead of letting each mint its own id (which meant duplicate rows + double side-effectful tools). - Final ChatTurnDeps (9):
body,lastPayloadMessage,clerkId,userMessage,turnHasAttachments,tokenStatus,toolContext,interaction,runId. - Envelope (ChatWorkflowInputV1):
version, clerkId, userMessage, turnHasAttachments, eventId, runId, tokenStatus, body— prep products only; the step rebuildstoolContext/interactionand the turn derives the rest. - Sink contract: the turn frames SSE itself (single framer) and hands
exact bytes to
ChatTurnSink.sendRaw; disconnect semantics live in the sinks (route: controller enqueue-failure flips a flag; workflow: writer failure ditto, explicit close in finally).
Seam decision
Extract the entire ReadableStreamstart body — minus the duplicate-run
replay branch — into lib/claude/chat-turn.ts:
- The route keeps: auth → token gate → routing → chat-log create → token trim
(prep,
1683–2560), the duplicate-replay branch (2588–2674, stays inline on BOTH transports per plan), then eithernew ReadableStream({ start: c => runChatTurn(deps, controllerSink(c)) })(inline) orstart(chatWorkflow, [frozenInput])→Response(run.readable). - The workflow step rebuilds
depsfrom the frozen envelope + re-created clients, thenrunChatTurn(deps, writerSink(getWritable()))with an explicitwriter.close()— the PR-8 close pattern (run.readable never auto-closes; PR-0 finding). - Byte-identical guarantee:
sendEvent’s text accumulation (needsSpaceBetweenmirroring, citation offset tracking,2163–2280) moves INSIDErunChatTurnunchanged, and theconsumeStream/createAndConsumeStreamWithFallbackcall chain is untouched — chunk boundaries are produced by the same code on both transports.
Verified landmarks (route.ts @ base)
Deps inventory (inputs crossing the seam)
Per-turn state (accumulators, heartbeat, citation maps,partialResponseText,
loopCount, cumulative tokens, presentedArtifacts, allToolsCalled) is
created inside the moved body — NOT deps. True inputs:
- ids:
clerkId,sessId,runId,persistedChatLogId,tier - model plan:
effectiveModel,thinkingConfig,isFastMode,skillsEnabled,cacheableSystem,cacheableTools/allTools,body.context_management(client edits) - conversation:
messages(post-trim),coreMessagesrefs used by suggested-prompts - services:
supabase,streamLogger,ftCollector, raindrop interaction handle,toolContext(built in prep; contains clerkId/sessionId/…) - config:
DEFAULT_LLM_CONFIG(imported, not passed)
ChatWorkflowInputV1) freezes the serializable
subset (ids + model plan + messages + client context_management edits);
services are rebuilt inside the step from ids (same pattern as radar’s
runRadarAnalysisForJob). Schema exists from PR 0 — reconcile fields against
this inventory in this PR (still V1; never shipped).
Golden-transcript harness (before the fork lands)
app/api/claude/__tests__/chat-turn-golden.test.ts:
- Mock the Anthropic client with scripted stream fixtures (deterministic
event sequences): plain text; multi-chunk text exercising
needsSpaceBetweenjoins; single tool round-trip; 529 → fallback (bedrock_fallback);max_tokenscutoff (error path); GenUI block. - Drive
runChatTurnwith a recording sink; snapshot the ordered(event, payload)list including text chunk boundaries. - Parity test: inline
controllerSinkvs workflowwriterSinkover the same fixture must produce byte-identical frame lists (the actual W3 risk). - Honest caveat: fixtures are recorded post-move (the pre-move loop is not callable in isolation — that’s the point of the move). Move-safety is carried by tsc + full suite + mechanical-diff review + staging soak; transport parity is what the harness proves mechanically.
Fork (lands after move + harness are green)
InPOST, after the duplicate-branch check and prep:
getDispatchDecision('chat', clerkId) → workflow: validate + freeze
ChatWorkflowInputV1 (size guard → inline fallback over 800KB), start(chatWorkflow, [input]),
return new Response(run.readable, SSE_HEADERS); inline: today’s path.
workflows/chat.ts: single step, maxRetries = 0 (side-effectful tools; user
watching), finding-11 import discipline (type-only top-level; await import()
in step bodies), explicit writer.close() in a finally.
Merge gate (staging flips chat to workflows on merge — non-prod default)
- tsc + full vitest +
next buildgreen; golden parity suite green. - Manual staging smoke: normal chat, tool-using chat, stop mid-stream, regenerate, reload-mid-turn (2s-partial recovery), session history.
- Kill-switch drill on staging:
SET workflow-dispatch:chat off→ inline within 5s.
Build order (each its own commit)
- Mechanical move + route adapter (zero behavior change; tsc + suite green)
- Golden harness + fixtures (parity suite)
workflows/chat.ts+ fork + envelope freeze- Preview e2e + staging smoke evidence in PR body
W4.5 ADDENDUM — sliced transport (2026-07-21)
Trigger: first prod 800s truncation, canary turn #2 (wrun_01KY1758RKNES49KCKNQ9DGXWR, 19-loop hyperframes video build; step 504’d at exactly 800s, stream went silent, chat row stuck ‘running’). The plan’s pre-registered Phase-2 condition (“step-per-model-call only if 800s truncations are observed”) is met — pulled forward so the canary ramps WITH the wall removed.What changed
runChatTurnis now a thin wrapper overrunChatTurnSlice(deps, sink, opts)— the same single body, with a fresh/resume fork and an optional slice boundary. Inline passes{}(slicing disabled) and is behavior-identical.ChatTurnCheckpoint(v:1): the loop-carried state (messages, accumulators, ids, token counters, ftCollector state). Threaded between workflow step invocations as step args/results; JSON round-trip enforced at build time.- Slice policy: TIME-based at loop boundaries — checkpoint at the first
boundary past
CHAT_TURN_SLICE_ELAPSED_MS(default 180s). Short turns (~99%) complete in slice 1 with zero new overhead. Oversized checkpoints (>CHAT_TURN_MAX_CHECKPOINT_BYTES, default 700KB) degrade to “bulk remainder”: keep looping in the current invocation (pre-split behavior). - Prep derivations (tools/system prompt/thinking/fast-mode) extracted to
deriveTurnStaticConfig— pure function of serializable inputs; resumed slices re-derive instead of re-running prep’s side effects. Config SSE events fire on the fresh slice only (original order preserved). workflows/chat.ts: orchestrator loopsrunTurnSlice(maxRetries=0 unchanged), threading checkpoints; the run writable SPANS slices (acquire → write → drain → releaseLock per checkpoint slice; only the final slice closes). NewfailTurnstep on lost invocations: sanitized terminal error frame + stream close + chat row marked failed (checkpointed id, else run_id lookup) — the 800s failure mode becomes a clean error instead of a silent hang + stuck row.- Client resume: the POST response (workflow path only) carries
x-chat-run-id+ HMACx-chat-resume-token(binds workflowRunId+clerkId; secretCHAT_STREAM_RESUME_SECRET||INTERNAL_API_TOKEN; none configured → headers omitted, pre-W4.5 behavior). If the pump dies without a terminal event,parseAnthropicStreamWithResumere-attaches atGET /api/claude/stream?runId&startIndex=<frames-consumed>(one SSE frame == one durable chunk) and continues yielding — downstream hook code cannot tell a resume happened. Tokens re-mint per hop. - Smoke:
?mode=sliced&stream=1on /api/internal/workflow-smoke proves the repeated-step loop + writer handoff + single close on a real deployment.
Parity evidence
Golden suite grew 5→9: sliced-parity (checkpoint-every-loop == single invocation, byte-for-byte), checkpoint JSON stability, oversize degrade, slicing-disabled inline. Orchestrator suite (workflows/tests/chat.test.ts): checkpoint threading, release-vs-close lifecycle, failTurn row cleanup both lookup paths. Resume: token contract (6 cases) + stream re-attach (frame counting incl. unknown event types, token chaining, terminal/abort guards).Deploy notes
- Zero migrations. New env (all optional): CHAT_TURN_SLICE_ELAPSED_MS, CHAT_TURN_MAX_CHECKPOINT_BYTES, CHAT_STREAM_RESUME_SECRET (falls back to INTERNAL_API_TOKEN, already present).
- Verification gates on preview/staging: sliced smoke run, then a long video-build chat proving >800s survival end-to-end with the live stream following across the pump boundary.