This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 12:03:50 -04:00
1 parent 66158e222e
commit 67c757f481
314 files changed
+37945 -22962

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { AgentApiServer, type ApiServerEventCallbacks } from "./server-core";
import { registerAgentRoutes } from "./routes-agents";
import { registerAgentControlRoutes } from "./routes-agent-control";
import { registerTaskRoutes } from "./routes-tasks";
import { registerWorkGraphRoutes } from "./routes-work-graph";
import { registerMessageRoutes } from "./routes-messages";
import { registerApprovalRoutes } from "./routes-approvals";
import { registerFileRoutes } from "./routes-files";
import { registerMiscRoutes } from "./routes-misc";
import { registerRunRoutes } from "./routes-runs";
import type { AppDatabase } from "../database";
export type { ApiServerEventCallbacks };
export { AgentApiServer };
/**
* Factory that creates and returns a fully-wired AgentApiServer with all
* domain route modules registered.
*/
export function createApiServer(
db: AppDatabase,
onDomainChanged: () => void,
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
callbacks?: Partial<ApiServerEventCallbacks>,
allowedOrigins?: Iterable<string>,
): AgentApiServer {
const server = new AgentApiServer(db, onDomainChanged, onWakeAgent, callbacks, allowedOrigins);
registerAgentRoutes(server);
registerAgentControlRoutes(server);
registerTaskRoutes(server);
registerWorkGraphRoutes(server);
registerMessageRoutes(server);
registerApprovalRoutes(server);
registerFileRoutes(server);
registerMiscRoutes(server);
registerRunRoutes(server);
return server;
}
+136
View File
@@ -0,0 +1,136 @@
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
export function registerAgentControlRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/agents/:agentId/wake", (_req, res, params, token, body) => {
const targetAgentId = params.agentId!;
const targetAgent = (() => {
try {
return db.getAgent(targetAgentId);
} catch {
return null;
}
})();
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.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"),
});
const parsed = wakeSchema.safeParse(body);
const reason = parsed.success ? parsed.data.reason : "peer_request";
const alreadyActive = db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, reason);
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Wake started for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Wake will be retried after the active run completes.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Agent could not be woken right now" });
});
server.addRoute("PATCH", "/api/agents/:agentId/heartbeat", (_req, res, params, token) => {
const targetAgentId = params.agentId!;
const snapshot = db.listSnapshot();
const targetAgent = snapshot.agents.find(
(a) => a.id === targetAgentId && a.companyId === token.companyId,
);
if (!targetAgent) {
server.json(res, 404, { error: "Agent not found or not in the same company" });
return;
}
if (!canControlAgent(db, token.companyId, token.agentId, targetAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to trigger heartbeat for this agent" });
return;
}
const alreadyActive = db.hasActiveRun({ companyId: token.companyId, agentId: targetAgentId });
const runId = onWakeAgent(targetAgentId, token.companyId, "peer_request");
if (runId) {
server.json(res, 200, { ok: true, runId, message: `Heartbeat triggered for agent ${targetAgentId}` });
return;
}
if (alreadyActive) {
server.json(res, 202, {
ok: true,
queued: true,
message: `Agent ${targetAgentId} is already running. Heartbeat will retry after the current run.`,
});
return;
}
server.json(res, 409, { ok: false, error: "Heartbeat could not be started for this agent" });
});
server.addRoute("GET", "/api/agents/:agentId/skills", (_req, res, params, token) => {
const agentId = params.agentId!;
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skillIds = db.listAgentSkillAssignments(agentId);
const skills = skillIds.map((sid) => db.getCompanySkill(token.companyId, sid)).filter(Boolean);
server.json(res, 200, skills.map((s) => ({
id: s!.id,
name: s!.name,
slug: s!.slug,
description: s!.description,
sourceType: s!.sourceType,
trustLevel: s!.trustLevel,
})));
});
server.addRoute("POST", "/api/agents/:agentId/skills", (_req, res, params, token, body) => {
const agentId = params.agentId!;
const parsed = z.object({ skillId: z.string().uuid() }).safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid body: skillId (uuid) required" });
return;
}
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
const skill = db.getCompanySkill(token.companyId, parsed.data.skillId);
if (!skill) {
server.json(res, 404, { error: "Skill not found in your company" });
return;
}
db.assignSkillToAgent(agentId, parsed.data.skillId);
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill "${skill.name}" assigned to agent ${agent.name}` });
});
server.addRoute("DELETE", "/api/agents/:agentId/skills/:skillId", (_req, res, params, token) => {
const agentId = params.agentId!;
const skillId = params.skillId!;
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === agentId && a.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
db.removeSkillFromAgent(agentId, skillId);
onDomainChanged();
server.json(res, 200, { ok: true, message: `Skill removed from agent ${agent.name}` });
});
}
+334
View File
@@ -0,0 +1,334 @@
import log from "electron-log/main.js";
import { hireRequestSchema } from "@shared/contracts";
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
import { canManageOrganization, canRequestHire } from "../agent-permissions";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
export function registerAgentRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
server.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => {
const agent = db.getAgent(token.agentId);
const chainOfCommand = db.getChainOfCommand(token.agentId);
const directReports = db.getDirectReports(token.agentId, token.companyId);
server.json(res, 200, { agent, chainOfCommand, directReports });
});
server.addRoute("GET", "/api/agents/me/standup", (_req, res, _params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((a) => a.id === token.agentId);
if (!agent) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const myTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === token.agentId && t.companyId === token.companyId);
const directReports = snapshot.agents.filter((a) => a.reportsTo === token.agentId && a.companyId === token.companyId);
const reportIds = new Set(directReports.map((a) => a.id));
const reportTasks = snapshot.tasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId) && t.companyId === token.companyId);
const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review");
const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked");
const myBlockedTasks = myTasks.filter((t) => t.status === "blocked");
const myInProgressTasks = myTasks.filter((t) => t.status === "in_progress");
const myTodoTasks = myTasks.filter((t) => t.status === "todo");
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
const recentComments = snapshot.comments
.filter((c) => {
const isMyTask = myTasks.some((t) => t.id === c.taskId);
const isReportTask = reportTasks.some((t) => t.id === c.taskId);
return (isMyTask || isReportTask) && new Date(c.createdAt).getTime() > twoHoursAgo;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 20);
const myPendingApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId,
);
const myResolvedApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId,
).slice(0, 5);
server.json(res, 200, {
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
summary: {
tasksAwaitingReview: tasksAwaitingMyReview.length,
blockedReportTasks: blockedReportTasks.length,
myBlockedTasks: myBlockedTasks.length,
myInProgress: myInProgressTasks.length,
myTodo: myTodoTasks.length,
pendingApprovals: myPendingApprovals.length,
directReports: directReports.length,
},
tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
priority: t.priority,
})),
blockedTasks: [...blockedReportTasks, ...myBlockedTasks].map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
priority: t.priority,
})),
recentComments: recentComments.map((c) => ({
taskId: c.taskId,
taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "",
author: c.authorName,
body: c.body.slice(0, 200),
createdAt: c.createdAt,
})),
pendingApprovals: myPendingApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
})),
resolvedApprovals: myResolvedApprovals.map((a) => ({
id: a.id,
type: a.type,
summary: a.payloadSummary,
state: a.state,
decisionNote: a.decisionNote,
})),
reportStatuses: directReports.map((r) => {
const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId);
return {
id: r.id,
name: r.name,
role: r.role,
status: r.status,
tasksInProgress: rTasks.filter((t) => t.status === "in_progress").length,
tasksInReview: rTasks.filter((t) => t.status === "in_review").length,
tasksBlocked: rTasks.filter((t) => t.status === "blocked").length,
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,
})),
});
});
server.addRoute("GET", "/api/agents/me/metrics", (_req, res, _params, token) => {
const companyMetrics = db.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find(m => m.agentId === token.agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId/metrics", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find((entry) => entry.id === params.agentId && entry.companyId === token.companyId);
if (!agent) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const companyMetrics = db.getCompanyMetrics(token.companyId, 30);
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === params.agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
}
server.json(res, 200, agentMetric);
});
server.addRoute("GET", "/api/agents/:agentId", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const agent = snapshot.agents.find(a => a.id === params.agentId && a.companyId === token.companyId);
if (!agent) {
server.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;
server.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 })),
});
});
server.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
server.json(res, 200, agents);
});
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
if (!canRequestHire(db, companyId, token.agentId)) {
server.json(res, 403, { error: "Current agent is not allowed to request hires" });
return;
}
const parsed = hireRequestSchema.safeParse({
companyId,
requestedByAgentId: token.agentId,
...(typeof body === "object" && body ? body : {}),
reportsTo:
typeof body === "object" && body && "reportsTo" in body
? (body as { reportsTo?: string | null }).reportsTo
: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid hire payload" });
return;
}
const input = parsed.data;
if (server.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;
}
if (
input.reportsTo &&
input.reportsTo !== token.agentId &&
!canManageOrganization(db, companyId, token.agentId) &&
!db.getChainOfCommand(input.reportsTo).some((agent) => agent.id === token.agentId)
) {
server.json(res, 403, { error: "Requested hire must report to you or a manager in your reporting chain." });
return;
}
const connectorRecord = (() => {
try {
return db.getConnector(input.connectorId);
} catch {
return null;
}
})();
if (!connectorRecord) {
server.json(res, 422, { error: "The selected connector does not exist." });
return;
}
const connectorIssue = getConnectorExecutionReadinessIssue(connectorRecord);
if (connectorIssue) {
server.json(res, 422, { error: connectorIssue });
return;
}
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
const existingAgents = db.listSnapshot().agents.filter(
(a) => a.companyId === companyId && a.status !== "terminated",
);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
);
if (duplicateByName) {
server.json(res, 409, { error: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.` });
return;
}
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
const pendingHires = db.listSnapshot().approvals.filter(
(a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending",
);
for (const pending of pendingHires) {
try {
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
server.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
return;
}
} catch { /* invalid payloadJson, skip */ }
}
const managerLabel = input.reportsTo
? (() => {
try {
return db.getAgent(input.reportsTo).name;
} catch {
return input.reportsTo;
}
})()
: "the company board";
let result: { agentId: string; approvalId: string };
try {
result = db.createHireRequest({
companyId,
requestedByAgentId: input.requestedByAgentId ?? token.agentId,
name: input.name,
role: input.role,
title: input.title,
department: input.department ?? null,
reportsTo: input.reportsTo ?? token.agentId,
connectorId: input.connectorId,
workspaceId: input.workspaceId ?? null,
model: input.model ?? null,
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) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
return;
}
db.addActivity({
companyId,
actor: token.agentId,
action: "agent.hire_requested",
entityType: "agent",
entityId: result.agentId,
detail: input.name,
});
let autoApproved = false;
if (shouldAutoApproveApproval(db, companyId, "hire_agent")) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
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`);
}
onDomainChanged();
server.json(res, 201, { ...result, autoApproved });
});
}
+212
View File
@@ -0,0 +1,212 @@
import { z } from "zod";
import log from "electron-log/main.js";
import { approvalInputSchema } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
const approvalListQuerySchema = z.object({
requestedByAgentId: z.string().uuid().optional(),
relatedTaskId: z.string().uuid().optional(),
status: z.string().optional(),
});
export function registerApprovalRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = approvalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
requestedByAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid approval payload" });
return;
}
const input = parsed.data;
if (server.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;
}
let id: string;
try {
id = 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) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Approval could not be created" });
return;
}
db.addActivity({
companyId,
actor: token.agentId,
action: "approval.created",
entityType: "approval",
entityId: id,
detail: input.payloadSummary,
});
// Notify thread integration about the new approval
callbacks.onApprovalCreated(id, companyId, input.relatedTaskId ?? null, input.type, input.payloadSummary ?? "");
if (shouldAutoApproveApproval(db, companyId, input.type)) {
resolveApprovalDecision({
db,
handleApprovalResolved: callbacks.onApprovalResolved,
companyId,
approvalId: id,
state: "approved",
decisionNote: "Auto-approved by company policy",
detail: "Auto-approved by company policy",
});
log.info(`[auto-approve] Auto-approved approval ${id}: ${input.payloadSummary}`);
} else {
callbacks.notify({
title: "Approval Required",
body: input.payloadSummary ?? `${input.type} approval pending`,
urgency: "critical",
navigation: { section: "approvals", entityId: id },
batchKey: "pending_approval",
});
}
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const parsedQuery = approvalListQuerySchema.safeParse({
requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined,
relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
});
if (!parsedQuery.success) {
server.json(res, 400, { error: "Invalid approval query" });
return;
}
const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null;
const snapshot = db.listSnapshot();
const approvals = snapshot.approvals.filter((approval) => {
if (approval.companyId !== companyId) return false;
if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false;
if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false;
if (statuses && !statuses.includes(approval.state)) return false;
return true;
});
server.json(res, 200, approvals);
});
server.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => {
const snapshot = db.listSnapshot();
const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId);
if (!approval) {
server.json(res, 404, { error: "Approval not found" });
return;
}
server.json(res, 200, approval);
});
server.addRoute("PATCH", "/api/approvals/:approvalId", (_req, res) => {
server.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
});
server.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const comments = db.listApprovalComments(params.approvalId!, approval.companyId);
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
if (!commentBody) { server.json(res, 400, { error: "Comment body is required" }); return; }
const commentId = db.addApprovalComment({
companyId: approval.companyId,
approvalId: approval.id,
authorAgentId: token.agentId ?? null,
body: commentBody,
});
// Wake mentioned agents in comment
const mentionedIds = db.findMentionedAgentIds(approval.companyId, commentBody);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId) {
onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned");
}
}
onDomainChanged();
server.json(res, 201, { id: commentId });
});
server.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
if (approval.state !== "revision_requested") {
server.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" });
return;
}
if (token.agentId !== approval.requestedByAgentId) {
server.json(res, 403, { error: "Only the requesting agent can resubmit this approval" });
return;
}
db.resubmitApproval(approval.id, approval.companyId);
onDomainChanged();
server.json(res, 200, { resubmitted: true });
});
server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const taskIds = db.listTasksForApproval(params.approvalId!);
server.json(res, 200, taskIds);
});
server.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { server.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
if (taskIds.length === 0) { server.json(res, 400, { error: "taskIds array is required" }); return; }
for (const taskId of taskIds) {
if (!db.belongsToCompany("tasks", taskId, token.companyId)) {
server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
onDomainChanged();
server.json(res, 200, { linked: taskIds.length });
});
}
+296
View File
@@ -0,0 +1,296 @@
import type { AgentApiServer } from "./server-core";
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/documents", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const reviewerAgentId = data.reviewerAgentId ? String(data.reviewerAgentId) : null;
const projectId = data.projectId ? String(data.projectId) : null;
const goalId = data.goalId ? String(data.goalId) : null;
if (reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
if (projectId && !db.belongsToCompany("projects", projectId, token.companyId)) {
server.json(res, 400, { error: "Project does not belong to the current company" });
return;
}
if (goalId && !db.belongsToCompany("goals", goalId, token.companyId)) {
server.json(res, 400, { error: "Goal does not belong to the current company" });
return;
}
const docType = String(data.type ?? "knowledge_article");
const docTitle = String(data.title ?? "Untitled");
const id = db.saveDocument({
companyId: token.companyId,
type: docType,
title: docTitle,
content: String(data.content ?? ""),
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
status: String(data.status ?? "draft"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
// Derive taskId from the agent's current run so thread integration
// and checkpoint engine can bind the document to its originating task.
let taskId: string | null = null;
if (token.runId) {
try {
const run = db.getRun(token.runId);
if (run.companyId === token.companyId) {
taskId = run.taskId;
}
} catch { /* run may have finished or been deleted */ }
}
// Fire document creation automation rules
callbacks.onDocumentCreated(token.companyId, token.agentId, projectId, id, docTitle, docType, taskId);
// Wake document reviewer if specified
if (reviewerAgentId) {
onWakeAgent(reviewerAgentId, token.companyId, "assignment");
}
// Deliverable review gate: check at document creation time (most reliable).
// In ai_cofounder mode, checkpoint-engine handles document reviews via the
// onDocumentCreated callback, so skip the direct approval here to avoid duplicates.
const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]);
if (IMPORTANT_DOC_TYPES.has(docType)) {
try {
const company = db.listSnapshot().companies.find((c) => c.id === token.companyId);
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
if (!db.hasPendingDeliverableReviews(token.companyId)) {
db.requestApproval({
companyId: token.companyId,
relatedTaskId: taskId,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
});
try {
const agent = db.getAgent(token.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
onDomainChanged();
}
}
} catch { /* deliverable review gate is best-effort */ }
}
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/documents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
server.json(res, 200, docs);
});
server.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const documentId = params.documentId;
if (!documentId) {
server.json(res, 400, { error: "Missing documentId" });
return;
}
const snapshot = db.listSnapshot();
const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId);
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
const data = body as Record<string, unknown>;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
: doc.reviewerAgentId;
if (data.reviewerAgentId !== undefined && reviewerAgentId && !db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
server.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
// Conflict detection: if baseRevisionId provided, verify it matches current
if (typeof data.baseRevisionId === "string") {
const currentRevId = db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
return;
}
}
if (data.status !== undefined) {
const newStatus = String(data.status);
if (!VALID_STATUSES.has(newStatus)) {
server.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
}
// Content update → create revision for audit trail
let updatedVersion = doc.version;
if (data.content !== undefined) {
const newContent = String(data.content);
const revision = db.createDocumentRevision({
documentId,
companyId: token.companyId,
body: newContent,
changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null,
createdByAgentId: token.agentId ?? null,
});
updatedVersion = revision.revisionNumber;
}
// Field updates (title, reviewer, tags, etc.)
if (data.status !== undefined) {
db.updateDocumentStatus(documentId, token.companyId, String(data.status));
}
db.saveDocument({
id: documentId,
companyId: token.companyId,
type: data.type ? String(data.type) : doc.type,
title: data.title ? String(data.title) : doc.title,
content: data.content !== undefined ? String(data.content) : doc.content,
authorAgentId: doc.authorAgentId,
reviewerAgentId,
projectId: doc.projectId,
goalId: doc.goalId,
version: updatedVersion,
status: data.status ? String(data.status) : doc.status,
tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson,
sourceRunId: data.content !== undefined ? (token.runId ?? null) : null,
});
onDomainChanged();
server.json(res, 200, { updated: true, latestRevisionId: db.getDocumentLatestRevisionId(documentId) });
});
server.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => {
if (params.companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); return; }
const documentId = params.documentId;
if (!documentId) { server.json(res, 400, { error: "Missing documentId" }); return; }
const revisions = db.listDocumentRevisions(documentId, token.companyId);
server.json(res, 200, revisions);
});
// ── Work Products ──
server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const products = db.listWorkProductsForTask(task.id, task.companyId);
server.json(res, 200, products);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const data = body as Record<string, unknown>;
if (!data.type || !data.provider || !data.title) {
server.json(res, 400, { error: "type, provider, and title are required" }); return;
}
const id = db.createWorkProduct({
companyId: task.companyId,
taskId: task.id,
projectId: typeof data.projectId === "string" ? data.projectId : task.projectId,
type: String(data.type),
provider: String(data.provider),
externalId: typeof data.externalId === "string" ? data.externalId : null,
title: String(data.title),
url: typeof data.url === "string" ? data.url : null,
status: typeof data.status === "string" ? data.status : "active",
reviewState: typeof data.reviewState === "string" ? data.reviewState : "none",
isPrimary: data.isPrimary === true,
summary: typeof data.summary === "string" ? data.summary : null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
const data = body as Record<string, unknown>;
db.updateWorkProduct(wpId, token.companyId, data);
onDomainChanged();
server.json(res, 200, { updated: true });
});
server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
const wpId = params.workProductId;
if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; }
db.deleteWorkProduct(wpId, token.companyId);
onDomainChanged();
server.json(res, 200, { deleted: true });
});
// ── Task-Document Links ──
server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const links = db.listDocumentsForTask(task.id, task.companyId);
server.json(res, 200, links);
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
server.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
}
const data = body as Record<string, unknown>;
const documentId = typeof data.documentId === "string" ? data.documentId : null;
if (!documentId) { server.json(res, 400, { error: "documentId is required" }); return; }
if (!db.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
const linkId = db.linkDocumentToTask(task.id, documentId, task.companyId, key);
onDomainChanged();
server.json(res, 200, { id: linkId, key });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { server.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
db.unlinkDocumentFromTask(task.id, key, task.companyId);
onDomainChanged();
server.json(res, 200, { unlinked: true });
} catch { server.json(res, 404, { error: "Task not found" }); }
});
}
+175
View File
@@ -0,0 +1,175 @@
import { agentMessageInputSchema } from "@shared/contracts";
import { dispatchAgentMessage } from "../message-dispatch";
import type { AgentApiServer } from "./server-core";
export function registerMessageRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
const emitEvent = server.emitEvent_;
server.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
const input = body as { body?: string } | null;
if (!input?.body || typeof input.body !== "string") {
server.json(res, 400, { error: "body is required" });
return;
}
if (input.body.length > 4000) {
server.json(res, 400, { error: "Comment body must not exceed 4000 characters" });
return;
}
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
const agent = db.getAgent(token.agentId);
const comment = db.addComment({
companyId: task.companyId,
taskId: task.id,
authorAgentId: token.agentId,
authorName: agent.name,
body: input.body,
});
onDomainChanged();
// Fire comment automation rules
server.callbacks_.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
wokenForComment.add(chain[0].id);
}
// @mention detection: wake any mentioned agents not already woken
const mentionedIds = db.findMentionedAgentIds(task.companyId, input.body);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) {
onWakeAgent(mentionedId, task.companyId, "comment_mentioned");
wokenForComment.add(mentionedId);
}
}
server.json(res, 201, comment);
} catch {
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
} catch {
server.json(res, 404, { error: "Task not found" });
return;
}
const comments = db.listComments(params.issueId!);
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = agentMessageInputSchema.safeParse({
...(body as Record<string, unknown>),
companyId,
fromAgentId: token.agentId,
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const dispatchResult = dispatchAgentMessage(
{
db,
emitEvent,
notify: server.callbacks_.notify,
publishDomainChanged: onDomainChanged,
wakeAgentIfPossible: (agentId, cid, trigger) => {
if (!agentId) return null;
return onWakeAgent(agentId, cid, trigger);
},
onHumanMessage: server.callbacks_.onHumanMessage,
},
parsed.data,
"message",
);
if (!dispatchResult.ok) {
server.json(res, 400, { error: dispatchResult.error.message, code: dispatchResult.error.code });
return;
}
server.json(res, 201, { id: dispatchResult.data.id });
});
server.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
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)) {
server.json(res, 400, { error: `Invalid channel filter: ${channelParam}` });
return;
}
const messages = db.listAgentMessages(companyId, {
agentId: token.agentId,
channel: channelParam ?? undefined,
channelTargetId: channelTargetId && channelTargetId.length > 0 ? channelTargetId : undefined,
unreadOnly,
limit,
});
server.json(res, 200, messages);
});
server.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const message = db.getAgentMessage(params.messageId!, companyId);
if (!message) {
server.json(res, 404, { error: "Message not found" });
return;
}
const visibleToAgent = db.listAgentMessages(companyId, {
agentId: token.agentId,
messageId: message.id,
limit: 1,
}).some((entry) => entry.id === message.id);
if (!visibleToAgent) {
server.json(res, 403, { error: "This message is not visible to the current agent" });
return;
}
db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
onDomainChanged();
server.json(res, 200, { ok: true });
});
}
+349
View File
@@ -0,0 +1,349 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
const socialDraftSchema = z.object({
socialAccountId: z.string().uuid(),
platform: z.string(),
content: z.string().min(1).max(10000),
mediaUrls: z.array(z.string()).default([]),
scheduledAt: z.string().datetime().optional(),
});
export function registerMiscRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.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"]),
payload: z.record(z.string(), z.unknown()).default({}),
});
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid browser action payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
const snapshot = db.listSnapshot();
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const postActions = ["post", "reply", "dm"];
if (account.requireApproval && postActions.includes(input.actionType)) {
const approvalId = 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.`,
});
const actionId = db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "approval_required",
approvalId,
});
db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_pending_approval",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform} requires approval`,
});
onDomainChanged();
server.json(res, 202, { id: actionId, status: "approval_required", approvalId, message: "Action requires Board approval before execution" });
return;
}
const actionId = db.saveBrowserAction({
companyId,
socialAccountId: input.socialAccountId,
agentId: token.agentId,
actionType: input.actionType,
payloadJson: JSON.stringify(input.payload),
status: "queued",
});
db.addActivity({
companyId,
actor: token.agentId,
action: "browser.action_queued",
entityType: "browser_action",
entityId: actionId,
detail: `${input.actionType} on ${account.platform}/@${account.accountName}`,
});
onDomainChanged();
const onBrowserAction = server.onBrowserAction_;
if (onBrowserAction) {
onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record<string, unknown>);
}
server.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" });
});
server.addRoute("GET", "/api/companies/:companyId/social-accounts", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = db.listSocialAccounts(params.companyId!);
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,
}));
server.json(res, 200, sanitized);
});
server.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = socialDraftSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid draft", details: parsed.error.issues });
return;
}
const account = db.getSocialAccount(parsed.data.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
}
const contentPreview = parsed.data.content.slice(0, 120) + (parsed.data.content.length > 120 ? "..." : "");
const approvalId = db.requestApproval({
companyId,
requestedByAgentId: token.agentId,
type: "social_post",
payloadSummary: contentPreview,
impactSummary: `Post to ${account.platform} via @${account.accountName}`,
payloadJson: JSON.stringify({
socialAccountId: parsed.data.socialAccountId,
platform: account.platform,
content: parsed.data.content,
mediaUrls: parsed.data.mediaUrls,
scheduledAt: parsed.data.scheduledAt ?? null,
}),
});
onDomainChanged();
server.json(res, 201, { approvalId, status: "pending_approval" });
});
server.addRoute("GET", "/api/companies/:companyId/browser/actions", (req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.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 = db.listBrowserActions(params.companyId!, { socialAccountId, status, limit });
server.json(res, 200, actions);
});
server.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]";
try {
const parsedParticipants = JSON.parse(participantAgentIds) as string[];
for (const agentId of parsedParticipants) {
if (typeof agentId !== "string" || !db.belongsToCompany("agents", agentId, token.companyId)) {
server.json(res, 400, { error: "Meeting participants must belong to the current company" });
return;
}
}
} catch {
server.json(res, 400, { error: "participantAgentIds must be a JSON array of agent ids" });
return;
}
const id = db.saveMeeting({
companyId: token.companyId,
type: String(data.type ?? "department_sync"),
title: String(data.title ?? "Meeting"),
organizerAgentId: token.agentId,
participantAgentIds,
scheduledAt: String(data.scheduledAt ?? new Date().toISOString()),
durationMinutes: Number(data.durationMinutes ?? 30),
agendaJson: data.agendaJson ? String(data.agendaJson) : "[]",
status: "scheduled",
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/meetings", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const meetings = snapshot.meetings.filter(m => m.companyId === token.companyId);
server.json(res, 200, meetings);
});
server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const snapshot = db.listSnapshot();
const existing = snapshot.meetings.find(m => m.id === params.meetingId && m.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Meeting not found" });
return;
}
const data = body as Record<string, unknown>;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>;
} catch {
server.json(res, 400, { error: "actionItemsJson must be valid JSON" });
return;
}
for (const item of actionItems) {
if (item.assigneeAgentId && !db.belongsToCompany("agents", item.assigneeAgentId, token.companyId)) {
server.json(res, 400, { error: "Meeting action item assignees must belong to the current company" });
return;
}
}
}
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(),
});
onDomainChanged();
// When action items are added, create tasks for them automatically
if (actionItems.length > 0) {
for (const item of actionItems) {
if (item.title && typeof item.title === "string") {
const meetingTaskValidation = validateTaskCreation(
{ title: item.title, description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}` },
false,
);
if (!meetingTaskValidation.valid) {
// Log but skip invalid meeting action items rather than failing the whole request
continue;
}
db.saveTask({
companyId: token.companyId,
title: item.title,
description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`,
assigneeAgentId: item.assigneeAgentId ?? null,
status: item.assigneeAgentId ? "todo" : "backlog",
priority: "medium",
});
if (item.assigneeAgentId) {
onWakeAgent(item.assigneeAgentId, token.companyId, "assignment");
}
}
}
}
server.json(res, 200, { updated: true });
});
server.addRoute("POST", "/api/companies/:companyId/knowledge", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
const id = 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,
sourceRunId: token.runId ?? null,
});
onDomainChanged();
server.json(res, 201, { id });
});
server.addRoute("GET", "/api/companies/:companyId/knowledge", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
server.json(res, 200, entries);
});
// ── Skill REST API endpoints ──────────────────────────────────────
server.addRoute("GET", "/api/companies/:companyId/skills", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const skills = db.listCompanySkills(companyId);
server.json(res, 200, skills.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
description: s.description,
sourceType: s.sourceType,
trustLevel: s.trustLevel,
createdAt: s.createdAt,
})));
});
server.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => {
const skill = db.getCompanySkill(token.companyId, params.skillId!);
if (!skill) {
server.json(res, 404, { error: "Skill not found" });
return;
}
server.json(res, 200, skill);
});
}
+92
View File
@@ -0,0 +1,92 @@
import type { TokenPayload } from "../agent-auth";
import type { AgentApiServer } from "./server-core";
const DEFAULT_EVENT_LIMIT = 500;
const MAX_EVENT_LIMIT = 5_000;
const EVENT_POLL_INTERVAL_MS = 500;
const SSE_HEARTBEAT_MS = 15_000;
function parsePositiveInt(value: string | null, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function resolveAuthorizedRun(server: AgentApiServer, token: TokenPayload, runId: string) {
const runState = server.db_.getRunAuthState(runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return null;
}
return runState;
}
function writeSse(res: Parameters<AgentApiServer["json"]>[0], event: string, data: unknown) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function registerRunRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
const runId = params.runId!;
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const offset = parsePositiveInt(url.searchParams.get("offset"), 0);
const limit = Math.min(parsePositiveInt(url.searchParams.get("limit"), DEFAULT_EVENT_LIMIT), MAX_EVENT_LIMIT);
const events = server.db_.listRunEvents(runId, token.companyId, { offset, limit });
server.json(res, 200, { events, nextOffset: offset + events.length });
});
server.addRoute("GET", "/api/runs/:runId/events/stream", (req, res, params, token) => {
const runId = params.runId!;
const runState = resolveAuthorizedRun(server, token, runId);
if (!runState) {
server.json(res, 404, { error: "Run not found for the current agent token" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
let cursor = parsePositiveInt(url.searchParams.get("after"), 0);
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
writeSse(res, "connected", { runId, after: cursor });
const flushPendingEvents = () => {
const events = server.db_
.listRunEvents(runId, token.companyId, { offset: 0, limit: MAX_EVENT_LIMIT })
.filter((event) => event.seq > cursor);
for (const event of events) {
writeSse(res, "run-event", event);
cursor = event.seq;
}
};
flushPendingEvents();
const pollTimer = setInterval(flushPendingEvents, EVENT_POLL_INTERVAL_MS);
const heartbeatTimer = setInterval(() => {
res.write(":keepalive\n\n");
}, SSE_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(pollTimer);
clearInterval(heartbeatTimer);
};
req.on("close", cleanup);
req.on("error", cleanup);
res.on("close", cleanup);
res.on("error", cleanup);
});
}
+370
View File
@@ -0,0 +1,370 @@
import { z } from "zod";
import { taskInputSchema } from "@shared/contracts";
import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
const taskUpdateSchema = z.object({
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
comment: z.string().min(1).max(4000).optional(),
title: z.string().min(2).max(160).optional(),
description: z.string().max(4000).optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
priority: z.enum(["critical", "high", "medium", "low"]).optional(),
});
const checkoutSchema = z.object({
agentId: z.string().uuid().optional(),
});
export function registerTaskRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
const assigneeAgentId = url.searchParams.get("assigneeAgentId");
const statusFilter = url.searchParams.get("status");
const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"];
if (assigneeAgentId) {
const tasks = db.listAgentTasks(assigneeAgentId, companyId, statuses);
server.json(res, 200, tasks);
} else {
const snapshot = db.listSnapshot();
const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status));
server.json(res, 200, tasks);
}
});
server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
server.json(res, 200, task);
} catch {
server.json(res, 404, { error: "Task not found" });
}
});
server.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => {
const parsedBody = checkoutSchema.safeParse(body);
if (!parsedBody.success) {
server.json(res, 400, { error: "Invalid checkout payload" });
return;
}
const agentId = parsedBody.data.agentId ?? token.agentId;
if (agentId !== token.agentId) {
server.json(res, 403, { error: "Agents can only checkout work for themselves" });
return;
}
let task;
try {
task = db.getTask(params.issueId!);
} catch {
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (task.assigneeAgentId && !canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to checkout this task" });
return;
}
const previousStatus = task.status;
const success = db.checkoutTask(params.issueId!, token.runId);
if (!success) {
server.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
return;
}
onDomainChanged();
// Checkout changes status from todo/backlog to in_progress — use actual previous status
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, "in_progress");
server.json(res, 200, { ok: true });
});
server.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => {
const parsedUpdate = taskUpdateSchema.safeParse(body);
if (!parsedUpdate.success) {
server.json(res, 400, { error: "Invalid task update payload" });
return;
}
const update = parsedUpdate.data;
try {
const task = db.getTask(params.issueId!);
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
}
if (!canUpdateTask(db, token.companyId, token.agentId, task)) {
server.json(res, 403, { error: "Current agent is not allowed to update this task" });
return;
}
const newAssignee = update.assigneeAgentId !== undefined ? update.assigneeAgentId : task.assigneeAgentId;
if (!canAssignTask(db, token.companyId, token.agentId, newAssignee ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
// Deliverable review gate: intercept agent trying to advance a flagged task to "done".
// This MUST succeed — silently skipping review would let unreviewed work advance.
if (update.status === "done" && task.requiresUserReview) {
const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"];
const placeholders = importantTypes.map(() => "?").join(", ");
const docs = db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders})
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
order by created_at desc limit 10`,
task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id,
);
const reviewerAgent = db.getAgent(token.agentId);
const docTitles = docs.map((d) => d.title).join(", ");
db.requestApproval({
companyId: task.companyId,
relatedTaskId: task.id,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280),
impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
db.setTaskRequiresUserReview(task.id, false);
if (task.assigneeAgentId) {
const prodAgent = db.getAgent(task.assigneeAgentId);
if (prodAgent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120);
}
}
db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." });
update.status = "in_review";
}
// Validate status transition
const effectiveStatus = update.status ?? task.status;
const VALID_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
if (update.status && !VALID_STATUSES.has(update.status)) {
server.json(res, 400, { error: `Invalid task status: ${update.status}` });
return;
}
// Apply status side-effect timestamps FIRST via advanceTaskStatus
// so started_at / completed_at / cancelled_at are set correctly.
// advanceTaskStatus checks previousStatus !== newStatus internally,
// so it must run before saveTask writes the new status.
if (update.status && update.status !== task.status) {
db.advanceTaskStatus(task.id, update.status);
}
const input: Record<string, unknown> = {
id: task.id,
companyId: task.companyId,
title: update.title ?? task.title,
description: update.description ?? task.description,
status: effectiveStatus,
priority: update.priority ?? task.priority,
projectId: task.projectId,
goalId: task.goalId,
parentId: task.parentId,
assigneeAgentId: newAssignee,
workspaceId: task.workspaceId,
};
db.saveTask(input as Parameters<typeof db.saveTask>[0]);
if (update.comment) {
const agent = db.getAgent(token.agentId);
db.addComment({
companyId: task.companyId,
taskId: task.id,
authorAgentId: token.agentId,
authorName: agent.name,
body: update.comment,
});
}
db.addActivity({
companyId: task.companyId,
actor: token.agentId,
action: `task.${update.status ? "status_changed" : "updated"}`,
entityType: "task",
entityId: task.id,
detail: update.comment ?? `Status → ${update.status ?? task.status}`,
});
onDomainChanged();
// Fire automation rules for status changes
const previousStatus = task.status;
const newStatus = update.status ?? task.status;
if (update.status && previousStatus !== newStatus) {
callbacks.onTaskStatusChanged(task.id, task.companyId, previousStatus, newStatus);
}
if (update.status && ["done", "in_review"].includes(update.status)) {
if (task.parentId) {
try {
const parentTask = db.getTask(task.parentId);
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
}
} catch { /* parent task may have been deleted */ }
} else {
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "subtask_completed");
}
}
}
if (update.status === "blocked") {
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0) {
onWakeAgent(chain[0].id, task.companyId, "report_blocked");
}
}
if (update.status && ["in_progress", "todo"].includes(update.status) &&
task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "assignment");
}
if (update.comment) {
// Fire comment automation rules
callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
wokenForComment.add(task.assigneeAgentId);
}
const chain = db.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
onWakeAgent(chain[0].id, task.companyId, "comment");
}
}
// Wake new assignee when task is reassigned
if (newAssignee && newAssignee !== task.assigneeAgentId && newAssignee !== token.agentId) {
onWakeAgent(newAssignee, task.companyId, "assignment");
}
server.json(res, 200, { ok: true });
} catch (error) {
if (error instanceof Error && error.message.includes("require an assignee")) {
server.json(res, 422, { error: error.message });
return;
}
if (error instanceof Error && error.message.includes("not found")) {
server.json(res, 404, { error: error.message });
return;
}
server.json(res, 500, { error: error instanceof Error ? error.message : "Task update failed" });
}
});
server.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = taskInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid task payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.assigneeAgentId, table: "agents", label: "assignee" },
{ id: input.parentId, table: "tasks", label: "parent task" },
{ id: input.projectId, table: "projects", label: "project" },
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
])) {
return;
}
if (!canAssignTask(db, companyId, token.agentId, input.assigneeAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
const isHeartbeat = input.title.startsWith("[Heartbeat]");
const taskValidation = validateTaskCreation({ title: input.title, description: input.description ?? "" }, isHeartbeat);
if (!taskValidation.valid) {
server.json(res, 422, { error: taskValidation.errors.join("; ") });
return;
}
let id: string;
try {
id = db.saveTask({
companyId,
title: input.title,
description: input.description ?? "",
assigneeAgentId: input.assigneeAgentId ?? null,
parentId: input.parentId ?? null,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
priority: input.priority,
status: input.status,
workspaceId: input.workspaceId ?? null,
});
} catch (error) {
server.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" });
return;
}
// Auto-infer task_type from assignee's department
if (input.assigneeAgentId) {
const assignee = db.getAgent(input.assigneeAgentId);
const inferredType = inferTaskType(assignee.department);
db.setTaskType(id, inferredType);
}
db.addActivity({
companyId,
actor: token.agentId,
action: "task.created",
entityType: "task",
entityId: id,
detail: input.title,
});
onDomainChanged();
// Fire the full task creation event chain (automation rules, auto-assign, etc.)
callbacks.onTaskCreated(id, companyId);
// Also wake the assignee directly if specified
if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) {
onWakeAgent(input.assigneeAgentId, companyId, "assignment");
}
// If no assignee specified, auto-assign
if (!input.assigneeAgentId) {
const assignedId = callbacks.onAutoAssignTask(id, companyId);
if (assignedId && assignedId !== token.agentId) {
onWakeAgent(assignedId, companyId, "assignment");
}
}
server.json(res, 201, { id });
});
}
+289
View File
@@ -0,0 +1,289 @@
import { z } from "zod";
import { goalInputSchema, projectInputSchema } from "@shared/contracts";
import { canManageGoal, canManageProject } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
export function registerWorkGraphRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
server.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = projectInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid project payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.goalId, table: "goals", label: "goal" },
{ id: input.leadAgentId, table: "agents", label: "lead agent" },
])) {
return;
}
if (!canManageProject(db, companyId, token.agentId, input.leadAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create or assign this project" });
return;
}
const id = db.saveProject({
companyId,
name: input.name,
description: input.description ?? "",
goalId: input.goalId ?? null,
leadAgentId: input.leadAgentId ?? null,
status: (input.status as "planned") ?? "planned",
targetDate: input.targetDate ?? null,
});
db.addActivity({
companyId,
actor: token.agentId,
action: "project.created",
entityType: "project",
entityId: id,
detail: input.name,
});
onDomainChanged();
if (input.leadAgentId && input.leadAgentId !== token.agentId) {
onWakeAgent(input.leadAgentId, companyId, "assignment");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => {
const projectId = params.projectId!;
const updateSchema = z.object({
name: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "completed", "cancelled"]).optional(),
leadAgentId: z.string().uuid().optional(),
goalId: z.string().uuid().nullable().optional(),
targetDate: z.string().nullable().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Project not found" });
return;
}
const nextLeadAgentId = parsed.data.leadAgentId ?? existing.leadAgentId ?? null;
if (!canManageProject(db, token.companyId, token.agentId, nextLeadAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this project" });
return;
}
try {
db.saveProject({
id: projectId,
companyId: token.companyId,
name: parsed.data.name ?? existing.name,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
leadAgentId: nextLeadAgentId,
goalId: parsed.data.goalId !== undefined ? parsed.data.goalId : (existing.goalId ?? null),
targetDate: parsed.data.targetDate !== undefined ? parsed.data.targetDate : (existing.targetDate ?? null),
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch {
server.json(res, 500, { error: "Failed to update project" });
}
});
server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const parsed = goalInputSchema.safeParse({
companyId,
...(typeof body === "object" && body ? body : {}),
});
if (!parsed.success) {
server.json(res, 400, { error: "Invalid goal payload" });
return;
}
const input = parsed.data;
if (server.companyRefError(res, companyId, [
{ id: input.parentId, table: "goals", label: "parent goal" },
{ id: input.ownerAgentId, table: "agents", label: "owner agent" },
])) {
return;
}
if (!canManageGoal(db, companyId, token.agentId, input.ownerAgentId ?? null)) {
server.json(res, 403, { error: "Current agent is not allowed to create this goal" });
return;
}
const id = db.saveGoal(input);
db.addActivity({
companyId,
actor: token.agentId,
action: "goal.created",
entityType: "goal",
entityId: id,
detail: input.title,
});
onDomainChanged();
if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") {
onWakeAgent(input.ownerAgentId, companyId, "goal_activated");
}
server.json(res, 201, { id });
});
server.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => {
const goalId = params.goalId!;
const updateSchema = z.object({
title: z.string().min(2).max(120).optional(),
description: z.string().max(2000).optional(),
status: z.enum(["planned", "active", "achieved", "cancelled"]).optional(),
ownerAgentId: z.string().uuid().optional(),
});
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() });
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId);
if (!existing) {
server.json(res, 404, { error: "Goal not found" });
return;
}
const nextOwnerAgentId = parsed.data.ownerAgentId ?? existing.ownerAgentId ?? null;
if (!canManageGoal(db, token.companyId, token.agentId, nextOwnerAgentId)) {
server.json(res, 403, { error: "Current agent is not allowed to update this goal" });
return;
}
try {
db.saveGoal({
id: goalId,
companyId: token.companyId,
title: parsed.data.title ?? existing.title,
description: parsed.data.description ?? existing.description,
status: parsed.data.status ?? existing.status,
ownerAgentId: nextOwnerAgentId,
});
onDomainChanged();
server.json(res, 200, { ok: true });
} catch {
server.json(res, 500, { error: "Failed to update goal" });
}
});
server.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
server.json(res, 200, goals);
});
server.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
server.json(res, 200, projects);
});
server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const sprints = snapshot.sprints.filter(s => s.companyId === companyId);
server.json(res, 200, sprints);
});
server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const data = body as Record<string, unknown>;
try {
const id = db.saveSprint({
companyId,
name: String(data.name ?? "Sprint"),
goal: String(data.goal ?? ""),
startDate: String(data.startDate ?? new Date().toISOString()),
endDate: String(data.endDate ?? new Date(Date.now() + 14 * 86400000).toISOString()),
status: String(data.status ?? "planning") as "planning" | "active" | "review" | "completed" | "cancelled",
retrospectiveNotes: String(data.retrospectiveNotes ?? ""),
velocityPoints: Number(data.velocityPoints ?? 0),
completedPoints: Number(data.completedPoints ?? 0),
});
onDomainChanged();
server.json(res, 201, { id });
} catch {
server.json(res, 400, { error: "Invalid sprint data" });
}
});
server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => {
const companyId = params.companyId!;
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === companyId);
const tasks = snapshot.tasks.filter((t) => t.companyId === companyId);
const recentActivity = snapshot.activity
.filter((a) => a.companyId === companyId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 20);
const tasksByStatus: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
}
server.json(res, 200, {
totalAgents: agents.length,
activeAgents: agents.filter((a) => a.status === "active" || a.status === "running").length,
totalTasks: tasks.length,
tasksByStatus,
recentActivity: recentActivity.map((a) => ({
id: a.id,
actor: a.actor,
action: a.action,
entityType: a.entityType,
entityId: a.entityId,
detail: a.detail,
createdAt: a.createdAt,
})),
});
});
}
+276
View File
@@ -0,0 +1,276 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import log from "electron-log/main.js";
import { validateAgentToken, type TokenPayload } from "../agent-auth";
import type { AppDatabase } from "../database";
import type { DesktopEvent, SectionId } from "@shared/types";
/**
* Decode an HTTP request body to a UTF-8 string, handling the common Windows
* encoding issue where Chinese/CJK characters arrive in GBK (CP936) instead
* of UTF-8. On Chinese Windows the system ANSI code page is GBK; tools like
* curl encode command-line arguments using that code page when stdout is piped
* (chcp 65001 does not help because there is no real console). We try strict
* UTF-8 first and, only if the bytes are invalid UTF-8, fall back to GBK.
*/
export function decodeBodyToUtf8(raw: Buffer): string {
if (raw.length === 0) return "";
try {
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
} catch {
// Bytes are not valid UTF-8 — most likely GBK from Chinese Windows.
try {
return new TextDecoder("gbk").decode(raw);
} catch {
// Last resort: lossy UTF-8 (replaces invalid bytes with U+FFFD).
return raw.toString("utf8");
}
}
}
export type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
params: Record<string, string>,
token: TokenPayload,
body: unknown,
) => void | Promise<void>;
export interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RequestHandler;
}
export interface ApiServerEventCallbacks {
onDomainChanged: () => void;
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
onTaskCreated: (taskId: string, companyId: string) => void;
onTaskStatusChanged: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
emitEvent: (event: DesktopEvent) => void;
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
}
export class AgentApiServer {
private routes: Route[] = [];
private server: ReturnType<typeof createServer> | null = null;
private port = 0;
private db: AppDatabase;
private onDomainChangedCb: () => void;
private onWakeAgentCb: (agentId: string, companyId: string, trigger: string) => string | null;
private emitEventCb: (event: DesktopEvent) => void;
private callbacks: ApiServerEventCallbacks;
private allowedOrigins: Set<string>;
private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null = null;
constructor(
db: AppDatabase,
onDomainChanged: () => void,
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null,
callbacks?: Partial<ApiServerEventCallbacks>,
allowedOrigins: Iterable<string> = ["app://-"],
) {
this.db = db;
this.onDomainChangedCb = onDomainChanged;
this.onWakeAgentCb = onWakeAgent;
this.emitEventCb = callbacks?.emitEvent ?? (() => {});
this.allowedOrigins = new Set(Array.from(allowedOrigins).filter(Boolean));
this.callbacks = {
onDomainChanged,
onWakeAgent,
onTaskCreated: callbacks?.onTaskCreated ?? (() => {}),
onTaskStatusChanged: callbacks?.onTaskStatusChanged ?? (() => {}),
onCommentPosted: callbacks?.onCommentPosted ?? (() => {}),
onApprovalCreated: callbacks?.onApprovalCreated ?? (() => {}),
onApprovalResolved: callbacks?.onApprovalResolved ?? (() => {}),
onDocumentCreated: callbacks?.onDocumentCreated ?? (() => {}),
onAutoAssignTask: callbacks?.onAutoAssignTask ?? (() => null),
onHumanMessage: callbacks?.onHumanMessage ?? (() => {}),
emitEvent: callbacks?.emitEvent ?? (() => {}),
notify: callbacks?.notify ?? (() => {}),
};
}
setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) {
this.onBrowserActionCb = handler;
}
getPort(): number {
return this.port;
}
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => void this.handleRequest(req, res));
this.server.listen(0, "127.0.0.1", () => {
const addr = this.server!.address();
this.port = typeof addr === "object" && addr ? addr.port : 0;
log.info(`[api] Agent API server listening on http://127.0.0.1:${this.port}`);
resolve(this.port);
});
this.server.on("error", reject);
});
}
stop() {
this.server?.close();
this.server = null;
}
addRoute(method: string, path: string, handler: RequestHandler) {
const paramNames: string[] = [];
const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => {
paramNames.push(name);
return "([^/]+)";
});
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
}
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
const origin = req.headers.origin;
if (origin) {
if (!this.allowedOrigins.has(origin)) {
this.json(res, 403, { error: "Origin not allowed" });
return;
}
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const pathname = url.pathname;
const method = (req.method ?? "GET").toUpperCase();
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
this.json(res, 401, { error: "Missing or invalid Authorization header" });
return;
}
const token = validateAgentToken(authHeader.slice(7));
if (!token) {
this.json(res, 401, { error: "Invalid or expired token" });
return;
}
const runHeader = req.headers["x-agent-company-run-id"];
const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader;
if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) {
this.json(res, 401, { error: "Missing or mismatched X-Agent-Company-Run-Id header" });
return;
}
const runState = this.db.getRunAuthState(token.runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
this.json(res, 403, { error: "Token references an unknown or mismatched run" });
return;
}
if (!["queued", "running"].includes(runState.status)) {
this.json(res, 401, { error: "Run token is no longer active" });
return;
}
for (const route of this.routes) {
if (route.method !== method) continue;
const match = pathname.match(route.pattern);
if (!match) continue;
const params: Record<string, string> = {};
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1]!;
});
let body: unknown = null;
if (["POST", "PATCH", "PUT"].includes(method)) {
try {
body = await this.readBody(req);
} catch (bodyErr) {
if (bodyErr instanceof Error && bodyErr.message === "Request body too large") {
this.json(res, 413, { error: "Request body too large (max 1 MB)" });
} else {
this.json(res, 400, { error: "Invalid JSON body" });
}
return;
}
}
try {
await route.handler(req, res, params, token, body);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[api] ${method} ${pathname}: ${message}`);
this.json(res, 500, { error: "Internal server error" });
}
return;
}
this.json(res, 404, { error: "Not found" });
}
private readBody(req: IncomingMessage): Promise<unknown> {
const MAX_BODY_BYTES = 1_048_576; // 1 MB
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let bytes = 0;
req.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks);
const data = decodeBodyToUtf8(raw);
resolve(data ? JSON.parse(data) : null);
}
catch { reject(new Error("Invalid JSON")); }
});
req.on("error", reject);
});
}
json(res: ServerResponse, status: number, data: unknown) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
companyRefError(
res: ServerResponse,
companyId: string,
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
) {
const invalid = refs.find((ref) => ref.id && !this.db.belongsToCompany(ref.table, ref.id, companyId));
if (!invalid) return false;
this.json(res, 422, { error: `${invalid.label} does not belong to the current company` });
return true;
}
// Accessors for route registrars
get db_(): AppDatabase { return this.db; }
get callbacks_(): ApiServerEventCallbacks { return this.callbacks; }
get onDomainChanged_(): () => void { return this.onDomainChangedCb; }
get onWakeAgent_(): (agentId: string, companyId: string, trigger: string) => string | null { return this.onWakeAgentCb; }
get emitEvent_(): (event: DesktopEvent) => void { return this.emitEventCb; }
get onBrowserAction_(): ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => void) | null {
return this.onBrowserActionCb;
}
get port_(): number { return this.port; }
}