update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -0,0 +1,357 @@
|
||||
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";
|
||||
|
||||
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 config = (() => {
|
||||
try {
|
||||
return JSON.parse(rule.actionConfigJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
switch (rule.action as AutomationAction) {
|
||||
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 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 isHeartbeat = title.startsWith("[Heartbeat]");
|
||||
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 ?? (config.projectId as string) ?? 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 toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null;
|
||||
const fromAgentId = (config.fromAgentId as string) ?? 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)
|
||||
? requestedChannel
|
||||
: defaultChannel;
|
||||
let channelTargetId: string | null = (config.channelTargetId as string) ?? (config.department as string) ?? 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) {
|
||||
// Derive a descriptive wake trigger from the automation context
|
||||
const triggerType = rule.trigger ?? "";
|
||||
const wakeTrigger =
|
||||
triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment"
|
||||
: triggerType === "run_completed" ? "subtask_completed"
|
||||
: triggerType === "run_failed" ? "report_failed"
|
||||
: triggerType === "hire_approved" ? "assignment"
|
||||
: triggerType === "agent_idle" ? "timer"
|
||||
: "manual";
|
||||
wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger);
|
||||
}
|
||||
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.
|
||||
}
|
||||
const manager = db.getManagerForAgent(targetAgentId);
|
||||
const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`;
|
||||
// Pick the most descriptive wake trigger based on the originating event
|
||||
const wakeTrigger = ctx.newStatus === "in_review"
|
||||
? "subtask_completed"
|
||||
: ctx.newStatus === "blocked"
|
||||
? "report_blocked"
|
||||
: rule.trigger === "run_completed"
|
||||
? "subtask_completed"
|
||||
: rule.trigger === "run_failed"
|
||||
? "report_failed"
|
||||
: "report_blocked";
|
||||
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.
|
||||
// Post as company-wide message so the board and other leaders see it.
|
||||
const escalationResult = dispatchAgentMessage(
|
||||
{ db, emitEvent, publishDomainChanged, wakeAgentIfPossible },
|
||||
{
|
||||
companyId: ctx.companyId,
|
||||
fromAgentId: targetAgentId,
|
||||
toAgentId: null,
|
||||
channel: "company",
|
||||
subject: `Escalation: ${taskTitle}`,
|
||||
body: escalationBody,
|
||||
priority: "urgent",
|
||||
},
|
||||
wakeTrigger,
|
||||
);
|
||||
if (!escalationResult.ok) {
|
||||
logger.warn(
|
||||
`[automation] Failed to escalate "${taskTitle}" to company channel: ${escalationResult.error.code} ${escalationResult.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 meetingType = (config.meetingType as string) ?? "department_sync";
|
||||
const title = (config.title as string) ?? "Auto-scheduled meeting";
|
||||
db.saveMeeting({
|
||||
companyId: ctx.companyId,
|
||||
type: meetingType,
|
||||
title,
|
||||
organizerAgentId: ctx.agentId ?? null,
|
||||
participantAgentIds: (config.participantAgentIds as string) ?? "[]",
|
||||
scheduledAt: new Date(Date.now() + 3600_000).toISOString(),
|
||||
durationMinutes: (config.durationMinutes as number) ?? 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import { extractApprovalDocumentIds } from "@shared/approval-documents";
|
||||
import { notifyHireApproved } from "../hire-hook";
|
||||
import { autoAssignSkillsToAgent } from "./automation-skills";
|
||||
import type { AutomationEventContext } from "./types";
|
||||
import { normalizeWorkflowSteps } from "./automation-workflow";
|
||||
|
||||
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
|
||||
return locale === "zh" ? zh : en;
|
||||
}
|
||||
|
||||
function recordReviewFeedbackKnowledge(
|
||||
db: AppDatabase,
|
||||
approval: {
|
||||
companyId: string;
|
||||
relatedTaskId: string | null;
|
||||
requestedByAgentId: string | null;
|
||||
decisionNote: string | null;
|
||||
},
|
||||
decision: string,
|
||||
): void {
|
||||
if (!approval.relatedTaskId) return;
|
||||
if (decision === "approved") return;
|
||||
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
const feedback = approval.decisionNote?.trim() || "Board requested revisions before approval.";
|
||||
const topic = `Review feedback: ${task.title}`;
|
||||
const content = [
|
||||
`Decision: ${decision}`,
|
||||
`Feedback: ${feedback}`,
|
||||
"Use this review guidance to improve future deliverables for similar work.",
|
||||
].join("\n");
|
||||
|
||||
db.saveKnowledgeEntry({
|
||||
companyId: approval.companyId,
|
||||
category: "lesson_learned",
|
||||
topic,
|
||||
content,
|
||||
authorAgentId: approval.requestedByAgentId ?? null,
|
||||
importance: "high",
|
||||
tagsJson: JSON.stringify(["review_feedback", decision]),
|
||||
referencedEntityType: "task",
|
||||
referencedEntityId: task.id,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ApprovalHandlerDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalHandlerCallbacks {
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
trackTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export function handleApprovalResolved(
|
||||
deps: ApprovalHandlerDeps,
|
||||
callbacks: ApprovalHandlerCallbacks,
|
||||
approvalId: string,
|
||||
companyId: string,
|
||||
decision: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible, dispatchBrowserAction } = deps;
|
||||
const { dispatchAutomation, handleTaskStatusChange, runWorkflowPipeline, trackTimer } = callbacks;
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const approval = snapshot.approvals.find((entry) => entry.id === approvalId);
|
||||
if (!approval) return;
|
||||
|
||||
dispatchAutomation("approval_resolved", {
|
||||
companyId,
|
||||
agentId: approval.requestedByAgentId ?? undefined,
|
||||
taskId: approval.relatedTaskId ?? undefined,
|
||||
});
|
||||
|
||||
if (approval.type === "hire_agent" && decision === "approved" && approval.relatedAgentId) {
|
||||
try {
|
||||
const agent = db.getAgent(approval.relatedAgentId);
|
||||
|
||||
if (agent.status === "idle") {
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "agent.hired",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `${agent.name} hire approved and activated.`,
|
||||
});
|
||||
|
||||
const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id);
|
||||
if (skillsAssigned > 0) {
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "system",
|
||||
action: "skills.auto_assigned",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`,
|
||||
});
|
||||
}
|
||||
|
||||
dispatchAutomation("hire_approved", {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
wakeAgentIfPossible(agent.id, companyId, "assignment");
|
||||
|
||||
const ceo = db.getCeoForCompany(companyId);
|
||||
if (ceo && ceo.id !== agent.id) {
|
||||
wakeAgentIfPossible(ceo.id, companyId, "assignment");
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
const hireLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: localizeNotification(hireLocale, "Agent Hired", "新员工入职"),
|
||||
body: localizeNotification(hireLocale, `${agent.name} joined as ${agent.role}`, `${agent.name} 已加入,职位:${agent.role}`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "orgchart", entityId: agent.id },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
// Notify adapter that hire was approved (Paperclip hire-hook parity)
|
||||
void notifyHireApproved(db, {
|
||||
companyId,
|
||||
agentId: agent.id,
|
||||
source: "approval",
|
||||
sourceId: approvalId,
|
||||
approvedAt: new Date().toISOString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Hire follow-up is best-effort; approval state is already committed in storage.
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "deliverable_review") {
|
||||
try {
|
||||
// Update linked document statuses from payloadJson
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved"
|
||||
? "approved"
|
||||
: decision === "rejected" || decision === "revision_requested"
|
||||
? "draft"
|
||||
: "in_review";
|
||||
for (const docId of documentIds) {
|
||||
db.updateDocumentStatus(docId, companyId, docStatus);
|
||||
}
|
||||
|
||||
if (approval.relatedTaskId) {
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
if (task.assigneeAgentId) {
|
||||
try {
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* agent lookup non-fatal */ }
|
||||
}
|
||||
if (decision === "approved") {
|
||||
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "done");
|
||||
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
|
||||
} else if (decision === "rejected") {
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
} else if (decision === "revision_requested") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board requested revisions: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
recordReviewFeedbackKnowledge(db, approval, decision);
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
} else if (approval.requestedByAgentId) {
|
||||
// Document-only review (no task) — resume the producing agent
|
||||
try {
|
||||
const agent = db.getAgent(approval.requestedByAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* agent lookup non-fatal */ }
|
||||
if (decision !== "approved") {
|
||||
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
|
||||
}
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
} catch {
|
||||
// Deliverable review follow-up is non-fatal — approval state is already committed.
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "draft_review" || approval.type === "strategy_review") {
|
||||
try {
|
||||
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
|
||||
const docStatus = decision === "approved" ? "approved" : "draft";
|
||||
for (const docId of documentIds) {
|
||||
db.updateDocumentStatus(docId, companyId, docStatus);
|
||||
}
|
||||
publishDomainChanged();
|
||||
} catch {
|
||||
// Document checkpoint follow-up is non-fatal — approval state is already committed.
|
||||
}
|
||||
}
|
||||
|
||||
// Handle budget override approvals
|
||||
if (approval.type === "budget_override" && decision === "approved") {
|
||||
try {
|
||||
const payload = JSON.parse(approval.payloadJson ?? "{}") as {
|
||||
policyId?: string;
|
||||
scopeType?: string;
|
||||
scopeId?: string;
|
||||
newAmount?: number;
|
||||
};
|
||||
if (payload.policyId) {
|
||||
// Resolve the budget incident that triggered this approval
|
||||
db.resolveBudgetIncident(payload.policyId, companyId);
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "budget.override_approved",
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
detail: `Budget override approved for ${payload.scopeType} scope`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[automation] Failed to process budget_override approval: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
|
||||
if (browserAction) {
|
||||
if (decision === "approved") {
|
||||
db.updateBrowserAction(browserAction.id, {
|
||||
status: "queued",
|
||||
resultSummary: "Approval granted. Dispatching browser action.",
|
||||
errorMessage: null,
|
||||
});
|
||||
publishDomainChanged();
|
||||
void dispatchBrowserAction(browserAction.id, companyId).catch((error) => {
|
||||
logger.error(`[browser] Failed to dispatch approved browser action ${browserAction.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
} else if (decision === "rejected") {
|
||||
db.updateBrowserAction(browserAction.id, {
|
||||
status: "cancelled",
|
||||
resultSummary: "Browser action was rejected during approval.",
|
||||
errorMessage: "Approval rejected by board.",
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
} else if (decision === "revision_requested") {
|
||||
db.updateBrowserAction(browserAction.id, {
|
||||
status: "approval_required",
|
||||
resultSummary: "Board requested revisions before execution.",
|
||||
errorMessage: "Approval requires revisions before the browser action can run.",
|
||||
});
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "social_post" && decision === "approved" && approval.payloadJson) {
|
||||
try {
|
||||
const draft = JSON.parse(approval.payloadJson) as {
|
||||
socialAccountId: string;
|
||||
platform: string;
|
||||
content: string;
|
||||
mediaUrls: string[];
|
||||
scheduledAt: string | null;
|
||||
};
|
||||
|
||||
const dispatchSocialAction = () => {
|
||||
const actionId = db.saveBrowserAction({
|
||||
companyId: approval.companyId,
|
||||
socialAccountId: draft.socialAccountId,
|
||||
agentId: approval.requestedByAgentId ?? "",
|
||||
actionType: "post",
|
||||
payloadJson: JSON.stringify({ content: draft.content, mediaUrls: draft.mediaUrls, scheduledAt: draft.scheduledAt }),
|
||||
status: "queued",
|
||||
approvalId: approval.id,
|
||||
});
|
||||
publishDomainChanged();
|
||||
void dispatchBrowserAction(actionId, approval.companyId).catch((error) => {
|
||||
logger.error(`[automation] Failed to dispatch approved social post ${actionId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (draft.scheduledAt) {
|
||||
const delay = new Date(draft.scheduledAt).getTime() - Date.now();
|
||||
if (delay > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
if (deps.isQuitting()) return;
|
||||
dispatchSocialAction();
|
||||
}, delay);
|
||||
trackTimer(timer);
|
||||
} else {
|
||||
dispatchSocialAction();
|
||||
}
|
||||
} else {
|
||||
dispatchSocialAction();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[automation] Failed to dispatch approved social post:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.payloadJson) {
|
||||
try {
|
||||
const payload = JSON.parse(approval.payloadJson) as { workflowId?: string; stepId?: string };
|
||||
if (payload.workflowId && payload.stepId) {
|
||||
const workflow = db.getWorkflowRecord(payload.workflowId, companyId);
|
||||
if (workflow) {
|
||||
const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
|
||||
const stepIndex = steps.findIndex((step) => step.id === payload.stepId);
|
||||
if (stepIndex >= 0) {
|
||||
if (decision === "approved") {
|
||||
steps[stepIndex]!.status = "pending";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflow.id,
|
||||
companyId,
|
||||
status: "paused",
|
||||
currentStepIndex: stepIndex,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
});
|
||||
publishDomainChanged();
|
||||
void runWorkflowPipeline(workflow.id, companyId).catch((error) => {
|
||||
logger.error(`[workflow] Failed to resume workflow ${workflow.id} after approval ${approvalId}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
} else {
|
||||
steps[stepIndex]!.status = decision === "revision_requested" ? "pending" : "failed";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflow.id,
|
||||
companyId,
|
||||
status: decision === "revision_requested" ? "paused" : "failed",
|
||||
currentStepIndex: stepIndex,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
});
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-workflow approvals may use payloadJson for unrelated metadata.
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.requestedByAgentId) {
|
||||
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { AutomationEventContext } from "./types";
|
||||
|
||||
export function matchesEventConfig(
|
||||
db: AppDatabase,
|
||||
configJson: string,
|
||||
sourceDepartment: string | null,
|
||||
ctx: AutomationEventContext,
|
||||
): boolean {
|
||||
if (sourceDepartment && ctx.department && sourceDepartment !== ctx.department) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const conditions = JSON.parse(configJson) as Record<string, unknown>;
|
||||
if (Object.keys(conditions).length === 0) return true;
|
||||
if (conditions.status && ctx.newStatus && conditions.status !== ctx.newStatus) return false;
|
||||
if (conditions.previousStatus && ctx.previousStatus && conditions.previousStatus !== ctx.previousStatus) return false;
|
||||
if (conditions.priority) {
|
||||
try {
|
||||
const task = ctx.taskId ? db.getTask(ctx.taskId) : null;
|
||||
if (task && task.priority !== conditions.priority) return false;
|
||||
// If task was deleted, skip this condition instead of rejecting the rule.
|
||||
} catch {
|
||||
// Entity missing — treat as condition not applicable, continue matching.
|
||||
}
|
||||
}
|
||||
if (typeof conditions.budgetThresholdPct === "number" && ctx.agentId) {
|
||||
try {
|
||||
const agent = db.getAgent(ctx.agentId);
|
||||
if (agent.budgetMonthlyUsd > 0) {
|
||||
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
|
||||
if (utilization < (conditions.budgetThresholdPct as number) / 100) return false;
|
||||
}
|
||||
// If budget is 0 (unlimited), skip budget condition instead of rejecting.
|
||||
} catch {
|
||||
// Agent missing — treat as condition not applicable, continue matching.
|
||||
}
|
||||
}
|
||||
if (conditions.taskId && ctx.taskId && conditions.taskId !== ctx.taskId) return false;
|
||||
if (conditions.projectId && ctx.projectId && conditions.projectId !== ctx.projectId) return false;
|
||||
if (conditions.agentId && ctx.agentId && conditions.agentId !== ctx.agentId) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesConditions(
|
||||
db: AppDatabase,
|
||||
rule: { conditionsJson: string; sourceDepartment: string | null },
|
||||
ctx: AutomationEventContext,
|
||||
): boolean {
|
||||
return matchesEventConfig(db, rule.conditionsJson, rule.sourceDepartment, ctx);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { AutomationEventContext } from "./types";
|
||||
|
||||
export interface HandlerDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface HandlerCallbacks {
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function handleTaskStatusChange(
|
||||
deps: HandlerDeps,
|
||||
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
|
||||
taskId: string,
|
||||
companyId: string,
|
||||
previousStatus: string,
|
||||
newStatus: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
if (previousStatus === newStatus) return;
|
||||
const { db, wakeAgentIfPossible } = deps;
|
||||
const { dispatchAutomation } = callbacks;
|
||||
|
||||
let task: ReturnType<typeof db.getTask> | null = null;
|
||||
try {
|
||||
task = db.getTask(taskId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!task) return;
|
||||
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: task.assigneeAgentId ?? "board",
|
||||
action: "task.status_changed",
|
||||
entityType: "task",
|
||||
entityId: taskId,
|
||||
detail: `${previousStatus} → ${newStatus}`,
|
||||
});
|
||||
|
||||
const context: AutomationEventContext = {
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: task.assigneeAgentId ?? undefined,
|
||||
previousStatus,
|
||||
newStatus,
|
||||
};
|
||||
try {
|
||||
if (task.assigneeAgentId) {
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
context.department = agent.department ?? undefined;
|
||||
}
|
||||
} catch {
|
||||
// Agent may have been deleted after the task update; keep the transition without department context.
|
||||
}
|
||||
|
||||
dispatchAutomation("task_status_changed", context);
|
||||
|
||||
if (newStatus === "blocked") {
|
||||
dispatchAutomation("task_blocked", context);
|
||||
if (task.assigneeAgentId) {
|
||||
const manager = db.getManagerForAgent(task.assigneeAgentId);
|
||||
if (manager) {
|
||||
wakeAgentIfPossible(manager.id, companyId, "report_blocked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus === "done" && task.parentId) {
|
||||
try {
|
||||
const siblings = db.listChildTasks(task.parentId, companyId);
|
||||
const allDone = siblings.every((entry) => entry.id === taskId ? true : entry.status === "done");
|
||||
if (allDone) {
|
||||
const parent = db.getTask(task.parentId);
|
||||
if (parent.assigneeAgentId) {
|
||||
wakeAgentIfPossible(parent.assigneeAgentId, companyId, "subtask_completed");
|
||||
} else {
|
||||
const assignedId = db.autoAssignTask(parent.id, companyId);
|
||||
if (assignedId) {
|
||||
wakeAgentIfPossible(assignedId, companyId, "subtask_completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Parent task may have been deleted or reassigned during completion cascade evaluation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTaskCreated(
|
||||
deps: HandlerDeps,
|
||||
callbacks: Pick<HandlerCallbacks, "dispatchAutomation">,
|
||||
taskId: string,
|
||||
companyId: string,
|
||||
): void {
|
||||
if (deps.isQuitting()) return;
|
||||
const { db, wakeAgentIfPossible } = deps;
|
||||
const { dispatchAutomation } = callbacks;
|
||||
|
||||
let task: ReturnType<typeof db.getTask> | null = null;
|
||||
try {
|
||||
task = db.getTask(taskId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!task) return;
|
||||
|
||||
dispatchAutomation("task_created", {
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: task.assigneeAgentId ?? undefined,
|
||||
});
|
||||
|
||||
if (!task.assigneeAgentId) {
|
||||
const assignedId = db.autoAssignTask(taskId, companyId);
|
||||
if (assignedId) {
|
||||
wakeAgentIfPossible(assignedId, companyId, "assignment");
|
||||
}
|
||||
} else {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "assignment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { RunRecord } from "@shared/types";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
import type { AutomationEventContext, RunFinishedEvent } from "./types";
|
||||
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
|
||||
|
||||
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
|
||||
return locale === "zh" ? zh : en;
|
||||
}
|
||||
|
||||
export interface RunCompletionDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
}
|
||||
|
||||
export interface RunCompletionCallbacks {
|
||||
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
|
||||
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
|
||||
}
|
||||
|
||||
function checkRunProducedDeliverable(db: AppDatabase, runId: string, companyId: string, taskId: string): boolean {
|
||||
// Prefer causal binding via source_run_id (set when document/knowledge
|
||||
// is created during a run). Falls back to time-window for legacy data
|
||||
// that predates the source_run_id column.
|
||||
const causalKnowledge = db.queryAll<{ id: string }>(
|
||||
"select id from knowledge_base where company_id = ? and source_run_id = ? limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
if (causalKnowledge.length > 0) return true;
|
||||
|
||||
const causalDocs = db.queryAll<{ id: string }>(
|
||||
"select id from documents where company_id = ? and source_run_id = ? limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
if (causalDocs.length > 0) return true;
|
||||
|
||||
// Time-window fallback: check for legacy records without source_run_id
|
||||
const legacyKnowledge = db.queryAll<{ id: string }>(
|
||||
"select id from knowledge_base where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
if (legacyKnowledge.length > 0) return true;
|
||||
|
||||
const legacyDocs = db.queryAll<{ id: string }>(
|
||||
"select id from documents where company_id = ? and source_run_id is null and created_at >= (select started_at from runs where id = ?) limit 1",
|
||||
companyId, runId,
|
||||
);
|
||||
if (legacyDocs.length > 0) return true;
|
||||
|
||||
const recentComments = db.queryAll<{ body: string }>(
|
||||
"select body from task_comments where task_id = ? and created_at >= (select started_at from runs where id = ?) and length(body) > 100",
|
||||
taskId, runId,
|
||||
);
|
||||
if (recentComments.length > 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
|
||||
function findImportantDocsForRun(db: AppDatabase, runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
|
||||
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
|
||||
// Prefer causal binding, fall back to time-window for legacy data
|
||||
const causal = db.queryAll<{ id: string; type: string; title: string }>(
|
||||
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id = ?`,
|
||||
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
|
||||
);
|
||||
if (causal.length > 0) return causal;
|
||||
return db.queryAll<{ id: string; type: string; title: string }>(
|
||||
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id is null and created_at >= (select started_at from runs where id = ?)`,
|
||||
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a deliverable_review approval and fires OS notification. */
|
||||
function createDeliverableReviewApproval(
|
||||
deps: RunCompletionDeps,
|
||||
companyId: string,
|
||||
taskId: string,
|
||||
agentId: string,
|
||||
docs: Array<{ id: string; type: string; title: string }>,
|
||||
reviewerNote?: string,
|
||||
): string {
|
||||
const { db, logger, publishDomainChanged, notify } = deps;
|
||||
const docTitles = docs.map((d) => d.title).join(", ");
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280),
|
||||
impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.",
|
||||
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
|
||||
});
|
||||
db.setTaskRequiresUserReview(taskId, false);
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`);
|
||||
}
|
||||
notify({
|
||||
title: "Deliverable Ready for Review",
|
||||
body: `${docTitles}`.slice(0, 200),
|
||||
urgency: "informational",
|
||||
navigation: { section: "approvals", entityId: approvalId },
|
||||
batchKey: "deliverable_review",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
export function processRunCompletion(
|
||||
deps: RunCompletionDeps,
|
||||
callbacks: RunCompletionCallbacks,
|
||||
run: RunRecord,
|
||||
event: RunFinishedEvent,
|
||||
isQuitting: () => boolean,
|
||||
): void {
|
||||
if (isQuitting()) return;
|
||||
const { db, logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
|
||||
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
|
||||
|
||||
let task: ReturnType<typeof db.getTask> | null = null;
|
||||
try {
|
||||
task = db.getTask(run.taskId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!task) return;
|
||||
|
||||
const isSuccess = event.status === "succeeded";
|
||||
const isFailed = event.status === "failed" || event.status === "timed_out";
|
||||
|
||||
let effectiveStatus = task.status;
|
||||
|
||||
if (isSuccess && effectiveStatus === "in_progress") {
|
||||
// Check if non-code task produced a deliverable before auto-advancing
|
||||
const taskType = ((task as unknown as Record<string, unknown>).task_type as string) ?? "general";
|
||||
const needsDeliverable = taskType === "research" || taskType === "content";
|
||||
|
||||
if (needsDeliverable) {
|
||||
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id);
|
||||
if (!hasDeliverable) {
|
||||
// Don't auto-advance -- flag for manager review
|
||||
db.advanceTaskStatus(task.id, "in_progress",
|
||||
`${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`);
|
||||
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_progress");
|
||||
// Wake manager for review
|
||||
try {
|
||||
const agent = db.getAgent(run.agentId);
|
||||
const chain = db.getChainOfCommand(agent.id);
|
||||
if (chain.length > 0) {
|
||||
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
publishDomainChanged();
|
||||
// Skip the normal in_review advance
|
||||
} else {
|
||||
db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review.");
|
||||
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
|
||||
effectiveStatus = "in_review";
|
||||
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
|
||||
if (company?.reviewDeliverables) {
|
||||
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
|
||||
if (importantDocs.length > 0) {
|
||||
db.setTaskRequiresUserReview(task.id, true);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
|
||||
}
|
||||
const chain = db.getChainOfCommand(agent.id);
|
||||
if (chain.length === 0) {
|
||||
createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs);
|
||||
}
|
||||
}
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
} else {
|
||||
// Code/management/general tasks advance normally
|
||||
db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review.");
|
||||
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
|
||||
effectiveStatus = "in_review";
|
||||
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
|
||||
if (company?.reviewDeliverables) {
|
||||
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
|
||||
if (importantDocs.length > 0) {
|
||||
db.setTaskRequiresUserReview(task.id, true);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
|
||||
}
|
||||
const chain = db.getChainOfCommand(agent.id);
|
||||
if (chain.length === 0) {
|
||||
createDeliverableReviewApproval(deps, run.companyId, task.id, run.agentId, importantDocs);
|
||||
}
|
||||
}
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess && task.title.startsWith("[Heartbeat]") && effectiveStatus !== "done") {
|
||||
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
|
||||
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
|
||||
}
|
||||
|
||||
const automationContext: AutomationEventContext = {
|
||||
companyId: run.companyId,
|
||||
taskId: run.taskId,
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
costUsd: event.costUsd ?? undefined,
|
||||
};
|
||||
try {
|
||||
const agent = db.getAgent(run.agentId);
|
||||
automationContext.department = agent.department ?? undefined;
|
||||
} catch {
|
||||
// Agent can disappear after a run completes; keep the context without department.
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
dispatchAutomation("run_completed", automationContext);
|
||||
if (task.parentId) {
|
||||
try {
|
||||
const siblings = db.listChildTasks(task.parentId, run.companyId);
|
||||
const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review");
|
||||
if (allSiblingsDoneOrReview) {
|
||||
const parent = db.getTask(task.parentId);
|
||||
if (parent.assigneeAgentId) {
|
||||
wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed");
|
||||
} else {
|
||||
const assignedId = db.autoAssignTask(parent.id, run.companyId);
|
||||
if (assignedId) {
|
||||
wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Parent task may have been deleted or reassigned during cascade evaluation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
dispatchAutomation("run_failed", automationContext);
|
||||
}
|
||||
|
||||
if (event.costUsd && event.costUsd > 0) {
|
||||
try {
|
||||
const agent = db.getAgent(run.agentId);
|
||||
if (agent.budgetMonthlyUsd > 0) {
|
||||
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
|
||||
if (utilization >= 0.8) {
|
||||
dispatchAutomation("budget_threshold", {
|
||||
...automationContext,
|
||||
agentId: agent.id,
|
||||
});
|
||||
// Fire OS notification for budget threshold warning
|
||||
const pct = Math.round(utilization * 100);
|
||||
const budgetLocale = db.listSnapshot().locale;
|
||||
notify({
|
||||
title: localizeNotification(budgetLocale, "Budget Warning", "预算警告"),
|
||||
body: localizeNotification(budgetLocale, `${agent.name} has used ${pct}% of monthly budget`, `${agent.name} 已使用月度预算的 ${pct}%`),
|
||||
urgency: "informational",
|
||||
navigation: { section: "costs" },
|
||||
batchKey: "budget_alerts",
|
||||
});
|
||||
if (utilization >= 1.0) {
|
||||
// Fire OS notification for budget exhaustion (agent auto-paused by database)
|
||||
notify({
|
||||
title: localizeNotification(budgetLocale, "Budget Exhausted", "预算耗尽"),
|
||||
body: localizeNotification(budgetLocale, `${agent.name} has reached 100% of monthly budget and is paused`, `${agent.name} 已达到月度预算上限并已暂停`),
|
||||
urgency: "critical",
|
||||
navigation: { section: "costs" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Budget follow-up is best-effort; do not fail run completion if the agent disappeared.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
|
||||
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
|
||||
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
|
||||
product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"],
|
||||
marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"],
|
||||
sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"],
|
||||
hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"],
|
||||
finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"],
|
||||
design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"],
|
||||
research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"],
|
||||
operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"],
|
||||
customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"],
|
||||
legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"],
|
||||
executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"],
|
||||
};
|
||||
|
||||
function scoreSkillAgentMatch(
|
||||
skill: { name: string; description: string | null; markdown: string },
|
||||
agent: { role: string; department: string | null; title: string | null; capabilities: string },
|
||||
): number {
|
||||
const agentText = [
|
||||
agent.role,
|
||||
agent.department ?? "",
|
||||
agent.title ?? "",
|
||||
agent.capabilities,
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
const skillText = [
|
||||
skill.name,
|
||||
skill.description ?? "",
|
||||
skill.markdown.slice(0, 500),
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
const dept = agent.department ?? "";
|
||||
const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? [];
|
||||
for (const keyword of deptKeywords) {
|
||||
if (skillText.includes(keyword)) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
const agentWords = agentText
|
||||
.replace(/[^a-z0-9\s-]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 3);
|
||||
for (const word of agentWords) {
|
||||
if (skillText.includes(word)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Executives need awareness of all domains
|
||||
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"];
|
||||
const roleLower = agent.role.toLowerCase();
|
||||
if (executiveRoles.some((r) => roleLower.includes(r))) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function autoAssignSkillsToAgent(
|
||||
db: AppDatabase,
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): number {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent || agent.companyId !== companyId) return 0;
|
||||
|
||||
const allSkills = db.listCompanySkills(companyId);
|
||||
if (allSkills.length === 0) return 0;
|
||||
|
||||
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
|
||||
|
||||
// Always assign basic-for-all builtin skills regardless of score
|
||||
let assigned = 0;
|
||||
for (const skill of allSkills) {
|
||||
if (existingAssignments.has(skill.id)) continue;
|
||||
try {
|
||||
const meta = skill.metadata ? (typeof skill.metadata === "string" ? JSON.parse(skill.metadata) : skill.metadata) : {};
|
||||
if (meta.builtinCategory === "basic-for-all") {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
existingAssignments.add(skill.id);
|
||||
assigned++;
|
||||
logger.info(`[skill-autoassign] Assigned basic skill "${skill.name}" to ${agent.name}`);
|
||||
}
|
||||
} catch { /* skip malformed metadata */ }
|
||||
}
|
||||
|
||||
const scored = allSkills
|
||||
.filter((skill) => !existingAssignments.has(skill.id))
|
||||
.map((skill) => ({
|
||||
skill,
|
||||
score: scoreSkillAgentMatch(skill, agent),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const MIN_SCORE = 3;
|
||||
for (const { skill, score } of scored) {
|
||||
if (score < MIN_SCORE) continue;
|
||||
try {
|
||||
db.assignSkillToAgent(agentId, skill.id);
|
||||
assigned++;
|
||||
logger.info(
|
||||
`[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (assigned === 0) {
|
||||
logger.info(
|
||||
`[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`,
|
||||
);
|
||||
}
|
||||
|
||||
return assigned;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { WORKFLOW_ACTIONS, type WorkflowStepState, type AutomationEventContext } from "./types";
|
||||
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
|
||||
import { executeAutomationAction } from "./automation-actions";
|
||||
|
||||
export interface WorkflowDeps {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
export function normalizeWorkflowSteps(raw: unknown): WorkflowStepState[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error("Workflow steps must be a JSON array.");
|
||||
}
|
||||
return raw.map((entry, index) => {
|
||||
const step = (entry ?? {}) as Record<string, unknown>;
|
||||
const rawAction = typeof step.action === "string" ? step.action : "send_message";
|
||||
if (!WORKFLOW_ACTIONS.has(rawAction as WorkflowStepState["action"])) {
|
||||
throw new Error(`Workflow step ${index + 1} has unsupported action "${rawAction}".`);
|
||||
}
|
||||
return {
|
||||
id: typeof step.id === "string" && step.id ? step.id : `step-${index + 1}`,
|
||||
name: typeof step.name === "string" && step.name ? step.name : `Step ${index + 1}`,
|
||||
action: rawAction as WorkflowStepState["action"],
|
||||
configJson: typeof step.configJson === "string" ? step.configJson : "{}",
|
||||
assigneeDepartment: typeof step.assigneeDepartment === "string" ? step.assigneeDepartment : null,
|
||||
assigneeAgentId: typeof step.assigneeAgentId === "string" ? step.assigneeAgentId : null,
|
||||
requiresApproval: step.requiresApproval === true,
|
||||
timeoutSec: typeof step.timeoutSec === "number" ? step.timeoutSec : 0,
|
||||
status: ["pending", "running", "completed", "failed", "skipped"].includes(String(step.status))
|
||||
? (String(step.status) as WorkflowStepState["status"])
|
||||
: "pending",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWorkflowStepActor(
|
||||
db: AppDatabase,
|
||||
step: WorkflowStepState,
|
||||
companyId: string,
|
||||
): AgentRecord | null {
|
||||
if (step.assigneeAgentId) {
|
||||
const agent = db.getAgent(step.assigneeAgentId);
|
||||
if (agent.companyId !== companyId) {
|
||||
throw new Error(`Workflow step assignee ${step.assigneeAgentId} does not belong to company ${companyId}.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
if (step.assigneeDepartment) {
|
||||
const idleAgents = db.getIdleAgentsForDepartment(companyId, step.assigneeDepartment);
|
||||
if (idleAgents.length > 0) {
|
||||
return db.getAgent(idleAgents[0]!.id);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
return snapshot.agents.find((agent) => agent.companyId === companyId && !agent.reportsTo && agent.status !== "terminated") ?? null;
|
||||
}
|
||||
|
||||
export function buildWorkflowActionContext(
|
||||
companyId: string,
|
||||
step: WorkflowStepState,
|
||||
actor: AgentRecord | null,
|
||||
): { config: Record<string, unknown>; ctx: AutomationEventContext } {
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
config = JSON.parse(step.configJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`Workflow step "${step.name}" has invalid config JSON.`);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
ctx: {
|
||||
companyId,
|
||||
agentId: actor?.id,
|
||||
department: actor?.department ?? step.assigneeDepartment ?? undefined,
|
||||
taskId: typeof config.taskId === "string" ? config.taskId : undefined,
|
||||
runId: typeof config.runId === "string" ? config.runId : undefined,
|
||||
projectId: typeof config.projectId === "string" ? config.projectId : undefined,
|
||||
newStatus: typeof config.status === "string" ? config.status : undefined,
|
||||
} satisfies AutomationEventContext,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorkflowPipeline(
|
||||
workflowId: string,
|
||||
companyId: string,
|
||||
deps: WorkflowDeps,
|
||||
actionDeps: ActionDeps,
|
||||
actionCallbacks: ActionCallbacks,
|
||||
): Promise<void> {
|
||||
const { db, publishDomainChanged } = deps;
|
||||
|
||||
const workflow = db.getWorkflowRecord(workflowId, companyId);
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found.");
|
||||
}
|
||||
const parsedSteps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
|
||||
if (parsedSteps.some((step) => step.status === "running")) {
|
||||
throw new Error("Workflow is already running.");
|
||||
}
|
||||
if (parsedSteps.length === 0) {
|
||||
throw new Error("Workflow has no steps.");
|
||||
}
|
||||
|
||||
const resumeFrom = workflow.status === "paused" ? workflow.currentStepIndex : 0;
|
||||
const steps: WorkflowStepState[] = parsedSteps.map((step, index) => {
|
||||
if (workflow.status !== "paused") {
|
||||
return { ...step, status: index < resumeFrom ? "completed" : "pending" };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
db.updateWorkflowExecution({
|
||||
id: workflowId,
|
||||
companyId,
|
||||
status: "active",
|
||||
currentStepIndex: resumeFrom,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
incrementRunCount: workflow.status !== "paused",
|
||||
lastRunAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
for (let index = resumeFrom; index < steps.length; index += 1) {
|
||||
const step = steps[index]!;
|
||||
step.status = "running";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflowId,
|
||||
companyId,
|
||||
status: "active",
|
||||
currentStepIndex: index,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
const actor = resolveWorkflowStepActor(db, step, companyId);
|
||||
const { config, ctx } = buildWorkflowActionContext(companyId, step, actor);
|
||||
|
||||
if (step.requiresApproval) {
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: actor?.id ?? null,
|
||||
relatedTaskId: ctx.taskId ?? null,
|
||||
type: "dangerous_command",
|
||||
payloadSummary: `Workflow approval required: ${workflow.name} / ${step.name}`,
|
||||
impactSummary: `Workflow step "${step.name}" requires board approval before ${step.action}.`,
|
||||
payloadJson: JSON.stringify({ workflowId, stepId: step.id, stepName: step.name }),
|
||||
});
|
||||
step.status = "pending";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflowId,
|
||||
companyId,
|
||||
status: "paused",
|
||||
currentStepIndex: index,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: new Date().toISOString(),
|
||||
});
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: actor?.id ?? "system",
|
||||
action: "workflow.paused_for_approval",
|
||||
entityType: "workflow",
|
||||
entityId: workflowId,
|
||||
detail: `Paused on step "${step.name}" with approval ${approvalId}.`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
executeAutomationAction(
|
||||
actionDeps,
|
||||
actionCallbacks,
|
||||
{
|
||||
action: step.action,
|
||||
actionConfigJson: JSON.stringify(config),
|
||||
targetDepartment: step.assigneeDepartment,
|
||||
trigger: "workflow_step",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
step.status = "completed";
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: actor?.id ?? "system",
|
||||
action: "workflow.step_completed",
|
||||
entityType: "workflow",
|
||||
entityId: workflowId,
|
||||
detail: step.name,
|
||||
});
|
||||
const isLastStep = index === steps.length - 1;
|
||||
const completionStatus = isLastStep ? "completed" : "active";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflowId,
|
||||
companyId,
|
||||
status: completionStatus,
|
||||
currentStepIndex: index + 1,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: new Date().toISOString(),
|
||||
});
|
||||
publishDomainChanged();
|
||||
} catch (error) {
|
||||
step.status = "failed";
|
||||
db.updateWorkflowExecution({
|
||||
id: workflowId,
|
||||
companyId,
|
||||
status: "failed",
|
||||
currentStepIndex: index,
|
||||
stepsJson: JSON.stringify(steps),
|
||||
lastRunAt: new Date().toISOString(),
|
||||
});
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor: actor?.id ?? "system",
|
||||
action: "workflow.step_failed",
|
||||
entityType: "workflow",
|
||||
entityId: workflowId,
|
||||
detail: `${step.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
publishDomainChanged();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { AutomationTrigger, AutomationAction, RunRecord } from "@shared/types";
|
||||
import type { AutomationDependencies, AutomationEventContext, RunFinishedEvent } from "./types";
|
||||
import { matchesConditions, matchesEventConfig } from "./automation-conditions";
|
||||
import { executeAutomationAction } from "./automation-actions";
|
||||
import type { ActionDeps, ActionCallbacks } from "./automation-actions";
|
||||
import { runWorkflowPipeline } from "./automation-workflow";
|
||||
import type { WorkflowDeps } from "./automation-workflow";
|
||||
import { processRunCompletion } from "./automation-run-completion";
|
||||
import type { RunCompletionDeps, RunCompletionCallbacks } from "./automation-run-completion";
|
||||
import { handleApprovalResolved } from "./automation-approval-handler";
|
||||
import type { ApprovalHandlerDeps, ApprovalHandlerCallbacks } from "./automation-approval-handler";
|
||||
import { handleTaskStatusChange, handleTaskCreated } from "./automation-handlers";
|
||||
import type { HandlerDeps } from "./automation-handlers";
|
||||
|
||||
export type { AutomationEventContext } from "./types";
|
||||
export { autoAssignSkillsToAgent } from "./automation-skills";
|
||||
|
||||
export function createAutomationService({
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
isQuitting,
|
||||
}: AutomationDependencies) {
|
||||
const activeAutomationExecutions = new Set<string>();
|
||||
const activeWorkflowTriggerExecutions = new Set<string>();
|
||||
const pendingScheduledTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const actionDeps: ActionDeps = {
|
||||
db,
|
||||
logger,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
};
|
||||
|
||||
const workflowDeps: WorkflowDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
};
|
||||
|
||||
const handlerDeps: HandlerDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
isQuitting,
|
||||
};
|
||||
|
||||
const approvalDeps: ApprovalHandlerDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction,
|
||||
isQuitting,
|
||||
};
|
||||
|
||||
const runCompletionDeps: RunCompletionDeps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
};
|
||||
|
||||
// Forward-declared so callbacks can reference each other
|
||||
function _runWorkflowPipeline(workflowId: string, companyId: string): Promise<void> {
|
||||
return runWorkflowPipeline(workflowId, companyId, workflowDeps, actionDeps, actionCallbacks);
|
||||
}
|
||||
|
||||
function _handleTaskCreated(taskId: string, companyId: string): void {
|
||||
handleTaskCreated(handlerDeps, { dispatchAutomation }, taskId, companyId);
|
||||
}
|
||||
|
||||
function _handleApprovalResolved(approvalId: string, companyId: string, decision: string): void {
|
||||
handleApprovalResolved(approvalDeps, approvalCallbacks, approvalId, companyId, decision);
|
||||
}
|
||||
|
||||
function _handleTaskStatusChange(taskId: string, companyId: string, previousStatus: string, newStatus: string): void {
|
||||
handleTaskStatusChange(handlerDeps, { dispatchAutomation }, taskId, companyId, previousStatus, newStatus);
|
||||
}
|
||||
|
||||
const actionCallbacks: ActionCallbacks = {
|
||||
handleTaskCreated: _handleTaskCreated,
|
||||
handleApprovalResolved: _handleApprovalResolved,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
};
|
||||
|
||||
const approvalCallbacks: ApprovalHandlerCallbacks = {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
runWorkflowPipeline: _runWorkflowPipeline,
|
||||
trackTimer: (timer) => { pendingScheduledTimers.add(timer); },
|
||||
};
|
||||
|
||||
const runCompletionCallbacks: RunCompletionCallbacks = {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
};
|
||||
|
||||
function evaluateAutomationRules(trigger: string, ctx: AutomationEventContext) {
|
||||
try {
|
||||
const rules = db.listActiveAutomationRules(ctx.companyId, trigger);
|
||||
for (const rule of rules) {
|
||||
if (!matchesConditions(db, rule, ctx)) continue;
|
||||
const executionKey = [
|
||||
rule.id,
|
||||
trigger,
|
||||
ctx.companyId,
|
||||
ctx.taskId ?? "-",
|
||||
ctx.agentId ?? "-",
|
||||
ctx.runId ?? "-",
|
||||
ctx.previousStatus ?? "-",
|
||||
ctx.newStatus ?? "-",
|
||||
].join(":");
|
||||
if (activeAutomationExecutions.has(executionKey)) {
|
||||
logger.warn(`[automation] Skipping re-entrant execution for rule "${rule.name}" (${executionKey})`);
|
||||
continue;
|
||||
}
|
||||
activeAutomationExecutions.add(executionKey);
|
||||
try {
|
||||
executeAutomationAction(actionDeps, actionCallbacks, rule, ctx);
|
||||
db.recordAutomationExecution(
|
||||
rule.id,
|
||||
ctx.companyId,
|
||||
rule.name,
|
||||
trigger,
|
||||
rule.action,
|
||||
`Fired for ${trigger} in context: taskId=${ctx.taskId ?? "n/a"}, agentId=${ctx.agentId ?? "n/a"}`,
|
||||
);
|
||||
emitEvent({
|
||||
type: "automation-fired",
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
trigger: trigger as AutomationTrigger,
|
||||
action: rule.action as AutomationAction,
|
||||
});
|
||||
logger.info(`[automation] Fired rule "${rule.name}" (${trigger} → ${rule.action})`);
|
||||
} catch (error) {
|
||||
logger.error(`[automation] Failed to execute action for rule "${rule.name}": ${error instanceof Error ? error.message : String(error)}`);
|
||||
db.recordAutomationExecution(
|
||||
rule.id,
|
||||
ctx.companyId,
|
||||
rule.name,
|
||||
trigger,
|
||||
rule.action,
|
||||
`FAILED: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
activeAutomationExecutions.delete(executionKey);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[automation] Error evaluating rules for trigger "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateWorkflowTriggers(trigger: string, ctx: AutomationEventContext) {
|
||||
try {
|
||||
const workflows = db.listTriggerableWorkflows(ctx.companyId, trigger);
|
||||
for (const workflow of workflows) {
|
||||
if (!matchesEventConfig(db, workflow.triggerConfigJson, null, ctx)) continue;
|
||||
const executionKey = [
|
||||
workflow.id,
|
||||
trigger,
|
||||
ctx.companyId,
|
||||
ctx.taskId ?? "-",
|
||||
ctx.agentId ?? "-",
|
||||
ctx.runId ?? "-",
|
||||
ctx.previousStatus ?? "-",
|
||||
ctx.newStatus ?? "-",
|
||||
].join(":");
|
||||
if (activeWorkflowTriggerExecutions.has(executionKey)) {
|
||||
logger.warn(`[workflow] Skipping re-entrant trigger for workflow "${workflow.name}" (${executionKey})`);
|
||||
continue;
|
||||
}
|
||||
activeWorkflowTriggerExecutions.add(executionKey);
|
||||
void _runWorkflowPipeline(workflow.id, ctx.companyId)
|
||||
.then(() => {
|
||||
logger.info(`[workflow] Triggered workflow "${workflow.name}" via ${trigger}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[workflow] Trigger failed for "${workflow.name}": ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
activeWorkflowTriggerExecutions.delete(executionKey);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[workflow] Error evaluating workflow triggers for "${trigger}": ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchAutomation(trigger: string, ctx: AutomationEventContext) {
|
||||
if (isQuitting()) return;
|
||||
evaluateAutomationRules(trigger, ctx);
|
||||
evaluateWorkflowTriggers(trigger, ctx);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const timer of pendingScheduledTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
pendingScheduledTimers.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
runWorkflowPipeline: _runWorkflowPipeline,
|
||||
dispatchAutomation,
|
||||
processRunCompletion: (run: RunRecord, event: RunFinishedEvent) =>
|
||||
processRunCompletion(runCompletionDeps, runCompletionCallbacks, run, event, isQuitting),
|
||||
handleApprovalResolved: _handleApprovalResolved,
|
||||
handleTaskStatusChange: _handleTaskStatusChange,
|
||||
handleTaskCreated: _handleTaskCreated,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AppDatabase } from "../database";
|
||||
import type {
|
||||
AutomationAction,
|
||||
DesktopEvent,
|
||||
RunStatus,
|
||||
} from "@shared/types";
|
||||
import type { NotificationOptions } from "../notification-service";
|
||||
|
||||
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
|
||||
export const IMPORTANT_DOCUMENT_TYPES = new Set([
|
||||
"prd", "technical_spec", "design_doc", "project_brief", "proposal",
|
||||
"budget_proposal", "architecture_decision", "contract", "sop",
|
||||
]);
|
||||
|
||||
export const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
|
||||
"assign_task",
|
||||
"create_task",
|
||||
"notify_agent",
|
||||
"send_message",
|
||||
"trigger_heartbeat",
|
||||
"create_approval",
|
||||
"update_task_status",
|
||||
"escalate_to_manager",
|
||||
"cross_department_notify",
|
||||
"schedule_meeting",
|
||||
"create_document",
|
||||
"auto_approve",
|
||||
"reassign_task",
|
||||
"run_task",
|
||||
]);
|
||||
|
||||
export interface WorkflowStepState {
|
||||
id: string;
|
||||
name: string;
|
||||
action: AutomationAction;
|
||||
configJson: string;
|
||||
assigneeDepartment: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
requiresApproval: boolean;
|
||||
timeoutSec: number;
|
||||
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
||||
}
|
||||
|
||||
export interface AutomationEventContext {
|
||||
companyId: string;
|
||||
taskId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
projectId?: string;
|
||||
department?: string;
|
||||
previousStatus?: string;
|
||||
newStatus?: string;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
export interface AutomationDependencies {
|
||||
db: AppDatabase;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
publishDomainChanged: () => void;
|
||||
notify: (options: NotificationOptions) => void;
|
||||
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
dispatchBrowserAction: (actionId: string, companyId: string) => Promise<void>;
|
||||
isQuitting: () => boolean;
|
||||
}
|
||||
|
||||
export interface RunFinishedEvent {
|
||||
status: RunStatus;
|
||||
summary: string | null;
|
||||
errorMessage: string | null;
|
||||
costUsd: number | null;
|
||||
}
|
||||
Reference in new issue
Block a user