diff --git a/src/main/api-server/routes-agents.ts b/src/main/api-server/routes-agents.ts index c4d6f656..56ee9c78 100644 --- a/src/main/api-server/routes-agents.ts +++ b/src/main/api-server/routes-agents.ts @@ -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( - "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( - "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( - "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( - `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( - "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 : {}; diff --git a/src/main/database-base.ts b/src/main/database-base.ts index f6e6e7e4..87566079 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -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; configAfter: Record; source?: string; rolledBackFromRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.recordConfigRevision(input); } } diff --git a/src/main/database/activity.ts b/src/main/database/activity.ts index 705fce97..22d286f7 100644 --- a/src/main/database/activity.ts +++ b/src/main/database/activity.ts @@ -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, + ); + }, }; } diff --git a/src/main/database/agents-hierarchy.ts b/src/main/database/agents-hierarchy.ts index 7e873078..a1debf5f 100644 --- a/src/main/database/agents-hierarchy.ts +++ b/src/main/database/agents-hierarchy.ts @@ -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( + "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( + "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( "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')", diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts index 0fb6d480..1fefe595 100644 --- a/src/main/database/approvals.ts +++ b/src/main/database/approvals.ts @@ -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 = ?)" diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index 8da8007e..04c79862 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -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[],