cs-bots 0.1.0 → 0.7.4
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/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
package/src/reviewer.js
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots ReviewerService —— 审查调度器(回合内自我审查的**计数器**,DESIGN-v2 §「审查调度」)。
|
|
3
|
+
*
|
|
4
|
+
* ── 它解决什么 ────────────────────────────────────────────────────────────
|
|
5
|
+
* 「每 N 回合该自我审查一次」听起来简单,但真正难的是**跨重启不丢计数**:
|
|
6
|
+
* - 纯内存计数 → 每次进程重启归零 → 审查永远到不了期(`due` 恒 false);
|
|
7
|
+
* - 用 `Date.now()` 判间隔 → 用户休息 8 小时也该审一次吗?不该,**回合数**才是语义;
|
|
8
|
+
* - 自己起 `setInterval` 定时器 → 宿主生命周期不可控(插件卸载/热更新后定时器泄漏,
|
|
9
|
+
* 或者会话空闲时审查根本不该跑)。
|
|
10
|
+
* 所以本模块是**纯被动记账**:上层在每回合调一次 `noteTurn()`,它只负责「数到 N 没有」。
|
|
11
|
+
* 到期后**干什么**不归它管 —— 由上层(api/工具层)读 `due`,决定是提示模型还是落一条建议。
|
|
12
|
+
*
|
|
13
|
+
* ── 为什么计数必须落盘(`reviewer.jsonl`)──
|
|
14
|
+
* 这是本模块最重要的不变量:**重启后计数不能丢**。
|
|
15
|
+
* 否则「每 10 回合审一次」在频繁重启的开发期等于「永远不审」,
|
|
16
|
+
* 而且这种失效**没有任何报错**,只能靠人发现「怎么从来没人审」—— 属于最难查的一类问题。
|
|
17
|
+
* 因此每个 `noteTurn` 都 append 一行事件,`replay()` 从事件流重建;
|
|
18
|
+
* 构造时立即 hydrate,保证重启后 `status()` 一读就是真值。
|
|
19
|
+
*
|
|
20
|
+
* ── 为什么**不做快照** ────────────────────────────────────────────────────
|
|
21
|
+
* 会话状态只有几个整数,事件流一行即可重建;加 `.snap.json` 只会引入
|
|
22
|
+
* 「快照与事件流两处真相不一致」的新故障面(board.js 需要快照是因为整板任务
|
|
23
|
+
* 重建代价高,这里没有这个需求)。事件流就是唯一真相源。
|
|
24
|
+
*
|
|
25
|
+
* ── 为什么允许运行时改 interval ───────────────────────────────────────────
|
|
26
|
+
* 「几回合审一次」是**人的偏好**(UI 设置项),不该写死在代码里;
|
|
27
|
+
* 且改完必须**立即生效**(不等下一次重启),所以 interval 也落进事件流,
|
|
28
|
+
* 重启后保持用户最后设的值。
|
|
29
|
+
*
|
|
30
|
+
* 依赖面:`node:*` + `./store.js` + `./kernel.js`(惰性桥)。**零第三方依赖**。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
appendJsonl,
|
|
35
|
+
readJsonl,
|
|
36
|
+
statePath,
|
|
37
|
+
} from './store.js'
|
|
38
|
+
|
|
39
|
+
// ── kernel 惰性桥(与 board.js / discipline.js 同款,理由见 suggest.js 同名段落)──
|
|
40
|
+
|
|
41
|
+
/** @type {object | null} */
|
|
42
|
+
let kernel = null
|
|
43
|
+
/** @type {unknown} */
|
|
44
|
+
let kernelError = null
|
|
45
|
+
try {
|
|
46
|
+
kernel = await import('./kernel.js')
|
|
47
|
+
} catch (error) {
|
|
48
|
+
kernelError = error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** 缺省间隔(回合)。10 是一个折中:太短会每几轮就打断,太长(如 50)用户感知不到「自我整理」。 */
|
|
54
|
+
export const DEFAULT_INTERVAL = 10
|
|
55
|
+
/** 间隔下限:1 = 每回合都到期(等于「每回合都提示审查」,压测/演示用)。 */
|
|
56
|
+
export const MIN_INTERVAL = 1
|
|
57
|
+
/**
|
|
58
|
+
* 间隔上限:100000 只是防呆(比如 UI 传了 `Number.MAX_SAFE_INTEGER` 把 `due` 永久锁死)。
|
|
59
|
+
* 上限本身不参与业务语义,**不因超限而静默截断** —— 超限抛错,让人看见自己传错了。
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_INTERVAL = 100000
|
|
62
|
+
|
|
63
|
+
/** 会话 id 的长度上限(防把一整段正文误传成 sessionId,污染状态文件)。 */
|
|
64
|
+
const MAX_SESSION_ID_LENGTH = 200
|
|
65
|
+
|
|
66
|
+
// ── 错误类 ──────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** 审查调度错误:`code` 一律 `REVIEW_` 前缀。 */
|
|
69
|
+
export class ReviewerError extends Error {
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} message 人类可读、含定位信息
|
|
72
|
+
* @param {string} [code] 大写下划线错误码(`REVIEW_` 前缀)
|
|
73
|
+
* @param {object} [detail] 机器可读上下文
|
|
74
|
+
*/
|
|
75
|
+
constructor(message, code = 'REVIEW_ERROR', detail) {
|
|
76
|
+
super(message)
|
|
77
|
+
this.name = 'ReviewerError'
|
|
78
|
+
this.code = code
|
|
79
|
+
if (detail !== undefined) this.detail = detail
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** 深拷贝 + JSON 归一(去掉 undefined 键,保证内存态与磁盘态逐字相等)。 */
|
|
86
|
+
function clone(value) {
|
|
87
|
+
if (value === undefined || value === null) return value
|
|
88
|
+
return JSON.parse(JSON.stringify(value))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `now` 归一:缺省当前时间;显式必须是有限数字。 */
|
|
92
|
+
function normalizeNow(now, where) {
|
|
93
|
+
if (now === undefined || now === null) return Date.now()
|
|
94
|
+
if (typeof now !== 'number' || !Number.isFinite(now)) {
|
|
95
|
+
throw new ReviewerError(
|
|
96
|
+
`reviewer.${where}:now 必须是有限数字(毫秒时间戳),收到 ${JSON.stringify(now)}`,
|
|
97
|
+
'REVIEW_BAD_FIELD',
|
|
98
|
+
{ where, field: 'now' },
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return now
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 必填字符串(trim 后非空)。 */
|
|
105
|
+
function requireString(value, field, where, code = 'REVIEW_BAD_FIELD') {
|
|
106
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
107
|
+
throw new ReviewerError(
|
|
108
|
+
`reviewer.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
|
|
109
|
+
code,
|
|
110
|
+
{ where, field },
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return value.trim()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 会话 id 校验(与 requireString 分开:这里额外限长)。 */
|
|
117
|
+
function requireSessionId(value, where) {
|
|
118
|
+
const sessionId = requireString(value, 'sessionId', where, 'REVIEW_BAD_SESSION')
|
|
119
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
120
|
+
throw new ReviewerError(
|
|
121
|
+
`reviewer.${where}:sessionId 过长(${sessionId.length} > ${MAX_SESSION_ID_LENGTH})—— `
|
|
122
|
+
+ '疑似把正文误传成会话 id,拒绝写进状态文件',
|
|
123
|
+
'REVIEW_BAD_SESSION',
|
|
124
|
+
{ where, length: sessionId.length },
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return sessionId
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** interval 校验:正整数且在上限内。 */
|
|
131
|
+
function requireInterval(value, where) {
|
|
132
|
+
if (!Number.isInteger(value) || value < MIN_INTERVAL || value > MAX_INTERVAL) {
|
|
133
|
+
throw new ReviewerError(
|
|
134
|
+
`reviewer.${where}:interval 必须是 ${MIN_INTERVAL}..${MAX_INTERVAL} 的整数,收到 ${JSON.stringify(value)}`,
|
|
135
|
+
'REVIEW_BAD_INTERVAL',
|
|
136
|
+
{ where, interval: value, min: MIN_INTERVAL, max: MAX_INTERVAL },
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 状态目录 ────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
146
|
+
* **不做静默兜底**(与 board.js 同一纪律):兜到临时目录会让计数重启即丢。
|
|
147
|
+
* @param {{ stateDir?: string }} config
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
function resolveStateDir(config) {
|
|
151
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
152
|
+
return config.stateDir.trim()
|
|
153
|
+
}
|
|
154
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
155
|
+
return kernel.stateDir()
|
|
156
|
+
}
|
|
157
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
158
|
+
throw new ReviewerError(
|
|
159
|
+
`reviewer.createReviewer:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
160
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
161
|
+
'REVIEW_NO_STATE_DIR',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `(ctx, config)` 归一,允许单参数简写(`{ stateDir }`),与 discipline.js 同款。 */
|
|
166
|
+
function splitArgs(ctx, config) {
|
|
167
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
168
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
169
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
170
|
+
return { context: {}, config: ctx }
|
|
171
|
+
}
|
|
172
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 创建审查调度器实例。
|
|
179
|
+
*
|
|
180
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即 no-op)
|
|
181
|
+
* @param {{ stateDir?: string, interval?: number, name?: string }} [config]
|
|
182
|
+
* @returns {object} reviewer service
|
|
183
|
+
*/
|
|
184
|
+
export function createReviewer(ctx, config = {}) {
|
|
185
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
186
|
+
const stateDir = resolveStateDir(cfg)
|
|
187
|
+
|
|
188
|
+
const jsonlPath = statePath(stateDir, 'reviewer.jsonl')
|
|
189
|
+
|
|
190
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
191
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
192
|
+
|
|
193
|
+
/** @type {Map<string, object>} sessionId → 会话审查状态 */
|
|
194
|
+
const sessions = new Map()
|
|
195
|
+
/** @type {object[]} 全部事件(审计流,按落盘顺序) */
|
|
196
|
+
const events = []
|
|
197
|
+
|
|
198
|
+
/** 全局配置(interval 可由 setInterval 运行时改;落盘后重启保持)。 */
|
|
199
|
+
let intervalDefault = cfg.interval === undefined || cfg.interval === null
|
|
200
|
+
? DEFAULT_INTERVAL
|
|
201
|
+
: requireInterval(cfg.interval, 'createReviewer')
|
|
202
|
+
let configUpdatedAt = null
|
|
203
|
+
let configUpdatedBy = null
|
|
204
|
+
|
|
205
|
+
let disposed = false
|
|
206
|
+
|
|
207
|
+
const diagnostics = {
|
|
208
|
+
stateDir,
|
|
209
|
+
jsonlPath,
|
|
210
|
+
kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
|
|
211
|
+
hydratedFrom: /** @type {null|'jsonl'|'empty'} */ (null),
|
|
212
|
+
replayedEvents: 0,
|
|
213
|
+
badLines: 0,
|
|
214
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 已销毁实例的调用一律抛错(静默返回 turnCount:0 会让人误以为「重启丢计数」是 bug)。 */
|
|
218
|
+
function assertAlive(where) {
|
|
219
|
+
if (disposed) {
|
|
220
|
+
throw new ReviewerError(
|
|
221
|
+
`reviewer.${where}:实例已 dispose(),请重新 createReviewer()(拒绝在已销毁实例上静默返回空状态)`,
|
|
222
|
+
'REVIEW_DISPOSED',
|
|
223
|
+
{ where },
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 提交一次变更:**先落事件,再改内存**。
|
|
232
|
+
* 顺序不可颠倒:append 失败必须抛错、内存原封不动,否则重启后计数凭空回退/前进。
|
|
233
|
+
*
|
|
234
|
+
* @param {object} event 事件(含 type / at / by / rev / before / after)
|
|
235
|
+
* @param {() => void} applyToMemory 落盘成功后才执行的「改内存」动作
|
|
236
|
+
* @returns {void}
|
|
237
|
+
*/
|
|
238
|
+
function commit(event, applyToMemory) {
|
|
239
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
240
|
+
throw new ReviewerError(
|
|
241
|
+
`reviewer.${event.type}:事件追加失败(${jsonlPath})—— 拒绝「只改内存不落盘」`
|
|
242
|
+
+ '(否则重启后计数丢失,审查永远不到期)',
|
|
243
|
+
'REVIEW_APPEND_FAILED',
|
|
244
|
+
{ type: event.type, sessionId: event.sessionId ?? null },
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
applyToMemory()
|
|
248
|
+
events.push(clone(event))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 从 `reviewer.jsonl`(唯一真相源)重建内存态。
|
|
253
|
+
* 坏行**不静默丢弃**:计入 diagnostics 并 warn。
|
|
254
|
+
*/
|
|
255
|
+
function hydrate() {
|
|
256
|
+
sessions.clear()
|
|
257
|
+
events.length = 0
|
|
258
|
+
// interval 先回到构造时的配置,再让事件流里的最后一次 setInterval 覆盖它 ——
|
|
259
|
+
// 这样「用户在 UI 里改过间隔」重启后才不会退回缺省值。
|
|
260
|
+
intervalDefault = cfg.interval === undefined || cfg.interval === null
|
|
261
|
+
? DEFAULT_INTERVAL
|
|
262
|
+
: requireInterval(cfg.interval, 'createReviewer')
|
|
263
|
+
configUpdatedAt = null
|
|
264
|
+
configUpdatedBy = null
|
|
265
|
+
|
|
266
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
267
|
+
diagnostics.badLines = bad
|
|
268
|
+
diagnostics.replayedEvents = rows.length
|
|
269
|
+
if (bad > 0) {
|
|
270
|
+
warn(`[cs-bots/reviewer] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
271
|
+
}
|
|
272
|
+
if (rows.length === 0) {
|
|
273
|
+
diagnostics.hydratedFrom = 'empty'
|
|
274
|
+
info('[cs-bots/reviewer] 回放完成:空状态(无事件流)')
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let skipped = 0
|
|
279
|
+
for (const ev of rows) {
|
|
280
|
+
if (!ev || typeof ev.type !== 'string') {
|
|
281
|
+
skipped += 1
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
if (ev.type === 'config') {
|
|
285
|
+
const next = ev.after
|
|
286
|
+
if (!next || !Number.isInteger(next.interval)) {
|
|
287
|
+
skipped += 1
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
intervalDefault = next.interval
|
|
291
|
+
configUpdatedAt = Number.isFinite(next.updatedAt) ? next.updatedAt : (Number.isFinite(ev.at) ? ev.at : null)
|
|
292
|
+
configUpdatedBy = typeof ev.by === 'string' ? ev.by : null
|
|
293
|
+
events.push(clone(ev))
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
const sessionId = typeof ev.sessionId === 'string' ? ev.sessionId : null
|
|
297
|
+
if (!sessionId || typeof ev.rev !== 'number' || !Number.isFinite(ev.rev)) {
|
|
298
|
+
skipped += 1
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
const current = sessions.get(sessionId)
|
|
302
|
+
if (current && ev.rev <= current.rev) {
|
|
303
|
+
// 同 rev 重放幂等(容忍重复投递/半截重试),不是错误但也不重复消费
|
|
304
|
+
skipped += 1
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (ev.type === 'reset') {
|
|
308
|
+
// reset 的 after 为 null:删除是**显式事件**,回放时必须真的删掉,
|
|
309
|
+
// 否则「重置过的会话」重启后又带着旧计数回来(比不重置更迷惑人)。
|
|
310
|
+
if (ev.after === null) sessions.delete(sessionId)
|
|
311
|
+
else sessions.set(sessionId, clone(ev.after))
|
|
312
|
+
} else if (ev.after && typeof ev.after === 'object') {
|
|
313
|
+
sessions.set(sessionId, clone(ev.after))
|
|
314
|
+
} else {
|
|
315
|
+
skipped += 1
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
events.push(clone(ev))
|
|
319
|
+
}
|
|
320
|
+
if (skipped > 0) {
|
|
321
|
+
warn(`[cs-bots/reviewer] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
322
|
+
}
|
|
323
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
324
|
+
info(`[cs-bots/reviewer] 回放完成:${rows.length} 条事件 → ${sessions.size} 个会话状态(interval=${intervalDefault})`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 读 ────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/** 组装一个会话的对外状态(du 计算集中在这里,避免各调用点各写一遍判据)。 */
|
|
330
|
+
function statusOf(sessionId) {
|
|
331
|
+
const state = sessions.get(sessionId)
|
|
332
|
+
if (!state) {
|
|
333
|
+
return {
|
|
334
|
+
sessionId,
|
|
335
|
+
known: false,
|
|
336
|
+
rev: 0,
|
|
337
|
+
turnCount: 0,
|
|
338
|
+
interval: intervalDefault,
|
|
339
|
+
due: false,
|
|
340
|
+
turnsSinceLastReview: 0,
|
|
341
|
+
lastReviewedAt: null,
|
|
342
|
+
lastReviewedBy: null,
|
|
343
|
+
updatedAt: null,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
sessionId,
|
|
348
|
+
known: true,
|
|
349
|
+
rev: state.rev,
|
|
350
|
+
turnCount: state.turnCount,
|
|
351
|
+
interval: intervalDefault,
|
|
352
|
+
due: state.turnsSinceLastReview >= intervalDefault,
|
|
353
|
+
turnsSinceLastReview: state.turnsSinceLastReview,
|
|
354
|
+
lastReviewedAt: state.lastReviewedAt,
|
|
355
|
+
lastReviewedBy: state.lastReviewedBy,
|
|
356
|
+
updatedAt: state.updatedAt,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 查审查状态。
|
|
362
|
+
* - 传 `sessionId`:返回该会话的明细(未计数过也返回零态 + `known:false`,
|
|
363
|
+
* 因为「还没到过回合」是合法查询,不该抛错);
|
|
364
|
+
* - 不传:返回全局概览(到期会话列表),给 UI 顶部红点/状态栏用。
|
|
365
|
+
*
|
|
366
|
+
* @param {{ sessionId?: string }} [query]
|
|
367
|
+
* @returns {object}
|
|
368
|
+
*/
|
|
369
|
+
function status(query = {}) {
|
|
370
|
+
assertAlive('status')
|
|
371
|
+
const sessionId = query?.sessionId
|
|
372
|
+
if (sessionId !== undefined && sessionId !== null) {
|
|
373
|
+
const sid = requireSessionId(sessionId, 'status')
|
|
374
|
+
return statusOf(sid)
|
|
375
|
+
}
|
|
376
|
+
const dueSessions = []
|
|
377
|
+
let totalTurns = 0
|
|
378
|
+
for (const sid of sessions.keys()) {
|
|
379
|
+
const one = statusOf(sid)
|
|
380
|
+
totalTurns += one.turnCount
|
|
381
|
+
if (one.due) dueSessions.push(sid)
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
interval: intervalDefault,
|
|
385
|
+
sessions: sessions.size,
|
|
386
|
+
due: dueSessions.length > 0,
|
|
387
|
+
dueSessions: clone(dueSessions).sort(),
|
|
388
|
+
totalTurns,
|
|
389
|
+
configUpdatedAt,
|
|
390
|
+
configUpdatedBy,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── 写 ────────────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 记一回合(上层**每回合调一次**;本模块不自己起定时器 —— 宿主生命周期不可控)。
|
|
398
|
+
*
|
|
399
|
+
* @param {{ sessionId: string, now?: number }} input
|
|
400
|
+
* @returns {{ sessionId: string, turnCount: number, turnsSinceLastReview: number,
|
|
401
|
+
* interval: number, due: boolean }}
|
|
402
|
+
*/
|
|
403
|
+
function noteTurn(input = {}) {
|
|
404
|
+
assertAlive('noteTurn')
|
|
405
|
+
const { sessionId, now } = input ?? {}
|
|
406
|
+
const sid = requireSessionId(sessionId, 'noteTurn')
|
|
407
|
+
const at = normalizeNow(now, 'noteTurn')
|
|
408
|
+
|
|
409
|
+
const before = sessions.get(sid)
|
|
410
|
+
const next = before
|
|
411
|
+
? {
|
|
412
|
+
...clone(before),
|
|
413
|
+
rev: before.rev + 1,
|
|
414
|
+
turnCount: before.turnCount + 1,
|
|
415
|
+
turnsSinceLastReview: before.turnsSinceLastReview + 1,
|
|
416
|
+
updatedAt: at,
|
|
417
|
+
}
|
|
418
|
+
: {
|
|
419
|
+
sessionId: sid,
|
|
420
|
+
rev: 1,
|
|
421
|
+
turnCount: 1,
|
|
422
|
+
turnsSinceLastReview: 1,
|
|
423
|
+
lastReviewedAt: null,
|
|
424
|
+
lastReviewedBy: null,
|
|
425
|
+
createdAt: at,
|
|
426
|
+
updatedAt: at,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const event = {
|
|
430
|
+
type: 'turn',
|
|
431
|
+
sessionId: sid,
|
|
432
|
+
rev: next.rev,
|
|
433
|
+
at,
|
|
434
|
+
before: before ? clone(before) : null,
|
|
435
|
+
after: clone(next),
|
|
436
|
+
}
|
|
437
|
+
commit(event, () => { sessions.set(sid, clone(next)) })
|
|
438
|
+
|
|
439
|
+
const due = next.turnsSinceLastReview >= intervalDefault
|
|
440
|
+
if (due && (!before || before.turnsSinceLastReview < intervalDefault)) {
|
|
441
|
+
// 只在**刚跨过阈值**那一刻提示一次,避免到期后每回合刷日志
|
|
442
|
+
info(`[cs-bots/reviewer] 会话 ${sid} 已累计 ${next.turnsSinceLastReview} 回合未审查(阈值 ${intervalDefault})→ 到期`)
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
sessionId: sid,
|
|
446
|
+
turnCount: next.turnCount,
|
|
447
|
+
turnsSinceLastReview: next.turnsSinceLastReview,
|
|
448
|
+
interval: intervalDefault,
|
|
449
|
+
due,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 审查完成:计数归零(`turnsSinceLastReview=0`),记录「何时、谁」审的。
|
|
455
|
+
* 为什么不减 N 而是归零:审查是**一次性扫全量**,不是「审掉 10 条待办」;
|
|
456
|
+
* 归零语义更简单,也避免用户改了 interval 后出现「负数/提前到期」。
|
|
457
|
+
*
|
|
458
|
+
* @param {{ sessionId: string, by: string, now?: number, expectedRev?: number }} input
|
|
459
|
+
* @returns {object} 审查后的状态(含 `reviewedTurns` = 本次审掉的累计回合数)
|
|
460
|
+
*/
|
|
461
|
+
function markReviewed(input = {}) {
|
|
462
|
+
assertAlive('markReviewed')
|
|
463
|
+
const { sessionId, by, now, expectedRev } = input ?? {}
|
|
464
|
+
const sid = requireSessionId(sessionId, 'markReviewed')
|
|
465
|
+
const author = requireString(by, 'by', 'markReviewed')
|
|
466
|
+
const at = normalizeNow(now, 'markReviewed')
|
|
467
|
+
|
|
468
|
+
const before = sessions.get(sid)
|
|
469
|
+
if (expectedRev !== undefined && expectedRev !== null) {
|
|
470
|
+
if (!Number.isInteger(expectedRev)) {
|
|
471
|
+
throw new ReviewerError(
|
|
472
|
+
`reviewer.markReviewed:expectedRev 必须是整数(当前 rev=${before?.rev ?? 0},收到 ${JSON.stringify(expectedRev)})`,
|
|
473
|
+
'REVIEW_BAD_FIELD',
|
|
474
|
+
{ where: 'markReviewed', field: 'expectedRev', sessionId: sid, rev: before?.rev ?? 0 },
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
const actualRev = before?.rev ?? 0
|
|
478
|
+
if (actualRev !== expectedRev) {
|
|
479
|
+
throw new ReviewerError(
|
|
480
|
+
`reviewer.markReviewed CAS 冲突:会话 ${sid} 期望 rev=${expectedRev},实际 rev=${actualRev} —— `
|
|
481
|
+
+ '拒绝静默覆盖(另一个窗口可能刚审过),请重新 status() 后重试',
|
|
482
|
+
'REVIEW_CAS_CONFLICT',
|
|
483
|
+
{ where: 'markReviewed', sessionId: sid, expectedRev, rev: actualRev },
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const reviewedTurns = before?.turnsSinceLastReview ?? 0
|
|
489
|
+
const next = before
|
|
490
|
+
? {
|
|
491
|
+
...clone(before),
|
|
492
|
+
rev: before.rev + 1,
|
|
493
|
+
turnsSinceLastReview: 0,
|
|
494
|
+
lastReviewedAt: at,
|
|
495
|
+
lastReviewedBy: author,
|
|
496
|
+
updatedAt: at,
|
|
497
|
+
}
|
|
498
|
+
: {
|
|
499
|
+
// 会话从未计数过也可以记「审查过」(例如重启后第一次就被调用):
|
|
500
|
+
// 若直接抛错,上层会为了「先 noteTurn 一次」写一堆特判,得不偿失。
|
|
501
|
+
sessionId: sid,
|
|
502
|
+
rev: 1,
|
|
503
|
+
turnCount: 0,
|
|
504
|
+
turnsSinceLastReview: 0,
|
|
505
|
+
lastReviewedAt: at,
|
|
506
|
+
lastReviewedBy: author,
|
|
507
|
+
createdAt: at,
|
|
508
|
+
updatedAt: at,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const event = {
|
|
512
|
+
type: 'review',
|
|
513
|
+
sessionId: sid,
|
|
514
|
+
rev: next.rev,
|
|
515
|
+
at,
|
|
516
|
+
by: author,
|
|
517
|
+
before: before ? clone(before) : null,
|
|
518
|
+
after: clone(next),
|
|
519
|
+
}
|
|
520
|
+
commit(event, () => { sessions.set(sid, clone(next)) })
|
|
521
|
+
info(`[cs-bots/reviewer] 会话 ${sid} 审查完成 by ${author}(清掉 ${reviewedTurns} 回合积压)`)
|
|
522
|
+
return { ...statusOf(sid), reviewedTurns }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 运行时改间隔(UI 设置项)。**立即生效**:下一次 status() 的 `due` 就按新阈值算。
|
|
527
|
+
* 值没变则**不写事件**(幂等,避免 UI 每次保存都堆一行噪音),但明确回报 `changed:false`。
|
|
528
|
+
*
|
|
529
|
+
* @param {{ interval: number, by?: string, now?: number }} input
|
|
530
|
+
* @returns {{ interval: number, changed: boolean, updatedAt: number|null, updatedBy: string|null }}
|
|
531
|
+
*/
|
|
532
|
+
function setIntervalConfig(input = {}) {
|
|
533
|
+
assertAlive('setInterval')
|
|
534
|
+
const { interval, by, now } = input ?? {}
|
|
535
|
+
const value = requireInterval(interval, 'setInterval')
|
|
536
|
+
const at = normalizeNow(now, 'setInterval')
|
|
537
|
+
const author = by === undefined || by === null ? null : requireString(by, 'by', 'setInterval')
|
|
538
|
+
|
|
539
|
+
if (value === intervalDefault) {
|
|
540
|
+
return { interval: intervalDefault, changed: false, updatedAt: configUpdatedAt, updatedBy: configUpdatedBy }
|
|
541
|
+
}
|
|
542
|
+
const event = {
|
|
543
|
+
type: 'config',
|
|
544
|
+
at,
|
|
545
|
+
by: author,
|
|
546
|
+
rev: events.filter((e) => e.type === 'config').length + 1,
|
|
547
|
+
before: { interval: intervalDefault, updatedAt: configUpdatedAt, updatedBy: configUpdatedBy },
|
|
548
|
+
after: { interval: value, updatedAt: at, updatedBy: author },
|
|
549
|
+
}
|
|
550
|
+
commit(event, () => {
|
|
551
|
+
intervalDefault = value
|
|
552
|
+
configUpdatedAt = at
|
|
553
|
+
configUpdatedBy = author
|
|
554
|
+
})
|
|
555
|
+
info(`[cs-bots/reviewer] 审查间隔改为 ${value} 回合 by ${author ?? 'unknown'}(立即生效)`)
|
|
556
|
+
return { interval: value, changed: true, updatedAt: at, updatedBy: author }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 重置某会话的计数(人点「重新开始计数」;比如换了任务阶段,旧积压不再有意义)。
|
|
561
|
+
* 未知会话 → 抛错:静默成功会让人以为「重置了」,实际什么都没发生。
|
|
562
|
+
*
|
|
563
|
+
* @param {{ sessionId: string, by?: string, now?: number, expectedRev?: number }} input
|
|
564
|
+
* @returns {{ sessionId: string, reset: true, cleared: object }}
|
|
565
|
+
*/
|
|
566
|
+
function reset(input = {}) {
|
|
567
|
+
assertAlive('reset')
|
|
568
|
+
const { sessionId, by, now, expectedRev } = input ?? {}
|
|
569
|
+
const sid = requireSessionId(sessionId, 'reset')
|
|
570
|
+
const author = by === undefined || by === null ? null : requireString(by, 'by', 'reset')
|
|
571
|
+
const at = normalizeNow(now, 'reset')
|
|
572
|
+
|
|
573
|
+
const before = sessions.get(sid)
|
|
574
|
+
if (!before) {
|
|
575
|
+
throw new ReviewerError(
|
|
576
|
+
`reviewer.reset:会话 ${sid} 没有审查计数可重置(拒绝静默成功)`,
|
|
577
|
+
'REVIEW_SESSION_NOT_FOUND',
|
|
578
|
+
{ where: 'reset', sessionId: sid },
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
if (expectedRev !== undefined && expectedRev !== null) {
|
|
582
|
+
if (!Number.isInteger(expectedRev)) {
|
|
583
|
+
throw new ReviewerError(
|
|
584
|
+
`reviewer.reset:expectedRev 必须是整数(当前 rev=${before.rev},收到 ${JSON.stringify(expectedRev)})`,
|
|
585
|
+
'REVIEW_BAD_FIELD',
|
|
586
|
+
{ where: 'reset', field: 'expectedRev', sessionId: sid, rev: before.rev },
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
if (before.rev !== expectedRev) {
|
|
590
|
+
throw new ReviewerError(
|
|
591
|
+
`reviewer.reset CAS 冲突:会话 ${sid} 期望 rev=${expectedRev},实际 rev=${before.rev} —— 拒绝静默覆盖`,
|
|
592
|
+
'REVIEW_CAS_CONFLICT',
|
|
593
|
+
{ where: 'reset', sessionId: sid, expectedRev, rev: before.rev },
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const cleared = clone(before)
|
|
599
|
+
const event = {
|
|
600
|
+
type: 'reset',
|
|
601
|
+
sessionId: sid,
|
|
602
|
+
rev: before.rev + 1,
|
|
603
|
+
at,
|
|
604
|
+
by: author,
|
|
605
|
+
before: clone(before),
|
|
606
|
+
after: null,
|
|
607
|
+
}
|
|
608
|
+
commit(event, () => { sessions.delete(sid) })
|
|
609
|
+
info(`[cs-bots/reviewer] 会话 ${sid} 审查计数已重置(原 ${before.turnCount} 回合)by ${author ?? 'unknown'}`)
|
|
610
|
+
return { sessionId: sid, reset: true, cleared }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 列出所有会话的审查状态(按 `updatedAt` 降序 —— UI 里「最近动过的会话」排前面)。
|
|
615
|
+
* @returns {object[]}
|
|
616
|
+
*/
|
|
617
|
+
function list() {
|
|
618
|
+
assertAlive('list')
|
|
619
|
+
const rows = [...sessions.keys()].map((sid) => statusOf(sid))
|
|
620
|
+
rows.sort((a, b) => (
|
|
621
|
+
((b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
622
|
+
|| (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)
|
|
623
|
+
))
|
|
624
|
+
return rows
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
629
|
+
* @returns {{ sessions: number, dueSessions: number, totalTurns: number, interval: number, events: number }}
|
|
630
|
+
*/
|
|
631
|
+
function stats() {
|
|
632
|
+
assertAlive('stats')
|
|
633
|
+
let totalTurns = 0
|
|
634
|
+
let dueSessions = 0
|
|
635
|
+
for (const sid of sessions.keys()) {
|
|
636
|
+
const one = statusOf(sid)
|
|
637
|
+
totalTurns += one.turnCount
|
|
638
|
+
if (one.due) dueSessions += 1
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
sessions: sessions.size,
|
|
642
|
+
dueSessions,
|
|
643
|
+
totalTurns,
|
|
644
|
+
interval: intervalDefault,
|
|
645
|
+
events: events.length,
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 从 `reviewer.jsonl` 重建内存态(重启后计数不丢,是**本模块存在的意义**)。
|
|
651
|
+
* @returns {{ from: string, sessions: number, events: number, bad: number, interval: number }}
|
|
652
|
+
*/
|
|
653
|
+
function replay() {
|
|
654
|
+
assertAlive('replay')
|
|
655
|
+
hydrate()
|
|
656
|
+
diagnostics.lastReplayAt = Date.now()
|
|
657
|
+
return {
|
|
658
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
659
|
+
sessions: sessions.size,
|
|
660
|
+
events: events.length,
|
|
661
|
+
bad: diagnostics.badLines,
|
|
662
|
+
interval: intervalDefault,
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* 销毁实例(释放内存、拒绝后续调用)。不删事件流:它是长期审计资产。
|
|
668
|
+
* @returns {{ disposed: boolean, sessions: number, events: number }}
|
|
669
|
+
*/
|
|
670
|
+
function dispose() {
|
|
671
|
+
const out = { disposed: true, sessions: sessions.size, events: events.length }
|
|
672
|
+
disposed = true
|
|
673
|
+
sessions.clear()
|
|
674
|
+
events.length = 0
|
|
675
|
+
return out
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 事件历史(审计:谁在第几回合审了/改了什么)。 */
|
|
679
|
+
function history(sessionId) {
|
|
680
|
+
assertAlive('history')
|
|
681
|
+
const rows = typeof sessionId === 'string' && sessionId !== ''
|
|
682
|
+
? events.filter((e) => e.sessionId === sessionId)
|
|
683
|
+
: events
|
|
684
|
+
return clone(rows)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** 首次构造即回放:重启后 `status()` 必须是真值,不能等第一次 noteTurn 才补。 */
|
|
688
|
+
hydrate()
|
|
689
|
+
|
|
690
|
+
const service = {
|
|
691
|
+
name: cfg.name ?? 'reviewer',
|
|
692
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath }),
|
|
693
|
+
// 契约函数名(任务书/API 层按这些名字调)
|
|
694
|
+
noteTurn,
|
|
695
|
+
status,
|
|
696
|
+
markReviewed,
|
|
697
|
+
setInterval: setIntervalConfig,
|
|
698
|
+
reset,
|
|
699
|
+
list,
|
|
700
|
+
stats,
|
|
701
|
+
replay,
|
|
702
|
+
dispose,
|
|
703
|
+
history,
|
|
704
|
+
diagnostics: () => clone(diagnostics),
|
|
705
|
+
}
|
|
706
|
+
return service
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export default createReviewer
|