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/LICENSE +21 -0
- package/README.md +164 -2
- package/cordis.patch.agent-team.yml +31 -0
- package/cordis.patch.yml +30 -0
- package/install.sh +218 -0
- package/package.json +57 -4
- package/src/api.js +706 -0
- package/src/board.js +1056 -0
- package/src/botprofile.js +331 -0
- package/src/client.js +2089 -0
- package/src/compat.js +196 -0
- package/src/dirbrowse.js +555 -0
- package/src/dispatcher.js +651 -0
- package/src/index.js +311 -0
- package/src/kernel.js +480 -0
- package/src/library.js +740 -0
- package/src/memory.js +1905 -0
- package/src/registry.js +34 -0
- package/src/room.js +1550 -0
- package/src/roster.js +1119 -0
- package/src/settings.js +107 -0
- package/src/store.js +243 -0
- package/src/tools.js +469 -0
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
|