update
This commit is contained in:
1 parent
1ba3147ed2
commit
0cd65521b8
32 files changed
+1853
-342
No files matched your search
@@ -16,6 +16,24 @@ function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string)
|
||||
return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId);
|
||||
}
|
||||
|
||||
export function canControlAgent(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string): boolean {
|
||||
const actor = safeGetAgent(db, actorAgentId);
|
||||
const target = safeGetAgent(db, targetAgentId);
|
||||
if (!actor || !target) {
|
||||
return false;
|
||||
}
|
||||
if (actor.companyId !== companyId || target.companyId !== companyId) {
|
||||
return false;
|
||||
}
|
||||
if (actor.id === target.id) {
|
||||
return true;
|
||||
}
|
||||
if (canManageOrganization(db, companyId, actorAgentId)) {
|
||||
return true;
|
||||
}
|
||||
return managesAgent(db, actorAgentId, targetAgentId);
|
||||
}
|
||||
|
||||
export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
|
||||
const agent = safeGetAgent(db, agentId);
|
||||
if (!agent || agent.companyId !== companyId) {
|
||||
|
||||
+133
-130
@@ -1,10 +1,13 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { agentMessageInputSchema, approvalInputSchema, goalInputSchema, hireRequestSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
import { validateAgentToken, type TokenPayload } from "./agent-auth";
|
||||
import { canAssignTask, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions";
|
||||
import { canAssignTask, canControlAgent, canManageGoal, canManageOrganization, canManageProject, canUpdateTask } from "./agent-permissions";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
type RequestHandler = (
|
||||
req: IncomingMessage,
|
||||
@@ -49,6 +52,7 @@ export interface ApiServerEventCallbacks {
|
||||
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
|
||||
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
}
|
||||
|
||||
export class AgentApiServer {
|
||||
@@ -58,6 +62,7 @@ export class AgentApiServer {
|
||||
private db: AppDatabase;
|
||||
private onDomainChanged: () => void;
|
||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
private emitEvent: (event: DesktopEvent) => void;
|
||||
private callbacks: ApiServerEventCallbacks;
|
||||
private allowedOrigins: Set<string>;
|
||||
private onBrowserAction: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
|
||||
@@ -72,6 +77,7 @@ export class AgentApiServer {
|
||||
this.db = db;
|
||||
this.onDomainChanged = onDomainChanged;
|
||||
this.onWakeAgent = onWakeAgent;
|
||||
this.emitEvent = callbacks?.emitEvent ?? (() => {});
|
||||
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
|
||||
this.callbacks = {
|
||||
onDomainChanged,
|
||||
@@ -82,6 +88,7 @@ export class AgentApiServer {
|
||||
onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}),
|
||||
onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}),
|
||||
onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null),
|
||||
emitEvent: callbacks?.emitEvent ?? (() => {}),
|
||||
};
|
||||
this.registerRoutes();
|
||||
}
|
||||
@@ -202,7 +209,7 @@ export class AgentApiServer {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[api] ${method} ${pathname}: ${message}`);
|
||||
this.json(res, 500, { error: message });
|
||||
this.json(res, 500, { error: "Internal server error" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -728,9 +735,9 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to request hires" });
|
||||
return;
|
||||
}
|
||||
const parsed = agentInputSchema.safeParse({
|
||||
const parsed = hireRequestSchema.safeParse({
|
||||
companyId,
|
||||
status: "pending_approval",
|
||||
requestedByAgentId: token.agentId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
reportsTo:
|
||||
typeof body === "object" && body && "reportsTo" in body
|
||||
@@ -743,85 +750,87 @@ export class AgentApiServer {
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.requestedByAgentId, table: "agents", label: "requesting agent" },
|
||||
{ id: input.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentId: string;
|
||||
const managerLabel = input.reportsTo
|
||||
? (() => {
|
||||
try {
|
||||
return this.db.getAgent(input.reportsTo).name;
|
||||
} catch {
|
||||
return input.reportsTo;
|
||||
}
|
||||
})()
|
||||
: "the company board";
|
||||
|
||||
let result: { agentId: string; approvalId: string };
|
||||
try {
|
||||
agentId = this.db.saveAgent({
|
||||
id: input.id ?? null,
|
||||
result = this.db.createHireRequest({
|
||||
companyId,
|
||||
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
department: input.department ?? null,
|
||||
status: "pending_approval",
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
payloadSummary: `Hire ${input.name} as ${input.role}`,
|
||||
impactSummary: input.capabilities || `${input.name} will report to ${managerLabel}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
department: input.department ?? null,
|
||||
title: input.title,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalId = this.db.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: token.agentId,
|
||||
relatedAgentId: agentId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: `Hire ${input.name} as ${input.role}`,
|
||||
impactSummary: input.capabilities || `${input.name} will report to ${input.reportsTo ?? token.agentId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
agentId,
|
||||
requestedByAgentId: token.agentId,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "agent.hire_requested",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
entityId: result.agentId,
|
||||
detail: input.name,
|
||||
});
|
||||
|
||||
// Auto-approve hire if company policy allows it
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const company = snapshot.companies.find((c) => c.id === companyId);
|
||||
if (company?.autoApproveHires) {
|
||||
this.db.decideApproval({ companyId, approvalId, state: "approved", decisionNote: "Auto-approved by company policy" });
|
||||
this.callbacks.onApprovalResolved(approvalId, companyId, "approved");
|
||||
this.db.addActivity({
|
||||
let autoApproved = false;
|
||||
if (shouldAutoApproveApproval(this.db, companyId, "hire_agent")) {
|
||||
resolveApprovalDecision({
|
||||
db: this.db,
|
||||
handleApprovalResolved: this.callbacks.onApprovalResolved,
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
approvalId: result.approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved by company policy",
|
||||
detail: `Auto-approved hire: ${input.name}`,
|
||||
});
|
||||
autoApproved = true;
|
||||
log.info(`[auto-approve] Auto-approved hire of ${input.name} via agent API`);
|
||||
}
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { agentId, approvalId });
|
||||
this.json(res, 201, { ...result, autoApproved });
|
||||
});
|
||||
|
||||
this.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
|
||||
@@ -904,18 +913,27 @@ export class AgentApiServer {
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.relatedTaskId, table: "tasks", label: "related task" },
|
||||
{ id: token.agentId, table: "agents", label: "requesting agent" },
|
||||
{ id: input.relatedAgentId, table: "agents", label: "related agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.requestApproval({
|
||||
companyId,
|
||||
type: input.type,
|
||||
relatedTaskId: input.relatedTaskId ?? null,
|
||||
requestedByAgentId: token.agentId,
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
});
|
||||
let id: string;
|
||||
try {
|
||||
id = this.db.requestApproval({
|
||||
companyId,
|
||||
type: input.type,
|
||||
relatedTaskId: input.relatedTaskId ?? null,
|
||||
requestedByAgentId: token.agentId,
|
||||
relatedAgentId: input.relatedAgentId ?? null,
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
payloadJson: input.payloadJson ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
@@ -926,20 +944,17 @@ export class AgentApiServer {
|
||||
detail: input.payloadSummary,
|
||||
});
|
||||
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const company = snapshot.companies.find((c) => c.id === companyId);
|
||||
if (company?.autoApproveHires && (input.type === "hire_agent" || input.type === "approve_ceo_strategy")) {
|
||||
this.db.decideApproval({ companyId, approvalId: id, state: "approved", decisionNote: "Auto-approved by company policy" });
|
||||
this.callbacks.onApprovalResolved(id, companyId, "approved");
|
||||
this.db.addActivity({
|
||||
if (shouldAutoApproveApproval(this.db, companyId, input.type)) {
|
||||
resolveApprovalDecision({
|
||||
db: this.db,
|
||||
handleApprovalResolved: this.callbacks.onApprovalResolved,
|
||||
companyId,
|
||||
actor: "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
approvalId: id,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved by company policy",
|
||||
detail: "Auto-approved by company policy",
|
||||
});
|
||||
log.info(`[auto-approve] Auto-approved hire request: ${input.payloadSummary}`);
|
||||
log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`);
|
||||
}
|
||||
|
||||
this.onDomainChanged();
|
||||
@@ -1437,74 +1452,35 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageSchema = z.object({
|
||||
toAgentId: z.string().uuid().nullable().optional(),
|
||||
channel: z.enum(["direct", "department", "company", "project", "incident"]).default("direct"),
|
||||
channelTargetId: z.string().nullable().optional(),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(8000),
|
||||
priority: z.enum(["urgent", "normal", "low"]).default("normal"),
|
||||
parentMessageId: z.string().uuid().nullable().optional(),
|
||||
attachmentsJson: z.string().default("[]"),
|
||||
const parsed = agentMessageInputSchema.safeParse({
|
||||
...(body as Record<string, unknown>),
|
||||
companyId,
|
||||
fromAgentId: token.agentId,
|
||||
});
|
||||
|
||||
const parsed = messageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = parsed.data;
|
||||
if (msg.channel === "direct" && !msg.toAgentId) {
|
||||
this.json(res, 400, { error: "Direct messages require a recipient" });
|
||||
const dispatchResult = dispatchAgentMessage(
|
||||
{
|
||||
db: this.db,
|
||||
emitEvent: this.emitEvent,
|
||||
publishDomainChanged: this.onDomainChanged,
|
||||
wakeAgentIfPossible: (agentId, companyId, trigger) => {
|
||||
if (!agentId) return null;
|
||||
return this.onWakeAgent(agentId, companyId, trigger);
|
||||
},
|
||||
},
|
||||
parsed.data,
|
||||
"message",
|
||||
);
|
||||
if (!dispatchResult.ok) {
|
||||
this.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code });
|
||||
return;
|
||||
}
|
||||
if (msg.toAgentId && !this.db.belongsToCompany("agents", msg.toAgentId, companyId)) {
|
||||
this.json(res, 400, { error: "Direct-message recipient does not belong to the current company" });
|
||||
return;
|
||||
}
|
||||
if (msg.channel === "project" && msg.channelTargetId && !this.db.belongsToCompany("projects", msg.channelTargetId, companyId)) {
|
||||
this.json(res, 400, { error: "Project message target does not belong to the current company" });
|
||||
return;
|
||||
}
|
||||
const messageId = this.db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: token.agentId,
|
||||
toAgentId: msg.toAgentId,
|
||||
channel: msg.channel,
|
||||
channelTargetId: msg.channelTargetId,
|
||||
subject: msg.subject,
|
||||
body: msg.body,
|
||||
priority: msg.priority,
|
||||
parentMessageId: msg.parentMessageId,
|
||||
attachmentsJson: msg.attachmentsJson,
|
||||
});
|
||||
|
||||
if (msg.toAgentId && msg.channel === "direct") {
|
||||
this.onWakeAgent(msg.toAgentId, companyId, "message");
|
||||
}
|
||||
|
||||
if (msg.channel === "department" && msg.channelTargetId) {
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const deptAgents = snapshot.agents.filter(
|
||||
(a) => a.department === msg.channelTargetId && a.companyId === companyId && a.id !== token.agentId && a.status !== "terminated",
|
||||
);
|
||||
for (const a of deptAgents) {
|
||||
this.onWakeAgent(a.id, companyId, "message");
|
||||
}
|
||||
}
|
||||
|
||||
// For company-wide messages, wake the CEO so leadership responds
|
||||
if (msg.channel === "company") {
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const ceo = snapshot.agents.find((a) => a.companyId === companyId && !a.reportsTo && a.status !== "terminated");
|
||||
if (ceo && ceo.id !== token.agentId) {
|
||||
this.onWakeAgent(ceo.id, companyId, "message");
|
||||
}
|
||||
}
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { id: messageId });
|
||||
this.json(res, 201, { id: dispatchResult.data.id });
|
||||
});
|
||||
|
||||
this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
|
||||
@@ -1515,14 +1491,21 @@ export class AgentApiServer {
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const channel = url.searchParams.get("channel") ?? undefined;
|
||||
const channelParam = url.searchParams.get("channel");
|
||||
const channelTargetId = url.searchParams.get("channelTargetId");
|
||||
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
|
||||
const limitStr = url.searchParams.get("limit");
|
||||
const limit = limitStr ? Math.min(Math.max(parseInt(limitStr, 10) || 50, 1), 200) : 50;
|
||||
const allowedChannels = new Set(["direct", "department", "company", "project", "incident"]);
|
||||
if (channelParam && !allowedChannels.has(channelParam)) {
|
||||
this.json(res, 400, { error: `Invalid channel filter: ${channelParam}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = this.db.listAgentMessages(companyId, {
|
||||
agentId: token.agentId,
|
||||
channel,
|
||||
channel: channelParam ?? undefined,
|
||||
channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined,
|
||||
unreadOnly,
|
||||
limit,
|
||||
});
|
||||
@@ -1540,16 +1523,17 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Message not found" });
|
||||
return;
|
||||
}
|
||||
const visibleToAgent = this.db.listAgentMessages(companyId, { agentId: token.agentId }).some((entry) => entry.id === message.id);
|
||||
const visibleToAgent = this.db.listAgentMessages(companyId, {
|
||||
agentId: token.agentId,
|
||||
messageId: message.id,
|
||||
limit: 1,
|
||||
}).some((entry) => entry.id === message.id);
|
||||
if (!visibleToAgent) {
|
||||
this.json(res, 403, { error: "This message is not visible to the current agent" });
|
||||
return;
|
||||
}
|
||||
if (message.channel === "direct" && message.toAgentId !== token.agentId) {
|
||||
this.json(res, 403, { error: "Only the direct recipient can mark this message as read" });
|
||||
return;
|
||||
}
|
||||
this.db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
@@ -1634,6 +1618,21 @@ export class AgentApiServer {
|
||||
|
||||
this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
|
||||
const targetAgentId = params.agentId!;
|
||||
const targetAgent = (() => {
|
||||
try {
|
||||
return this.db.getAgent(targetAgentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!targetAgent || targetAgent.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to wake this agent" });
|
||||
return;
|
||||
}
|
||||
const wakeSchema = z.object({
|
||||
reason: z.string().min(1).max(200).default("peer_request"),
|
||||
});
|
||||
@@ -1666,6 +1665,10 @@ export class AgentApiServer {
|
||||
this.json(res, 404, { error: "Agent not found or not in the same company" });
|
||||
return;
|
||||
}
|
||||
if (!canControlAgent(this.db, token.companyId, token.agentId, targetAgentId)) {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" });
|
||||
return;
|
||||
}
|
||||
const alreadyActive = this.db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
|
||||
const runId = this.onWakeAgent(targetAgentId, token.companyId, "peer_request");
|
||||
if (runId) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
const AUTO_APPROVAL_TYPES = new Set(["hire_agent", "approve_ceo_strategy"]);
|
||||
|
||||
export function shouldAutoApproveApproval(
|
||||
db: AppDatabase,
|
||||
companyId: string,
|
||||
approvalType: string,
|
||||
): boolean {
|
||||
if (!AUTO_APPROVAL_TYPES.has(approvalType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return db
|
||||
.listSnapshot()
|
||||
.companies
|
||||
.some((company) => company.id === companyId && company.autoApproveHires);
|
||||
}
|
||||
|
||||
export function resolveApprovalDecision({
|
||||
db,
|
||||
handleApprovalResolved,
|
||||
companyId,
|
||||
approvalId,
|
||||
state,
|
||||
decisionNote,
|
||||
detail,
|
||||
actor = "board",
|
||||
}: {
|
||||
db: AppDatabase;
|
||||
handleApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
companyId: string;
|
||||
approvalId: string;
|
||||
state: "approved" | "rejected" | "revision_requested";
|
||||
decisionNote?: string;
|
||||
detail?: string | null;
|
||||
actor?: string;
|
||||
}) {
|
||||
db.decideApproval({
|
||||
companyId,
|
||||
approvalId,
|
||||
state,
|
||||
decisionNote: decisionNote ?? "",
|
||||
});
|
||||
db.addActivity({
|
||||
companyId,
|
||||
actor,
|
||||
action: `approval.${state}`,
|
||||
entityType: "approval",
|
||||
entityId: approvalId,
|
||||
detail: detail ?? decisionNote ?? "",
|
||||
});
|
||||
handleApprovalResolved(approvalId, companyId, state);
|
||||
}
|
||||
+107
-54
@@ -7,6 +7,7 @@ import type {
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
} from "@shared/types";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
|
||||
const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
|
||||
"assign_task",
|
||||
@@ -198,7 +199,14 @@ export function createAutomationService({
|
||||
}
|
||||
|
||||
function executeAutomationAction(
|
||||
rule: { action: string; actionConfigJson: string; targetDepartment: string | null; trigger?: string },
|
||||
rule: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
action: string;
|
||||
actionConfigJson: string;
|
||||
targetDepartment: string | null;
|
||||
trigger?: string;
|
||||
},
|
||||
ctx: AutomationEventContext,
|
||||
) {
|
||||
const config = (() => {
|
||||
@@ -268,7 +276,11 @@ export function createAutomationService({
|
||||
const toAgentId = (config.toAgentId as string) ?? ctx.agentId ?? null;
|
||||
const fromAgentId = (config.fromAgentId as string) ?? ctx.agentId ?? toAgentId;
|
||||
if (!fromAgentId) break;
|
||||
const channel = (config.channel as string) ?? (toAgentId ? "direct" : "company");
|
||||
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 {
|
||||
@@ -278,23 +290,29 @@ export function createAutomationService({
|
||||
channelTargetId = null;
|
||||
}
|
||||
}
|
||||
const messageId = db.sendAgentMessage({
|
||||
companyId: ctx.companyId,
|
||||
fromAgentId,
|
||||
toAgentId: channel === "direct" ? toAgentId : null,
|
||||
channel,
|
||||
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 string) ?? "normal",
|
||||
});
|
||||
const message = db.getAgentMessage(messageId, ctx.companyId);
|
||||
if (message) {
|
||||
emitEvent({ type: "new-message", message });
|
||||
}
|
||||
publishDomainChanged();
|
||||
if (toAgentId && channel === "direct") {
|
||||
wakeAgentIfPossible(toAgentId, ctx.companyId, "message");
|
||||
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;
|
||||
}
|
||||
@@ -374,30 +392,55 @@ export function createAutomationService({
|
||||
? "report_failed"
|
||||
: "report_blocked";
|
||||
if (manager) {
|
||||
db.sendAgentMessage({
|
||||
companyId: ctx.companyId,
|
||||
fromAgentId: targetAgentId,
|
||||
toAgentId: manager.id,
|
||||
channel: "direct",
|
||||
subject: `Escalation from ${agentName}: ${taskTitle}`,
|
||||
body: escalationBody,
|
||||
priority: "urgent",
|
||||
});
|
||||
publishDomainChanged();
|
||||
wakeAgentIfPossible(manager.id, ctx.companyId, wakeTrigger);
|
||||
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.
|
||||
db.sendAgentMessage({
|
||||
companyId: ctx.companyId,
|
||||
fromAgentId: targetAgentId,
|
||||
toAgentId: null,
|
||||
channel: "company",
|
||||
subject: `Escalation: ${taskTitle}`,
|
||||
body: escalationBody,
|
||||
priority: "urgent",
|
||||
});
|
||||
publishDomainChanged();
|
||||
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;
|
||||
}
|
||||
@@ -407,20 +450,30 @@ export function createAutomationService({
|
||||
const departmentAgents = db.getIdleAgentsForDepartment(ctx.companyId, targetDepartment);
|
||||
const senderAgentId = ctx.agentId ?? departmentAgents[0]?.id;
|
||||
if (senderAgentId) {
|
||||
db.sendAgentMessage({
|
||||
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",
|
||||
});
|
||||
publishDomainChanged();
|
||||
}
|
||||
for (const agent of departmentAgents.slice(0, 5)) {
|
||||
wakeAgentIfPossible(agent.id, ctx.companyId, "message");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface BootstrapApplicationDependencies {
|
||||
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
||||
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null) => void;
|
||||
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
}, allowedOrigins: string[]) => AgentApiServer;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
seedDefaultAutomationRules: (companyId: string) => void;
|
||||
@@ -155,6 +156,7 @@ export async function bootstrapApplication({
|
||||
dispatchAutomation("document_created", { companyId, agentId: authorAgentId ?? undefined, projectId: projectId ?? undefined });
|
||||
},
|
||||
onAutoAssignTask: (taskId, companyId) => db.autoAssignTask(taskId, companyId),
|
||||
emitEvent,
|
||||
}, [isDev && devServerUrl ? new URL(devServerUrl).origin : null, "app://-"].filter((origin): origin is string => Boolean(origin)));
|
||||
|
||||
apiServerRef.current = apiServer;
|
||||
|
||||
@@ -25,6 +25,30 @@ export function createBrowserActionService({
|
||||
if (!action) {
|
||||
throw new Error(`Browser action not found: ${actionId}`);
|
||||
}
|
||||
if (action.status !== "queued") {
|
||||
throw new Error(`Browser action ${actionId} is ${action.status} and cannot be dispatched.`);
|
||||
}
|
||||
if (action.approvalId) {
|
||||
const approval = (() => {
|
||||
try {
|
||||
return db.getApprovalRecord(action.approvalId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!approval || approval.companyId !== companyId) {
|
||||
throw new Error(`Browser action approval ${action.approvalId} does not belong to company ${companyId}.`);
|
||||
}
|
||||
if (approval.state !== "approved") {
|
||||
db.updateBrowserAction(actionId, {
|
||||
status: "approval_required",
|
||||
resultSummary: "Browser action is still waiting for approval.",
|
||||
errorMessage: "Approval must be granted before execution.",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const account = db.getSocialAccount(action.socialAccountId, companyId);
|
||||
if (!account) {
|
||||
|
||||
+43
-17
@@ -327,45 +327,63 @@ export class BrowserManager {
|
||||
assertNotCancelled();
|
||||
|
||||
if (platform === "twitter") {
|
||||
// Use clipboard-based approach since Twitter's React editor ignores execCommand
|
||||
await win.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(content)});
|
||||
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
// Write content to clipboard and paste it — this triggers React's input handlers
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', ${JSON.stringify(content)});
|
||||
const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
||||
editor.dispatchEvent(pasteEvent);
|
||||
// Fallback: if paste didn't work, set innerHTML and dispatch input
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
if (!editor.textContent || editor.textContent.trim().length === 0) {
|
||||
editor.textContent = ${JSON.stringify(content)};
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(content)} }));
|
||||
}
|
||||
})()
|
||||
`);
|
||||
await this.delay(1000);
|
||||
await this.delay(1500);
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]');
|
||||
if (btn) btn.click();
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
})()
|
||||
`);
|
||||
await this.delay(3000);
|
||||
} else if (platform === "linkedin") {
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('button.share-box-feed-entry__trigger');
|
||||
const btn = document.querySelector('button.share-box-feed-entry__trigger, button.artdeco-button--muted');
|
||||
if (btn) btn.click();
|
||||
})()
|
||||
`);
|
||||
await this.delay(2000);
|
||||
await win.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const editor = document.querySelector('[role="textbox"][contenteditable="true"]');
|
||||
const editor = document.querySelector('[role="textbox"][contenteditable="true"], .ql-editor[contenteditable="true"]');
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(content)});
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', ${JSON.stringify(content)});
|
||||
const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
||||
editor.dispatchEvent(pasteEvent);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
if (!editor.textContent || editor.textContent.trim().length === 0) {
|
||||
editor.textContent = ${JSON.stringify(content)};
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
})()
|
||||
`);
|
||||
await this.delay(1000);
|
||||
await this.delay(1500);
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('button.share-actions__primary-action');
|
||||
if (btn) btn.click();
|
||||
const btn = document.querySelector('button.share-actions__primary-action, button.artdeco-button--primary[type="submit"]');
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
})()
|
||||
`);
|
||||
await this.delay(3000);
|
||||
@@ -386,18 +404,26 @@ export class BrowserManager {
|
||||
|
||||
await win.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"]');
|
||||
const editor = document.querySelector('[data-testid="tweetTextarea_0"], [role="textbox"][contenteditable="true"]');
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
document.execCommand('insertText', false, ${JSON.stringify(replyContent)});
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', ${JSON.stringify(replyContent)});
|
||||
const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
||||
editor.dispatchEvent(pasteEvent);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
if (!editor.textContent || editor.textContent.trim().length === 0) {
|
||||
editor.textContent = ${JSON.stringify(replyContent)};
|
||||
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(replyContent)} }));
|
||||
}
|
||||
}
|
||||
})()
|
||||
`);
|
||||
await this.delay(1000);
|
||||
await this.delay(1500);
|
||||
await win.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const btn = document.querySelector('[data-testid="tweetButton"], [data-testid="tweetButtonInline"], button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
})()
|
||||
`);
|
||||
await this.delay(3000);
|
||||
@@ -675,7 +701,7 @@ export class BrowserManager {
|
||||
|
||||
private getComposeUrl(platform: string): string {
|
||||
const urls: Record<string, string> = {
|
||||
twitter: "https://x.com/compose/post",
|
||||
twitter: "https://x.com/compose/tweet",
|
||||
linkedin: "https://www.linkedin.com/feed/",
|
||||
instagram: "https://www.instagram.com/",
|
||||
facebook: "https://www.facebook.com/",
|
||||
|
||||
+79
-3
@@ -787,6 +787,53 @@ export class AppDatabase {
|
||||
return id;
|
||||
}
|
||||
|
||||
createHireRequest(input: {
|
||||
companyId: string;
|
||||
requestedByAgentId?: string | null;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
department?: string | null;
|
||||
reportsTo?: string | null;
|
||||
connectorId: ConnectorId;
|
||||
workspaceId?: string | null;
|
||||
model?: string | null;
|
||||
metadataJson?: string;
|
||||
capabilities: string;
|
||||
budgetMonthlyUsd: number;
|
||||
payloadSummary: string;
|
||||
impactSummary: string;
|
||||
payloadJson?: string | null;
|
||||
}) {
|
||||
return this.transaction(() => {
|
||||
const agentId = this.saveAgent({
|
||||
companyId: input.companyId,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
department: input.department ?? null,
|
||||
status: "pending_approval",
|
||||
reportsTo: input.reportsTo ?? null,
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
});
|
||||
const approvalId = this.requestApproval({
|
||||
companyId: input.companyId,
|
||||
requestedByAgentId: input.requestedByAgentId ?? null,
|
||||
relatedAgentId: agentId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
payloadJson: input.payloadJson ?? null,
|
||||
});
|
||||
return { agentId, approvalId };
|
||||
});
|
||||
}
|
||||
|
||||
saveGoal(input: { id?: string | null; companyId: string; title: string; description: string; parentId?: string | null; ownerAgentId?: string | null; status: string }) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
@@ -882,6 +929,18 @@ export class AppDatabase {
|
||||
relatedAgentId = payload.agentId ?? null;
|
||||
} catch { /* ignore malformed payload */ }
|
||||
}
|
||||
if (input.type === "hire_agent") {
|
||||
if (!relatedAgentId) {
|
||||
throw new Error("hire_agent approvals require a linked pending agent.");
|
||||
}
|
||||
const agent = this.getAgent(relatedAgentId);
|
||||
if (agent.companyId !== input.companyId) {
|
||||
throw new Error(`Agent ${relatedAgentId} does not belong to company ${input.companyId}.`);
|
||||
}
|
||||
if (agent.status !== "pending_approval") {
|
||||
throw new Error(`hire_agent approvals can only target agents pending approval. Current status: ${agent.status}.`);
|
||||
}
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
"insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'pending', ?, ?)",
|
||||
@@ -923,14 +982,20 @@ export class AppDatabase {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} has no linked agent — cannot resolve hire target. Ensure relatedAgentId or payloadJson.agentId is set.`);
|
||||
}
|
||||
if (input.state === "approved") {
|
||||
this.db
|
||||
const result = this.db
|
||||
.prepare("update agents set status = 'idle', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
|
||||
.run(ts, agentId, approval.companyId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} could not activate agent ${agentId}; the agent is missing or no longer pending approval.`);
|
||||
}
|
||||
}
|
||||
if (input.state === "rejected") {
|
||||
this.db
|
||||
const result = this.db
|
||||
.prepare("update agents set status = 'terminated', updated_at = ? where id = ? and company_id = ? and status = 'pending_approval'")
|
||||
.run(ts, agentId, approval.companyId);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`hire_agent approval ${input.approvalId} could not reject agent ${agentId}; the agent is missing or no longer pending approval.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2349,9 +2414,20 @@ export class AppDatabase {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
listAgentMessages(companyId: string, opts?: { agentId?: string; channel?: string; channelTargetId?: string | null; unreadOnly?: boolean; limit?: number }) {
|
||||
listAgentMessages(companyId: string, opts?: {
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
unreadOnly?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
let sql = "select id, company_id as companyId, from_agent_id as fromAgentId, to_agent_id as toAgentId, channel, channel_target_id as channelTargetId, subject, body, priority, read_at as readAt, parent_message_id as parentMessageId, attachments_json as attachmentsJson, created_at as createdAt from agent_messages where company_id = ?";
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
if (opts?.messageId) {
|
||||
sql += " and id = ?";
|
||||
params.push(opts.messageId);
|
||||
}
|
||||
if (opts?.channel) { sql += " and channel = ?"; params.push(opts.channel); }
|
||||
if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) {
|
||||
sql += " and channel_target_id = ?"; params.push(opts.channelTargetId);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
messageQuerySchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
|
||||
export function registerAgentCoordinationHandlers({
|
||||
registerHandle,
|
||||
@@ -29,41 +30,20 @@ export function registerAgentCoordinationHandlers({
|
||||
const parsed = agentMessageInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (parsed.channel === "direct" && !parsed.toAgentId) {
|
||||
return fail("DIRECT_MESSAGE_RECIPIENT_REQUIRED", "Direct messages require a recipient agent.");
|
||||
const dispatchResult = dispatchAgentMessage(
|
||||
{
|
||||
db,
|
||||
emitEvent,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
},
|
||||
parsed,
|
||||
"message",
|
||||
);
|
||||
if (!dispatchResult.ok) {
|
||||
return fail(dispatchResult.error.code, dispatchResult.error.message);
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.fromAgentId, table: "agents", label: "message sender" },
|
||||
{ id: parsed.toAgentId, table: "agents", label: "message recipient" },
|
||||
{ id: parsed.channel === "project" ? parsed.channelTargetId : null, table: "projects", label: "project channel" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const id = db.sendAgentMessage(parsed);
|
||||
const message = db.getAgentMessage(id, parsed.companyId);
|
||||
if (message) {
|
||||
emitEvent({ type: "new-message", message });
|
||||
}
|
||||
publishDomainChanged();
|
||||
if (parsed.toAgentId) {
|
||||
wakeAgentIfPossible(parsed.toAgentId, parsed.companyId, "message");
|
||||
}
|
||||
if (parsed.channel === "department" && parsed.channelTargetId) {
|
||||
const departmentAgents = db.getIdleAgentsForDepartment(parsed.companyId, parsed.channelTargetId);
|
||||
for (const agent of departmentAgents.filter((entry: { id: string }) => entry.id !== parsed.fromAgentId)) {
|
||||
wakeAgentIfPossible(agent.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
if (parsed.channel === "company") {
|
||||
const ceo = db.listSnapshot().agents.find((agent: { companyId: string; reportsTo: string | null; status: string }) =>
|
||||
agent.companyId === parsed.companyId && !agent.reportsTo && agent.status !== "terminated",
|
||||
);
|
||||
if (ceo && ceo.id !== parsed.fromAgentId) {
|
||||
wakeAgentIfPossible(ceo.id, parsed.companyId, "message");
|
||||
}
|
||||
}
|
||||
return ok(id);
|
||||
return ok(dispatchResult.data.id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.listAgentMessages, async (_event, payload) => {
|
||||
@@ -90,13 +70,27 @@ export function registerAgentCoordinationHandlers({
|
||||
const parsed = markMessageReadSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.getAgentMessage(parsed.messageId, parsed.companyId)) {
|
||||
const message = db.getAgentMessage(parsed.messageId, parsed.companyId);
|
||||
if (!message) {
|
||||
return fail("MESSAGE_NOT_FOUND", "Message not found in the current company.");
|
||||
}
|
||||
if (parsed.readerAgentId && !db.belongsToCompany("agents", parsed.readerAgentId, parsed.companyId)) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The selected message reader does not belong to the current company.");
|
||||
}
|
||||
if (parsed.readerAgentId) {
|
||||
const visibleToReader = db
|
||||
.listAgentMessages(parsed.companyId, {
|
||||
agentId: parsed.readerAgentId,
|
||||
messageId: parsed.messageId,
|
||||
limit: 1,
|
||||
})
|
||||
.some((entry) => entry.id === parsed.messageId);
|
||||
if (!visibleToReader) {
|
||||
return fail("MESSAGE_NOT_VISIBLE", "This message is not visible to the selected reader.");
|
||||
}
|
||||
}
|
||||
db.markMessageReadForReader(parsed.messageId, parsed.companyId, parsed.readerAgentId ?? null);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
startRunSchema,
|
||||
triggerHeartbeatSchema,
|
||||
} from "@shared/contracts";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreOperationHandlers({
|
||||
@@ -43,10 +44,16 @@ export function registerCoreOperationHandlers({
|
||||
if (companyError) return companyError;
|
||||
const invalidTask = parsed.relatedTaskId && !db.belongsToCompany("tasks", parsed.relatedTaskId, parsed.companyId);
|
||||
const invalidAgent = parsed.requestedByAgentId && !db.belongsToCompany("agents", parsed.requestedByAgentId, parsed.companyId);
|
||||
if (invalidTask || invalidAgent) {
|
||||
const invalidRelatedAgent = parsed.relatedAgentId && !db.belongsToCompany("agents", parsed.relatedAgentId, parsed.companyId);
|
||||
if (invalidTask || invalidAgent || invalidRelatedAgent) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", "The approval references data outside the current company.");
|
||||
}
|
||||
const id = db.requestApproval(parsed);
|
||||
let id: string;
|
||||
try {
|
||||
id = db.requestApproval(parsed);
|
||||
} catch (error) {
|
||||
return fail("APPROVAL_INVALID", error instanceof Error ? error.message : "Approval could not be created.");
|
||||
}
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.requestedByAgentId ?? "board",
|
||||
@@ -55,6 +62,20 @@ export function registerCoreOperationHandlers({
|
||||
entityId: id,
|
||||
detail: parsed.payloadSummary,
|
||||
});
|
||||
if (shouldAutoApproveApproval(db, parsed.companyId, parsed.type)) {
|
||||
resolveApprovalDecision({
|
||||
db,
|
||||
handleApprovalResolved,
|
||||
companyId: parsed.companyId,
|
||||
approvalId: id,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved by company policy",
|
||||
detail: "Auto-approved by company policy",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
}
|
||||
publishDomainChanged();
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
@@ -69,16 +90,15 @@ export function registerCoreOperationHandlers({
|
||||
if (!approval || approval.companyId !== parsed.companyId) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
db.decideApproval(parsed);
|
||||
db.addActivity({
|
||||
resolveApprovalDecision({
|
||||
db,
|
||||
handleApprovalResolved,
|
||||
companyId: approval.companyId,
|
||||
actor: "board",
|
||||
action: `approval.${parsed.state}`,
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
approvalId: approval.id,
|
||||
state: parsed.state,
|
||||
decisionNote: parsed.decisionNote,
|
||||
detail: parsed.decisionNote,
|
||||
});
|
||||
handleApprovalResolved(parsed.approvalId, approval.companyId, parsed.state);
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
@@ -86,8 +86,7 @@ export function registerCoreReadHandlers({
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.checkForUpdates, async () => {
|
||||
await checkForUpdates();
|
||||
return ok(true);
|
||||
return ok(await checkForUpdates());
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.getSidebarBadges, async (_event, companyId) => {
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
agentInputSchema,
|
||||
deleteEntitySchema,
|
||||
goalInputSchema,
|
||||
hireRequestSchema,
|
||||
projectInputSchema,
|
||||
taskInputSchema,
|
||||
workspaceInputSchema,
|
||||
} from "@shared/contracts";
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "./approval-policy";
|
||||
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
|
||||
|
||||
export function registerCoreWorkGraphHandlers({
|
||||
@@ -16,6 +18,7 @@ export function registerCoreWorkGraphHandlers({
|
||||
findInvalidReference,
|
||||
publishDomainChanged,
|
||||
wakeAgentIfPossible,
|
||||
handleApprovalResolved,
|
||||
handleTaskCreated,
|
||||
handleTaskStatusChange,
|
||||
}: Pick<
|
||||
@@ -26,6 +29,7 @@ export function registerCoreWorkGraphHandlers({
|
||||
| "findInvalidReference"
|
||||
| "publishDomainChanged"
|
||||
| "wakeAgentIfPossible"
|
||||
| "handleApprovalResolved"
|
||||
| "handleTaskCreated"
|
||||
| "handleTaskStatusChange"
|
||||
>) {
|
||||
@@ -56,6 +60,35 @@ export function registerCoreWorkGraphHandlers({
|
||||
const parsed = agentInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const isNew = !parsed.id;
|
||||
if (isNew) {
|
||||
return fail(
|
||||
"AGENT_CREATION_REQUIRES_HIRE_REQUEST",
|
||||
"Create new agents through the hiring workflow so approval, onboarding, and org state stay synchronized.",
|
||||
);
|
||||
}
|
||||
const existingAgent = (() => {
|
||||
try {
|
||||
return db.getAgent(parsed.id!);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!existingAgent || existingAgent.companyId !== parsed.companyId) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
if (existingAgent.status === "pending_approval") {
|
||||
return fail(
|
||||
"AGENT_APPROVAL_REQUIRED",
|
||||
"Pending hires must be resolved from Hiring or Approvals. Direct edits, deletes, and activation changes are blocked here.",
|
||||
);
|
||||
}
|
||||
if (parsed.status === "pending_approval") {
|
||||
return fail(
|
||||
"AGENT_STATUS_LOCKED",
|
||||
"Active agents cannot be moved back to pending approval by editing status directly.",
|
||||
);
|
||||
}
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
@@ -96,6 +129,93 @@ export function registerCoreWorkGraphHandlers({
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.requestHire, async (_event, payload) => {
|
||||
const parsed = hireRequestSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
const invalidReference = findInvalidReference(parsed.companyId, [
|
||||
{ id: parsed.requestedByAgentId, table: "agents", label: "requesting agent" },
|
||||
{ id: parsed.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: parsed.workspaceId, table: "workspaces", label: "workspace" },
|
||||
]);
|
||||
if (invalidReference) {
|
||||
return fail("INVALID_COMPANY_REFERENCE", `The selected ${invalidReference.label} does not belong to the current company.`);
|
||||
}
|
||||
const managerLabel = parsed.reportsTo
|
||||
? (() => {
|
||||
try {
|
||||
return db.getAgent(parsed.reportsTo).name;
|
||||
} catch {
|
||||
return parsed.reportsTo;
|
||||
}
|
||||
})()
|
||||
: "the company board";
|
||||
const payloadSummary = `Hire ${parsed.name} as ${parsed.role}`;
|
||||
const impactSummary = parsed.capabilities || `${parsed.name} will report to ${managerLabel}.`;
|
||||
let result: { agentId: string; approvalId: string };
|
||||
try {
|
||||
result = db.createHireRequest({
|
||||
companyId: parsed.companyId,
|
||||
requestedByAgentId: parsed.requestedByAgentId ?? null,
|
||||
name: parsed.name,
|
||||
role: parsed.role,
|
||||
title: parsed.title,
|
||||
department: parsed.department ?? null,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
connectorId: parsed.connectorId,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
model: parsed.model ?? null,
|
||||
metadataJson: parsed.metadataJson,
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
payloadSummary,
|
||||
impactSummary,
|
||||
payloadJson: JSON.stringify({
|
||||
requestedByAgentId: parsed.requestedByAgentId ?? null,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: parsed.connectorId,
|
||||
workspaceId: parsed.workspaceId ?? null,
|
||||
model: parsed.model ?? null,
|
||||
metadataJson: parsed.metadataJson ?? "{}",
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
reportsTo: parsed.reportsTo ?? null,
|
||||
department: parsed.department ?? null,
|
||||
title: parsed.title,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("HIRE_REQUEST_FAILED", error instanceof Error ? error.message : "Hire request could not be created.");
|
||||
}
|
||||
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: parsed.requestedByAgentId ?? "board",
|
||||
action: "agent.hire_requested",
|
||||
entityType: "agent",
|
||||
entityId: result.agentId,
|
||||
detail: parsed.name,
|
||||
});
|
||||
|
||||
let autoApproved = false;
|
||||
if (shouldAutoApproveApproval(db, parsed.companyId, "hire_agent")) {
|
||||
resolveApprovalDecision({
|
||||
db,
|
||||
handleApprovalResolved,
|
||||
companyId: parsed.companyId,
|
||||
approvalId: result.approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved by company policy",
|
||||
detail: "Auto-approved by company policy",
|
||||
});
|
||||
autoApproved = true;
|
||||
}
|
||||
|
||||
publishDomainChanged();
|
||||
return ok({ ...result, autoApproved });
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.saveGoal, async (_event, payload) => {
|
||||
const parsed = goalInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
@@ -196,13 +316,65 @@ export function registerCoreWorkGraphHandlers({
|
||||
return ok(id);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteAgent, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("agents", parsed.id, parsed.companyId)) {
|
||||
return fail("AGENT_NOT_FOUND", "Agent not found in the current company.");
|
||||
}
|
||||
const agent = db.getAgent(parsed.id);
|
||||
if (agent.status === "pending_approval") {
|
||||
return fail(
|
||||
"AGENT_APPROVAL_REQUIRED",
|
||||
"Pending hires cannot be deleted from the agent editor. Reject the hire request from Hiring or Approvals instead.",
|
||||
);
|
||||
}
|
||||
db.deleteAgent(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: "agent.deleted",
|
||||
entityType: "agent",
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.deleteApproval, async (_event, payload) => {
|
||||
const parsed = deleteEntitySchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
if (!db.belongsToCompany("approvals", parsed.id, parsed.companyId)) {
|
||||
return fail("APPROVAL_NOT_FOUND", "Approval not found in the current company.");
|
||||
}
|
||||
const approval = db.getApprovalRecord(parsed.id);
|
||||
if (approval.state === "pending" && approval.type === "hire_agent") {
|
||||
return fail(
|
||||
"APPROVAL_DELETE_BLOCKED",
|
||||
"Pending hire approvals cannot be deleted. Reject the hire request instead so the linked agent record stays consistent.",
|
||||
);
|
||||
}
|
||||
db.deleteApproval(parsed.id, parsed.companyId);
|
||||
db.addActivity({
|
||||
companyId: parsed.companyId,
|
||||
actor: "board",
|
||||
action: "approval.deleted",
|
||||
entityType: "approval",
|
||||
entityId: parsed.id,
|
||||
detail: "",
|
||||
});
|
||||
publishDomainChanged();
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
const deleteHandlers = [
|
||||
{ channel: IPC_CHANNELS.deleteAgent, table: "agents", entity: "agent", deleteFn: (id: string, companyId: string) => db.deleteAgent(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteTask, table: "tasks", entity: "task", deleteFn: (id: string, companyId: string) => db.deleteTask(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteGoal, table: "goals", entity: "goal", deleteFn: (id: string, companyId: string) => db.deleteGoal(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteProject, table: "projects", entity: "project", deleteFn: (id: string, companyId: string) => db.deleteProject(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteWorkspace, table: "workspaces", entity: "workspace", deleteFn: (id: string, companyId: string) => db.deleteWorkspace(id, companyId) },
|
||||
{ channel: IPC_CHANNELS.deleteApproval, table: "approvals", entity: "approval", deleteFn: (id: string, companyId: string) => db.deleteApproval(id, companyId) },
|
||||
] as const;
|
||||
|
||||
for (const { channel, table, entity, deleteFn } of deleteHandlers) {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { AgentMessageRecord, DesktopEvent, MessageChannel } from "@shared/types";
|
||||
|
||||
export interface AgentMessageDispatchInput {
|
||||
companyId: string;
|
||||
fromAgentId: string;
|
||||
toAgentId?: string | null;
|
||||
channel?: MessageChannel;
|
||||
channelTargetId?: string | null;
|
||||
subject?: string | null;
|
||||
body: string;
|
||||
priority?: "urgent" | "normal" | "low";
|
||||
parentMessageId?: string | null;
|
||||
attachmentsJson?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedAgentMessageInput {
|
||||
companyId: string;
|
||||
fromAgentId: string;
|
||||
toAgentId: string | null;
|
||||
channel: MessageChannel;
|
||||
channelTargetId: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
priority: "urgent" | "normal" | "low";
|
||||
parentMessageId: string | null;
|
||||
attachmentsJson: string;
|
||||
}
|
||||
|
||||
export interface MessageValidationError {
|
||||
code:
|
||||
| "DIRECT_MESSAGE_RECIPIENT_REQUIRED"
|
||||
| "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN"
|
||||
| "INVALID_COMPANY_REFERENCE"
|
||||
| "CHANNEL_TARGET_REQUIRED"
|
||||
| "PARENT_MESSAGE_NOT_FOUND";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DispatchAgentMessageDependencies {
|
||||
db: AppDatabase;
|
||||
publishDomainChanged?: () => void;
|
||||
wakeAgentIfPossible?: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
|
||||
emitEvent?: (event: DesktopEvent) => void;
|
||||
}
|
||||
|
||||
export interface DispatchAgentMessageResult {
|
||||
id: string;
|
||||
message: AgentMessageRecord | null;
|
||||
wakeTargets: string[];
|
||||
}
|
||||
|
||||
function normalizeString(value: string | null | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function normalizeChannelTargetId(channel: MessageChannel, channelTargetId: string | null | undefined): string | null {
|
||||
const target = normalizeString(channelTargetId);
|
||||
if (channel === "department" || channel === "project") {
|
||||
return target || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): NormalizedAgentMessageInput {
|
||||
const channel = input.channel ?? "direct";
|
||||
const subject = normalizeString(input.subject) || "(no subject)";
|
||||
const priority = input.priority ?? "normal";
|
||||
const toAgentId = normalizeString(input.toAgentId) || null;
|
||||
const channelTargetId = normalizeChannelTargetId(channel, input.channelTargetId);
|
||||
const parentMessageId = normalizeString(input.parentMessageId) || null;
|
||||
const attachmentsJson = normalizeString(input.attachmentsJson) || "[]";
|
||||
|
||||
return {
|
||||
companyId: input.companyId,
|
||||
fromAgentId: input.fromAgentId,
|
||||
toAgentId,
|
||||
channel,
|
||||
channelTargetId,
|
||||
subject,
|
||||
body: input.body,
|
||||
priority,
|
||||
parentMessageId,
|
||||
attachmentsJson,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAgentMessageInput(
|
||||
db: AppDatabase,
|
||||
input: NormalizedAgentMessageInput,
|
||||
): MessageValidationError | null {
|
||||
if (!db.belongsToCompany("agents", input.fromAgentId, input.companyId)) {
|
||||
return {
|
||||
code: "INVALID_COMPANY_REFERENCE",
|
||||
message: "The message sender does not belong to the current company.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.channel === "direct" && !input.toAgentId) {
|
||||
return {
|
||||
code: "DIRECT_MESSAGE_RECIPIENT_REQUIRED",
|
||||
message: "Direct messages require a recipient agent.",
|
||||
};
|
||||
}
|
||||
if (input.channel !== "direct" && input.toAgentId) {
|
||||
return {
|
||||
code: "NON_DIRECT_MESSAGE_RECIPIENT_FORBIDDEN",
|
||||
message: "Only direct messages may set a recipient agent.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.toAgentId && !db.belongsToCompany("agents", input.toAgentId, input.companyId)) {
|
||||
return {
|
||||
code: "INVALID_COMPANY_REFERENCE",
|
||||
message: "The message recipient does not belong to the current company.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.channel === "project") {
|
||||
if (!input.channelTargetId) {
|
||||
return {
|
||||
code: "CHANNEL_TARGET_REQUIRED",
|
||||
message: "Project messages require a project channel target.",
|
||||
};
|
||||
}
|
||||
if (!db.belongsToCompany("projects", input.channelTargetId, input.companyId)) {
|
||||
return {
|
||||
code: "INVALID_COMPANY_REFERENCE",
|
||||
message: "The selected project channel does not belong to the current company.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.channel === "department" && !input.channelTargetId) {
|
||||
return {
|
||||
code: "CHANNEL_TARGET_REQUIRED",
|
||||
message: "Department messages require a department channel target.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.parentMessageId) {
|
||||
const parentMessage = db.getAgentMessage(input.parentMessageId, input.companyId);
|
||||
if (!parentMessage) {
|
||||
return {
|
||||
code: "PARENT_MESSAGE_NOT_FOUND",
|
||||
message: "The parent message was not found in the current company.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProjectWakeTargets(
|
||||
input: NormalizedAgentMessageInput,
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>,
|
||||
): string[] {
|
||||
if (!input.channelTargetId) return [];
|
||||
const project = snapshot.projects.find(
|
||||
(entry) => entry.id === input.channelTargetId && entry.companyId === input.companyId,
|
||||
);
|
||||
const participantIds = new Set<string>();
|
||||
if (project?.leadAgentId) participantIds.add(project.leadAgentId);
|
||||
for (const task of snapshot.tasks) {
|
||||
if (task.companyId !== input.companyId) continue;
|
||||
if (task.projectId !== input.channelTargetId) continue;
|
||||
if (task.assigneeAgentId) participantIds.add(task.assigneeAgentId);
|
||||
}
|
||||
|
||||
return Array.from(participantIds).filter((agentId) => {
|
||||
if (agentId === input.fromAgentId) return false;
|
||||
const agent = snapshot.agents.find((entry) => entry.id === agentId);
|
||||
return Boolean(agent && agent.companyId === input.companyId && agent.status !== "terminated");
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMessageWakeTargets(
|
||||
db: AppDatabase,
|
||||
input: NormalizedAgentMessageInput,
|
||||
): string[] {
|
||||
const snapshot = db.listSnapshot();
|
||||
const wakeTargets = new Set<string>();
|
||||
|
||||
if (input.channel === "direct" && input.toAgentId && input.toAgentId !== input.fromAgentId) {
|
||||
wakeTargets.add(input.toAgentId);
|
||||
}
|
||||
|
||||
if (input.channel === "department" && input.channelTargetId) {
|
||||
for (const agent of snapshot.agents) {
|
||||
if (agent.companyId !== input.companyId) continue;
|
||||
if (agent.status === "terminated") continue;
|
||||
if (agent.id === input.fromAgentId) continue;
|
||||
if (agent.department === input.channelTargetId) {
|
||||
wakeTargets.add(agent.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.channel === "company") {
|
||||
const topLevel = snapshot.agents.filter(
|
||||
(agent) => agent.companyId === input.companyId && !agent.reportsTo && agent.status !== "terminated",
|
||||
);
|
||||
for (const agent of topLevel) {
|
||||
if (agent.id !== input.fromAgentId) {
|
||||
wakeTargets.add(agent.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.channel === "project") {
|
||||
for (const agentId of resolveProjectWakeTargets(input, snapshot)) {
|
||||
wakeTargets.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.channel === "incident") {
|
||||
for (const agent of snapshot.agents) {
|
||||
if (agent.companyId !== input.companyId) continue;
|
||||
if (agent.status === "terminated" || agent.status === "pending_approval") continue;
|
||||
if (agent.id === input.fromAgentId) continue;
|
||||
wakeTargets.add(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(wakeTargets);
|
||||
}
|
||||
|
||||
export function dispatchAgentMessage(
|
||||
deps: DispatchAgentMessageDependencies,
|
||||
rawInput: AgentMessageDispatchInput,
|
||||
wakeTrigger = "message",
|
||||
): { ok: true; data: DispatchAgentMessageResult } | { ok: false; error: MessageValidationError } {
|
||||
const normalized = normalizeAgentMessageInput(rawInput);
|
||||
const validationError = validateAgentMessageInput(deps.db, normalized);
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError };
|
||||
}
|
||||
|
||||
const persistedMessage = normalized.channel === "direct"
|
||||
? normalized
|
||||
: { ...normalized, toAgentId: null };
|
||||
const id = deps.db.sendAgentMessage(persistedMessage);
|
||||
const message = deps.db.getAgentMessage(id, normalized.companyId);
|
||||
if (message && deps.emitEvent) {
|
||||
deps.emitEvent({ type: "new-message", message });
|
||||
}
|
||||
deps.publishDomainChanged?.();
|
||||
|
||||
const wakeTargets = resolveMessageWakeTargets(deps.db, normalized);
|
||||
if (deps.wakeAgentIfPossible) {
|
||||
for (const wakeTarget of wakeTargets) {
|
||||
deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
id,
|
||||
message,
|
||||
wakeTargets,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -76,6 +76,7 @@ const activeByAgent = new Map<string, string>();
|
||||
const activeByWorkspace = new Map<string, string>();
|
||||
const queue: RunJob[] = [];
|
||||
const cancelledRuns = new Set<string>();
|
||||
const allowUnsafeConnectorBypass = process.env.AGENTCOMPANY_ALLOW_UNSAFE_CONNECTOR_BYPASS === "true";
|
||||
|
||||
function post(message: ChildMessage) {
|
||||
parentPort.postMessage(message);
|
||||
@@ -101,11 +102,11 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
|
||||
return {
|
||||
...baseConfig,
|
||||
modelReasoningEffort: job.thinkingEffort ?? "",
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
dangerouslyBypassApprovalsAndSandbox: allowUnsafeConnectorBypass,
|
||||
extraArgs: [
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"danger-full-access",
|
||||
allowUnsafeConnectorBypass ? "danger-full-access" : "workspace-write",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
],
|
||||
@@ -114,14 +115,14 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
|
||||
return {
|
||||
...baseConfig,
|
||||
effort: job.thinkingEffort ?? "",
|
||||
dangerouslySkipPermissions: true,
|
||||
dangerouslySkipPermissions: allowUnsafeConnectorBypass,
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
...baseConfig,
|
||||
yolo: true,
|
||||
approvalMode: "yolo",
|
||||
sandbox: false,
|
||||
sandbox: true,
|
||||
};
|
||||
case "kimi_local":
|
||||
return {
|
||||
|
||||
@@ -300,15 +300,19 @@ You have TWO communication channels — use both strategically:
|
||||
|
||||
**Agent Messaging** (for coordination, announcements, and requests):
|
||||
- Use POST /api/companies/{companyId}/messages to send messages
|
||||
- Direct messages (channel: "direct") WAKE the recipient immediately
|
||||
- Department messages (channel: "department", channelTargetId: "engineering") reach all dept members
|
||||
- Company-wide messages (channel: "company") reach everyone
|
||||
- Direct messages (channel: "direct", toAgentId: "{agentId}") WAKE the recipient immediately
|
||||
- Department messages (channel: "department", channelTargetId: "{departmentName}") reach all dept members
|
||||
- Company-wide messages (channel: "company") reach everyone and wake the CEO
|
||||
- Project messages (channel: "project", channelTargetId: "{projectId}") reach all agents assigned to tasks in that project
|
||||
- Incident messages (channel: "incident") are high-visibility alerts visible to all agents company-wide
|
||||
- USE MESSAGING WHEN:
|
||||
* You need another agent to take action NOW (send direct message)
|
||||
* You're announcing something department-wide (send to department channel)
|
||||
* You're sharing a status update or decision (send to company channel)
|
||||
* You need cross-department coordination (send to target department)
|
||||
* You want to discuss strategy or priorities (send to your manager directly)
|
||||
* You're coordinating within a specific project (send to project channel with projectId)
|
||||
* There is a critical outage or urgent issue (send to incident channel)
|
||||
|
||||
**IMPORTANT**: Check your messages EVERY wake cycle. Read unread messages first:
|
||||
GET /api/companies/{companyId}/messages?unreadOnly=true&limit=20
|
||||
@@ -603,16 +607,23 @@ GET /api/companies/${ctx.agent.companyId}/approvals?requestedByAgentId=${ctx.age
|
||||
### Send a Message to Another Agent
|
||||
POST /api/companies/${ctx.agent.companyId}/messages
|
||||
Body: {
|
||||
"toAgentId": "{targetAgentId}",
|
||||
"toAgentId": "{targetAgentId or null for non-direct channels}",
|
||||
"channel": "direct|department|company|project|incident",
|
||||
"channelTargetId": "{departmentName or null for direct}",
|
||||
"channelTargetId": "{see below}",
|
||||
"subject": "Message subject — clear and actionable",
|
||||
"body": "Your message content — include context so recipient can act",
|
||||
"priority": "urgent|normal|low",
|
||||
"parentMessageId": "{optionalReplyToMessageId}"
|
||||
}
|
||||
channelTargetId rules:
|
||||
- channel "direct": set channelTargetId to null, set toAgentId to recipient
|
||||
- channel "department": set channelTargetId to department name (e.g. "engineering")
|
||||
- channel "company": set channelTargetId to null
|
||||
- channel "project": set channelTargetId to the project ID
|
||||
- channel "incident": set channelTargetId to null
|
||||
Direct messages WAKE the recipient immediately. Use this when you need another agent to act.
|
||||
Department messages wake all members of that department.
|
||||
Company-wide messages wake the CEO. Incident messages are visible company-wide.
|
||||
Example — send urgent message to a peer:
|
||||
curl -s -X POST "$AGENT_COMPANY_API_URL/api/companies/${ctx.agent.companyId}/messages" \\
|
||||
-H "Authorization: Bearer $AGENT_COMPANY_API_KEY" \\
|
||||
@@ -773,12 +784,26 @@ Body: {
|
||||
### Check Action Status
|
||||
GET /api/companies/{companyId}/browser/actions?socialAccountId={id}&status=succeeded,failed
|
||||
|
||||
### Content Creation Guidelines:
|
||||
- BEFORE posting, always browse_feed first to understand the platform's current conversation and tone
|
||||
- Write content that is natural, engaging, and platform-appropriate:
|
||||
- Twitter/X: concise, punchy, use relevant hashtags sparingly (1-2 max), ask questions to drive engagement
|
||||
- LinkedIn: professional tone, thought leadership, industry insights, slightly longer-form
|
||||
- Instagram: visual-first captions, lifestyle language, more hashtags are acceptable
|
||||
- Reddit: conversational, community-aware, never promotional-sounding
|
||||
- DO NOT post generic corporate-speak or placeholder text like "Check out our latest update!"
|
||||
- Each post must have a clear purpose: inform, engage, promote, or respond
|
||||
- Vary content types: questions, insights, announcements, responses to trends, tips
|
||||
- Include a call-to-action when appropriate (reply, share, visit link)
|
||||
- Match the voice and brand of the company — review company goals and documents for brand guidance
|
||||
|
||||
### Important Notes:
|
||||
- Posts on accounts with "requires approval" will be held for Board review before publishing
|
||||
- Always browse the feed first to understand the current conversation before posting
|
||||
- Keep posts professional and on-brand
|
||||
- Check action results for success/failure before proceeding
|
||||
- Rate limit yourself: don't submit more than 5 actions per session
|
||||
- If an action fails, try again with a different approach — do not keep retrying the same action
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const ENV_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"DEVELOPER_DIR",
|
||||
"SDKROOT",
|
||||
"NODE_ENV",
|
||||
"NODE_OPTIONS",
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_COMMITTER_NAME",
|
||||
|
||||
Reference in new issue
Block a user