- P3-52: replace 5-deep if-chain in deriveEscalationWakeTrigger with two lookup maps (status takes precedence over rule trigger), which removes the cognitive overhead of reading the chain and makes the two inputs' roles explicit. - P3-53: rewrite the Windows-only curl warning as platform-neutral guidance that matches the surrounding examples. The old text said 'prefer fetch on Windows' while the doc showed curl POSTs everywhere; the new text recommends fetch universally and explains when the curl temp-file pattern is needed.
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import type { AppDatabase } from "../database";
|
|
import type { AutomationAction, DesktopEvent } from "@shared/types";
|
|
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> = {
|
|
run_completed: "subtask_completed",
|
|
run_failed: "report_failed",
|
|
hire_approved: "assignment",
|
|
agent_idle: "timer",
|
|
};
|
|
|
|
function deriveHeartbeatWakeTrigger(triggerType: string, previousStatus: string | null): string {
|
|
if (triggerType === "task_status_changed" && previousStatus === "in_review") return "comment";
|
|
return HEARTBEAT_WAKE_TRIGGER_MAP[triggerType] ?? "manual";
|
|
}
|
|
|
|
// Map the run/state combination → wake trigger for escalation to manager.
|
|
// Status-based mapping takes precedence; rule-trigger mapping is the fallback.
|
|
const ESCALATION_STATUS_TRIGGER_MAP: Record<string, string> = {
|
|
in_review: "subtask_completed",
|
|
blocked: "report_blocked",
|
|
};
|
|
const ESCALATION_RULE_TRIGGER_MAP: Record<string, string> = {
|
|
run_completed: "subtask_completed",
|
|
run_failed: "report_failed",
|
|
};
|
|
function deriveEscalationWakeTrigger(newStatus: string | null, ruleTrigger: string | null): string {
|
|
if (newStatus && ESCALATION_STATUS_TRIGGER_MAP[newStatus]) return ESCALATION_STATUS_TRIGGER_MAP[newStatus];
|
|
if (ruleTrigger && ESCALATION_RULE_TRIGGER_MAP[ruleTrigger]) return ESCALATION_RULE_TRIGGER_MAP[ruleTrigger];
|
|
return "report_blocked";
|
|
}
|
|
|
|
export interface ActionDeps {
|
|
db: AppDatabase;
|
|
logger: {
|
|
info: (...args: unknown[]) => void;
|
|
warn: (...args: unknown[]) => void;
|
|
error: (...args: unknown[]) => void;
|
|
};
|
|
emitEvent: (event: DesktopEvent) => void;
|
|
publishDomainChanged: () => void;
|
|
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
|
}
|
|
|
|
export interface ActionCallbacks {
|
|
handleTaskCreated: (taskId: string, companyId: string) => void;
|
|
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
|
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
|
}
|
|
|
|
export function executeAutomationAction(
|
|
deps: ActionDeps,
|
|
callbacks: ActionCallbacks,
|
|
rule: {
|
|
id?: string;
|
|
name?: string;
|
|
action: string;
|
|
actionConfigJson: string;
|
|
targetDepartment: string | null;
|
|
trigger?: string;
|
|
},
|
|
ctx: AutomationEventContext,
|
|
): void {
|
|
const { db, logger, emitEvent, publishDomainChanged, wakeAgentIfPossible } = deps;
|
|
const { handleTaskCreated, handleApprovalResolved, handleTaskStatusChange } = callbacks;
|
|
|
|
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 (action) {
|
|
case "assign_task": {
|
|
if (ctx.taskId) {
|
|
const assignedId = db.autoAssignTask(ctx.taskId, ctx.companyId);
|
|
if (assignedId) {
|
|
wakeAgentIfPossible(assignedId, ctx.companyId, "assignment");
|
|
}
|
|
} else if (ctx.agentId) {
|
|
const snapshot = db.listSnapshot();
|
|
const unassigned = snapshot.tasks
|
|
.filter((task) => task.companyId === ctx.companyId && !task.assigneeAgentId && ["backlog", "todo"].includes(task.status))
|
|
.sort((a, b) => {
|
|
const priorityScore = { critical: 4, high: 3, medium: 2, low: 1 } as Record<string, number>;
|
|
return (priorityScore[b.priority] ?? 0) - (priorityScore[a.priority] ?? 0);
|
|
});
|
|
if (unassigned.length > 0) {
|
|
const task = unassigned[0]!;
|
|
db.saveTask({
|
|
id: task.id,
|
|
companyId: task.companyId,
|
|
projectId: task.projectId,
|
|
goalId: task.goalId,
|
|
parentId: task.parentId,
|
|
title: task.title,
|
|
description: task.description,
|
|
assigneeAgentId: ctx.agentId,
|
|
workspaceId: task.workspaceId,
|
|
priority: task.priority,
|
|
status: "todo",
|
|
});
|
|
wakeAgentIfPossible(ctx.agentId, ctx.companyId, "assignment");
|
|
logger.info(`[automation] Auto-assigned task "${task.title}" to idle agent ${ctx.agentId}`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "create_task": {
|
|
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) {
|
|
logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`);
|
|
break;
|
|
}
|
|
const taskId = db.saveTask({
|
|
companyId: ctx.companyId,
|
|
projectId: ctx.projectId ?? cfg.projectId ?? null,
|
|
title,
|
|
description,
|
|
assigneeAgentId,
|
|
priority,
|
|
status: assigneeAgentId ? "todo" : "backlog",
|
|
});
|
|
handleTaskCreated(taskId, ctx.companyId);
|
|
logger.info(`[automation] Created task: ${title} (${taskId})`);
|
|
break;
|
|
}
|
|
case "notify_agent":
|
|
case "send_message": {
|
|
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 = cfg.channel ?? defaultChannel;
|
|
const channel = (["direct", "department", "company", "project", "incident"] as const).includes(requestedChannel)
|
|
? requestedChannel
|
|
: defaultChannel;
|
|
let channelTargetId: string | null = cfg.channelTargetId ?? cfg.department ?? ctx.department ?? null;
|
|
if (channel === "department" && !channelTargetId && fromAgentId) {
|
|
try {
|
|
const sender = db.getAgent(fromAgentId);
|
|
channelTargetId = sender.department ?? null;
|
|
} catch {
|
|
channelTargetId = null;
|
|
}
|
|
}
|
|
const dispatchResult = dispatchAgentMessage(
|
|
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
|
|
{
|
|
companyId: ctx.companyId,
|
|
fromAgentId,
|
|
toAgentId: channel === "direct" ? toAgentId : null,
|
|
channel: channel as "direct" | "department" | "company" | "project" | "incident",
|
|
channelTargetId,
|
|
subject: (config.subject as string) ?? "Automation notification",
|
|
body: (config.body as string) ?? `Automation rule triggered for task ${ctx.taskId ?? "n/a"}`,
|
|
priority: (config.priority as "urgent" | "normal" | "low") ?? "normal",
|
|
},
|
|
"message",
|
|
);
|
|
if (!dispatchResult.ok) {
|
|
logger.warn(
|
|
`[automation] Failed to dispatch message for rule ${rule.name} (${rule.id}): ${dispatchResult.error.code} ${dispatchResult.error.message}`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "trigger_heartbeat": {
|
|
let targetAgentId = (config.agentId as string) ?? null;
|
|
if (!targetAgentId && ctx.agentId) {
|
|
const triggerType = rule.trigger ?? "";
|
|
if (triggerType === "run_completed" || triggerType === "run_failed") {
|
|
const manager = db.getManagerForAgent(ctx.agentId);
|
|
targetAgentId = manager?.id ?? ctx.agentId;
|
|
} else {
|
|
targetAgentId = ctx.agentId;
|
|
}
|
|
}
|
|
if (targetAgentId) {
|
|
wakeAgentIfPossible(targetAgentId, ctx.companyId, deriveHeartbeatWakeTrigger(rule.trigger ?? "", ctx.previousStatus ?? null));
|
|
}
|
|
break;
|
|
}
|
|
case "create_approval": {
|
|
const approvalType = (config.approvalType as string) ?? "document_review";
|
|
db.requestApproval({
|
|
companyId: ctx.companyId,
|
|
relatedTaskId: ctx.taskId ?? null,
|
|
requestedByAgentId: ctx.agentId ?? null,
|
|
type: approvalType,
|
|
payloadSummary: (config.summary as string) ?? "Auto-generated approval request",
|
|
impactSummary: (config.impact as string) ?? "Triggered by automation rule",
|
|
});
|
|
break;
|
|
}
|
|
case "update_task_status": {
|
|
if (!ctx.taskId) break;
|
|
const newStatus = (config.status as string) ?? "todo";
|
|
const previousTask = db.getTask(ctx.taskId);
|
|
db.advanceTaskStatus(ctx.taskId, newStatus, "Status changed by automation rule");
|
|
publishDomainChanged();
|
|
if (previousTask.status !== newStatus && rule.trigger !== "task_status_changed") {
|
|
handleTaskStatusChange(ctx.taskId, ctx.companyId, previousTask.status, newStatus);
|
|
}
|
|
break;
|
|
}
|
|
case "escalate_to_manager": {
|
|
const targetAgentId = ctx.agentId;
|
|
if (!targetAgentId) break;
|
|
let agentName = "an agent";
|
|
try {
|
|
agentName = db.getAgent(targetAgentId).name;
|
|
} catch {
|
|
// Fall back to the generic label when the agent was deleted mid-flight.
|
|
}
|
|
let taskTitle = ctx.taskId ?? "unknown";
|
|
try {
|
|
if (ctx.taskId) taskTitle = db.getTask(ctx.taskId).title;
|
|
} catch {
|
|
// Keep the fallback title when the task no longer exists.
|
|
}
|
|
// Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels.
|
|
if (isHeartbeatTaskTitle(taskTitle)) {
|
|
logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`);
|
|
break;
|
|
}
|
|
const manager = db.getManagerForAgent(targetAgentId);
|
|
const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`;
|
|
const wakeTrigger = deriveEscalationWakeTrigger(ctx.newStatus ?? null, rule.trigger ?? null);
|
|
if (manager) {
|
|
const escalationResult = dispatchAgentMessage(
|
|
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
|
|
{
|
|
companyId: ctx.companyId,
|
|
fromAgentId: targetAgentId,
|
|
toAgentId: manager.id,
|
|
channel: "direct",
|
|
subject: `Escalation from ${agentName}: ${taskTitle}`,
|
|
body: escalationBody,
|
|
priority: "urgent",
|
|
},
|
|
wakeTrigger,
|
|
);
|
|
if (!escalationResult.ok) {
|
|
logger.warn(
|
|
`[automation] Failed to escalate "${taskTitle}" to manager: ${escalationResult.error.code} ${escalationResult.error.message}`,
|
|
);
|
|
}
|
|
} else {
|
|
// No manager found — agent is likely the CEO or top-level.
|
|
// Skip posting to company channel to avoid flooding it with system noise.
|
|
// The task status change is already visible in the task list and activity log.
|
|
logger.info(`[automation] Skipping escalation for top-level agent "${agentName}" — no manager to escalate to.`);
|
|
}
|
|
break;
|
|
}
|
|
case "cross_department_notify": {
|
|
const targetDepartment = rule.targetDepartment ?? (config.department as string);
|
|
if (!targetDepartment) break;
|
|
const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment);
|
|
const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id;
|
|
if (senderAgentId) {
|
|
const dispatchResult = dispatchAgentMessage(
|
|
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
|
|
{
|
|
companyId: ctx.companyId,
|
|
fromAgentId: senderAgentId,
|
|
toAgentId: null,
|
|
channel: "department",
|
|
channelTargetId: targetDepartment,
|
|
subject: (config.subject as string) ?? "Cross-department notification",
|
|
body: (config.body as string) ?? "Automation triggered a cross-department alert.",
|
|
priority: "normal",
|
|
},
|
|
"message",
|
|
);
|
|
if (!dispatchResult.ok) {
|
|
logger.warn(
|
|
`[automation] Failed cross-department notification for ${targetDepartment}: ${dispatchResult.error.code} ${dispatchResult.error.message}`,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "schedule_meeting": {
|
|
const cfg = config as AutomationActionConfigMap["schedule_meeting"];
|
|
const meetingType = cfg.meetingType ?? "department_sync";
|
|
const title = cfg.title ?? "Auto-scheduled meeting";
|
|
// Prefer an absolute scheduledAt; otherwise honor scheduleOffsetMinutes.
|
|
// Default to +60min only when the rule specifies no time at all.
|
|
const offsetMinutes = cfg.scheduleOffsetMinutes ?? 60;
|
|
const scheduledAt = cfg.scheduledAt ?? new Date(Date.now() + offsetMinutes * 60_000).toISOString();
|
|
db.saveMeeting({
|
|
companyId: ctx.companyId,
|
|
type: meetingType,
|
|
title,
|
|
organizerAgentId: ctx.agentId ?? null,
|
|
participantAgentIds: cfg.participantAgentIds ?? "[]",
|
|
scheduledAt,
|
|
durationMinutes: cfg.durationMinutes ?? 30,
|
|
});
|
|
break;
|
|
}
|
|
case "create_document": {
|
|
const documentType = (config.documentType as string) ?? "status_report";
|
|
const title = (config.title as string) ?? "Auto-generated document";
|
|
db.saveDocument({
|
|
companyId: ctx.companyId,
|
|
type: documentType,
|
|
title,
|
|
content: (config.content as string) ?? "",
|
|
authorAgentId: ctx.agentId ?? null,
|
|
projectId: ctx.projectId ?? null,
|
|
});
|
|
break;
|
|
}
|
|
case "auto_approve": {
|
|
const snapshot = db.listSnapshot();
|
|
const pendingApprovals = snapshot.approvals.filter(
|
|
(approval) => approval.companyId === ctx.companyId && approval.state === "pending",
|
|
);
|
|
for (const approval of pendingApprovals.slice(0, 5)) {
|
|
db.decideApproval({
|
|
companyId: ctx.companyId,
|
|
approvalId: approval.id,
|
|
state: "approved",
|
|
decisionNote: "Auto-approved by automation rule.",
|
|
});
|
|
handleApprovalResolved(approval.id, ctx.companyId, "approved");
|
|
}
|
|
break;
|
|
}
|
|
case "reassign_task": {
|
|
if (!ctx.taskId) break;
|
|
const newAssignee = db.autoAssignTask(ctx.taskId, ctx.companyId);
|
|
if (newAssignee) {
|
|
wakeAgentIfPossible(newAssignee, ctx.companyId, "assignment");
|
|
}
|
|
break;
|
|
}
|
|
case "run_task": {
|
|
const targetTaskId = (config.taskId as string) ?? ctx.taskId;
|
|
if (!targetTaskId) break;
|
|
const task = db.getTask(targetTaskId);
|
|
if (!task.assigneeAgentId) break;
|
|
wakeAgentIfPossible(task.assigneeAgentId, ctx.companyId, "assignment");
|
|
break;
|
|
}
|
|
default:
|
|
throw new Error(`Unknown automation action: ${rule.action}`);
|
|
}
|
|
}
|