feat/board-templates:把「每项目固定状态列」重做成「模板 + 有序阶段」
eyrie-board-templates · a58644c3...HEAD(35 commits)· 2026-07 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自本分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这份文档只做「理解」,不做 code review——不挑刺、不提改进建议。
1TL;DR
Eyrie 的看板原本是「每个项目一套写死的状态列」:列头固定,想给不同类型的项目配不同流程做不到,而且如果自动化靠「列的名字」判断任务处于什么阶段(比如「进了叫 Review 的列就触发某动作」),用户一改列名,自动化就静默失效,不报错。
这个分支把它重做成两层:一个项目可以挂多套任务模板(task template),每套模板自带一串有序阶段(stage);阶段带一个 band(波段:todo / doing / done)语义字段——阶段名可以随便改,但它归属哪个波段是 schema 里的结构字段,自动化认 band 不认名字。任务从挂在 status 上改成挂在 stage 上。在这个新数据模型之上,又叠了一层筛选、搜索,和两种看板视图(聚合 / 聚焦)。改动一路贯穿数据库、API 契约、守护进程、CLI、桌面端五层,是一次全栈重构。
2变更地图(称重)
约 11,562 变更行,测试占 44%(5,051 行)。改动重心压倒性地在两个地方:桌面端的筛选/搜索/视图新内核(全新代码),和守护进程的服务层(模板/阶段 CRUD + band 不变式 + 位置算法)。其余各层大量是 status → stage/template 的机械重命名。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
desktop/src | features/tasks/query/ 筛选搜索内核、拖拽解析 board-model.ts、写入闸 use-tasks.ts、板面路由 board.tsx | statusId→stageId 逐字改名(board-move-model、task-selection、stage-change-model、StageDot、详情面板 props) |
daemon | services/templates.ts(全新 684 行)、services/tasks.ts 的 move/batchMove/adjacency、services/position.ts 的 exhausted 语义 | 十几个测试 fixture 的改名;ws-driver / testing 的 seed 结构跟随 |
db / api | schema 的 band 枚举 + 三组复合 FK + 循环 FK;DTO 与错误码命名空间从 status.* 拆成 template.*/stage.* | 0000_init.sql 与 0000_snapshot.json(Drizzle Kit 从 schema 重新生成的 baseline);错误 params/messages 的机械增改 |
cli / client | CLI 命令树 project status → project template+project stage;dispatcher 对「消失 task」的缓存失效 | 大量 statusId→stageId、statusesChanged→boardMetaChanged 改名 |
诚实说明:测试占比虽高,但非测试的 6,511 行里也有相当比例是机械重命名——真正承载新设计的,是桌面端 query/ 内核(约 630 行源码 + 大量单测)、守护进程的 templates.ts/position.ts,以及几个安放并发/拖拽/URL 语义的接缝点。
3架构一图流
数据模型的层级从「一层」变成「两层」,是理解整个 PR 的地基。以前项目直接挂一串状态列;现在项目挂模板,模板挂阶段,任务挂到阶段上,项目再用一个「默认模板」指针指回自己的某套模板(这就形成了一个循环外键)。
以前 · 项目直接挂固定状态列
现在 · 模板 → 阶段两层 + 循环指针
桌面端的看板渲染管线也多了一层。以前守护进程回来的 board 列直接画;现在中间插了一个纯函数内核 applyQuery,它把「原始 board + 一份查询条件」解析成一个「视图模型 BoardView」,看板和工具栏都读这一份结果。
以前 · board 直接渲染
现在 · 中间过一层纯函数内核
4数据与状态先行
先把几个新形状的「样子」摆出来,后面的旅程会反复用到它们。这一节只看形状不讲行为。
阶段表:band 是 schema 字段
每个阶段行带一个 band 枚举列,取值只能是 todo/doing/done。它同时被 Drizzle enum(管 TypeScript 类型)和一条 SQL CHECK(管数据库层)双重约束。注意注释里写死的那句规则——这就是整个 feature 的语义锚点。
projectId: text('project_id').notNull(), // 从模板反范式下来的 project_id,写一次不变
key: text('key').notNull(),
name: text('name').notNull(),
color: text('color').notNull(), // 由旧的可空变成必填:阶段列永远有颜色
position: real('position').notNull(),
// Semantic anchor for automation: every template opens on a 'todo' stage and closes on a
// 'done' stage, with all intermediate stages 'doing'. Match a stage by band, never by name or key.
band: text('band', { enum: ['todo', 'doing', 'done'] }).notNull(),
循环外键:项目指回自己的默认模板
项目要记住「新任务默认落进哪套模板」,于是加了 default_template_id。但它指向的模板行,是在同一个创建事务里、项目行之后才插入的——所以这一列 schema 层允许为空(只在创建那一瞬间为空),服务层和 DTO 都当它必填。它用的是一个复合外键:把 (默认模板 id, 项目自己的 id) 一起指向模板表的 (id, project_id),这样一个项目的默认模板不可能是别的项目的模板。
// Nullable at the schema level because the FK target row is inserted after the project row
// inside one create transaction; the service layer and DTO treat this as a required project field.
defaultTemplateId: text('default_template_id'),
// ...
defaultTemplateFk: foreignKey({
columns: [table.defaultTemplateId, table.id],
foreignColumns: [taskTemplatesTable.id, taskTemplatesTable.projectId], // 成对校验:默认模板必属于本项目
}),
同样的手法用在三处,全都围绕「项目所有权」:项目 → 默认模板、阶段 →(模板, 项目)、任务 →(阶段, 项目)。效果是数据库层直接拒绝「任务坐到别项目的阶段上」,服务层的校验只是前置的第一道。
DTO:任务多了一个读时派生的 templateId
export type StageBand = 'todo' | 'doing' | 'done'
export type TaskTemplateDto = {
id: string; projectId: string; name: string; position: number; version: number
stages: TemplateStageDto[] // 阶段内嵌,board 快照一趟带回,前端不用再查一次
}
export type TaskDto = {
// ...
stageId: string
templateId: string // server-derived via the stage join (read-side denormalization)
}
export type BoardDto = {
projectId: string
templates: TaskTemplateDto[] // 新增:board 顶层带回全部模板,供渲染器缓存模板列表
columns: BoardColumnDto[] // 按 (模板 position, 阶段 position) 铺平,空阶段列也出现
}
查询三件套:scope / conditions / search
桌面端的筛选状态是一份纯数据。scope 决定看哪个视图(聚合还是聚焦某模板),conditions 是筛选条件(维度间 AND、维度内 OR),search 是搜索词。这三样喂给 applyQuery 得到一个 BoardView。
export type BoardScope = { kind: 'all' } | { kind: 'template'; templateId: string }
export type FilterCondition = {
dimensionId: string // 哪个维度(template / label / priority)
selected: string[] // 选中的 option id,维度内 OR
includeNone: boolean // 三态「无值」桶,仅支持 none 的维度生效
}
export type BoardQuery = {
scope: BoardScope
conditions: readonly FilterCondition[] // 跨维度 AND
search: string // 原始文本,trim 后为空即不搜索
}
这三样里,只有 scope 存在 URL 里(所以聚焦某模板的看板可以分享链接、可以用浏览器后退键)。conditions 和 search 是高频编辑状态,每敲一个键写一次 URL 会灌爆历史记录,所以它们放在一个 per-project 的前端 store 里,不进 URL。
5底座:band 不变式 + 写入闸
有两个机制被多条旅程共用,先在这里走读一遍,后面的旅程只讲各自特有的部分。一个是守护进程侧的 band 不变式(保证阶段顺序永远语义正确),一个是桌面端的 共享写入闸(保证同一时刻只有一个任务写在飞)。
5.1band 不变式:入口永远是 todo,出口永远是 done
阶段可以任意多个、任意命名,但必须满足一条铁律:第一个阶段是 todo 波段,最后一个是 done 波段,中间的全是 doing。这样「入口」和「出口」永远语义明确,自动化和完成度计算才有稳定的锚点。这条规则不在数据库层强制,而是在守护进程每一条可能改变阶段顺序的写入路径上跑一遍校验函数。
function assertBandOrder(stages: Array<{ id: string; position: number; band: string }>): void {
const ordered = [...stages].sort((a, b) => a.position - b.position || compareIds(a.id, b.id))
const first = ordered[0]
const last = ordered[ordered.length - 1]
if (first?.band !== 'todo' || last?.band !== 'done') { // 首必 todo、尾必 done
throw new AppError({ code: EyrieErrorCode.stage.bandInvariant })
}
for (const stage of ordered.slice(1, -1)) { // 中间全 doing
if (stage.band !== 'doing') throw new AppError({ code: EyrieErrorCode.stage.bandInvariant })
}
}
它被安放在五个写入点上,每个点先把「改动后会长什么样」投影出来,再喂给这个函数,不通过就整体回滚:创建模板(对内嵌的阶段数组)、新增阶段、更新阶段、删除阶段(对剩下的阶段)、重排阶段。因为模板和它的初始阶段是在一个事务里一起创建的,一套活模板永远至少握着 todo/done 这对边界,所以想删掉边界阶段、或把 done 拖到 todo 前面,都会直接被 stage.bandInvariant 拒绝。
正因为「新阶段永远不能追加到 done 之后」,新增阶段有个特殊的落位规则:它默认落在倒数第二个 gap(done 收尾阶段之前)。守护进程用一个位置算法算这个 gap,如果算不出可用的分数位(列太挤了),就先重排整列再落位。
5.2共享写入闸:三种写都改同一份缓存,只能一个一个来
先摆清问题。看板上有三种乐观写:拖动改阶段(走 move)、改标题/描述/优先级(走 update)、删除(走 delete)。它们看起来是三件独立的事,但都在 patch 同一份 board.snapshot 乐观缓存。TanStack Query 只按 scope 串行化「写入阶段」,不串行化 onMutate(打乐观补丁的那一步),所以两个写可以基于同一个版本号同时开跑——一个成功推进到下一版本,另一个因为版本过期失败,失败的那个回滚时,会把成功那个的乐观改动一起覆盖掉,直到结算时的重新拉取才修复。
修法是加一道所有任务写共用的闸:只要有任何一个任务写在飞,就同时禁掉拖拽、阶段选择、字段编辑和删除。这里的关键细节是——要两把闸。
const TASK_WRITE_MUTATION_KEYS = [[['tasks','move']], [['tasks','update']], [['tasks','delete']]]
// 渲染态:闸值变化会 re-render 读取方(面板/看板据它 disable 各种编辑控件)
export function useTaskWritePending(): boolean {
const moves = useIsMutating({ mutationKey: [['tasks','move']] })
const updates = useIsMutating({ mutationKey: [['tasks','update']] })
const deletes = useIsMutating({ mutationKey: [['tasks','delete']] })
return moves + updates + deletes > 0
}
// 同步再查:手势发生的那一刻直接查 live mutation cache,不等 React re-render
export function isTaskWriteInFlight(queryClient: QueryClient): boolean {
return TASK_WRITE_MUTATION_KEYS.some((key) => queryClient.isMutating({ mutationKey: key }) > 0)
}
为什么光有渲染态那把不够?因为「失焦提交编辑」和「点击」是同一次事件派发里的两个 DOM 动作——点击的处理函数在 React 重渲染 taskWritePending 之前就跑了,可这时 update 的 mutate 已经把 live count 加了。所以在动作真正发生的那一刻(拖拽落点、标题失焦提交、删除确认),必须同步再查一次实时的写入计数,而不是只信上一次渲染留下的标志。板面路由里每个 move 都从这个选择点过一道:
function writeMove(input: MoveTaskInput) {
if (isTaskWriteInFlight(queryClient)) return // 手势瞬间同步再查,命中就丢弃这次 move
move.mutate(input)
}
delete 一开始被排除在闸外,后来纳入——因为 delete 的乐观补丁也从同一份 board 缓存快照并删卡,和在飞的 move/edit 重叠时,回滚会复活已删的卡、或抹掉兄弟的乐观改动。
6旅程 A:在工具栏点一个标签筛选
这条旅程走通「筛选/搜索内核」——用户在工具栏点一个标签,看板怎么收窄。走完你会知道筛选的解析全在一个纯函数里,以及那个「点一个 option,其他 option 的数字也跟着变」的交叉计数是怎么算的。
BoardToolbar.tsx→ 写 store
board-query-store.ts→ 重算视图
apply-query.ts→ FLIP 收拢
BoardColumn.tsx
A.1点击写进 per-project store
用户点工具栏里 Label 维度 popover 的一个选项,触发 store 的 toggleOption,把这个项目的 conditions 改掉。store 按项目分桶,所以切到别的项目筛选状态互不干扰。有个细节值得一提:所有没被碰过的项目共享同一个冻结的空数组引用,防止某处 push/splice 就地改坏了全局默认值。
// 只保留真正「收窄结果」的条件:已知维度 + 非空选择,或一个被认可的 none 开关。
// 针对已删除维度的 stale 条件在这里被丢弃。
function resolveActiveConditions(query: BoardQuery): ActiveCondition[] {
const active: ActiveCondition[] = []
for (const condition of query.conditions) {
const dimension = dimensionById(condition.dimensionId)
if (!dimension) continue // 未知维度直接跳过(stale 条件自愈)
const includeNone = condition.includeNone && dimension.supportsNone
if (condition.selected.length === 0 && !includeNone) continue
active.push({ dimension, selected: condition.selected, includeNone })
}
return active
}
A.2applyQuery 把条件变成一份视图
store 一变,板面路由的 query 就变,纯函数 applyQuery 重新跑一遍。它做四件事:按 scope 选列(聚合折三档 / 聚焦单模板)、按条件决定每张卡可见还是隐藏、把命中搜索的卡浮到列首、算出各种计数。整个函数无时间、无随机、无 IO——同样的输入永远得到同样的视图。
const matched: BoardViewCard[] = []
const dimmed: BoardViewCard[] = []
let hiddenCount = 0
for (const task of rawColumn.tasks) {
if (!passesConditions(task, conditions, board)) { // 没过筛选 → 计入 hidden,不进卡片列表
hiddenCount += 1
continue
}
const hits = collectTaskHits(task.id, searchValues.get(task.id) ?? new Map(), search)
const isMatched = !isSearchActive(search) || hits.length > 0
;(isMatched ? matched : dimmed).push({ task, matched: isMatched, hits })
}
const cards = [...matched, ...dimmed] // 命中的浮顶、未命中的沉底,组内保持 board 原序
注意筛选和搜索的行为不一样:筛选是真隐藏(计入 hiddenCount,列头显示「N 张被筛掉」),搜索是浮顶+淡化(命中的卡浮到列首高亮,没命中的沉底淡显但不消失)。
A.3交叉计数:数字是「选它会剩多少」
popover 里每个选项后面的数字,不是「当前有多少」,而是「如果我选它,会筛出多少」。这靠一个技巧:数某个维度的每个选项时,先用「除这个维度以外的其他条件」过滤一遍,再数。所以正在调的这个维度,它自己已经选中的东西不参与它自己的计数。
for (const dimension of FILTER_DIMENSIONS) {
const base = inScopeTasks.filter((task) =>
passesConditions(task, conditions, board, dimension.id), // 排除自己这个维度,只按其他维度过滤
)
const options = new Map<string, number>()
for (const option of dimension.optionsFrom(board)) {
options.set(option.id, base.filter((t) => dimension.predicate(t, [option.id], false, board)).length)
}
// ...noneCount 同理...
}
维度是一张注册表 FILTER_DIMENSIONS = [template, label, priority]。想加一个筛选维度只加一条 entry,applyQuery 泛化读取,不用改它本身。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 筛选/搜索结果不对(该显示的没显示) | query/apply-query.ts:passesConditions(AND/OR 逻辑)、buildColumnCards(可见/隐藏划分) |
| popover 里选项数字不对 | query/apply-query.ts:computeDimensionCounts(交叉计数排除自身维度) |
| 切项目后筛选串了 / 筛选状态丢了 | query/board-query-store.ts:按项目分桶、EMPTY_CONDITIONS 冻结引用 |
| 搜索框清空后旧词又冒回来 | board-query-store.ts 的 resets 计数器 + 工具栏防抖 effect(见旅程 A 的 store 形状) |
7旅程 B:聚焦一个模板
这条旅程走通「聚合 ⇄ 聚焦」两种视图的切换,以及一个不显然的边界——URL 里的聚焦目标指向一个已经不存在的模板时会发生什么。
board.tsx→ 写 URL
board-scope-search.ts→ 选列
apply-query.ts→ 校验回落
board.tsx effect
B.1聚焦 = 往 URL 写一个 focus 参数
不选具体模板时是聚合视图:把所有模板的阶段按 band 折叠成三大列,进行中的卡片带一个「模板·阶段」小 chip,点它就聚焦到那个模板。聚焦后是聚焦视图:展开成那个模板的真实阶段列。聚焦这个动作,本质是往 URL 的 search 里写一个 focus 参数。
function focusTemplate(templateId: string) {
void navigate({ to: '.', search: (prev) => ({ ...prev, focus: templateId }) }) // 写 URL → 独立 history 项
}
function clearFocus() {
void navigate({ to: '.', search: ({ focus: _focus, ...rest }) => rest }) // 剥掉 focus key,回聚合
}
因为 scope 在 URL 里,所以一个聚焦的看板可以分享链接、可以用后退键回到聚合。URL 里的 focus 一变,applyQuery 就走 focusColumns 只出这套模板的真实阶段列。
B.2坏掉的 focus 会渲染成一块假空板——所以要校验回落
这里有个跨阶段的坑。因为 scope 只活在 URL 里,一个 focus id 可能比它指向的模板活得更久:另一个客户端或 CLI 把这个模板删了(board 和模板缓存都通过增量流修好了,但没人管 URL),或者一个分享链接里带着的是另一个项目的模板 id。这时 focusColumns 会返回零列,看板读起来就像一块空板——其实不是空,是 focus 指错了。
const board = boardView.board
useEffect(() => {
if (focus === undefined || !board) return // 等 board 加载,别把「还没加载」误判成「不存在」
if (board.templates.some((template) => template.id === focus)) return // focus 还活着,不动
void navigate({ to: '.', replace: true, search: ({ focus: _focus, ...rest }) => rest })
}, [focus, board, navigate])
逻辑是:等 board 真正加载出来(免得把「还没加载的模板」误判成「不存在的模板」),确认这个 focus 不在 board.templates 里,就用 replace 剥掉它——用 replace 而不是 push,是为了让这条坏掉的 URL 不成为后退键的一个停靠点。这个问题在体验层阶段曾被当作「非崩溃、可手动清除」放过,后来在全局复审阶段被论证成一个真正的跨阶段问题,补上了这段回落。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 点 chip / picker 没聚焦,或后退键行为怪 | routes/board.tsx:focusTemplate / clearFocus(navigate 的 search 写法) |
| 看板莫名一片空白 | routes/board.tsx 的回落 effect + board-scope-search.ts 的 scopeFromSearch;先确认 URL 的 ?focus= 是否指向当前项目的活模板 |
| 聚焦后列不对 | query/apply-query.ts:focusColumns(按模板过滤 + 阶段 position 排序) |
8旅程 C:拖一张卡片
这是整个 PR 最曲折的一条旅程。拖拽看似简单,但「客户端告诉服务器把卡片放哪」这件事的语义,在开发过程中来回改了三次。走通它你会明白为什么最后的结论是「客户端必须按存储顺序解析锚点」,以及聚合视图下拖拽为什么有一套特殊规则。
Board.tsx→ 解析锚点
board-model.ts→ 过写入闸
board.tsx writeMove→ 校验邻接
daemon tasks.ts
C.1卡片位置用「邻居 id」表达,不用绝对坐标
先理解一个基本约定:守护进程给卡片定位,用的是「上一张卡的 id / 下一张卡的 id」这对锚点,而不是一个绝对位置数字。它自己根据这对邻居算一个分数位置。所以客户端要在「移除被拖的那张卡之后」的顺序里,算出落点的上下两个邻居是谁。
export type DropTarget = {
readonly stageId: string
readonly beforeTaskId: string | null // 落点下方那张卡,底部时为 null
readonly afterTaskId: string | null // 落点上方那张卡,顶部时为 null
}
C.2存储序 vs 显示序:一次绕了整整一圈的修复
问题的核心是:数据库里的存储顺序 和 用户在屏幕上看到的顺序 不一样。筛选隐藏了一些卡,搜索把一些卡浮到了顶部。用户拖的是屏幕上看到的顺序,可守护进程校验锚点时按的是完整存储顺序。这个错位导致了两轮方向相反的 bug:
第二次那个新 bug 值得具体看。聚焦视图往列中间拖一张卡时,前端会算出「落在哪两张卡之间」的两个锚点,但它算的是可见子集里相邻的两张。而守护进程校验这两个锚点是否相邻,用的是完整存储顺序。举个具体场景:某列存储顺序是 A, X, B, C,其中 X 被筛选隐藏了。用户看到 A, B, C,把 C 拖到「看起来的 A 和 B 之间」:
存储顺序: A X B C
↑ 被筛选隐藏
用户看到: A B C
用户意图: 把 C 放到 A 和 B 之间
前端发出(按可见序): afterTaskId=A, beforeTaskId=B
守护进程校验(按存储序): A 和 B 相邻吗?
A(下标0) 与 B(下标2) 之间隔着 X —— 不相邻!
结果: 抛 invalidSortAnchor,正常的拖拽被回滚
守护进程这一端的校验代码,明确注释了「邻接是关于客户端看到的那块板的陈述,所以要拿移动前的顺序去校验」——而它拿到的移动前顺序,永远是完整存储序:
// adjacency is a statement about the board the client saw, so check it against the pre-move order
if (before && after && !areAdjacent(anchorOrdered, after.id, before.id)) {
throw new AppError({ code: EyrieErrorCode.task.invalidSortAnchor })
}
所以最终结论:两条拖拽路径都改回按原始存储顺序解析,那个中间引入的 visibleColumns 被整个删掉(两条路径都不用它了,它就没有正确的调用者了)。绕了一圈回到原点,是因为 daemon 那端始终按完整存储序解释锚点,客户端任何「按显示序发锚点」的尝试都和 daemon 的语义对不上。现在 Board.tsx 松手时明确用 board.columns 的原始存储顺序:
const columns = board.columns.map((c) => ({ stage: c.stage, tasks: c.tasks })) // 原始存储序,绝不用 render 顺序
const drop = overId.startsWith('band:')
? resolveAggregateBoardDrop(columns, task.id, overId.slice(5) as StageBand) // 聚合:折进 band
: resolveBoardDrop(columns, task.id, overId) // 聚焦:普通 per-stage
C.3聚合视图的落点:卡片不能离开自己的模板
聚合视图把多套模板的阶段折成了三大 band 列,所以拖拽落点有一套特殊规则。首先,一张卡不能被拖到别的模板去(守护进程会拒 stageCrossTemplate),所以释放到别模板的列上是个惰性 no-op,而不是一次会在服务器报错后弹回的乐观移动:
const source = columns.find((column) => column.tasks.some((task) => task.id === activeTaskId))
if (source && target.column.stage.templateId !== source.stage.templateId) return null // 跨模板 → 惰性丢弃
其次,band 隐藏了真实阶段,所以卡片落进某个 band 时,落到自己模板在该 band 的第一个阶段、追加到末尾。而在同一个 band 内拖拽是 no-op——因为聚合列把整个 band 折成了一列,无法区分卡片该落在 band 内的哪个阶段,而硬折到第一个阶段会把「Review」悄悄倒退成「Coding」:
// 卡片已在目标 band 的某个阶段 → no-op:折叠列无法区分落点,硬折会把 Review 倒退成 Coding。
if (source.stage.band === targetBand) return null
// 否则落到该卡自己模板在目标 band 的第一个阶段(存储序)
const destination = columns.find(
(column) => column.stage.templateId === source.stage.templateId && column.stage.band === targetBand,
)
if (!destination) return null // 模板在这个 band 没有阶段 → 惰性丢弃
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| 筛选/搜索激活时拖拽把隐藏卡挪乱了,或落点弹回 | features/tasks/components/Board.tsx 的 handleDragEnd:确认它用 board.columns 存储序,不是 render 序 |
服务端报 invalidSortAnchor | daemon/src/services/tasks.ts:resolvePositionFromSnapshot 的 areAdjacent 校验(锚点必须在存储序里相邻) |
| 聚合视图里拖卡片没反应 / 拖不动 | board-model.ts:resolveAggregateBoardDrop(同 band no-op、跨模板惰性、模板无该 band 阶段) |
| 拖拽期间编辑控件没被禁用导致数据错乱 | board.tsx 的 writeMove + use-tasks.ts 的写入闸(见 §5.2) |
9旅程 D:切换项目(一个零并发就能触发的破坏性写)
这条旅程短,但它对应的是全过程发现的最严重的一个 bug——不需要任何并发,单个用户日常的「导航 + 编辑」就能触发一次跨项目的破坏性写。理解它,你就明白为什么切换项目时要清一堆状态。
看板页面在 TanStack Router 里是跨项目复用的同一个组件。切项目时如果只清一部分状态、没清「当前选中的任务」,就会出事:在项目 A 打开了任务 A-42 的详情面板,导航到项目 B——面板还显示着 A-42。而任务的写操作只认任务 id、不认当前在哪个项目,于是:
在项目 A 打开任务 A-42(详情面板选中它)
→ 导航到项目 B(组件复用,面板仍显示 A-42)
→ 改标题失焦 → 对 A-42 发 update,却乐观地 patch 了项目 B 的看板缓存
→ 点删除确认 → 把 A-42 及其子树从「显示着项目 B」的界面上硬删除
修法是在 projectId 变化的 effect 里,把选中任务、草稿阶段、预览目标一起重置。注释把「为什么必须清」写得很清楚——任务的 mutation 只按 taskId 键控,跨项目会打错板:
useEffect(() => {
setActiveBoardProject(projectId)
setSelection(null) // 清选中任务:否则 A 的 task 会带进 B
setDraftStageId(null) // 清草稿列
clearPreview()
return () => {
setActiveBoardProject(null)
clearPreview()
}
}, [projectId, setActiveBoardProject])
与之配套,客户端的 dispatcher 还补了一条:当看板增量流告诉它「某个任务从板上消失了」(被别的客户端归档或硬删),就主动失效那个任务的详情缓存——否则一个开着的详情面板会在缓存有效期内继续服务已删任务的旧行,甚至失焦提交时把旧行写回。
for (const column of previousBoard.columns) {
for (const task of column.tasks) {
if (presentIds.has(task.id)) continue // 仍在板上,跳过
for (const key of [taskGetQueryKey(task.id), taskLabelsListQueryKey(task.id)]) {
if (queryClient.getQueryState(key) !== undefined) { // 只失效「已经打开过」的缓存
repairs.push(queryClient.invalidateQueries({ queryKey: key }))
}
}
}
}
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| 切项目后详情面板还显示上个项目的任务 / 编辑打到错项目 | routes/board.tsx:projectId effect 是否重置了 selection / draftStageId / preview |
| 别人删了任务,我这还能编辑它 | packages/client/src/dispatcher.ts:invalidateRemovedTasks 和 patchBoardSnapshot 里的 previousBoard 对账 |
10计划 vs 实现的偏差
这个 PR 全程有随附文档(技术方案、run log、遗留清单)。照计划做成的部分不必细看,真正的认知裂缝在「中途变了卦」的地方。下面每条都是实质偏差。
| 议题 | 计划 → 实际,为什么变 |
|---|---|
| board 增量的两个标志 | 计划让 board 增量分别带 stagesChanged 和 templatesChanged 两个布尔标志。实际折叠成单个 boardMetaChanged——因为模板 DTO 内嵌了阶段,两个标志最终都只触发同一个「模板列表」缓存失效,对所有客户端可证明等价。代价:未来若要区分「仅阶段变」和「模板结构变」做更细的缓存失效,得重新拆开。 |
| 改阶段不能走 update | 更新任务的助手函数其实无法表达「改阶段」(乐观补丁表达不了),带 stage 变更的 update 会让卡片卡在旧列直到重新拉取。于是把 stageId 从 update 的输入里拿掉——改阶段结构上强制走独立的 move 路径,正好和「拖拽卡片 = 改阶段」的直觉对齐。 |
| 拖拽锚点:存储序 → 显示序 → 存储序 | 见 §8.2。体验层用存储序(隐藏卡被静默重排的 bug)→ 改成显示序并引入 visibleColumns(修了旧 bug 但引入聚焦视图新回归)→ 全局复审改回存储序并删掉 visibleColumns。绕一圈回原点,因为 daemon 端始终按完整存储序解释锚点。 |
| 共享写入闸 | feature 之前就潜伏的并发接缝:move / update / delete 三种写 patch 同一份缓存却各有各的 pending 判断。补一道共享闸(含同步再查),并把 delete 也纳入。见 §5.2。 |
| 切项目跨项目破坏性写 | 全局复审发现的 must-fix:组件跨项目复用,选中任务不清会导致对上个项目的任务发写/删。见 §9。 |
| focus URL 指向已删模板 | 体验层当「非崩溃、可手动清除」放过;全局复审论证它是跨阶段问题(别的客户端删模板后缓存修了但 URL 没人管),补上校验回落。见 §7.2。 |
| 无阶段创建任务的落位 | 规范没定义 task create 不给 stageId 时落哪。实现补充:落到项目默认模板的 todo 开头阶段。CLI 的 --stageId 也随之从必填变可选。 |
| 位置分配的确定性 | 一组守护进程侧修复:resolveInsertPosition 加 exhausted 语义(大数值下中点可能舍入到边界,即使 gap 还很大,也要重排);批量移动按请求顺序落位而非按 UUID 破平;阶段重排会 bump version,让基于旧位置模型的重排被判过期拒绝而非静默落错顺序。 |
11心智模型补丁
读完这个 PR,你对代码库的理解需要改这几处:
band 字段(todo/doing/done),名字随便改都不影响。自动化只认 band。
assertBandOrder 在每条写入路径强制。board.columns)。守护进程按完整存储序校验锚点邻接,客户端必须对齐它。
12新词表
| 数据模型 | |
|---|---|
task template(任务模板) | 一个项目下的一套工作流定义,其有序阶段就是看板列;一个项目可有多套(默认落地 default 和 develop 两套)。 |
template stage(阶段) | 模板内的一个有序阶段,渲染成一列看板。带 key/name/color/position/band。取代旧的 project_statuses。 |
band(波段) | 阶段的语义锚点,枚举 todo/doing/done。自动化按它匹配阶段,不看名字。 |
| band 不变式 | 模板阶段顺序必须首 todo、尾 done、中间全 doing;破坏它报 stage.bandInvariant。 |
default_template_id | 项目上的循环外键,指向本项目某套活模板;无阶段建任务、归档恢复兜底都落它的开头阶段。删它前须先改指别的模板(否则 template.isDefault)。 |
stageCrossProject / stageCrossTemplate | 任务落点校验的两类错误:阶段不属于任务项目 / 阶段属于同项目的另一套模板。 |
| 桌面端视图与查询 | |
scope(范围) | all(聚合三 band 列)或 template(聚焦单模板真实列)。存 URL,可分享可后退。 |
| aggregate / focus(视图模式) | applyQuery 输出的 mode;聚合折叠、聚焦展开。 |
| 交叉计数(cross-filter count) | 某选项的计数按「除它所在维度以外」的其他条件过滤后再数,反映「选它会剩多少」。 |
| 双口径计数 | visibleTasks(筛选后)/ totalTasks(scope 内全量),列头与工具栏都显示 可见/总数。 |
| 存储序(stored order) | 守护进程权威的完整卡片顺序,拖拽锚点必须据此解析(对立于筛选/浮顶后的显示序)。 |
| task-write gate(写入闸) | move/update/delete 共享的单一在飞标志,防止两个写重叠 patch 同一份看板缓存。 |
| 协议与守护进程 | |
boardMetaChanged | 看板增量上的合并标志,模板/阶段写置位(提示客户端刷新模板列表),任务写不置位。取代旧的 statusesChanged。 |
exhausted(槽位耗尽) | 算出的分数插入位在浮点精度下无法严格落在上下界之间,需先重排列。 |
13测试与风险地图
纯事实陈述:哪些行为有测试钉住,哪些重要逻辑还踩着薄冰。
有兜底的
- band 不变式全路径:create 缺 todo 开头原子失败、update 翻转边界 band、删边界阶段、reorder 把 done 移到 todo 前——四条路径各有拒绝用例。
- 拖拽存储序锚点:
board-model.test.ts有专门的「drag anchors stay adjacent in stored order」用例,覆盖越过隐藏卡解析、跨模板返回 null、聚合同 band 不倒退。 - 筛选/搜索内核:
apply-query.test.ts(21 例)钉住 AND/OR、交叉计数排除自身、matched 浮顶、纯函数同输入深相等;search.test.ts(13 例)钉住 Unicode 折叠时高亮区间不越界。 - 写入闸:
task-write-gate.test.tsx钉住 move/edit/delete 任一在飞即 pending、settle 后复位。 - 删除守卫栈:
isDefault/lastActive/inUse三关 + 级联软删阶段带 version+1。 - 位置算法确定性:
resolveInsertPosition的 exhausted、大数值中点舍入、批量移动按请求序落位、重排后旧 reorder 撞过期版本。 - 复合 FK:db 层「项目默认模板指向他项目 / 任务坐他项目阶段」都抛 FOREIGN KEY;循环 FK 的清理路径也有专测(把 default_template_id 指向真实模板,走生产的有环图)。
- 切项目重置 / stale focus 回落 / 消失 task 缓存失效:
board.test.tsx和dispatcher.test.ts各有专测。
薄冰
- 🟠 筛选态真实拖拽的组件级链路无端到端测试:拖拽解析有纯函数单测,但「筛选/搜索激活时,用 dnd-kit 真的拖一张卡、锚点走完守护进程校验」这条完整链路没有组件级测试(dnd-kit 在 jsdom 里难驱动)。这正是本次要用 agent-browser 做用户视角自测补上的地方。
- 🟡 FLIP 动画 / prefers-reduced-motion 分支:动画本身难测,重排触发 re-measure 的正确性没有单测钉住。
- 🟡 详情面板的写闸集成交互:闸的纯逻辑有测试,但「命中闸时保留本地脏值、闸解除后再失焦提交」这条交互路径没有组件级断言。
- ⚪ 模板级重排(非阶段级):只有一条 happy-path,没有乐观并发冲突和 not-found 用例。
- ⚪ i18n 覆盖度:三语 locale(en/zh-Hans/zh-Hant)是否已彻底清掉残留的
status文案,建议人工扫一遍。
data-model.md——它目前仍停留在旧的固定三栏假设,写着 project_statuses、status_id,与本 PR 完全脱节。
14验收提示
下面这些不是缺陷,验收时别被吓到:
- 三个 rename 会被 git 标成删除+新增:
status-dot → stage-dot、status-change-model → stage-change-model(含各自 .test),逻辑一字未改,只换了字段名。 - 聚合视图里无法把 Review 拖到同 band 的 Coding:这是刻意设计(聚合列把整个 band 折成一列,无法区分落点),要跨阶段拖必须先聚焦该模板。
- 写入闸不按项目区分:它匹配所有项目的任务写。因为看板一次只挂一个项目,短暂阻塞别项目的写无害——注释已写明这个边界假设。
0000_init.sql/0000_snapshot.json是 Drizzle Kit 从 schema 重新生成的 baseline,不是手写;本分支假设数据库从零重建,没有从旧 status 数据迁移的路径(项目处于 pre-1.0,可弃库重建)。- 阶段/模板 DTO 不带时间戳:旧的
ProjectStatusDto带 createdAt/updatedAt,新 DTO 有意收窄,不是漏了。 status.test.ts文件名和describe('eyrie status')未改名,但内部已测新命令——命名漂移,不影响行为。
15覆盖声明
本报告基于对分支 a58644c3...HEAD 全量 diff 的精读。约 11,562 变更行按子系统分给了五个精读 agent(db+api / daemon / cli+client+杂项兜底 / desktop-src / 文档矿工)全覆盖,无死角。报告正文出现的每一段代码,都由编排者亲自 Read 过对应文件后裁剪——包括 schema(packages/db/src/index.ts)、筛选内核(apply-query.ts、search.ts)、拖拽模型(board-model.ts)、位置算法(position.ts)、写入闸(use-tasks.ts)、板面路由(board.tsx)、守护进程 move/adjacency(services/tasks.ts)、band 不变式(services/templates.ts)、dispatcher(dispatcher.ts)——不引用 subagent 的转述代码。子系统级的机械重命名(大量测试 fixture、逐字段改名)经 subagent 确认过,未逐行复读。