Plan 06 — studio-api Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended,逐 task fresh subagent) or superpowers:executing-plans. 这是 Phase 1 最大 caller migration plan(9 task);建议 subagent 模式,每 task 完成后 user review SQL 写法 + transaction wrap 是否到位再进下一 task。
Goal: 把 studio-api 6 个 task endpoint(close / reopen / postpone + 新 progress)和 3 个读路径(contacts list / leads KPI / dashboard attempt workload)收口到 @retaintive/common/domain 的 buildTaskActionSQL() / buildCloseAllOpenSQL() raw-SQL adapter。Phase 1 ship 后,studio-api 写 task 不再自拼 SQL,所有 mutation 走统一 state machine + Policy Guard。
Architecture: studio-api 是 Hono on AWS Lambda,数据访问层用 @neondatabase/serverless 的 neon-http driver(raw SQL tagged-template / sql.query() / sql.transaction(),不用 Drizzle)。所以本 plan 用 Plan 02 Task 6 交付的 buildTaskActionSQL() adapter,它返回 { sql: string; params: unknown[] }[],caller 必须用 sql.transaction(statements.map(s => sql(s.sql, s.params))) 包裹(B2 invariant)。
Tech Stack: TypeScript 5 / Hono 4 / @neondatabase/serverless neon-http / Zod 3 / Vitest 4 / AWS Lambda(Node 20)
Spec source: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §4a-4i + §5.0 invariants + §5.2 integration tests
Dependency:
- Plan 01 merged(
@retaintive/common@1.1.0 schema delta:task_progress_events 表 + tasks 新字段)
- Plan 02 merged(
@retaintive/common@1.2.0 含 ./domain export:buildTaskActionSQL / buildCloseAllOpenSQL)
- 测试 Neon 已 apply Plan 01 migration SQL(否则
WHERE status IN ('pending','open') 跑得通但 task_progress_events 表不存在)
关键 invariant 复读(每 task 都要记):
- B2 —
sql.transaction(statements.map(s => sql(s.sql, s.params))) 必须包裹 buildTaskActionSQL() 返回的 statements;禁止 for (const stmt of statements) await sql.query(stmt.sql, stmt.params)(后者非原子,timeline 写一半失败 = 数据不一致)
- 过渡期 status 字符串用
IN ('pending', 'open')(Plan 01 transitional schema 期间;Plan 07 final cutover 后 sweep 成 status = 'open')
- Phase 1 不删
contacts.action_needed 字段,只改读路径(SELECT c.action_needed 暂留,Phase 2 才 DROP)
- B3 dashboard attempt workload 改算
task_progress_events,不算 close_result = 'attempted'
File Structure
测试文件(每个改造的 route 一个):
Task 1: Bump @retaintive/common 1.0.x → 1.2.0 + verify domain export 可 import
Files:
- Modify:
apps/api/package.json
Why this task first: Task 2-9 全 import @retaintive/common/domain 的 buildTaskActionSQL。先 bump dep 让后续 task TypeScript 编译过。
Step 1: Map what to verify
Step 2: Implementation
// apps/api/package.json
{
"dependencies": {
"@retaintive/common": "^1.2.0", // 从 "*" 或 "^1.0.x" bump
"...": "..."
}
}
Monorepo 用 workspace 引用的话,workspace:^1.2.0 或 * 视 turbo / bun workspaces 设定 — 本 apps/api 当前是 "*",可能要确认 packages/common 已 publish 1.2.0(本 monorepo 不是直接消费 retaintive/callytics-common,而是 npm consume),具体看 lockfile 当前怎么 resolve。
cd /Users/maxwsy/workspace/studio-website-monorepo && bun install
# Verify dist
ls node_modules/@retaintive/common/dist/domain/ # 应含 index.js + index.d.ts
# Quick smoke
echo 'import { buildTaskActionSQL } from "@retaintive/common/domain"; console.log(typeof buildTaskActionSQL);' > /tmp/smoke.ts
bun run /tmp/smoke.ts # 期望输出 "function"
Step 3: Test write + run
无独立 test 文件;Step 2 的 smoke import 算 verification。Type check:
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck
Expected: PASS(无 unresolved import error)。
Step 4: Commit
git add apps/api/package.json
git commit -m "chore(api): bump @retaintive/common to 1.2.0 (domain modules)
Plan 06 Task 1 — 引入 buildTaskActionSQL / buildCloseAllOpenSQL adapter
为 Phase 1 close/reopen/postpone/progress 改造做准备。
Spec: normative-spec.md §3.2
"
Task 1.5: Create apps/api/src/lib/check-silent-reject.ts
Why: normative-spec §3.1 line 251 — caller MUST 检查 result.resultChecks,0-row RETURNING = silent business reject(duplicate / stale_proposal / task_not_open / task_not_closed),不能让 transaction "通过" 而真改 0 行。close / reopen / postpone / progress 都要这逻辑 — 抽 helper。Codex audit 2026-06-02 抓的 #1 issue。
Files:
- Create:
apps/api/src/lib/check-silent-reject.ts
Step 1: Implementation
// apps/api/src/lib/check-silent-reject.ts
import type { ApplyResult } from '@retaintive/common/domain';
import { ConflictError, NotFoundError } from '../errors'; // 用 studio-api 现有 error types
import { logger } from '../logger';
interface CheckOpts {
/** record_progress 的 idempotency_key duplicate 是预期 retry,只 log 不 throw */
allowDuplicate?: boolean;
}
/**
* 校验 sql.transaction() 返回的每个 statement result row count,
* 跟 ApplyResult.resultChecks 比对。0 row = silent business reject。
*
* pattern source: normative-spec.md §3.1 line 251 contract
*/
export function checkSilentReject(
resultChecks: ApplyResult['resultChecks'] | undefined,
txResults: unknown[], // neon-http sql.transaction 返回 row arrays
ctx: { taskId: string; userId: string },
action: 'close' | 'reopen' | 'postpone' | 'record_progress',
opts: CheckOpts = {},
): void {
for (const check of resultChecks ?? []) {
const stmtResult = txResults[check.statementIndex];
const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
if (rowCount === 0) {
logger.warn(`${action} silent reject — 0 rows`, {
...ctx,
reason: check.zeroRowsReason,
});
if (opts.allowDuplicate && check.zeroRowsReason === 'duplicate') {
continue; // record_progress retry,预期幂等
}
// throw map per zeroRowsReason
throw makeErrorFor(check.zeroRowsReason, action);
}
}
}
function makeErrorFor(reason: string | undefined, action: string): Error {
switch (reason) {
case 'task_not_open':
return new ConflictError(`Task is not open (cannot ${action})`);
case 'task_not_closed':
return new ConflictError(`Task is not closed (cannot ${action})`);
case 'stale_proposal':
return new ConflictError('Stale request — please refresh and try again');
case 'duplicate':
return new ConflictError('Duplicate request');
default:
return new ConflictError(`Operation rejected: ${reason ?? 'unknown'}`);
}
}
Step 2: Commit
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api
git add src/lib/check-silent-reject.ts
git commit -m "feat(api): shared checkSilentReject helper
Phase 1 Plan 06 Task 1.5(Codex audit 2026-06-02 抓的 #1)。
close / reopen / postpone / record_progress 共用 — 校验 sql.transaction
返回的 row count 跟 ApplyResult.resultChecks 比对,0-row = silent business
reject(spec §3.1 line 251)。
之前每个 route 直接 await sql.transaction(...) 不读 resultChecks,
silent failure:duplicate / stale_proposal / task_not_open 都通过。
"
Task 2: close.ts — buildTaskActionSQL + sql.transaction wrap + 拒绝 progress closeResult
Files:
- Modify:
apps/api/src/routes/tasks/close.ts
- Create:
apps/api/src/__tests__/routes/tasks-close.test.ts
Step 1: Map test scenarios
Sample test code style(scenario 1 + 3 + 8 — 给 implementer 参考):
// apps/api/src/__tests__/routes/tasks-close.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { testApp, seedTask, seedContact, getTaskRow } from '../helpers/test-app.ts';
describe('PATCH /v2/tasks/close', () => {
beforeEach(async () => { await resetTestDb(); });
it('closes open task atomically (UPDATE + timeline in same transaction)', async () => {
const { taskId, contactPhone, storeId } = await seedTask({ status: 'open', typeCategory: 'lead_follow_up' });
const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
method: 'PATCH',
body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
const updated = await getTaskRow(taskId);
expect(updated.status).toBe('closed');
// Verify timeline row written
const timeline = await getTimelineRows(taskId);
expect(timeline).toHaveLength(1);
expect(timeline[0].event_type).toBe('task.status_changed');
});
it('rejects close with progress closeResult — routes user to progress endpoint', async () => {
const { taskId, storeId } = await seedTask({ status: 'open' });
const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
method: 'PATCH',
body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'no_answer', note: '' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/progress endpoint/i);
});
it('uses one sql.transaction for UPDATE + timeline (B2 wrapper)', async () => {
const { taskId, contactPhone, storeId } = await seedTask({ status: 'open' });
const sqlSpy = vi.spyOn(getNeonClient(), 'transaction');
const res = await testApp.request(`/v2/tasks/close?taskId=${taskId}`, {
method: 'PATCH',
body: JSON.stringify({ storeId, staffName: 'Alice', closeResult: 'converted', note: '' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
expect(sqlSpy).toHaveBeenCalledTimes(1);
expect(sqlSpy.mock.calls[0]![0]).toHaveLength(1); // Orchestrator returns complete SQL unit(s)
sqlSpy.mockRestore();
});
});
Step 2: Implementation
Critical contract:
- 删 line 22-27
FOLLOW_UP_TASK_TYPE / FOLLOW_UP_TYPE_CATEGORY / FOLLOW_UP_STATUS / FOLLOW_UP_PRIORITY / FOLLOW_UP_SOURCE 常量(no_answer / left_voicemail 不再走 close)
- 删 line 199-218 的 auto follow-up
INSERT INTO tasks ... INTERVAL '${daysToFollowUp} days'(改走 progress endpoint)
buildContactTimelineInsertSQL import 保留(其他 route 仍可能用),本 file 不再直接调用 — Orchestrator 内部自动写 timeline,跟 UPDATE 同 transaction
// apps/api/src/routes/tasks/close.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';
import type { CloseTaskResult } from './types.ts';
const PROGRESS_CLOSE_RESULTS = new Set([
'no_answer', 'left_voicemail', 'callback_later', 'attempted',
]);
const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
storeId: z.string().min(1),
staffName: z.string().min(1).max(200),
closeResult: z.string().min(1).max(100),
note: z.string().max(1000).optional().default(''),
});
export const closeTaskRoute = new Hono()
.patch('/close', async (c) => {
const auth = c.get('auth');
const userId = auth.userId;
const queryParsed = querySchema.safeParse(c.req.query());
if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
const { taskId } = queryParsed.data;
const body = await c.req.json();
const bodyParsed = bodySchema.safeParse(body);
if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
const { storeId, staffName, closeResult, note } = bodyParsed.data;
/*
* Phase 1: close API does NOT accept progress closeResult values.
* Frontend (Phase 1.5) should call POST /v2/tasks/:taskId/progress instead.
* Temporary fallback until UI ships — return 400 with a helpful message.
*/
if (PROGRESS_CLOSE_RESULTS.has(closeResult)) {
throw new ValidationError(
`closeResult='${closeResult}' is a progress event, not a close outcome. ` +
`Use POST /v2/tasks/${taskId}/progress instead.`,
);
}
const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');
const sql = getNeonClient();
/*
* Phase 1: route delegates to @retaintive/common/domain buildTaskActionSQL.
* It handles Policy Guard (hallucination / state / DNC-allowed-for-close /
* closeNote required) and returns transactional statements (UPDATE + timeline).
*/
const result = await buildTaskActionSQL(
{
action: 'close',
payload: { taskId, closeResult: closeResult as any, closeNote: note },
},
{
storeId,
actor: { type: 'staff', id: userId, name: staffName },
db: sql as any, // adapter accepts NeonHttpClient internally for SELECTs
} as any,
);
if (result.status === 'reject') {
switch (result.reason) {
case 'hallucinated_task_id':
throw new NotFoundError('Task');
case 'task_not_open':
throw new ConflictError('Task is already closed');
case 'close_note_required':
throw new ValidationError('closeNote required when closeResult=other');
case 'stale_proposal':
throw new ConflictError('Task was modified by another actor');
case 'invalid_close_result':
throw new ValidationError(`invalid closeResult: ${result.details}`);
case 'store_mismatch':
throw new NotFoundError('Task'); // 不泄露跨 store 存在
default:
throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
}
}
/*
* B2 — sql.transaction wraps all statements (UPDATE + timeline INSERT)
* into a single Postgres BEGIN/COMMIT. NEVER replace with a for-loop
* of sql.query() — that is non-atomic and breaks audit consistency.
*
* neon-http sql.transaction 返回 each statement 的 result rows array,
* 我们用它 + result.resultChecks 检测 silent business reject(spec §3.1
* line 251):0-row RETURNING = stale_proposal / task_not_open / 等。
*/
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
for (const check of result.resultChecks ?? []) {
const stmtResult = txResults[check.statementIndex];
const rowCount = Array.isArray(stmtResult) ? stmtResult.length : 0;
if (rowCount === 0) {
// 0 rows = business invariant violated(stale snapshot / wrong state)。
// close endpoint 这里翻译成 409 Conflict — 前端 staff 手动 close
// 时碰到 race 才会触发(AI close 已在 Lambda path,不走 studio-api)。
logger.warn('Close silent reject — 0 rows', {
taskId, userId, reason: check.zeroRowsReason,
});
throw new ConflictError(
check.zeroRowsReason === 'task_not_open'
? 'Task is already closed by another user'
: 'Stale request — please refresh and try again',
);
}
}
logger.info('Task closed', { taskId, staffName, closeResult, userId });
return c.json({
data: {
task_id: taskId,
status: 'closed' as const,
closed_at: new Date().toISOString(),
} satisfies CloseTaskResult,
});
});
注意点:
result.statements 是 { sql: string; params: unknown[] }[],sql(stmt.sql, stmt.params) 是 neon-http tagged-template 的调用形式
- handler 不返回 RETURNING 拿到的 task row(Orchestrator 内部 query 已 verify),直接构造 response shape;若前端依赖
closed_at 精确值,改成 transaction 后 SELECT 一次或让 adapter 返回 echo
getAuthorizedStoreNeon 是 studio-api 现有 auth gate,保留 — Orchestrator 的 Policy Guard 是补充层
Step 3: Test write + run
Implementer 按 case map 8 scenarios 写测试。
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-close.test.ts
Expected: 8/8 PASS。
Step 4: Commit
git add apps/api/src/routes/tasks/close.ts apps/api/src/__tests__/routes/tasks-close.test.ts
git commit -m "feat(api): tasks/close — buildTaskActionSQL + sql.transaction (B2) + 拒绝 progress closeResult
Plan 06 Task 2.
- 改用 @retaintive/common/domain buildTaskActionSQL 单接口
- B2 — sql.transaction(statements.map(...)) 包裹 UPDATE + timeline,原子提交
- 拒绝 closeResult ∈ {no_answer, left_voicemail, callback_later, attempted}
改走 POST /v2/tasks/:taskId/progress (Phase 1 临时 fallback)
- 删 FOLLOW_UP_TASK_TYPE 常量 + auto follow-up INSERT(过渡到 progress endpoint)
- Policy Guard reject 映射:hallucinated_task_id → 404 / task_not_open → 409 /
close_note_required → 400 / stale_proposal → 409
Spec: normative-spec.md §4a + §5.0 invariant 'closeResult 纯 outcome'
"
Task 3: reopen.ts — buildTaskActionSQL action='reopen' + sql.transaction wrap
Files:
- Modify:
apps/api/src/routes/tasks/reopen.ts
- Create:
apps/api/src/__tests__/routes/tasks-reopen.test.ts
Step 1: Map test scenarios
Step 2: Implementation
跟 close.ts 同 pattern。改动 vs 现有 reopen.ts:
- 删 line 91-106 自拼的
UPDATE tasks SET status='pending' 整段
- 删 line 117-127 的 existing-row 二次 SELECT(Orchestrator 内部已做)
- 删 line 136-158 自拼的
buildContactTimelineInsertSQL(Orchestrator 内部写)
- handler 改成调
buildTaskActionSQL({ action: 'reopen', payload: { taskId, reason } })
// apps/api/src/routes/tasks/reopen.ts (核心改动 — 省略 import / Zod schema)
const result = await buildTaskActionSQL(
{ action: 'reopen', payload: { taskId } },
{
storeId,
actor: { type: 'staff', id: userId },
db: sql as any,
} as any,
);
if (result.status === 'reject') {
switch (result.reason) {
case 'hallucinated_task_id': throw new NotFoundError('Task');
case 'task_not_closed': throw new ConflictError('Task is already open');
case 'dnc': throw new ValidationError('Cannot reopen task for DNC contact');
case 'store_mismatch': throw new NotFoundError('Task');
default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
}
}
// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'reopen');
return c.json({
data: {
task_id: taskId,
status: 'open' as const,
updated_at: new Date().toISOString(),
} satisfies ReopenTaskResult,
});
注意 response shape:现有 reopen 返回 status: 'pending',Phase 1 transitional 期间 schema 允许 'pending' | 'open',但 final cutover 后只有 'open'。本 handler 直接返 'open'(写入的就是 'open')。前端如果硬编码读 'pending',需要 Phase 1.5 一并改 — issue 标 follow-up。
Step 3: Test write + run
按 case map 5 scenarios 写,运行同 Task 2 pattern。
Step 4: Commit
git add apps/api/src/routes/tasks/reopen.ts apps/api/src/__tests__/routes/tasks-reopen.test.ts
git commit -m "feat(api): tasks/reopen — buildTaskActionSQL action='reopen' + sql.transaction (B2)
Plan 06 Task 3.
Spec: normative-spec.md §4c
"
Task 4: postpone.ts — buildTaskActionSQL action='update' + sql.transaction wrap
Files:
- Modify:
apps/api/src/routes/tasks/postpone.ts
- Create:
apps/api/src/__tests__/routes/tasks-postpone.test.ts
Step 1: Map test scenarios
Step 2: Implementation
// apps/api/src/routes/tasks/postpone.ts (核心改动)
// 保留现有 route-level dueAt future check
if (newDueAt.getTime() <= Date.now()) {
throw new ValidationError('dueAt must be in the future');
}
const result = await buildTaskActionSQL(
{
action: 'update',
payload: { taskId, dueAt: newDueAt }, // 只改 dueAt
},
{
storeId,
actor: { type: 'staff', id: userId },
db: sql as any,
} as any,
);
if (result.status === 'reject') {
switch (result.reason) {
case 'hallucinated_task_id': throw new NotFoundError('Task');
case 'task_not_open': throw new ConflictError('Cannot postpone a closed task');
case 'dnc': throw new ValidationError('Cannot postpone task for DNC contact');
case 'store_mismatch': throw new NotFoundError('Task');
default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
}
}
// B2 — sql.transaction wrap + resultChecks(spec §3.1 line 251)
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'postpone');
return c.json({
data: {
task_id: taskId,
due_at: newDueAt.toISOString(),
updated_at: new Date().toISOString(),
},
});
Step 3 + 4: Test + commit(同 Task 3 pattern)
git add apps/api/src/routes/tasks/postpone.ts apps/api/src/__tests__/routes/tasks-postpone.test.ts
git commit -m "feat(api): tasks/postpone — buildTaskActionSQL action='update' + sql.transaction (B2)
Plan 06 Task 4.
Spec: normative-spec.md §4c
"
Task 5: 新建 progress.ts — POST /v2/tasks/:taskId/progress endpoint
Files:
- Create:
apps/api/src/routes/tasks/progress.ts
- Modify:
apps/api/src/routes/tasks/index.ts(注册 route)
- Create:
apps/api/src/__tests__/routes/tasks-progress.test.ts
Step 1: Map test scenarios
Step 2: Implementation
// apps/api/src/routes/tasks/progress.ts (NEW FILE)
/**
* POST /v2/tasks/:taskId/progress — Record progress event
*
* Records a non-terminal touch on an open task. Examples: no_answer,
* left_voicemail, text_sent, callback_requested, follow_up_scheduled,
* customer_considering.
*
* Phase 1 ship: 这是新端点,替代过去通过 close API + closeResult ∈ progress_set
* 触发的"关旧建新 follow-up"模式。现在 progress 单独追踪,task 保持 open,
* attempt_count 累加,dueAt 按 progressType 自动推后。
*
* Data: writes to task_progress_events + UPDATE tasks (attempt_count + due_at)
* + contact_timeline (task.progress_recorded)
* 全部在单个 sql.transaction() 内原子提交 (B2)
*/
import { Hono } from 'hono';
import { z } from 'zod';
import { buildTaskActionSQL } from '@retaintive/common/domain';
import { getNeonClient } from '../../services/neon.ts';
import { getAuthorizedStoreNeon } from '../../utils/store-authorization-neon.ts';
import { logger } from '../../utils/logger.ts';
import {
ValidationError, NotFoundError, ForbiddenError, ConflictError,
} from '../../utils/errors.ts';
const PROGRESS_TYPES = [
'no_answer', 'left_voicemail', 'text_sent',
'callback_requested', 'follow_up_scheduled', 'customer_considering',
] as const;
const CHANNELS = ['phone', 'sms', 'voicemail', 'email'] as const;
// progressType 需要 nextDueAt — Orchestrator 内部 computeNextDueAt 对这两类返回
// currentDueAt 不变;route-level 提示用户必传 override 避免 dueAt 不动 = bug
const REQUIRES_NEXT_DUE_AT = new Set([
'callback_requested', 'follow_up_scheduled',
]);
const querySchema = z.object({ taskId: z.string().uuid() });
const bodySchema = z.object({
storeId: z.string().min(1),
progressType: z.enum(PROGRESS_TYPES),
channel: z.enum(CHANNELS),
callId: z.string().optional(),
messageId: z.string().optional(),
note: z.string().max(1000).optional(),
nextDueAt: z.string().datetime().optional(),
}).refine(
(b) => !REQUIRES_NEXT_DUE_AT.has(b.progressType) || !!b.nextDueAt,
{ message: 'nextDueAt is required for callback_requested / follow_up_scheduled' },
);
export const progressRoute = new Hono()
.post('/:taskId/progress', async (c) => {
const auth = c.get('auth');
const userId = auth.userId;
const staffName = auth.staffName ?? auth.userId; // 视现有 auth shape
const queryParsed = querySchema.safeParse({ taskId: c.req.param('taskId') });
if (!queryParsed.success) throw new ValidationError(queryParsed.error.message);
const { taskId } = queryParsed.data;
const body = await c.req.json();
const bodyParsed = bodySchema.safeParse(body);
if (!bodyParsed.success) throw new ValidationError(bodyParsed.error.message);
const { storeId, progressType, channel, callId, messageId, note, nextDueAt } = bodyParsed.data;
const storeAuth = await getAuthorizedStoreNeon(userId, storeId);
if (storeAuth.status === 'not_found') throw new NotFoundError('Store');
if (storeAuth.status === 'forbidden') throw new ForbiddenError('No access to this store');
if (storeAuth.role === 'VIEWER') throw new ForbiddenError('Viewers cannot modify tasks');
const sql = getNeonClient();
const result = await buildTaskActionSQL(
{
action: 'record_progress',
payload: {
taskId,
progressType,
channel,
callId,
messageId,
note,
nextDueAtOverride: nextDueAt ? new Date(nextDueAt) : undefined,
},
},
{
storeId,
actor: { type: 'staff', id: userId, name: staffName },
db: sql as any,
} as any,
);
if (result.status === 'reject') {
switch (result.reason) {
case 'hallucinated_task_id': throw new NotFoundError('Task');
case 'task_not_open': throw new ConflictError('Task is not open');
case 'dnc': throw new ValidationError('Cannot record progress for DNC contact');
case 'invalid_progress_type': throw new ValidationError(`invalid progressType: ${result.details}`);
case 'duplicate':
/*
* Idempotency hit — ON CONFLICT idempotency_key DO NOTHING.
* Return ok+deduped so client retries are silently safe.
*/
return c.json({ data: { ok: true, deduped: true } });
case 'store_mismatch': throw new NotFoundError('Task');
default: throw new ValidationError(result.details ?? `rejected: ${result.reason}`);
}
}
/*
* B2 — sql.transaction wraps:
* 1. CTE: INSERT task_progress_events ON CONFLICT (idempotency_key) DO NOTHING
* + UPDATE tasks SET attempt_count = attempt_count + 1 (only if INSERT fired)
* 2. INSERT contact_timeline (task.progress_recorded)
* Plus resultChecks(spec §3.1 line 251)— idempotency_key duplicate 是
* 预期 retry,log warn 但不 throw(progress 是 idempotent operation)。
*/
const txResults = await sql.transaction(result.statements.map(stmt => sql(stmt.sql, stmt.params)));
checkSilentReject(result.resultChecks, txResults, { taskId, userId }, 'record_progress', { allowDuplicate: true });
logger.info('Progress recorded', { taskId, progressType, channel, userId });
return c.json({ data: { ok: true } });
});
修改 apps/api/src/routes/tasks/index.ts 注册新 route:
// apps/api/src/routes/tasks/index.ts (添加)
import { progressRoute } from './progress.ts';
const tasks = new Hono()
// ... existing routes
.route('/', progressRoute); // POST /v2/tasks/:taskId/progress
Step 3: Test write + run
按 9 scenarios 写测试。关键 test #2 (idempotency) 是 spec §5.3 invariant 直接验证:
it('idempotent same (taskId, callId, progressType) — attempt_count not double-bumped', async () => {
const { taskId, storeId } = await seedTask({ status: 'open', attempt_count: 0 });
const body = {
storeId,
progressType: 'no_answer',
channel: 'phone',
callId: 'CALL_X',
};
const r1 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
method: 'POST', body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
const r2 = await testApp.request(`/v2/tasks/${taskId}/progress`, {
method: 'POST', body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
const r2body = await r2.json();
expect(r2body.data.deduped).toBe(true);
const task = await getTaskRow(taskId);
expect(task.attempt_count).toBe(1); // ← CTE 保护:不翻倍
const events = await getProgressEvents(taskId);
expect(events).toHaveLength(1); // ← 只 1 行 event
});
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/tasks-progress.test.ts
Expected: 8/8 PASS,特别 #2 必须 PASS(否则 B8 CTE 没 wire 通)。
Step 4: Commit
git add apps/api/src/routes/tasks/progress.ts apps/api/src/routes/tasks/index.ts apps/api/src/__tests__/routes/tasks-progress.test.ts
git commit -m "feat(api): 新建 POST /v2/tasks/:taskId/progress endpoint
Plan 06 Task 5.
- 替代过去 close API + closeResult ∈ progress_set 的关旧建新 follow-up 模式
- 6 个 progressType (no_answer / left_voicemail / text_sent /
callback_requested / follow_up_scheduled / customer_considering)
- 4 个 channel (phone / sms / voicemail / email)
- B2 — sql.transaction 包裹 CTE + timeline INSERT 原子
- B8 — CTE 保护 attempt_count 不在 ON CONFLICT DO NOTHING 时翻倍
- callback_requested / follow_up_scheduled 必须传 nextDueAt
(否则 Orchestrator 不动 dueAt = 半 bug)
Spec: normative-spec.md §4b + §3.2 B8
"
Task 6: status 字符串 sweep — list.ts / events.ts / dashboard-multi-store.ts(过渡期 IN ('pending','open'))
Files:
- Modify:
apps/api/src/routes/tasks/list.ts(8 处 status = 'pending')
- Modify:
apps/api/src/routes/tasks/events.ts(如有 status filter)
- Modify:
apps/api/src/routes/v3/dashboard-multi-store.ts(1 处 status = 'pending')
- Modify:
apps/api/src/routes/tasks/types.ts(TaskRow.status enum 加 'open')
- Create: 无新 test 文件(Task 7 contacts/leads 一起覆盖)
Step 1: Map test scenarios
不需要新独立 test 文件,本 task 是机械 sweep。验证手段:
- TypeScript 编译过(
TaskRow.status enum 含 'open')
- 集成 test(Task 9)跑过(过渡期 seed 既有
'pending' 又有 'open' 的 task,verify list / dashboard / contacts 都正确返回两类)
Step 2: Implementation — 机械替换 + TaskRow 改 type
apps/api/src/routes/tasks/types.ts 改动:
// 改 line 18:
status: 'pending' | 'open' | 'closed'; // transitional;Plan 07 cutover 后收缩到 'open' | 'closed'
// 删 line 16:
// task_type: string; ← 删除
// 删 line 20-21:
// action_needed: boolean;
// action_needed_reason: string | null;
apps/api/src/routes/tasks/list.ts 机械替换:
apps/api/src/routes/v3/dashboard-multi-store.ts line 191:
-- Before:
COUNT(*) FILTER (WHERE status = 'pending' AND due_at < NOW())::int AS tasks_overdue
-- After:
COUNT(*) FILTER (WHERE status IN ('pending', 'open') AND due_at < NOW())::int AS tasks_overdue
apps/api/src/routes/tasks/events.ts: grep status filter 后视情况改(当前已 grep 无 hit,只读路径 SELECT 不带 status filter — 不动)。
Step 3: Test write + run
无新 test。Type check + 集成 test(Task 9)是 verification:
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run typecheck && bun test
Expected: PASS(TaskRow.status type 改后,所有消费 row.status 的代码若硬编码 'pending' 会编译报错 — 必须修)。
关键 grep verify — commit 前跑:
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && grep -rn "status = 'pending'\|status='pending'" src/routes/ | grep -v "// transitional"
Expected: 0 hit(全部已切到 IN ('pending', 'open'))。
Step 4: Commit
git add apps/api/src/routes/tasks/list.ts apps/api/src/routes/tasks/types.ts apps/api/src/routes/v3/dashboard-multi-store.ts
git commit -m "refactor(api): sweep status='pending' → IN ('pending','open') + 删 task_type/action_needed
Plan 06 Task 6.
- 过渡期(Plan 01 transitional schema)所有读路径用 IN ('pending', 'open')
覆盖既有 'pending' 行 + 新写入 'open' 行
- TaskRow.status type 加 'open'(过渡期 3-value;Plan 07 cutover 后收缩)
- 删 TaskRow.task_type / action_needed / action_needed_reason 字段
(tasks 表字段退役;contacts.action_needed 字段保留待 Phase 2)
- list.ts 8 处 + dashboard-multi-store.ts 1 处 status sweep
Spec: normative-spec.md §4h + §6.2 transitional + §4i task_type 字段消费端清理
"
Files:
- Modify:
apps/api/src/routes/v3/contacts.ts(line ~112 SELECT clause)
- Modify:
apps/api/src/routes/v3/leads.ts(line ~209 KPI summary)
- Create:
apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts
- Create:
apps/api/src/__tests__/routes/v3-leads-kpi.test.ts
Step 1: Map test scenarios
v3/contacts.ts EXISTS 子查询等价 test:
v3/leads.ts KPI etc:
Step 2: Implementation
apps/api/src/routes/v3/contacts.ts line 109-118 — 加 has_open_task 子查询:
// Before (line 109-118):
SELECT
c.phone, c.first_name, c.last_name, c.lifecycle_stage,
c.lead_status, c.purchase_intent, c.has_card_on_file,
c.do_not_contact, c.action_needed, c.has_open_complaint,
c.last_activity_at, c.created_at,
COALESCE(cs.call_count, 0)::integer AS call_count,
COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}
// After:
SELECT
c.phone, c.first_name, c.last_name, c.lifecycle_stage,
c.lead_status, c.purchase_intent, c.has_card_on_file,
c.do_not_contact,
c.action_needed, -- 字段保留,Phase 2 才 DROP
/*
* Phase 1: has_open_task EXISTS subquery is the new source of truth
* for "this contact has work pending". Frontend should consume
* has_open_task; c.action_needed kept for backward compat until
* Phase 2 ships its DROP COLUMN migration.
*
* Transitional: status IN ('pending', 'open') covers both legacy
* 'pending' rows (pre-Plan 01) and new 'open' rows (post-Plan 01).
*/
EXISTS (
SELECT 1 FROM tasks t
WHERE t.contact_phone = c.phone
AND t.store_id = c.store_id
AND t.status IN ('pending', 'open')
) AS has_open_task,
c.has_open_complaint,
c.last_activity_at, c.created_at,
COALESCE(cs.call_count, 0)::integer AS call_count,
COALESCE(ss.sms_count, 0)::integer AS sms_count
FROM contacts c
LEFT JOIN call_stats cs ON cs.phone = c.phone
LEFT JOIN sms_stats ss ON ss.phone = c.phone
WHERE ${conditions.join(' AND ')}
记得也改 ContactRow interface 加 has_open_task: boolean。
apps/api/src/routes/v3/leads.ts line 207-214 KPI:
// Before:
const summaryQuery = `
SELECT
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE c.action_needed = true)::integer AS action_needed,
COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
FROM contacts c
WHERE ${whereClause}
`;
// After:
const summaryQuery = `
SELECT
COUNT(*)::integer AS total,
/*
* Phase 1: action_needed KPI changes source — old denormalized
* c.action_needed boolean replaced by an EXISTS subquery against
* tasks. Transitional IN ('pending', 'open') covers Plan 01 window.
* COUNT(DISTINCT c.phone) because a phone may have multiple
* (phone, store_id) contact rows in legacy data.
*/
COUNT(DISTINCT c.phone) FILTER (WHERE EXISTS (
SELECT 1 FROM tasks t
WHERE t.contact_phone = c.phone
AND t.store_id = c.store_id
AND t.status IN ('pending', 'open')
))::integer AS action_needed,
COUNT(*) FILTER (WHERE c.created_at >= NOW() - INTERVAL '1 day')::integer AS new_today,
COUNT(*) FILTER (WHERE c.last_activity_at < NOW() - INTERVAL '5 days')::integer AS stale_count
FROM contacts c
WHERE ${whereClause}
`;
LeadRow interface 中 action_needed: boolean | null 字段 — Phase 1 留(仍 SELECT 行级 c.action_needed 给前端 fallback),Phase 2 一起删。
Step 3: Test write + run
按 case map 写 5 + 3 = 8 test。重点验证 transitional IN ('pending', 'open') 覆盖(用户给的 case map 第 3 条核心要求)。
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/v3-contacts-action-needed.test.ts src/__tests__/routes/v3-leads-kpi.test.ts
Expected: PASS。
Step 4: Commit
git add apps/api/src/routes/v3/contacts.ts apps/api/src/routes/v3/leads.ts apps/api/src/__tests__/routes/v3-contacts-action-needed.test.ts apps/api/src/__tests__/routes/v3-leads-kpi.test.ts
git commit -m "feat(api): v3/contacts + v3/leads — EXISTS 子查询替代 action_needed 读路径
Plan 06 Task 7.
- contacts list 加 has_open_task: EXISTS(SELECT 1 FROM tasks ...)
- leads KPI summary.action_needed 改 EXISTS 子查询(COUNT DISTINCT phone)
- 过渡期用 status IN ('pending', 'open') 覆盖 Plan 01 transitional window
- c.action_needed 字段 SELECT 保留(Phase 2 才 DROP COLUMN);
c.action_needed_reason 同保留
Spec: normative-spec.md §4g
"
Task 8: dashboard attempt workload 改算 task_progress_events(B3)
Files:
- Modify:
apps/api/src/routes/v3/dashboard-staff.ts(或具体哪个 dashboard 文件含 attempt workload — 视具体 file 决定)
- 若 dashboard-analytics.ts / dashboard-overview.ts 也有 attempt metric,同样改
- Create:
apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
Step 1: Locate which dashboard file 含 attempt workload
grep -rn "attempt\|close_result = 'attempted'" apps/api/src/routes/v3/dashboard-*.ts
当前现状(2026-06-02 grep):
dashboard-analytics.ts:119 cancel_attempts 是 call subcategory 计算(不是 task close_result),保留不动
- 其他 dashboard-*.ts 暂无
close_result = 'attempted' 用法
说明:Phase 1 之前,studio-api dashboard 没有暴露 "attempt workload" metric(用 task close_result = attempted)。本 Task 8 主要是为 Phase 1 后新增的 workload metric 提供正确 query 实现。如果 dashboard PM 没要求新 metric,可以 defer 到 Phase 2(标 DEFERRED 在 doc 里)。
但 spec §4h B3 要求"口径迁移",意思是 现有 metric 若以 closeResult='attempted' 为口径要改。grep 没 hit 说明 studio-api 这部分本来就用 calls 的 cancel_attempts 而不是 tasks。Task 8 简化为:文档化口径 + 预埋一个 getAttemptWorkload() helper function 给 Phase 1.5 dashboard 用,但本 plan 不强制 wire 进 endpoint。
Step 2: Map test scenarios(helper function only)
Step 3: Implementation — 新建 helper
apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts(新建):
/**
* Attempt workload metric (Phase 1 口径迁移 — spec §4h B3).
*
* Phase 1 之前若 dashboard 用 tasks.close_result = 'attempted' 算 attempt
* workload,Phase 1 这种 close_result 不存在了(progress 拆出来到 task_progress_events)。
* 这里给出新口径的 helper。
*
* studio-api 当前 grep 无 close_result='attempted' 用法 — 本 helper 是预埋,
* Phase 1.5 dashboard 改造时可直接消费。
*/
import { getNeonClient } from '../../../services/neon.ts';
export interface AttemptWorkloadRow {
attempts: number;
tasks_touched: number;
active_staff: number;
}
export async function getAttemptWorkload(
storeId: string,
windowDays = 7,
): Promise<AttemptWorkloadRow> {
const sql = getNeonClient();
const rows = await sql.query(
`SELECT
COUNT(*)::int AS attempts,
COUNT(DISTINCT task_id)::int AS tasks_touched,
COUNT(DISTINCT actor_id) FILTER (WHERE actor_type = 'staff')::int AS active_staff
FROM task_progress_events
WHERE store_id = $1
AND occurred_at >= NOW() - INTERVAL '${windowDays} days'`,
[storeId],
) as unknown as AttemptWorkloadRow[];
return rows[0] ?? { attempts: 0, tasks_touched: 0, active_staff: 0 };
}
注意 INTERVAL '${windowDays} days' 是 string interpolation 不是参数化 — windowDays 必须是 number(不可 SQL injection),route 调用方应 hardcode 或 enum,不接受 user input。
Step 4: Test write + run
// apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
import { describe, it, expect } from 'vitest';
import { getAttemptWorkload } from '../../routes/v3/dashboard-helpers/attempt-workload.ts';
import { seedProgressEvent, resetTestDb } from '../helpers/test-db.ts';
describe('getAttemptWorkload', () => {
it('counts progress events by store + actor distinct', async () => {
await resetTestDb();
const storeA = 'STORE_A';
const taskA = 'task-A';
const taskB = 'task-B';
await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 0 });
await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'staff', actorId: 'alice', progressType: 'left_voicemail', occurredAtDaysAgo: 1 });
await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'staff', actorId: 'bob', progressType: 'no_answer', occurredAtDaysAgo: 2 });
await seedProgressEvent({ taskId: taskB, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 3 });
await seedProgressEvent({ taskId: taskA, storeId: storeA, actorType: 'system', actorId: 'cron', progressType: 'follow_up_scheduled', occurredAtDaysAgo: 4 });
const r = await getAttemptWorkload(storeA, 7);
expect(r.attempts).toBe(5);
expect(r.tasks_touched).toBe(2);
expect(r.active_staff).toBe(2); // alice + bob distinct
});
it('tenant-isolates by store_id', async () => {
await resetTestDb();
await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer' });
await seedProgressEvent({ taskId: 'tB', storeId: 'STORE_B', actorType: 'staff', actorId: 'bob', progressType: 'no_answer' });
const r = await getAttemptWorkload('STORE_A', 7);
expect(r.attempts).toBe(1);
expect(r.active_staff).toBe(1);
});
it('excludes events outside the window', async () => {
await resetTestDb();
await seedProgressEvent({ taskId: 'tA', storeId: 'STORE_A', actorType: 'staff', actorId: 'alice', progressType: 'no_answer', occurredAtDaysAgo: 10 });
const r = await getAttemptWorkload('STORE_A', 7);
expect(r.attempts).toBe(0);
});
});
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test src/__tests__/routes/dashboard-attempt-workload.test.ts
Expected: 3/3 PASS。
Step 5: Commit
git add apps/api/src/routes/v3/dashboard-helpers/attempt-workload.ts apps/api/src/__tests__/routes/dashboard-attempt-workload.test.ts
git commit -m "feat(api): dashboard helper getAttemptWorkload — 算 task_progress_events (B3)
Plan 06 Task 8.
- 新口径:Attempt workload = COUNT(*) FROM task_progress_events
WHERE store_id=$1 AND occurred_at >= NOW() - INTERVAL '7 days'
- 返回 attempts / tasks_touched / active_staff (distinct staff actor_id)
- 本 plan 不强 wire 进现有 dashboard route(grep 无 close_result='attempted'
当前用法);预埋给 Phase 1.5 dashboard 改造消费
Spec: normative-spec.md §4h B3
"
Task 9: E2E integration test + final PR
Files:
- Create:
apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
Step 1: Map E2E scenarios(连真实测试 Neon)
Step 2: Implementation outline
测试 setup 通过 apps/api/scripts/integration-test.ts 已有的 Neon test DB connection。Reset 步骤 + seed helpers 沿用现有 __tests__/integration/neon-services.integration.test.ts 的 pattern。
// apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { getNeonClient } from '../../services/neon.ts';
import {
resetIntegrationDb,
seedContact, seedTask, seedTimelineRow,
callApi,
} from '../helpers/integration-helpers.ts';
describe('Phase 1 task mutations — E2E', () => {
beforeEach(async () => { await resetIntegrationDb(); });
it('close → UPDATE + timeline atomic', async () => {
const { phone, storeId } = await seedContact({ doNotContact: false });
const taskId = await seedTask({ phone, storeId, status: 'open', typeCategory: 'lead_follow_up' });
const res = await callApi('PATCH', `/v2/tasks/close?taskId=${taskId}`, {
storeId, staffName: 'Alice', closeResult: 'converted', note: '',
});
expect(res.status).toBe(200);
const sql = getNeonClient();
const [row] = await sql.query(`SELECT status FROM tasks WHERE task_id=$1`, [taskId]);
expect(row.status).toBe('closed');
const [tl] = await sql.query(`SELECT event_type FROM contact_timeline WHERE entity_id=$1`, [taskId]);
expect(tl.event_type).toBe('task.status_changed');
});
it('progress idempotency — same (callId, progressType) twice → attempt_count not doubled (B8 CTE)', async () => {
const { phone, storeId } = await seedContact({ doNotContact: false });
const taskId = await seedTask({ phone, storeId, status: 'open', attempt_count: 0 });
const body = { storeId, progressType: 'no_answer', channel: 'phone', callId: 'CALL_X' };
await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
const r2 = await callApi('POST', `/v2/tasks/${taskId}/progress`, body);
expect((await r2.json()).data.deduped).toBe(true);
const sql = getNeonClient();
const [row] = await sql.query(`SELECT attempt_count FROM tasks WHERE task_id=$1`, [taskId]);
expect(row.attempt_count).toBe(1);
});
// ... 其他 5 scenarios
});
Step 3: Test run
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run integration-test:neon
Expected: 7/7 PASS。
Step 4: Final verification + PR
# 全 suite
cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun run check && bun test && bun run integration-test:neon
# Grep verify
grep -rn "status = 'pending'" apps/api/src/routes/ | grep -v "// transitional"
# Expected: 0 hit
grep -rn "buildContactTimelineInsertSQL" apps/api/src/routes/tasks/{close,reopen,postpone}.ts
# Expected: 0 hit (Orchestrator 内部已封)
grep -rn "task_type" apps/api/src/routes/tasks/
# Expected: 0 hit (字段退役)
如果全 pass,commit + push:
git add apps/api/src/__tests__/integration/tasks-mutations.integration.test.ts
git commit -m "test(api): Phase 1 E2E integration — close/progress/reopen 7 scenarios
Plan 06 Task 9.
- 跑真实测试 Neon
- 验 spec §5.0 invariants 全 hold:
- tasks 二态(过渡期 pending 也接受)
- progress 不 close task(attempt_count + 1,status 不变)
- B8 CTE — 重复 progress 不翻倍 attempt_count
- B2 — sql.transaction UPDATE + timeline 原子
- Tenant isolation — 跨 store taskId 404
- DNC hard stop — record_progress + reopen 拒绝
Spec: normative-spec.md §5.0 + §5.2 + §5.3
"
PR 用 pr skill 写中文 description:
git push -u origin <feature-branch>
# 用 pr skill 生成 body 后:
gh pr create --title "feat(api): Phase 1 — studio-api task mutations 收口到 @retaintive/common/domain" \
--body-file /tmp/pr-body.md
Self-Review Checklist
Spec coverage
- ✅ §4a close.ts → Task 2(
buildTaskActionSQL + sql.transaction + 拒绝 progress closeResult + 删 FOLLOW_UP 常量 + 删 auto follow-up INSERT)
- ✅ §4b progress endpoint → Task 5(新建 POST /v2/tasks/:taskId/progress)
- ✅ §4c reopen.ts → Task 3
- ✅ §4c postpone.ts → Task 4(action='update')
- ✅ §4g contacts.ts / leads.ts EXISTS → Task 7
- ✅ §4h status 字符串 sweep → Task 6
- ✅ §4h B3 dashboard attempt workload helper → Task 8
- ✅ §4i task_type / action_needed 字段消费端清理 → Task 6(types.ts)+ Task 2(close.ts 删 INSERT)+ Task 7(SELECT 保留 c.action_needed Phase 2 删)
- ✅ §5.0 invariant 'progress 不 close task' → Task 5 test #2 + Task 9 scenario #2
- ✅ §5.0 invariant 'closeResult 纯 outcome' → Task 2 test #3-4
- ✅ §5.0 invariant 'tenant isolation' → Task 2 test #7 + Task 9 scenario #3
- ✅ §5.0 invariant 'tasks 二态'(过渡期 3-value 兼容) → Task 6
IN ('pending', 'open')
B2 transaction wrap invariant
- ✅ Task 2(close):
sql.transaction(result.statements.map(s => sql(s.sql, s.params))) — 强制 + atomicity test scenario #8
- ✅ Task 3(reopen): 同
- ✅ Task 4(postpone): 同
- ✅ Task 5(progress): 同
Type consistency
- ✅
buildTaskActionSQL() signature 跟 Plan 02 Task 6 一致:(action: TaskAction, ctx: ActionContext) => Promise<ApplyResultSQL>
- ✅ Import path
@retaintive/common/domain(Plan 02 Task 7 barrel)
- ✅
ApplyResultSQL reject reason 字符串与 Plan 02 types.ts RejectReason union 完全 match(close.ts switch case 引用 13 个 reason 中的 6 个)
- ✅ Task 5
ProgressEvidence 4 source 跟 Plan 02 Task 2 buildProgressIdempotencyKey 一致(adapter 内部已用,handler 不需要直接调)
No placeholders
- ✅ 每 task 给具体 file path + line range / new file 标 Create + 完整 path
- ✅ Test scenarios 全表格列清(无 "write tests for the above")
- ✅ SQL 写法完整给(EXISTS 子查询 / CTE / status IN 三种 pattern 都有 sample)
- ✅ 关键 invariant test 给 sample code(Task 2 #8 atomicity / Task 5 #2 idempotency / Task 8 #1 attempt workload)
Execution order dependency
- Task 1 必须先(bump dep,后续 task 编译依赖)
- Task 2-5 可并行(独立 endpoint)— 但 Task 5 progress endpoint 的 contract 解释了 Task 2 为何拒绝 progress closeResult,逻辑上 Task 5 在 Task 2 前 explain 上下文更佳;实操可并行
- Task 6(sweep)依赖 Task 2-5 完成(否则 close/reopen/postpone 还会写
'pending')
- Task 7 与 Task 6 可并行(改读路径,不影响写路径)
- Task 8 与 Task 7 可并行(dashboard helper 独立)
- Task 9 E2E 最后跑(verify 整 chain)
Outstanding
- ⚠️
auth.staffName 当前 studio-api auth shape 是否含 staffName 字段需 verify(Task 5 progress.ts 引用)— 若无,改用 getAuthorizedStoreNeon 返回的 user info,或要求 body 传 staffName(跟 close 一致)
- ⚠️ Phase 1.5 前端改造 issue(
Status Update 按钮 / Progress UI / status='pending' 硬编码消费)— 不在本 plan,follow-up 单独开
- ⚠️ Task 8 attempt workload helper 没 wire 进 dashboard endpoint(grep 无现有 attempt metric 用 close_result='attempted')— 预埋,等 PM 决定 Phase 1.5 是否暴露
LoC estimate
总计 ~2200-2500 LoC(含 production code + test)。9 task 拆分匹配规模,subagent 模式每 task 1 fresh session 可控。
Execution Handoff
Option 1: Subagent-Driven(推荐)— 每 Task 一 fresh subagent,review 之间 iterate。9 task 拆分明确,每 task 单独 commit + review。
Option 2: Inline — superpowers:executing-plans 跑全 9 task,checkpoint 之间停。Plan 06 是 Phase 1 最大 caller plan,LoC ~2200+,inline 模式 context 易 drift,不推荐。
前置 dependency check(开工前 verify):
# Plan 01 schema migration 在测试 Neon 已 apply?
psql $TEST_NEON_URL -c "\d task_progress_events" | head -5 # 表存在 → OK
psql $TEST_NEON_URL -c "SELECT executor_type, attempt_count, source_call_id FROM tasks LIMIT 1" # 新字段存在 → OK
# Plan 02 published?
cd /Users/maxwsy/workspace/callytics-common && cat package.json | grep version # 1.2.0 → OK
ls dist/domain/ # index.js + index.d.ts → OK
# studio-api 当前 @retaintive/common 版本
cd /Users/maxwsy/workspace/studio-website-monorepo && grep '"@retaintive/common"' apps/api/package.json
任一 check 不通过 → 不能开工,先 unblock dependency。
Plan 06 结束 = Phase 1 studio-api caller migration 完成;Phase 1 整体只剩 contacts-analyzer / lead-processor / message-processor STOP 三个 Lambda caller(Plans 03-05)+ final schema cutover(Plan 07)。