update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user