Files
ftb-project-management/docs/superpowers/plans/2026-06-25-workflow-effort-engine.md
2026-06-25 15:21:32 +08:00

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: 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:

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 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.