MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3456

(motir-core) Route every emitter through `sendEvent`, and make the ESLint boundary enforce it

Done
Description

Route every event emitter through sendEvent, so the per-job cutover switch is consulted for all of them — and widen the ESLint boundary so a new bypass cannot be added silently.

The call sites, enumerated

sendEvent is the only place the switch is read on the emit side. Five call sites emit events without going through it, so for the events they carry the switch does not exist. Measured on origin/main@b944dab5:

$ git grep -n "inngest\.send(" origin/main -- '*.ts' '*.tsx'
  lib/billing/seatSync.ts:25              system.billing-seat-sync
  lib/ciFleet/bootDispatch.ts:99          system.ci-runner-boot
  lib/github/indexEnqueue.ts:37           system.code-graph-index
  lib/github/indexEnqueue.ts:56           system.code-graph-refresh
  lib/jobs/definitions/ciRunnerFleet.ts:152   system.ci-runner-boot
  lib/jobs/dlq.ts:116                     the REPLAY path — keep (see below)
  lib/jobs/sendEvent.ts:82                the Inngest arm of sendEvent itself — keep

The remaining hits in that grep are prose: lib/jobs/client.ts:9 and lib/env.ts:13 are comments, and scripts/plan-seed/data/story-1.6.ts:147 is seed fixture text. Count real calls, not the word.

Why ESLint did not catch it. INNGEST_RESTRICTION in eslint.config.mjs restricts the group ['inngest', 'inngest/*'] — the SDK package. All five files import { inngest } from '@/lib/jobs/client', which is our own module and unrestricted. The boundary was drawn around the vendor package rather than around the client, so it holds against the import it was written for and not against the one people actually reach for.

The change

1. A door for system events. sendEvent accepts only WorkspaceScopedEventName, and lib/jobs/types.ts says so deliberately: "SYSTEM events (the system.* namespace) are NOT dispatched through sendEvent at all." That exclusion exists to protect a real invariant — every business event carries an explicit tenant — and widening sendEvent to accept system.* would weaken it, because a system payload's workspaceId is optional.

So add a sibling rather than loosening the guard: sendSystemEvent(name, data) in the same module, typed to the system.* names, sharing the same dispatch body (engine first, then Inngest when subscribers remain), and skipping only the workspace-id assertion that does not apply to it. The invariant the type was protecting stays exactly as strong; the switch becomes reachable for four more jobs.

2. Convert the five call sites to sendEvent / sendSystemEvent. Each keeps its existing best-effort semantics — these are all POST-COMMIT notification side effects, and sendEvent already implements exactly that contract with the same try/catch and the same reasoning in its header. Do not add a second error-handling policy beside it.

3. Widen the boundary so this cannot recur. Add @/lib/jobs/client to the restricted paths outside lib/jobs/** (and scripts/worker.ts / app/api/inngest/**, which already carry exceptions), mirroring JOB_ENGINE_RESTRICTION's shape. ESLint alone is not sufficientlib/jobs/definitions/ciRunnerFleet.ts sits INSIDE lib/jobs/**, where the rule permits the import — so add a guard test asserting inngest.send( is called in exactly two files: lib/jobs/sendEvent.ts and lib/jobs/dlq.ts.

Scope boundary

This card DOES modify lib/jobs/definitions/ciRunnerFleet.ts — one enqueue line at 152 — and no job's behaviour changes as a result.

ENDS at: every emitter consulting the switch. Moves NO job onto the engine: MOTIR_POSTGRES_JOB_IDS is untouched, so all four system.* jobs keep running exactly where they run today. Making a switch reachable is not throwing it — the three supervisor jobs (system.code-graph-index, system.code-graph-refresh, system.ci-runner-boot) are MOTIR-3417's to move, and this card is what makes that possible for them.

KEEPS both remaining inngest.send calls. lib/jobs/dlq.ts:116 is the replay path and is already cutover-aware — it branches on routedToEngine(row.functionId) and enqueues a job_queue row for a migrated job before ever reaching the Inngest arm. lib/jobs/sendEvent.ts:82 is the Inngest transport itself. Neither is an abandoned path yet, so nothing is deleted here; the deletion of the transport belongs to MOTIR-3418, which owns the retirement.

Acceptance criteria

  • git grep -n "inngest\.send(" -- '*.ts' '*.tsx' returns real calls in exactly two files: lib/jobs/sendEvent.ts and lib/jobs/dlq.ts.
  • A guard test asserts that set by reading the tree, and fails when a sixth call site is added. It counts CALLS, not occurrences of the string — the three prose mentions above must not make it pass or fail.
  • ESLint refuses an import of @/lib/jobs/client from outside lib/jobs/**, scripts/worker.ts and app/api/inngest/**, asserted by a fixture the lint run rejects.
  • Each of the four system.* events still reaches its job unchanged with an empty MOTIR_POSTGRES_JOB_IDS: same event name, same payload shape, same best-effort behaviour when the transport throws.
  • system.billing-seat-sync enqueues a job_queue row when its id IS in the routing set — the one job of the four this story is entitled to move.
  • No job handler is modified; the only line touched under lib/jobs/definitions/ is the enqueue at ciRunnerFleet.ts:152.

Context refs

  • lib/jobs/sendEvent.ts — the contract to extend, and its post-commit best-effort header
  • lib/jobs/types.tsWorkspaceScopedEventName, and the comment excluding system.*
  • lib/billing/seatSync.ts · lib/ciFleet/bootDispatch.ts · lib/github/indexEnqueue.ts · lib/jobs/definitions/ciRunnerFleet.ts — the five call sites
  • eslint.config.mjsINNGEST_RESTRICTION and JOB_ENGINE_RESTRICTION, and the per-surface overrides
  • lib/jobs/dlq.ts — the replay path, already cutover-aware, which stays