feat(flows): wire executor into webhook pipeline (NF-6) #360

Merged
code-lead merged 1 commit from feat/327-nf6-executor-wiring into main 2026-04-24 15:28:11 +00:00
Collaborator

Replaces the stalled #359 attempt, which flipped the config mode to "live" but admitted no webhook → executor bridge existed — that flip was a no-op at runtime. This PR ships the actual bridge.

Summary

  • New apps/server/src/domain/flows/flow-dispatch.ts:

    • dispatchToFlows(event, opts?) — normalizes to TriggerEvent, reads node_flows.mode, short-circuits on "off", lists enabled flows, filters by trigger.kind + on.filters, compiles each matching graph, runs the executor with the right mutationMode ("dry-run" → NF-5 intent recording; "live" → real side effects), persists one flow_runs row + one flow_node_runs row per executed node.
    • matchesFilters(filters, trigger) — evaluates repo_patterns (glob with * / **), issue_labels_any/all, pr_labels_any/all, author_is, comment_regex. Non-applicable filters fail closed on triggers that don't carry the predicate's field.
    • Full crash isolation: malformed JSON bodies, compile errors, executor crashes, DB hiccups — all caught + logged; the webhook HTTP response is never affected.
  • Wired into all three handlers in apps/server/src/http/webhook.ts:

    • Forgejo: after the legacy dispatch returns 200, normalise via the existing normalizeForgejoPayload and fire-and-forget dispatchToFlows.
    • GitHub / GitLab: already have a ForgeEvent in hand post-MF-3; dispatch directly after the 204. These routes had no legacy dispatch, so flows are now their only consumer.

Design notes

  • Default node_flows.mode stays "off". Operator flips to "dry-run" for the soak window; "live" after the 7-day observation per spec § Migration.
  • Fire-and-forget on purpose — awaiting would block the webhook HTTP response. Every failure surface in the bridge is caught internally.
  • Persistence of flow_node_runs.input is intentionally "{}" for v1; the executor doesn't surface resolved inputs. Adding that is an observability follow-up, not a correctness blocker for cutover.
  • FlowNodeRunStatus does not model "cancelled"; the bridge folds cancelled → skipped at persistence time (run-level status captures "cancelled" in the outer flow_runs row).

Test plan

  • bun test apps/server/src/domain/flows/flow-dispatch.test.ts — 20/20, 30 expects.
  • bun test apps/server — 1734 pass, 4 pre-existing fails (sweeper JSONL pruning × 3, foreman session CRUD × 1, all confirmed on main).
  • bun x turbo run typecheck — 4/4 clean.
  • bun x biome check — clean on touched files (--fix applied once for import-order/format).
  • Operator: flip node_flows.mode to "dry-run" in config/agents.json, restart, send a synthetic webhook, confirm GET /flows/runs shows the matching flow fired and flow_node_runs.intent captured.
  • Operator: after 7-day soak, check GET /flows/divergences against task_history; flip to "live" if <5% divergence per NF-5 spec.

Out of scope

  • Flipping the config to "live" — operator action after soak.
  • Wiring resolved inputs into flow_node_runs.input — observability follow-up.
  • Removing the legacy handlers — NF-8.

Closes #327. Supersedes #359.

🤖 Generated with Claude Code

Replaces the stalled #359 attempt, which flipped the config mode to `"live"` but admitted no webhook → executor bridge existed — that flip was a no-op at runtime. This PR ships the actual bridge. ## Summary - New `apps/server/src/domain/flows/flow-dispatch.ts`: - `dispatchToFlows(event, opts?)` — normalizes to `TriggerEvent`, reads `node_flows.mode`, short-circuits on `"off"`, lists enabled flows, filters by `trigger.kind` + `on.filters`, compiles each matching graph, runs the executor with the right `mutationMode` (`"dry-run"` → NF-5 intent recording; `"live"` → real side effects), persists one `flow_runs` row + one `flow_node_runs` row per executed node. - `matchesFilters(filters, trigger)` — evaluates `repo_patterns` (glob with `*` / `**`), `issue_labels_any/all`, `pr_labels_any/all`, `author_is`, `comment_regex`. Non-applicable filters fail closed on triggers that don't carry the predicate's field. - Full crash isolation: malformed JSON bodies, compile errors, executor crashes, DB hiccups — all caught + logged; the webhook HTTP response is never affected. - Wired into all three handlers in `apps/server/src/http/webhook.ts`: - **Forgejo**: after the legacy dispatch returns 200, normalise via the existing `normalizeForgejoPayload` and fire-and-forget `dispatchToFlows`. - **GitHub / GitLab**: already have a `ForgeEvent` in hand post-MF-3; dispatch directly after the 204. These routes had no legacy dispatch, so flows are now their only consumer. ## Design notes - Default `node_flows.mode` stays `"off"`. Operator flips to `"dry-run"` for the soak window; `"live"` after the 7-day observation per spec § Migration. - Fire-and-forget on purpose — awaiting would block the webhook HTTP response. Every failure surface in the bridge is caught internally. - Persistence of `flow_node_runs.input` is intentionally `"{}"` for v1; the executor doesn't surface resolved inputs. Adding that is an observability follow-up, not a correctness blocker for cutover. - `FlowNodeRunStatus` does not model `"cancelled"`; the bridge folds cancelled → skipped at persistence time (run-level status captures `"cancelled"` in the outer `flow_runs` row). ## Test plan - [x] `bun test apps/server/src/domain/flows/flow-dispatch.test.ts` — 20/20, 30 expects. - [x] `bun test apps/server` — 1734 pass, 4 pre-existing fails (sweeper JSONL pruning × 3, foreman session CRUD × 1, all confirmed on main). - [x] `bun x turbo run typecheck` — 4/4 clean. - [x] `bun x biome check` — clean on touched files (`--fix` applied once for import-order/format). - [ ] Operator: flip `node_flows.mode` to `"dry-run"` in `config/agents.json`, restart, send a synthetic webhook, confirm `GET /flows/runs` shows the matching flow fired and `flow_node_runs.intent` captured. - [ ] Operator: after 7-day soak, check `GET /flows/divergences` against `task_history`; flip to `"live"` if <5% divergence per NF-5 spec. ## Out of scope - Flipping the config to `"live"` — operator action after soak. - Wiring resolved inputs into `flow_node_runs.input` — observability follow-up. - Removing the legacy handlers — NF-8. Closes #327. Supersedes #359. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(flows): wire executor into webhook pipeline (NF-6)
All checks were successful
qa / qa (pull_request) Successful in 4m43s
qa / dockerfile (pull_request) Successful in 10s
61a93d9c93
Replaces the stalled #359 attempt. That PR flipped the config mode to
"live" but admitted no webhook → executor bridge existed — the flip
was a no-op at runtime. This ships the bridge.

New `apps/server/src/domain/flows/flow-dispatch.ts`:

  - `dispatchToFlows(event: ForgeEvent, opts?)` — converts via
    toTriggerEvent, reads `node_flows.mode`, short-circuits on "off",
    lists enabled flows, filters by trigger.kind + on.filters, compiles
    each matching graph, runs the executor with the right mutationMode
    (dry-run / live), persists one `flow_runs` row + one
    `flow_node_runs` row per executed node.
  - `matchesFilters(filters, trigger)` — evaluates repo_patterns (with
    `*` / `**` glob), issue_labels_any/all, pr_labels_any/all,
    author_is, comment_regex. Non-applicable filters fail closed (no
    author on cron → author_is never passes).
  - Full crash isolation: broken body JSON, compile errors, executor
    throws, db hiccups all caught + logged; webhook response unaffected.

Wired into all three webhook handlers in `webhook.ts`:

  - Forgejo: after the legacy dispatch, normalise the payload via the
    existing `normalizeForgejoPayload` and fire-and-forget
    `dispatchToFlows`.
  - GitHub + GitLab: already have a ForgeEvent in hand; dispatch
    directly after the 204. These routes had no legacy handlers wired,
    so flows are now their only consumer.

Default `node_flows.mode` stays "off" — operator action to flip to
"dry-run" for the soak window, then "live" after the 7-day observation
per spec.

20 new tests cover: mode=off/dry-run/live gating, explicit mode
override, trigger.kind mismatch skip, disabled flow skip, malformed
body crash-isolation, unhandled event no-op, repo_patterns glob
match/no-match, issue_labels_any intersection logic, author_is
fail-closed on non-author triggers, repo_patterns/labels_all/regex
edge cases, comment_regex malformed → false, run + node_run
persistence.

Closes #327.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
code-lead deleted branch feat/327-nf6-executor-wiring 2026-04-24 15:28:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!360
No description provided.