feat(ai-orchestration): generator-based workflows + orchestrators#542
Closed
AlemTuzlak wants to merge 82 commits into
Closed
feat(ai-orchestration): generator-based workflows + orchestrators#542AlemTuzlak wants to merge 82 commits into
AlemTuzlak wants to merge 82 commits into
Conversation
Implements yield-helpers for the workflow engine: approve() for human-in-the-loop approval steps, bindAgents() to convert agent/workflow definitions into bound step generators, and retry() (async generator) for fault-tolerant step execution with configurable backoff.
…helpers Implements snapshotState/diffState using fast-json-patch for RFC 6902 JSON Patch diffs, plus emit-events helpers (runStartedEvent, stepStartedEvent, stateSnapshotEvent, approvalRequestedEvent, etc.) that produce StreamChunk values for the workflow SSE stream.
…ue, plug RUN_ERROR runId - resumeWorkflow now calls runStore.set() before runStore.delete() so observers see the finished state - pendingEvents queue moved onto LiveRun so the emit() closure captured during runWorkflow is drained correctly by resumeWorkflow - Both drive loops drain live.pendingEvents at the top of each iteration - runErrorEvent now includes runId in the returned chunk
…xports Implements Tasks 3.1–3.4 and 4.1: defineAgent, defineWorkflow, defineOrchestrator factory functions; toWorkflowSSEResponse SSE helper; and wires up the full public API surface in src/index.ts.
…single entry point
…yield* delegation works
Rename the result helper `ok()` to `succeed()` for clarity. The name `succeed` reads better alongside `fail` and avoids shadowing the `Response.ok` DOM property name in server contexts.
Add `defineRouter(config, fn)` — a phantom-config wrapper that captures generic type parameters from a shared config object so users can extract orchestrator routers as named functions without losing type inference.
Remove `phase: 'scoping' as const` from the orchestrator initialize since the schema default covers it. Extract the orchestrator router using the new `defineRouter` helper to demonstrate zero-cast extraction of a named router function.
Add an `endpoint` option to WorkflowClientOptions (and UseWorkflowOptions) as a mutually exclusive alternative to `connection`. When `endpoint` is provided the client internally POSTs JSON and parses the SSE response, eliminating the inline fetch boilerplate and `as any` cast at every call site.
Replace the 50-line inline fetch+SSE adapter with a single \`endpoint: '/api/workflow'\` (resp. \`/api/orchestration\`) option, removing the last \`as any\` cast in the demo route files.
Add a \`handleWorkflowRequest\` function that encapsulates JSON body
parsing, start-vs-resume-vs-abort dispatch, and SSE response shaping.
Server API routes can now delegate entirely to this helper, eliminating
the \`as { ... }\` cast on the request body and the manual
\`toServerSentEventsResponse\` wiring.
Round 1 of the cr-loop CR pass against PR #589 surfaced ~56 bucket (a) findings across 19 review agents. This commit lands the well-scoped mechanical batch (~22 fixes). Engine-source and example-app fixes are tracked separately. Docs (docs/orchestration/run-persistence.md, docs/api/ai-orchestration.md, docs/orchestration/workflows.md, docs/orchestration/orchestrators.md): - Replace stale RunStore shape (get/set/delete) with the actual five-method interface (getRunState/setRunState/deleteRun/appendStep/ getSteps) and document the CAS log semantics. - Restate RunState's full field set including fingerprint, workflowVersion, startingPatches, waitingFor — load-bearing for durable RunStore implementers. - Fix observableRunStore example to wrap the real method names (setRunState/deleteRun/appendStep instead of set/delete). - Document that the engine already implements replay-from-log durability; the gap to durable resume is the runStore type widening, not engine work. - runWorkflow options table: add signalDelivery, attach, publish that PR #589 added; note the InMemoryRunStore narrow typing. - parseWorkflowRequest fields: drop the false signal/attach claim, add signalDelivery and note the body-field rename. - defineWorkflow config: add version, patches, defaultStepRetry. - useWorkflow/useOrchestration action table: add attach(runId) and signal(name, payload, { signalId? }) PR #589 added. - Types table: add SignalResult, StepRecord, StepKind, StepRetryOptions, LogConflictError, StepTimeoutError; correct RouterDecision shape. Packaging (packages/typescript/ai-orchestration): - package.json: add sideEffects: false and engines.node so tree-shaking works in consumer bundlers; add "skills" to files so the agent skill ships to npm. - tsconfig.json: extend ../../../tsconfig.base.json (matches every sibling package) instead of the root tsconfig.json; drop the .tsx glob from include (this is a Node-only library) and the vite.config.ts include/exclude contradiction. Configs: - knip.json: drop the dead packages/react-ai workspace entry. - coupling.json: drop the $schema reference to the missing coupling.schema.json. Skill (ai-core/SKILL.md): - Bump library_version 0.10.0 -> 0.20.0 to match the current @tanstack/ai package version. - Fix the useChat({ clientTools }) lie — the actual call is createChatClientOptions({ tools }) using the clientTools() helper. Tests (packages/typescript/ai-orchestration/tests): - engine.timeout.test.ts: the StepTimeoutError retry-predicate test never incremented callCount inside the step fn, so caughtImmediately was always false and the only outer assertion was an existence check. Increment callCount, assert via toMatchObject on the run output, drop the dead monkeyPatch/timeoutFired scaffolding. - engine.idempotency.test.ts: the signalId-persistence test ended with `void 0` and no assertions. Add a RUN_FINISHED check and a comment pointing to the multi-signal test for the persistence assertion. - engine.cas.test.ts: the duplicate-delivery test only did ONE delivery. Rewrote with a two-stage workflow that pauses between signals so the same signalId can be replayed against the existing log entry via simulateRestart. - engine.durability.test.ts: the StepRecord-per-agent test never read the step log because deleteRun fires on finish. Add an approve() pause before return so the log is inspectable, then assert two agent records with their results. All 162 nx tasks green (test:sherif, test:knip, test:docs, test:eslint, test:lib, test:types, test:build, build).
Five clean fixes in the orchestration demo: - ArticleModal: useEffect deps were `[props]`, which is a new reference every render and re-ran the effect — capturing `'hidden'` into `prev` on the second pass and leaking `document.body.style.overflow = 'hidden'` after the modal closed. Pin onClose via a ref and depend on `[]` so the effect only runs on mount/unmount. - diff-extract `stripCodeFence`: the closing-fence regex was `\`\`\`?\s*$` (third backtick optional) and was matching mid-content early-termination on patches containing two-backtick spans. Tighten to `\n?\`\`\`\s*$` (mandatory triple-backtick at the close). - CodeBlock: `errored` state was sticky — once it flipped true, subsequent successful re-highlights stayed hidden behind the fallback `<pre>`. And `html` from a previous `code` value was shown during the async re-highlight, masking the new (raw) code. Reset both at the top of the effect; also log on failure so the dev console isn't silent. - shiki highlighter: cache no longer poisoned on init failure (highlighterPromise is cleared so a remount can retry), and the failure is logged. - shiki normalizeLang: aliases (`md`, `ts`, `sh`) now map to canonical ids (`markdown`, `typescript`, `bash`) before `loadLanguage` is called. Previously the aliases passed through unchanged and shiki rejected them, combined with the now-fixed sticky-errored bug it kept the panel permanently unhighlightable. - workflow.tsx: remove dead `if (wf.status === 'idle' || wf.status === 'running')` block whose only body was a comment.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
- retry primitive: relax TNext from `T` to `any` to match StepGenerator<T>. Constraining TNext to T rejected workflows that yielded multiple agent or step calls with differing return types inside the retried block. Type-only change; no runtime impact. - runErrorEvent: emit `threadId` alongside `runId` so error events match the AG-UI shape that runStartedEvent / runFinishedEvent already use. Falls back to runId when no threadId is provided, mirroring the existing helpers. Both fixes are pure additions / loosenings — no behavior change for existing callers. 68 tests still pass.
…s from cr-loop
Engine (run-workflow.ts):
- Honor pre-aborted AbortSignal at start/resume so a caller who cancels
before runWorkflow has a chance to listen still triggers the engine's
abort controller. addEventListener('abort') is not invoked for the
already-aborted state.
- Track step-timeout cause via an explicit `timedOut` flag rather than
`!timeoutHandle` (which was always truthy once setTimeout assigned),
so a run-level abort during a step+timeout no longer misclassifies as
StepTimeoutError. Test in engine.timeout.test.ts.
- Restore RunState.status to 'paused' on signal_lost before returning,
on both the in-memory and replay paths. The losing caller's resume
was setting status to 'running' but never reverting, so the next
resume saw a stale running state.
- Idempotent in-memory retry now emits STEP_FINISHED with the EXISTING
recorded result (not the caller's payload) AND overrides nextValue
so the generator resumes with the authoritative first-write. Two
tabs delivering the same signalId with different payloads now
observe identical state.
- Use the reserved sentinel `__approval` (not `'approval'`) for the
signalName equality check, matching every other site in the codebase.
- Attach to a paused-on-approval run now re-emits `approval-requested`
after `run.paused` so the client's existing handler populates
pendingApproval. Test in engine.attach.test.ts.
Engine type-widening:
- runWorkflow.runStore now accepts the base RunStore interface. Engine
uses a new asLiveStore() helper to duck-type setLive/getLive — durable
RunStore implementations omit those and the engine falls back to the
replay path. Internal helpers (drainPersistedRun, etc.) widened to
RunStore too.
Engine misc:
- fingerprint.ts: rewrote the docblock to be honest about being a custom
64-bit dispersion hash (uses 32-bit FNV prime as FNV_PRIME_LO + 16-bit
carry split) rather than canonical FNV-1a-64. Locked-in by stored
fingerprints — changing the algorithm would invalidate in-flight runs.
- sleep.ts: documented the Date.now() determinism gap. Anchoring via
yield* now() requires engine cooperation we don't have yet; the
deadline is advisory so the divergence only affects hosts that build
time-indexed worker jobs off waitingFor.deadline on the replay path.
- invoke-agent shape (c) detection now verifies stream is async-iterable
AND output is thenable before taking that branch, so a user object
with literal `stream`/`output` keys doesn't crash.
- selectWorkflowVersion: a versioned run that doesn't match any
registered version now returns undefined instead of silently falling
through to the unversioned default. Test in registry.test.ts.
- inMemoryRunStore: don't schedule TTL expiry while a run is paused.
Long-running waitForSignal / sleep > TTL would silently delete the
run from underneath the engine; the host owns cleanup via deleteRun.
- parseWorkflowRequest now wraps JSON.parse failures and non-object
bodies in a typed WorkflowRequestParseError that callers can catch to
return a proper 400. Tests in parse-request.test.ts.
Client (workflow-client.ts):
- STEP_FINISHED failure detection now requires the engine error envelope
(`{ error: { name, message } }`) instead of `'error' in content`,
so a successful step returning `{ error: null, value: ... }` (a
common tagged-result shape) is no longer misclassified as failed.
Test in workflow-client.test.ts.
- applyJsonPatch handles root-pointer ops (`path: ''`) — replace/add
swap the doc, remove clears it. Previously the engine emitted a
root-replace whenever prev/next state types disagreed and the client
silently dropped it. Test in workflow-client.test.ts.
- handleChunk guards RUN_FINISHED / RUN_ERROR / RUN_STARTED against
flipping a terminal local status. After user calls stop(), a delayed
server RUN_FINISHED from the in-flight stream no longer overwrites
the local 'aborted'. The error field is also cleared when status is
'aborted'. Test in workflow-client.test.ts.
New tests (+ 78 → 86 passing):
- tests/engine.smoke.test.ts: pre-aborted signal propagates to agent.
- tests/engine.timeout.test.ts: parent abort during step+timeout does
NOT surface as StepTimeoutError.
- tests/engine.attach.test.ts: paused-on-approval attach emits
approval-requested so client UI gets the prompt.
- tests/registry.test.ts: versioned run with no match returns
undefined (no silent unversioned fallback).
- tests/parse-request.test.ts: 6 tests for the new request-parse
surface — field extraction, signal→signalDelivery rename, malformed
JSON / non-object body rejection, cause preservation.
- packages/typescript/ai-client/tests/workflow-client.test.ts: 7 tests
covering applyJsonPatch root replace + nested mix, failure-envelope
detection, stop() terminal-state guard, RUN_ERROR aborted-code
handling, and idle subscribe state.
All 162 nx tasks green. 86 tests pass (+10 from prior). E2E green (179
passed, 5 flaky retries succeeded).
Subject-scoped, load-bearing fixes from CR review on PR #542: - state-diff: normalize undefined to null in emitted JSON Patch ops so JSON.stringify doesn't drop the value field (RFC 6902 invalidity). - in-memory store: aborting deleteRun now tears down the live controller and rejects any pending approval resolver so awaiters don't hang. - parse-request: enforce signal-over-approval precedence at the parse boundary so downstream code never sees an ambiguous body. - fingerprint: use JSON.stringify for the sorted patch array so patch names containing commas don't collide. - workflow-client: surface stream iteration errors as { status: 'error', error } so UI recovery works after a connection drop; preserve the aborted terminal state when late failures arrive. - workflow-client.applyJsonPatch: skip nested ops when an intermediate path segment is missing or a primitive, instead of throwing. - invoke-agent: settle the output Promise from a finally block so consumers awaiting it can't hang when the iterator exits early. - example api routes: wrap parseWorkflowRequest/runWorkflow in try/catch and return 400/500 JSON instead of unhandled errors. - DraftPreview / WorkflowTimeline FailureBlock: narrow unknown payloads with proper runtime guards instead of blind casts. - package.json: switch peerDependencies @tanstack/ai to workspace:*. - docs: add language tag to fenced code block (MD040). Tests: - new state-diff.test.ts pinning undefined → null normalization. - new in-memory teardown test pinning paused-run abort + resolver reject. - new workflow-client tests pinning consumeStream error mapping and late-failure-after-stop behavior. - parse-request precedence test pinning signal-wins-over-approval. 162 nx tasks green, 9 ai-client suites pass, 15 ai-orchestration suites pass (86 tests up from 82), E2E 183 passed.
This was referenced May 22, 2026
Merged
Resolves conflicts after main flattened packages/typescript/* to packages/* and removed PHP/Python packages (#643). Key conflict resolutions: - packages/ai-client/src/connection-adapters.ts: combined imports and kept both StreamTruncatedError (from main) and WorkflowConnectionAdapter (PR) - packages/ai-client/src/index.ts: re-exported both stream (PR) and StreamTruncatedError (main) - examples/ts-react-chat/src/components/Header.tsx: import both Network (PR, orchestration link) and Server (main, server-fn-chat link) icons - examples/ts-react-chat/src/routeTree.gen.ts: regenerated to include both orchestration/workflow (PR) and server-fn-chat (main) routes - knip.json: adopted flattened workspace paths from main, kept PR's duplicates rule - .agent/self-learning/coupling.json: flattened SKILL.md paths and kept the ai-orchestration entry added by the PR - Moved packages/typescript/ai-orchestration/ to packages/ai-orchestration/ via git mv to match main's flattened layout (fixes tsconfig extends path, package.json directory field) - pnpm-lock.yaml: regenerated via pnpm install
0.0.2 hardens the runtime: strict approval/signal delivery matching, retained terminal run logs, and automatic input/output schema validation. Notable wins for ai-orchestration: - Post-mortem attach works for free — attaching to a finished run now replays the persisted RUN_FINISHED instead of emitting run_lost. Hosts no longer need an archive hook to support refresh-after-completion. - Mismatched approval/signal deliveries now lose deterministically with explicit `approval_lost` / `signal_lost` codes instead of racing. - Workflow input/output get validated by the engine, so consumers see a `validation_error` RUN_ERRORED instead of a downstream type error. Test updates required by the new strict approvalId match: tests that hard-coded `approvalId: 'a1'` now extract the engine-generated id from phase 1 events via a new `findApprovalId` helper. `engine.attach.test.ts > attach — finished run` updated to assert the new replay behavior. Also fix `packages/ai-orchestration/eslint.config.js` import path (`../../../` → `../../`), a leftover from when the package lived under `packages/typescript/`.
Member
|
Closing as superseded by #979. We are standardizing on TanStack Workflow as the durable runtime and on one async handler/context authoring model. The generator/ The AG-UI, client, persistence, and interrupt questions are carried into the new RFC. Thank you for the extensive prototype work; it directly informed the ownership boundaries and decisions in #979. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@tanstack/ai-orchestrationpackage: define agents, workflows, and orchestrators using async generators.yield* agents.x(...)for typed agent calls,yield* approve(...)for pause/resume on user decision, plain JS for everything else (if,for,await,Promise.all).defineOrchestratoris sugar overdefineWorkflow(same runtime, different vocabulary).defineRouter(config, fn)lets users extract the orchestrator router as a named function with full type inference.RunStarted,StepStarted/Finished,StateSnapshot,StateDeltavia JSON Patch RFC 6902,RunFinishedcarrying typed output,RunError). Approvals reuse the existingapproval-requestedcustom event.WorkflowClientadded to@tanstack/ai-client(mirrorsChatClient's connection-adapter pattern),useWorkflow/useOrchestrationhooks added to@tanstack/ai-react.parseWorkflowRequest(request)extractor; consumers userunWorkflow({ workflow, runStore, ...params })+toServerSentEventsResponse(stream)— symmetric with howchat()is called.RunStoreinterface with defaultinMemoryRunStore(1h TTL).ts-react-chat:/workflow(article writing pipeline with writer → legal → skeptic → editor → approve/revise loop, dramatic fullscreen ArticleModal on publish) and/orchestration(Claude Code-style spec → approve → implement [nested workflow] → review).DraftPreviewrendering the article-in-progress as the editor mutates state.Architecture decisions captured during design
function*), not a node-array DSL — preserves "feels like JavaScript".succeed({...})/fail(reason)helpers replaceas constdiscriminator casts at return sites.runStoreis in-memory only; engine uses live generator handles (no replay) for resume. Pluggable interface in place for future durable stores.Out of scope (post-prototype)
parallel/loop/step/askprimitivesTest plan
Summary by CodeRabbit
Release Notes
New Features
@tanstack/ai-orchestrationfor multi-step LLM workflows with typed agents and router-based orchestrationuseWorkflowanduseOrchestrationReact hooks for client-side workflow managementDocumentation
Tests