From 7f097e3319ac7b674694c8cc56165c573f655591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 22:29:36 -0400 Subject: [PATCH] refactor(api-server): move tasks SQL into db.tasks/db.documents domain methods Replace inline queryAll calls in routes-tasks.ts with two new domain methods: tasks.listTasksByStatuses (used by GET /api/companies/:id/issues) and documents.listImportantDocsForReview (used by the deliverable-review gate). Routes now hold no SQL or column-mapping logic; the TaskListByStatusesRow shape preserves the existing HTTP response fields including requiresUserReview as a boolean. --- src/main/api-server/routes-tasks.ts | 32 +++++-------------- src/main/database-base.ts | 1 + src/main/database.ts | 1 + src/main/database/documents.ts | 27 ++++++++++++++++ src/main/database/tasks-crud.ts | 48 +++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 24 deletions(-) diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts index 4370fd81..240d5e58 100644 --- a/src/main/api-server/routes-tasks.ts +++ b/src/main/api-server/routes-tasks.ts @@ -6,17 +6,6 @@ import { inferTaskType } from "../connector-matching"; import { validateTaskCreation } from "../task-quality-gates"; 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(), comment: z.string().min(1).max(4000).optional(), @@ -51,11 +40,7 @@ export function registerTaskRoutes(server: AgentApiServer) { const tasks = db.listAgentTasks(assigneeAgentId, companyId, statuses); server.json(res, 200, tasks); } else { - const placeholders = statuses.map(() => "?").join(", "); - const tasks = db.queryAll( - `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 })); + const tasks = db.listTasksByStatuses(companyId, statuses); server.json(res, 200, tasks); } }); @@ -139,14 +124,13 @@ export function registerTaskRoutes(server: AgentApiServer) { // Deliverable review gate: intercept agent trying to advance a flagged task to "done". // This MUST succeed — silently skipping review would let unreviewed work advance. if (update.status === "done" && task.requiresUserReview) { - const importantTypes = IMPORTANT_DOC_TYPES; - const placeholders = importantTypes.map(() => "?").join(", "); - const docs = db.queryAll<{ id: string; type: string; title: string }>( - `select id, type, title from documents where company_id = ? and type in (${placeholders}) - and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?)) - order by created_at desc limit 10`, - task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id, - ); + const docs = db.listImportantDocsForReview({ + companyId: task.companyId, + types: IMPORTANT_DOC_TYPES, + authorAgentId: task.assigneeAgentId ?? null, + taskId: task.id, + limit: 10, + }); const reviewerAgent = db.getAgent(token.agentId); const docTitles = docs.map((d) => d.title).join(", "); db.requestApproval({ diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 2a2cd9ad..60531dc4 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -242,6 +242,7 @@ export class AppDatabaseBase { releaseTaskLock(taskId: string, runId: string) { return this.tasks.releaseTaskLock(taskId, runId); } 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); } 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); } diff --git a/src/main/database.ts b/src/main/database.ts index 6008d538..d2b1f2dd 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -82,6 +82,7 @@ export class AppDatabase extends AppDatabaseBase { // ─── Documents & Work Products ───────────────────────────────────────────── saveDocument(input: { id?: string | null; companyId: string; type: string; title: string; content?: string; authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null; goalId?: string | null; parentDocId?: string | null; version?: number; status?: string; tagsJson?: string; sourceRunId?: string | null }) { return this.documentsMod.saveDocument(input); } + listImportantDocsForReview(input: { companyId: string; types: readonly string[]; authorAgentId: string | null; taskId: string; limit?: number }) { return this.documentsMod.listImportantDocsForReview(input); } updateDocumentStatus(id: string, companyId: string, status: string) { this.documentsMod.updateDocumentStatus(id, companyId, status); } deleteDocument(id: string, companyId: string) { this.documentsMod.deleteDocument(id, companyId); } createDocumentRevision(input: { documentId: string; companyId: string; body: string; changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.createDocumentRevision(input); } diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index 54e844e0..d0409ac3 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -68,6 +68,33 @@ export function buildDocumentMethods( return row?.latest_revision_id ?? null; }, + /** + * Returns up to `limit` documents of the given types for a company that + * are either authored by `authorAgentId` or were created at/after the + * task's createdAt. Used by the deliverable-review gate when a task is + * advanced to "done". + */ + listImportantDocsForReview(input: { + companyId: string; + types: readonly string[]; + authorAgentId: string | null; + taskId: string; + limit?: number; + }): Array<{ id: string; type: string; title: string }> { + const limit = input.limit ?? 10; + const placeholders = input.types.map(() => "?").join(", "); + return readAll<{ id: string; type: string; title: string }>( + `select id, type, title from documents where company_id = ? and type in (${placeholders}) + and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?)) + order by created_at desc limit ?`, + input.companyId, + ...input.types, + input.authorAgentId ?? "", + input.taskId, + limit, + ); + }, + listDocumentRevisions(documentId: string, companyId: string) { return readAll<{ id: string; company_id: string; document_id: string; revision_number: number; diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index 7059277d..8da8007e 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -10,6 +10,36 @@ import { taskPriorityOrderSql, } from "./helpers"; +/** + * Row shape returned by listTasksByStatuses — used by the + * GET /api/companies/:companyId/issues route. Mirrors the historical + * inline-SQL projection (no taskType column) so the HTTP response shape + * stays stable for agent CLI clients. + */ +export type TaskListByStatusesRow = { + 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: boolean; + startedAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + createdAt: string; + updatedAt: string; +}; + export function buildTaskCrudMethods( getDb: () => DatabaseSync, readAll: ReadAll, @@ -160,6 +190,24 @@ export function buildTaskCrudMethods( ); }, + listTasksByStatuses( + companyId: string, + statuses: string[], + ): TaskListByStatusesRow[] { + const placeholders = statuses.map(() => "?").join(", "); + return readAll( + `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, + })) as TaskListByStatusesRow[]; + }, + countActiveTasksForCompany(companyId: string): number { const row = getDb() .prepare(