feat(automation): add per-action zod schemas for config validation

P2-29: JSON.parse(actionConfigJson) with blanket '(config.x as string)'
coerced malformed config to placeholder values silently (e.g. numeric
priority landed as 'medium'). Introduce parseActionConfig(action, json)
backed by per-action zod schemas in automation-action-schemas.ts.
Validation failures throw, surfaced by evaluateAutomationRules' logger
+ execution recorder. Individual case branches keep existing default
semantics for optional fields.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 22:56:18 -04:00
1 parent 6429c76ac7
commit 2deaa0d8b8
2 files changed
+135 -12

No files matched your search

@@ -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<typeof ACTION_SCHEMAS[K]>;
};
/**
* 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<K extends keyof AutomationActionConfigMap>(
action: K,
json: string,
): AutomationActionConfigMap[K] {
const raw = JSON.parse(json) as unknown;
const schema = ACTION_SCHEMAS[action] as z.ZodSchema<AutomationActionConfigMap[K]>;
return schema.parse(raw);
}
+18 -12
View File
@@ -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<string, string> = {
@@ -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<string, unknown>;
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<string, unknown>;
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);