feat(flows-yaml): engine + 15 ops + integration layer (closes #1075) #1079
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!1079
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "flows-yaml/engine"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1075. Spec:
docs/specs/flows-yaml.md.Lands the YAML flows engine end-to-end as a parallel dispatch path. Existing JSON node engine untouched; main.ts not yet wired (that's #1076 alongside default flow files + REST CRUD). Cutover is #1078.
Commits
docs(specs): add flows-yaml spec— full spec on disk; all four milestone tickets reference it.feat(flows-yaml): engine bones— schema, expression, loader, executor, op registry. Replaces the conceptual surface ofdefaultArgInjections(flow-dispatch.ts:113-218) andmatchesFilters(flow-dispatch.ts:356-407).feat(flows-yaml): 15 ops + capability extensions— every op from spec §6.1, each declaring its own argsSchema/outputsSchema and capability deps.feat(flows-yaml): dispatcher, trigger bus, hooks, internal-trigger emission— integration layer wiring the engine to the existing service.What landed
Engine (
apps/server/src/domain/flows-yaml/)types.ts,schema.ts,trigger-events.ts— typedFlowFile,TriggerEventdiscriminated union (forge events + internaltask.*/flow.*/pr.merged), capability shapesexpr/{parser,evaluator,builtins,index}.ts— hand-rolled recursive-descent parser, AST evaluator,${{ }}interpolation, builtins (has_label,author_is,repo_matches,comment_matches, …)loader.ts—loadFlowsFromDisk(custom shadows defaults byname), debouncedfs.watch,FlowRegistryexecutor.ts— linear executor withparallel:block,continue-on-error,concurrency.groupmutex (cancel-in-progress: trueaborts active holder), audit writes via injected hooksops/index.ts—defaultRegistryaggregating all 15 ops;buildOpContextreturns only declared depsOps (15)
Forge-event:
dispatch,dispatch_breakdown,resolve_agent,dedup_check,dedup_record,dedup_cancel_stale,guard_reviewer_dispatch,guard_author_dispatch,detect_change_request,set_label,remove_label,comment.Imperative-handler ports:
cancel_tasks(replaceshandleIssueUnassignedatwebhook.ts:296),pr_dependency_markers(replaceshandlePrDependencyMarkersatwebhook.ts:325),propagate_dependencies(replaceshandleStackedRebaseCascadeatwebhook.ts:333and the legacyhandleIssueClosedcallback atflow-dispatch.ts:184).No op imports from
domain/flows,domain/dispatch, ordomain/workflow— they talk to the typedCapabilitiessurface only.Integration
trigger-bus.ts— in-process pub/sub, microtask-scheduled, error-isolated subscriberswebhook-adapter.ts—ForgeEvent→ YAMLTriggerEventmappingdispatcher.ts— mode-gate (off/dry-run/live) + depth cap (MAX_INTERNAL_DEPTH = 4) +selectFlows/executeFlow+flow.completed/flow.failedre-publishhooks-impl.ts—FlowDispatchHookswired to existingflow_runs/flow_node_runsinserts + SSE topics. Every column-name adapter line tagged// #1078for the cutover rename pass.index.ts— public barrel for the eventualmain.tswiringInternal-trigger emission (additive)
domain/dispatch/registry.ts—emitTaskTrigger()called fromonFinish(the unified terminal hook for every dispatched task path). Mapssuccess→task.completed,failure/cancelled→task.failed. No-op when no bus is wired.domain/workflow/event-handlers.ts—handlePullRequestClosedemitspr.mergedwhenpr.merged && pr.base?.ref(same gate the legacy post-merge rebase dispatch uses).Both modules expose
setTriggerBus/resetTriggerBusmirroring the existingsetAgentDeleteRunnerpattern.Migration
drizzle/0013_flows_yaml_internal_trigger_source.sql— addsflow_runs.internal_trigger_source TEXT(NULL for forge-event runs)migrate.tsmirrorsaddModelRatesJsonColumnIfPresentso the column lands on partial / pre-Drizzle DBsThe
flow_runs.flow_id→flow_nameandflow_node_runs.node_id→step_idrenames are deferred to #1078 cutover (every adapter line tagged inhooks-impl.ts).Audit findings addressed
cancel_tasks,pr_dependency_markers,propagate_dependencies); webhook cutover lands in #1078ctxwithdepscapability list;buildOpContextpanics on missing capability with clear messageparallel:block; wave executor + topo sort + cycle validator gone (legacy stays until #1078)if:) with composable predicates; replacesmatchesFilterstask.*/flow.*/pr.mergedinternal triggers withMAX_INTERNAL_DEPTH = 4capTest plan
bun test apps/server/src/domain/flows-yaml/— 179 / 0 (engine + ops + integration)bun test(server-only,cd apps/server) — 3633 / 0 (full suite)bun x tsc --noEmit— clean (server + shared; web typecheck unaffected by this PR)webhook.ts, no edits todomain/flows/, no edits toflow-routes.ts— legacy path runs unchangedOut of scope (ships in follow-ups)
*.ymlflow files, Zod → JSON Schema build pipeline, REST CRUD + SSE,main.tswiring of the dispatcherflow_id→flow_name/node_id→step_idrename🤖 Generated with Claude Code
Integration layer wiring the YAML engine to the existing service. New files in apps/server/src/domain/flows-yaml/: - trigger-bus.ts — in-process pub/sub for internal triggers, microtask- scheduled, errors isolated per subscriber - webhook-adapter.ts — ForgeEvent → YAML TriggerEvent mapping - dispatcher.ts — main entry: mode-gate + depth-cap (MAX_INTERNAL_DEPTH=4) + selectFlows + executeFlow + flow.completed/flow.failed re-publish + bus subscription for internal events - hooks-impl.ts — FlowDispatchHooks wired to flow_runs + flow_node_runs inserts + SSE topics flow_run.{started,finished} + flow_step.completed. Every column-name adapter line tagged // #1078 for the cutover rename pass. - index.ts — public barrel for main.ts wiring Drizzle migration 0013 adds flow_runs.internal_trigger_source (TEXT, NULL for forge-event runs). Idempotent guard added to migrate.ts so the column lands on partial / pre-Drizzle DBs. Internal-trigger emission lands in two existing files (additive, no-op when bus undefined): - domain/dispatch/registry.ts — extracts terminal-state emission to emitTaskTrigger() called from onFinish (the unified terminal hook for every dispatched task path). Maps result.status: success → task.completed, failure/cancelled → task.failed. - domain/workflow/event-handlers.ts — handlePullRequestClosed emits pr.merged when pr.merged && pr.base?.ref (same gate the legacy post-merge rebase dispatch uses). Both modules expose setTriggerBus / resetTriggerBus mirroring the existing setAgentDeleteRunner pattern. main.ts is NOT yet wired — the engine is ready but the dispatcher is not started in production. That ships with #1076 alongside default flow files + REST CRUD. 179 flows-yaml tests pass, full server suite 3633/3633 green, tsc clean. Web tests unaffected. Refs: #1075 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The vitest browser-mode mocker serves vi.mock'd modules through Playwright RouteHandlers. An async factory still resolving when birpc closes (e.g. a late module fetch during teardown) raises: [vitest] There was an error when mocking a module. … Caused by: [birpc] rpc is closed, cannot call "resolveManualMock" Failure path: ManualMockedModule.factory → vi.importActual('@tanstack/react-router') → RPC call → birpc closed → unhandled rejection → process exit 1 The factory in vitest.setup.tsx was the only async mock in the suite; every other vi.mock returned a sync object literal. Replace it with a sync factory that returns `routerStubs` directly, and extend `routerStubs` with the four remaining tanstack-router exports app code touches transitively (through route loaders / root-router setup): `createRootRouteWithContext`, `createRouter`, `RouterProvider`, `redirect`. Tests never mount a real RouterProvider, so identity / no-op stubs are sufficient. `redirect` keeps a throw shape so an accidental call surfaces loudly rather than returning `undefined`. This addresses the recurring CI failure on PR #1079 (and the same flake on several recent unrelated PRs — #3392, #3394, #3395). No app-code changes. Refs: #1075 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>a21ba7c7574906644405resolveManualMockRPC race fails CI after green tests #1080behavior
dispatcher.ts:61:event.depth >= MAX_INTERNAL_DEPTHshould beevent.depth > MAX_INTERNAL_DEPTH. The inline comment on that line says "Count>=so depth=4 is the last dispatch and depth=5 (would-be) is dropped" — but>= 4drops depth=4, not depth=5.trigger-events.tsJSDoc also says"refuses to dispatch when depth > MAX_INTERNAL_DEPTH". Issue #1075 AC says"depth > 4 → log + drop". All three state>, not>=. Fix: change operator; updatedispatcher.test.ts:174fromdepth: MAX_INTERNAL_DEPTH→depth: MAX_INTERNAL_DEPTH + 1to match.doc-gap Two explicit AC checkboxes from #1075 are unresolved and not mentioned in the out-of-scope list:
POST /flows/reload(deferred to #1076) andflow_node_runs.node_* → step_*rename migration (deferred to #1078). Deferral is rational but the issue will stay open without those checked — track them on the follow-up tickets explicitly.dispatcher.ts:61 used `event.depth >= MAX_INTERNAL_DEPTH` with a comment claiming "depth=4 is the last dispatch and depth=5 is dropped" — but the operator dropped depth=4 itself. Two other sources stated the correct contract: trigger-events.ts JSDoc ("refuses to dispatch when depth > MAX_INTERNAL_DEPTH") and #1075 acceptance criteria ("depth > 4 → log + drop"). Switch the operator to strict `>` so depth=4 dispatches, depth=5 drops. Update dispatcher.test.ts to use MAX_INTERNAL_DEPTH + 1 for the drop case and add a boundary assertion that depth=cap still dispatches. Refs: #1075 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>