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 { validateTaskCreation } from "../task-quality-gates";
|
||||||
import type { AutomationEventContext } from "./types";
|
import type { AutomationEventContext } from "./types";
|
||||||
import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task";
|
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.
|
// Map trigger type → default wake trigger when automation fires a heartbeat.
|
||||||
const HEARTBEAT_WAKE_TRIGGER_MAP: Record<string, string> = {
|
const HEARTBEAT_WAKE_TRIGGER_MAP: Record<string, string> = {
|
||||||
@@ -61,9 +62,12 @@ export function executeAutomationAction(
|
|||||||
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
|
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
|
||||||
const { handleTaskCreated, handleApprovalResolved, handleTaskStatusChange } = callbacks;
|
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": {
|
case "assign_task": {
|
||||||
if (ctx.taskId) {
|
if (ctx.taskId) {
|
||||||
const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId);
|
const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId);
|
||||||
@@ -100,10 +104,11 @@ export function executeAutomationAction(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "create_task": {
|
case "create_task": {
|
||||||
const title = (config.title as string) ?? "[Auto] Task from automation rule";
|
const cfg = config as AutomationActionConfigMap["create_task"];
|
||||||
const description = (config.description as string) ?? "";
|
const title = cfg.title ?? "[Auto] Task from automation rule";
|
||||||
const priority = (config.priority as string) ?? "medium";
|
const description = cfg.description ?? "";
|
||||||
const assigneeAgentId = (config.assigneeAgentId as string) ?? null;
|
const priority = cfg.priority ?? "medium";
|
||||||
|
const assigneeAgentId = cfg.assigneeAgentId ?? null;
|
||||||
const isHeartbeat = isHeartbeatTaskTitle(title);
|
const isHeartbeat = isHeartbeatTaskTitle(title);
|
||||||
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
|
const taskValidation = validateTaskCreation({ title, description }, isHeartbeat);
|
||||||
if (!taskValidation.valid) {
|
if (!taskValidation.valid) {
|
||||||
@@ -112,7 +117,7 @@ export function executeAutomationAction(
|
|||||||
}
|
}
|
||||||
const taskId = db.saveTask({
|
const taskId = db.saveTask({
|
||||||
companyId: ctx.companyId,
|
companyId: ctx.companyId,
|
||||||
projectId: ctx.projectId ?? (config.projectId as string) ?? null,
|
projectId: ctx.projectId ?? cfg.projectId ?? null,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
assigneeAgentId,
|
assigneeAgentId,
|
||||||
@@ -125,15 +130,16 @@ export function executeAutomationAction(
|
|||||||
}
|
}
|
||||||
case "notify_agent":
|
case "notify_agent":
|
||||||
case "send_message": {
|
case "send_message": {
|
||||||
const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null;
|
const cfg = config as AutomationActionConfigMap["notify_agent"];
|
||||||
const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId;
|
const toAgentId = cfg.toAgentId ?? ctx.agentId ?? null;
|
||||||
|
const fromAgentId = cfg.fromAgentId ?? ctx.agentId ?? toAgentId;
|
||||||
if (!fromAgentId) break;
|
if (!fromAgentId) break;
|
||||||
const defaultChannel = toAgentId ? "direct" : "company";
|
const defaultChannel = toAgentId ? "direct" : "company";
|
||||||
const requestedChannel = (config.channel as string) ?? defaultChannel;
|
const requestedChannel = cfg.channel ?? defaultChannel;
|
||||||
const channel = ["direct", "department", "company", "project", "incident"].includes(requestedChannel)
|
const channel = (["direct", "department", "company", "project", "incident"] as const).includes(requestedChannel)
|
||||||
? requestedChannel
|
? requestedChannel
|
||||||
: defaultChannel;
|
: 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) {
|
if (channel === "department" && !channelTargetId && fromAgentId) {
|
||||||
try {
|
try {
|
||||||
const sender = db.getAgent(fromAgentId);
|
const sender = db.getAgent(fromAgentId);
|
||||||
|
|||||||
Reference in new issue
Block a user