This commit is contained in:
ZacharyZhang-NY committed 2026-04-05 20:14:48 -04:00
1 parent 09971cabc2
commit 9145572422
163 files changed
+3348 -2874

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ import { registerMessageRoutes } from "./routes-messages";
import { registerApprovalRoutes } from "./routes-approvals";
import { registerFileRoutes } from "./routes-files";
import { registerMiscRoutes } from "./routes-misc";
import { registerMeetingRoutes } from "./routes-meetings";
import { registerRunRoutes } from "./routes-runs";
import type { AppDatabase } from "../database";
@@ -33,6 +34,7 @@ export function createApiServer(
registerApprovalRoutes(server);
registerFileRoutes(server);
registerMiscRoutes(server);
registerMeetingRoutes(server);
registerRunRoutes(server);
return server;
}
+35 -31
View File
@@ -1,14 +1,14 @@
import { z } from "zod";
import { canControlAgent } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
export function registerAgentControlRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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 targetAgentId = requireParam(params, "agentId");
const targetAgent = (() => {
try {
return db.getAgent(targetAgentId);
@@ -47,12 +47,14 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const targetAgentId = requireParam(params, "agentId");
let targetAgent;
try {
targetAgent = db.getAgent(targetAgentId);
} catch {
targetAgent = null;
}
if (!targetAgent || targetAgent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found or not in the same company" });
return;
}
@@ -78,35 +80,37 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
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);
const skills = skillIds
.map((sid) => db.getCompanySkill(token.companyId, sid))
.filter((s): s is NonNullable<typeof s> => s != null);
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,
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 agentId = requireParam(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) {
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
@@ -121,11 +125,11 @@ export function registerAgentControlRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
const skillId = requireParam(params, "skillId");
let agent;
try { agent = db.getAgent(agentId); } catch { agent = null; }
if (!agent || agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found in your company" });
return;
}
+108 -53
View File
@@ -3,12 +3,44 @@ 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";
import { requireParam, type AgentApiServer } from "./server-core";
type AgentStandupRow = {
id: string; companyId: string; name: string; role: string;
status: string; reportsTo: string | null; department: string | null;
};
type TaskStandupRow = {
id: string; companyId: string; title: string;
assigneeAgentId: string | null; status: string; priority: string;
};
type ApprovalStandupRow = {
id: string; companyId: string; type: string;
requestedByAgentId: string; state: string;
payloadSummary: string | null; decisionNote: string | null;
};
type TaskCommentRow = {
id: string; taskId: string; authorName: string;
body: string; createdAt: string;
};
type AgentListRow = {
id: string; companyId: string; name: string; role: string;
title: string | null; department: string | null; status: string;
reportsTo: string | null; connectorId: string; workspaceId: string | null;
model: string | null; capabilities: string | null;
budgetMonthlyUsd: number | null; spentMonthlyUsd: number | null;
metadataJson: string; heartbeatEnabled: number;
heartbeatIntervalSec: number | null; lastHeartbeatAt: string | null;
createdAt: string; updatedAt: string;
};
export function registerAgentRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
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);
@@ -17,17 +49,29 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
const agent = db.getAgent(token.agentId);
if (!agent || agent.companyId !== token.companyId) {
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 companyAgents = db.queryAll<AgentStandupRow>(
"select id, company_id as companyId, name, role, status, reports_to as reportsTo, department from agents where company_id = ? order by created_at asc",
token.companyId,
);
const companyTasks = db.queryAll<TaskStandupRow>(
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
token.companyId,
);
const companyApprovals = db.queryAll<ApprovalStandupRow>(
"select id, company_id as companyId, type, requested_by_agent_id as requestedByAgentId, state, payload_summary as payloadSummary, decision_note as decisionNote from approvals where company_id = ? order by created_at desc",
token.companyId,
);
const myTasks = companyTasks.filter((t) => t.assigneeAgentId === token.agentId);
const directReports = companyAgents.filter((a) => a.reportsTo === token.agentId);
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 reportTasks = companyTasks.filter((t) => t.assigneeAgentId && reportIds.has(t.assigneeAgentId));
const tasksAwaitingMyReview = reportTasks.filter((t) => t.status === "in_review");
const blockedReportTasks = reportTasks.filter((t) => t.status === "blocked");
@@ -35,23 +79,26 @@ export function registerAgentRoutes(server: AgentApiServer) {
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 allRelevantTaskIds = new Set([...myTasks.map((t) => t.id), ...reportTasks.map((t) => t.id)]);
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const recentComments = allRelevantTaskIds.size > 0
? db.queryAll<TaskCommentRow>(
`select id, task_id as taskId, author_name as authorName, body, created_at as createdAt from task_comments where task_id in (${[...allRelevantTaskIds].map(() => "?").join(",")}) and created_at > ? order by created_at desc limit 20`,
...[...allRelevantTaskIds], twoHoursAgo,
)
: [];
const myPendingApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending" && a.companyId === token.companyId,
const myPendingApprovals = companyApprovals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state === "pending",
);
const myResolvedApprovals = snapshot.approvals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending" && a.companyId === token.companyId,
const myResolvedApprovals = companyApprovals.filter(
(a) => a.requestedByAgentId === token.agentId && a.state !== "pending",
).slice(0, 5);
const agentNameMap = new Map(companyAgents.map((a) => [a.id, a.name]));
const taskTitleMap = new Map(companyTasks.map((t) => [t.id, t.title]));
const socialAccounts = db.listSocialAccounts(token.companyId);
server.json(res, 200, {
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
summary: {
@@ -66,18 +113,18 @@ export function registerAgentRoutes(server: AgentApiServer) {
tasksAwaitingReview: tasksAwaitingMyReview.map((t) => ({
id: t.id,
title: t.title,
assignee: snapshot.agents.find((a) => a.id === t.assigneeAgentId)?.name ?? "Unknown",
assignee: agentNameMap.get(t.assigneeAgentId ?? "") ?? "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",
assignee: agentNameMap.get(t.assigneeAgentId ?? "") ?? "Unknown",
priority: t.priority,
})),
recentComments: recentComments.map((c) => ({
taskId: c.taskId,
taskTitle: snapshot.tasks.find((t) => t.id === c.taskId)?.title ?? "",
taskTitle: taskTitleMap.get(c.taskId) ?? "",
author: c.authorName,
body: c.body.slice(0, 200),
createdAt: c.createdAt,
@@ -96,7 +143,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
decisionNote: a.decisionNote,
})),
reportStatuses: directReports.map((r) => {
const rTasks = snapshot.tasks.filter((t) => t.assigneeAgentId === r.id && t.companyId === token.companyId);
const rTasks = companyTasks.filter((t) => t.assigneeAgentId === r.id);
return {
id: r.id,
name: r.name,
@@ -108,16 +155,14 @@ export function registerAgentRoutes(server: 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,
})),
socialAccounts: socialAccounts.map((a) => ({
id: a.id,
platform: a.platform,
accountName: a.accountName,
displayName: a.displayName,
status: a.status,
requireApproval: a.requireApproval,
})),
});
});
@@ -132,14 +177,14 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
const agentId = requireParam(params, "agentId");
const agent = db.getAgent(agentId);
if (!agent || agent.companyId !== token.companyId) {
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);
const agentMetric = companyMetrics.agentMetrics.find((metric) => metric.agentId === agentId);
if (!agentMetric) {
server.json(res, 404, { error: "Metrics not found" });
return;
@@ -148,14 +193,20 @@ export function registerAgentRoutes(server: AgentApiServer) {
});
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) {
let agent;
try {
agent = db.getAgent(requireParam(params, "agentId"));
} catch {
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;
if (agent.companyId !== token.companyId) {
server.json(res, 404, { error: "Agent not found" });
return;
}
const directReports = db.getDirectReports(agent.id, token.companyId);
const reportsTo = agent.reportsTo;
const manager = reportsTo ? (() => { try { return db.getAgent(reportsTo); } catch { return null; } })() : null;
server.json(res, 200, {
...agent,
manager: manager ? { id: manager.id, name: manager.name, role: manager.role } : null,
@@ -168,13 +219,15 @@ export function registerAgentRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
const agents = db.queryAll<AgentListRow>(
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
params.companyId,
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) }));
server.json(res, 200, agents);
});
server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -231,8 +284,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
}
// 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 existingAgents = db.queryAll<{ id: string; name: string; role: string }>(
"select id, name, role from agents where company_id = ? and status != 'terminated'",
companyId,
);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
@@ -242,8 +296,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
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",
const pendingHires = db.queryAll<{ payloadJson: string | null }>(
"select payload_json as payloadJson from approvals where company_id = ? and type = 'hire_agent' and state = 'pending'",
companyId,
);
for (const pending of pendingHires) {
try {
+45 -27
View File
@@ -1,8 +1,10 @@
import { z } from "zod";
import log from "electron-log/main.js";
import type { SQLInputValue } from "node:sqlite";
import { approvalInputSchema } from "@shared/contracts";
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
import type { AppDatabase } from "../database";
const approvalListQuerySchema = z.object({
requestedByAgentId: z.string().uuid().optional(),
@@ -10,14 +12,29 @@ const approvalListQuerySchema = z.object({
status: z.string().optional(),
});
type ApprovalRow = {
id: string; companyId: string; relatedTaskId: string | null; relatedAgentId: string | null;
requestedByAgentId: string | null; type: string; payloadSummary: string | null;
impactSummary: string | null; payloadJson: string | null; decisionNote: string | null;
state: string; createdAt: string; updatedAt: string;
};
function getApproval(db: AppDatabase, approvalId: string): ApprovalRow | null {
const rows = db.queryAll<ApprovalRow>(
"select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where id = ? limit 1",
approvalId,
);
return rows[0] ?? null;
}
export function registerApprovalRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -95,12 +112,12 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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 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,
@@ -111,21 +128,19 @@ export function registerApprovalRoutes(server: AgentApiServer) {
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;
});
let sql = "select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals where company_id = ?";
const sqlArgs: unknown[] = [companyId];
if (parsedQuery.data.requestedByAgentId) { sql += " and requested_by_agent_id = ?"; sqlArgs.push(parsedQuery.data.requestedByAgentId); }
if (parsedQuery.data.relatedTaskId) { sql += " and related_task_id = ?"; sqlArgs.push(parsedQuery.data.relatedTaskId); }
if (statuses && statuses.length > 0) { sql += ` and state in (${statuses.map(() => "?").join(", ")})`; sqlArgs.push(...statuses); }
sql += " order by created_at desc";
const approvals = db.queryAll<ApprovalRow>(sql, ...(sqlArgs as SQLInputValue[]));
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) {
const approval = getApproval(db, requireParam(params, "approvalId"));
if (!approval || approval.companyId !== token.companyId) {
server.json(res, 404, { error: "Approval not found" });
return;
}
@@ -137,15 +152,16 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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);
const comments = db.listApprovalComments(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);
const approval = getApproval(db, requireParam(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>;
@@ -169,7 +185,7 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approval = getApproval(db, requireParam(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") {
@@ -186,15 +202,17 @@ export function registerApprovalRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approval = db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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!);
const taskIds = db.listTasksForApproval(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);
const approvalId = requireParam(params, "approvalId");
const approval = getApproval(db, 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>;
@@ -205,7 +223,7 @@ export function registerApprovalRoutes(server: AgentApiServer) {
server.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
db.linkManyTasksToApproval(approvalId, taskIds, token.companyId, token.agentId ?? null);
onDomainChanged();
server.json(res, 200, { linked: taskIds.length });
});
+132 -57
View File
@@ -1,20 +1,89 @@
import type { AgentApiServer } from "./server-core";
import { z } from "zod";
import { requireParam, type AgentApiServer } from "./server-core";
type DocumentRow = {
id: string; companyId: string; type: string; title: string; content: string;
authorAgentId: string; reviewerAgentId: string | null; projectId: string | null;
goalId: string | null; parentDocId: string | null; version: number; status: string;
tagsJson: string; latestRevisionId: string | null; createdAt: string; updatedAt: string;
};
type DocumentLookupRow = {
id: string; companyId: string; type: string; title: string; content: string;
authorAgentId: string; reviewerAgentId: string | null; projectId: string | null;
goalId: string | null; version: number; status: string; tagsJson: string;
};
const createDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.string().optional(),
projectId: z.string().optional(),
goalId: z.string().optional(),
tagsJson: z.string().optional(),
});
const updateDocumentSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
status: z.string().optional(),
reviewerAgentId: z.union([z.string(), z.null()]).optional(),
tagsJson: z.string().optional(),
changeSummary: z.string().optional(),
baseRevisionId: z.string().optional(),
});
const createWorkProductSchema = z.object({
type: z.string(),
provider: z.string(),
title: z.string(),
projectId: z.string().optional(),
externalId: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadata: z.unknown().optional(),
});
const updateWorkProductSchema = z.object({
type: z.string().optional(),
provider: z.string().optional(),
externalId: z.string().optional(),
title: z.string().optional(),
url: z.string().optional(),
status: z.string().optional(),
reviewState: z.string().optional(),
isPrimary: z.boolean().optional(),
summary: z.string().optional(),
metadataJson: z.string().optional(),
}).strict();
const linkDocumentSchema = z.object({
documentId: z.string(),
});
export function registerFileRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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;
const parsed = createDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const reviewerAgentId = data.reviewerAgentId ?? null;
const projectId = data.projectId ?? null;
const goalId = 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;
@@ -27,19 +96,19 @@ export function registerFileRoutes(server: AgentApiServer) {
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 docType = data.type ?? "knowledge_article";
const docTitle = data.title ?? "Untitled";
const id = db.saveDocument({
companyId: token.companyId,
type: docType,
title: docTitle,
content: String(data.content ?? ""),
content: data.content ?? "",
authorAgentId: token.agentId,
reviewerAgentId,
projectId,
goalId,
status: String(data.status ?? "draft"),
tagsJson: data.tagsJson ? String(data.tagsJson) : "[]",
status: data.status ?? "draft",
tagsJson: data.tagsJson ?? "[]",
sourceRunId: token.runId ?? null,
});
onDomainChanged();
@@ -67,7 +136,8 @@ export function registerFileRoutes(server: AgentApiServer) {
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 companySettings = db.getCompanyApprovalSettings(token.companyId);
const company = companySettings ? { reviewDeliverables: companySettings.reviewDeliverables } : null;
const isCofounderMode = db.getCompanyExecutionMode(token.companyId) === "ai_cofounder";
if (company?.reviewDeliverables && !isCofounderMode) {
@@ -101,8 +171,10 @@ export function registerFileRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const docs = snapshot.documents.filter(d => d.companyId === token.companyId);
const docs = db.queryAll<DocumentRow>(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, parent_doc_id as parentDocId, version, status, tags_json as tagsJson, latest_revision_id as latestRevisionId, created_at as createdAt, updated_at as updatedAt from documents where company_id = ? order by updated_at desc",
token.companyId,
).map((d) => ({ ...d, latestRevisionId: d.latestRevisionId ?? null }));
server.json(res, 200, docs);
});
@@ -116,16 +188,21 @@ export function registerFileRoutes(server: AgentApiServer) {
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);
const docRows = db.queryAll<DocumentLookupRow>(
"select id, company_id as companyId, type, title, content, author_agent_id as authorAgentId, reviewer_agent_id as reviewerAgentId, project_id as projectId, goal_id as goalId, version, status, tags_json as tagsJson from documents where id = ? and company_id = ? limit 1",
documentId, token.companyId,
);
const doc = docRows[0] ?? null;
if (!doc) {
server.json(res, 404, { error: "Document not found in this company" });
return;
}
const data = body as Record<string, unknown>;
const parsed = updateDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
? (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" });
@@ -133,7 +210,7 @@ export function registerFileRoutes(server: AgentApiServer) {
}
// Conflict detection: if baseRevisionId provided, verify it matches current
if (typeof data.baseRevisionId === "string") {
if (data.baseRevisionId !== undefined) {
const currentRevId = db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
server.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
@@ -142,9 +219,8 @@ export function registerFileRoutes(server: AgentApiServer) {
}
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(", ")}` });
if (!VALID_STATUSES.has(data.status)) {
server.json(res, 400, { error: `Invalid status "${data.status}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
}
@@ -152,12 +228,11 @@ export function registerFileRoutes(server: AgentApiServer) {
// 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,
body: data.content,
changeSummary: data.changeSummary ?? null,
createdByAgentId: token.agentId ?? null,
});
updatedVersion = revision.revisionNumber;
@@ -165,21 +240,21 @@ export function registerFileRoutes(server: AgentApiServer) {
// Field updates (title, reviewer, tags, etc.)
if (data.status !== undefined) {
db.updateDocumentStatus(documentId, token.companyId, String(data.status));
db.updateDocumentStatus(documentId, token.companyId, 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,
type: data.type ?? doc.type,
title: data.title ?? doc.title,
content: 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,
status: data.status ?? doc.status,
tagsJson: data.tagsJson ?? doc.tagsJson,
sourceRunId: data.content !== undefined ? (token.runId ?? null) : null,
});
@@ -199,7 +274,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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);
@@ -208,25 +283,24 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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 parsed = createWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const data = parsed.data;
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",
projectId: data.projectId ?? task.projectId,
type: data.type,
provider: data.provider,
externalId: data.externalId ?? null,
title: data.title,
url: data.url ?? null,
status: data.status ?? "active",
reviewState: data.reviewState ?? "none",
isPrimary: data.isPrimary === true,
summary: typeof data.summary === "string" ? data.summary : null,
summary: data.summary ?? null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
@@ -238,8 +312,9 @@ export function registerFileRoutes(server: AgentApiServer) {
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);
const parsed = updateWorkProductSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
db.updateWorkProduct(wpId, token.companyId, parsed.data);
onDomainChanged();
server.json(res, 200, { updated: true });
});
@@ -256,7 +331,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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);
@@ -265,15 +340,15 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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; }
const parsed = linkDocumentSchema.safeParse(body);
if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; }
const documentId = parsed.data.documentId;
if (!db.belongsToCompany("documents", documentId, token.companyId)) {
server.json(res, 400, { error: "Document does not belong to this company" }); return;
}
@@ -285,7 +360,7 @@ export function registerFileRoutes(server: AgentApiServer) {
server.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(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);
+163
View File
@@ -0,0 +1,163 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
type MeetingRow = {
id: string; companyId: string; type: string; title: string;
organizerAgentId: string; participantAgentIds: string;
scheduledAt: string; durationMinutes: number;
agendaJson: string; notesJson: string | null;
decisionsJson: string | null; actionItemsJson: string | null;
status: string; createdAt: string; updatedAt: string;
};
export function registerMeetingRoutes(server: AgentApiServer) {
const db = server.db;
const onDomainChanged = server.onDomainChanged;
const onWakeAgent = server.onWakeAgent;
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 meetingCreateSchema = z.object({
type: z.string().default("department_sync"),
title: z.string().default("Meeting"),
participantAgentIds: z.string().default("[]"),
scheduledAt: z.string().default(new Date().toISOString()),
durationMinutes: z.number().default(30),
agendaJson: z.string().default("[]"),
});
const parsed = meetingCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
try {
const parsedParticipants = JSON.parse(input.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: input.type,
title: input.title,
organizerAgentId: token.agentId,
participantAgentIds: input.participantAgentIds,
scheduledAt: input.scheduledAt,
durationMinutes: input.durationMinutes,
agendaJson: input.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 meetings = db.queryAll<MeetingRow>(
"select id, company_id as companyId, type, title, organizer_agent_id as organizerAgentId, participant_agent_ids as participantAgentIds, scheduled_at as scheduledAt, duration_minutes as durationMinutes, agenda_json as agendaJson, notes_json as notesJson, decisions_json as decisionsJson, action_items_json as actionItemsJson, status, created_at as createdAt, updated_at as updatedAt from meetings where company_id = ? order by scheduled_at desc",
token.companyId,
);
server.json(res, 200, meetings);
});
server.addRoute("PATCH", "/api/meetings/:meetingId", (_req, res, params, token, body) => {
const rows = db.queryAll<MeetingRow>(
"select id, company_id as companyId, type, title, organizer_agent_id as organizerAgentId, participant_agent_ids as participantAgentIds, scheduled_at as scheduledAt, duration_minutes as durationMinutes, agenda_json as agendaJson, notes_json as notesJson, decisions_json as decisionsJson, action_items_json as actionItemsJson, status, created_at as createdAt, updated_at as updatedAt from meetings where id = ? and company_id = ? limit 1",
params.meetingId, token.companyId,
);
const existing = rows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Meeting not found" });
return;
}
const meetingUpdateSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
notesJson: z.string().optional(),
decisionsJson: z.string().optional(),
actionItemsJson: z.string().optional(),
status: z.string().optional(),
scheduledAt: z.string().optional(),
});
const parsed = meetingUpdateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
return;
}
const data = parsed.data;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(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 ?? "department_sync",
title: data.title ?? "Meeting",
notesJson: data.notesJson,
decisionsJson: data.decisionsJson,
actionItemsJson: data.actionItemsJson,
status: data.status,
scheduledAt: 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 });
});
}
+17 -17
View File
@@ -1,12 +1,12 @@
import { agentMessageInputSchema } from "@shared/contracts";
import { dispatchAgentMessage } from "../message-dispatch";
import type { AgentApiServer } from "./server-core";
import { requireParam, 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_;
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;
@@ -19,7 +19,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
return;
}
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -35,7 +35,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
onDomainChanged();
// Fire comment automation rules
server.callbacks_.onCommentPosted(task.id, task.companyId, token.agentId);
server.callbacks.onCommentPosted(task.id, task.companyId, token.agentId);
const wokenForComment = new Set<string>();
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
@@ -66,7 +66,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/issues/:issueId/comments", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -75,12 +75,12 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.json(res, 404, { error: "Task not found" });
return;
}
const comments = db.listComments(params.issueId!);
const comments = db.listComments(requireParam(params, "issueId"));
server.json(res, 200, comments);
});
server.addRoute("POST", "/api/companies/:companyId/messages", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -100,13 +100,13 @@ export function registerMessageRoutes(server: AgentApiServer) {
{
db,
emitEvent,
notify: server.callbacks_.notify,
notify: server.callbacks.notify,
publishDomainChanged: onDomainChanged,
wakeAgentIfPossible: (agentId, cid, trigger) => {
if (!agentId) return null;
return onWakeAgent(agentId, cid, trigger);
},
onHumanMessage: server.callbacks_.onHumanMessage,
onHumanMessage: server.callbacks.onHumanMessage,
},
parsed.data,
"message",
@@ -120,13 +120,13 @@ export function registerMessageRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/messages", (req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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 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";
@@ -149,12 +149,12 @@ export function registerMessageRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/messages/:messageId/read", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const message = db.getAgentMessage(params.messageId!, companyId);
const message = db.getAgentMessage(requireParam(params, "messageId"), companyId);
if (!message) {
server.json(res, 404, { error: "Message not found" });
return;
@@ -168,7 +168,7 @@ export function registerMessageRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "This message is not visible to the current agent" });
return;
}
db.markAgentMessageRead(params.messageId!, companyId, token.agentId);
db.markAgentMessageRead(requireParam(params, "messageId"), companyId, token.agentId);
onDomainChanged();
server.json(res, 200, { ok: true });
});
+23 -157
View File
@@ -1,6 +1,12 @@
import { z } from "zod";
import { validateTaskCreation } from "../task-quality-gates";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
type KnowledgeRow = {
id: string; companyId: string; category: string; topic: string;
content: string; authorAgentId: string; importance: string;
tagsJson: string; referencedEntityType: string | null;
referencedEntityId: string | null; createdAt: string; updatedAt: string;
};
const socialDraftSchema = z.object({
socialAccountId: z.string().uuid(),
@@ -11,12 +17,11 @@ const socialDraftSchema = z.object({
});
export function registerMiscRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
const db = server.db;
const onDomainChanged = server.onDomainChanged;
server.addRoute("POST", "/api/companies/:companyId/browser/action", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -36,8 +41,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
const input = parsed.data;
const snapshot = db.listSnapshot();
const account = snapshot.socialAccounts.find(a => a.id === input.socialAccountId && a.companyId === companyId);
const account = db.getSocialAccount(input.socialAccountId, companyId);
if (!account) {
server.json(res, 404, { error: "Social account not found" });
return;
@@ -98,7 +102,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
onDomainChanged();
const onBrowserAction = server.onBrowserAction_;
const onBrowserAction = server.onBrowserAction;
if (onBrowserAction) {
onBrowserAction(actionId, companyId);
}
@@ -111,7 +115,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const accounts = db.listSocialAccounts(params.companyId!);
const accounts = db.listSocialAccounts(requireParam(params, "companyId"));
const sanitized = accounts.map(a => ({
id: a.id,
platform: a.platform,
@@ -125,7 +129,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/social/draft", async (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -169,155 +173,15 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port_}`);
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 });
const actions = db.listBrowserActions(requireParam(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 meetingCreateSchema = z.object({
type: z.string().default("department_sync"),
title: z.string().default("Meeting"),
participantAgentIds: z.string().default("[]"),
scheduledAt: z.string().default(new Date().toISOString()),
durationMinutes: z.number().default(30),
agendaJson: z.string().default("[]"),
});
const parsed = meetingCreateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues });
return;
}
const input = parsed.data;
try {
const parsedParticipants = JSON.parse(input.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: input.type,
title: input.title,
organizerAgentId: token.agentId,
participantAgentIds: input.participantAgentIds,
scheduledAt: input.scheduledAt,
durationMinutes: input.durationMinutes,
agendaJson: input.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 meetingUpdateSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
notesJson: z.string().optional(),
decisionsJson: z.string().optional(),
actionItemsJson: z.string().optional(),
status: z.string().optional(),
scheduledAt: z.string().optional(),
});
const parsed = meetingUpdateSchema.safeParse(body);
if (!parsed.success) {
server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues });
return;
}
const data = parsed.data;
let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = [];
if (data.actionItemsJson) {
try {
actionItems = JSON.parse(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 ?? "department_sync",
title: data.title ?? "Meeting",
notesJson: data.notesJson,
decisionsJson: data.decisionsJson,
actionItemsJson: data.actionItemsJson,
status: data.status,
scheduledAt: 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" });
@@ -362,15 +226,17 @@ export function registerMiscRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const entries = snapshot.knowledgeBase.filter(k => k.companyId === token.companyId);
const entries = db.queryAll<KnowledgeRow>(
"select id, company_id as companyId, category, topic, content, author_agent_id as authorAgentId, importance, tags_json as tagsJson, referenced_entity_type as referencedEntityType, referenced_entity_id as referencedEntityId, created_at as createdAt, updated_at as updatedAt from knowledge_base where company_id = ? order by updated_at desc",
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -388,7 +254,7 @@ export function registerMiscRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/skills/:skillId", (_req, res, params, token) => {
const skill = db.getCompanySkill(token.companyId, params.skillId!);
const skill = db.getCompanySkill(token.companyId, requireParam(params, "skillId"));
if (!skill) {
server.json(res, 404, { error: "Skill not found" });
return;
+8 -8
View File
@@ -1,5 +1,5 @@
import type { TokenPayload } from "../agent-auth";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
const DEFAULT_EVENT_LIMIT = 500;
const MAX_EVENT_LIMIT = 5_000;
@@ -15,7 +15,7 @@ function parsePositiveInt(value: string | null, fallback: number) {
}
function resolveAuthorizedRun(server: AgentApiServer, token: TokenPayload, runId: string) {
const runState = server.db_.getRunAuthState(runId);
const runState = server.db.getRunAuthState(runId);
if (!runState || runState.companyId !== token.companyId || runState.agentId !== token.agentId) {
return null;
}
@@ -29,29 +29,29 @@ function writeSse(res: Parameters<AgentApiServer["json"]>[0], event: string, dat
export function registerRunRoutes(server: AgentApiServer) {
server.addRoute("GET", "/api/runs/:runId/events", (req, res, params, token) => {
const runId = params.runId!;
const runId = requireParam(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 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 });
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 runId = requireParam(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 url = new URL(req.url ?? "/", `http://127.0.0.1:${server.port}`);
let cursor = parsePositiveInt(url.searchParams.get("after"), 0);
res.writeHead(200, {
@@ -64,7 +64,7 @@ export function registerRunRoutes(server: AgentApiServer) {
writeSse(res, "connected", { runId, after: cursor });
const flushPendingEvents = () => {
const events = server.db_
const events = server.db
.listRunEvents(runId, token.companyId, { offset: 0, limit: MAX_EVENT_LIMIT })
.filter((event) => event.seq > cursor);
for (const event of events) {
+28 -14
View File
@@ -3,7 +3,18 @@ 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";
import { requireParam, type AgentApiServer } from "./server-core";
type TaskListRow = {
id: string; companyId: string; projectId: string | null; goalId: string | null;
parentId: string | null; title: string; description: string;
assigneeAgentId: string | null; workspaceId: string | null; status: string;
priority: string; activeRunId: string | null; sessionJson: string | null;
sessionDisplayId: string | null; costUsd: number | null;
requiresUserReview: number; startedAt: string | null;
completedAt: string | null; cancelledAt: string | null;
createdAt: string; updatedAt: string;
};
const taskUpdateSchema = z.object({
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
@@ -19,18 +30,18 @@ const checkoutSchema = z.object({
});
export function registerTaskRoutes(server: AgentApiServer) {
const db = server.db_;
const callbacks = server.callbacks_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(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 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"];
@@ -39,15 +50,18 @@ export function registerTaskRoutes(server: AgentApiServer) {
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));
const placeholders = statuses.map(() => "?").join(", ");
const tasks = db.queryAll<TaskListRow>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`,
companyId, ...statuses,
).map((t) => ({ ...t, requiresUserReview: Boolean(t.requiresUserReview), startedAt: t.startedAt ?? null, completedAt: t.completedAt ?? null, cancelledAt: t.cancelledAt ?? null }));
server.json(res, 200, tasks);
}
});
server.addRoute("GET", "/api/issues/:issueId", (_req, res, params, token) => {
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -71,7 +85,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
}
let task;
try {
task = db.getTask(params.issueId!);
task = db.getTask(requireParam(params, "issueId"));
} catch {
server.json(res, 404, { error: "Task not found" });
return;
@@ -85,7 +99,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
return;
}
const previousStatus = task.status;
const success = db.checkoutTask(params.issueId!, token.runId);
const success = db.checkoutTask(task.id, token.runId);
if (!success) {
server.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
return;
@@ -105,7 +119,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
const update = parsedUpdate.data;
try {
const task = db.getTask(params.issueId!);
const task = db.getTask(requireParam(params, "issueId"));
if (task.companyId !== token.companyId) {
server.json(res, 404, { error: "Task not found" });
return;
@@ -277,7 +291,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
+71 -28
View File
@@ -1,15 +1,41 @@
import { z } from "zod";
import { goalInputSchema, projectInputSchema } from "@shared/contracts";
import { canManageGoal, canManageProject } from "../agent-permissions";
import type { AgentApiServer } from "./server-core";
import { requireParam, type AgentApiServer } from "./server-core";
type ProjectRow = {
id: string; companyId: string; goalId: string | null; name: string;
description: string; leadAgentId: string | null; status: string;
targetDate: string | null; createdAt: string; updatedAt: string;
};
type GoalRow = {
id: string; companyId: string; title: string; description: string;
parentId: string | null; ownerAgentId: string | null; status: string;
createdAt: string; updatedAt: string;
};
type SprintRow = {
id: string; companyId: string; name: string; goal: string;
startDate: string; endDate: string; status: string;
retrospectiveNotes: string | null; velocityPoints: number;
completedPoints: number; createdAt: string; updatedAt: string;
};
type AgentStatusRow = { id: string; status: string };
type TaskStatusRow = { id: string; status: string };
type ActivityRow = {
id: string; companyId: string; actor: string; action: string;
entityType: string; entityId: string; detail: string | null; createdAt: string;
};
export function registerWorkGraphRoutes(server: AgentApiServer) {
const db = server.db_;
const onDomainChanged = server.onDomainChanged_;
const onWakeAgent = server.onWakeAgent_;
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!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -61,7 +87,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("PATCH", "/api/projects/:projectId", (_req, res, params, token, body) => {
const projectId = params.projectId!;
const projectId = requireParam(params, "projectId");
const updateSchema = z.object({
name: z.string().min(2).max(120).optional(),
@@ -78,8 +104,11 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.projects.find(p => p.id === projectId && p.companyId === token.companyId);
const projectRows = db.queryAll<ProjectRow>(
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where id = ? and company_id = ? limit 1",
projectId, token.companyId,
);
const existing = projectRows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Project not found" });
return;
@@ -109,7 +138,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -151,7 +180,7 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("PATCH", "/api/goals/:goalId", (_req, res, params, token, body) => {
const goalId = params.goalId!;
const goalId = requireParam(params, "goalId");
const updateSchema = z.object({
title: z.string().min(2).max(120).optional(),
@@ -166,8 +195,11 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
return;
}
const snapshot = db.listSnapshot();
const existing = snapshot.goals.find(g => g.id === goalId && g.companyId === token.companyId);
const goalRows = db.queryAll<GoalRow>(
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where id = ? and company_id = ? limit 1",
goalId, token.companyId,
);
const existing = goalRows[0] ?? null;
if (!existing) {
server.json(res, 404, { error: "Goal not found" });
return;
@@ -199,8 +231,10 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
const goals = db.queryAll<GoalRow>(
"select id, company_id as companyId, title, description, parent_id as parentId, owner_agent_id as ownerAgentId, status, created_at as createdAt, updated_at as updatedAt from goals where company_id = ? order by created_at asc",
params.companyId,
);
server.json(res, 200, goals);
});
@@ -209,24 +243,28 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
server.json(res, 403, { error: "Company mismatch" });
return;
}
const snapshot = db.listSnapshot();
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
const projects = db.queryAll<ProjectRow>(
"select id, company_id as companyId, goal_id as goalId, name, description, lead_agent_id as leadAgentId, status, target_date as targetDate, created_at as createdAt, updated_at as updatedAt from projects where company_id = ? order by created_at desc",
params.companyId,
);
server.json(res, 200, projects);
});
server.addRoute("GET", "/api/companies/:companyId/sprints", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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);
const sprints = db.queryAll<SprintRow>(
"select id, company_id as companyId, name, goal, start_date as startDate, end_date as endDate, status, retrospective_notes as retrospectiveNotes, velocity_points as velocityPoints, completed_points as completedPoints, created_at as createdAt, updated_at as updatedAt from sprints where company_id = ? order by start_date desc",
companyId,
);
server.json(res, 200, sprints);
});
server.addRoute("POST", "/api/companies/:companyId/sprints", (_req, res, params, token, body) => {
const companyId = params.companyId!;
const companyId = requireParam(params, "companyId");
if (companyId !== token.companyId) {
server.json(res, 403, { error: "Company mismatch" });
return;
@@ -252,18 +290,23 @@ export function registerWorkGraphRoutes(server: AgentApiServer) {
});
server.addRoute("GET", "/api/companies/:companyId/dashboard", (_req, res, params, token) => {
const companyId = params.companyId!;
const companyId = requireParam(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 agents = db.queryAll<AgentStatusRow>(
"select id, status from agents where company_id = ?",
companyId,
);
const tasks = db.queryAll<TaskStatusRow>(
"select id, status from tasks where company_id = ?",
companyId,
);
const recentActivity = db.queryAll<ActivityRow>(
"select id, company_id as companyId, actor, action, entity_type as entityType, entity_id as entityId, detail, created_at as createdAt from activity where company_id = ? order by created_at desc limit 20",
companyId,
);
const tasksByStatus: Record<string, number> = {};
for (const t of tasks) {
+53 -47
View File
@@ -27,6 +27,17 @@ export function decodeBodyToUtf8(raw: Buffer): string {
}
}
/**
* Extract a required route parameter or throw a 400-level error.
* The HTTP router guarantees all named params are present when a route
* matches, but TypeScript cannot prove this statically.
*/
export function requireParam(params: Record<string, string>, name: string): string {
const value = params[name];
if (value === undefined) throw Object.assign(new Error(`Missing route parameter: ${name}`), { statusCode: 400 });
return value;
}
export type RequestHandler = (
req: IncomingMessage,
res: ServerResponse,
@@ -58,16 +69,16 @@ export interface ApiServerEventCallbacks {
}
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) => void) | null = null;
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) => void) | null = null;
constructor(
db: AppDatabase,
@@ -76,12 +87,12 @@ export class AgentApiServer {
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 = {
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 ?? (() => {}),
@@ -98,29 +109,26 @@ export class AgentApiServer {
}
setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) {
this.onBrowserActionCb = handler;
}
getPort(): number {
return this.port;
this._onBrowserActionCb = handler;
}
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 = createServer((req, res) => void this.handleRequest(req, res));
this._server.listen(0, "127.0.0.1", () => {
const srv = this._server;
const addr = srv ? srv.address() : null;
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);
this._server.on("error", reject);
});
}
stop() {
this.server?.close();
this.server = null;
this._server?.close();
this._server = null;
}
addRoute(method: string, path: string, handler: RequestHandler) {
@@ -129,13 +137,13 @@ export class AgentApiServer {
paramNames.push(name);
return "([^/]+)";
});
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
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)) {
if (!this._allowedOrigins.has(origin)) {
this.json(res, 403, { error: "Origin not allowed" });
return;
}
@@ -151,7 +159,7 @@ export class AgentApiServer {
return;
}
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this._port}`);
const pathname = url.pathname;
const method = (req.method ?? "GET").toUpperCase();
@@ -173,7 +181,7 @@ export class AgentApiServer {
return;
}
const runState = this.db.getRunAuthState(token.runId);
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;
@@ -184,14 +192,14 @@ export class AgentApiServer {
return;
}
for (const route of this.routes) {
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]!;
route.paramNames.forEach((name: string, i: number) => {
params[name] = match[i + 1] ?? "";
});
let body: unknown = null;
@@ -257,20 +265,18 @@ export class AgentApiServer {
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));
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) => void) | null {
return this.onBrowserActionCb;
}
get port_(): number { return this.port; }
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) => void) | null { return this._onBrowserActionCb; }
get port(): number { return this._port; }
}