This commit is contained in:
ZacharyZhang-NY committed 2026-03-16 13:07:14 -04:00
1 parent 41eafc7c44
commit 54bcc77ef7
19 files changed
+11420 -64

No files matched your search

+428
View File
@@ -49,6 +49,7 @@ export class AgentApiServer {
private db: AppDatabase;
private onDomainChanged: () => void;
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => void;
private onBrowserAction: ((actionId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => void) {
this.db = db;
@@ -57,6 +58,10 @@ export class AgentApiServer {
this.registerRoutes();
}
setBrowserActionHandler(handler: (actionId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
this.onBrowserAction = handler;
}
getPort(): number {
return this.port;
}
@@ -960,7 +965,430 @@ export class AgentApiServer {
tasksDone: rTasks.filter((t) => t.status === "done").length,
};
}),
socialAccounts: snapshot.socialAccounts
.filter((a) => a.companyId === token.companyId)
.map((a) => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
status: a.status,
requireApproval: a.requireApproval,
})),
});
});
// ── Browser Automation API (for agents) ────────────────────────────
// POST /api/companies/:companyId/browser/action — Submit a browser action
this.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const actionSchema = z.object({
socialAccountId: z.string().uuid(),
actionType: z.enum(["post", "reply", "like", "follow", "browse_feed", "screenshot", "navigate", "search", "dm", "custom_script"]),
payload: z.record(z.string(), z.unknown()).default({}),
});
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
this.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
// Verify the social account belongs to this company
const snapshot = this.db.listSnapshot();
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
if (!account) {
this.json(res, 404, { error: "Social account not found" });
return;
}
// Check if approval is required for posting actions
const postActions = ["post", "reply", "dm"];
if (account.requireApproval && postActions.includes(input.actionType)) {
// Create an approval request
const approvalId = this.db.requestApproval({
companyId,
type: "dangerous_command",
relatedTaskId: null,
requestedByAgentId: token.agentId,
payloadSummary: `${input.actionType} on ${account.platform}/@${account.accountName}: ${String(input.payload.content || "").slice(0, 100)}`,
impactSummary: `Will ${input.actionType} on ${account.displayName || account.accountName}'s ${account.platform} account. Content will be publicly visible.`,
});
// Save the action in approval_required state
const actionId = this.db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "approval_required",
approvalId,
});
this.db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_pending_approval",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform} requires approval`,
});
this.onDomainChanged();
this.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" });
return;
}
// Save the action
const actionId = this.db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "queued",
});
this.db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_queued",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform}/@${account.accountName}`,
});
this.onDomainChanged();
// Execute the action asynchronously via the browser manager callback
if (this.onBrowserAction) {
this.onBrowserAction(actionId, input.socialAccountId, input.actionType, input.payload as Record<string, unknown>);
}
this.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
});
// GET /api/companies/:companyId/social-accounts — List social accounts
this.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = this.db.listSocialAccounts(params.companyId!);
// Don't expose credential secret IDs to agents
const sanitized = accounts.map(a => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
profileUrl: a.profileUrl,
status: a.status,
requireApproval: a.requireApproval,
}));
this.json(res, 200, sanitized);
});
// GET /api/companies/:companyId/browser/actions — List browser action history
this.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const socialAccountId = url.searchParams.get("socialAccountId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
const limit = Number(url.searchParams.get("limit") || 50);
const actions = this.db.listBrowserActions(params.companyId!, { socialAccountId, status, limit });
this.json(res, 200, actions);
});
// ── Documents API ─────────────────────────────────────────────────
// POST /api/companies/:companyId/documents — Create a document
this.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const id = this.db.saveDocument({
companyId: token.companyId,
type: String(data.type ?? "knowledge_article"),
title: String(data.title ?? "Untitled"),
content: String(data.content ?? ""),
authorAgentId: token.agentId,
reviewerAgentId: data.reviewerAgentId ? String(data.reviewerAgentId) : null,
projectId: data.projectId ? String(data.projectId) : null,
goalId: data.goalId ? String(data.goalId) : null,
status: String(data.status ?? "draft"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
});
this.onDomainChanged();
this.json(res, 201, { id });
});
// GET /api/companies/:companyId/documents — List documents
this.addRoute("GET", "/api/companies/:companyId/documents", (req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = this.db.listSnapshot();
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
this.json(res, 200, docs);
});
// ── Meetings API ──────────────────────────────────────────────────
// POST /api/companies/:companyId/meetings — Schedule a meeting
this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const id = this.db.saveMeeting({
companyId: token.companyId,
type: String(data.type ?? "department_sync"),
title: String(data.title ?? "Meeting"),
organizerAgentId: token.agentId,
participantAgentIds: data.participantAgentIds ? String(data.participantAgentIds) : "[]",
scheduledAt: String(data.scheduledAt ?? new Date().toISOString()),
durationMinutes: Number(data.durationMinutes ?? 30),
agendaJson: data.agendaJson ? String(data.agendaJson) : "[]",
status: "scheduled",
});
this.onDomainChanged();
this.json(res, 201, { id });
});
// GET /api/companies/:companyId/meetings — List meetings
this.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = this.db.listSnapshot();
const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId);
this.json(res, 200, meetings);
});
// PATCH /api/meetings/:meetingId — Update meeting (add notes, decisions, etc.)
this.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const data = body as Record<string, unknown>;
this.db.saveMeeting({
id: params.meetingId,
companyId: token.companyId,
type: data.type ? String(data.type) : "department_sync",
title: data.title ? String(data.title) : "Meeting",
notesJson: data.notesJson ? String(data.notesJson) : undefined,
decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined,
actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined,
status: data.status ? String(data.status) : undefined,
scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(),
});
this.onDomainChanged();
this.json(res, 200, { updated: true });
});
// ── Knowledge Base API ────────────────────────────────────────────
// POST /api/companies/:companyId/knowledge — Add knowledge entry
this.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const id = this.db.saveKnowledgeEntry({
companyId: token.companyId,
category: String(data.category ?? "lesson_learned"),
topic: String(data.topic ?? ""),
content: String(data.content ?? ""),
authorAgentId: token.agentId,
importance: String(data.importance ?? "medium"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null,
referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null,
});
this.onDomainChanged();
this.json(res, 201, { id });
});
// GET /api/companies/:companyId/knowledge — List knowledge entries
this.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = this.db.listSnapshot();
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
this.json(res, 200, entries);
});
// ── Agent Messaging API ──────────────────────────────────────────────
// POST /api/companies/:companyId/messages — Send a message to another agent
this.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
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 = messageSchema.safeParse(body);
if (!parsed.success) {
this.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const msg = parsed.data;
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,
});
// Wake the recipient if it's a direct message
if (msg.toAgentId && msg.channel === "direct") {
this.onWakeAgent(msg.toAgentId, companyId, "message");
}
// Wake all agents in the department if it's a department broadcast
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");
}
}
this.onDomainChanged();
this.json(res, 201, { id: messageId });
});
// GET /api/companies/:companyId/messages — List messages for the calling agent
this.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const channel = url.searchParams.get("channel") ?? undefined;
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 messages = this.db.listAgentMessages(companyId, {
agentId: token.agentId,
channel,
unreadOnly,
limit,
});
this.json(res, 200, messages);
});
// POST /api/companies/:companyId/messages/:messageId/read — Mark message as read
this.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
this.db.markMessageRead(params.messageId!, companyId);
this.json(res, 200, { ok: true });
});
// ── Sprint API ───────────────────────────────────────────────────────
// GET /api/companies/:companyId/sprints — List sprints
this.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = this.db.listSnapshot();
const sprints = snapshot.sprints.filter(s => s.companyId === companyId);
this.json(res, 200, sprints);
});
// ── Performance Metrics API ──────────────────────────────────────────
// GET /api/agents/me/metrics — Get the calling agent's performance metrics
this.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
const companyMetrics = this.db.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
if (!agentMetric) {
this.json(res, 404, { error: "Metrics not found" });
return;
}
this.json(res, 200, agentMetric);
});
// ── Org Chart / Agent Details API ────────────────────────────────────
// GET /api/agents/:agentId — Get a specific agent's details
this.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => {
const snapshot = this.db.listSnapshot();
const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId);
if (!agent) {
this.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = snapshot.agents.filter(a => a.reportsTo === agent.id && a.companyId === token.companyId);
const manager = agent.reportsTo ? snapshot.agents.find(a => a.id === agent.reportsTo) : null;
this.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
directReports: directReports.map(r => ({ id: r.id, name: r.name, role: r.role, status: r.status })),
});
});
// ── Wake / Heartbeat API ─────────────────────────────────────────────
// POST /api/agents/:agentId/wake — Request to wake another agent
this.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
const targetAgentId = params.agentId!;
const wakeSchema = z.object({
reason: z.string().min(1).max(200).default("peer_request"),
});
const parsed = wakeSchema.safeParse(body);
const reason = parsed.success ? parsed.data.reason : "peer_request";
this.onWakeAgent(targetAgentId, token.companyId, reason);
this.json(res, 200, { ok: true, message: `Wake requested for agent ${targetAgentId}` });
});
}
}