# 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`: add `estimateHours`, make estimates prefer explicit hours, remove `estimateHours` from 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 for `changeStatus`, normalize created tasks. - Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx`: persist computed `estimateHours`. - Modify `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`: import transitions from workflow constants if needed. - Modify `apps/web/lib/test-case.ts`: add `estimateHours`, labels `待测试/测试中`, derived estimate/actual/progress helpers. - Modify `apps/web/stores/useTestCaseStore.ts`: normalize `estimateHours`, use workflow helper for `changeStatus`. - 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`: add `estimateHours` to `AgentTestCaseDraft`. - 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, use `addWorkHours`, 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`: ```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: ```bash 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`: ```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: ```bash pnpm --filter web test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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`: ```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 { 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: ```bash 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`: ```ts import type { DevTask, DevTaskStatus } from './dev-task'; import { canTransition } from './dev-task'; export interface DevTaskWorkflowResult { ok: boolean; patch?: Partial; 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 = { 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`: ```ts export interface DevTask { // existing fields... expectedStartAt: string; expectedEndAt: string; estimateHours?: number; actualStartAt?: string; actualEndAt?: string; // existing fields... } ``` Change `getEstimateHours`: ```ts 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: ```ts 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: ```ts import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; ``` Remove these imports: ```ts import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions'; ``` Replace `fetchTasks` with: ```ts fetchTasks: async () => { const cached = await loadStored(); set({ tasks: cached ?? [] }); }, ``` Update `createTask`: ```ts const task: DevTask = normalizeDevTaskOnCreate({ ...data, id: `task-${Date.now()}`, taskNo: generateTaskNo(list), isBlocked: false, createdAt: now, updatedAt: now, } as DevTask); ``` Update `changeStatus`: ```ts 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: ```ts 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: ```ts estimateHours, ``` The payload section should include: ```ts expectedStartAt: expectedStartISO, expectedEndAt: expectedEndISO, estimateHours, actualStartAt: undefined, actualEndAt: undefined, status: 'todo', ``` - [ ] **Step 8: Run verification** Run: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass, and DevTask fetch no longer changes task status automatically. - [ ] **Step 9: Commit** ```bash 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`: ```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 { 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: ```bash 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`: ```ts import type { TestCase, TestCaseStatus } from './test-case'; import { canTcTransition } from './test-case'; export interface TestCaseWorkflowResult { ok: boolean; patch?: Partial; 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 = { 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`: ```ts import { aggregateWorkEffort } from './work-effort-engine'; ``` Add `estimateHours?: number`: ```ts export interface TestCase { // existing fields... status: TestCaseStatus; estimateHours?: number; startedAt?: string; completedAt?: string; // existing fields... } ``` Change status labels: ```ts export const TEST_CASE_STATUS_LABEL: Record = { pending: '待测试', running: '测试中', passed: '通过', failed: '不通过', blocked: '阻塞', }; ``` Update `normalizeTestCase`: ```ts estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5, ``` Add progress and estimate helpers: ```ts export const TEST_CASE_STATUS_PROGRESS: Record = { 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: ```ts 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: ```ts import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; ``` Update `createTestCase`: ```ts 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`: ```ts 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: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 7: Commit** ```bash 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`: ```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 { 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: ```bash 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`: ```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; 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 = { 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: ```ts import { applyBugTransition } from '@/lib/bug-workflow'; ``` Keep local `makeLog` for create/transfer, or rename it to `makeStoreLog` so it does not conflict. Replace `changeStatus`: ```ts 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: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 6: Commit** ```bash 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`: ```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: ```bash 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`: ```ts import type { AgentTaskCategoryCode } from '@ftb/shared'; type EstimateRange = { min: number; max: number; fallback: number }; const DEV_ESTIMATE_RANGES: Record = { 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 = { 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: ```bash pnpm --filter web test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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: ```ts import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; import { findCategoryByCode } from '@/lib/task-category'; ``` Add state after `categoryId`: ```ts const selectedCategory = useMemo( () => categories.find((category) => category.id === categoryId), [categories, categoryId], ); const [estimateHours, setEstimateHours] = useState(0.5); ``` Add a category change handler: ```ts 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: ```tsx 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" /> ``` Pass estimate to store: ```ts estimateHours: clampTestCaseEstimateHours(selectedCategory?.code, estimateHours), ``` - [ ] **Step 2: Update TestCase tab stats** Modify imports in `apps/web/components/test-case/TestCaseTab.tsx`: ```ts import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; ``` Add: ```ts const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases); ``` Replace the stat text: ```tsx {stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)} ``` Keep calendar/person metrics: ```tsx {tcManhours > 0 && ( 日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)} )} ``` - [ ] **Step 3: Update TestCase row hours display** Modify `apps/web/components/test-case/TestCaseRow.tsx`: ```ts import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; ``` Inside component: ```ts const estimateHours = getTestCaseEstimateHours(testCase); const actualHours = getTestCaseActualHours(testCase); ``` Replace hours render: ```tsx {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`} ``` - [ ] **Step 4: Update TestCase drawer time fields** Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx` imports: ```ts import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; import { formatWorkHours } from '@/lib/work-hours'; ``` Remove: ```ts import { calcActualHoursByDates } from '@/lib/dev-task'; ``` Add: ```ts const estimateHours = getTestCaseEstimateHours(tc); const actualHours = getTestCaseActualHours(tc); ``` Replace the testing duration line: ```tsx
预估耗时:{formatWorkHours(estimateHours)}
{tc.startedAt &&
实际耗时:{formatWorkHours(actualHours)}
} ``` - [ ] **Step 5: Verify no batch status actions exist** Run: ```bash 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: ```bash pnpm --filter web type-check ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash 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`: ```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: ```ts 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: ```ts estimateHours: { type: 'number' }, ``` Update TestCase required array: ```ts required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'], ``` - [ ] **Step 3: Update server schema test** Modify `apps/server/src/modules/ai/ai.service.spec.ts`: ```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: ```ts expect(testCaseRequired).toContain('estimateHours'); ``` - [ ] **Step 4: Clamp estimates when adopting AI drafts** Modify `apps/web/components/version/DecomposeReportModal.tsx` imports: ```ts import { addWorkHours } from '@/lib/work-hours'; import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; ``` Replace AI dev estimate logic: ```ts const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours); const startISO = new Date().toISOString(); const endISO = addWorkHours(startISO, estimateHours); ``` Include in `createTask`: ```ts estimateHours, expectedStartAt: startISO, expectedEndAt: endISO, actualStartAt: undefined, actualEndAt: undefined, status: 'todo', ``` For test cases: ```ts const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours); ``` Include in `createTestCase`: ```ts estimateHours, ``` Show estimate in TestCase draft preview: ```tsx {clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h ``` - [ ] **Step 5: Run verification** Run: ```bash pnpm --filter server test pnpm --filter web test pnpm --filter web type-check ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```text 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: ```bash 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: ```bash git add git commit -m "fix(工作流): 修正单条流转验证问题" ``` Do not create an empty commit. --- ## Self-Review - Spec coverage: DevTask starts as `todo`, TestCase starts as `pending`, 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: `estimateHours` is added to DevTask, TestCase, and `AgentTestCaseDraft`; helper names used by stores and UI match the definitions in earlier tasks.