43 KiB
Workflow and Effort Engine Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make DevTask, TestCase, and Bug status changes single-item only, move timestamp rules into workflow helpers, add TestCase estimates and weighted effort progress, and tighten AI estimates for AI-assisted delivery.
Architecture: Add small pure rule helpers in apps/web/lib/ before changing stores or UI. Stores will call workflow helpers for single-item commands; UI will display derived effort from helpers instead of encoding status or time rules inline. AI estimate policy stays as a pure helper used when adopting AI drafts, while the server prompt/schema defines the upstream contract.
Tech Stack: Next.js 14, React, Zustand, TypeScript, Node node:test for web pure-function tests, NestJS/Jest for AI schema tests, existing PostgreSQL AppData persistence.
File Structure
- Create
apps/web/lib/dev-task-workflow.ts: DevTask single-item transition helper and create normalization. - Create
apps/web/lib/dev-task-workflow.test.ts: DevTask transition, timestamp, and AI draft default tests. - Create
apps/web/lib/test-case-workflow.ts: TestCase single-item transition helper and create normalization. - Create
apps/web/lib/test-case-workflow.test.ts: TestCase transition, timestamp, and reason cleanup tests. - Create
apps/web/lib/bug-workflow.ts: Bug single-item transition helper with log patch generation. - Create
apps/web/lib/bug-workflow.test.ts: Bug transition and timestamp tests. - Create
apps/web/lib/work-effort-engine.ts: shared estimate, actual, weighted progress aggregation. - Create
apps/web/lib/work-effort-engine.test.ts: weighted progress and rounding tests. - Create
apps/web/lib/ai-estimation-policy.ts: strict estimate ranges for AI-assisted dev/test work. - Create
apps/web/lib/ai-estimation-policy.test.ts: estimate clamp and default tests. - Modify
apps/web/lib/dev-task.ts: addestimateHours, make estimates prefer explicit hours, removeestimateHoursfrom legacy discard marker. - Modify
apps/web/lib/dev-task-transitions.ts: keep delay detection only; stop using auto-start helpers. - Modify
apps/web/stores/useDevTaskStore.ts: remove auto-start scan, use workflow helper forchangeStatus, normalize created tasks. - Modify
apps/web/components/dev-task/DevTaskCreateModal.tsx: persist computedestimateHours. - Modify
apps/web/components/dev-task/DevTaskDetailDrawer.tsx: import transitions from workflow constants if needed. - Modify
apps/web/lib/test-case.ts: addestimateHours, labels待测试/测试中, derived estimate/actual/progress helpers. - Modify
apps/web/stores/useTestCaseStore.ts: normalizeestimateHours, use workflow helper forchangeStatus. - Modify
apps/web/components/test-case/TestCaseCreateModal.tsx: add strict estimate input with 0.25h step. - Modify
apps/web/components/test-case/TestCaseTab.tsx: show total estimate, actual, weighted completion, and pass rate. - Modify
apps/web/components/test-case/TestCaseRow.tsx: show actual/estimate per row. - Modify
apps/web/components/test-case/TestCaseDetailDrawer.tsx: show estimate, actual start, completed time, and actual/estimate. - Modify
apps/web/stores/useBugStore.ts: use Bug workflow helper. - Modify
packages/shared/src/agent.ts: addestimateHourstoAgentTestCaseDraft. - Modify
apps/server/src/modules/ai/prompts/decompose.ts: require TestCase estimate and tighten estimate rules. - Modify
apps/server/src/modules/ai/ai.service.spec.ts: assert TestCase estimate schema. - Modify
apps/web/components/version/DecomposeReportModal.tsx: clamp AI estimates, useaddWorkHours, persist TestCase estimates.
Task 1: Add Shared Work Effort Engine
Files:
-
Create:
apps/web/lib/work-effort-engine.ts -
Create:
apps/web/lib/work-effort-engine.test.ts -
Step 1: Write failing effort tests
Create apps/web/lib/work-effort-engine.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine';
test('roundHalfHour rounds to nearest half hour', () => {
assert.equal(roundHalfHour(0.24), 0);
assert.equal(roundHalfHour(0.25), 0.5);
assert.equal(roundHalfHour(1.24), 1);
assert.equal(roundHalfHour(1.25), 1.5);
});
test('aggregateWorkEffort calculates weighted progress by estimate', () => {
const result = aggregateWorkEffort([
{ estimateHours: 1, actualHours: 0, progress: 0 },
{ estimateHours: 3, actualHours: 1.25, progress: 100 },
]);
assert.equal(result.estimateHours, 4);
assert.equal(result.actualHours, 1.5);
assert.equal(result.progress, 75);
});
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
const result = aggregateWorkEffort([
{ estimateHours: 0, actualHours: 0, progress: 50 },
{ estimateHours: 0, actualHours: 0, progress: 100 },
]);
assert.equal(result.progress, 75);
});
- Step 2: Run test and confirm failure
Run:
pnpm --filter web test
Expected: FAIL because work-effort-engine.ts does not exist.
- Step 3: Implement effort engine
Create apps/web/lib/work-effort-engine.ts:
export interface WorkEffortItem {
estimateHours: number;
actualHours: number;
progress: number;
}
export interface WorkEffortSummary {
estimateHours: number;
actualHours: number;
progress: number;
}
export function roundHalfHour(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Math.round(hours * 2) / 2;
}
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
if (estimateHours <= 0) {
const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length);
return { estimateHours, actualHours, progress };
}
const weighted = items.reduce((sum, item) => {
const estimate = Math.max(0, item.estimateHours || 0);
return sum + estimate * item.progress;
}, 0);
return {
estimateHours,
actualHours,
progress: Math.round(weighted / estimateHours),
};
}
- Step 4: Run tests
Run:
pnpm --filter web test
Expected: PASS.
- Step 5: Commit
git add apps/web/lib/work-effort-engine.ts apps/web/lib/work-effort-engine.test.ts
git commit -m "feat(工时): 增加通用工时汇总引擎"
Task 2: Add DevTask Workflow Helper and Stop Auto Start
Files:
-
Create:
apps/web/lib/dev-task-workflow.ts -
Create:
apps/web/lib/dev-task-workflow.test.ts -
Modify:
apps/web/lib/dev-task.ts -
Modify:
apps/web/lib/dev-task-transitions.ts -
Modify:
apps/web/stores/useDevTaskStore.ts -
Modify:
apps/web/components/dev-task/DevTaskCreateModal.tsx -
Step 1: Write failing workflow tests
Create apps/web/lib/dev-task-workflow.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DevTask } from './dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow';
function task(patch: Partial<DevTask> = {}): DevTask {
return {
id: 'task-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: '实现拖拽排序',
categoryId: 'cat-frontend-interaction',
assigneeId: 'Alice',
priority: 'P2',
expectedStartAt: '2026-06-25T01:00:00.000Z',
expectedEndAt: '2026-06-25T02:00:00.000Z',
status: 'todo',
isBlocked: false,
createdBy: 'PM',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
...patch,
};
}
test('normalizeDevTaskOnCreate forces todo and clears actual timestamps for AI drafts', () => {
const normalized = normalizeDevTaskOnCreate(task({
status: 'in_progress',
actualStartAt: '2026-06-25T01:00:00.000Z',
actualEndAt: '2026-06-25T02:00:00.000Z',
aiDraft: true,
}));
assert.equal(normalized.status, 'todo');
assert.equal(normalized.actualStartAt, undefined);
assert.equal(normalized.actualEndAt, undefined);
});
test('todo to in_progress writes actualStartAt from manual click time', () => {
const result = applyDevTaskTransition(task(), 'in_progress', {
now: new Date('2026-06-25T03:30:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.status, 'in_progress');
assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z');
});
test('in_progress to testing does not write actualEndAt', () => {
const result = applyDevTaskTransition(task({
status: 'in_progress',
actualStartAt: '2026-06-25T03:30:00.000Z',
}), 'testing', {
now: new Date('2026-06-25T04:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.actualEndAt, undefined);
});
test('testing to submitted writes actualEndAt', () => {
const result = applyDevTaskTransition(task({
status: 'testing',
actualStartAt: '2026-06-25T03:30:00.000Z',
}), 'submitted', {
now: new Date('2026-06-25T05:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z');
});
test('invalid transition is rejected', () => {
const result = applyDevTaskTransition(task(), 'submitted', {
now: new Date('2026-06-25T05:00:00.000Z'),
});
assert.equal(result.ok, false);
assert.match(result.message || '', /不允许/);
});
- Step 2: Run test and confirm failure
Run:
pnpm --filter web test
Expected: FAIL because dev-task-workflow.ts does not exist.
- Step 3: Implement DevTask workflow helper
Create apps/web/lib/dev-task-workflow.ts:
import type { DevTask, DevTaskStatus } from './dev-task';
import { canTransition } from './dev-task';
export interface DevTaskWorkflowResult {
ok: boolean;
patch?: Partial<DevTask>;
message?: string;
}
export interface DevTaskTransitionOptions {
now?: Date;
delayReason?: string;
}
export function normalizeDevTaskOnCreate(task: DevTask): DevTask {
return {
...task,
status: 'todo',
actualStartAt: undefined,
actualEndAt: undefined,
};
}
export function applyDevTaskTransition(
task: DevTask,
to: DevTaskStatus,
options: DevTaskTransitionOptions = {},
): DevTaskWorkflowResult {
if (!canTransition(task.status, to)) {
return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
}
const nowIso = (options.now ?? new Date()).toISOString();
const patch: Partial<DevTask> = {
status: to,
aiDraft: false,
};
if (to === 'in_progress' && !task.actualStartAt) {
patch.actualStartAt = nowIso;
if (options.delayReason?.trim()) patch.delayReason = options.delayReason.trim();
}
if (to === 'submitted') {
patch.actualEndAt = nowIso;
}
return { ok: true, patch };
}
- Step 4: Add explicit DevTask estimate support
Modify apps/web/lib/dev-task.ts:
export interface DevTask {
// existing fields...
expectedStartAt: string;
expectedEndAt: string;
estimateHours?: number;
actualStartAt?: string;
actualEndAt?: string;
// existing fields...
}
Change getEstimateHours:
export function getEstimateHours(task: DevTask): number {
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
return Math.round(task.estimateHours * 2) / 2;
}
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
}
Change isLegacyTask by removing estimateHours from the legacy marker list:
export function isLegacyTask(t: any): boolean {
if (!t || typeof t !== 'object') return false;
return (
'startDate' in t ||
'dueDate' in t ||
'completedAt' in t ||
'actualHours' in t ||
'overdueReason' in t
);
}
- Step 5: Remove auto-start from DevTask fetch
Modify apps/web/stores/useDevTaskStore.ts imports:
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
Remove these imports:
import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions';
Replace fetchTasks with:
fetchTasks: async () => {
const cached = await loadStored();
set({ tasks: cached ?? [] });
},
Update createTask:
const task: DevTask = normalizeDevTaskOnCreate({
...data,
id: `task-${Date.now()}`,
taskNo: generateTaskNo(list),
isBlocked: false,
createdAt: now,
updatedAt: now,
} as DevTask);
Update changeStatus:
changeStatus: (id, to, opts) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return { ok: false, message: '任务不存在' };
const result = applyDevTaskTransition(task, to, {
now: new Date(),
delayReason: opts?.delayReason,
});
if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateTask(id, result.patch);
return { ok: true };
},
- Step 6: Keep delay helper but stop using auto-start helpers
Modify apps/web/lib/dev-task-transitions.ts so only needsDelayReason remains exported:
import type { DevTask } from './dev-task';
export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean {
if (task.status !== 'todo' || !task.expectedStartAt) return false;
return now.getTime() > new Date(task.expectedStartAt).getTime();
}
- Step 7: Persist manual DevTask estimate
Modify apps/web/components/dev-task/DevTaskCreateModal.tsx in createTask payload:
estimateHours,
The payload section should include:
expectedStartAt: expectedStartISO,
expectedEndAt: expectedEndISO,
estimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
- Step 8: Run verification
Run:
pnpm --filter web test
pnpm --filter web type-check
Expected: both pass, and DevTask fetch no longer changes task status automatically.
- Step 9: Commit
git add apps/web/lib/dev-task-workflow.ts apps/web/lib/dev-task-workflow.test.ts apps/web/lib/dev-task.ts apps/web/lib/dev-task-transitions.ts apps/web/stores/useDevTaskStore.ts apps/web/components/dev-task/DevTaskCreateModal.tsx
git commit -m "feat(开发任务): 状态流转接入单条工作流"
Task 3: Add TestCase Workflow, Estimates, and Weighted Progress
Files:
-
Create:
apps/web/lib/test-case-workflow.ts -
Create:
apps/web/lib/test-case-workflow.test.ts -
Modify:
apps/web/lib/test-case.ts -
Modify:
apps/web/stores/useTestCaseStore.ts -
Step 1: Write failing TestCase workflow tests
Create apps/web/lib/test-case-workflow.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import type { TestCase } from './test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';
function tc(patch: Partial<TestCase> = {}): TestCase {
return {
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'version-1',
title: '拖拽排序正常',
description: '验证拖拽排序',
categoryId: 'cat-test-functional',
priority: 'P2',
status: 'pending',
estimateHours: 0.5,
createdBy: 'QA',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
...patch,
};
}
test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', () => {
const result = normalizeTestCaseOnCreate(tc({
status: 'running',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
}));
assert.equal(result.status, 'pending');
assert.equal(result.startedAt, undefined);
assert.equal(result.completedAt, undefined);
});
test('pending to running writes startedAt', () => {
const result = applyTestCaseTransition(tc(), 'running', {
now: new Date('2026-06-25T01:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
});
test('running to passed writes completedAt', () => {
const result = applyTestCaseTransition(tc({
status: 'running',
startedAt: '2026-06-25T01:00:00.000Z',
}), 'passed', {
now: new Date('2026-06-25T02:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.completedAt, '2026-06-25T02:00:00.000Z');
});
test('failed back to running clears failure and completion reason', () => {
const result = applyTestCaseTransition(tc({
status: 'failed',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
failReason: '排序未保存',
}), 'running', {
now: new Date('2026-06-25T03:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.startedAt, undefined);
assert.equal(result.patch?.completedAt, undefined);
assert.equal(result.patch?.failReason, undefined);
});
test('invalid transition is rejected', () => {
const result = applyTestCaseTransition(tc(), 'passed', {
now: new Date('2026-06-25T02:00:00.000Z'),
});
assert.equal(result.ok, false);
});
- Step 2: Run test and confirm failure
Run:
pnpm --filter web test
Expected: FAIL because test-case-workflow.ts does not exist.
- Step 3: Implement TestCase workflow helper
Create apps/web/lib/test-case-workflow.ts:
import type { TestCase, TestCaseStatus } from './test-case';
import { canTcTransition } from './test-case';
export interface TestCaseWorkflowResult {
ok: boolean;
patch?: Partial<TestCase>;
message?: string;
}
export interface TestCaseTransitionOptions {
now?: Date;
failReason?: string;
blockReason?: string;
}
export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase {
return {
...testCase,
status: 'pending',
startedAt: undefined,
completedAt: undefined,
executedAt: undefined,
failReason: undefined,
blockReason: undefined,
};
}
export function applyTestCaseTransition(
testCase: TestCase,
to: TestCaseStatus,
options: TestCaseTransitionOptions = {},
): TestCaseWorkflowResult {
if (!canTcTransition(testCase.status, to)) {
return { ok: false, message: `不允许从「${testCase.status}」流转到「${to}」` };
}
const nowIso = (options.now ?? new Date()).toISOString();
const patch: Partial<TestCase> = {
status: to,
aiDraft: false,
executedAt: nowIso,
};
if (to === 'running') {
if (!testCase.startedAt) patch.startedAt = nowIso;
patch.completedAt = undefined;
patch.failReason = undefined;
patch.blockReason = undefined;
}
if (to === 'passed' || to === 'failed' || to === 'blocked') {
patch.completedAt = nowIso;
}
if (to === 'failed' && options.failReason?.trim()) patch.failReason = options.failReason.trim();
if (to === 'blocked' && options.blockReason?.trim()) patch.blockReason = options.blockReason.trim();
return { ok: true, patch };
}
- Step 4: Add TestCase estimate and weighted helpers
Modify apps/web/lib/test-case.ts:
import { aggregateWorkEffort } from './work-effort-engine';
Add estimateHours?: number:
export interface TestCase {
// existing fields...
status: TestCaseStatus;
estimateHours?: number;
startedAt?: string;
completedAt?: string;
// existing fields...
}
Change status labels:
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
pending: '待测试',
running: '测试中',
passed: '通过',
failed: '不通过',
blocked: '阻塞',
};
Update normalizeTestCase:
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
Add progress and estimate helpers:
export const TEST_CASE_STATUS_PROGRESS: Record<TestCaseStatus, number> = {
pending: 0,
running: 50,
passed: 100,
failed: 100,
blocked: 100,
};
export function getTestCaseEstimateHours(tc: TestCase): number {
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
? Math.round(tc.estimateHours * 2) / 2
: 0.5;
}
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
const summary = aggregateWorkEffort(cases.map((c) => ({
estimateHours: getTestCaseEstimateHours(c),
actualHours: getTestCaseActualHours(c, now),
progress: TEST_CASE_STATUS_PROGRESS[c.status],
})));
return { estimate: summary.estimateHours, actual: summary.actualHours };
}
Update calcTestProgress to keep the existing return shape but make completionRate weighted:
export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
const total = cases.length;
if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
const passed = cases.filter((c) => c.status === 'passed').length;
const failed = cases.filter((c) => c.status === 'failed').length;
const blocked = cases.filter((c) => c.status === 'blocked').length;
const executed = passed + failed + blocked;
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
const effort = aggregateWorkEffort(cases.map((c) => ({
estimateHours: getTestCaseEstimateHours(c),
actualHours: getTestCaseActualHours(c),
progress: TEST_CASE_STATUS_PROGRESS[c.status],
})));
return { total, executed, passed, failed, blocked, passRate, completionRate: effort.progress };
}
- Step 5: Update TestCase store to use workflow
Modify apps/web/stores/useTestCaseStore.ts imports:
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
Update createTestCase:
const tc: TestCase = normalizeTestCaseOnCreate({
...data,
id: `tc-${Date.now()}`,
caseNo: generateCaseNo(list),
status: 'pending',
estimateHours: data.estimateHours ?? 0.5,
createdAt: now,
updatedAt: now,
} as TestCase);
Update changeStatus:
changeStatus: (id, to, extra) => {
const tc = get().testCases.find((c) => c.id === id);
if (!tc) return { ok: false, message: '用例不存在' };
const result = applyTestCaseTransition(tc, to, {
now: new Date(),
failReason: extra?.failReason,
blockReason: extra?.blockReason,
});
if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateTestCase(id, result.patch);
return { ok: true };
},
- Step 6: Run verification
Run:
pnpm --filter web test
pnpm --filter web type-check
Expected: both pass.
- Step 7: Commit
git add apps/web/lib/test-case-workflow.ts apps/web/lib/test-case-workflow.test.ts apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts
git commit -m "feat(测试用例): 状态流转接入单条工作流"
Task 4: Add Bug Workflow Helper
Files:
-
Create:
apps/web/lib/bug-workflow.ts -
Create:
apps/web/lib/bug-workflow.test.ts -
Modify:
apps/web/stores/useBugStore.ts -
Step 1: Write failing Bug workflow tests
Create apps/web/lib/bug-workflow.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Bug } from './bug';
import { applyBugTransition } from './bug-workflow';
function bug(patch: Partial<Bug> = {}): Bug {
return {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'version-1',
testCaseId: 'tc-1',
title: '排序未保存',
description: '拖拽后刷新丢失',
severity: 'major',
priority: 'P1',
reportedBy: 'QA',
assigneeId: 'Dev',
status: 'open',
logs: [],
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
...patch,
};
}
test('open to fixing changes only one bug patch', () => {
const result = applyBugTransition(bug(), 'fixing', 'Dev', {
now: new Date('2026-06-25T01:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.status, 'fixing');
assert.equal(result.patch?.logs?.length, 1);
});
test('fixing to fixed writes resolvedAt and resolution', () => {
const result = applyBugTransition(bug({ status: 'fixing' }), 'fixed', 'Dev', {
now: new Date('2026-06-25T02:00:00.000Z'),
resolution: '补充保存接口',
});
assert.equal(result.ok, true);
assert.equal(result.patch?.resolvedAt, '2026-06-25T02:00:00.000Z');
assert.equal(result.patch?.resolution, '补充保存接口');
});
test('verifying to closed writes closedAt', () => {
const result = applyBugTransition(bug({ status: 'verifying' }), 'closed', 'QA', {
now: new Date('2026-06-25T03:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.closedAt, '2026-06-25T03:00:00.000Z');
});
test('invalid bug transition is rejected', () => {
const result = applyBugTransition(bug(), 'closed', 'QA');
assert.equal(result.ok, false);
});
- Step 2: Run test and confirm failure
Run:
pnpm --filter web test
Expected: FAIL because bug-workflow.ts does not exist.
- Step 3: Implement Bug workflow helper
Create apps/web/lib/bug-workflow.ts:
import type { Bug, BugLog, BugStatus } from './bug';
import { canBugTransition } from './bug';
export interface BugTransitionOptions {
now?: Date;
resolution?: string;
}
export interface BugWorkflowResult {
ok: boolean;
patch?: Partial<Bug>;
message?: string;
}
function makeBugLog(
action: BugLog['action'],
operator: string,
nowIso: string,
from?: string,
to?: string,
remark?: string,
): BugLog {
return {
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
action,
fromValue: from,
toValue: to,
operator,
remark,
createdAt: nowIso,
};
}
export function applyBugTransition(
bug: Bug,
to: BugStatus,
operator: string,
options: BugTransitionOptions = {},
): BugWorkflowResult {
if (!canBugTransition(bug.status, to)) {
return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` };
}
const nowIso = (options.now ?? new Date()).toISOString();
const patch: Partial<Bug> = { status: to };
if (to === 'fixed') patch.resolvedAt = nowIso;
if (to === 'closed') patch.closedAt = nowIso;
if (options.resolution?.trim()) patch.resolution = options.resolution.trim();
const log = makeBugLog(
to === 'fixed' ? 'resolve' : 'status_change',
operator,
nowIso,
bug.status,
to,
options.resolution,
);
patch.logs = [...(bug.logs || []), log];
return { ok: true, patch };
}
- Step 4: Use workflow in Bug store
Modify apps/web/stores/useBugStore.ts imports:
import { applyBugTransition } from '@/lib/bug-workflow';
Keep local makeLog for create/transfer, or rename it to makeStoreLog so it does not conflict.
Replace changeStatus:
changeStatus: (id, to, operator, extra) => {
const bug = get().bugs.find((b) => b.id === id);
if (!bug) return { ok: false, message: 'Bug不存在' };
const result = applyBugTransition(bug, to, operator, {
now: new Date(),
resolution: extra?.resolution,
});
if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateBug(id, result.patch);
return { ok: true };
},
- Step 5: Run verification
Run:
pnpm --filter web test
pnpm --filter web type-check
Expected: both pass.
- Step 6: Commit
git add apps/web/lib/bug-workflow.ts apps/web/lib/bug-workflow.test.ts apps/web/stores/useBugStore.ts
git commit -m "feat(Bug): 状态流转接入单条工作流"
Task 5: Add Strict AI Estimation Policy
Files:
-
Create:
apps/web/lib/ai-estimation-policy.ts -
Create:
apps/web/lib/ai-estimation-policy.test.ts -
Step 1: Write failing estimate policy tests
Create apps/web/lib/ai-estimation-policy.test.ts:
import test from 'node:test';
import assert from 'node:assert/strict';
import { clampDevEstimateHours, clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from './ai-estimation-policy';
test('clamps simple frontend interaction to AI-assisted range', () => {
assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
});
test('keeps backend API within strict range', () => {
assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
});
test('defaults and clamps test case estimates', () => {
assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
});
- Step 2: Run test and confirm failure
Run:
pnpm --filter web test
Expected: FAIL because ai-estimation-policy.ts does not exist.
- Step 3: Implement estimate policy
Create apps/web/lib/ai-estimation-policy.ts:
import type { AgentTaskCategoryCode } from '@ftb/shared';
type EstimateRange = { min: number; max: number; fallback: number };
const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
backend_development: { min: 1, max: 3, fallback: 2 },
backend_api: { min: 0.75, max: 1.5, fallback: 1 },
database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
api_integration: { min: 0.75, max: 1.5, fallback: 1 },
data_processing: { min: 1, max: 2, fallback: 1.5 },
implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
};
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
test_api: { min: 0.5, max: 1, fallback: 0.5 },
test_exception: { min: 0.5, max: 1, fallback: 0.5 },
test_compatibility: { min: 0.5, max: 1, fallback: 1 },
};
function roundQuarterHour(hours: number): number {
return Math.round(hours * 4) / 4;
}
function clampToRange(raw: number | undefined, range: EstimateRange): number {
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
}
export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
}
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
}
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
}
- Step 4: Run tests
Run:
pnpm --filter web test
Expected: PASS.
- Step 5: Commit
git add apps/web/lib/ai-estimation-policy.ts apps/web/lib/ai-estimation-policy.test.ts
git commit -m "feat(AI): 增加严格估时策略"
Task 6: Update TestCase UI for Estimates and Single-Item Flow
Files:
-
Modify:
apps/web/components/test-case/TestCaseCreateModal.tsx -
Modify:
apps/web/components/test-case/TestCaseTab.tsx -
Modify:
apps/web/components/test-case/TestCaseRow.tsx -
Modify:
apps/web/components/test-case/TestCaseDetailDrawer.tsx -
Step 1: Add estimate input to TestCase create modal
Modify apps/web/components/test-case/TestCaseCreateModal.tsx imports:
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import { findCategoryByCode } from '@/lib/task-category';
Add state after categoryId:
const selectedCategory = useMemo(
() => categories.find((category) => category.id === categoryId),
[categories, categoryId],
);
const [estimateHours, setEstimateHours] = useState(0.5);
Add a category change handler:
const handleCategoryChange = (nextCategoryId: string) => {
setCategoryId(nextCategoryId);
const category = categories.find((c) => c.id === nextCategoryId);
setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
};
Use it in the task type select:
<select value={categoryId} onChange={(e) => handleCategoryChange(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
Add estimate input under task type:
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预估耗时 *</label>
<input
type="number"
min="0.25"
max="2"
step="0.25"
value={estimateHours}
onChange={(e) => setEstimateHours(Number(e.target.value))}
onBlur={() => setEstimateHours(clampTestCaseEstimateHours(selectedCategory?.code, estimateHours))}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
Pass estimate to store:
estimateHours: clampTestCaseEstimateHours(selectedCategory?.code, estimateHours),
- Step 2: Update TestCase tab stats
Modify imports in apps/web/components/test-case/TestCaseTab.tsx:
import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
Add:
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases);
Replace the stat text:
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">
{stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)}
</span>
Keep calendar/person metrics:
{tcManhours > 0 && (
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">
日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}
</span>
)}
- Step 3: Update TestCase row hours display
Modify apps/web/components/test-case/TestCaseRow.tsx:
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
Inside component:
const estimateHours = getTestCaseEstimateHours(testCase);
const actualHours = getTestCaseActualHours(testCase);
Replace hours render:
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
</span>
- Step 4: Update TestCase drawer time fields
Modify apps/web/components/test-case/TestCaseDetailDrawer.tsx imports:
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours';
Remove:
import { calcActualHoursByDates } from '@/lib/dev-task';
Add:
const estimateHours = getTestCaseEstimateHours(tc);
const actualHours = getTestCaseActualHours(tc);
Replace the testing duration line:
<div><span className="text-[var(--ink-muted)]">预估耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">实际耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
- Step 5: Verify no batch status actions exist
Run:
rg -n "batch|全部通过|全部不通过|全部提测|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web/components apps/web/stores
Expected: any result is either batch delete or plain display copy. No batch status transition remains.
- Step 6: Run verification
Run:
pnpm --filter web type-check
Expected: PASS.
- Step 7: Commit
git add apps/web/components/test-case/TestCaseCreateModal.tsx apps/web/components/test-case/TestCaseTab.tsx apps/web/components/test-case/TestCaseRow.tsx apps/web/components/test-case/TestCaseDetailDrawer.tsx
git commit -m "feat(测试用例): 展示预估和实际耗时"
Task 7: Update AI Contract and Adoption Estimates
Files:
-
Modify:
packages/shared/src/agent.ts -
Modify:
apps/server/src/modules/ai/prompts/decompose.ts -
Modify:
apps/server/src/modules/ai/ai.service.spec.ts -
Modify:
apps/web/components/version/DecomposeReportModal.tsx -
Step 1: Update shared TestCase draft type
Modify packages/shared/src/agent.ts:
export interface AgentTestCaseDraft {
title: string;
description: string;
categoryCode: AgentTaskCategoryCode;
priority: 'P0' | 'P1' | 'P2' | 'P3';
estimateHours: number;
references: AgentReference[];
}
- Step 2: Tighten prompt estimate policy
Modify apps/server/src/modules/ai/prompts/decompose.ts section 5. 工时估算(小时) to:
5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
- 简单前端字段、文案、展示调整: 0.25-0.5h
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
- 拖拽排序并需要持久化接口: 1-1.5h
- 简单 CRUD 接口: 0.75-1.5h
- 数据库字段/索引调整: 0.5h
- 中等业务规则变更: 1.5-3h
- 简单功能测试用例执行: 0.25-0.5h
- API/异常/兼容性测试用例执行: 0.5-1h
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
Add TestCase schema property:
estimateHours: { type: 'number' },
Update TestCase required array:
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
- Step 3: Update server schema test
Modify apps/server/src/modules/ai/ai.service.spec.ts:
it('requires estimateHours for test case drafts', () => {
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
assert(testCaseRequired.includes('estimateHours'));
});
Use the existing assertion style in that file. If the file uses Jest expect, write:
expect(testCaseRequired).toContain('estimateHours');
- Step 4: Clamp estimates when adopting AI drafts
Modify apps/web/components/version/DecomposeReportModal.tsx imports:
import { addWorkHours } from '@/lib/work-hours';
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
Replace AI dev estimate logic:
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
const startISO = new Date().toISOString();
const endISO = addWorkHours(startISO, estimateHours);
Include in createTask:
estimateHours,
expectedStartAt: startISO,
expectedEndAt: endISO,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
For test cases:
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
Include in createTestCase:
estimateHours,
Show estimate in TestCase draft preview:
<span className="text-[10px] text-[var(--ink-muted)]">{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h</span>
- Step 5: Run verification
Run:
pnpm --filter server test
pnpm --filter web test
pnpm --filter web type-check
Expected: all pass.
- Step 6: Commit
git add packages/shared/src/agent.ts apps/server/src/modules/ai/prompts/decompose.ts apps/server/src/modules/ai/ai.service.spec.ts apps/web/components/version/DecomposeReportModal.tsx
git commit -m "feat(AI): 拆解用例增加严格估时"
Task 8: Final Verification and Manual Smoke Check
Files:
-
No source edits unless verification finds focused issues.
-
Step 1: Run focused automated verification
Run:
pnpm --filter web test
pnpm --filter web type-check
pnpm --filter server test
pnpm --filter server type-check
Expected: all pass.
- Step 2: Confirm no auto-start code path remains
Run:
rg -n "findTasksToAutoStart|deriveActualStartAt|status: 'in_progress'.*actualStartAt|autoStart|自动切换扫描" apps/web
Expected: no active DevTask auto-start usage remains. A historical comment is acceptable only if it is not imported or called.
- Step 3: Confirm no batch status mutation remains
Run:
rg -n "全部提测|全部通过|全部不通过|batch.*status|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web
Expected: no batch status action remains. Batch delete may remain if found under delete handlers.
- Step 4: Browser smoke check
If the dev server is already running, open http://localhost:3000/versions and verify on a version detail page:
1. AI adopted DevTask stays 待开发 and actual start/end are empty.
2. Clicking a single DevTask to 开发中 writes actual start only for that task.
3. Clicking a single DevTask to 已提测 writes actual end only for that task.
4. New TestCase starts 待测试, has estimateHours, and shows estimate in row/drawer.
5. Clicking a single TestCase to 测试中 writes startedAt only for that case.
6. Clicking passed/failed/blocked writes completedAt only for that case.
7. TestCase top progress shows weighted completion plus pre/actual hours.
8. Bug drawer still only changes the currently opened Bug.
- Step 5: Inspect final diff
Run:
git status --short
git diff --stat
Expected: only workflow/effort/AI estimate files and related UI/store files changed in this implementation series. Existing unrelated dirty files must not be reverted.
- Step 6: Commit verification fixes if needed
Only if Step 1-5 reveal small focused issues, fix them, rerun the failed command, then commit:
git add <focused-files>
git commit -m "fix(工作流): 修正单条流转验证问题"
Do not create an empty commit.
Self-Review
- Spec coverage: DevTask starts as
todo, TestCase starts aspending, actual timestamps are only written by single-item workflow transitions, TestCase estimates and weighted progress are added, Bug status flow is centralized, AI estimates are stricter and clamped on adoption. - Placeholder scan: passed. Each task includes concrete files, code, commands, and expected results.
- Type consistency:
estimateHoursis added to DevTask, TestCase, andAgentTestCaseDraft; helper names used by stores and UI match the definitions in earlier tasks.