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:
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user