refactor(api-server): move agents SQL into db.agents/db.activity/db.approvals methods
Replace seven inline queryAll calls in routes-agents.ts with typed domain methods. db.agents gains listAgentsForApi, listAgentsForStandup, listExistingAgentsForHireCheck. db.tasks gains listTasksForStandup. db.approvals gains listApprovalsForStandup and listPendingHireApprovalPayloads. db.activity gains listRecentTaskComments (safe on empty taskIds). All HTTP response projections stay identical.
This commit is contained in:
1 parent
efb9fdb8cc
commit
28296b3d5f
6 files changed
+213
-62
No files matched your search
@@ -11,38 +11,6 @@ import {
|
||||
import { resolveApprovalDecision, shouldAutoApproveApproval } from "../approval-policy";
|
||||
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;
|
||||
@@ -61,18 +29,9 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 companyAgents = db.listAgentsForStandup(token.companyId);
|
||||
const companyTasks = db.listTasksForStandup(token.companyId);
|
||||
const companyApprovals = db.listApprovalsForStandup(token.companyId);
|
||||
|
||||
const myTasks = companyTasks.filter((t) => t.assigneeAgentId === token.agentId);
|
||||
const directReports = companyAgents.filter((a) => a.reportsTo === token.agentId);
|
||||
@@ -87,12 +46,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
|
||||
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 recentComments = db.listRecentTaskComments([...allRelevantTaskIds], twoHoursAgo, 20);
|
||||
|
||||
const myPendingApprovals = companyApprovals.filter(
|
||||
(a) => a.requestedByAgentId === token.agentId && a.state === "pending",
|
||||
@@ -225,10 +179,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
server.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
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) }));
|
||||
const agents = db.listAgentsForApi(params.companyId);
|
||||
server.json(res, 200, agents);
|
||||
});
|
||||
|
||||
@@ -317,10 +268,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
}
|
||||
|
||||
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
|
||||
const existingAgents = db.queryAll<{ id: string; name: string; role: string }>(
|
||||
"select id, name, role from agents where company_id = ? and status != 'terminated'",
|
||||
companyId,
|
||||
);
|
||||
const existingAgents = db.listExistingAgentsForHireCheck(companyId);
|
||||
const duplicateByName = existingAgents.find(
|
||||
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
|
||||
);
|
||||
@@ -329,10 +277,7 @@ export function registerAgentRoutes(server: AgentApiServer) {
|
||||
return;
|
||||
}
|
||||
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
|
||||
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,
|
||||
);
|
||||
const pendingHires = db.listPendingHireApprovalPayloads(companyId);
|
||||
for (const pending of pendingHires) {
|
||||
try {
|
||||
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
|
||||
|
||||
@@ -211,6 +211,9 @@ export class AppDatabaseBase {
|
||||
getDirectReports(agentId: string, companyId: string) { return this.agents.getDirectReports(agentId, companyId); }
|
||||
getCeoForCompany(companyId: string) { return this.agents.getCeoForCompany(companyId); }
|
||||
listActiveAgentsForCompany(companyId: string) { return this.agents.listActiveAgentsForCompany(companyId); }
|
||||
listAgentsForApi(companyId: string) { return this.agents.listAgentsForApi(companyId); }
|
||||
listAgentsForStandup(companyId: string) { return this.agents.listAgentsForStandup(companyId); }
|
||||
listExistingAgentsForHireCheck(companyId: string) { return this.agents.listExistingAgentsForHireCheck(companyId); }
|
||||
hasAgentAssignedTasks(agentId: string, companyId: string) { return this.agents.hasAgentAssignedTasks(agentId, companyId); }
|
||||
updateAgentStatus(agentId: string, status: string) { return this.agents.updateAgentStatus(agentId, status); }
|
||||
isAgentPendingApproval(agentId: string) { return this.agents.isAgentPendingApproval(agentId); }
|
||||
@@ -244,6 +247,7 @@ export class AppDatabaseBase {
|
||||
renewTaskLease(taskId: string, runId: string) { return this.tasks.renewTaskLease(taskId, runId); }
|
||||
listAgentTasks(agentId: string, companyId: string, statuses: string[]) { return this.tasks.listAgentTasks(agentId, companyId, statuses); }
|
||||
listTasksByStatuses(companyId: string, statuses: string[]) { return this.tasks.listTasksByStatuses(companyId, statuses); }
|
||||
listTasksForStandup(companyId: string) { return this.tasks.listTasksForStandup(companyId); }
|
||||
findReusableHeartbeatTask(agentId: string, companyId: string) { return this.tasks.findReusableHeartbeatTask(agentId, companyId); }
|
||||
acquireHeartbeatTask(input: { agentId: string; companyId: string; runId: string; workspaceId: string | null; title: string; description: string; priority: string }) { return this.tasks.acquireHeartbeatTask(input); }
|
||||
cleanupDuplicateHeartbeatTasks(companyId: string) { return this.tasks.cleanupDuplicateHeartbeatTasks(companyId); }
|
||||
@@ -297,6 +301,9 @@ export class AppDatabaseBase {
|
||||
listApprovalsForTask(taskId: string) { return this.approvalsMod.listApprovalsForTask(taskId); }
|
||||
listTasksForApproval(approvalId: string) { return this.approvalsMod.listTasksForApproval(approvalId); }
|
||||
linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null) { this.approvalsMod.linkManyTasksToApproval(approvalId, taskIds, companyId, linkedByAgentId); }
|
||||
listApprovalsForStandup(companyId: string) { return this.approvalsMod.listApprovalsForStandup(companyId); }
|
||||
listPendingHireApprovalPayloads(companyId: string) { return this.approvalsMod.listPendingHireApprovalPayloads(companyId); }
|
||||
listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); }
|
||||
|
||||
// ─── Workspaces ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -406,5 +413,6 @@ export class AppDatabaseBase {
|
||||
|
||||
addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { this.activityMod.addActivity(input); }
|
||||
addComment(input: { companyId: string; taskId: string; authorAgentId: string | null; authorName: string; body: string }) { return this.activityMod.addComment(input); }
|
||||
listRecentTaskComments(taskIds: string[], sinceIso: string, limit: number) { return this.activityMod.listRecentTaskComments(taskIds, sinceIso, limit); }
|
||||
recordConfigRevision(input: { companyId: string; agentId: string; changedKeys: string[]; configBefore: Record<string, unknown>; configAfter: Record<string, unknown>; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); }
|
||||
}
|
||||
@@ -33,5 +33,31 @@ export function buildActivityMethods(
|
||||
taskId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the `limit` most recent task comments (author_name + body +
|
||||
* createdAt) for a given set of task ids since `sinceIso`. Used by
|
||||
* GET /api/agents/me/standup to build the "recent comments" feed.
|
||||
*/
|
||||
listRecentTaskComments(
|
||||
taskIds: string[],
|
||||
sinceIso: string,
|
||||
limit: number,
|
||||
): Array<{
|
||||
id: string;
|
||||
taskId: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}> {
|
||||
if (taskIds.length === 0) return [];
|
||||
const placeholders = taskIds.map(() => "?").join(",");
|
||||
return readAll(
|
||||
`select id, task_id as taskId, author_name as authorName, body, created_at as createdAt from task_comments where task_id in (${placeholders}) and created_at > ? order by created_at desc limit ?`,
|
||||
...taskIds,
|
||||
sinceIso,
|
||||
limit,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,47 @@ import {
|
||||
toBoolean,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* Agent row shape returned by listAgentsForApi. Mirrors the historical
|
||||
* inline SQL projection in routes-agents.ts so HTTP response bodies
|
||||
* stay byte-stable. heartbeatEnabled is exposed as a boolean.
|
||||
*/
|
||||
export type AgentApiRow = {
|
||||
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: boolean;
|
||||
heartbeatIntervalSec: number | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slim agent projection used by the me/standup aggregation.
|
||||
*/
|
||||
export type AgentStandupRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
reportsTo: string | null;
|
||||
department: string | null;
|
||||
};
|
||||
|
||||
export function buildAgentHierarchyMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
@@ -74,6 +115,44 @@ export function buildAgentHierarchyMethods(
|
||||
.run(nowIso(), agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all agents for a company with the full API projection used
|
||||
* by GET /api/companies/:companyId/agents. Mirrors the historical
|
||||
* inline SQL and maps heartbeat_enabled into a boolean.
|
||||
*/
|
||||
listAgentsForApi(companyId: string): AgentApiRow[] {
|
||||
return readAll<AgentApiRow & { heartbeatEnabled: number | boolean }>(
|
||||
"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",
|
||||
companyId,
|
||||
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim standup projection used by
|
||||
* GET /api/agents/me/standup — only the fields that feed the
|
||||
* aggregation (id, name, role, status, reportsTo, department).
|
||||
*/
|
||||
listAgentsForStandup(companyId: string): AgentStandupRow[] {
|
||||
return readAll<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",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the duplicate-detection projection used by
|
||||
* POST /api/companies/:id/agents to reject hires with a name that
|
||||
* already belongs to a non-terminated agent.
|
||||
*/
|
||||
listExistingAgentsForHireCheck(
|
||||
companyId: string,
|
||||
): Array<{ id: string; name: string; role: string }> {
|
||||
return readAll<{ id: string; name: string; role: string }>(
|
||||
"select id, name, role from agents where company_id = ? and status != 'terminated'",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
listHeartbeatEligibleAgents(companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"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 = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
|
||||
@@ -116,6 +116,81 @@ export function buildApprovalMethods(
|
||||
getDb().prepare("delete from approvals where id = ? and company_id = ?").run(id, companyId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim approval projection used by
|
||||
* GET /api/agents/me/standup. Ordered by created_at desc.
|
||||
*/
|
||||
listApprovalsForStandup(companyId: string): Array<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
requestedByAgentId: string | null;
|
||||
state: string;
|
||||
payloadSummary: string | null;
|
||||
decisionNote: string | null;
|
||||
}> {
|
||||
return readAll(
|
||||
"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",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns pending `hire_agent` approval payloads for a company, used
|
||||
* during hire-request creation to detect duplicate names across
|
||||
* concurrent requests.
|
||||
*/
|
||||
listPendingHireApprovalPayloads(
|
||||
companyId: string,
|
||||
): Array<{ payloadJson: string | null }> {
|
||||
return readAll<{ payloadJson: string | null }>(
|
||||
"select payload_json as payloadJson from approvals where company_id = ? and type = 'hire_agent' and state = 'pending'",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the full approval row projection used by
|
||||
* GET /api/companies/:id/approvals. Optional filters match the
|
||||
* historical inline SQL behaviour.
|
||||
*/
|
||||
listApprovalsForApi(companyId: string, filters: {
|
||||
requestedByAgentId?: string;
|
||||
relatedTaskId?: string;
|
||||
statuses?: string[] | null;
|
||||
}): Array<{
|
||||
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;
|
||||
}> {
|
||||
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 args: SQLInputValue[] = [companyId];
|
||||
if (filters.requestedByAgentId) {
|
||||
sql += " and requested_by_agent_id = ?";
|
||||
args.push(filters.requestedByAgentId);
|
||||
}
|
||||
if (filters.relatedTaskId) {
|
||||
sql += " and related_task_id = ?";
|
||||
args.push(filters.relatedTaskId);
|
||||
}
|
||||
if (filters.statuses && filters.statuses.length > 0) {
|
||||
sql += ` and state in (${filters.statuses.map(() => "?").join(", ")})`;
|
||||
args.push(...filters.statuses);
|
||||
}
|
||||
sql += " order by created_at desc";
|
||||
return readAll(sql, ...args);
|
||||
},
|
||||
|
||||
getBlockingApproval(companyId: string, agentId: string, taskId: string | null): ApprovalRecord | null {
|
||||
const taskCondition = taskId != null
|
||||
? "(related_task_id = ? or requested_by_agent_id = ?)"
|
||||
|
||||
@@ -190,6 +190,24 @@ export function buildTaskCrudMethods(
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the slim task projection used by the me/standup aggregation.
|
||||
* Ordered by created_at desc.
|
||||
*/
|
||||
listTasksForStandup(companyId: string): Array<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string;
|
||||
assigneeAgentId: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
}> {
|
||||
return readAll(
|
||||
"select id, company_id as companyId, title, assignee_agent_id as assigneeAgentId, status, priority from tasks where company_id = ? order by created_at desc",
|
||||
companyId,
|
||||
);
|
||||
},
|
||||
|
||||
listTasksByStatuses(
|
||||
companyId: string,
|
||||
statuses: string[],
|
||||
|
||||
Reference in new issue
Block a user