cs-bots 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/board.js ADDED
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * cs-bots BoardService —— 跨会话任务板。
3
+ *
4
+ * 设计依据:`multiagent/10-初步架构设计方案-v1.md` §3.3(BoardService)。
5
+ * 相对官方任务板的三个增量(官方**完全没有**,是我方的核心价值):
6
+ * 1. `lease` + 心跳 + `expireLeases()` —— 官方 owner 在 interrupt/崩溃后
7
+ * **永不释放**,任务永久挂着一个已死成员;本模块用租约到期自动回收封死这个硬伤。
8
+ * 2. `priority` / `dueAt` —— 官方 schema 只有 `blockedBy/writeScopes`,无排期能力。
9
+ * 3. `acceptance` —— 官方 `complete` 由 owner 自己完成,没有「验收/驳回」这个动作。
10
+ *
11
+ * ── 本文件新增的字段(在 §3.3 的 Task 形状之外,纯增量,不改动既有字段语义)──
12
+ * `artifacts` —— `complete` 的产出物证据(§3.3 要求 complete 必须带 artifacts,
13
+ * 但未规定落点;落在 Task 上便于查询,事件流里也有一份)。
14
+ * `createdAt` / `createdBy` / `completedAt` —— 便于 `list()` 消费方直接展示。
15
+ * `blockedReason` —— `block({ note })` 时记录阻塞原因。
16
+ *
17
+ * ── 三条不变量(全模块机械保证)──
18
+ * 1. **CAS 优先**:任何变更都要带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
19
+ * 2. **事件流是真相源**:先 append `board.jsonl`(失败即抛错,不动内存),
20
+ * 再更新内存,最后原子写 `board.snap.json`(快照失败只降级 warn——
21
+ * 因为事件流已落盘,`replay()` 能完整重建)。
22
+ * 3. **生命周期只走 action**:`subject/description/blockedBy/writeScopes/priority/dueAt`
23
+ * 走字段补丁;`status` **不允许**直接补丁(否则 `complete` 的 artifacts
24
+ * 校验会被绕过)。改状态请用 claim/release/heartbeat/complete/reopen/block/delete。
25
+ *
26
+ * ── 为什么 kernel.js 是「动态导入」而不是静态 import(重要,勿改成静态)──
27
+ * `kernel.js` 静态 import 了宿主包(DSH 提供,只在 DSH profile 内可解析)。
28
+ * 本仓库是**裸检出**(无 node_modules),静态 import 会让 board.js 连带
29
+ * kernel.js 一并加载失败 → `test/board.test.mjs` 根本起不来。
30
+ * 故此处用顶层 `await import()` 并在失败时记下原因:
31
+ * - 拿得到 kernel → `config.stateDir` 缺省走 `kernel.stateDir()`
32
+ * - 拿不到 kernel 且调用方也没给 `config.stateDir` → **抛可读错误**(不静默兜底)
33
+ * 零依赖 CI(`test/ci-zero-dep.sh` 检查 1)依然成立:这里引用的只是 `./kernel.js`,
34
+ * 满足「除 kernel.js 外不得出现宿主包真实引用」这条纪律。
35
+ *
36
+ * 依赖面:node 内置 + `./store.js` + `./kernel.js`。**不 import 任何宿主包**。
37
+ */
38
+
39
+ import {
40
+ appendJsonl,
41
+ readJson,
42
+ readJsonl,
43
+ statePath,
44
+ writeJsonAtomic,
45
+ IdempotencySet,
46
+ } from './store.js'
47
+
48
+ // ── kernel 惰性桥(见文件头「为什么是动态导入」)──────────────────────────────
49
+
50
+ /** @type {object | null} */
51
+ let kernel = null
52
+ /** @type {unknown} */
53
+ let kernelError = null
54
+ try {
55
+ kernel = await import('./kernel.js')
56
+ } catch (error) {
57
+ kernelError = error
58
+ }
59
+
60
+ // ── 常量 ────────────────────────────────────────────────────────────────────
61
+
62
+ /** `description` 上限(§3.3)。 */
63
+ const MAX_DESCRIPTION = 16384
64
+ /** 合法状态集合。 */
65
+ const STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
66
+ /** 验收结果集合。 */
67
+ const ACCEPT_RESULTS = ['pass', 'reject']
68
+ /** 租约缺省 TTL(ms)。 */
69
+ const DEFAULT_LEASE_MS = 300_000
70
+ /** 字段补丁允许改的字段(**不含 status**,见文件头不变量 3)。 */
71
+ const PATCH_FIELDS = ['subject', 'description', 'blockedBy', 'writeScopes', 'priority', 'dueAt']
72
+ /** 事件动作集合。 */
73
+ const ACTIONS = ['create', 'edit', 'claim', 'release', 'heartbeat', 'complete', 'reopen', 'block', 'delete', 'accept', 'lease_expired']
74
+
75
+ // ── 错误 ────────────────────────────────────────────────────────────────────
76
+
77
+ /** 任务板结构化错误。所有失败都走它,**绝不静默**。 */
78
+ export class BoardError extends Error {
79
+ /**
80
+ * @param {string} message 人类可读、含定位信息(任务 id / rev / 字段名)
81
+ * @param {string} [code]
82
+ * @param {object} [details]
83
+ */
84
+ constructor(message, code = 'BOARD_ERROR', details) {
85
+ super(message)
86
+ this.name = 'BoardError'
87
+ this.code = code
88
+ if (details !== undefined) this.details = details
89
+ }
90
+ }
91
+
92
+ // ── 小工具 ──────────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * 深拷贝 + **JSON 归一**(去掉值为 undefined 的键)。
96
+ * 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出
97
+ * `{lease: undefined}` 这类键,导致 `replay()` 重建后的 `list()` 与原值不等。
98
+ */
99
+ function clone(value) {
100
+ if (value === undefined || value === null) return value
101
+ return JSON.parse(JSON.stringify(value))
102
+ }
103
+
104
+ /** 取 `t-<n>` 里的 n;非本格式返回 0。 */
105
+ function idNumber(id) {
106
+ const m = /^t-(\d+)$/.exec(String(id ?? ''))
107
+ return m ? Number(m[1]) : 0
108
+ }
109
+
110
+ /** 非空字符串字段校验。 */
111
+ function requireString(value, field, where) {
112
+ if (typeof value !== 'string' || value.length === 0) {
113
+ throw new BoardError(
114
+ `board.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
115
+ 'BOARD_MISSING_FIELD',
116
+ { field, where },
117
+ )
118
+ }
119
+ return value
120
+ }
121
+
122
+ /** 可选数字字段校验。 */
123
+ function optionalNumber(value, field, where) {
124
+ if (value === undefined || value === null) return undefined
125
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
126
+ throw new BoardError(
127
+ `board.${where}:字段 ${field} 类型错误(要求有限数字,收到 ${JSON.stringify(value)})`,
128
+ 'BOARD_BAD_FIELD',
129
+ { field, where },
130
+ )
131
+ }
132
+ return value
133
+ }
134
+
135
+ /** 字符串数组归一(去空、去重、保序)。 */
136
+ function stringList(value, field, where) {
137
+ if (value === undefined || value === null) return []
138
+ if (!Array.isArray(value)) {
139
+ throw new BoardError(
140
+ `board.${where}:字段 ${field} 类型错误(要求字符串数组,收到 ${JSON.stringify(value)})`,
141
+ 'BOARD_BAD_FIELD',
142
+ { field, where },
143
+ )
144
+ }
145
+ const out = []
146
+ for (const item of value) {
147
+ if (typeof item !== 'string' || item.trim() === '') {
148
+ throw new BoardError(
149
+ `board.${where}:字段 ${field} 的元素必须是非空字符串,收到 ${JSON.stringify(item)}`,
150
+ 'BOARD_BAD_FIELD',
151
+ { field, where },
152
+ )
153
+ }
154
+ const v = item.trim()
155
+ if (!out.includes(v)) out.push(v)
156
+ }
157
+ return out
158
+ }
159
+
160
+ /** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
161
+ function normalizeNow(value, where) {
162
+ if (value === undefined || value === null) return Date.now()
163
+ if (value instanceof Date) return value.getTime()
164
+ if (typeof value === 'number' && Number.isFinite(value)) return value
165
+ throw new BoardError(
166
+ `board.${where}:now 必须是毫秒时间戳或 Date,收到 ${JSON.stringify(value)}`,
167
+ 'BOARD_BAD_NOW',
168
+ { where },
169
+ )
170
+ }
171
+
172
+ /** `description` 校验(≤16384)。 */
173
+ function normalizeDescription(value, where = 'create') {
174
+ if (value === undefined || value === null) return ''
175
+ if (typeof value !== 'string') {
176
+ throw new BoardError(
177
+ `board.${where}:description 必须是字符串,收到 ${JSON.stringify(value)}`,
178
+ 'BOARD_BAD_FIELD',
179
+ { field: 'description' },
180
+ )
181
+ }
182
+ if (value.length > MAX_DESCRIPTION) {
183
+ throw new BoardError(
184
+ `board.${where}:description 超长(${value.length} > ${MAX_DESCRIPTION} 字符上限)——请把长正文落旁路文件,任务板只存摘要`,
185
+ 'BOARD_DESCRIPTION_TOO_LONG',
186
+ { length: value.length, max: MAX_DESCRIPTION },
187
+ )
188
+ }
189
+ return value
190
+ }
191
+
192
+ /** `complete` 的产出物证据归一:非空字符串或非空数组(元素为字符串/对象)。 */
193
+ function normalizeArtifacts(value, taskId) {
194
+ let list
195
+ if (typeof value === 'string') list = [value]
196
+ else if (Array.isArray(value)) list = value
197
+ else {
198
+ throw new BoardError(
199
+ `board.update/complete:任务 ${taskId} 的 complete 必须带 artifacts(产出物证据),收到 ${JSON.stringify(value)}`,
200
+ 'BOARD_MISSING_ARTIFACTS',
201
+ { id: taskId },
202
+ )
203
+ }
204
+ const out = []
205
+ for (const item of list) {
206
+ if (typeof item === 'string') {
207
+ if (item.trim() === '') {
208
+ throw new BoardError(
209
+ `board.update/complete:任务 ${taskId} 的 artifacts 含空白字符串,非有效证据`,
210
+ 'BOARD_MISSING_ARTIFACTS',
211
+ { id: taskId },
212
+ )
213
+ }
214
+ out.push(item.trim())
215
+ } else if (item && typeof item === 'object') {
216
+ out.push(clone(item))
217
+ } else {
218
+ throw new BoardError(
219
+ `board.update/complete:任务 ${taskId} 的 artifacts 元素必须是字符串或对象,收到 ${JSON.stringify(item)}`,
220
+ 'BOARD_BAD_FIELD',
221
+ { id: taskId, field: 'artifacts' },
222
+ )
223
+ }
224
+ }
225
+ if (out.length === 0) {
226
+ throw new BoardError(
227
+ `board.update/complete:任务 ${taskId} 的 complete 必须带**非空** artifacts —— 拒绝无证据完成`,
228
+ 'BOARD_MISSING_ARTIFACTS',
229
+ { id: taskId },
230
+ )
231
+ }
232
+ return out
233
+ }
234
+
235
+ // ── 工厂 ────────────────────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
239
+ * **不做静默兜底**:两条路都不通时必须让调用方看见。
240
+ * @param {{ stateDir?: string }} config
241
+ * @returns {string}
242
+ */
243
+ function resolveStateDir(config) {
244
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
245
+ return config.stateDir.trim()
246
+ }
247
+ if (kernel && typeof kernel.stateDir === 'function') {
248
+ return kernel.stateDir()
249
+ }
250
+ const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
251
+ throw new BoardError(
252
+ `board.createBoard:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
253
+ + '裸检出(无宿主包)下必须显式传 config.stateDir。',
254
+ 'BOARD_NO_STATE_DIR',
255
+ )
256
+ }
257
+
258
+ /**
259
+ * 创建一个任务板实例。
260
+ *
261
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即静默降级为 no-op 日志)
262
+ * @param {{ stateDir?: string, leaseMs?: number, idempotencyMax?: number, name?: string }} [config]
263
+ * @returns {object} board
264
+ */
265
+ export function createBoard(ctx, config = {}) {
266
+ const stateDir = resolveStateDir(config)
267
+ const leaseMsDefault = Number.isFinite(config.leaseMs) && config.leaseMs > 0
268
+ ? config.leaseMs
269
+ : DEFAULT_LEASE_MS
270
+
271
+ const jsonlPath = statePath(stateDir, 'board.jsonl')
272
+ const snapPath = statePath(stateDir, 'board.snap.json')
273
+
274
+ const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
275
+ const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
276
+
277
+ /** @type {Map<string, object>} 任务 id → Task(创建序 = Map 插入序) */
278
+ const tasks = new Map()
279
+ /** @type {object[]} 全部 TaskEvent(审计流,按落盘顺序) */
280
+ const events = []
281
+ /** @type {Map<string, string>} sourceSeq → taskId(幂等取回) */
282
+ const sourceSeqIndex = new Map()
283
+ /** 幂等去重器(store 提供;`replay()` 时从事件流重建) */
284
+ const idem = new IdempotencySet(Number.isFinite(config.idempotencyMax) ? config.idempotencyMax : 10_000)
285
+ /** 已 warn 过的缺失依赖 id(防刷屏) */
286
+ const warnedMissingDeps = new Set()
287
+
288
+ let idCounter = 0
289
+
290
+ const diagnostics = {
291
+ stateDir,
292
+ jsonlPath,
293
+ snapPath,
294
+ kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
295
+ hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
296
+ replayedEvents: 0,
297
+ badLines: 0,
298
+ snapshotWriteFailures: 0,
299
+ lastReplayAt: /** @type {number|undefined} */ (undefined),
300
+ }
301
+
302
+ // ── 持久化 ────────────────────────────────────────────────────────────────
303
+
304
+ /** 快照内容(落盘形态)。 */
305
+ const snapshot = () => ({
306
+ version: 1,
307
+ savedAt: Date.now(),
308
+ idCounter,
309
+ tasks: [...tasks.values()],
310
+ })
311
+
312
+ /**
313
+ * 提交一次变更:**先落事件,再改内存,最后写快照**。
314
+ *
315
+ * 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
316
+ * 重启回放后状态凭空回退,属于最恶劣的静默失败)。
317
+ *
318
+ * @param {string} taskId
319
+ * @param {string} action
320
+ * @param {string|null} byId
321
+ * @param {number} at
322
+ * @param {object|null} before 变更前 Task(create 为 null)
323
+ * @param {object} after 变更后 Task
324
+ * @returns {object} 克隆后的 after
325
+ */
326
+ function commit(taskId, action, byId, at, before, after) {
327
+ const event = {
328
+ taskId,
329
+ rev: after.rev,
330
+ action,
331
+ byId: byId ?? null,
332
+ at,
333
+ before: before ? clone(before) : null,
334
+ after: clone(after),
335
+ }
336
+ if (!appendJsonl(jsonlPath, event)) {
337
+ throw new BoardError(
338
+ `board.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
339
+ 'BOARD_APPEND_FAILED',
340
+ { taskId, action },
341
+ )
342
+ }
343
+ tasks.set(taskId, clone(after))
344
+ events.push(event)
345
+ if (after.sourceSeq) {
346
+ // 幂等键在这里登记(而不是在 create 开头),保证「校验/落盘失败不污染键」
347
+ idem.check(after.sourceSeq)
348
+ sourceSeqIndex.set(after.sourceSeq, taskId)
349
+ }
350
+ if (!writeJsonAtomic(snapPath, snapshot())) {
351
+ diagnostics.snapshotWriteFailures += 1
352
+ warn(`[cs-bots/board] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
353
+ }
354
+ return clone(after)
355
+ }
356
+
357
+ // ── 回放 ──────────────────────────────────────────────────────────────────
358
+
359
+ /**
360
+ * 从 `board.jsonl`(真相源)重建内存态;jsonl 为空才回落到 `board.snap.json`。
361
+ * 坏行**不静默丢弃**:计入 diagnostics 并 warn。
362
+ */
363
+ function hydrate() {
364
+ tasks.clear()
365
+ events.length = 0
366
+ sourceSeqIndex.clear()
367
+ idem.keys.clear()
368
+ warnedMissingDeps.clear()
369
+ idCounter = 0
370
+
371
+ const { rows, bad } = readJsonl(jsonlPath)
372
+ diagnostics.badLines = bad
373
+ diagnostics.replayedEvents = rows.length
374
+ if (bad > 0) {
375
+ warn(`[cs-bots/board] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
376
+ }
377
+
378
+ if (rows.length === 0) {
379
+ const snap = readJson(snapPath, null)
380
+ const snapTasks = Array.isArray(snap?.tasks) ? snap.tasks : null
381
+ if (snapTasks) {
382
+ for (const raw of snapTasks) {
383
+ if (!raw || typeof raw.id !== 'string') continue
384
+ const t = clone(raw)
385
+ if (!Array.isArray(t.blockedBy)) t.blockedBy = []
386
+ if (!Array.isArray(t.writeScopes)) t.writeScopes = []
387
+ tasks.set(t.id, t)
388
+ idCounter = Math.max(idCounter, idNumber(t.id))
389
+ if (t.sourceSeq) sourceSeqIndex.set(t.sourceSeq, t.id)
390
+ }
391
+ if (Number.isFinite(snap?.idCounter)) idCounter = Math.max(idCounter, snap.idCounter)
392
+ idem.rebuildFrom([...sourceSeqIndex.keys()].map((k) => ({ dedupKey: k })), 'dedupKey')
393
+ diagnostics.hydratedFrom = 'snapshot'
394
+ info(`[cs-bots/board] 回放完成:无事件流 → 由快照恢复 ${tasks.size} 个任务`)
395
+ return
396
+ }
397
+ diagnostics.hydratedFrom = 'empty'
398
+ info('[cs-bots/board] 回放完成:空板(无事件流、无快照)')
399
+ return
400
+ }
401
+
402
+ // 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
403
+ let skipped = 0
404
+ for (const ev of rows) {
405
+ if (!ev || typeof ev.taskId !== 'string' || !ev.after || typeof ev.after.id !== 'string') {
406
+ skipped += 1
407
+ continue
408
+ }
409
+ const before = tasks.get(ev.taskId)
410
+ if (before && Number.isFinite(ev.rev) && ev.rev <= before.rev) {
411
+ skipped += 1
412
+ continue
413
+ }
414
+ const t = clone(ev.after)
415
+ if (!Array.isArray(t.blockedBy)) t.blockedBy = []
416
+ if (!Array.isArray(t.writeScopes)) t.writeScopes = []
417
+ tasks.set(ev.taskId, t)
418
+ events.push(clone(ev))
419
+ idCounter = Math.max(idCounter, idNumber(ev.taskId))
420
+ if (t.sourceSeq) {
421
+ sourceSeqIndex.set(t.sourceSeq, ev.taskId)
422
+ idem.check(t.sourceSeq)
423
+ }
424
+ }
425
+ if (skipped > 0) {
426
+ warn(`[cs-bots/board] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
427
+ }
428
+ diagnostics.hydratedFrom = 'jsonl'
429
+ info(`[cs-bots/board] 回放完成:${rows.length} 条事件 → ${tasks.size} 个任务`)
430
+ }
431
+
432
+ // ── DAG 校验 ──────────────────────────────────────────────────────────────
433
+
434
+ /**
435
+ * 依赖环探测:从 `selfId` 出发沿 blockedBy 边遍历,回到 `selfId` 即环。
436
+ * @param {string} selfId
437
+ * @param {string[]} deps 该任务**将要生效**的 blockedBy
438
+ * @returns {string[] | null} 环路径(含首尾重复的 selfId),无环返回 null
439
+ */
440
+ function findCycle(selfId, deps) {
441
+ const visited = new Set([selfId])
442
+ const stack = [[selfId, [selfId]]]
443
+ while (stack.length > 0) {
444
+ const [node, trail] = stack.pop()
445
+ const edges = node === selfId ? deps : (tasks.get(node)?.blockedBy ?? [])
446
+ for (const dep of edges) {
447
+ if (dep === selfId) return [...trail, dep]
448
+ if (visited.has(dep)) continue
449
+ visited.add(dep)
450
+ stack.push([dep, [...trail, dep]])
451
+ }
452
+ }
453
+ return null
454
+ }
455
+
456
+ /**
457
+ * 校验 blockedBy:非空 id / 引用存在 / 无环。
458
+ * 依赖不存在的 id 会**抛错**:否则该任务永远等不到 completed,形成静默死锁。
459
+ * @param {string} selfId
460
+ * @param {string[]} deps
461
+ */
462
+ function assertDependencyGraph(selfId, deps) {
463
+ for (const dep of deps) {
464
+ if (dep === selfId) {
465
+ throw new BoardError(
466
+ `board:依赖环 → ${selfId} → ${selfId}(任务不能 blockedBy 自己)`,
467
+ 'BOARD_DEPENDENCY_CYCLE',
468
+ { id: selfId, cycle: [selfId, selfId] },
469
+ )
470
+ }
471
+ if (!tasks.has(dep)) {
472
+ throw new BoardError(
473
+ `board:blockedBy 引用了不存在的任务 ${dep}(任务 ${selfId})——拒绝写入永远无法满足的依赖`,
474
+ 'BOARD_DEPENDENCY_NOT_FOUND',
475
+ { id: selfId, dep },
476
+ )
477
+ }
478
+ }
479
+ const cycle = findCycle(selfId, deps)
480
+ if (cycle) {
481
+ const shown = cycle.map((x) => `${x}`).join(' → ')
482
+ throw new BoardError(
483
+ `board:依赖环 ${shown}(任务 ${selfId} 的 blockedBy 会让依赖图无法收敛)`,
484
+ 'BOARD_DEPENDENCY_CYCLE',
485
+ { id: selfId, cycle },
486
+ )
487
+ }
488
+ }
489
+
490
+ /** 未完成的依赖清单(就绪判定用)。 */
491
+ function unfinishedDeps(task) {
492
+ const out = []
493
+ for (const dep of task.blockedBy ?? []) {
494
+ const d = tasks.get(dep)
495
+ if (!d) {
496
+ if (!warnedMissingDeps.has(dep)) {
497
+ warnedMissingDeps.add(dep)
498
+ warn(`[cs-bots/board] blockedBy 引用了未知任务 ${dep}(依赖视为**未满足**,不静默放过)`)
499
+ }
500
+ out.push(`${dep}(missing)`)
501
+ continue
502
+ }
503
+ if (d.status !== 'completed') out.push(`${dep}(${d.status})`)
504
+ }
505
+ return out
506
+ }
507
+
508
+ // ── API:读 ────────────────────────────────────────────────────────────────
509
+
510
+ /** @param {string} id @returns {object | undefined} */
511
+ function get(id) {
512
+ const t = tasks.get(id)
513
+ return t ? clone(t) : undefined
514
+ }
515
+
516
+ /**
517
+ * 列出任务(**创建序**)。
518
+ * 缺省不含 `deleted`;只有显式传 `status:'deleted'` 才看得到墓碑。
519
+ * @param {{ status?: string }} [options]
520
+ * @returns {object[]}
521
+ */
522
+ function list(options = {}) {
523
+ const { status } = options ?? {}
524
+ if (status !== undefined && status !== null) {
525
+ if (!STATUSES.includes(status)) {
526
+ throw new BoardError(
527
+ `board.list:非法 status=${JSON.stringify(status)}(合法值:${STATUSES.join(' | ')})`,
528
+ 'BOARD_BAD_STATUS',
529
+ { status },
530
+ )
531
+ }
532
+ }
533
+ const out = []
534
+ for (const t of tasks.values()) {
535
+ if (status) {
536
+ if (t.status === status) out.push(clone(t))
537
+ } else if (t.status !== 'deleted') {
538
+ out.push(clone(t))
539
+ }
540
+ }
541
+ return out
542
+ }
543
+
544
+ /**
545
+ * 就绪任务:`status === 'pending'` **且**全部 blockedBy 都已 completed(依赖整图校验)。
546
+ * 纯读操作,不做租约回收(租约回收请显式调用 `expireLeases()`)。
547
+ * @returns {object[]} 创建序
548
+ */
549
+ function ready() {
550
+ const out = []
551
+ for (const t of tasks.values()) {
552
+ if (t.status !== 'pending') continue
553
+ if (unfinishedDeps(t).length === 0) out.push(clone(t))
554
+ }
555
+ return out
556
+ }
557
+
558
+ /**
559
+ * 审计流查询。
560
+ * @param {string} [taskId] 省略则返回全部事件
561
+ * @returns {object[]}
562
+ */
563
+ function history(taskId) {
564
+ const rows = taskId ? events.filter((e) => e.taskId === taskId) : events
565
+ return clone(rows)
566
+ }
567
+
568
+ // ── API:写 ────────────────────────────────────────────────────────────────
569
+
570
+ /**
571
+ * 创建任务。同 `sourceSeq` **幂等**:重复创建返回同一个 Task,不产生第二条事件。
572
+ * @param {{ subject: string, description?: string, blockedBy?: string[], writeScopes?: string[],
573
+ * priority?: number, dueAt?: number, byId: string, sourceSeq?: string, now?: number }} input
574
+ * @returns {object} Task
575
+ */
576
+ function create(input = {}) {
577
+ const { subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now } = input ?? {}
578
+ const by = requireString(byId, 'byId', 'create')
579
+ const subj = requireString(subject, 'subject', 'create')
580
+ const desc = normalizeDescription(description, 'create')
581
+ const deps = stringList(blockedBy, 'blockedBy', 'create')
582
+ const scopes = stringList(writeScopes, 'writeScopes', 'create')
583
+ const prio = optionalNumber(priority, 'priority', 'create')
584
+ const due = optionalNumber(dueAt, 'dueAt', 'create')
585
+ const seq = sourceSeq === undefined || sourceSeq === null
586
+ ? undefined
587
+ : requireString(sourceSeq, 'sourceSeq', 'create')
588
+
589
+ // ── 幂等(校验全部通过后再判定,避免校验失败污染幂等键)──
590
+ if (seq !== undefined) {
591
+ const existingId = sourceSeqIndex.get(seq)
592
+ if (existingId && tasks.has(existingId)) {
593
+ idem.check(seq)
594
+ info(`[cs-bots/board] create 幂等命中 sourceSeq=${seq} → 复用 ${existingId}`)
595
+ return clone(tasks.get(existingId))
596
+ }
597
+ if (idem.has(seq)) {
598
+ // 去重器有记录、索引却没有 → 内部状态不一致,绝不猜测,直接暴露
599
+ throw new BoardError(
600
+ `board.create:sourceSeq=${seq} 已登记但找不到对应任务(幂等索引不一致)——拒绝静默新建`,
601
+ 'BOARD_IDEMPOTENCY_INDEX_MISSING',
602
+ { sourceSeq: seq },
603
+ )
604
+ }
605
+ }
606
+
607
+ const at = normalizeNow(now, 'create')
608
+ const nextCounter = idCounter + 1
609
+ const taskId = `t-${nextCounter}`
610
+ assertDependencyGraph(taskId, deps)
611
+
612
+ /** @type {Record<string, unknown>} */
613
+ const task = {
614
+ id: taskId,
615
+ rev: 1,
616
+ subject: subj,
617
+ description: desc,
618
+ status: 'pending',
619
+ blockedBy: deps,
620
+ writeScopes: scopes,
621
+ createdAt: at,
622
+ createdBy: by,
623
+ }
624
+ if (prio !== undefined) task.priority = prio
625
+ if (due !== undefined) task.dueAt = due
626
+ if (seq !== undefined) task.sourceSeq = seq
627
+
628
+ idCounter = nextCounter
629
+ try {
630
+ const committed = commit(taskId, 'create', by, at, null, task)
631
+ return committed
632
+ } catch (error) {
633
+ idCounter = nextCounter - 1
634
+ throw error
635
+ }
636
+ }
637
+
638
+ /**
639
+ * 按 `expectedRev` 做 CAS 变更。**rev 不匹配即抛错,绝不静默覆盖。**
640
+ *
641
+ * @param {{ id: string, expectedRev: number, action?: string, byId: string,
642
+ * subject?: string, description?: string, blockedBy?: string[], writeScopes?: string[],
643
+ * priority?: number|null, dueAt?: number|null, artifacts?: (string|object)[], note?: string,
644
+ * leaseMs?: number, force?: boolean, now?: number }} input
645
+ * @returns {object} 变更后的 Task
646
+ * @throws {BoardError} rev 冲突 / 缺字段 / 非法 action / 依赖环 / complete 缺 artifacts
647
+ */
648
+ function update(input = {}) {
649
+ const {
650
+ id, expectedRev, action = 'edit', byId, now,
651
+ artifacts, note, leaseMs, force, ...fields
652
+ } = input ?? {}
653
+ const taskId = requireString(id, 'id', 'update')
654
+ const by = requireString(byId, 'byId', 'update')
655
+ if (action !== 'edit' && !ACTIONS.includes(action)) {
656
+ throw new BoardError(
657
+ `board.update:不支持 action=${JSON.stringify(action)}(支持:edit | claim | release | heartbeat | `
658
+ + 'complete | reopen | block | delete;验收请用 board.accept())',
659
+ 'BOARD_BAD_ACTION',
660
+ { action },
661
+ )
662
+ }
663
+
664
+ const current = tasks.get(taskId)
665
+ if (!current) {
666
+ throw new BoardError(`board.update:任务不存在 id=${taskId}`, 'BOARD_TASK_NOT_FOUND', { id: taskId })
667
+ }
668
+ if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
669
+ throw new BoardError(
670
+ `board.update:expectedRev 必须是整数(CAS 需要显式期望版本,任务 ${taskId} 当前 rev=${current.rev},`
671
+ + `收到 ${JSON.stringify(expectedRev)})`,
672
+ 'BOARD_MISSING_EXPECTED_REV',
673
+ { id: taskId, rev: current.rev },
674
+ )
675
+ }
676
+ if (current.rev !== expectedRev) {
677
+ throw new BoardError(
678
+ `board.update CAS 冲突:任务 ${taskId} 期望 rev=${expectedRev},实际 rev=${current.rev} —— 拒绝静默覆盖,`
679
+ + '请重新 get() 后按最新 rev 重试',
680
+ 'BOARD_REV_CONFLICT',
681
+ { id: taskId, expectedRev, rev: current.rev },
682
+ )
683
+ }
684
+
685
+ const at = normalizeNow(now, 'update')
686
+ const before = clone(current)
687
+ /** @type {Record<string, any>} */
688
+ const next = clone(current)
689
+ next.rev = current.rev + 1
690
+
691
+ const isDead = (st) => st === 'deleted'
692
+ const ownerName = (lease) => (lease?.ownerId ? String(lease.ownerId) : '?')
693
+
694
+ switch (action) {
695
+ // ── 字段补丁(内容类)────────────────────────────────────────────────
696
+ case 'edit': {
697
+ const given = Object.keys(fields).filter((k) => fields[k] !== undefined)
698
+ if (given.length === 0) {
699
+ throw new BoardError(
700
+ `board.update/edit:未提供任何可改字段(可改:${PATCH_FIELDS.join(' | ')};`
701
+ + 'status 只能改走 action —— claim/release/heartbeat/complete/reopen/block/delete)',
702
+ 'BOARD_NO_PATCH_FIELDS',
703
+ { id: taskId },
704
+ )
705
+ }
706
+ if (isDead(current.status)) {
707
+ throw new BoardError(
708
+ `board.update/edit:任务 ${taskId} 已删除(tombstone),先 reopen 再改`,
709
+ 'BOARD_TASK_DELETED',
710
+ { id: taskId },
711
+ )
712
+ }
713
+ const unknown = given.filter((k) => !PATCH_FIELDS.includes(k))
714
+ if (unknown.length > 0) {
715
+ throw new BoardError(
716
+ `board.update/edit:未知字段 ${unknown.join(', ')}(可改:${PATCH_FIELDS.join(' | ')})——拒绝静默忽略`,
717
+ 'BOARD_UNKNOWN_FIELD',
718
+ { id: taskId, unknown },
719
+ )
720
+ }
721
+ if (fields.subject !== undefined) next.subject = requireString(fields.subject, 'subject', 'update')
722
+ if (fields.description !== undefined) next.description = normalizeDescription(fields.description, 'update')
723
+ if (fields.writeScopes !== undefined) next.writeScopes = stringList(fields.writeScopes, 'writeScopes', 'update')
724
+ if (fields.blockedBy !== undefined) {
725
+ const deps = stringList(fields.blockedBy, 'blockedBy', 'update')
726
+ assertDependencyGraph(taskId, deps)
727
+ next.blockedBy = deps
728
+ }
729
+ if (fields.priority !== undefined) {
730
+ if (fields.priority === null) delete next.priority
731
+ else next.priority = optionalNumber(fields.priority, 'priority', 'update')
732
+ }
733
+ if (fields.dueAt !== undefined) {
734
+ if (fields.dueAt === null) delete next.dueAt
735
+ else next.dueAt = optionalNumber(fields.dueAt, 'dueAt', 'update')
736
+ }
737
+ break
738
+ }
739
+
740
+ // ── 取租约 ──────────────────────────────────────────────────────────
741
+ case 'claim': {
742
+ if (isDead(current.status)) {
743
+ throw new BoardError(`board.claim:任务 ${taskId} 已删除,不能 claim`, 'BOARD_TASK_DELETED', { id: taskId })
744
+ }
745
+ if (current.status === 'completed') {
746
+ throw new BoardError(`board.claim:任务 ${taskId} 已完成(rev=${current.rev}),需先 reopen`, 'BOARD_ALREADY_COMPLETED', { id: taskId })
747
+ }
748
+ if (current.status === 'blocked') {
749
+ throw new BoardError(`board.claim:任务 ${taskId} 处于 blocked,需先 reopen 才能 claim`, 'BOARD_TASK_BLOCKED', { id: taskId })
750
+ }
751
+ const ttl = optionalNumber(leaseMs, 'leaseMs', 'claim') ?? leaseMsDefault
752
+ if (!(ttl > 0)) {
753
+ throw new BoardError(`board.claim:leaseMs 必须 > 0,收到 ${JSON.stringify(leaseMs)}`, 'BOARD_BAD_FIELD', { field: 'leaseMs' })
754
+ }
755
+ if (current.lease) {
756
+ const exp = Number(current.lease.expiresAt)
757
+ const live = Number.isFinite(exp) && exp > at
758
+ if (live && current.lease.ownerId !== by) {
759
+ throw new BoardError(
760
+ `board.claim:任务 ${taskId} 的租约仍被 ${ownerName(current.lease)} 持有(至 ${new Date(exp).toISOString()})`
761
+ + `——不允许抢占,调用者为 ${by}`,
762
+ 'BOARD_LEASE_HELD',
763
+ { id: taskId, ownerId: current.lease.ownerId, expiresAt: exp },
764
+ )
765
+ }
766
+ if (!live) {
767
+ warn(`[cs-bots/board] 任务 ${taskId} 的租约已于 ${new Date(exp).toISOString()} 过期(持有者 ${ownerName(current.lease)})→ 允许抢占`)
768
+ }
769
+ }
770
+ if (force !== true && current.status === 'pending') {
771
+ const pending = unfinishedDeps(current)
772
+ if (pending.length > 0) {
773
+ throw new BoardError(
774
+ `board.claim:任务 ${taskId} 尚未就绪,blockedBy 未完成 → ${pending.join(', ')}(确要抢占请传 force:true)`,
775
+ 'BOARD_NOT_READY',
776
+ { id: taskId, blockedBy: current.blockedBy },
777
+ )
778
+ }
779
+ }
780
+ next.status = 'in_progress'
781
+ next.ownerId = by
782
+ next.lease = { ownerId: by, expiresAt: at + ttl, heartbeatAt: at }
783
+ break
784
+ }
785
+
786
+ // ── 放租约 ──────────────────────────────────────────────────────────
787
+ case 'release': {
788
+ if (!current.lease) {
789
+ throw new BoardError(
790
+ `board.release:任务 ${taskId} 当前没有租约(status=${current.status}),无可释放`,
791
+ 'BOARD_NO_LEASE',
792
+ { id: taskId },
793
+ )
794
+ }
795
+ if (current.lease.ownerId !== by) {
796
+ throw new BoardError(
797
+ `board.release:只允许租约持有者操作(任务 ${taskId} 的租约属于 ${ownerName(current.lease)},调用者 ${by})`,
798
+ 'BOARD_LEASE_NOT_OWNER',
799
+ { id: taskId, ownerId: current.lease.ownerId },
800
+ )
801
+ }
802
+ next.status = current.status === 'blocked' ? 'blocked' : 'pending'
803
+ delete next.lease
804
+ delete next.ownerId
805
+ break
806
+ }
807
+
808
+ // ── 心跳续租 ────────────────────────────────────────────────────────
809
+ case 'heartbeat': {
810
+ if (current.status !== 'in_progress' || !current.lease) {
811
+ throw new BoardError(
812
+ `board.heartbeat:任务 ${taskId} 没有活跃租约(status=${current.status},lease=${current.lease ? '已设' : '无'})`
813
+ + '——请先 claim',
814
+ 'BOARD_NO_LEASE',
815
+ { id: taskId, status: current.status },
816
+ )
817
+ }
818
+ if (current.lease.ownerId !== by) {
819
+ throw new BoardError(
820
+ `board.heartbeat:只允许租约持有者续租(任务 ${taskId} 的租约属于 ${ownerName(current.lease)},调用者 ${by})`,
821
+ 'BOARD_LEASE_NOT_OWNER',
822
+ { id: taskId, ownerId: current.lease.ownerId },
823
+ )
824
+ }
825
+ const exp = Number(current.lease.expiresAt)
826
+ if (!Number.isFinite(exp) || exp <= at) {
827
+ throw new BoardError(
828
+ `board.heartbeat:任务 ${taskId} 的租约已过期(expiresAt=${Number.isFinite(exp) ? new Date(exp).toISOString() : exp})`
829
+ + '——心跳无法复活过期租约,请走 claim 或 expireLeases',
830
+ 'BOARD_LEASE_EXPIRED',
831
+ { id: taskId, expiresAt: exp },
832
+ )
833
+ }
834
+ const ttl = optionalNumber(leaseMs, 'leaseMs', 'heartbeat') ?? leaseMsDefault
835
+ next.lease = { ownerId: by, expiresAt: at + ttl, heartbeatAt: at }
836
+ break
837
+ }
838
+
839
+ // ── 完成(必须带 artifacts)─────────────────────────────────────────
840
+ case 'complete': {
841
+ if (current.status === 'completed') {
842
+ throw new BoardError(
843
+ `board.complete:任务 ${taskId} 已完成(rev=${current.rev})——重复完成无意义,需先 reopen`,
844
+ 'BOARD_ALREADY_COMPLETED',
845
+ { id: taskId },
846
+ )
847
+ }
848
+ if (isDead(current.status)) {
849
+ throw new BoardError(`board.complete:任务 ${taskId} 已删除,不能 complete`, 'BOARD_TASK_DELETED', { id: taskId })
850
+ }
851
+ if (current.lease && current.lease.ownerId !== by) {
852
+ throw new BoardError(
853
+ `board.complete:只允许租约持有者交付(任务 ${taskId} 的租约属于 ${ownerName(current.lease)},调用者 ${by})`,
854
+ 'BOARD_LEASE_NOT_OWNER',
855
+ { id: taskId, ownerId: current.lease.ownerId },
856
+ )
857
+ }
858
+ next.artifacts = normalizeArtifacts(artifacts, taskId)
859
+ next.status = 'completed'
860
+ next.completedAt = at
861
+ next.ownerId = current.ownerId ?? by
862
+ delete next.lease
863
+ delete next.acceptance // 完成即待验收,旧的验收结论作废(历史在事件流里)
864
+ break
865
+ }
866
+
867
+ // ── 重开 ────────────────────────────────────────────────────────────
868
+ case 'reopen': {
869
+ if (current.status === 'pending' && !current.lease) {
870
+ throw new BoardError(
871
+ `board.reopen:任务 ${taskId} 已是 pending 且无租约,无需 reopen`,
872
+ 'BOARD_NOTHING_TO_REOPEN',
873
+ { id: taskId },
874
+ )
875
+ }
876
+ next.status = 'pending'
877
+ delete next.lease
878
+ delete next.ownerId
879
+ delete next.acceptance
880
+ delete next.completedAt
881
+ delete next.artifacts
882
+ delete next.blockedReason
883
+ break
884
+ }
885
+
886
+ // ── 阻塞 ────────────────────────────────────────────────────────────
887
+ case 'block': {
888
+ if (isDead(current.status)) {
889
+ throw new BoardError(`board.block:任务 ${taskId} 已删除,不能 block`, 'BOARD_TASK_DELETED', { id: taskId })
890
+ }
891
+ if (current.status === 'completed') {
892
+ throw new BoardError(`board.block:任务 ${taskId} 已完成,需先 reopen 才能 block`, 'BOARD_ALREADY_COMPLETED', { id: taskId })
893
+ }
894
+ next.status = 'blocked'
895
+ delete next.lease
896
+ if (note !== undefined && note !== null) next.blockedReason = String(note)
897
+ break
898
+ }
899
+
900
+ // ── 删除(tombstone,不物理删除)─────────────────────────────────────
901
+ case 'delete': {
902
+ if (isDead(current.status)) {
903
+ throw new BoardError(
904
+ `board.delete:任务 ${taskId} 已是 deleted(rev=${current.rev})——墓碑不重复写`,
905
+ 'BOARD_TASK_DELETED',
906
+ { id: taskId },
907
+ )
908
+ }
909
+ const dependents = [...tasks.values()].filter((t) => t.status !== 'deleted' && (t.blockedBy ?? []).includes(taskId))
910
+ if (dependents.length > 0) {
911
+ warn(
912
+ `[cs-bots/board] 删除任务 ${taskId} 会让依赖它的任务永久挂在 blockedBy 上:`
913
+ + `${dependents.map((t) => t.id).join(', ')}(未自动改写 DAG,请人工决定)`,
914
+ )
915
+ }
916
+ next.status = 'deleted'
917
+ next.deletedAt = at
918
+ delete next.lease
919
+ break
920
+ }
921
+
922
+ default: {
923
+ throw new BoardError(
924
+ `board.update:不支持的 action=${JSON.stringify(action)}(支持:edit | claim | release | heartbeat | `
925
+ + 'complete | reopen | block | delete)',
926
+ 'BOARD_BAD_ACTION',
927
+ { action },
928
+ )
929
+ }
930
+ }
931
+
932
+ return commit(taskId, action, by, at, before, next)
933
+ }
934
+
935
+ /**
936
+ * 过期租约回收:`status==='in_progress'` 且 `lease.expiresAt <= now` → 回 `pending`。
937
+ *
938
+ * **这是封死官方「owner interrupt/崩溃后永不释放」硬伤的关键动作**:
939
+ * 回收即清租约、清 ownerId,再把任务放回待领池,谁也不欠谁。
940
+ *
941
+ * @param {{ now?: number }} [options]
942
+ * @returns {string[]} 被回收的 taskId(创建序)
943
+ */
944
+ function expireLeases(options = {}) {
945
+ const at = normalizeNow(options?.now, 'expireLeases')
946
+ const recycled = []
947
+ for (const current of [...tasks.values()]) {
948
+ if (current.status !== 'in_progress') continue
949
+ if (!current.lease) continue
950
+ const exp = Number(current.lease.expiresAt)
951
+ if (!Number.isFinite(exp)) {
952
+ warn(
953
+ `[cs-bots/board] 任务 ${current.id} 的租约 expiresAt 非法(${JSON.stringify(current.lease.expiresAt)})`
954
+ + '——不静默放过,本次跳过回收',
955
+ )
956
+ continue
957
+ }
958
+ if (exp > at) continue
959
+
960
+ const before = clone(current)
961
+ const next = clone(current)
962
+ next.rev = current.rev + 1
963
+ next.status = 'pending'
964
+ delete next.lease
965
+ delete next.ownerId
966
+ commit(current.id, 'lease_expired', current.lease.ownerId ?? null, at, before, next)
967
+ recycled.push(current.id)
968
+ info(`[cs-bots/board] 租约回收:${current.id} 由 ${current.lease.ownerId} 于 ${new Date(exp).toISOString()} 过期 → pending`)
969
+ }
970
+ return recycled
971
+ }
972
+
973
+ /**
974
+ * 记验收动作(官方**没有**这个语义:官方 complete 由 owner 自评自结)。
975
+ *
976
+ * `pass` → 状态置 completed(验收完成);`reject` → 回 pending 并清租约/产出物,
977
+ * 让任务重新可领。验收结论落在 `task.acceptance`,事件流里也有一条。
978
+ *
979
+ * @param {{ id: string, by: string, result: 'pass'|'reject', note?: string, now?: number }} input
980
+ * @returns {object} 变更后的 Task
981
+ */
982
+ function accept(input = {}) {
983
+ const { id, by, result, note, now } = input ?? {}
984
+ const taskId = requireString(id, 'id', 'accept')
985
+ const actor = requireString(by, 'by', 'accept')
986
+ if (!ACCEPT_RESULTS.includes(result)) {
987
+ throw new BoardError(
988
+ `board.accept:result 必须是 ${ACCEPT_RESULTS.join(' | ')},收到 ${JSON.stringify(result)}`,
989
+ 'BOARD_BAD_ACCEPT_RESULT',
990
+ { result },
991
+ )
992
+ }
993
+ const current = tasks.get(taskId)
994
+ if (!current) {
995
+ throw new BoardError(`board.accept:任务不存在 id=${taskId}`, 'BOARD_TASK_NOT_FOUND', { id: taskId })
996
+ }
997
+ if (current.status === 'deleted') {
998
+ throw new BoardError(`board.accept:任务 ${taskId} 已删除(tombstone),不能验收`, 'BOARD_TASK_DELETED', { id: taskId })
999
+ }
1000
+ const at = normalizeNow(now, 'accept')
1001
+ const before = clone(current)
1002
+ const next = clone(current)
1003
+ next.rev = current.rev + 1
1004
+ /** @type {Record<string, unknown>} */
1005
+ const acceptance = { by: actor, at, result }
1006
+ if (note !== undefined && note !== null) acceptance.note = String(note)
1007
+ next.acceptance = acceptance
1008
+ delete next.lease
1009
+ if (result === 'pass') {
1010
+ next.status = 'completed'
1011
+ next.completedAt = current.completedAt ?? at
1012
+ } else {
1013
+ next.status = 'pending'
1014
+ delete next.ownerId
1015
+ delete next.completedAt
1016
+ delete next.artifacts // 验收驳回 → 产出物作废(在事件流的 before 里留档)
1017
+ }
1018
+ return commit(taskId, 'accept', actor, at, before, next)
1019
+ }
1020
+
1021
+ /**
1022
+ * 从 `board.jsonl` 重建内存态(重启回放一致性)。
1023
+ * @returns {{ from: string, tasks: number, events: number, bad: number }}
1024
+ */
1025
+ function replay() {
1026
+ hydrate()
1027
+ diagnostics.lastReplayAt = Date.now()
1028
+ return {
1029
+ from: diagnostics.hydratedFrom ?? 'empty',
1030
+ tasks: tasks.size,
1031
+ events: events.length,
1032
+ bad: diagnostics.badLines,
1033
+ }
1034
+ }
1035
+
1036
+ /** 首次构造即回放,保证 `list()` 在重启后马上可用。 */
1037
+ hydrate()
1038
+
1039
+ const board = {
1040
+ name: config.name ?? 'board',
1041
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
1042
+ create,
1043
+ get,
1044
+ list,
1045
+ update,
1046
+ ready,
1047
+ expireLeases,
1048
+ accept,
1049
+ replay,
1050
+ history,
1051
+ diagnostics: () => clone(diagnostics),
1052
+ }
1053
+ return board
1054
+ }
1055
+
1056
+ export default createBoard