import { unique } from "remeda" import type { PlanningContext } from "./runtime" import { formatHookLocation, type HookLocation } from "./runtime" import { signalAncestorOrigins, signalContextOrigins, type Signal, } from "./signal-protocol" interface PlannedActionBoundary extends HookLocation { name: string requiredOrigins: readonly string[] } interface PlannedContextBoundary extends HookLocation { ancestorOrigins: readonly string[] implicitJoin: boolean origin: string } interface ContextBoundaryPlan { actions: PlannedActionBoundary[] boundaries: PlannedContextBoundary[] nonInitiatingRoots: Set } const CONTEXT_BOUNDARY_PLANS = new WeakMap< PlanningContext, ContextBoundaryPlan >() /** * Excludes a delivery-only subscription from single-root action inference. * * @param planningContext - Planning traversal being recorded. * @param location - Delivery subscription hook. */ export function markNonInitiatingContextBoundary( planningContext: PlanningContext, location: HookLocation, ) { getContextBoundaryPlan(planningContext).nonInitiatingRoots.add( `event:${formatHookLocation(location)}`, ) } /** * Records one action's static activation provenance during planning. * * @param planningContext - Planning traversal being recorded. * @param location - Action hook. * @param name - User-facing action name for diagnostics. * @param dependencies - Signals that determine the action's placement. */ export function planActionContextBoundary( planningContext: PlanningContext, location: HookLocation, name: string, dependencies: readonly Signal[], ) { getContextBoundaryPlan(planningContext).actions.push({ ...location, name, requiredOrigins: unique( dependencies.flatMap((signal) => signal[signalContextOrigins]), ), }) } /** * Records a durable signal hook that creates a child execution context. * * @param planningContext - Planning traversal being recorded. * @param location - Durable signal hook. * @param dependencies - Signals inherited by the child context. * @param implicitJoin - Whether original inputs can resolve to this boundary. */ export function planSignalContextBoundary( planningContext: PlanningContext, location: HookLocation, dependencies: readonly Signal[], implicitJoin: boolean, ) { const origin = `signal:${formatHookLocation(location)}` getContextBoundaryPlan(planningContext).boundaries.push({ ...location, ancestorOrigins: unique([ ...dependencies.flatMap((signal) => signal[signalAncestorOrigins]), origin, ]), implicitJoin, origin, }) } /** * Rejects actions whose static dependencies can activate in several unrelated * contexts. * * @param planningContext - Completed planning traversal to validate. * @throws When an action cannot resolve to exactly one context boundary. */ export function validateActionContextBoundaries( planningContext: PlanningContext, ) { const plan = CONTEXT_BOUNDARY_PLANS.get(planningContext) if (!plan || plan.actions.length === 0) return const roots = planningContext.subscriptions.map((subscription) => ({ eventType: subscription.eventType, origin: `event:${formatHookLocation({ scopePath: subscription.scopePath ?? [], slot: subscription.hookSlot, })}`, })) const inferredRoots = roots.filter( ({ origin }) => !plan.nonInitiatingRoots.has(origin), ) for (const action of plan.actions) { if (action.requiredOrigins.length === 0) { if (roots.length === 1 && inferredRoots.length === 1) continue const candidates = inferredRoots .slice(0, 4) .map( ({ eventType, origin }) => `${eventType} at hook ${origin.slice("event:".length)}`, ) if (inferredRoots.length > 4) { candidates.push( `and ${inferredRoots.length - 4} other initiating trigger boundaries`, ) } const deliveryRootCount = roots.length - inferredRoots.length if (deliveryRootCount > 0) { candidates.push( `and ${deliveryRootCount} delivery-only trigger ${deliveryRootCount === 1 ? "boundary" : "boundaries"}`, ) } const candidateList = candidates.join(", ") throw new Error( roots.length === 0 || inferredRoots.length === 0 ? `Action "${action.name}" at hook ${formatHookLocation(action)} has no causal dependency and the automation has no initiating trigger boundary. Pass a signal into the action or scope it with withPrerequisites().` : `Action "${action.name}" at hook ${formatHookLocation(action)} has no causal dependency and could run from multiple trigger boundaries: ${candidateList}. Pass a signal into the action or scope it to one boundary with withPrerequisites().`, ) } const candidates = findLeastBoundaryOrigins( action.requiredOrigins, plan.boundaries, ) if (candidates.length === 1) continue const dependencies = action.requiredOrigins.join(", ") throw new Error( candidates.length === 0 ? `Action "${action.name}" at hook ${formatHookLocation(action)} depends on context boundaries ${dependencies}, but no coordinator joins them. Correlate the signals or scope the action to one coordinated boundary.` : `Action "${action.name}" at hook ${formatHookLocation(action)} could activate in multiple coordinated contexts: ${candidates.join(", ")}. Depend on the intended coordinator result or remove the competing join.`, ) } } /** * Returns the mutable boundary plan for one planning traversal. * * @param planningContext - Planning traversal being recorded. */ function getContextBoundaryPlan(planningContext: PlanningContext) { const existing = CONTEXT_BOUNDARY_PLANS.get(planningContext) if (existing) return existing const created: ContextBoundaryPlan = { actions: [], boundaries: [], nonInitiatingRoots: new Set(), } CONTEXT_BOUNDARY_PLANS.set(planningContext, created) return created } /** * Finds all least durable boundaries that contain every required origin. * * @param requiredOrigins - Context origins that must be joined. * @param boundaries - Durable signal boundaries declared during planning. */ function findLeastBoundaryOrigins( requiredOrigins: readonly string[], boundaries: readonly PlannedContextBoundary[], ) { const ancestry = new Map( boundaries.map((boundary) => [ boundary.origin, new Set(boundary.ancestorOrigins), ]), ) const candidates = unique([ ...requiredOrigins.filter((origin) => containsEvery(ancestry.get(origin) ?? new Set([origin]), requiredOrigins), ), ...boundaries .filter( (boundary) => boundary.implicitJoin && containsEvery(new Set(boundary.ancestorOrigins), requiredOrigins), ) .map((boundary) => boundary.origin), ]) return candidates.filter( (candidate) => !candidates.some( (other) => other !== candidate && (ancestry.get(candidate)?.has(other) ?? false), ), ) } /** * Tests whether a provenance set contains every required origin. * * @param values - Available provenance origins. * @param expected - Origins that must all be present. */ function containsEvery(values: Set, expected: readonly string[]) { return expected.every((value) => values.has(value)) }