update
This commit is contained in:
1 parent
09971cabc2
commit
9145572422
163 files changed
+3348
-2874
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user