/** * apps/worker — automation executor shared helpers (TASK-0112). * * - resolveTarget: `from_trigger` / `trigger_user` / `automation_creator` * / literal resolution (record + user), * - emitDomainEvent: XADD factory:events with the automationContext depth * stamp (the TASK-0112 §5/§6 event contract), * - auditSystem: write the AuditLog row (actorType SYSTEM, onBehalfOf flag), * - runWithTimeout: Promise.race timeout wrapper (timeout → fatal * ACTION_TIMEOUT — a timed-out write may have partially applied, so it is * never retryable), * - interpolateConfig: apply `{{…}}` interpolation to a config value using * the shared `interpolate`, escalating INTERPOLATION_OVERFLOW. */ import type { Redis } from "ioredis"; import { AUTOMATION_MAX_DEPTH, interpolate, nextAutomationContext, normalizeConditionValue, type InterpolationVars } from "@factory/shared"; import { unwrapEnvelope } from "../condition-evaluator.js"; import { getLogger } from "../../../logger.js"; import { FatalError } from "./classify.js"; import type { AutomationRunContext } from "./types.js"; const log = getLogger("automation-exec"); export interface PropertyMetaLite { id: string; name: string; type: string; isTitle: boolean; config: Record | null; } /** Load the database's properties (name→meta) for a record's database. */ export async function loadProperties( prisma: { databaseProperty: any }, databaseId: string ): Promise { return prisma.databaseProperty.findMany({ where: { databaseId, deletedAt: null }, select: { id: true, name: true, type: true, isTitle: true, config: true } }); } /** * Resolve `from_trigger` | literal recordId. Throws FatalError NO_TRIGGER_RECORD * when the action targets the trigger record but the event had none. */ export function resolveRecordId(cfg: Record, ctx: AutomationRunContext): string { const v = cfg.recordId; if (typeof v === "string" && v !== "from_trigger") return v; if (ctx.recordId) return ctx.recordId; throw new FatalError("NO_TRIGGER_RECORD", "action targets the trigger record but the event has no record"); } /** * Resolve a user target: literal cuid | "trigger_user" | "automation_creator". * - trigger_user: the user in the event payload (createdBy/actorId/submitter), * - automation_creator: the automation's creator (ctx.actorUserId). * Throws FatalError NO_TARGET_USER when unresolvable. */ export function resolveUserId( target: unknown, ctx: AutomationRunContext, payloadUserIds: string[] = [] ): string { if (typeof target === "string" && target !== "from_trigger" && target !== "trigger_user" && target !== "automation_creator") { return target; } if (target === "automation_creator") return ctx.actorUserId; // trigger_user (or from_trigger fallback): pull from the event payload. const p = ctx.triggerEvent as Record; for (const k of ["createdBy", "actorId", "submittedBy", "userId"]) { if (typeof p[k] === "string" && p[k]) return p[k] as string; } if (payloadUserIds.length > 0) return payloadUserIds[0]!; throw new FatalError("NO_TARGET_USER", `cannot resolve user target "${String(target)}" from the event`); } /** * Emit a domain event onto factory:events, stamping automationContext * (depth = incoming + 1). No-op (returns false) when the depth budget is * exhausted — chained automations past MAX_DEPTH are never triggered. The * stream entry id is Redis-generated ("*"); the matcher's dedupe eventId is * read from that generated id downstream. */ export async function emitDomainEvent( redis: Redis, event: string, payload: Record, ctx: AutomationRunContext, extra?: Record ): Promise { if (ctx.depth >= AUTOMATION_MAX_DEPTH) { log.warn({ runId: ctx.runId, event, depth: ctx.depth }, "depth guard: event not emitted"); return false; } const ac = nextAutomationContext(ctx.runId, ctx.automationId, ctx.depth); await redis.xadd( "factory:events", "*", "event", event, "payload", JSON.stringify({ ...payload, ...extra, automationContext: ac }), "ts", new Date().toISOString() ); return true; } /** Write a system audit row (creator-role action, onBehalfOf automation). */ export async function auditSystem( prisma: { auditLog: any }, ctx: AutomationRunContext, entry: { action: string; resourceType: string; resourceId: string; metadata?: Record } ): Promise { try { await prisma.auditLog.create({ data: { actorType: "SYSTEM", actorId: `automation:${ctx.automationId}`, workspaceId: ctx.workspaceId, resourceType: entry.resourceType, resourceId: entry.resourceId, action: entry.action, metadata: { ...(entry.metadata ?? {}), onBehalfOf: "automation", automationId: ctx.automationId, runId: ctx.runId } } }); } catch (err) { log.warn({ err: (err as Error).message, runId: ctx.runId }, "automation audit write failed"); } } /** * Run an async task under its timeoutMs budget. A timeout throws the FATAL * ACTION_TIMEOUT (re-executing a timed-out write risks double-effects). */ export async function runWithTimeout(ms: number, task: () => Promise): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ task(), new Promise((_, reject) => { timer = setTimeout(() => reject(new FatalError("ACTION_TIMEOUT", `action timed out after ${ms} ms`)), ms); timer.unref?.(); }) ]); } finally { if (timer) clearTimeout(timer); } } /** * Interpolate `{{…}}` in a config string field. Unknown keys → "" (warning * collected by the caller); a result longer than `max` → FATAL * INTERPOLATION_OVERFLOW (the field's max length is a contract). */ export function interpolateConfig( raw: string, vars: InterpolationVars, max: number, field: string, warnings: string[] ): string { const { value, warnings: w } = interpolate(raw, vars); warnings.push(...w); if (Buffer.byteLength(value, "utf8") > max) { throw new FatalError("INTERPOLATION_OVERFLOW", `${field} exceeds ${max} bytes after interpolation`); } return value; } /** Recursively interpolate every string leaf of an object (webhook payloads). */ export function interpolateDeep( value: unknown, vars: InterpolationVars, warnings: string[], path: string = "" ): unknown { if (typeof value === "string") return interpolateConfig(value, vars, 64 * 1024, path || "(root)", warnings); if (Array.isArray(value)) return value.map((v, i) => interpolateDeep(v, vars, warnings, `${path}[${i}]`)); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = interpolateDeep(v, vars, warnings, path ? `${path}.${k}` : k); } return out; } return value; } /** * Build the interpolation vars for a run: record (flattened by property NAME, * envelope-unwrapped + type-normalized), event, input, automation, run. One * record query (reuses the evaluator's unwrap + normalize so `{{record.X}}` * sees the same shape a condition predicate sees). */ export async function buildVars( prisma: { databaseRecord: any; databaseProperty: any }, ctx: AutomationRunContext ): Promise { const vars: InterpolationVars = { record: null, event: ctx.triggerEvent, input: ctx.input, automation: { id: ctx.automationId, name: ctx.automationName }, run: { id: ctx.runId } }; if (ctx.recordId) { const row = await prisma.databaseRecord.findUnique({ where: { id: ctx.recordId }, select: { id: true, properties: { select: { value: true, property: { select: { name: true, type: true, config: true } } } } } }); if (row) { const flat: Record = { id: row.id }; for (const rp of row.properties) { flat[rp.property.name] = normalizeConditionValue( rp.property.type as string, rp.property.config as Record | null, unwrapEnvelope(rp.property.type as string, rp.value) ); } vars.record = flat; } } return vars; }