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
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots SubscriptionService —— 任务订阅(改造07 · 借鉴 Super-agent 的 Subscription 扇出)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 现有协作里有一个**断点**:房间里的讨论在推进,但相关任务**看不到**。
|
|
7
|
+
* 人只好回到任务里手动补一句「刚才房间说……」,或者重新 @ Bot 一次。
|
|
8
|
+
*
|
|
9
|
+
* Super-agent 的 Subscription 解决了这个:一次性订阅后,
|
|
10
|
+
* Thread 上的**后续消息自动扇出**给订阅它的 Ticket——讨论进展自动到达,
|
|
11
|
+
* 不需要再点名。
|
|
12
|
+
*
|
|
13
|
+
* ## 本模块的形态(与 Super-agent 的差异,写清避免误读)
|
|
14
|
+
*
|
|
15
|
+
* | | Super-agent | cs-bots(本模块) |
|
|
16
|
+
* |---|---|---|
|
|
17
|
+
* | 订阅主体 | Ticket ↔ Thread | **Task ↔ Room** |
|
|
18
|
+
* | 扇出后果 | 消息作为上下文 Input 送达执行器 | 消息作为**任务上下文**追加,并**可选**唤醒 owner |
|
|
19
|
+
* | 授权影响 | 只给上下文,不授予新权力 | 同:**只给上下文,不改变任务状态** |
|
|
20
|
+
* | 生成方式 | 模型/人订阅 | 人/模型订阅(显式) |
|
|
21
|
+
*
|
|
22
|
+
* **刻意不做的事**:扇出不改变任务状态、不授予「可以开工」的权力——
|
|
23
|
+
* 与 Super-agent 的设计一致(Subscription 只加 context,不加 authority)。
|
|
24
|
+
* 谁有权开工仍由任务本身的 claim 决定。
|
|
25
|
+
*
|
|
26
|
+
* ## 四条不变量
|
|
27
|
+
*
|
|
28
|
+
* 1. **事件流是真相源**:先 append `subscription.jsonl`,再改内存,最后写快照。
|
|
29
|
+
* 2. **CAS**:所有变更带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
|
|
30
|
+
* 3. **幂等**:同一 (taskId, roomId) 只存在一条活订阅;重复 subscribe 返回既有记录。
|
|
31
|
+
* 4. **绝不静默失败**:订阅/取消订阅/扇出全部有明确返回值与可读原因。
|
|
32
|
+
*
|
|
33
|
+
* ## 扇出节流(K4 类比)
|
|
34
|
+
*
|
|
35
|
+
* 房间发消息可以很频繁,每条都扇出会让任务上下文爆炸。
|
|
36
|
+
* 故扇出有两条约束:
|
|
37
|
+
* - 每次扇出只保留**最近 N 条**(默认 20,`fanoutKeep`),滚动的上下文窗口;
|
|
38
|
+
* - **唤醒**默认关闭(`wakeOnFanout:false`)——上下文到达不等于要打扰执行者。
|
|
39
|
+
*
|
|
40
|
+
* ## 依赖面
|
|
41
|
+
*
|
|
42
|
+
* node 内置 + `./store.js` + `./kernel.js`(惰性)。零第三方依赖。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { appendJsonl, readJsonl, readJson, writeJsonAtomic, ensureDir, statePath } from './store.js'
|
|
46
|
+
import { decideAdmission, ADMISSIONS, DEFAULT_ACCEPT_MODE, ACCEPT_MODES } from './admission.js'
|
|
47
|
+
|
|
48
|
+
let kernel = null
|
|
49
|
+
let kernelError = null
|
|
50
|
+
try {
|
|
51
|
+
kernel = await import('./kernel.js')
|
|
52
|
+
} catch (error) {
|
|
53
|
+
kernelError = error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 合法状态。 */
|
|
57
|
+
export const SUB_STATES = Object.freeze(['active', 'active_stopped', 'dropped'])
|
|
58
|
+
|
|
59
|
+
/** 事件动作集合。 */
|
|
60
|
+
const ACTIONS = ['subscribe', 'unsubscribe', 'fanout', 'drop']
|
|
61
|
+
|
|
62
|
+
/** 订阅上可配置的准入字段(改造09)。 */
|
|
63
|
+
const ADMISSION_FIELDS = ['acceptMode', 'rules', 'receivesEveryMessage']
|
|
64
|
+
|
|
65
|
+
/** 缺省配置。 */
|
|
66
|
+
const DEFAULTS = Object.freeze({
|
|
67
|
+
/** 每个任务保留的上下文条目上限(滚动窗口)。 */
|
|
68
|
+
fanoutKeep: 20,
|
|
69
|
+
/** 扇出后是否唤醒 owner(默认否:上下文到达不等于要打扰)。 */
|
|
70
|
+
wakeOnFanout: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** 结构化错误。 */
|
|
74
|
+
export class SubscriptionError extends Error {
|
|
75
|
+
constructor(message, code = 'SUBSCRIPTION_ERROR', details) {
|
|
76
|
+
super(message)
|
|
77
|
+
this.name = 'SubscriptionError'
|
|
78
|
+
this.code = code
|
|
79
|
+
if (details !== undefined) this.details = details
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clone(value) {
|
|
84
|
+
if (value === undefined || value === null) return value
|
|
85
|
+
return JSON.parse(JSON.stringify(value))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireString(value, field, where) {
|
|
89
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
90
|
+
throw new SubscriptionError(
|
|
91
|
+
`subscription.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`,
|
|
92
|
+
'SUBSCRIPTION_BAD_FIELD',
|
|
93
|
+
{ where, field },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return value.trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function optionalString(value, field, where) {
|
|
100
|
+
if (value === undefined || value === null) return undefined
|
|
101
|
+
if (typeof value !== 'string') {
|
|
102
|
+
throw new SubscriptionError(
|
|
103
|
+
`subscription.${where}:${field} 必须是字符串,收到 ${JSON.stringify(value)}`,
|
|
104
|
+
'SUBSCRIPTION_BAD_FIELD',
|
|
105
|
+
{ where, field },
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeNow(value, where) {
|
|
112
|
+
if (value === undefined || value === null) return Date.now()
|
|
113
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
114
|
+
throw new SubscriptionError(
|
|
115
|
+
`subscription.${where}:now 必须是有限数字,收到 ${JSON.stringify(value)}`,
|
|
116
|
+
'SUBSCRIPTION_BAD_FIELD',
|
|
117
|
+
{ where, field: 'now' },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveStateDir(config) {
|
|
124
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') return config.stateDir.trim()
|
|
125
|
+
if (kernel && typeof kernel.stateDir === 'function') return kernel.stateDir()
|
|
126
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
127
|
+
throw new SubscriptionError(
|
|
128
|
+
`subscription.createSubscription:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`,
|
|
129
|
+
'SUBSCRIPTION_NO_STATE_DIR',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 创建订阅服务。
|
|
135
|
+
*
|
|
136
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx]
|
|
137
|
+
* @param {{ stateDir?: string, fanoutKeep?: number, wakeOnFanout?: boolean, now?: () => number }} [config]
|
|
138
|
+
* @returns {object} subscription service
|
|
139
|
+
*/
|
|
140
|
+
export function createSubscription(ctx, config = {}) {
|
|
141
|
+
const stateDir = resolveStateDir(config)
|
|
142
|
+
const jsonlPath = statePath(stateDir, 'subscription.jsonl')
|
|
143
|
+
const snapPath = statePath(stateDir, 'subscription.snap.json')
|
|
144
|
+
const fanoutKeep = Number.isFinite(config.fanoutKeep) && config.fanoutKeep > 0
|
|
145
|
+
? Math.floor(config.fanoutKeep)
|
|
146
|
+
: DEFAULTS.fanoutKeep
|
|
147
|
+
const wakeOnFanout = config.wakeOnFanout === true
|
|
148
|
+
const now = typeof config.now === 'function' ? config.now : () => Date.now()
|
|
149
|
+
|
|
150
|
+
const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* */ } }
|
|
151
|
+
const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* */ } }
|
|
152
|
+
|
|
153
|
+
/** @type {Map<string, object>} 订阅 id → Subscription */
|
|
154
|
+
const subs = new Map()
|
|
155
|
+
/** 索引:(taskId, roomId) → subId(活订阅唯一) */
|
|
156
|
+
const liveIndex = new Map()
|
|
157
|
+
/** 全部事件(审计) */
|
|
158
|
+
const events = []
|
|
159
|
+
let idCounter = 0
|
|
160
|
+
|
|
161
|
+
const diagnostics = {
|
|
162
|
+
stateDir,
|
|
163
|
+
jsonlPath,
|
|
164
|
+
snapPath,
|
|
165
|
+
hydratedFrom: null,
|
|
166
|
+
replayedEvents: 0,
|
|
167
|
+
badLines: 0,
|
|
168
|
+
snapshotWriteFailures: 0,
|
|
169
|
+
fanouts: 0,
|
|
170
|
+
wakeFailures: 0,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function snapshot() {
|
|
174
|
+
return { version: 1, savedAt: Date.now(), idCounter, subs: [...subs.values()] }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。顺序不可颠倒。
|
|
179
|
+
*/
|
|
180
|
+
function commit(action, at, by, before, after) {
|
|
181
|
+
if (!appendJsonl(jsonlPath, { id: after.id, rev: after.rev, action, by, at, before, after })) {
|
|
182
|
+
throw new SubscriptionError(
|
|
183
|
+
`subscription.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
184
|
+
'SUBSCRIPTION_APPEND_FAILED',
|
|
185
|
+
{ action },
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
subs.set(after.id, clone(after))
|
|
189
|
+
events.push({ id: after.id, rev: after.rev, action, by, at, before: before ? clone(before) : null, after: clone(after) })
|
|
190
|
+
if (after.state === 'active') liveIndex.set(keyOf(after.taskId, after.roomId), after.id)
|
|
191
|
+
else liveIndex.delete(keyOf(after.taskId, after.roomId))
|
|
192
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
193
|
+
diagnostics.snapshotWriteFailures += 1
|
|
194
|
+
warn(`[cs-bots/subscription] 快照写入失败(${snapPath})——事件流已落盘,replay() 可恢复`)
|
|
195
|
+
}
|
|
196
|
+
return clone(after)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function keyOf(taskId, roomId) {
|
|
200
|
+
return `${taskId}::${roomId}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hydrate() {
|
|
204
|
+
subs.clear()
|
|
205
|
+
liveIndex.clear()
|
|
206
|
+
events.length = 0
|
|
207
|
+
idCounter = 0
|
|
208
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
209
|
+
diagnostics.badLines = bad
|
|
210
|
+
diagnostics.replayedEvents = rows.length
|
|
211
|
+
if (bad > 0) warn(`[cs-bots/subscription] ${jsonlPath} 有 ${bad} 行无法解析——已跳过,请核对`)
|
|
212
|
+
if (rows.length === 0) {
|
|
213
|
+
const snap = readJson(snapPath, null)
|
|
214
|
+
const list = Array.isArray(snap?.subs) ? snap.subs : null
|
|
215
|
+
if (list) {
|
|
216
|
+
for (const raw of list) {
|
|
217
|
+
if (!raw || typeof raw.id !== 'string') continue
|
|
218
|
+
subs.set(raw.id, clone(raw))
|
|
219
|
+
if (raw.state === 'active') liveIndex.set(keyOf(raw.taskId, raw.roomId), raw.id)
|
|
220
|
+
const m = /^sub-(\d+)$/.exec(raw.id)
|
|
221
|
+
if (m) idCounter = Math.max(idCounter, Number(m[1]))
|
|
222
|
+
}
|
|
223
|
+
if (Number.isFinite(snap?.idCounter)) idCounter = Math.max(idCounter, snap.idCounter)
|
|
224
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
225
|
+
info(`[cs-bots/subscription] 回放完成:由快照恢复 ${subs.size} 条订阅`)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
diagnostics.hydratedFrom = 'empty'
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
let skipped = 0
|
|
232
|
+
for (const ev of rows) {
|
|
233
|
+
if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
|
|
234
|
+
const cur = subs.get(ev.id)
|
|
235
|
+
if (cur && Number.isFinite(ev.rev) && ev.rev <= cur.rev) { skipped += 1; continue }
|
|
236
|
+
subs.set(ev.id, clone(ev.after))
|
|
237
|
+
events.push(clone(ev))
|
|
238
|
+
if (ev.after.state === 'active') liveIndex.set(keyOf(ev.after.taskId, ev.after.roomId), ev.after.id)
|
|
239
|
+
else liveIndex.delete(keyOf(ev.after.taskId, ev.after.roomId))
|
|
240
|
+
const m = /^sub-(\d+)$/.exec(ev.id)
|
|
241
|
+
if (m) idCounter = Math.max(idCounter, Number(m[1]))
|
|
242
|
+
}
|
|
243
|
+
if (skipped > 0) warn(`[cs-bots/subscription] 回放跳过 ${skipped} 条非递增/残缺事件`)
|
|
244
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function requireSub(id, where) {
|
|
248
|
+
const s = subs.get(id)
|
|
249
|
+
if (!s) {
|
|
250
|
+
throw new SubscriptionError(
|
|
251
|
+
`subscription.${where}:订阅 ${id} 不存在`,
|
|
252
|
+
'SUBSCRIPTION_NOT_FOUND',
|
|
253
|
+
{ where, id },
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
return s
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cas(id, expectedRev, where) {
|
|
260
|
+
const s = requireSub(id, where)
|
|
261
|
+
if (expectedRev !== undefined && expectedRev !== null) {
|
|
262
|
+
if (!Number.isInteger(expectedRev)) {
|
|
263
|
+
throw new SubscriptionError(
|
|
264
|
+
`subscription.${where}:expectedRev 必须是整数(当前 rev=${s.rev})`,
|
|
265
|
+
'SUBSCRIPTION_BAD_FIELD', { where, field: 'expectedRev' },
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (s.rev !== expectedRev) {
|
|
269
|
+
throw new SubscriptionError(
|
|
270
|
+
`subscription.${where} CAS 冲突:订阅 ${id} 期望 rev=${expectedRev},实际 rev=${s.rev} —— 拒绝静默覆盖`,
|
|
271
|
+
'SUBSCRIPTION_REV_CONFLICT', { where, id, expectedRev, rev: s.rev },
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return s
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── 写 ────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 归一化订阅上的准入配置(改造09)。
|
|
282
|
+
*
|
|
283
|
+
* 只认白名单字段 —— 未知字段一律不写,与 board 的「绝不发明字段」同口径。
|
|
284
|
+
* 非法模式值退回缺省 `observe`(**绝不退回 `auto`**:那等于偷偷放权)。
|
|
285
|
+
*
|
|
286
|
+
* @param {object} input
|
|
287
|
+
* @returns {{ acceptMode: string, rules?: object[], receivesEveryMessage?: boolean }}
|
|
288
|
+
*/
|
|
289
|
+
function normalizeAdmission(input) {
|
|
290
|
+
const out = { acceptMode: DEFAULT_ACCEPT_MODE }
|
|
291
|
+
const mode = input && typeof input === 'object' ? input.acceptMode : undefined
|
|
292
|
+
if (ACCEPT_MODES.includes(mode)) out.acceptMode = mode
|
|
293
|
+
if (Array.isArray(input?.rules)) out.rules = input.rules
|
|
294
|
+
// ⚠️ 房间订阅缺省 `receivesEveryMessage: true` —— 房间是群聊,
|
|
295
|
+
// 成员本来就**看到所有消息**。这不是放权,而是对事实的承认:
|
|
296
|
+
// 在群聊里「没点名」不等于「与我无关」。`false` 用于「只收点名」的场景。
|
|
297
|
+
out.receivesEveryMessage = input?.receivesEveryMessage !== false
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 订阅:让任务接收某个房间的后续消息。
|
|
303
|
+
*
|
|
304
|
+
* **幂等**:同一 (taskId, roomId) 已有活订阅时,直接返回既有记录
|
|
305
|
+
* (不抛错、不新建——重复订阅是无害的用户操作)。
|
|
306
|
+
*
|
|
307
|
+
* @param {{ taskId: string, roomId: string, note?: string, by: string, now?: number }} input
|
|
308
|
+
* @returns {object} Subscription
|
|
309
|
+
*/
|
|
310
|
+
function subscribe(input = {}) {
|
|
311
|
+
const { taskId, roomId, note, by, now: at0 } = input ?? {}
|
|
312
|
+
const tId = requireString(taskId, 'taskId', 'subscribe')
|
|
313
|
+
const rId = requireString(roomId, 'roomId', 'subscribe')
|
|
314
|
+
const author = requireString(by, 'by', 'subscribe')
|
|
315
|
+
const n = optionalString(note, 'note', 'subscribe')
|
|
316
|
+
const at = normalizeNow(at0, 'subscribe')
|
|
317
|
+
|
|
318
|
+
// 幂等:已有活订阅 → 原样返回
|
|
319
|
+
const existingId = liveIndex.get(keyOf(tId, rId))
|
|
320
|
+
if (existingId) {
|
|
321
|
+
const existing = subs.get(existingId)
|
|
322
|
+
if (existing && existing.state === 'active') {
|
|
323
|
+
info(`[cs-bots/subscription] subscribe 幂等命中 ${tId} ↔ ${rId} → 复用 ${existingId}`)
|
|
324
|
+
return clone(existing)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
idCounter += 1
|
|
329
|
+
const id = `sub-${idCounter}`
|
|
330
|
+
/** @type {Record<string, unknown>} */
|
|
331
|
+
const sub = {
|
|
332
|
+
id,
|
|
333
|
+
rev: 1,
|
|
334
|
+
taskId: tId,
|
|
335
|
+
roomId: rId,
|
|
336
|
+
state: 'active',
|
|
337
|
+
context: [],
|
|
338
|
+
fanoutCount: 0,
|
|
339
|
+
createdAt: at,
|
|
340
|
+
createdBy: author,
|
|
341
|
+
updatedAt: at,
|
|
342
|
+
updatedBy: author,
|
|
343
|
+
}
|
|
344
|
+
if (n !== undefined) sub.note = n
|
|
345
|
+
// 改造09:订阅自带准入配置(缺省 observe —— 最保守的一档)
|
|
346
|
+
const adm = normalizeAdmission(input)
|
|
347
|
+
sub.acceptMode = adm.acceptMode
|
|
348
|
+
if (adm.rules !== undefined) sub.rules = adm.rules
|
|
349
|
+
if (adm.receivesEveryMessage !== undefined) sub.receivesEveryMessage = adm.receivesEveryMessage
|
|
350
|
+
commit('subscribe', at, author, null, sub)
|
|
351
|
+
info(`[cs-bots/subscription] ${id}:任务 ${tId} 订阅房间 ${rId}`)
|
|
352
|
+
return clone(sub)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 取消订阅(活 → dropped,终态)。
|
|
357
|
+
*
|
|
358
|
+
* @param {{ id: string, expectedRev?: number, by: string, now?: number }} input
|
|
359
|
+
* @returns {object} Subscription
|
|
360
|
+
*/
|
|
361
|
+
function unsubscribe(input = {}) {
|
|
362
|
+
const { id, expectedRev, by, now: at0 } = input ?? {}
|
|
363
|
+
const author = requireString(by, 'by', 'unsubscribe')
|
|
364
|
+
const at = normalizeNow(at0, 'unsubscribe')
|
|
365
|
+
const cur = cas(id, expectedRev, 'unsubscribe')
|
|
366
|
+
if (cur.state === 'dropped') {
|
|
367
|
+
throw new SubscriptionError(
|
|
368
|
+
`subscription.unsubscribe:订阅 ${id} 已是 dropped(终态),无法再次取消`,
|
|
369
|
+
'SUBSCRIPTION_ALREADY_DROPPED', { id },
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
const next = clone(cur)
|
|
373
|
+
next.rev = cur.rev + 1
|
|
374
|
+
next.state = 'dropped'
|
|
375
|
+
next.droppedAt = at
|
|
376
|
+
next.droppedBy = author
|
|
377
|
+
next.updatedAt = at
|
|
378
|
+
next.updatedBy = author
|
|
379
|
+
commit('unsubscribe', at, author, clone(cur), next)
|
|
380
|
+
return clone(next)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 扇出:把房间的一条消息送达所有订阅该房间的活任务。
|
|
385
|
+
*
|
|
386
|
+
* **只加上下文,不改任务状态**(与 Super-agent「Subscription 只加 context 不加 authority」一致)。
|
|
387
|
+
* 每条订阅的 `context` 是滚动窗口(默认保留最近 20 条)。
|
|
388
|
+
*
|
|
389
|
+
* @param {{ roomId: string, message: object, wake?: boolean, by?: string, now?: number }} input
|
|
390
|
+
* @returns {{ fanned: number, woke: number, failed: number, details: object[] }}
|
|
391
|
+
*/
|
|
392
|
+
async function fanout(input = {}) {
|
|
393
|
+
const { roomId, message, wake } = input ?? {}
|
|
394
|
+
const rId = requireString(roomId, 'roomId', 'fanout')
|
|
395
|
+
if (!message || typeof message !== 'object') {
|
|
396
|
+
throw new SubscriptionError(
|
|
397
|
+
'subscription.fanout:message 必须是消息对象',
|
|
398
|
+
'SUBSCRIPTION_BAD_FIELD', { field: 'message' },
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
const at = normalizeNow(input.now, 'fanout')
|
|
402
|
+
const by = typeof input.by === 'string' && input.by ? input.by : 'system'
|
|
403
|
+
const shouldWake = wake === true || (wake === undefined && wakeOnFanout)
|
|
404
|
+
|
|
405
|
+
// 找出订阅该房间的全部活订阅
|
|
406
|
+
const targets = [...subs.values()].filter((s) => s.roomId === rId && s.state === 'active')
|
|
407
|
+
const details = []
|
|
408
|
+
let woke = 0
|
|
409
|
+
let failed = 0
|
|
410
|
+
|
|
411
|
+
for (const s of targets) {
|
|
412
|
+
// ── 改造09:Gate 判定(确定性、可审计)────────────────────────────
|
|
413
|
+
// superagent 把准入拆成 Gate + Assessment:Gate 判「是不是候选 / 明确请求」,
|
|
414
|
+
// 只有放行的才进上下文并唤醒。忽略掉的连上下文都不记(省 token、省打扰)。
|
|
415
|
+
const gate = decideAdmission({
|
|
416
|
+
message,
|
|
417
|
+
recipientId: s.taskId,
|
|
418
|
+
subscribed: s.state === 'active',
|
|
419
|
+
subscriptionState: s.state,
|
|
420
|
+
acceptMode: s.acceptMode,
|
|
421
|
+
rules: s.rules,
|
|
422
|
+
receivesEveryMessage: s.receivesEveryMessage !== false,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
if (gate.admission === ADMISSIONS.IGNORE) {
|
|
426
|
+
details.push({
|
|
427
|
+
subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: false,
|
|
428
|
+
admission: gate.admission, decidedBy: gate.decidedBy, reason: gate.reason,
|
|
429
|
+
})
|
|
430
|
+
continue // 不记上下文 —— 不相关的消息不该占用执行者的上下文窗口
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const entry = {
|
|
434
|
+
from: message.from ?? '?',
|
|
435
|
+
seq: message.seq ?? null,
|
|
436
|
+
text: typeof message.text === 'string' ? message.text.slice(0, 2000) : '',
|
|
437
|
+
at: message.at ?? at,
|
|
438
|
+
messageId: message.id ?? null,
|
|
439
|
+
admission: gate.admission, // 记下它是 explicit 还是 inferred(审计 / 评估用)
|
|
440
|
+
}
|
|
441
|
+
const next = clone(s)
|
|
442
|
+
next.rev = s.rev + 1
|
|
443
|
+
const ctx = Array.isArray(s.context) ? [...s.context, entry] : [entry]
|
|
444
|
+
// 滚动窗口:只留最近 fanoutKeep 条
|
|
445
|
+
next.context = ctx.length > fanoutKeep ? ctx.slice(-fanoutKeep) : ctx
|
|
446
|
+
next.fanoutCount = (s.fanoutCount ?? 0) + 1
|
|
447
|
+
next.lastFanoutAt = at
|
|
448
|
+
next.updatedAt = at
|
|
449
|
+
next.updatedBy = by
|
|
450
|
+
try {
|
|
451
|
+
commit('fanout', at, by, clone(s), next)
|
|
452
|
+
diagnostics.fanouts += 1
|
|
453
|
+
} catch (error) {
|
|
454
|
+
failed += 1
|
|
455
|
+
details.push({ subId: s.id, taskId: s.taskId, ok: false, reason: `commit-failed:${error?.message ?? error}` })
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 唤醒 = 「全局开关允许」(shouldWake) AND 「这条消息是明确要你」(explicit)。
|
|
460
|
+
// 二者是 AND 而非替代:一个说「这次允许唤醒」,一个说「这条值得唤醒」。
|
|
461
|
+
// 候选(inferred)只记上下文,不打扰 —— 是否动手交给执行者评估。
|
|
462
|
+
if (shouldWake && gate.admission === ADMISSIONS.EXPLICIT) {
|
|
463
|
+
try {
|
|
464
|
+
const text = `[任务上下文] 房间 ${rId} 新消息(${entry.from},${gate.admission}):${entry.text.slice(0, 500)}`
|
|
465
|
+
const wakeResult = await wakeTask(s.taskId, text)
|
|
466
|
+
if (wakeResult.ok) woke += 1
|
|
467
|
+
else { failed += 1; diagnostics.wakeFailures += 1 }
|
|
468
|
+
details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: wakeResult.ok, admitted: true, admission: gate.admission, wakeReason: wakeResult.reason })
|
|
469
|
+
} catch (error) {
|
|
470
|
+
failed += 1
|
|
471
|
+
diagnostics.wakeFailures += 1
|
|
472
|
+
details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: true, admission: gate.admission, reason: `wake-failed:${error?.message ?? error}` })
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
details.push({ subId: s.id, taskId: s.taskId, ok: true, woke: false, admitted: true, admission: gate.admission, decidedBy: gate.decidedBy })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { fanned: targets.length, woke, failed, details }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 唤醒某任务的 owner(未提供 dispatcher 时返回明确原因,不假装成功)。 */
|
|
483
|
+
async function wakeTask(taskId, text) {
|
|
484
|
+
if (!kernel) return { ok: false, reason: `kernel-unavailable:${kernelError?.message ?? 'unknown'}` }
|
|
485
|
+
const target = ctx?.services?.board
|
|
486
|
+
? (() => {
|
|
487
|
+
try {
|
|
488
|
+
const t = ctx.services.board.get(taskId)
|
|
489
|
+
return t?.ownerId
|
|
490
|
+
} catch { return undefined }
|
|
491
|
+
})()
|
|
492
|
+
: undefined
|
|
493
|
+
if (!target) return { ok: false, reason: 'task-has-no-owner' }
|
|
494
|
+
try {
|
|
495
|
+
const out = kernel.followup(ctx, target, text)
|
|
496
|
+
return { ok: Boolean(out?.ok), reason: out?.reason }
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return { ok: false, reason: String(error?.message ?? error) }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── 读 ────────────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
function get(id) {
|
|
505
|
+
const s = subs.get(id)
|
|
506
|
+
return s ? clone(s) : null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function list(query = {}) {
|
|
510
|
+
let rows = [...subs.values()]
|
|
511
|
+
if (query?.taskId) rows = rows.filter((s) => s.taskId === query.taskId)
|
|
512
|
+
if (query?.roomId) rows = rows.filter((s) => s.roomId === query.roomId)
|
|
513
|
+
if (query?.state) rows = rows.filter((s) => s.state === query.state)
|
|
514
|
+
return clone(rows)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 某任务的全部活订阅(任务详情页显示「订阅了什么」)。 */
|
|
518
|
+
function listByTask(taskId) {
|
|
519
|
+
return clone([...subs.values()].filter((s) => s.taskId === taskId))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 某房间的订阅者任务 id 列表(房间页显示「谁在听」)。 */
|
|
523
|
+
function subscribersOf(roomId) {
|
|
524
|
+
return [...subs.values()]
|
|
525
|
+
.filter((s) => s.roomId === roomId && s.state === 'active')
|
|
526
|
+
.map((s) => s.taskId)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function stats() {
|
|
530
|
+
const byState = {}
|
|
531
|
+
for (const s of subs.values()) byState[s.state] = (byState[s.state] ?? 0) + 1
|
|
532
|
+
return {
|
|
533
|
+
total: subs.size,
|
|
534
|
+
active: byState.active ?? 0,
|
|
535
|
+
dropped: byState.dropped ?? 0,
|
|
536
|
+
byState,
|
|
537
|
+
fanouts: diagnostics.fanouts,
|
|
538
|
+
wakeFailures: diagnostics.wakeFailures,
|
|
539
|
+
events: events.length,
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function replay() {
|
|
544
|
+
hydrate()
|
|
545
|
+
return { from: diagnostics.hydratedFrom, events: events.length, bad: diagnostics.badLines }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
hydrate()
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
name: config.name ?? 'subscription',
|
|
552
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
553
|
+
subscribe,
|
|
554
|
+
unsubscribe,
|
|
555
|
+
fanout,
|
|
556
|
+
get,
|
|
557
|
+
list,
|
|
558
|
+
listByTask,
|
|
559
|
+
subscribersOf,
|
|
560
|
+
stats,
|
|
561
|
+
replay,
|
|
562
|
+
diagnostics: () => clone(diagnostics),
|
|
563
|
+
constants: Object.freeze({
|
|
564
|
+
SUB_STATES: Object.freeze([...SUB_STATES]),
|
|
565
|
+
ACTIONS: Object.freeze([...ACTIONS]),
|
|
566
|
+
DEFAULTS,
|
|
567
|
+
}),
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export default createSubscription
|