Files
AgentCompany/src/main/automation/automation-approval-handler.ts
T
ZacharyZhang-NY 6a78455feb Package Boss build 0.4.181 from the current workspace snapshot
Constraint: Boss Windows artifacts are built on a native GitHub runner from a temporary snapshot branch
Rejected: Build Boss Windows locally on Apple Silicon | docker/wine packaging is not stable on this host
Confidence: medium
Scope-risk: narrow
Directive: This snapshot branch is transport-only and should be deleted after the Boss build is recovered locally
Tested: local Boss mac build stages before snapshot creation
Not-tested: remote Boss Windows packaging before workflow execution
2026-04-29 15:00:50 -04:00

376 lines
14 KiB
TypeScript

import { z } from "zod";
import type { AppDatabase } from "../database";
import type { NotificationOptions } from "../notification-service";
import { extractApprovalDocumentIds } from "@shared/approval-documents";
import { notifyHireApproved } from "../hire-hook";
import { autoAssignSkillsForHire } from "../skills/auto-assign-on-hire";
import type { AutomationEventContext } from "./types";
import { normalizeWorkflowSteps } from "./automation-workflow";
const workflowApprovalPayloadSchema = z.object({
workflowId: z.string().optional(),
stepId: z.string().optional(),
stepName: z.string().optional(),
}).passthrough();
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.core.entity.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.core.knowledge.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;
queueBrowserActionExecution: (actionId: string, companyId: string) => 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, queueBrowserActionExecution } = deps;
const { dispatchAutomation, handleTaskStatusChange, runWorkflowPipeline, trackTimer } = callbacks;
const explicitlyWokenAgentIds = new Set<string>();
function wakeAgent(agentId: string | null | undefined, trigger: string): void {
if (!agentId) return;
wakeAgentIfPossible(agentId, companyId, trigger);
explicitlyWokenAgentIds.add(agentId);
}
const snapshot = db.projection.snapshot.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) {
const agent = db.core.entity.getAgent(approval.relatedAgentId);
if (agent.status === "idle") {
// Deterministic default skill kit. The CEO's operating prompt
// used to carry this responsibility ("ensure every direct report
// has skills"), turning O(reports) API calls into a per-wake
// tax. At hire approval time we know the agent's department and
// the company's seeded catalogue — assign the default bundle so
// the new hire's first run already has the right SKILL.md
// manifest.
const { assignedSkillIds } = autoAssignSkillsForHire(db, {
agentId: agent.id,
companyId,
department: agent.department,
});
if (assignedSkillIds.length > 0) {
db.core.activity.addActivity({
companyId,
actor: "system",
action: "agent.skills_defaulted",
entityType: "agent",
entityId: agent.id,
detail: `${assignedSkillIds.length} default skill(s) assigned on hire.`,
});
}
db.core.activity.addActivity({
companyId,
actor: "system",
action: "agent.hired",
entityType: "agent",
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgent(agent.id, "assignment");
const ceo = db.core.agents.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgent(ceo.id, "assignment");
}
publishDomainChanged();
const hireLocale = db.projection.snapshot.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: "agents", 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(() => {});
}
}
if (approval.type === "deliverable_review") {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved"
? "approved"
: decision === "rejected" || decision === "revision_requested"
? "draft"
: "in_review";
for (const docId of documentIds) {
db.core.documents.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.core.entity.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
const agent = db.core.entity.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
}
if (decision === "approved") {
db.core.tasks.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.core.activity.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected" || decision === "revision_requested") {
const previousStatus = task.status;
const statusMessage = decision === "rejected"
? `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`
: `Board requested revisions: ${approval.decisionNote || "No reason given."}`;
recordReviewFeedbackKnowledge(db, approval, decision);
db.core.tasks.advanceTaskStatus(task.id, "todo", statusMessage);
handleTaskStatusChange(task.id, companyId, previousStatus, "todo");
wakeAgent(task.assigneeAgentId, "revision_needed");
}
} else if (approval.requestedByAgentId) {
const agent = db.core.entity.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.core.agents.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
if (decision !== "approved") {
wakeAgent(approval.requestedByAgentId, "revision_needed");
}
}
publishDomainChanged();
}
if (approval.type === "draft_review" || approval.type === "strategy_review") {
const documentIds = extractApprovalDocumentIds(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : "draft";
for (const docId of documentIds) {
db.core.documents.updateDocumentStatus(docId, companyId, docStatus);
}
publishDomainChanged();
}
// 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.core.budget.resolveBudgetIncident(payload.policyId, companyId);
db.core.activity.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.core.social.getBrowserActionByApprovalId(approvalId, companyId);
if (browserAction) {
if (decision === "approved") {
db.core.social.updateBrowserAction(browserAction.id, {
status: "queued",
resultSummary: "Approval granted. Dispatching browser action.",
errorMessage: null,
});
publishDomainChanged();
queueBrowserActionExecution(browserAction.id, companyId);
} else if (decision === "rejected") {
db.core.social.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.core.social.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.core.social.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();
queueBrowserActionExecution(actionId, approval.companyId);
};
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);
}
}
// Workflow-step approvals are emitted by runWorkflowPipeline with
// type "dangerous_command" + workflowId/stepId in payloadJson. Gate the
// parse explicitly on that type instead of attempting a speculative
// JSON.parse for every approval.
if (approval.type === "dangerous_command" && approval.payloadJson) {
const payload = workflowApprovalPayloadSchema.safeParse(JSON.parse(approval.payloadJson));
if (payload.success && payload.data.workflowId && payload.data.stepId) {
const { workflowId, stepId } = payload.data;
const workflow = db.automation.rules.getWorkflowRecord(workflowId, companyId);
if (workflow) {
const steps = normalizeWorkflowSteps(JSON.parse(workflow.stepsJson || "[]"));
const stepIndex = steps.findIndex((step) => step.id === stepId);
if (stepIndex >= 0) {
if (decision === "approved") {
steps[stepIndex]!.status = "pending";
db.automation.rules.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.automation.rules.updateWorkflowExecution({
id: workflow.id,
companyId,
status: decision === "revision_requested" ? "paused" : "failed",
currentStepIndex: stepIndex,
stepsJson: JSON.stringify(steps),
lastRunAt: workflow.lastRunAt,
});
publishDomainChanged();
}
}
}
}
}
if (approval.requestedByAgentId) {
if (!explicitlyWokenAgentIds.has(approval.requestedByAgentId)) {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "approval_resolved");
}
}
}