Q161: the home page measures accepted work, not sessions that merely ended
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9117fe46d
commit
58871fbaed
15 files changed
+183
-17
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export const zh: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"{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}%",
|
||||
|
||||
@@ -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) {
|
||||
<div style={METRICS}>
|
||||
<AgentWorkloadChart agents={agents} tasks={businessTasks} />
|
||||
<RunActivityChart days={usageStats.days} />
|
||||
<SuccessRateChart days={usageStats.days} />
|
||||
<AcceptanceChart days={usageStats.days} />
|
||||
<CostTrendChart days={usageStats.days} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ChartCard title={t("Success rate (7 days)")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
|
||||
<ChartCard title={t("Work accepted (7 days)")} readout={t("{accepted} done · {returned} sent back · sessions: {ok} ended, {failed} failed", { accepted, returned, ok: completed, failed })}>
|
||||
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
|
||||
<circle
|
||||
@@ -115,7 +118,7 @@ export function SuccessRateChart({ days }: { days: UsageDays }) {
|
||||
strokeLinecap="round" transform="rotate(-90 50 50)"
|
||||
style={{ transition: "stroke-dashoffset var(--motion-slow) var(--ease-out)" }}
|
||||
/>
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="20" fontWeight="600" fill="var(--text-1)">{rate}%</text>
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="20" fontWeight="600" fill="var(--text-1)">{judged > 0 ? `${rate}%` : "—"}</text>
|
||||
</svg>
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
Reference in new issue
Block a user