From 58871fbaed5aabd3f87a888cf3953723c9d91318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 03:02:04 -0400 Subject: [PATCH] Q161: the home page measures accepted work, not sessions that merely ended Co-Authored-By: Claude Opus 5.5 --- .../database/migrations/103-work-verdicts.ts | 37 ++++++++++++++++ src/main/database/schema.ts | 2 + src/main/services/approval-service.ts | 10 ++++- src/main/services/organization-service.ts | 5 ++- src/main/services/task-mutations.ts | 15 +++++-- src/main/services/usage-stats.ts | 9 ++++ src/main/services/work-verdicts.ts | 9 ++++ src/renderer/src/i18n/zh.ts | 4 +- src/renderer/src/pages/HomePage.tsx | 4 +- .../src/pages/home/DashboardCharts.tsx | 13 +++--- src/shared/usage-stats.ts | 4 ++ tests/unit/domain-services.test.ts | 2 +- tests/unit/execution-modes.test.ts | 11 +++++ tests/unit/usage-stats.test.ts | 32 ++++++++++++++ tests/unit/work-verdicts-backfill.test.ts | 43 +++++++++++++++++++ 15 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 src/main/database/migrations/103-work-verdicts.ts create mode 100644 src/main/services/work-verdicts.ts create mode 100644 tests/unit/work-verdicts-backfill.test.ts diff --git a/src/main/database/migrations/103-work-verdicts.ts b/src/main/database/migrations/103-work-verdicts.ts new file mode 100644 index 00000000..bfcc9c30 --- /dev/null +++ b/src/main/database/migrations/103-work-verdicts.ts @@ -0,0 +1,37 @@ +// Verdicts on plain work, kept apart from tasks so deletions cannot rewrite them; past ones are read once from the audit trail. +export const workVerdictsSchema = ` + create table if not exists work_verdicts ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + task_id text, + verdict text not null check (verdict in ('accepted', 'returned')), + at text not null + ); + create index if not exists work_verdicts_org_at on work_verdicts(org_id, at); + + with moves as ( + select e.id, e.org_id, e.entity_id as task_id, e.created_at as at, json_extract(e.payload_json, '$.status') as status, + lag(json_extract(e.payload_json, '$.status')) over (partition by e.entity_id order by e.created_at, e.rowid) as from_status + from audit_events e + where e.entity_type = 'task' and e.action = 'status_changed' + ) + insert into work_verdicts(id, org_id, task_id, verdict, at) + select 'wv_' || m.id, m.org_id, m.task_id, + case when m.status = 'done' then 'accepted' else 'returned' end, m.at + from moves m join tasks t on t.id = m.task_id and t.origin is null + where (m.status = 'done' and coalesce(m.from_status, '') <> 'done') + or (m.from_status in ('review', 'done') and m.status in ('in_progress', 'backlog') + and not exists ( + select 1 from approvals a join held_deliverables h on h.approval_id = a.id + where h.task_id = m.task_id and a.kind = 'deliverable_review' and a.state = 'declined' + and abs(julianday(m.at) - julianday(a.decided_at)) * 86400 < 5 + )); + + insert into work_verdicts(id, org_id, task_id, verdict, at) + select 'wv_' || a.id, a.org_id, h.task_id, 'returned', a.decided_at + from approvals a join held_deliverables h on h.approval_id = a.id join tasks t on t.id = h.task_id and t.origin is null + where a.kind = 'deliverable_review' and a.state = 'declined' and a.decided_at is not null; + + insert into schema_migrations(version, name, applied_at) + values (103, 'work_verdicts', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index d5195d48..93155f75 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -101,6 +101,7 @@ import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-record import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests"; import { taskBlockedSchema } from "./migrations/101-task-blocked"; import { goalDueDateSchema } from "./migrations/102-goal-due-date"; +import { workVerdictsSchema } from "./migrations/103-work-verdicts"; interface Migration { version: number; @@ -211,6 +212,7 @@ const MIGRATIONS: Migration[] = [ { version: 100, name: "chat_reply_requests", sql: chatReplyRequestsSchema }, { version: 101, name: "task_blocked", sql: taskBlockedSchema }, { version: 102, name: "goal_due_date", sql: goalDueDateSchema }, + { version: 103, name: "work_verdicts", sql: workVerdictsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/approval-service.ts b/src/main/services/approval-service.ts index fa1b8c03..d8529d38 100644 --- a/src/main/services/approval-service.ts +++ b/src/main/services/approval-service.ts @@ -1,3 +1,4 @@ +import { recordWorkVerdict } from "./work-verdicts"; import type { DatabaseSync } from "node:sqlite"; import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands"; import { APPROVAL_STATE, ROLE_KEY, TASK_STATUS } from "../../shared/domain"; @@ -107,7 +108,12 @@ export class ApprovalService { if (approval.source_entity_type === "held_deliverable" && approval.source_entity_id) { const approved = input.state === APPROVAL_STATE.APPROVED; const taskId = settleHeldDeliverable(this.db, approval.source_entity_id, approved); - if (!approved && taskId) this.reopenForRework(input.orgId, taskId); + if (!approved && taskId) { + // A task that no longer exists has no known kind, so its decline is not counted as work. + const task = this.db.prepare("select origin from tasks where id = ?").get(taskId) as { origin: string | null } | undefined; + if (task) recordWorkVerdict(this.db, { orgId: input.orgId, taskId, origin: task.origin, verdict: "returned" }); + this.reopenForRework(input.orgId, taskId); + } } } @@ -115,7 +121,7 @@ export class ApprovalService { private reopenForRework(orgId: string, taskId: string): void { const task = this.db.prepare("select status from tasks where id = ? and org_id = ?").get(taskId, orgId) as { status: string } | undefined; if (!task || task.status === TASK_STATUS.IN_PROGRESS) return; - new OrganizationService(this.db).updateTaskStatus(orgId, taskId, TASK_STATUS.IN_PROGRESS); + new OrganizationService(this.db).updateTaskStatus(orgId, taskId, TASK_STATUS.IN_PROGRESS, "declined_deliverable"); console.info(`[deliverables] task ${taskId} reopened for rework after a declined deliverable`); } diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 6f8187be..98ab1d9a 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,3 +1,4 @@ +import type { StatusCause } from "./task-mutations"; import type { TaskDeleteImpact } from "../../shared/domain"; import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain"; @@ -493,8 +494,8 @@ export class OrganizationService { this.tasks.delete(input); } - updateTaskStatus(orgId: string, taskId: string, status: string): void { - this.tasks.updateStatus(orgId, taskId, status); + updateTaskStatus(orgId: string, taskId: string, status: string, cause: StatusCause | null = null): void { + this.tasks.updateStatus(orgId, taskId, status, cause); } agentSetTaskStatus(input: { orgId: string; taskId: string; status: string; agentId: string; evidence: string | null }): string { diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index 455c74d7..f250f6f0 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -1,3 +1,4 @@ +import { recordWorkVerdict } from "./work-verdicts"; import type { TaskDeleteImpact } from "../../shared/domain"; import type { DatabaseSync } from "node:sqlite"; import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain"; @@ -8,6 +9,9 @@ import { heldWorkTitles } from "./held-deliverables"; type StatusActor = { type: "system" } | { type: "agent"; id: string }; +/** Why a status moved when another decision moved it. */ +export type StatusCause = "declined_deliverable"; + interface TaskRow { id: string; title: string; @@ -123,8 +127,9 @@ export class TaskMutations { }); } - updateStatus(orgId: string, taskId: string, status: string): void { - withSavepoint(this.db, "task_status", () => this.applyStatus(orgId, this.requireTask(orgId, taskId), status, { type: "system" }, null)); + /** `cause` marks a move made by another decision (a declined deliverable), so it is not counted as a second verdict. */ + updateStatus(orgId: string, taskId: string, status: string, cause: StatusCause | null = null): void { + withSavepoint(this.db, "task_status", () => this.applyStatus(orgId, this.requireTask(orgId, taskId), status, { type: "system" }, null, cause)); } /** Assignee, managers up the chain or the open reviewer may move a task; finishing needs evidence and a manager's sign-off. */ @@ -183,7 +188,7 @@ export class TaskMutations { return row?.id ?? null; } - private applyStatus(orgId: string, task: TaskRow, status: string, actor: StatusActor, evidence: string | null): void { + private applyStatus(orgId: string, task: TaskRow, status: string, actor: StatusActor, evidence: string | null, cause: StatusCause | null = null): void { const taskId = task.id; if (status === TASK_STATUS.DONE) this.assertNoOpenSubtasks(orgId, task); const reopening = task.status === TASK_STATUS.DONE && status !== TASK_STATUS.DONE; @@ -203,6 +208,10 @@ export class TaskMutations { action: "status_changed", payload: evidence ? { status, evidence } : { status }, }); + // A verdict on the work: reaching done accepts it; moving back from review or done sends it back, unless a decline (counted there) moved it. + const backward = (task.status === TASK_STATUS.REVIEW || task.status === TASK_STATUS.DONE) && (status === TASK_STATUS.IN_PROGRESS || status === TASK_STATUS.BACKLOG); + if (status === TASK_STATUS.DONE && task.status !== TASK_STATUS.DONE) recordWorkVerdict(this.db, { orgId, taskId, origin: task.origin, verdict: "accepted" }); + else if (backward && cause !== "declined_deliverable") recordWorkVerdict(this.db, { orgId, taskId, origin: task.origin, verdict: "returned" }); if (status === TASK_STATUS.REVIEW && task.status !== TASK_STATUS.REVIEW) this.openReview(orgId, task, evidence); if (task.status === TASK_STATUS.REVIEW && status !== TASK_STATUS.REVIEW) this.closeReviews(orgId, taskId, status); } diff --git a/src/main/services/usage-stats.ts b/src/main/services/usage-stats.ts index aede7d9e..6acc99a7 100644 --- a/src/main/services/usage-stats.ts +++ b/src/main/services/usage-stats.ts @@ -20,6 +20,7 @@ export function buildUsageStats(db: DatabaseSync, now: Date): UsageStatsRecord[] created_at: string; state: string; input_tokens: number | null; output_tokens: number | null; cached_input_tokens: number | null; reasoning_tokens: number | null; cost_cents: number | null; agent_id: string | null; provider_id: string; }>; + const verdicts = taskVerdicts(db, orgId, weekStart); const days = dayStarts.map((start, index) => { const end = index + 1 < dayStarts.length ? dayStarts[index + 1] : localDayStart(now, -1); const inDay = (iso: string) => { const at = Date.parse(iso); return at >= start.getTime() && at < end.getTime(); }; @@ -30,6 +31,8 @@ export function buildUsageStats(db: DatabaseSync, now: Date): UsageStatsRecord[] runs: dayRuns.length, completedRuns: dayRuns.filter((run) => run.state === "completed").length, failedRuns: dayRuns.filter((run) => run.state === "failed").length, + acceptedTasks: verdicts.filter((verdict) => verdict.accepted && inDay(verdict.at)).length, + returnedTasks: verdicts.filter((verdict) => !verdict.accepted && inDay(verdict.at)).length, tokens: dayCalls.reduce((sum, call) => sum + (call.input_tokens ?? 0) + (call.output_tokens ?? 0), 0), callsWithoutUsage: dayCalls.filter((call) => call.input_tokens === null).length, }; @@ -69,6 +72,12 @@ export function buildUsageStats(db: DatabaseSync, now: Date): UsageStatsRecord[] }); } +/** Since `since`, each verdict on plain work, as recorded where it was made. */ +function taskVerdicts(db: DatabaseSync, orgId: string, since: string): Array<{ at: string; accepted: boolean }> { + const rows = db.prepare("select verdict, at from work_verdicts where org_id = ? and at >= ?").all(orgId, since) as Array<{ verdict: string; at: string }>; + return rows.map((row) => ({ at: row.at, accepted: row.verdict === "accepted" })); +} + /** Local midnight of the calendar day `daysAgo` days before today; calendar arithmetic keeps DST days 23 or 25 hours long. */ function localDayStart(now: Date, daysAgo: number): Date { return new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo); diff --git a/src/main/services/work-verdicts.ts b/src/main/services/work-verdicts.ts new file mode 100644 index 00000000..9cef38f7 --- /dev/null +++ b/src/main/services/work-verdicts.ts @@ -0,0 +1,9 @@ +import type { DatabaseSync } from "node:sqlite"; +import { createId } from "../../shared/ids"; + +/** Records a verdict on a plain task (one the system did not create for itself); internal vehicles are not work. */ +export function recordWorkVerdict(db: DatabaseSync, input: { orgId: string; taskId: string; origin: string | null; verdict: "accepted" | "returned" }): void { + if (input.origin !== null) return; + db.prepare("insert into work_verdicts(id, org_id, task_id, verdict, at) values (?, ?, ?, ?, ?)") + .run(createId("wv"), input.orgId, input.taskId, input.verdict, new Date().toISOString()); +} diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 5182b8c1..0adcb341 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -54,6 +54,8 @@ export const zh: Record = { "Cancel run": "取消运行", "Show older runs": "显示更早的运行", "Target date": "目标日期", + "Work accepted (7 days)": "工作验收(近 7 天)", + "{accepted} done · {returned} sent back · sessions: {ok} ended, {failed} failed": "{accepted} 项完成 · {returned} 项退回 · 会话:{ok} 次结束,{failed} 次失败", "no target date": "未设目标日期", "overdue": "已逾期", "These are all the runs.": "已显示全部运行。", @@ -126,7 +128,6 @@ export const zh: Record = { "This model follows instructions and calls tools.": "该模型能遵循指令并调用工具。", "This model cannot do agent work here: {detail}": "该模型无法在此完成 Agent 工作:{detail}", "{n} in the last 7 days": "近 7 天 {n}", - "Success rate (7 days)": "成功率(近 7 天)", "{n} tokens in the last 7 days": "近 7 天 {n} token", "{n} tokens in the last 7 days · {count} calls without usage": "近 7 天 {n} token · {count} 次调用未报告用量", "Spent this month": "本月已花费", @@ -660,7 +661,6 @@ export const zh: Record = { "{n} task": "{n} 个任务", "{n} tasks": "{n} 个任务", "{n} this week": "本周 {n} 次", - "{ok} ok · {failed} failed": "{ok} 成功 · {failed} 失败", "{passed}/{total} passed": "{passed}/{total} 通过", "{pending} pending · {approved} approved this week": "{pending} 待处理 · 本周已批准 {approved}", "{percent}% of cap": "上限的 {percent}%", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index 4cb4dd54..8a0249fe 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -15,7 +15,7 @@ import type { TaskRunRecord, } from "../../../shared/domain"; import { NewTaskDialog } from "./NewTaskDialog"; -import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts"; +import { AcceptanceChart, AgentWorkloadChart, CostTrendChart, RunActivityChart } from "./home/DashboardCharts"; import type { PageProps } from "./types"; import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types"; import { t } from "../i18n/i18n"; @@ -75,7 +75,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
- +
diff --git a/src/renderer/src/pages/home/DashboardCharts.tsx b/src/renderer/src/pages/home/DashboardCharts.tsx index a84cb1f9..dd200ce6 100644 --- a/src/renderer/src/pages/home/DashboardCharts.tsx +++ b/src/renderer/src/pages/home/DashboardCharts.tsx @@ -97,16 +97,19 @@ export function RunActivityChart({ days }: { days: UsageDays }) { ); } -export function SuccessRateChart({ days }: { days: UsageDays }) { +/** Of the work judged this week, how much was accepted (done) rather than sent back; session outcomes are shown apart, as what they are. */ +export function AcceptanceChart({ days }: { days: UsageDays }) { + const accepted = days.reduce((sum, day) => sum + day.acceptedTasks, 0); + const returned = days.reduce((sum, day) => sum + day.returnedTasks, 0); const completed = days.reduce((sum, day) => sum + day.completedRuns, 0); const failed = days.reduce((sum, day) => sum + day.failedRuns, 0); - const total = completed + failed; - const rate = total > 0 ? Math.round((completed / total) * 100) : 0; + const judged = accepted + returned; + const rate = judged > 0 ? Math.round((accepted / judged) * 100) : 0; const circumference = 2 * Math.PI * 42; const offset = circumference - (rate / 100) * circumference; return ( - + - {rate}% + {judged > 0 ? `${rate}%` : "—"} ); diff --git a/src/shared/usage-stats.ts b/src/shared/usage-stats.ts index 8871681d..c6dac849 100644 --- a/src/shared/usage-stats.ts +++ b/src/shared/usage-stats.ts @@ -6,6 +6,10 @@ export interface UsageStatsRecord { runs: number; completedRuns: number; failedRuns: number; + /** Plain tasks that reached done this day. */ + acceptedTasks: number; + /** Plain tasks moved back from review or done to in progress or backlog this day. */ + returnedTasks: number; tokens: number; callsWithoutUsage: number; }>; diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 8c05557c..5b867d69 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -35,7 +35,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, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102]); + 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, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103]); connection.close(); }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index f6bd1c8e..51cf95eb 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { currentLinks } from "../helpers/task-links"; +import type { DatabaseSync } from "node:sqlite"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -12,6 +13,7 @@ import { ApprovalService } from "../../src/main/services/approval-service"; import { runJournalLines } from "../../src/main/services/run-journal"; import { DocumentService } from "../../src/main/services/document-service"; import { approvalDrifts } from "../../src/main/services/approval-drift"; +import { buildUsageStats } from "../../src/main/services/usage-stats"; import { createIsolationContext, executionApprovalProblem, loadWorkspace, requireAssignedAgent } from "../../src/main/services/task-run-preparation"; import { TASK_PRIORITY } from "../../src/shared/domain"; import type { TaskExecutionTaskRow } from "../../src/main/services/task-prompts"; @@ -74,6 +76,11 @@ function pendingApprovals(h: Harness, kind: string): Array<{ state: string; sour ).all(h.orgId, kind) as Array<{ state: string; source_entity_type: string | null }>; } +/** Work sent back this week, as the home page's acceptance chart counts it. */ +function returned(db: DatabaseSync, orgId: string): number { + return buildUsageStats(db, new Date()).find((item) => item.orgId === orgId)!.days.reduce((sum, day) => sum + day.returnedTasks, 0); +} + describe("execution modes", () => { test("AI Co-Founder mode holds every deliverable and document until the board approves it", () => { const h = harness("ai_cofounder"); @@ -128,6 +135,8 @@ describe("execution modes", () => { h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review, state: "declined", decidedByEmployeeId: employeeId })); expect(existsSync(join(h.ctx.workspace!.localPath, "plan.md"))).toBe(false); expect(db.prepare("select state from held_deliverables").get()).toEqual({ state: "declined" }); + // A decline on work still in progress sends it back once, though the task did not move. + expect(returned(db, h.orgId)).toBe(1); finish(); }); @@ -145,6 +154,8 @@ describe("execution modes", () => { const review = (db.prepare("select id from approvals where org_id = ? and kind = 'deliverable_review'").get(h.orgId) as { id: string }).id; h.connection.transaction(() => new ApprovalService(db).decide({ orgId: h.orgId, approvalId: review, state: "declined", decidedByEmployeeId: employeeId, notes: "Cite the market data." })); expect(db.prepare("select status from tasks where id = ?").get(taskId)).toEqual({ status: "in_progress" }); + // The decline and the reopen it caused are one verdict, not two. + expect(returned(db, h.orgId)).toBe(1); expect(runJournalLines(db, taskId).join("\n")).toContain("The board declined plan.md: Cite the market data. Redo it before closing the task."); // Another deliverable under the same name elsewhere does not answer the decline; a resubmission of the same one does. const elsewhere = join(h.ctx.workspace!.localPath, "other"); diff --git a/tests/unit/usage-stats.test.ts b/tests/unit/usage-stats.test.ts index fbf68085..7b0a70fd 100644 --- a/tests/unit/usage-stats.test.ts +++ b/tests/unit/usage-stats.test.ts @@ -66,3 +66,35 @@ describe("usage statistics", () => { process.env.TZ = previous; }); }); + +describe("accepted work", () => { + test("counts plain tasks reaching done and work sent back, not sessions that merely ended", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-acceptance-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + const ids: string[] = []; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + for (const title of ["Ship", "Spec", "Chat reply"]) { + ids.push(organization.createTask({ + orgId, title, description: "d", status: "backlog", assigneeAgentId: null, risk: "low", projectId: null, goalId: null, + workspaceId: null, priority: "normal", requiresFilesystem: false, ...(title === "Chat reply" ? { origin: "chat_wake" as const } : {}), + })); + } + }); + const [ship, spec, reply] = ids; + const move = (taskId: string, status: string) => connection.transaction(() => organization.updateTaskStatus(orgId, taskId, status)); + move(ship, "in_progress"); move(ship, "review"); move(ship, "done"); + move(spec, "in_progress"); move(spec, "review"); move(spec, "in_progress"); + move(reply, "done"); + const week = buildUsageStats(connection.db, new Date()).find((item) => item.orgId === orgId)!.days; + const total = (pick: (day: typeof week[number]) => number) => week.reduce((sum, day) => sum + pick(day), 0); + expect([total((day) => day.acceptedTasks), total((day) => day.returnedTasks)]).toEqual([1, 1]); + // Marking done twice is one acceptance; deleting the task keeps the history. + move(ship, "done"); + connection.transaction(() => organization.deleteTask({ orgId, taskId: ship })); + const after = buildUsageStats(connection.db, new Date()).find((item) => item.orgId === orgId)!.days; + expect([after.reduce((sum, day) => sum + day.acceptedTasks, 0), after.reduce((sum, day) => sum + day.returnedTasks, 0)]).toEqual([1, 1]); + connection.close(); + }); +}); diff --git a/tests/unit/work-verdicts-backfill.test.ts b/tests/unit/work-verdicts-backfill.test.ts new file mode 100644 index 00000000..cf62f2f2 --- /dev/null +++ b/tests/unit/work-verdicts-backfill.test.ts @@ -0,0 +1,43 @@ +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 { workVerdictsSchema } from "../../src/main/database/migrations/103-work-verdicts"; + +describe("past verdicts on work", () => { + test("are read once from the audit trail: kind known, done once, a decline and its reopen counted once", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-verdict-backfill-${randomUUID()}.db`)); + const db = connection.db; + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(db).createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + }); + const task = (id: string, origin: string | null) => db.prepare("insert into tasks(id, org_id, title, description, status, risk, priority, requires_filesystem, writing_mode, origin, created_at, updated_at) values (?, ?, ?, 'd', 'backlog', 'low', 'normal', 0, 'conversational', ?, ?, ?)") + .run(id, orgId, id, origin, "2026-05-01T00:00:00.000Z", "2026-05-01T00:00:00.000Z"); + let n = 0; + const event = (taskId: string, status: string, at: string) => db.prepare("insert into audit_events(id, org_id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at) values (?, ?, 'task', ?, 'system', null, 'status_changed', ?, ?)") + .run(`aud_${n++}`, orgId, taskId, JSON.stringify({ status }), at); + task("tsk_plain", null); + task("tsk_wake", "chat_wake"); + event("tsk_plain", "review", "2026-05-02T00:00:00.000Z"); + event("tsk_plain", "done", "2026-05-02T01:00:00.000Z"); + event("tsk_plain", "done", "2026-05-02T02:00:00.000Z"); + event("tsk_wake", "done", "2026-05-02T03:00:00.000Z"); + event("tsk_gone", "done", "2026-05-02T04:00:00.000Z"); + // A decline reopened the plain task a moment after it was decided. + db.prepare("insert into approvals(id, org_id, kind, title, details, risk, requested_by_type, requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at) values ('app_d', ?, 'deliverable_review', 't', 'd', 'low', 'system', null, 'held_deliverable', 'held_1', 'declined', null, ?, ?)") + .run(orgId, "2026-05-03T00:00:00.000Z", "2026-05-03T00:00:00.000Z"); + db.prepare("insert into held_deliverables(id, org_id, approval_id, kind, task_id, content, state, created_at) values ('held_1', ?, 'app_d', 'file', 'tsk_plain', 'x', 'declined', ?)") + .run(orgId, "2026-05-03T00:00:00.000Z"); + event("tsk_plain", "in_progress", "2026-05-03T00:00:00.500Z"); + db.exec("delete from work_verdicts"); + db.exec(workVerdictsSchema.slice(0, workVerdictsSchema.indexOf("insert into schema_migrations"))); + expect(db.prepare("select task_id, verdict from work_verdicts order by at").all()).toEqual([ + { task_id: "tsk_plain", verdict: "accepted" }, + { task_id: "tsk_plain", verdict: "returned" }, + ]); + connection.close(); + }); +});