PR #146 · task-template lifecycle:把「模板」做成一等公民
buffin · main...agent/task-template-lifecycle(ab3f6a22,未 push)· 2026-07-18 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件、哪个函数下手。不贴行号,位置到文件/模块粒度。
1TL;DR
这个 PR 把「Task Template」从 PR #143 落下的底层能力,补成完整、前后一致的用户功能。核心是三件事:(1) 模板定义变不可变——创建后名称/阶段/顺序/颜色/key 都不能再改,删掉了服务端全部 template.update / stage 增删改重排 API 与 CLI 命令;(2) 引入 Active / Archived / Deleted 三态生命周期——归档是可恢复、无级联副作用的「下架分配资格」,物理删除只对没有任何任务引用的归档模板开放;(3) 任务跨模板迁移成为独立显式领域操作 changeTemplate,不再借用通用 update 或看板拖拽。
配套地:GUI 创建弹窗新增独立的 Template + Stage 两个字段,Settings 新增项目级模板管理页,Project 默认模板成为服务端真相源(三端共享一套默认规则),删除了第二套不可达的 Task 创建路径(UnsavedTaskPanel),stage 颜色从任意 CSS 字符串收紧为受控枚举。动机来自随附设计文档 task-template-mvp-design.md 的一句判断:根因不是少了几个控件,而是「可读取 / 可分配 / 可运行」三个概念此前被一个含糊的 active 布尔混为一谈。
2变更地图(称重)
改动横跨全栈,但重心明确落在两处:daemon 领域实现(生命周期守卫 + 三种查找 + delta 推送)和 desktop 渲染层(管理页 + 创建/迁移交互)。两者合计约占非测试改动的三分之二。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
apps/daemon | services/templates.ts 生命周期守卫、template-definition.ts 派生器、tasks.ts 的 resolveCreateStage/changeTemplate、trpc/services.ts 的 delta 接线、maintenance-tick.ts 的 reaper 钩子 | seed/createTask 调用补 templateId(dev-recovery、testing、board-seed 等机械跟随) |
apps/desktop | ProjectTemplatesSettings.tsx(新,492 行)、CreateTaskDialog.tsx、TaskTemplatePicker.tsx(新)、create-task-assignment.ts(新纯函数)、board.tsx 对账 effect、client-provider.tsx 订阅并集 | 9 个测试 fixture 适配(stage 删 version、颜色改 token、模板补 archivedAt/canDelete);i18n 三语 ×28 key;UnsavedTaskPanel 删除(逻辑已搬入 CreateTaskDialog) |
apps/cli | task change-template 新命令、template archive/restore、--stages 收窄为 {name,color} | 删掉的旧命令测试(证明命令确实没了);本地 DTO 类型声明外迁到 @buffin/api |
packages/* | api:新 schema/错误码/DTO 字段;client:invalidateBoardMeta;db:archived_at + 去 partial 索引 | migrations/meta/* drizzle-kit 重生成物;_journal.json 仅时间戳 |
scripts / CI | — | check-retired-name.mjs 是与本特性无关的搭车提交(d3b9479f);4 个 check-*.mjs 加 existsSync 过滤 |
诚实称重:107 个文件里真正承载设计的是 daemon 的生命周期/查找/delta 三块、desktop 的管理页与创建/迁移交互、以及 api 契约的形状收窄;其余约一半是「跟随 API 形状变化」的机械改动和删除。测试占 44%,其中 daemon 契约测试与 desktop 组件测试是新逻辑的主要兜底。
3架构一图流
两处架构面变了。其一,模板的写面从「一堆可变 CRUD + stage 子路由」坍缩成「一次性创建 + 三个生命周期开关」。其二,看板 delta 多了一条带外补发的通道:维护线程(reaper)物理删任务行后,经一个新钩子把「模板现在可以删了」这个变化推给客户端——这是以前完全不存在的一条边。
以前 · 可变 CRUD,delta 只从事务里发
现在 · 不可变定义 + 三态开关 + reaper 补发
右栏那条 reaper → onTasksReaped 的实线是本 PR 最不显眼、也最容易漏读的一笔:模板的「可删」资格只有在任务行被物理删除后才翻转,而物理删除发生在维护线程里,那里以前没有任何发布缝隙。旅程 D 专门走这条边。
4数据与状态先行
先只看形状,不讲行为——给后面的旅程预载词汇。契约真相源在 packages/api,数据库 baseline 在 packages/db。
阶段颜色:从任意字符串收紧为受控枚举
以前 stage 颜色是 daemon 发来的任意 CSS 字符串,renderer 内联渲染;现在是六值枚举 token,DB 端也用 enum + CHECK 兜底。
/** Theme-aware color choices accepted for an immutable template stage. */
export const stageColorSchema = z.enum(['gray', 'blue', 'green', 'amber', 'red', 'pink'])
// 一个模板定义一次写死,公共输入里没有 position 或任何生命周期字段——客户端创建后无从再改
const templateStageSeedSchema = z.object({
name: z.string().trim().min(1).max(120),
color: stageColorSchema, // 只收 name + color;key / band / position 全部服务端派生
})
export const MAX_TEMPLATE_STAGES = 20 // 单一数字:daemon validator 与编辑器的「加 stage」按钮共用
export const createTemplateSchema = templateFieldsSchema.extend({
stages: z.array(templateStageSeedSchema).min(2).max(MAX_TEMPLATE_STAGES), // 至少 todo 开 + done 闭
})
模板 DTO:新增 archivedAt 与 canDelete,stage 去掉 version
模板 DTO 多了两个字段表达生命周期;stage DTO 反而去掉了 version——因为 stage 不可变,永远不会有第二个版本。
export type TemplateStageDto = {
id: string; templateId: string; key: string; name: string
color: StageColor // 收窄枚举;且不再有 version / updatedAt(stage 不可变)
position: number
band: StageBand // 语义带:todo / doing / done,绑定自动化,不绑名字或 key
}
export type TaskTemplateDto = {
id: string; projectId: string; name: string; position: number
version: number // 乐观并发版本(archive/restore/delete 走它)
archivedAt: string | null // 生命周期标记;归档后仍可读,但不能接新分配
canDelete: boolean // 服务端派生:该归档定义已无任何任务引用 → 可物理删
stages: TemplateStageDto[]
}
两条新的任务契约:显式 templateId + 跨模板迁移
export const createTaskSchema = z.object({
projectId: idSchema,
// 两个都省略 = 走项目默认;显式分配则两个身份都带,stage 永不隐式代表模板意图
templateId: idSchema.optional(),
stageId: idSchema.optional(),
/* ...其余字段... */
}).superRefine((input, ctx) => {
if (input.stageId !== undefined && input.templateId === undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['templateId'],
message: 'templateId is required when stageId is provided' }) // 拒绝 stage-only 意图
}
})
/** 显式跨模板迁移,必须选定目标 stage */
export const changeTaskTemplateSchema = expectedVersionSchema.extend({
targetTemplateId: idSchema,
targetStageId: idSchema,
})
updateTaskSchema 同时删掉了 stageId——通用 update 不再能换列,同模板换列走 move,跨模板走 changeTemplate。
错误码:新增三态生命周期码,删掉 stage 可变码
| 新增 | 含义(白话) |
|---|---|
template.archived | 目标模板已归档,不能作为分配/迁移/默认目标 |
template.notArchived | 只有归档模板才能 restore,未归档时报它 |
template.mustArchive | 物理删除前必须先归档(先 archive 再 delete 的两步闸门) |
task.templateUnchanged | 迁移目标就是任务当前模板——白干,让你改用同模板 move |
同步删除的旧码:stage.keyConflict / stage.inUse / stage.bandInvariant(stage 不再可单独增删改,这些冲突不可能发生)。三个新 template 码与 task.templateUnchanged 均映射 HTTP 409。
数据库 baseline:archived_at 取代 deleted_at,索引去 partial
pre-1.0 直接改 0000_init baseline,无兼容迁移。关键变化:task_templates 的 (project_id, name) 唯一索引现在覆盖 active + archived 全部行(以前是 WHERE deleted_at IS NULL 的 partial)——即归档模板的名字仍占位,不能被新模板复用,只有物理删除后名称才释放。
export const taskTemplatesTable = sqliteTable('task_templates', {
/* ...id / projectId / name / position / version / createdAt / updatedAt... */
archivedAt: integer('archived_at'), // 取代旧 deleted_at:可逆的「停放」轴
},
(table) => ({
// 唯一索引不再带 partial predicate——名字在 active+archived 范围内共同唯一
projectNameUnique: uniqueIndex('task_templates_project_name_unique').on(table.projectId, table.name),
positionIdx: index('task_templates_position_idx').on(table.projectId, table.position, table.id),
}),
)
template_stages 同步删掉 version / updated_at / deleted_at 三列(不可变行不该暗示并不存在的修改能力),color 从自由文本收紧为 enum + CHECK。
5底座:三种查找 · delta 推送 · 乐观并发
三条旅程共用两套机制,先在这里走通一遍,旅程里只讲各自特有的部分。
5.1三种 stage 查找语义
设计文档的根因判断落到代码上,就是把以前那个含糊的 getActiveStageContext 拆成三种语义不同的查找。哪一个用错了,报出来的错误码就会指向用户根本没选的东西。
- assignable(可分配):
getAssignableTemplateRow(id)——选新目标定义时用(创建、迁移目标)。归档就抛template.archived。 - operational(可运行):
getOperationalStageContext(id)——解析已存在任务引用的 stage 时用(move / batchMove / 校验 stage 归属)。无视归档状态,只要项目未 purge 就能查到。 - readable(可读取):
listForBoard/list(includeArchived)——渲染看板、管理列表用。
为什么这个分离重要,看创建任务时的落地:所选模板的「可分配性」先被 assignable 查找定死;stage 随后用 operational(不带生命周期)查找,因此一个「属于归档兄弟模板的 stageId」报的是归属错误(跨模板),而不是那个用户根本没选中的兄弟模板的归档状态。
private resolveCreateStage(input, defaultTemplateId) {
if (input.stageId !== undefined && input.templateId === undefined)
throw new AppError({ code: BuffinErrorCode.validation.failed }) // 拒绝 stage-only
const templateId = input.templateId ?? defaultTemplateId // 缺省落到项目默认
const template = this.templates.getAssignableTemplateRow(templateId) // ① 目标先定可分配性
if (template.projectId !== input.projectId)
throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
if (input.stageId === undefined) {
const stage = this.templates.firstAssignableStage(template.id) // 无 stage:落该模板 todo 开列
/* ... */
}
// stage 用 operational 查找:模板可分配性已定,stage 归属不符时报 cross-*,
// 而不是未被选中的兄弟模板的归档状态
const context = this.templates.getOperationalStageContext(input.stageId) // ② stage 只看归属
if (context.projectId !== input.projectId)
throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
if (context.template.id !== template.id)
throw new AppError({ code: BuffinErrorCode.task.stageCrossTemplate })
return context
}
5.2delta 推送与 boardMetaChanged 边沿标志
看板 delta 一直只有一种形态:整份 board 快照。本 PR 给它加了一个边沿触发的旗标 boardMetaChanged——它请求客户端「顺带失效两个不订阅 delta 的缓存:模板列表 + 项目详情」。普通任务写(拖拽/建/改)不带旗;模板生命周期写、默认模板变更、子树硬删都带旗。
服务端只有一个发布器 createBoardSnapshotPublisher,被传输层 mutation 和维护线程 reaper 共用——目的是让每一次推送都过同一个订阅者守卫(无人订阅就不投影,省掉最贵的一步)和同一条投影路径。
export function createBoardSnapshotPublisher(deps) {
return (projectId, options) => {
// 投影整份 board 是发布里最贵的一步;零订阅者时无人会听,而 topic 首次订阅本就以 resync
// 标记开场、会重取快照——所以这里直接短路
if (!deps.boardDeltas.hasSubscribers(projectId)) return
try {
const board = deps.tasks.board(projectId)
deps.boardDeltas.publish(projectId, {
kind: 'snapshot', projectId, board,
...(options?.boardMetaChanged ? { boardMetaChanged: true as const } : {}), // 旗标
})
} catch (err) {
// 写已提交;缓存修复不能回滚这次 mutation
logger.warn({ err, projectId }, 'board delta publish failed')
}
}
}
客户端这一侧,dispatcher 收到带旗的 delta 时,除了打 board 缓存,还会跑 invalidateBoardMeta——本 PR 把它从「只失效模板列表」扩成「模板列表 + 项目详情」,因为默认模板变更也走同一条旗标,而 defaultTemplateId 挂在项目详情上、驱动隐式建任务。
// 修复 boardMetaChanged 这条边沿信号覆盖的每个缓存:模板列表(看板列内嵌 stages)
// 与项目详情(默认模板变更发同一条 delta,详情的 defaultTemplateId 掌舵隐式建任务)。
// 两者都不自己订阅 delta,这条旗标是它俩唯一的推送通道。
function invalidateBoardMeta(queryClient, projectId) {
return [
queryClient.invalidateQueries({ queryKey: templatesListQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectGetQueryKey(projectId) }), // 本 PR 新增
]
}
「边沿触发」有个连带后果:当一份带旗 delta 被更新的快照取代(superseded)而丢弃时,它的 board 载荷可以安全丢,但旗标不能——没有更新快照会重新断言它。所以 dispatcher 在三处(正常应用 / superseded / 断线 resync)都保证旗标不丢,carryMetaFlag 会把被丢弃 delta 的旗标转移到保留的那一份上。这层机制是旅程 D 能成立的前提。
@buffin/api 加 schema(templateLifecycleSchema 复用 expectedVersion)+ 错误码 + status 映射;② TemplateService 里事务内按「version → 状态前置 → 领域守卫」顺序检查,再 guarded UPDATE(eq(version) + 状态谓词),未命中走 throwTemplateWriteConflict 区分 stale-version 与同版本竞争;③ wireServices 接线时先读 projectIdOfTemplate,写成功后 publishBoardSnapshot(projectId, {boardMetaChanged:true});④ CLI 挂到 templateLifecycleCommand 工厂;⑤ 加契约测试。
6旅程 A:创建任务
这是最能体现「服务端真相源」原则的一条旅程。走通它,你会明白为什么点看板某一列的「+」号,建出来的任务不一定落在那一列,以及远端把你正选着的模板归档时弹窗为什么会自己换一个模板。
BoardColumn.tsx→ 算初值
create-task-assignment.ts→ 双字段弹窗
CreateTaskDialog.tsx→ 解析落库
tasks.ts · resolveCreateStage→ 跟随 focus
board.tsx
A.1点「+」发的不再是 stageId,而是「来源上下文」
以前看板列脚点「+」,直接把这一列的 stageId 当创建真相传进去。现在改传一个来源对象:aggregate(聚合)视图传 band、focus(聚焦某模板)视图传真实 stage。这个对象只播种初值,不约束用户后续的选择。
/** 播种但不约束一次创建分配的看板上下文 */
export type CreateTaskOrigin = {
readonly band: StageBand // 聚合列的 band,取其默认模板首个匹配 stage
readonly stageId: string | null // 聚焦模式点中的真实 stage,聚合下为 null
}
export function resolveCreateTaskAssignment(project, templates, origin): CreateTaskAssignment | null {
const focusedTemplate = origin.stageId === null ? undefined
: templates.find((t) => t.stages.some((s) => s.id === origin.stageId)) // 聚焦:含该 stage 的模板
const template =
focusedTemplate
?? templates.find((c) => c.id === project.defaultTemplateId) // 否则:项目默认模板
?? templates[0] // 再否则:第一个(见偏差 #4)
if (!template) return null
const stage =
(origin.stageId === null
? template.stages.find((c) => c.band === origin.band) // 聚合:该模板首个匹配 band 的 stage
: template.stages.find((c) => c.id === origin.stageId)) ?? template.stages[0]
return stage ? { templateId: template.id, stageId: stage.id } : null
}
把「点哪列建哪」的隐式规则外化成这个可测的纯函数,是本 PR 的一个新先例——它有独立的 create-task-assignment.test.ts(四场景全覆盖),旅程里每一处需要初值的地方(弹窗打开、远端归档后重置)都调它,不再各写一套。
A.2弹窗:Template 在前,Stage 在后,改模板则 stage 跟随重置
创建弹窗新增两个并排字段——先选 Template(只列 Active),再选该模板的 Stage。打开时用一个 effect 按 seedKey 一次性播种;改模板时把 stage 重置为新模板第一个 stage。提交门槛是「repos 与 templates 都加载完 且 选中的 stage 存在」。
function chooseTemplate(nextTemplate: TaskTemplateDto) {
setTemplateId(nextTemplate.id)
setStageId(nextTemplate.stages[0]?.id ?? '') // 改模板 → stage 跟随新模板首个
}
async function submit() {
if (!canSubmitDraft(draft) || !templateId || !stageId || submitting) return
const task = await createTask.mutateAsync({
templateId, stageId, // GUI 创建始终两个身份都发,绝不靠 stage 隐式代表模板
title: draft.title.trim(),
/* ...description / priority / repos / labels... */
})
onCreated(task) // 回的是整个 TaskDto,board 要读回 task.templateId 决定跟随
}
const ready = projectRepos.isSuccess && templatesQuery.isSuccess && selectedStage !== undefined
A.3远端归档所选模板:渲染期直接重置,不等 effect
一个竞态:弹窗开着,另一个客户端把你正选着的模板归档了——它从 active 列表消失,两个下拉会停在占位、Create 静默禁用。修法不用 effect,而是在渲染体里直接判:选中的 templateId 已不在最新 active 列表里,就立刻用幸存模板重算。
// 渲染期重置一个被世界作废的选择:远端归档把选中定义从 active 列表里删了,弹窗还开着——
// 保留旧 id 会把两个下拉钉在占位、Create 静默禁用,所以从幸存模板重新播种
if (templateId !== '' && templatesQuery.data
&& !templates.some((template) => template.id === templateId)) {
const assignment = project.data
? resolveCreateTaskAssignment(project.data, templates, origin) : null
setTemplateId(assignment?.templateId ?? '')
setStageId(assignment?.stageId ?? '')
}
同一手法在跨模板迁移控件 TaskTemplatePicker 里也有一份(旅程 C)。
A.4落库后:如果建到了聚焦视图之外的模板,就切 focus 跟过去
daemon 侧 resolveCreateStage(见 §5.1)落库;回到 board,onCreated 拿到完整任务后,如果当前聚焦的是模板 X 而新任务落在模板 Y,就把 URL focus 切到 Y——避免「创建成功但卡片从当前视图消失、详情停在看不见的任务上」。
draftStageIdtaskId,不处理「卡片落到别的视图」CreateTaskOrigin(band / 真实 stage)task.templateId 决定是否 focusTemplate 跟随再选中UnsavedTaskPanel 已删)排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 建的任务落错模板 / 忽略了项目默认 | tasks.ts:resolveCreateStage(服务端解析);GUI 侧 create-task-assignment.ts 的 resolver |
| 弹窗里 Template 下拉缺了某个模板 | CreateTaskDialog.tsx:TemplatePicker 只吃 useProjectTemplates(active,不含归档) |
| 远端归档后弹窗卡住 / Create 灰着点不动 | CreateTaskDialog.tsx:渲染期重置那段 if;看 project.data 是否还没加载(偏差存疑 3) |
| 建完卡片不见了、详情停在空处 | board.tsx:onCreated 里的 focusTemplate(task.templateId) 跟随分支 |
报 stageCrossTemplate / stageCrossProject | tasks.ts:resolveCreateStage 的两道归属检查(§5.1) |
7旅程 B:Settings 里的模板生命周期
这条旅程从零新建:以前 renderer 没有任何模板管理界面(trpc 层有可变 API 但没人消费)。现在 Settings → Projects → 项目详情下挂一个 492 行的管理页,Active / Archived 分区,承载创建、复制、设默认、归档、恢复、永久删除。
ProjectTemplatesSettings.tsx→ 数据 hooks
use-templates.ts→ 事务守卫
templates.ts→ 发旗标 delta→ 保活订阅
client-provider.tsx
B.1不可变:管理页没有「编辑」,改动只能靠复制
页面按 archivedAt 把模板分成 Active 与折叠的 Archived 两区。Active 行有「设默认(非默认才显示)/ 复制 / 归档」,归档按钮在「是默认 或 只剩一个 active」时禁用;Archived 行有「复制 / 恢复」,仅 canDelete 为真时才出现永久删除。整页没有 update/editStage 路径——这正对应服务端把可变 API 全删的事实。复制草稿有两处防御:颜色选项从 schema tuple 派生(新增色不会漏进 picker),复制名先按 120 上限截断(受控 input 不截断程序化赋值,先截好过提交被拒)。
// 从 schema tuple 派生,新色板成员绝不会静默地漏出 picker
const COLORS = stageColorSchema.options
// 本地化的「<源名> copy」种子,压在 schema 上限内;截断的种子仍可编辑,好过提交后被服务端校验拒
function copyName(sourceName: string): string {
const suffixLength = t('settings.templates.copyName', { name: '' }).length
return t('settings.templates.copyName', {
name: sourceName.slice(0, Math.max(1, TEMPLATE_NAME_MAX - suffixLength)),
})
}
B.2永久删除:行内两击确认,不叠第二层模态
Settings 本身已是一个模态面,设计文档禁止再叠模态。所以不可逆的物理删除用一个 confirmDeleteId state 做「武装 → 确认」两击守卫:第一击把该行换成确认+取消两个图标,第二击才真删。
{template.canDelete && (confirmDeleteId === template.id ? (
<>
<RowAction label={t('settings.templates.confirmDelete')} destructive
onClick={() => remove.mutate(
{ templateId: template.id, expectedVersion: template.version },
{ onSettled: () => setConfirmDeleteId(null) })}> // 第二击才真删
<Trash2 /></RowAction>
<RowAction label={t('common.cancel')} onClick={() => setConfirmDeleteId(null)}><X /></RowAction>
</>
) : (
<RowAction label={t('settings.templates.delete')} destructive
onClick={() => setConfirmDeleteId(template.id)}><Trash2 /></RowAction> // 第一击:武装
))}
B.3服务端守卫:事务内按固定顺序检查,再做 guarded UPDATE
archive 是三态守卫的代表:版本 → 已归档? → 是默认? → 是最后一个 active?,全在一个事务里检查并写入。写用 eq(version) + isNull(archivedAt) 双谓词,未命中则 throwTemplateWriteConflict 区分「版本过期」与「同版本生命周期竞争」。
this.db.transaction((tx) => {
const current = this.getTemplateRow(id, tx)
assertVersion(current.version, input.expectedVersion, 'template')
if (current.archivedAt !== null) throw new AppError({ code: BuffinErrorCode.template.archived })
const project = this.projects.getActiveRowFrom(tx, current.projectId)
if (project.defaultTemplateId === id)
throw new AppError({ code: BuffinErrorCode.template.isDefault }) // 当前默认不能归档
// 默认恒为 active,故唯一的 active 必是默认、isDefault 先触发;此守卫是纵深防御
if (this.activeTemplateCount(tx, current.projectId) <= 1)
throw new AppError({ code: BuffinErrorCode.template.lastActive }) // 最后一个 active 不能归档
const result = tx.update(taskTemplatesTable)
.set({ archivedAt, updatedAt: archivedAt, version: current.version + 1 })
.where(and(eq(taskTemplatesTable.id, id), eq(taskTemplatesTable.version, current.version),
isNull(taskTemplatesTable.archivedAt)))
.run()
if (result.changes !== 1) this.throwTemplateWriteConflict(id, input.expectedVersion)
})
物理删除(delete)的守卫链是:未归档 → template.mustArchive;templateTaskCount > 0 → template.inUse。关键在于 templateTaskCount 是不带 activeTaskWhere() 的裸 count——它计任意生命周期状态的任务(含归档、含墓碑),这就把「可删」资格的翻转时机推到了物理删除之后(旅程 D)。
B.4Settings 面为什么要单独保活一条 board 流
Settings 是覆盖在任意路由之上的 overlay,它显示的项目常常不是当前活动看板项目。若没有自己的 delta 流,别的客户端归档/恢复模板或改默认时,这个面板会一直陈旧到重开。所以订阅并集里新增了第四个来源:settings 选中的项目。为什么复用 board 流而不是新开模板通道——因为模板写在其他客户端正是通过 board 流的 boardMetaChanged 触发失效的(§5.2)。
const boardProjectIds = new Set(sessionBoardProjectIds) // 持有 open session 的项目
if (projectId !== null) boardProjectIds.add(projectId) // 活动看板项目
if (settingsProjectId !== null) boardProjectIds.add(settingsProjectId) // 本 PR 新增:Settings 面
for (const id of boardProjectIds) subscriptions.push({ topic: 'board.deltas', projectId: id })
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 归档被拒(默认 / 最后一个) | templates.ts · archive:isDefault / lastActive 守卫顺序 |
| 永久删除按钮该出现却没出现 | 服务端 canDelete(deletableTemplateIds);GUI 只在 template.canDelete 真时渲染——多半是旅程 D 的翻转还没到 |
| 删除报「必须先归档」/「仍被使用」 | templates.ts · delete:mustArchive / inUse(templateTaskCount 计任意状态) |
| Settings 面模板列表 / 默认徽章不刷新 | client-provider.tsx:setSettingsProject 订阅槽;ProjectSettingsDetail 挂载期注册 |
写冲突报 resource.conflict 而非版本过期 | templates.ts · throwTemplateWriteConflict:同版本生命周期竞争的分类 |
8旅程 C:任务跨模板迁移
跨模板迁移是本 PR 引入的一个独立领域操作,它的价值不是「保留迁移历史」,而是把 Project / Template / Stage / 归档状态 / 乐观版本这五重校验集中在一处,且明确不碰任务的执行现场(session、worktree、run、Activity)。它绝不能从通用 update 或拖拽绕过。
TaskTemplatePicker.tsx→ 单事务迁移
tasks.ts · changeTemplate→ 跟随 + 对账
board.tsx
C.1控件:选目标模板 → 选目标 stage → 确认,两步不可跳
详情面板 stage 行上方新增 Template 行。下拉自动排除任务当前模板;选中后弹出确认小面板(目标名 + 目标 stage 选择器 + 取消/确认)。用 task.version 做乐观并发。这里同样有渲染期重置——目标被远端归档、或任务已被并发迁移到该目标(再提交只会得 templateUnchanged),就折叠面板并遗忘选择。
// 渲染期重置一个被世界作废的选择:远端归档的目标离开 activeTemplates 会折叠确认面板,
// 但保留的 id 在目标被恢复时会静默复活它;且一旦任务已落在所选目标上,提交只会得 templateUnchanged
if (targetTemplateId !== null &&
(targetTemplateId === task.templateId ||
!activeTemplates.some((template) => template.id === targetTemplateId))) {
setTargetTemplateId(null)
setTargetStageId(null)
}
/* ... */
function confirm() {
if (!target || !targetStageId || disabled || change.isPending) return
change.mutate(
{ taskId: task.id, targetTemplateId: target.id, targetStageId, expectedVersion: task.version },
{ onSuccess: () => { onChanged(target.id); cancel() } }, // 成功后通知 board 跟随 focus
)
}
值得留意的双数据源:详情面板的迁移目标读 active 列表(useProjectTemplates,不含归档),而同模板 stage 操作读 board 快照的 templates——因为 board 快照含仍被引用的归档模板,所以停在归档模板上的任务,它的 stage 仍可操作。
C.2服务端:一个事务,五重校验,只改三列
daemon 侧 changeTemplate 用 assignable 查找定目标模板、operational 查找解目标 stage(同 §5.1 的哲学),校验通过后只更新 stageId / position / version——taskRepos、sessions 一概不碰(契约测试用 before/after 行相等来钉这一点)。
this.db.transaction((tx) => {
const task = assertFound(/* anyTaskWhere 选行,含归档 */, BuffinErrorCode.task.notFound)
assertTaskMutable(task) // 归档任务报 archived 而非 notFound
assertVersion(task.version, input.expectedVersion, 'task')
const targetTemplate = this.templates.getAssignableTemplateRow(input.targetTemplateId, tx) // 归档目标→archived
const target = this.templates.getOperationalStageContext(input.targetStageId, tx)
if (target.projectId !== task.projectId || targetTemplate.projectId !== task.projectId)
throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
if (target.template.id !== targetTemplate.id)
throw new AppError({ code: BuffinErrorCode.task.stageCrossTemplate })
const currentTemplateId = this.templates.templateIdOfStage(task.stageId, tx)
if (currentTemplateId === targetTemplate.id)
throw new AppError({ code: BuffinErrorCode.task.templateUnchanged }) // 目标即当前 → 白干
// guarded update:stageId + nextPosition(target) + version+1(不触碰执行现场资源)
})
C.3迁移之后:先切 focus 跟过去,再让对账 effect 清理越界选择
迁移把任务送到别的模板列,如果当前正聚焦源模板,卡片就会消失而右侧详情还活着——这正是设计文档 §8.5 要防的「渲染视图之外的过期详情」。两段逻辑配合解决:onChanged 先切 focus 跟随;board 上一个对账 effect 负责清理那些没被跟随、真正越界的选择。
useEffect(() => {
// 四个提前返回是设计要点,缺一个就会误清
if (scope.kind !== 'template' || !board || selectedTaskId === null || taskWritePending) return
const selected = board.columns.flatMap((c) => c.tasks).find((t) => t.id === selectedTaskId)
if (!selected || selected.templateId === scope.templateId) return // 不在快照里=removed 观察器的活
clearPreview()
setSelection(null)
}, [scope, board, selectedTaskId, taskWritePending])
四个守卫各挡一种误清:!board 挡 history back 落在 board 解析前;taskWritePending 挡迁移窗口(此刻 board 还把任务列在旧模板下,onChanged/onCreated 会先 focusTemplate 对齐);!selected 把「任务根本不在快照」交给 removed-task 观察器,不当作 scope 不匹配。因为创建/迁移都「先 focus 自己模板再选中」,跟随中的选择永不被误清。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 迁移后 session / 终端 / worktree 断了 | 不该发生:tasks.ts · changeTemplate 只改 stageId/position/version,契约测试钉了行相等 |
迁移报 templateUnchanged | tasks.ts · changeTemplate:目标模板与当前相同;或控件渲染期重置已折叠面板 |
| 迁移后卡片消失、右侧还停着旧详情 | board.tsx:先看 onTemplateChanged → focusTemplate 跟随;再看 §8.5 对账 effect 四守卫 |
| 迁移中选择被莫名清掉 | board.tsx:对账 effect 的 taskWritePending 守卫(迁移窗口不该清) |
| 归档模板上的任务 stage 下拉空了 | SavedTaskPanel.tsx:stage 源应读 board.data.templates(含归档)而非 active 列表 |
9旅程 D:canDelete 的两段式翻转
这条旅程走的是一个看不见的事件链,也是审查里两轮才闭环的那个 must-fix。问题是:模板的「可以物理删了」这个资格,究竟在哪一刻翻转,又靠谁把这个翻转推给正开着 Settings 面板的用户。答案是——它分两段,中间隔着一个异步维护线程。
D.1时间线:为什么删掉最后一个任务,canDelete 还不翻
Buffin 的任务删除是墓碑化(打 purge_requested_at)+ 异步 reaper 物理删行两段式。而 canDelete 的判据 templateTaskCount 计任意状态的任务引用(含墓碑)。所以:用户删掉归档模板上最后一个任务的那一刻,行还在(只是墓碑),stage 外键还挡着模板物理删除,canDelete 依旧是 false。它只有等 reaper 真正把行删掉才翻 true。
purgeRequestedAt)canDelete 仍 false——计数含墓碑行,stage FK 还挡着删除runOnce 物理删墓碑任务行onTasksReaped(projectIds) 钩子触发canDelete 此刻翻 trueD.2第一段:删除当场发旗,但注释直说「翻转还没来」
tasks.delete 的接线现在发带旗 delta(以前不带旗),但注释诚实地标明这只修了常见竞态,真正的翻转由维护钩子补发——这段注释本身就是审查修复留下的路标。
const { deletedTaskIds, ...result } = await deps.taskUseCases.delete(id)
/* ...扫终端、reconcile watcher... */
// 带旗:子树硬删改变了客户端缓存的模板任务计数,其模板列表须重取。canDelete 的翻转本身
// 稍后才落地——计数跨越墓碑行(stage FK 在 reaper 删掉它们前一直挡着模板删除),
// 由维护 tick 的 onTasksReaped 钩子在物理行消失后补发那份带旗 delta。
publishBoardSnapshot(projectId, { boardMetaChanged: true })
D.3第二段:reaper 返回被删项目集,钩子补发那条唯一的带外 delta
reaper 以前只删行、不通知任何人。现在 reapTombstonedTasks 返回 {deleted, projectIds},runOnce 在删掉行后调 notifyTasksReaped;这个钩子是 best-effort 的——投影抛错只 log,绝不失败整个维护 pass 或搁浅积压重试。
const reaped = await this.reapTombstonedTasks()
result.tasksDeleted = reaped.deleted
if (reaped.deleted > 0) this.notifyTasksReaped(reaped.projectIds) // 有行被删才通知
/* ... */
private notifyTasksReaped(projectIds: string[]): void {
// best-effort:一个抛错的 publisher 只 log,绝不失败维护 pass 或搁浅积压重试
try { this.onTasksReaped(projectIds) }
catch (err) { logger.warn({ err }, 'task reap notification failed') }
}
组合根(daemon index.ts)把 onTasksReaped 接到 同一个 createBoardSnapshotPublisher(§5.2),于是 reaper 的补发和传输层的 mutation 共用一个订阅者守卫、一条投影路径。onTasksReaped 收到被删项目集后,逐个 publishReapedBoards(id, {boardMetaChanged:true})——这是整个系统里模板可删态唯一的带外推送通道。这条通道在本 PR 之前完全不存在,客户端只能靠下次 refetch 才看到「可删了」。
canDelete),都会继承同一个缺口:维护线程里没有天然的发布缝隙。本 PR 补的这条 onTasksReaped 缝隙是它们共同的出口。trpc-wss 测试用「delete 发第一条带旗 → 查 canDelete:false → maintenance.runOnce() → 第二条带旗 → canDelete:true」把两段语义都钉住了。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 删完最后一个任务,永久删除按钮迟迟不出现 | 预期行为:等维护 tick 跑一轮。看 maintenance-tick.ts · reapTombstonedTasks 是否有 worktree 故障把任务搁浅 |
| reaper 跑了但 Settings 面没刷新 | index.ts:onTasksReaped 是否接到了 publishReapedBoards;trpc/services.ts · createBoardSnapshotPublisher 的订阅者守卫 |
| 补发 delta 报错但没影响别的 | 预期:notifyTasksReaped / createBoardSnapshotPublisher 的 try/catch 只 log(这两处无测试兜底) |
| canDelete 计算拖慢每次 board publish | templates.ts · deletableTemplateIds:单条 grouped 查询 + archivedIds 空则 early-return(成本压在热路径,见存疑) |
10计划 vs 实现的偏差
照设计文档做成的部分不必再看;真正的认知裂缝在偏差里。这些从随附的双视角审查 run log 挖出——四轮审查(arch ×2、cross ×2)共确认 27 条发现,26 条落地修复。下面只收对「理解代码」有意义的实质偏差。
| 计划是什么 | 实际做成什么 | 为什么变 / 状态 |
|---|---|---|
| 默认模板变更要刷新 Project 缓存(§6.4) | 初版 projects.update 改默认后不发任何 delta,dispatcher 也只失效 templates 不失效 project,其他客户端持续把任务建进旧默认 |
arch r1 修复:默认变更时发带旗 delta,invalidateBoardMeta 扩到同时失效 projects.get。✅ 已闭环 |
| 硬删任务后模板可删态要能推送 | 初版 tasks.delete 发的快照不带旗,且 reaper 根本没有发布缝隙——canDelete 的 false→true 永远推不到客户端 |
arch r1 修一半(发旗),arch r2 以 onTasksReaped 缝隙闭环(旅程 D)。✅ 已闭环 |
| 显式创建的错误码应指向用户选的东西(§6.5) | 初版给「归档兄弟模板的 stageId」报 template.archived——指向调用者根本没选的模板 |
arch r2 修复:归属检查前置,改经 operational 查找,正确报 stageCrossTemplate(§5.1)。✅ 已闭环 |
| 模板创建的领域检查应在事务内(§6.5 精神) | 初版重名检查与 position 推导跑在事务外,未来任何 async 缝隙下会退化成裸 SQLite UNIQUE 错误 | arch r1 修复:assertTemplateNameAvailable + nextTemplatePosition 移入 insert 事务。✅ 已闭环 |
| scope 变更后离开视图的选择必须清理(§8.5) | 初版只做了创建/迁移半边,用户主动 focus 切换那半边没做——越界任务的详情面板带着活的 stage 药丸和删除按钮继续挂着 | cross r1 修复:board 新增对账 effect + taskWritePending 门控(旅程 C.3)。✅ 已闭环 |
| 复制模板要求用户输入新的唯一名(§8.2) | 实际预填 <源名> copy(三语 + 120 截断),预填-而非-要求-输入的偏离仍在;同源重复复制仍会撞名往返 |
arch r1 仅缓解。⚠️ 遗留「低优清理」 |
| 受控色板选择(§8.2) | 颜色用原生 <select>,<option> 装不下 StageDot 色块,只能用本地化文字表意 |
cross r1 修 i18n 半条;无色块的原生 select 仍低于「受控色板」标准。⚠️ 遗留「低优清理」 |
| 默认是服务端真相,客户端无替补(§4.4) | resolveCreateTaskAssignment 在默认缺席时回落 templates[0],静默掩蔽被破坏的默认不变式 |
有意未修。⚠️ 遗留「架构需决策」(见 §13 合并前必办) |
过程元信息:这是一个 agent 代写 + 双视角(Claude + Codex)审查的 PR。审查分四轮迭代修复,每轮门禁全绿。流程层面有个观察——arch r1 与 cross r2 两轮 Codex 未产出,实际退化为单审查者,双视角覆盖打了折扣。此外 d3b9479f(check-retired-name 门禁)是一笔与模板特性无关的搭车提交,混进了同一审查区间。
11心智模型补丁
读完之后,你对这个项目的假设需要改这几处:
stageId 就够了,模板由 stage 隐式决定,通用 update 也能换 stage/换模板。
stage 不再隐式代表模板意图。创建必须显式带 templateId(或全省略走默认);update 删掉了 stageId;同模板换列走 move,跨模板走独立的 changeTemplate。
templates.update / templates.stages.* / reorder 全部删除,CLI 对应命令组也删了。要改就复制一个新模板再迁移任务。
{name,color}[] 的数组顺序派生(首 todo、尾 done、中 doing)。deletedAt),既表达「删了」又表达「不可用」。
归档(archivedAt)与物理删除是两件事。归档可恢复、无级联副作用,只下架分配资格;物理删除是终态,只对没有任何任务引用的归档模板开放。
canDelete 在 reaper 物理删行后才翻转,不是删任务当场。中间隔着异步维护线程,靠 onTasksReaped 补发 delta 推给客户端(旅程 D)。
StageDot 内联渲染。
颜色是六值受控枚举 StageColor,renderer 映射到专用的 tag-* 主题 token 族——改语义色(agent-state / danger)不会再误染看板列。
12新词表
仅本 PR 新增、不能假设你已知的名词,按域分组:
| 领域概念 | |
|---|---|
不可变定义 (definition) | 模板行 + 其 stage 行作为一个整体,创建后不可改。 |
Active / Archived / Deleted | 模板三态:archivedAt=null 为 Active;非 null 为 Archived(可恢复、无级联);物理删行为 Deleted。 |
archive / restore | 下架/恢复模板的分配资格,取代旧的 update/reorder 可变面。 |
changeTemplate | 把任务显式迁到另一 active 定义的独立领域操作,不走通用 update 或拖拽。 |
canDelete | 服务端派生的「该归档定义无任何状态的任务引用」标志,随 board publish 计算并嵌入 DTO。 |
assignable / operational / readable | 三种 stage 查找语义,分别服务「选新目标」「解析已有任务的 stage」「渲染/管理列表」。 |
band / key(派生化) | stage 的语义带(todo/doing/done)与稳定键不再由客户端提供,服务端从数组顺序 + name 派生。 |
| 基础设施 / 数据流 | |
|---|---|
boardMetaChanged | board delta 上的边沿触发旗标,请求客户端顺带失效模板列表 + 项目详情两个不订阅 delta 的缓存。 |
createBoardSnapshotPublisher | 被传输层 mutation 与 reaper 共用的 board 快照发布器,含订阅者守卫。 |
onTasksReaped | 维护 tick 物理删任务行后的回调缝隙,用于补发那条唯一的带外 board delta。 |
invalidateBoardMeta | dispatcher 里把 boardMetaChanged 覆盖的两个缓存(templates.list + project.get)一起失效的 helper。 |
throwTemplateWriteConflict | guarded write 未命中时,区分「版本过期」与「同版本生命周期竞争」的分类器。 |
| 前端 | |
|---|---|
resolveCreateTaskAssignment | 把「看板上下文 → 弹窗初始 (template, stage)」外化的可测纯函数;播种但不约束。 |
CreateTaskOrigin | 点「+」传的来源对象(band + 可能的真实 stageId),取代以前直接传的 stageId。 |
ProjectTemplatesSettings | Settings 项目级模板管理页(Active/Archived 分区、创建/复制/生命周期)。 |
TaskTemplatePicker | 详情面板里两步(选模板 → 选 stage → 确认)的跨模板迁移控件。 |
settingsProjectId 订阅槽 | client-provider 订阅并集里为 Settings 面保活 board 流的第四个来源。 |
tag-* token 族 | stage 颜色专用的主题变量族,本 PR 补齐 gray/amber/red 三成员。 |
13测试与风险地图
纯事实陈述:哪些行为有测试钉住,哪些重要逻辑走在薄冰上。分支未 push——合并前 CI 首跑即最后一道验证。
有兜底的
- daemon 契约(
template-contract.test.ts几乎整体重写):任务创建分配矩阵(8 种组合的错误码)、archive/restore 保持不可变 graph、三态守卫(isDefault/lastActive/mustArchive/inUse)、归档定义对已有任务仍 operational、changeTemplate 不改 taskRepo/session、hard-delete 的canDelete:false→true。 - reaper 两段 delta(
trpc-wss.test.ts):delete 发第一条带旗、runOnce后第二条带旗且canDelete翻 true;默认变更发旗、普通改名不发。reaper-maintenance.test.ts补去重通知与 worktree 故障搁浅两例。 - desktop 交互:
create-task-assignment.test.ts(resolver 四场景)、CreateTaskDialog.test.tsx与TaskTemplatePicker.test.tsx(远端归档渲染期重置)、ProjectTemplatesSettings.test.tsx(分区/合法动作/两击删除)、board.test.tsx(§8.5 对账四例)、dispatcher.test.ts(两种 key 形状钉 subset 失效契约)。
薄冰(重要逻辑无测试兜底 / 已知遗留)
- 🟠
resolveCreateTaskAssignment的templates[0]回落:客户端在默认缺席时静默替补,掩蔽被破坏的服务端默认不变式,违反 §4.4「客户端无替补」。无测试针对这条回落。 - 🟡存在性掩蔽哲学不统一:task 写路径揭示外项目模板的存在/归档态(
stageCrossProject/archived),project 写路径以notFound反枚举掩蔽——两条路径持相反策略,各有注释与契约测试背书,但未统一。 - 🟡reaper 补发链路的 try/catch 分支:
notifyTasksReaped与createBoardSnapshotPublisher吞异常只 log 的分支无测试(注入的 publisher 从不抛)。 - 🟡ProjectTemplatesSettings 的编辑器交互:stage 拖拽排序、
copyName截断、加 stage 插倒数第二位、MAX_TEMPLATE_STAGES上限禁用——均无测试。 - 🟡desktop 数据 hooks 的失效集合:
use-templates.ts三个 hook 的onSuccess/onError失效范围、useSetDefaultTemplate缓存写——无 hook 级单测(仅间接覆盖路由存在)。 - ⚪CLI project 侧 repo/label 命令:命令仍注册,但其 CLI 测试随大清理被删且未迁移——属覆盖流失(task 侧同名命令仍有覆盖)。
- ⚪
lastActive守卫不可达:被isDefault永久遮蔽,仅作纵深防御,测试无法单独触达(详见验收提示)。
resolveCreateTaskAssignment 的 templates[0] 回落:改为显式报错/空态暴露坏默认,或在设计文档为该防御性回落背书;③ template.lastActive 守卫的可达性:当前被 isDefault 永久遮蔽,若想给「唯一活动模板」更可行动的报错,需有意识重排守卫并补钉测试。CI 层面:四个修复提交每次均通过 check + typecheck + test + lint,pre-commit 复跑全绿,无 --no-verify。
14验收提示
验收时别把这些误判为缺陷:
UnsavedTaskPanel.tsx删了 252 行不是净损失:它的核心逻辑(TaskDraft状态、repo 首选播种、label picker、原子创建、UnboundReposNotice)几乎逐字搬进了CreateTaskDialog。真正消失的只是「详情面板内联 draft」这个第二形态本身。template.lastActive守卫看似死代码:因默认恒为 active、唯一 active 必是默认、isDefault先触发,这个分支实际不可达。它是有意的纵深防御,源码注释已说明——防未来重排守卫或允许「无默认项目」时无意识破坏不变式。- CLI
helpers.ts里parseOptionalNumberFlag成了死导出:它原来只被删掉的 template/stage input builder 用,现在无调用者。是保留待用还是清理遗漏,从 diff 无法判断——不是本特性的功能缺陷。 tag-blue/green/pink的色值被改了:这三个 token 在main里 dark 模式是近灰占位、且 desktop 内无其他消费者(已 grep 证实),本 PR 给它们换上真实色相并新增 gray/amber/red。看似「改了别人的颜色」,实为把这个族重新定位为 stage 调色板真相源。- 「归档但不可删、且当前不在 board」的中间态:一个只被归档任务引用的归档模板——不进 board(其唯一任务已归档),但
canDelete=false(仍被引用)。这是刻意设计,契约测试「keeps an archived task blocking hard delete」钉住了它。 d3b9479f(check-retired-name门禁)与模板特性无关:它是一笔搭车提交(拒绝已退役产品名的 CI/hook 门禁),审查时把它当范围外看待,不是本 PR 的设计承载。
15覆盖声明
本报告基于全量精读,不抽样。主力(我)亲自 Read 了报告中出现的每一段代码所在文件:daemon 的 templates.ts / template-definition.ts / tasks.ts / projects.ts / trpc/services.ts / maintenance-tick.ts;desktop 的 create-task-assignment.ts / CreateTaskDialog.tsx / TaskTemplatePicker.tsx / ProjectTemplatesSettings.tsx / board.tsx / client-provider.tsx / use-templates.ts;契约层 schemas.ts / dto.ts / errors/codes.ts / dispatcher.ts / db/index.ts;CLI 的 task.ts 与 project.ts 模板命令段。报告中所有代码片段均出自这些文件、经亲手裁剪(保留原文,// ... 省略无关行)。
四个子系统(daemon / desktop / cli / packages+杂项)各另有一个 subagent 全量精读 diff 做地图与交叉印证,覆盖每一行改动,包括本文未展开的机械跟随文件(9 个测试 fixture 适配、i18n 三语 key、seed/createTask 补参、migrations 生成物、4 个 check-*.mjs 的 existsSync 过滤)。「计划 vs 实现的偏差」(§10)与「合并前必办」(§13)取自随附的设计文档与四轮双视角审查 run log,结论已内联,不要求读者回看原文。