diff --git a/src/main/database/migrations/068-task-review-link.ts b/src/main/database/migrations/068-task-review-link.ts new file mode 100644 index 00000000..275dd38c --- /dev/null +++ b/src/main/database/migrations/068-task-review-link.ts @@ -0,0 +1,6 @@ +export const taskReviewLinkSchema = ` + alter table tasks add column review_of_task_id text references tasks(id) on delete set null; + + insert into schema_migrations(version, name, applied_at) + values (68, 'task_review_link', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 17518a25..59794b18 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -66,6 +66,7 @@ import { knowledgeRevisionsSchema } from "./migrations/064-knowledge-revisions"; import { documentVersionsSchema } from "./migrations/065-document-versions"; import { taskWritingModeSchema } from "./migrations/066-task-writing-mode"; import { taskRunBudgetSchema } from "./migrations/067-task-run-budget"; +import { taskReviewLinkSchema } from "./migrations/068-task-review-link"; interface Migration { version: number; @@ -141,6 +142,7 @@ const MIGRATIONS: Migration[] = [ { version: 65, name: "document_versions", sql: documentVersionsSchema }, { version: 66, name: "task_writing_mode", sql: taskWritingModeSchema }, { version: 67, name: "task_run_budget", sql: taskRunBudgetSchema }, + { version: 68, name: "task_review_link", sql: taskReviewLinkSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 7803944b..c3d2585b 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -383,7 +383,14 @@ function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: Json const taskId = requireString(args.task_id, "task_id"); const status = normalizeStatus(args.status); deps.organization.updateTaskStatus(ctx.orgId, taskId, status); - return `Task ${taskId} moved to ${status}.`; + if (status !== TASK_STATUS.REVIEW) return `Task ${taskId} moved to ${status}.`; + const reviewer = deps.db.prepare(` + select a.name from tasks r join agents a on a.id = r.assignee_agent_id + where r.review_of_task_id = ? and r.status <> 'done' limit 1 + `).get(taskId) as { name: string } | undefined; + return reviewer + ? `Task ${taskId} moved to review. ${reviewer.name} will review it.` + : `Task ${taskId} moved to review. It has no manager to review it, so it waits for the operator on the Work board.`; } diff --git a/src/main/services/avatar-values.ts b/src/main/services/avatar-values.ts new file mode 100644 index 00000000..368d4873 --- /dev/null +++ b/src/main/services/avatar-values.ts @@ -0,0 +1,22 @@ +/** Avatar colors persist as #rrggbb; anything else is a caller bug. */ +export function normalizeAvatarColor(value: string): string | null { + const color = value.trim(); + if (!color) return null; + if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error("Avatar color must be a #rrggbb hex value."); + return color.toLowerCase(); +} + +/** Portraits are small base64 data URLs; the cap keeps the snapshot from ballooning. */ +const MAX_AVATAR_IMAGE_CHARS = 300_000; + +export function normalizeAvatarImage(value: string): string | null { + const image = value.trim(); + if (!image) return null; + if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) { + throw new Error("Avatar image must be a base64 data URL (png, jpeg, or webp)."); + } + if (image.length > MAX_AVATAR_IMAGE_CHARS) { + throw new Error("Avatar image is too large - upload a smaller picture."); + } + return image; +} diff --git a/src/main/services/org-approvals.ts b/src/main/services/org-approvals.ts new file mode 100644 index 00000000..a84591c7 --- /dev/null +++ b/src/main/services/org-approvals.ts @@ -0,0 +1,23 @@ +import type { DatabaseSync } from "node:sqlite"; +import { APPROVAL_STATE, type ApprovalState } from "../../shared/domain"; + +/** The org's autonomy: `ai_ceo` runs tasks without operator approval; `autoApproveHires` clears hire reviews. */ +export function autonomy(db: DatabaseSync, orgId: string): { executionMode: string; autoApproveHires: boolean } { + const row = db.prepare("select execution_mode, auto_approve_hires from organizations where id = ?") + .get(orgId) as { execution_mode: string; auto_approve_hires: number } | undefined; + if (!row) throw new Error("Organization was not found."); + return { executionMode: row.execution_mode, autoApproveHires: row.auto_approve_hires === 1 }; +} + +/** The decision recorded on an approval: pending, or auto-approved by the board chair. */ +export function approvalDecision(db: DatabaseSync, orgId: string, autoApprove: boolean, now: string): { state: ApprovalState; decidedBy: string | null; decidedAt: string | null } { + if (!autoApprove) return { state: APPROVAL_STATE.PENDING, decidedBy: null, decidedAt: null }; + return { state: APPROVAL_STATE.APPROVED, decidedBy: boardChairEmployeeId(db, orgId), decidedAt: now }; +} + +function boardChairEmployeeId(db: DatabaseSync, orgId: string): string { + const row = db.prepare("select id from employees where org_id = ? order by created_at asc limit 1") + .get(orgId) as { id: string } | undefined; + if (!row) throw new Error("Organization has no employee to record the approval decision."); + return row.id; +} diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index c99108fd..dd503fe3 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type ApprovalState } from "../../shared/domain"; +import { RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; @@ -9,18 +9,23 @@ import { IdentityAccessService } from "./identity-access-service"; import { OrgGovernanceService } from "./org-governance-service"; import { assertUsableMentionName } from "../../shared/mention-handles"; import { seedOrganization } from "./organization-seed"; +import { TaskMutations } from "./task-mutations"; +import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values"; +import { approvalDecision, autonomy } from "./org-approvals"; export class OrganizationService { private readonly audit: AuditService; private readonly policy: PolicyService; private readonly identityAccess: IdentityAccessService; private readonly governance: OrgGovernanceService; + private readonly tasks: TaskMutations; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.policy = new PolicyService(db); this.identityAccess = new IdentityAccessService(db); this.governance = new OrgGovernanceService(db); + this.tasks = new TaskMutations(db, (input) => this.createTask(input)); } createOrganization(input: CreateOrganizationInput): string { @@ -132,8 +137,8 @@ export class OrganizationService { this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now); if (hiringPolicy.reviewRequired) { const approvalId = createId("app"); - const autoApprove = this.autonomy(input.orgId).autoApproveHires; - const decision = this.approvalDecision(input.orgId, autoApprove, now); + const autoApprove = autonomy(this.db, input.orgId).autoApproveHires; + const decision = approvalDecision(this.db, input.orgId, autoApprove, now); this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, @@ -260,8 +265,8 @@ export class OrganizationService { if (approvalRequired) { const approvalId = createId("app"); const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution"; - const autoApprove = this.autonomy(input.orgId).executionMode === "ai_ceo"; - const decision = this.approvalDecision(input.orgId, autoApprove, now); + const autoApprove = autonomy(this.db, input.orgId).executionMode === "ai_ceo"; + const decision = approvalDecision(this.db, input.orgId, autoApprove, now); this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, @@ -297,23 +302,6 @@ export class OrganizationService { return id; } - /** - * Reads the org's autonomy settings. In `ai_ceo` mode the CEO runs without - * the operator approving each task; `autoApproveHires` clears hire reviews. - */ - private autonomy(orgId: string): { executionMode: string; autoApproveHires: boolean } { - const row = this.db.prepare("select execution_mode, auto_approve_hires from organizations where id = ?") - .get(orgId) as { execution_mode: string; auto_approve_hires: number } | undefined; - if (!row) throw new Error("Organization was not found."); - return { executionMode: row.execution_mode, autoApproveHires: row.auto_approve_hires === 1 }; - } - - /** The decision recorded on an approval: pending, or auto-approved by the board chair. */ - private approvalDecision(orgId: string, autoApprove: boolean, now: string): { state: ApprovalState; decidedBy: string | null; decidedAt: string | null } { - if (!autoApprove) return { state: APPROVAL_STATE.PENDING, decidedBy: null, decidedAt: null }; - return { state: APPROVAL_STATE.APPROVED, decidedBy: this.boardChairEmployeeId(orgId), decidedAt: now }; - } - /** Routes a pending approval to its decider, or records the auto-approval in the audit trail. */ private settleApproval(orgId: string, approvalId: string, kind: string, risk: typeof RISK_LEVEL[keyof typeof RISK_LEVEL], autoApproved: boolean): void { if (!autoApproved) { @@ -331,13 +319,6 @@ export class OrganizationService { }); } - private boardChairEmployeeId(orgId: string): string { - const row = this.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1") - .get(orgId) as { id: string } | undefined; - if (!row) throw new Error("Organization has no employee to record the approval decision."); - return row.id; - } - private verifyOptionalOrgEntity(table: "agents" | "goals" | "workspaces", id: string | null, orgId: string, label: string): void { if (!id) return; const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId); @@ -490,111 +471,14 @@ export class OrganizationService { } updateTask(input: UpdateTaskInput): void { - const now = new Date().toISOString(); - if (input.assigneeAgentId) this.verifyOptionalOrgEntity("agents", input.assigneeAgentId, input.orgId, "Assignee agent"); - if (input.parentTaskId) this.assertParentAllowed(input.orgId, input.taskId, input.parentTaskId, input.title.trim()); - const result = this.db.prepare(` - update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?, - stalled_reason = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs) - where id = ? and org_id = ? - `).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId); - if (result.changes === 0) throw new Error("Task was not found."); - this.audit.record({ - orgId: input.orgId, - entityType: "task", - entityId: input.taskId, - actorType: "employee", - actorId: null, - action: "updated", - payload: { title: input.title.trim() }, - }); + this.tasks.update(input); } deleteTask(input: DeleteTaskInput): void { - const running = this.db.prepare("select 1 from task_runs where task_id = ? and org_id = ? and state = 'running' limit 1") - .get(input.taskId, input.orgId); - if (running) throw new Error("This task has a run in progress; wait for it to finish before deleting the task."); - const result = this.db.prepare("delete from tasks where id = ? and org_id = ?").run(input.taskId, input.orgId); - if (result.changes === 0) throw new Error("Task was not found."); - this.audit.record({ - orgId: input.orgId, - entityType: "task", - entityId: input.taskId, - actorType: "employee", - actorId: null, - action: "deleted", - payload: {}, - }); + this.tasks.delete(input); } updateTaskStatus(orgId: string, taskId: string, status: string): void { - if (status === TASK_STATUS.DONE) this.assertNoOpenSubtasks(orgId, taskId); - const now = new Date().toISOString(); - // Reopening a done task gives it a fresh run budget. - const result = this.db.prepare(` - update tasks set status = ?, updated_at = ?, - stalled_reason = case when status = 'done' and ? <> 'done' then null else stalled_reason end, - run_budget_floor = case when status = 'done' and ? <> 'done' then (select coalesce(max(rowid), 0) from task_runs) else run_budget_floor end - where id = ? and org_id = ? - `).run(status, now, status, status, taskId, orgId); - if (result.changes === 0) throw new Error("Task was not found."); - this.audit.record({ - orgId, - entityType: "task", - entityId: taskId, - actorType: "system", - actorId: null, - action: "status_changed", - payload: { status }, - }); - } - - private assertParentAllowed(orgId: string, taskId: string, parentTaskId: string, title: string): void { - if (parentTaskId === taskId) throw new Error(`"${title}" cannot be its own parent.`); - const parent = this.db.prepare("select title from tasks where id = ? and org_id = ?").get(parentTaskId, orgId) as { title: string } | undefined; - if (!parent) throw new Error("Parent task was not found."); - const loop = this.db.prepare(` - with recursive ancestors(id) as ( - select parent_task_id from tasks where id = ? - union select t.parent_task_id from tasks t join ancestors a on t.id = a.id where t.parent_task_id is not null - ) - select 1 from ancestors where id = ? limit 1 - `).get(parentTaskId, taskId); - if (loop) throw new Error(`"${title}" cannot sit under "${parent.title}": "${parent.title}" is already beneath "${title}".`); - } - - private assertNoOpenSubtasks(orgId: string, taskId: string): void { - const open = this.db.prepare("select title from tasks where org_id = ? and parent_task_id = ? and status <> 'done' order by created_at") - .all(orgId, taskId) as Array<{ title: string }>; - if (open.length === 0) return; - const parent = this.db.prepare("select title from tasks where id = ? and org_id = ?").get(taskId, orgId) as { title: string }; - throw new Error(`"${parent.title}" has open subtasks: ${open.map((task) => task.title).join(", ")}. It can close once they are done.`); + this.tasks.updateStatus(orgId, taskId, status); } } - -/** Avatar colors persist as #rrggbb; anything else is a caller bug. */ -function normalizeAvatarColor(value: string): string | null { - const color = value.trim(); - if (!color) return null; - if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error("Avatar color must be a #rrggbb hex value."); - return color.toLowerCase(); -} - -/** - * Avatar portraits persist as small base64 data URLs (the renderer downscales - * before submitting). The cap keeps a rogue caller from turning the snapshot - * back into a megabyte payload. - */ -const MAX_AVATAR_IMAGE_CHARS = 300_000; - -function normalizeAvatarImage(value: string): string | null { - const image = value.trim(); - if (!image) return null; - if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) { - throw new Error("Avatar image must be a base64 data URL (png, jpeg, or webp)."); - } - if (image.length > MAX_AVATAR_IMAGE_CHARS) { - throw new Error("Avatar image is too large - upload a smaller picture."); - } - return image; -} diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts new file mode 100644 index 00000000..12d58774 --- /dev/null +++ b/src/main/services/task-mutations.ts @@ -0,0 +1,162 @@ +import type { DatabaseSync } from "node:sqlite"; +import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain"; +import type { CreateTaskInput, DeleteTaskInput, UpdateTaskInput } from "../../shared/commands"; +import { AuditService } from "./audit-service"; +import { withSavepoint } from "../database/connection"; + +interface TaskRow { + id: string; + title: string; + description: string; + status: string; + assignee_agent_id: string | null; + project_id: string | null; + goal_id: string | null; + workspace_id: string | null; + priority: TaskPriority; +} + +/** Edits, deletion and status moves of existing tasks, with their hierarchy and review rules. */ +export class TaskMutations { + private readonly audit: AuditService; + + constructor(private readonly db: DatabaseSync, private readonly createTask: (input: CreateTaskInput) => string) { + this.audit = new AuditService(db); + } + + update(input: UpdateTaskInput): void { + const now = new Date().toISOString(); + if (input.assigneeAgentId && !this.db.prepare("select 1 from agents where id = ? and org_id = ?").get(input.assigneeAgentId, input.orgId)) { + throw new Error("Assignee agent was not found."); + } + if (input.parentTaskId) this.assertParentAllowed(input.orgId, input.taskId, input.parentTaskId, input.title.trim()); + const result = this.db.prepare(` + update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?, + stalled_reason = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs) + where id = ? and org_id = ? + `).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId); + if (result.changes === 0) throw new Error("Task was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "task", + entityId: input.taskId, + actorType: "employee", + actorId: null, + action: "updated", + payload: { title: input.title.trim() }, + }); + } + + delete(input: DeleteTaskInput): void { + const running = this.db.prepare("select 1 from task_runs where task_id = ? and org_id = ? and state = 'running' limit 1") + .get(input.taskId, input.orgId); + if (running) throw new Error("This task has a run in progress; wait for it to finish before deleting the task."); + this.closeReviews(input.orgId, input.taskId, "deleted"); + const result = this.db.prepare("delete from tasks where id = ? and org_id = ?").run(input.taskId, input.orgId); + if (result.changes === 0) throw new Error("Task was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "task", + entityId: input.taskId, + actorType: "employee", + actorId: null, + action: "deleted", + payload: {}, + }); + } + + updateStatus(orgId: string, taskId: string, status: string): void { + withSavepoint(this.db, "task_status", () => this.applyStatus(orgId, taskId, status)); + } + + private applyStatus(orgId: string, taskId: string, status: string): void { + const task = this.db.prepare(` + select id, title, description, status, assignee_agent_id, project_id, goal_id, workspace_id, priority + from tasks where id = ? and org_id = ? + `).get(taskId, orgId) as TaskRow | undefined; + if (!task) throw new Error("Task was not found."); + if (status === TASK_STATUS.DONE) this.assertNoOpenSubtasks(orgId, task); + const reopening = task.status === TASK_STATUS.DONE && status !== TASK_STATUS.DONE; + // Reopening a done task gives it a fresh run budget. + this.db.prepare(` + update tasks set status = ?, updated_at = ?, + stalled_reason = case when ? then null else stalled_reason end, + run_budget_floor = case when ? then (select coalesce(max(rowid), 0) from task_runs) else run_budget_floor end + where id = ? and org_id = ? + `).run(status, new Date().toISOString(), reopening ? 1 : 0, reopening ? 1 : 0, taskId, orgId); + this.audit.record({ + orgId, + entityType: "task", + entityId: taskId, + actorType: "system", + actorId: null, + action: "status_changed", + payload: { status }, + }); + if (status === TASK_STATUS.REVIEW && task.status !== TASK_STATUS.REVIEW) this.openReview(orgId, task); + if (task.status === TASK_STATUS.REVIEW && status !== TASK_STATUS.REVIEW) this.closeReviews(orgId, taskId, status); + } + + /** Hands work in review to the assignee's manager as a review task; without one it waits for the operator. */ + private openReview(orgId: string, task: TaskRow): void { + const assignee = task.assignee_agent_id + ? this.db.prepare("select name, manager_agent_id from agents where id = ?").get(task.assignee_agent_id) as { name: string; manager_agent_id: string | null } + : null; + const reviewer = assignee?.manager_agent_id + ? this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'").get(assignee.manager_agent_id, orgId) as { id: string } | undefined + : undefined; + if (!reviewer) { + this.audit.record({ orgId, entityType: "task", entityId: task.id, actorType: "system", actorId: null, action: "review_requested", payload: { reviewer: "operator" } }); + return; + } + const author = assignee?.name ?? "The assignee"; + const reviewId = this.createTask({ + orgId, + title: `Review: ${task.title}`, + description: [ + `${author} moved "${task.title}" (task_id=${task.id}) to review.`, + `Task brief:\n${task.description}`, + `Check the actual output against the brief: read_messages for their report, list_files/read_file and list_documents for what they produced. If it meets the brief, approve it with update_task_status(task_id="${task.id}", status="done"). If not, send it back with update_task_status(task_id="${task.id}", status="in_progress") and tell ${author} in a direct message exactly what to fix. Either verdict closes this review.`, + ].join("\n\n"), + status: TASK_STATUS.BACKLOG, + assigneeAgentId: reviewer.id, + risk: RISK_LEVEL.LOW, + projectId: task.project_id, + goalId: task.goal_id, + workspaceId: task.workspace_id, + priority: task.priority, + requiresFilesystem: false, + origin: "task_review", + }); + this.db.prepare("update tasks set review_of_task_id = ? where id = ?").run(task.id, reviewId); + } + + private closeReviews(orgId: string, taskId: string, verdict: string): void { + const open = this.db.prepare("select id from tasks where org_id = ? and review_of_task_id = ? and status <> 'done'").all(orgId, taskId) as Array<{ id: string }>; + for (const review of open) { + this.db.prepare("update tasks set status = 'done', updated_at = ? where id = ?").run(new Date().toISOString(), review.id); + this.audit.record({ orgId, entityType: "task", entityId: review.id, actorType: "system", actorId: null, action: "review_closed", payload: { reviewedTaskId: taskId, verdict } }); + } + } + + private assertParentAllowed(orgId: string, taskId: string, parentTaskId: string, title: string): void { + if (parentTaskId === taskId) throw new Error(`"${title}" cannot be its own parent.`); + const parent = this.db.prepare("select title from tasks where id = ? and org_id = ?").get(parentTaskId, orgId) as { title: string } | undefined; + if (!parent) throw new Error("Parent task was not found."); + const loop = this.db.prepare(` + with recursive ancestors(id) as ( + select parent_task_id from tasks where id = ? + union select t.parent_task_id from tasks t join ancestors a on t.id = a.id where t.parent_task_id is not null + ) + select 1 from ancestors where id = ? limit 1 + `).get(parentTaskId, taskId); + if (loop) throw new Error(`"${title}" cannot sit under "${parent.title}": "${parent.title}" is already beneath "${title}".`); + } + + private assertNoOpenSubtasks(orgId: string, task: TaskRow): void { + const open = this.db.prepare("select title from tasks where org_id = ? and parent_task_id = ? and status <> 'done' order by created_at") + .all(orgId, task.id) as Array<{ title: string }>; + if (open.length === 0) return; + throw new Error(`"${task.title}" has open subtasks: ${open.map((child) => child.title).join(", ")}. It can close once they are done.`); + } +} diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index fc105ba9..022d063e 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -88,8 +88,8 @@ export interface CreateTaskInput { requiresFilesystem: boolean; /** Defaults to conversational. */ writingMode?: WritingMode; - /** Provenance: 'chat_wake' for mention-wake vehicles, 'goal_review' for CEO goal checkpoints, omitted for plain work. */ - origin?: "chat_wake" | "goal_review"; + /** Provenance: 'chat_wake' for mention-wake vehicles, 'goal_review' for CEO goal checkpoints, 'task_review' for a manager's review of work, omitted for plain work. */ + origin?: "chat_wake" | "goal_review" | "task_review"; } export interface UpdateTaskStatusInput { diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index d3f14cd0..7d6606ae 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68]); connection.close(); }); diff --git a/tests/unit/task-review.test.ts b/tests/unit/task-review.test.ts new file mode 100644 index 00000000..d044e713 --- /dev/null +++ b/tests/unit/task-review.test.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { TaskMutations } from "../../src/main/services/task-mutations"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +function setup() { + const connection = new DatabaseConnection(join(tmpdir(), `ac-review-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + let devId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + ceoId = organization.createAgent({ orgId, name: "Cleo", role: "CEO", department: "Executive", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" }); + devId = organization.createAgent({ orgId, name: "Dev", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ceoId }); + }); + const task = (assigneeAgentId: string, title: string) => organization.createTask({ + orgId, title, description: "Build the login page.", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId, + risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.HIGH, requiresFilesystem: false, + }); + const ctx = (agentId: string): AgentToolContext => ({ orgId, agent: { id: agentId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null }); + const deps = { db: connection.db, organization, chat: new ChatService(connection.db) }; + const reviews = (taskId: string) => connection.db.prepare("select id, title, assignee_agent_id, status, origin, priority, description from tasks where review_of_task_id = ?").all(taskId) as Array>; + return { connection, organization, orgId, ceoId, devId, task, ctx, deps, reviews }; +} + +describe("task review", () => { + test("moving work to review hands a review task to the assignee's manager; the verdict closes it", () => { + const h = setup(); + const taskId = h.task(h.devId, "Login page"); + expect(dispatchAgentTool(h.deps, h.ctx(h.devId), { id: "c1", name: "update_task_status", arguments: { task_id: taskId, status: "review" } })) + .toBe(`Task ${taskId} moved to review. Cleo will review it.`); + const [review] = h.reviews(taskId); + expect(review).toMatchObject({ title: "Review: Login page", assignee_agent_id: h.ceoId, status: "backlog", origin: "task_review", priority: "high" }); + expect(review.description).toContain(`update_task_status(task_id="${taskId}", status="done")`); + expect(review.description).toContain(`update_task_status(task_id="${taskId}", status="in_progress")`); + + // Moving to review again while one is open does not duplicate it. + h.organization.updateTaskStatus(h.orgId, taskId, TASK_STATUS.REVIEW); + expect(h.reviews(taskId)).toHaveLength(1); + + h.organization.updateTaskStatus(h.orgId, taskId, TASK_STATUS.DONE); + expect(h.reviews(taskId).map((row) => row.status)).toEqual(["done"]); + h.connection.close(); + }); + + test("sending work back closes the review; work without a manager goes to the operator", () => { + const h = setup(); + const taskId = h.task(h.devId, "Login page"); + h.organization.updateTaskStatus(h.orgId, taskId, TASK_STATUS.REVIEW); + h.organization.updateTaskStatus(h.orgId, taskId, TASK_STATUS.IN_PROGRESS); + expect(h.reviews(taskId).map((row) => row.status)).toEqual(["done"]); + + const ceoTask = h.task(h.ceoId, "Company plan"); + expect(dispatchAgentTool(h.deps, h.ctx(h.ceoId), { id: "c2", name: "update_task_status", arguments: { task_id: ceoTask, status: "review" } })) + .toBe(`Task ${ceoTask} moved to review. It has no manager to review it, so it waits for the operator on the Work board.`); + expect(h.reviews(ceoTask)).toEqual([]); + h.connection.close(); + }); + + test("a failed review hand-off leaves the task where it was; deleting reviewed work closes its review", () => { + const h = setup(); + const taskId = h.task(h.devId, "Login page"); + const failing = new TaskMutations(h.connection.db, () => { throw new Error("review task could not be created"); }); + expect(() => failing.updateStatus(h.orgId, taskId, TASK_STATUS.REVIEW)).toThrow("review task could not be created"); + expect(h.connection.db.prepare("select status from tasks where id = ?").get(taskId)).toEqual({ status: "in_progress" }); + + h.organization.updateTaskStatus(h.orgId, taskId, TASK_STATUS.REVIEW); + const [review] = h.reviews(taskId); + h.organization.deleteTask({ orgId: h.orgId, taskId }); + expect(h.connection.db.prepare("select status, review_of_task_id from tasks where id = ?").get(review.id)).toEqual({ status: "done", review_of_task_id: null }); + h.connection.close(); + }); +});