diff --git a/src/main/automation/automation-action-schemas.ts b/src/main/automation/automation-action-schemas.ts new file mode 100644 index 00000000..f00614cc --- /dev/null +++ b/src/main/automation/automation-action-schemas.ts @@ -0,0 +1,117 @@ +/** + * Per-action config schemas for automation rules. + * + * Previously `automation-actions.ts` coerced `config.x as string` at every + * branch without runtime validation, which hid config-authoring mistakes + * behind silent default values (e.g. a numeric `priority` landed as the + * literal string "medium"). Each schema describes the contract of the JSON + * stored in `automation_rules.action_config_json`; validation failures are + * surfaced to the caller so the rule is skipped and logged. + */ + +import { z } from "zod"; + +const messageChannelSchema = z.enum(["direct", "department", "company", "project", "incident"]); +const messagePrioritySchema = z.enum(["urgent", "normal", "low"]); +const taskPrioritySchema = z.enum(["critical", "high", "medium", "low"]); + +export const assignTaskConfigSchema = z.object({}).passthrough(); + +export const createTaskConfigSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + priority: taskPrioritySchema.optional(), + assigneeAgentId: z.string().nullable().optional(), + projectId: z.string().optional(), +}); + +export const notifyAgentConfigSchema = z.object({ + toAgentId: z.string().optional(), + fromAgentId: z.string().optional(), + channel: messageChannelSchema.optional(), + channelTargetId: z.string().nullable().optional(), + department: z.string().optional(), + subject: z.string().optional(), + body: z.string().optional(), + priority: messagePrioritySchema.optional(), +}); + +export const triggerHeartbeatConfigSchema = z.object({ + agentId: z.string().optional(), +}); + +export const createApprovalConfigSchema = z.object({ + approvalType: z.string().optional(), + summary: z.string().optional(), + impact: z.string().optional(), +}); + +export const updateTaskStatusConfigSchema = z.object({ + status: z.string().optional(), +}); + +export const escalateToManagerConfigSchema = z.object({ + body: z.string().optional(), +}); + +export const crossDepartmentNotifyConfigSchema = z.object({ + department: z.string().optional(), + subject: z.string().optional(), + body: z.string().optional(), +}); + +export const scheduleMeetingConfigSchema = z.object({ + meetingType: z.string().optional(), + title: z.string().optional(), + participantAgentIds: z.string().optional(), + durationMinutes: z.number().int().positive().optional(), +}); + +export const createDocumentConfigSchema = z.object({ + documentType: z.string().optional(), + title: z.string().optional(), + content: z.string().optional(), +}); + +export const autoApproveConfigSchema = z.object({}).passthrough(); + +export const reassignTaskConfigSchema = z.object({}).passthrough(); + +export const runTaskConfigSchema = z.object({ + taskId: z.string().optional(), +}); + +const ACTION_SCHEMAS = { + assign_task: assignTaskConfigSchema, + create_task: createTaskConfigSchema, + notify_agent: notifyAgentConfigSchema, + send_message: notifyAgentConfigSchema, + trigger_heartbeat: triggerHeartbeatConfigSchema, + create_approval: createApprovalConfigSchema, + update_task_status: updateTaskStatusConfigSchema, + escalate_to_manager: escalateToManagerConfigSchema, + cross_department_notify: crossDepartmentNotifyConfigSchema, + schedule_meeting: scheduleMeetingConfigSchema, + create_document: createDocumentConfigSchema, + auto_approve: autoApproveConfigSchema, + reassign_task: reassignTaskConfigSchema, + run_task: runTaskConfigSchema, +} as const; + +export type AutomationActionConfigMap = { + [K in keyof typeof ACTION_SCHEMAS]: z.infer; +}; + +/** + * Parse action config JSON against its per-action schema. + * Returns the validated config or throws with the action name so the caller + * can log/skip the rule instead of silently advancing with defaults. + */ +export function parseActionConfig( + action: K, + json: string, +): AutomationActionConfigMap[K] { + const raw = JSON.parse(json) as unknown; + const schema = ACTION_SCHEMAS[action] as z.ZodSchema; + return schema.parse(raw); +} diff --git a/src/main/automation/automation-actions.ts b/src/main/automation/automation-actions.ts index b3946f2e..390e88aa 100644 --- a/src/main/automation/automation-actions.ts +++ b/src/main/automation/automation-actions.ts @@ -4,6 +4,7 @@ import { dispatchAgentMessage } from "../message-dispatch"; import { validateTaskCreation } from "../task-quality-gates"; import type { AutomationEventContext } from "./types"; import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; +import { parseActionConfig, type AutomationActionConfigMap } from "./automation-action-schemas"; // Map trigger type → default wake trigger when automation fires a heartbeat. const HEARTBEAT_WAKE_TRIGGER_MAP: Record = { @@ -61,9 +62,12 @@ export function executeAutomationAction( const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps; const { handleTaskCreated, handleApprovalResolved, handleTaskStatusChange } = callbacks; - const config = JSON.parse(rule.actionConfigJson) as Record; + const action = rule.action as AutomationAction; + // Validate config against the per-action schema; schema failures surface + // as thrown errors so the caller (evaluateAutomationRules) records + logs. + const config = parseActionConfig(action, rule.actionConfigJson) as AutomationActionConfigMap[typeof action] & Record; - switch (rule.action as AutomationAction) { + switch (action) { case "assign_task": { if (ctx.taskId) { const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId); @@ -100,10 +104,11 @@ export function executeAutomationAction( break; } case "create_task": { - const title = (config.title as string) ?? "[Auto] Task from automation rule"; - const description = (config.description as string) ?? ""; - const priority = (config.priority as string) ?? "medium"; - const assigneeAgentId = (config.assigneeAgentId as string) ?? null; + const cfg = config as AutomationActionConfigMap["create_task"]; + const title = cfg.title ?? "[Auto] Task from automation rule"; + const description = cfg.description ?? ""; + const priority = cfg.priority ?? "medium"; + const assigneeAgentId = cfg.assigneeAgentId ?? null; const isHeartbeat = isHeartbeatTaskTitle(title); const taskValidation = validateTaskCreation({ title, description }, isHeartbeat); if (!taskValidation.valid) { @@ -112,7 +117,7 @@ export function executeAutomationAction( } const taskId = db.saveTask({ companyId: ctx.companyId, - projectId: ctx.projectId ?? (config.projectId as string) ?? null, + projectId: ctx.projectId ?? cfg.projectId ?? null, title, description, assigneeAgentId, @@ -125,15 +130,16 @@ export function executeAutomationAction( } case "notify_agent": case "send_message": { - const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null; - const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId; + const cfg = config as AutomationActionConfigMap["notify_agent"]; + const toAgentId = cfg.toAgentId ?? ctx.agentId ?? null; + const fromAgentId = cfg.fromAgentId ?? ctx.agentId ?? toAgentId; if (!fromAgentId) break; const defaultChannel = toAgentId ? "direct" : "company"; - const requestedChannel = (config.channel as string) ?? defaultChannel; - const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel) + const requestedChannel = cfg.channel ?? defaultChannel; + const channel = (["direct", "department", "company", "project", "incident"] as const).includes(requestedChannel) ? requestedChannel : defaultChannel; - let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? ctx.department ?? null; + let channelTargetId: string | null = cfg.channelTargetId ?? cfg.department ?? ctx.department ?? null; if (channel === "department" && !channelTargetId && fromAgentId) { try { const sender = db.getAgent(fromAgentId);