/** * Hook-dispatch planning + execution for the build bus * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4, reshaped by * module-orchestrator-primitives slice 7). * * Pure: planHookDispatch(event, modules) → HookDispatchPlan[]. * Decides which module hooks match the event; the executor * (runUpstreamHook) takes a plan plus the event and runs the matched * entry through the ORDINARY hook executor — `runNamedHook` → * `invokeHook`, jailed, with the verified PublishEvent as the hook's * `event` input. * * There is exactly one hook dispatch path in celilo (tasks.md 7.1): * `apps/celilo/src/hooks/executor.ts`. This module used to carry the * second one — `runHookDispatch` spawned `bash ` detached * with `env: { ...process.env, ...plan.env }`, no jail, no broker — and * slice 7 deleted it. `planHookDispatch`'s match logic is unchanged. * * Non-zero hook outcomes are logged by the dispatcher but don't gate the * receiver — failures are operational signal for the operator, not a * publish blocker. */ import type { HookDefinition } from '@celilo/capabilities'; import { type PublishEvent, matchesRule } from '@celilo/event-bus/build-bus'; import { type DbClient, getDb } from '../../db/client'; import { runNamedHook } from '../../hooks/run-named-hook'; import type { HookLogger } from '../../hooks/types'; /** Mirrors UpstreamPublishHookSchema in the manifest schema. */ export interface UpstreamHookEntry { name?: string; match: { registry?: string; tag?: string; package_pattern?: string }; /** Path to a TypeScript `defineHook` script, relative to the module directory. */ script: string; /** Milliseconds before the hook is killed. */ timeout?: number; } /** * What the dispatcher knows about a single installed module that * declares on_upstream_publish hooks. Built by the dispatcher's * loader (which reads the celilo modules table + each module's * manifest.yml); injected as a list into planHookDispatch. */ export interface ModuleHookContext { moduleId: string; sourcePath: string; hooks: UpstreamHookEntry[]; } export interface HookDispatchPlan { module: ModuleHookContext; hook: UpstreamHookEntry; } /** Matches the old bash path's default (600s), in milliseconds. */ const DEFAULT_TIMEOUT_MS = 600_000; /** * Pure: walk every (module, hook) combination, keep the ones whose * match rule fires for this event. Same world → same plan; injects * nothing. The match logic is unchanged since the bash-spawn days. * * The manifest's match uses snake_case (`package_pattern`) to match * YAML conventions, but the @celilo/event-bus/build-bus matcher * uses camelCase (`packagePattern`). Bridge that here so the * manifest stays YAML-idiomatic and the typed-API stays JS-idiomatic. */ export function planHookDispatch( event: PublishEvent, modules: ModuleHookContext[], ): HookDispatchPlan[] { const plans: HookDispatchPlan[] = []; for (const module of modules) { for (const hook of module.hooks) { const rule = { registry: hook.match.registry, tag: hook.match.tag, packagePattern: hook.match.package_pattern, }; if (!matchesRule(event, rule)) continue; plans.push({ module, hook }); } } return plans; } export interface UpstreamHookResult { module: string; hookName: string; success: boolean; error?: string; durationMs: number; } /** What running one plan entry needs. Injectable so tests don't need a daemon. */ export interface UpstreamHookDeps { db: DbClient; logger: HookLogger; } /** * Run one plan entry through the ordinary executor (module-orchestrator- * primitives slice 7, tasks.md 7.4). The matched entry's script runs as a * `defineHook` under `invokeHook`, jailed, with the verified PublishEvent * as its `event` input — the same path `celilo module run-hook` uses for * every other hook. * * Doesn't throw — the result carries the outcome. Caller (dispatcher) * logs failures and keeps going to the next plan entry. */ export async function runUpstreamHook( plan: HookDispatchPlan, event: PublishEvent, deps: UpstreamHookDeps, ): Promise { const hookName = plan.hook.name ?? plan.hook.script; const startedAt = Date.now(); // `HookDefinition.timeout` is milliseconds; the manifest entry declares // milliseconds too (schema: "Milliseconds before the hook is killed"). const definition: HookDefinition = { script: plan.hook.script, timeout: plan.hook.timeout ?? DEFAULT_TIMEOUT_MS, }; try { const result = await runNamedHook( plan.module.moduleId, 'on_upstream_publish', deps.db, deps.logger, { inputs: { event }, timeoutMs: definition.timeout, hookDefinition: definition, }, ); return { module: plan.module.moduleId, hookName, success: result.success, error: result.error, durationMs: Date.now() - startedAt, }; } catch (error) { return { module: plan.module.moduleId, hookName, success: false, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt, }; } } /** * Convenience for the dispatcher: deps over the process's database. Tests * inject `runUpstreamHook` instead so they never touch it. */ export function defaultUpstreamHookDeps(db?: DbClient): UpstreamHookDeps { return { get db() { return db ?? getDb(); }, logger: consoleLogger(), }; } function consoleLogger(): HookLogger { return { info: (message: string) => console.log(message), warn: (message: string) => console.warn(message), error: (message: string) => console.error(message), success: (message: string) => console.log(message), }; }