cs-bots 0.1.1 → 0.7.5
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 +539 -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/api.js
CHANGED
|
@@ -12,10 +12,18 @@
|
|
|
12
12
|
* - **前缀路由**:`/cs-bots/api`(与包名一致,避免和其他插件撞路径)。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { join, resolve, sep as pathSep } from 'node:path'
|
|
18
|
+
import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark } from './kernel.js'
|
|
16
19
|
import { createLibrary } from './library.js'
|
|
17
20
|
import { createDirBrowser } from './dirbrowse.js'
|
|
18
21
|
import { getServices } from './registry.js'
|
|
22
|
+
import { createFence } from './fence.js'
|
|
23
|
+
import { appendJsonl, readJsonl, statePath } from './store.js'
|
|
24
|
+
import { deriveStage, attachStages, stageSummary } from './stage.js'
|
|
25
|
+
import { CMD_WHITELIST } from './verify.js'
|
|
26
|
+
import { execFile } from 'node:child_process'
|
|
19
27
|
|
|
20
28
|
/** 请求体上限(防大包打爆内存)。 */
|
|
21
29
|
const MAX_BODY = 2 * 1024 * 1024
|
|
@@ -23,6 +31,49 @@ const MAX_BODY = 2 * 1024 * 1024
|
|
|
23
31
|
/** API 前缀。 */
|
|
24
32
|
export const API_BASE = '/cs-bots/api'
|
|
25
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 模块级告警出口。
|
|
36
|
+
*
|
|
37
|
+
* `ensureLive()` 是模块级函数,拿不到 `installApi(ctx, { warn })` 里的 warn,
|
|
38
|
+
* 但 preset 装配(`resumeSession` 内部)的降级告警必须能说出来 —— 所以在这里
|
|
39
|
+
* 挂一个可变出口,由 `installApi` 在装配时注入(缺省静默,绝不抛)。
|
|
40
|
+
*/
|
|
41
|
+
let moduleWarn = () => {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 页面操作者身份。
|
|
45
|
+
* 域服务用 `'user'` 表示「人类所有者 = 超级管理员」(见 room.js 的 USER 常量)。
|
|
46
|
+
* 之前 API 传的是 `'ui'`,导致房间设置页一改就 `ROOM_NOT_MEMBER` —— 人类反而没权限。
|
|
47
|
+
*/
|
|
48
|
+
const OWNER = 'user'
|
|
49
|
+
|
|
50
|
+
/** 纪律层级与分类(前端下拉直接用,避免两边写死不一致)。 */
|
|
51
|
+
const DISCIPLINE_SCOPES = ['global', 'workspace', 'room', 'bot']
|
|
52
|
+
const DISCIPLINE_KINDS = ['rule', 'requirement', 'principle', 'style']
|
|
53
|
+
|
|
54
|
+
/** PDCA 复盘阶段(前端展示用;与 cycle.js 的 CYCLE_PHASES 逐字一致)。 */
|
|
55
|
+
const CYCLE_PHASES = ['planned', 'doing', 'checking', 'acting', 'done']
|
|
56
|
+
/** 需求4:周期类型——human=人跟长任务,agent=智能体事后归纳。 */
|
|
57
|
+
const CYCLE_KINDS = ['human', 'agent']
|
|
58
|
+
|
|
59
|
+
/** 团队类型与角色(前端展示用;与 teams.js 的 TEAM_TYPES/TEAM_ROLES 逐字一致)。 */
|
|
60
|
+
const TEAM_TYPES = ['big', 'small']
|
|
61
|
+
const TEAM_ROLES = ['lead', 'member']
|
|
62
|
+
/** 团队管理策略(与 teams.js 的 TEAM_ADMIN_POLICIES 逐字一致;页面下拉直接用)。 */
|
|
63
|
+
const TEAM_ADMIN_POLICIES = ['lead-only', 'any-member', 'open']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 团队域错误 → HTTP 状态码。
|
|
67
|
+
* 只有「无权管理团队」升级成 403(语义正确,且与 /teams/memory 的 403 口径一致);
|
|
68
|
+
* 其余 TEAM_* 保持历史口径 400;非团队错误原样抛出(503/500 不受影响)。
|
|
69
|
+
*/
|
|
70
|
+
function markTeamError(error) {
|
|
71
|
+
if (error && typeof error.code === 'string' && error.code.startsWith('TEAM_')) {
|
|
72
|
+
error.status = error.code === 'TEAM_FORBIDDEN' ? 403 : 400
|
|
73
|
+
}
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
|
|
26
77
|
/** 读请求体(JSON)。 */
|
|
27
78
|
async function readBody(request) {
|
|
28
79
|
const chunks = []
|
|
@@ -77,6 +128,42 @@ async function withService(services, key, fn) {
|
|
|
77
128
|
return fn(svc)
|
|
78
129
|
}
|
|
79
130
|
|
|
131
|
+
/** 数据浏览:合法文件名/子目录(防目录穿越)。 */
|
|
132
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
133
|
+
|
|
134
|
+
/** 判断是否是安全的单段路径(无 `..`、无分隔符)。 */
|
|
135
|
+
function safeSegment(value) {
|
|
136
|
+
if (typeof value !== 'string' || value === '') return false
|
|
137
|
+
if (value === '.' || value === '..') return false
|
|
138
|
+
if (value.includes('..')) return false
|
|
139
|
+
return SAFE_SEGMENT.test(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 把 artifacts 归一成字符串数组(对象用单行 JSON)。 */
|
|
143
|
+
function normalizeArtifactItems(list) {
|
|
144
|
+
if (!Array.isArray(list)) return []
|
|
145
|
+
const out = []
|
|
146
|
+
for (const item of list) {
|
|
147
|
+
if (item === null || item === undefined) continue
|
|
148
|
+
if (typeof item === 'string') {
|
|
149
|
+
const t = item.trim()
|
|
150
|
+
if (t) out.push(t)
|
|
151
|
+
} else if (typeof item === 'object') {
|
|
152
|
+
try {
|
|
153
|
+
out.push(JSON.stringify(item))
|
|
154
|
+
} catch {
|
|
155
|
+
out.push(String(item))
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
out.push(String(item))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 任务状态集合(与 board.js STATUSES 同口径,删除态不参与)。 */
|
|
165
|
+
const GRAPH_STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
|
|
166
|
+
|
|
80
167
|
|
|
81
168
|
/**
|
|
82
169
|
* 把 Bot 档案里的「预制记忆」同步进记忆服务(带 botId)。
|
|
@@ -110,579 +197,4169 @@ function seedBotMemory(services, bot, by) {
|
|
|
110
197
|
}
|
|
111
198
|
|
|
112
199
|
/**
|
|
113
|
-
*
|
|
200
|
+
* 把一条建议**真正写进目标域**(建议确认制的"采纳"落地)。
|
|
114
201
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
202
|
+
* 为什么放在 api 层而不是 suggest.js:`suggest.js` 刻意不 import memory/library/discipline,
|
|
203
|
+
* 避免循环依赖与越界写入。真正的写库动作由调用方以回调注入 —— 就是这里。
|
|
204
|
+
*
|
|
205
|
+
* 任一步抛错都会向上抛:`suggest.accept` 收到异常后会把该条**保持 pending**
|
|
206
|
+
* 并记 `lastError`,绝不静默标记成"已采纳"。
|
|
207
|
+
*
|
|
208
|
+
* @param {object} services
|
|
209
|
+
* @param {{ kind: string, layer?: string, payload?: object }} suggestion
|
|
210
|
+
* @param {string} by
|
|
118
211
|
*/
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
212
|
+
function applySuggestion(services, suggestion, by) {
|
|
213
|
+
const kind = suggestion?.kind
|
|
214
|
+
const payload = suggestion?.payload ?? {}
|
|
215
|
+
|
|
216
|
+
// ⚠️ 必须是**同步**函数:suggest.accept 的 apply 回调若返回 Promise,
|
|
217
|
+
// 它会在真正落库前就把状态标成 accepted(假采纳)。而这里要写的三个域服务
|
|
218
|
+
// (memory / library / discipline)全部是同步内存写 + 同步落盘,所以可以同步完成。
|
|
219
|
+
const need = (key) => {
|
|
220
|
+
const svc = services?.[key]
|
|
221
|
+
if (!svc) {
|
|
222
|
+
const error = new Error(`${key} 服务未装配,无法落地这条建议`)
|
|
223
|
+
error.code = `${String(key).toUpperCase()}_UNAVAILABLE`
|
|
224
|
+
throw error
|
|
128
225
|
}
|
|
129
|
-
|
|
130
|
-
|
|
226
|
+
return svc
|
|
227
|
+
}
|
|
131
228
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
229
|
+
if (kind === 'memory') {
|
|
230
|
+
const entry = need('memory').add({
|
|
231
|
+
layer: suggestion.layer ?? payload.layer ?? 'L4',
|
|
232
|
+
content: payload.content,
|
|
233
|
+
tags: payload.tags,
|
|
234
|
+
by,
|
|
235
|
+
cwd: payload.cwd,
|
|
236
|
+
})
|
|
237
|
+
return { kind, id: Array.isArray(entry) ? entry[0]?.id : entry?.id }
|
|
238
|
+
}
|
|
239
|
+
if (kind === 'skill') {
|
|
240
|
+
const skill = need('library').createSkill({
|
|
241
|
+
name: payload.name,
|
|
242
|
+
description: payload.description,
|
|
243
|
+
body: payload.body,
|
|
244
|
+
tags: payload.tags,
|
|
245
|
+
by,
|
|
246
|
+
})
|
|
247
|
+
return { kind, id: skill?.id }
|
|
248
|
+
}
|
|
249
|
+
if (kind === 'discipline') {
|
|
250
|
+
const item = need('discipline').createDiscipline({
|
|
251
|
+
scope: payload.scope,
|
|
252
|
+
scopeId: payload.scopeId,
|
|
253
|
+
kind: payload.disciplineKind ?? payload.kind ?? 'rule',
|
|
254
|
+
title: payload.title,
|
|
255
|
+
content: payload.content,
|
|
256
|
+
priority: payload.priority,
|
|
257
|
+
by,
|
|
258
|
+
})
|
|
259
|
+
return { kind, id: item?.id }
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`applySuggestion:未知建议类型 ${JSON.stringify(kind)}(拒绝静默忽略)`)
|
|
262
|
+
}
|
|
149
263
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
sendJson(response, 200, { ok: true, tasks })
|
|
158
|
-
return
|
|
159
|
-
}
|
|
264
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
265
|
+
// bots改造04:任务目标锚(objective anchor)与直发消息
|
|
266
|
+
//
|
|
267
|
+
// 设计依据:`design/CONTRACT-bots04.md` §2(**已冻结**,字段名/文案格式/状态码逐字照做)。
|
|
268
|
+
// 为什么需要:长任务几十轮后容易「目标漂移」。每次唤醒 Bot 都带**全量目标原文**,
|
|
269
|
+
// 补充说明只追加(task-notes.jsonl,append-only)、永不覆盖目标,从机制上防漂移。
|
|
270
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
160
271
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
168
|
-
}
|
|
272
|
+
/** 目标锚文案的固定标题(契约 §2.1 原文,逐字不可改)。 */
|
|
273
|
+
const ANCHOR_TITLE = '【任务目标 · 原文锚定,禁止改写】'
|
|
274
|
+
/** 补充说明落盘文件(新建;append-only,一行一条)。 */
|
|
275
|
+
const TASK_NOTES_FILE = 'task-notes.jsonl'
|
|
276
|
+
/** 补充说明 / 直发消息正文长度上限(契约 §2.2 / §2.4:trim 后 1–4000)。 */
|
|
277
|
+
const ANCHOR_TEXT_MAX = 4000
|
|
169
278
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
sendJson(response, 200, { ok: true, task })
|
|
176
|
-
return
|
|
177
|
-
}
|
|
279
|
+
/** 取状态目录(board 优先,roster 兜底;都没有返回 undefined)。 */
|
|
280
|
+
function anchorStateDir(services) {
|
|
281
|
+
return services?.board?.paths?.stateDir ?? services?.roster?.paths?.stateDir
|
|
282
|
+
}
|
|
178
283
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
sendJson(response, 200, { ok: true, task })
|
|
185
|
-
return
|
|
186
|
-
}
|
|
284
|
+
/** 补充说明文件路径(状态目录缺失时 undefined)。 */
|
|
285
|
+
function taskNotesPath(services) {
|
|
286
|
+
const dir = anchorStateDir(services)
|
|
287
|
+
return dir ? statePath(dir, TASK_NOTES_FILE) : undefined
|
|
288
|
+
}
|
|
187
289
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
presets: presets.map((p) => ({
|
|
202
|
-
id: p.id,
|
|
203
|
-
name: p.name ?? p.id,
|
|
204
|
-
description: p.description,
|
|
205
|
-
order: p.order,
|
|
206
|
-
broken: p.broken,
|
|
207
|
-
})),
|
|
208
|
-
defaultId,
|
|
209
|
-
note: presets.length ? undefined : '宿主未提供 agentPresets 服务(Bot 将使用默认预设)',
|
|
210
|
-
})
|
|
211
|
-
return
|
|
212
|
-
}
|
|
290
|
+
/**
|
|
291
|
+
* 把时间戳格式化成人类可读的本地时间(`YYYY-MM-DD HH:mm:ss`)。
|
|
292
|
+
* 契约只写 `<时间>` 占位符,未冻结具体格式;这里选无 locale 依赖的固定格式,
|
|
293
|
+
* 保证跨环境稳定、可被测试断言。
|
|
294
|
+
*/
|
|
295
|
+
function formatAnchorTime(at) {
|
|
296
|
+
const n = Number(at)
|
|
297
|
+
if (!Number.isFinite(n)) return '未知时间'
|
|
298
|
+
const d = new Date(n)
|
|
299
|
+
const pad = (x) => String(x).padStart(2, '0')
|
|
300
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
|
301
|
+
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
302
|
+
}
|
|
213
303
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
...(includeRetired ? r.listRetired() : []),
|
|
220
|
-
])
|
|
221
|
-
sendJson(response, 200, { ok: true, bots })
|
|
222
|
-
return
|
|
223
|
-
}
|
|
304
|
+
/** 逗号拼接;空数组返回契约规定的占位文案。 */
|
|
305
|
+
function joinAnchorList(list, emptyText) {
|
|
306
|
+
const items = Array.isArray(list) ? list.filter((x) => x !== null && x !== undefined && String(x).trim() !== '').map(String) : []
|
|
307
|
+
return items.length ? items.join(',') : emptyText
|
|
308
|
+
}
|
|
224
309
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
...(sharedWith.length
|
|
247
|
-
? {
|
|
248
|
-
warnings: [
|
|
249
|
-
`目录 ${bot.cwd} 已被 ${sharedWith.map((b) => b.name).join('、')} 使用 —— 两个 Bot 会互相覆盖文件`,
|
|
250
|
-
],
|
|
251
|
-
sharedWith,
|
|
252
|
-
}
|
|
253
|
-
: {}),
|
|
254
|
-
})
|
|
255
|
-
return
|
|
256
|
-
}
|
|
310
|
+
/**
|
|
311
|
+
* 从任务快照里抽出「目标锚」(契约 §2.1 的 `anchor` 结构)。
|
|
312
|
+
* 字段恒定出现(前端直接渲染,不判 undefined);unknown 值给安全默认。
|
|
313
|
+
* @param {object} task
|
|
314
|
+
*/
|
|
315
|
+
function buildTaskAnchor(task) {
|
|
316
|
+
return {
|
|
317
|
+
subject: task?.subject ?? '',
|
|
318
|
+
description: task?.description ?? '',
|
|
319
|
+
writeScopes: Array.isArray(task?.writeScopes) ? [...task.writeScopes] : [],
|
|
320
|
+
blockedBy: Array.isArray(task?.blockedBy) ? [...task.blockedBy] : [],
|
|
321
|
+
priority: task?.priority ?? 0,
|
|
322
|
+
status: task?.status,
|
|
323
|
+
ownerId: task?.ownerId ?? null,
|
|
324
|
+
progress: task?.progress ?? 0,
|
|
325
|
+
artifacts: normalizeArtifactItems(task?.artifacts),
|
|
326
|
+
createdAt: task?.createdAt,
|
|
327
|
+
// 授权留痕(改造08):随锚定文本一起下发给执行者
|
|
328
|
+
mandate: task?.mandate ?? null,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
257
331
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
bot: detail.bot,
|
|
269
|
-
profile: detail.profile ?? {},
|
|
270
|
-
profilePath: detail.profilePath,
|
|
271
|
-
managedText: detail.managedText,
|
|
272
|
-
hasOutside: detail.hasOutside,
|
|
273
|
-
profileError: detail.profileError,
|
|
274
|
-
})
|
|
275
|
-
return
|
|
276
|
-
}
|
|
332
|
+
/** 读某任务的全部补充说明(按 `at` 升序;坏行由 readJsonl 计入 bad,不静默崩)。 */
|
|
333
|
+
function readTaskNotes(services, taskId) {
|
|
334
|
+
const file = taskNotesPath(services)
|
|
335
|
+
if (!file) return []
|
|
336
|
+
const { rows } = readJsonl(file)
|
|
337
|
+
return rows
|
|
338
|
+
.filter((row) => row && row.taskId === taskId)
|
|
339
|
+
.map((row) => ({ id: row.id, text: row.text, by: row.by ?? 'ui', at: row.at }))
|
|
340
|
+
.sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0))
|
|
341
|
+
}
|
|
277
342
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
291
|
-
}
|
|
343
|
+
/** 生成下一个补充说明编号 `n-<seq>`(append-only 文件内全局递增,绝不复用)。 */
|
|
344
|
+
function nextTaskNoteId(rows) {
|
|
345
|
+
let max = 0
|
|
346
|
+
for (const row of rows) {
|
|
347
|
+
const m = /^n-(\d+)$/.exec(String(row?.id ?? ''))
|
|
348
|
+
if (!m) continue
|
|
349
|
+
const n = Number(m[1])
|
|
350
|
+
if (Number.isFinite(n) && n > max) max = n
|
|
351
|
+
}
|
|
352
|
+
// 契约允许「已读行数+1」;同时取两者最大值,防止删行/坏行导致编号重复。
|
|
353
|
+
return `n-${Math.max(max + 1, rows.length + 1)}`
|
|
354
|
+
}
|
|
292
355
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
case 'setCwd':
|
|
303
|
-
return r.setCwd({ id: body.id, cwd: body.cwd, expectedRev: body.expectedRev, byId })
|
|
304
|
-
case 'claim':
|
|
305
|
-
return r.claim({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
306
|
-
case 'release':
|
|
307
|
-
return r.release({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
308
|
-
case 'heartbeat':
|
|
309
|
-
return r.heartbeat({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
310
|
-
default:
|
|
311
|
-
throw new Error(`未知 action: ${body.action}`)
|
|
312
|
-
}
|
|
313
|
-
})
|
|
314
|
-
sendJson(response, 200, { ok: true, bot: result })
|
|
315
|
-
return
|
|
316
|
-
}
|
|
356
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
357
|
+
// bots改造06:任务变更中心(重开纠错 / 派生优化单 / 转长任务 / 换负责人)
|
|
358
|
+
//
|
|
359
|
+
// 设计依据:`design/CONTRACT-bots06.md` §2(**已冻结**)。
|
|
360
|
+
// 根因:`board.reopen` 会**清空** `artifacts` / `acceptance` / `completedAt`
|
|
361
|
+
// (内核行为,board.js 冻结不能改),所以变更**前**必须先把「当时的样子」快照进
|
|
362
|
+
// `task-meta.jsonl`(append-only)—— 这是「纠错不丢证据」的唯一保障,同时充当
|
|
363
|
+
// 变更历史与长任务标记。
|
|
364
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
317
365
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
366
|
+
/** 任务变更元数据文件(新建;append-only,一行一条)。 */
|
|
367
|
+
const TASK_META_FILE = 'task-meta.jsonl'
|
|
368
|
+
/** 变更说明长度上限(契约 §2.2:trim 后 1–4000)。 */
|
|
369
|
+
const EVOLVE_NOTE_MAX = ANCHOR_TEXT_MAX
|
|
370
|
+
/** evolve 支持的三种变更模式(契约 §2.2)。 */
|
|
371
|
+
const EVOLVE_MODES = ['reopen', 'followup', 'longrun']
|
|
372
|
+
/** 长任务阶段数上限 / 单项长度上限(契约 §2.2 longrun)。 */
|
|
373
|
+
const LONGRUN_PHASES_MAX = 12
|
|
374
|
+
const LONGRUN_PHASE_MAX = 200
|
|
375
|
+
/** 长任务未给 phases 时的默认三阶段(契约 §2.2 longrun)。 */
|
|
376
|
+
const LONGRUN_DEFAULT_PHASES = ['范围与验收标准确认', '执行与自测', '交付与复盘']
|
|
324
377
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
sendJson(response, 200, { ok: true, room })
|
|
331
|
-
return
|
|
332
|
-
}
|
|
378
|
+
/** task-meta.jsonl 路径(状态目录缺失时 undefined)。 */
|
|
379
|
+
function taskMetaPath(services) {
|
|
380
|
+
const dir = anchorStateDir(services)
|
|
381
|
+
return dir ? statePath(dir, TASK_META_FILE) : undefined
|
|
382
|
+
}
|
|
333
383
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
384
|
+
/**
|
|
385
|
+
* 读某任务的全部变更记录(按 `at` 升序;同一毫秒按落盘顺序稳定排序)。
|
|
386
|
+
* 坏行由 readJsonl 计入 bad,不静默崩。
|
|
387
|
+
* @param {object} services
|
|
388
|
+
* @param {string} taskId
|
|
389
|
+
* @returns {object[]}
|
|
390
|
+
*/
|
|
391
|
+
function readTaskMeta(services, taskId) {
|
|
392
|
+
const file = taskMetaPath(services)
|
|
393
|
+
if (!file) return []
|
|
394
|
+
const { rows } = readJsonl(file)
|
|
395
|
+
return rows
|
|
396
|
+
.map((row, index) => ({ row, index }))
|
|
397
|
+
.filter(({ row }) => row && typeof row === 'object' && row.taskId === taskId)
|
|
398
|
+
.sort((a, b) => (Number(a.row.at) || 0) - (Number(b.row.at) || 0) || a.index - b.index)
|
|
399
|
+
.map(({ row }) => row)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 追加一条变更记录。
|
|
404
|
+
* 落盘失败**必须抛 503**:静默丢历史 = 丢证据,是本平台最忌讳的失败方式。
|
|
405
|
+
* @param {object} services
|
|
406
|
+
* @param {object} record
|
|
407
|
+
*/
|
|
408
|
+
function appendTaskMeta(services, record) {
|
|
409
|
+
const file = taskMetaPath(services)
|
|
410
|
+
if (!file) {
|
|
411
|
+
const error = new Error('状态目录不可用(task-meta.jsonl 无法落盘)')
|
|
412
|
+
error.status = 503
|
|
413
|
+
throw error
|
|
414
|
+
}
|
|
415
|
+
if (!appendJsonl(file, record)) {
|
|
416
|
+
const error = new Error(`变更记录落盘失败(${file})`)
|
|
417
|
+
error.status = 503
|
|
418
|
+
throw error
|
|
419
|
+
}
|
|
420
|
+
return record
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** 取某任务最近一条 `kind:'longrun'` 记录(没有则 undefined)。 */
|
|
424
|
+
function readLongrunMeta(services, taskId) {
|
|
425
|
+
const rows = readTaskMeta(services, taskId).filter(
|
|
426
|
+
(row) => row.kind === 'longrun' && Array.isArray(row.phases),
|
|
427
|
+
)
|
|
428
|
+
return rows.length ? rows[rows.length - 1] : undefined
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 把 longrun 记录 / phases 数组归一成非空阶段清单。 */
|
|
432
|
+
function longrunPhaseList(longrun) {
|
|
433
|
+
const list = Array.isArray(longrun)
|
|
434
|
+
? longrun
|
|
435
|
+
: (Array.isArray(longrun?.phases) ? longrun.phases : [])
|
|
436
|
+
return list
|
|
437
|
+
.filter((phase) => typeof phase === 'string' && phase.trim() !== '')
|
|
438
|
+
.map((phase) => phase.trim())
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 归一化长任务阶段清单(**创建与 evolve 共用的单一真相**)。
|
|
443
|
+
*
|
|
444
|
+
* 为什么抽出来:创建时长任务和「事后转长任务」是同一份数据结构,
|
|
445
|
+
* 校验规则(上限 12 项 / 单项 200 字 / 不许空白)必须只有一处 ——
|
|
446
|
+
* 两处各写一遍,早晚会漂移成「创建的能过、转的不能过」这种鬼故事。
|
|
447
|
+
*
|
|
448
|
+
* @param {unknown} raw phases 数组(undefined/null/[] ⇒ 默认三阶段)
|
|
449
|
+
* @param {string} code 报错时用的错误码(区分调用方)
|
|
450
|
+
* @returns {string[]}
|
|
451
|
+
*/
|
|
452
|
+
function normalizePhases(raw, code = 'EVOLVE_BAD_PHASES') {
|
|
453
|
+
const failPhases = (message) => {
|
|
454
|
+
const error = new Error(message)
|
|
455
|
+
error.status = 400
|
|
456
|
+
error.code = code
|
|
457
|
+
return error
|
|
458
|
+
}
|
|
459
|
+
if (raw === undefined || raw === null || (Array.isArray(raw) && raw.length === 0)) {
|
|
460
|
+
return [...LONGRUN_DEFAULT_PHASES]
|
|
461
|
+
}
|
|
462
|
+
if (!Array.isArray(raw)) throw failPhases('phases 必须是字符串数组')
|
|
463
|
+
if (raw.length > LONGRUN_PHASES_MAX) {
|
|
464
|
+
throw failPhases(`phases 最多 ${LONGRUN_PHASES_MAX} 项(收到 ${raw.length} 项)`)
|
|
465
|
+
}
|
|
466
|
+
return raw.map((phase, index) => {
|
|
467
|
+
if (typeof phase !== 'string') throw failPhases(`phases[${index}] 必须是字符串(收到 ${JSON.stringify(phase)})`)
|
|
468
|
+
const text = phase.trim()
|
|
469
|
+
if (!text) throw failPhases(`phases[${index}] 不能为空`)
|
|
470
|
+
if (text.length > LONGRUN_PHASE_MAX) {
|
|
471
|
+
throw failPhases(`phases[${index}] 过长(最多 ${LONGRUN_PHASE_MAX} 字,收到 ${text.length} 字)`)
|
|
472
|
+
}
|
|
473
|
+
return text
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 变更**前**的任务快照(契约 §2.2 reopen 步骤 1)。 */
|
|
478
|
+
function buildTaskSnapshot(task) {
|
|
479
|
+
return {
|
|
480
|
+
kind: 'snapshot',
|
|
481
|
+
taskId: task.id,
|
|
482
|
+
at: Date.now(),
|
|
483
|
+
before: {
|
|
484
|
+
status: task.status,
|
|
485
|
+
artifacts: Array.isArray(task.artifacts) ? task.artifacts : [],
|
|
486
|
+
acceptance: task.acceptance === undefined ? null : task.acceptance,
|
|
487
|
+
completedAt: task.completedAt ?? null,
|
|
488
|
+
progress: task.progress ?? 0,
|
|
489
|
+
ownerId: task.ownerId ?? null,
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 追加一条补充说明(append-only;长任务说明与 /tasks/note 共用同一份文件与编号规则)。 */
|
|
495
|
+
function appendTaskNoteRow(services, taskId, text, by) {
|
|
496
|
+
const file = taskNotesPath(services)
|
|
497
|
+
if (!file) {
|
|
498
|
+
const error = new Error('状态目录不可用(task-notes.jsonl 无法落盘)')
|
|
499
|
+
error.status = 503
|
|
500
|
+
throw error
|
|
501
|
+
}
|
|
502
|
+
const { rows } = readJsonl(file)
|
|
503
|
+
const note = { id: nextTaskNoteId(rows), taskId, text, by, at: Date.now() }
|
|
504
|
+
if (!appendJsonl(file, note)) {
|
|
505
|
+
const error = new Error(`补充说明落盘失败(${file})`)
|
|
506
|
+
error.status = 503
|
|
507
|
+
throw error
|
|
508
|
+
}
|
|
509
|
+
return note
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 按契约 §2.2 的 reopen 流程:**先快照** → 再调内核 reopen → 记一条 reopen。
|
|
514
|
+
*
|
|
515
|
+
* 已 pending 且无租约时内核抛 `BOARD_NOTHING_TO_REOPEN` —— 契约明确「视为成功」,
|
|
516
|
+
* 不能把用户点「重开」当成报错(任务本来就是可做的)。
|
|
517
|
+
*/
|
|
518
|
+
async function reopenWithSnapshot(services, task, by, note) {
|
|
519
|
+
const snapshot = appendTaskMeta(services, buildTaskSnapshot(task))
|
|
520
|
+
let after = task
|
|
521
|
+
try {
|
|
522
|
+
after = await withService(services, 'board', (b) => b.update({
|
|
523
|
+
id: task.id,
|
|
524
|
+
expectedRev: task.rev,
|
|
525
|
+
action: 'reopen',
|
|
526
|
+
byId: by,
|
|
527
|
+
}))
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error?.code !== 'BOARD_NOTHING_TO_REOPEN') throw error
|
|
530
|
+
after = (await withService(services, 'board', (b) => b.get(task.id))) ?? task
|
|
531
|
+
}
|
|
532
|
+
const change = appendTaskMeta(services, {
|
|
533
|
+
kind: 'reopen', taskId: task.id, note, by, at: Date.now(),
|
|
534
|
+
})
|
|
535
|
+
return { task: after, snapshot, change }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 派生任务标题前缀(契约 §2.2 followup:修复 > 优化 > 变更,先命中先算)。 */
|
|
539
|
+
function evolvePrefixFor(note) {
|
|
540
|
+
if (/错|缺陷|bug|修复/i.test(note)) return '【修复】'
|
|
541
|
+
if (/优化|提升|改进/.test(note)) return '【优化】'
|
|
542
|
+
return '【变更】'
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 生成契约 §2.1 的固定锚定文案(**前端只展示/复制,不得改写**)。
|
|
547
|
+
*
|
|
548
|
+
* 固定格式(逐行):
|
|
549
|
+
* ```
|
|
550
|
+
* 【任务目标 · 原文锚定,禁止改写】
|
|
551
|
+
* 任务 <id>:<subject>
|
|
552
|
+
* 目标:<description 原文;为空则(未填写)>
|
|
553
|
+
* 边界:只改 <writeScopes 逗号拼接;为空则(未限定)>
|
|
554
|
+
* 前置:<blockedBy 逗号拼接;为空则(无)>
|
|
555
|
+
* 进度:<progress>%
|
|
556
|
+
* 已交付:<artifacts 逗号拼接;为空则(暂无)>
|
|
557
|
+
* 补充说明(按时间,全部有效,不得忽略):
|
|
558
|
+
* - [<时间>] <text>
|
|
559
|
+
* ```
|
|
560
|
+
*
|
|
561
|
+
* 改造06 追加(契约 bots06 §2.4,**只加不改**):该任务在 `task-meta.jsonl` 里有
|
|
562
|
+
* `kind:'longrun'` 记录时,在「补充说明」**之前**插入阶段清单:
|
|
563
|
+
* ```
|
|
564
|
+
* 阶段(长任务,按序推进,不得跳步):
|
|
565
|
+
* 1. <phase1>
|
|
566
|
+
* 2. <phase2>
|
|
567
|
+
* ```
|
|
568
|
+
* 没有 longrun 记录时输出**逐字不变**(既有测试对文案有逐行断言)。
|
|
569
|
+
*
|
|
570
|
+
* @param {object} task
|
|
571
|
+
* @param {{ at: number, text: string }[]} [notes]
|
|
572
|
+
* @param {{ phases?: string[] } | string[]} [longrun] 该任务的 longrun 记录(或阶段数组)
|
|
573
|
+
* @returns {string}
|
|
574
|
+
*/
|
|
575
|
+
/**
|
|
576
|
+
* 把 `task.mandate` 翻译成人话(改造08 · 借鉴 superagent Mandate)。
|
|
577
|
+
*
|
|
578
|
+
* 三条纪律:
|
|
579
|
+
* 1. **缺省不编造**:没有 mandate 就返回空数组,绝不说「用户已授权」;
|
|
580
|
+
* 2. **依据要写清楚**:`explicit`(人明确点名)和 `inferred`(按规则推断)
|
|
581
|
+
* 对人的意义完全不同,必须能分辨;
|
|
582
|
+
* 3. **不承诺越权**:明说「超出范围要重新要授权」——
|
|
583
|
+
* mandate 是「此刻已被要求什么」,不是「随便干」。
|
|
584
|
+
*
|
|
585
|
+
* @param {object|undefined} mandate
|
|
586
|
+
* @returns {string[]}
|
|
587
|
+
*/
|
|
588
|
+
function describeMandate(mandate) {
|
|
589
|
+
if (!mandate || typeof mandate !== 'object') return []
|
|
590
|
+
const lines = []
|
|
591
|
+
const sender = typeof mandate.sender === 'string' && mandate.sender.trim() ? mandate.sender.trim() : ''
|
|
592
|
+
const BASIS_TEXT = {
|
|
593
|
+
explicit: '人明确点名要做(最强 —— 可以直接动手)',
|
|
594
|
+
inferred: '按规则推断(**推断,动手前先确认**)',
|
|
595
|
+
'task-board': '任务板派发(管理面正式派活)',
|
|
596
|
+
room: '房间里的指令',
|
|
597
|
+
cycle: '周期复盘产物',
|
|
598
|
+
}
|
|
599
|
+
const basis = typeof mandate.basis === 'string' ? mandate.basis : ''
|
|
600
|
+
if (basis && BASIS_TEXT[basis]) {
|
|
601
|
+
lines.push(`- 依据:${BASIS_TEXT[basis]}`)
|
|
602
|
+
}
|
|
603
|
+
if (sender) lines.push(`- 下达者:${sender}`)
|
|
604
|
+
if (typeof mandate.policyVersion === 'string' && mandate.policyVersion) {
|
|
605
|
+
lines.push(`- 规则版本:${mandate.policyVersion}`)
|
|
606
|
+
}
|
|
607
|
+
if (typeof mandate.scope === 'string' && mandate.scope) {
|
|
608
|
+
lines.push(`- 范围:${mandate.scope}`)
|
|
609
|
+
}
|
|
610
|
+
return lines
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function buildTaskAnchorText(task, notes = [], longrun = null) {
|
|
614
|
+
const anchor = buildTaskAnchor(task)
|
|
615
|
+
const desc = typeof anchor.description === 'string' ? anchor.description : ''
|
|
616
|
+
const lines = [
|
|
617
|
+
ANCHOR_TITLE,
|
|
618
|
+
`任务 ${task?.id}:${anchor.subject}`,
|
|
619
|
+
`目标:${desc.trim() ? desc : '(未填写)'}`,
|
|
620
|
+
`边界:只改 ${joinAnchorList(anchor.writeScopes, '(未限定)')}`,
|
|
621
|
+
`前置:${joinAnchorList(anchor.blockedBy, '(无)')}`,
|
|
622
|
+
`进度:${anchor.progress}%`,
|
|
623
|
+
`已交付:${joinAnchorList(anchor.artifacts, '(暂无)')}`,
|
|
624
|
+
]
|
|
625
|
+
const phases = longrunPhaseList(longrun)
|
|
626
|
+
if (phases.length > 0) {
|
|
627
|
+
lines.push('阶段(长任务,按序推进,不得跳步):')
|
|
628
|
+
phases.forEach((phase, index) => lines.push(`${index + 1}. ${phase}`))
|
|
629
|
+
}
|
|
630
|
+
// ── 授权留痕(改造08 · 借鉴 superagent Mandate)────────────────────────
|
|
631
|
+
// 执行者被唤醒时必须知道:**这活是谁批的、凭什么**。
|
|
632
|
+
// 少了这一行,「我自己猜用户想要」和「用户明确要我干」看起来一模一样 ——
|
|
633
|
+
// 而那正是失控与越权的分界线。
|
|
634
|
+
//
|
|
635
|
+
// ⚠️ 但**没有 basis 就不渲染**:UI 随手建的任务记的是 `{ sender: 'ui' }`,
|
|
636
|
+
// 那是「谁建的」,不是「谁授权的」。把它渲染成「授权」等于编造授权 ——
|
|
637
|
+
// 正是这套纪律要防的。只有记录了明确依据(explicit/inferred/task-board…)
|
|
638
|
+
// 才说明这确实是一份授权。
|
|
639
|
+
const mandate = anchor.mandate
|
|
640
|
+
if (mandate && typeof mandate === 'object' && typeof mandate.basis === 'string' && mandate.basis) {
|
|
641
|
+
lines.push('授权(谁批准的、凭什么 —— 超出此授权范围的新需求要重新要授权):')
|
|
642
|
+
lines.push(...describeMandate(mandate))
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
lines.push('补充说明(按时间,全部有效,不得忽略):')
|
|
646
|
+
for (const note of notes) {
|
|
647
|
+
lines.push(`- [${formatAnchorTime(note?.at)}] ${note?.text ?? ''}`)
|
|
648
|
+
}
|
|
649
|
+
return lines.join('\n')
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
653
|
+
// 来源跳转(契约 bots04 §2.5,2026-11-07 补)
|
|
654
|
+
//
|
|
655
|
+
// `sourceSeq` 是任务的**幂等键**,顺带承载「这个任务是从哪儿来的」。
|
|
656
|
+
// 契约冻结的三类前缀:
|
|
657
|
+
// `room:<roomId>:<seq>` → 房间消息出处(可跳回房间定位那条消息)
|
|
658
|
+
// `todo:<id>` → 待办(room/jump 均为 null)
|
|
659
|
+
// `evolve:<id>:<seq>` → 变更派生(room/jump 均为 null)
|
|
660
|
+
// 判定顺序**按前缀逐字匹配**,不做模糊猜测:匹配不上就 `unparsed`,
|
|
661
|
+
// 把原始标记如实返回,由前端提示「来源已失效」。
|
|
662
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
663
|
+
|
|
664
|
+
/** 来源消息正文预览的截断长度(契约 §2.5:首行截 80 字,与 §3.4 建任务时取标题同口径)。 */
|
|
665
|
+
const SOURCE_PREVIEW_MAX = 80
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* 解析 `sourceSeq` 的前缀(契约 §2.5 的判定顺序)。
|
|
669
|
+
*
|
|
670
|
+
* 返回 `null` 表示「匹配不上任何已知前缀」(→ 后端判 `unparsed`)。
|
|
671
|
+
* 注意 `room:<roomId>:<seq>` 的三段切分是安全的:房间 id 恒为 `room-<uuid8>`
|
|
672
|
+
* (`src/room.js:1032`),**内部不含冒号**;测试里也固化这一点,防止将来
|
|
673
|
+
* 改 id 规则时被静默误判。
|
|
674
|
+
*
|
|
675
|
+
* @param {string} sourceSeq
|
|
676
|
+
* @returns {{ kind: 'room'|'todo'|'evolve', roomId?: string, seq?: number } | null}
|
|
677
|
+
*/
|
|
678
|
+
function parseSourceSeq(sourceSeq) {
|
|
679
|
+
if (typeof sourceSeq !== 'string' || sourceSeq === '') return null
|
|
680
|
+
const parts = sourceSeq.split(':')
|
|
681
|
+
if (parts.length === 3 && parts[0] === 'room' && parts[1] !== '' && parts[2] !== '') {
|
|
682
|
+
// 「seq 为正整数」的判定:字符串必须**全是数字**且数值 > 0。
|
|
683
|
+
// 用 `^\d+$` 而不是 `Number.isInteger(Number(...))`:后者会把 `"7.0"`、
|
|
684
|
+
// `"1e2"`、`"0x10"` 之类全收进来,语义含糊;前者只认十进制正整数字面量
|
|
685
|
+
// (`"07"` 仍是数值 7,允许),把格式残缺的形态留给 `unparsed` 如实上报。
|
|
686
|
+
if (/^\d+$/.test(parts[2]) && Number(parts[2]) > 0) {
|
|
687
|
+
return { kind: 'room', roomId: parts[1], seq: Number(parts[2]) }
|
|
688
|
+
}
|
|
689
|
+
return null
|
|
690
|
+
}
|
|
691
|
+
if (parts.length === 2 && parts[0] === 'todo' && parts[1] !== '') {
|
|
692
|
+
return { kind: 'todo' }
|
|
693
|
+
}
|
|
694
|
+
if (parts.length === 3 && parts[0] === 'evolve' && parts[1] !== '' && parts[2] !== '') {
|
|
695
|
+
return { kind: 'evolve' }
|
|
696
|
+
}
|
|
697
|
+
return null
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** 来源消息正文 → 预览(首行截 80 字;与 §3.4 建任务时取标题的口径一致)。 */
|
|
701
|
+
function sourcePreview(message) {
|
|
702
|
+
const body = typeof message?.body === 'string' ? message.body : message?.text ?? ''
|
|
703
|
+
const firstLine = body.split('\n').map((line) => line.trim()).find((line) => line !== '') ?? ''
|
|
704
|
+
return firstLine.slice(0, SOURCE_PREVIEW_MAX)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 组装来源响应的公共部分(契约 §2.5 的字段形状)。
|
|
709
|
+
*
|
|
710
|
+
* 所有 kind 都带:`source` / `sourceSeq` / `kind` / `room` / `jump` / `reason` / `hint`。
|
|
711
|
+
* `room`/`jump` 只在 `kind: 'room'` 时有值;`reason` 只在 `kind: 'unparsed'` 时有值
|
|
712
|
+
* (契约原文:绝不猜测,reason 写明无法解析的来源标记)。
|
|
713
|
+
*
|
|
714
|
+
* @param {object} task 任务对象(只读)
|
|
715
|
+
* @param {object} [parsed] `parseSourceSeq` 的命中结果
|
|
716
|
+
*/
|
|
717
|
+
function buildSourcePayload(task, parsed) {
|
|
718
|
+
const sourceSeq = typeof task?.sourceSeq === 'string' ? task.sourceSeq : ''
|
|
719
|
+
const base = {
|
|
720
|
+
taskId: task?.id ?? null,
|
|
721
|
+
source: task?.source ?? null,
|
|
722
|
+
sourceSeq: sourceSeq === '' ? null : sourceSeq,
|
|
723
|
+
room: null,
|
|
724
|
+
jump: null,
|
|
725
|
+
reason: null,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (!sourceSeq) {
|
|
729
|
+
return { ...base, kind: 'none', hint: '这个任务不是从房间消息创建的' }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!parsed) {
|
|
733
|
+
return {
|
|
734
|
+
...base,
|
|
735
|
+
kind: 'unparsed',
|
|
736
|
+
reason: `无法解析来源标记 "${sourceSeq}"(不是 room:/todo:/evolve: 前缀)`,
|
|
737
|
+
hint: '来源已失效,无法自动跳转',
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (parsed.kind === 'room') {
|
|
742
|
+
return {
|
|
743
|
+
...base,
|
|
744
|
+
kind: 'room',
|
|
745
|
+
room: {
|
|
746
|
+
roomId: parsed.roomId,
|
|
747
|
+
name: null,
|
|
748
|
+
seq: parsed.seq,
|
|
749
|
+
messageId: `m-${parsed.seq}`,
|
|
750
|
+
exists: null,
|
|
751
|
+
messageExists: null,
|
|
752
|
+
at: null,
|
|
753
|
+
preview: null,
|
|
754
|
+
},
|
|
755
|
+
jump: { roomId: parsed.roomId, seq: parsed.seq },
|
|
756
|
+
hint: `来源:房间消息 ${parsed.roomId} #${parsed.seq}`,
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (parsed.kind === 'todo') return { ...base, kind: 'todo', hint: '来源:待办' }
|
|
761
|
+
return { ...base, kind: 'evolve', hint: '来源:任务变更派生' }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
765
|
+
// bots改造05:唤醒前自动复活会话(修「派活报 agent-not-live」)
|
|
766
|
+
//
|
|
767
|
+
// 设计依据:`design/CONTRACT-bots05.md` §2(**已冻结**)。
|
|
768
|
+
// 根因:dsh-web 重启后宿主不会自动把旧会话装载进 `agents` 注册表(懒加载),
|
|
769
|
+
// 而 dispatcher 投递走 `kernel.followup` → `getAgent`;拿不到 agent 就是
|
|
770
|
+
// `agent-not-live`。会话数据仍在磁盘上,`agents.resume({resumeSessionId})` 能救回来。
|
|
771
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
772
|
+
|
|
773
|
+
/** `/bots/revive {all:true}` 单次最多处理的 Bot 数(契约 §2.2)。 */
|
|
774
|
+
const REVIVE_MAX = 50
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 确保某个会话在宿主 `agents` 注册表里是 live 的;不在就尝试 resume。
|
|
778
|
+
*
|
|
779
|
+
* **绝不抛错**:复活失败必须让上层拿到 `reason`,由上层决定「继续投递并在
|
|
780
|
+
* wake 里如实报失败」还是「明确报错」。静默吞掉失败才是本平台最忌讳的。
|
|
781
|
+
*
|
|
782
|
+
* @param {object} ctx
|
|
783
|
+
* @param {string} sessionId
|
|
784
|
+
* @returns {Promise<{ ok: boolean, live: boolean, revived: boolean, reason?: string }>}
|
|
785
|
+
*/
|
|
786
|
+
async function ensureLive(ctx, sessionId) {
|
|
787
|
+
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
|
788
|
+
return { ok: false, live: false, revived: false, reason: 'missing-session' }
|
|
789
|
+
}
|
|
790
|
+
const id = sessionId.trim()
|
|
791
|
+
// 已经 live:**什么都不做**(不得多出任何副作用,保证既有行为完全一致)。
|
|
792
|
+
if (getAgent(ctx, id)) return { ok: true, live: true, revived: false }
|
|
793
|
+
try {
|
|
794
|
+
await resumeSession(ctx, { resumeSessionId: id, warn: moduleWarn })
|
|
795
|
+
return { ok: true, live: true, revived: true }
|
|
796
|
+
} catch (error) {
|
|
797
|
+
return { ok: false, live: false, revived: false, reason: `resume-failed:${error?.message ?? error}` }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 投递前取「会话水位」基线(用于投递后判定执行者到底动没动)。
|
|
803
|
+
*
|
|
804
|
+
* 读不到水位就返回 undefined —— 宁可不追踪,也不要拿假基线去误报。
|
|
805
|
+
*
|
|
806
|
+
* @param {object} ctx
|
|
807
|
+
* @param {string} sessionId
|
|
808
|
+
* @returns {Promise<object|undefined>}
|
|
809
|
+
*/
|
|
810
|
+
async function takeWatermarkBaseline(ctx, sessionId) {
|
|
811
|
+
try {
|
|
812
|
+
const wm = await readSessionWatermark(ctx, sessionId)
|
|
813
|
+
return wm?.ok ? wm : undefined
|
|
814
|
+
} catch {
|
|
815
|
+
return undefined
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* 投递成功后登记一条「等回执」期望(2026-11-09 · 补 superagent inputsRead 那一层)。
|
|
821
|
+
*
|
|
822
|
+
* 为什么必须在**投递前**取基线:等投递完再读,可能已经把这次投递算进基线里,
|
|
823
|
+
* 于是「水位从来没涨过」也看不出差异 —— 那就等于没追踪。
|
|
824
|
+
*
|
|
825
|
+
* @param {object} services
|
|
826
|
+
* @param {string} taskId
|
|
827
|
+
* @param {string} sessionId
|
|
828
|
+
* @param {object|undefined} baseline 投递前 takeWatermarkBaseline() 的结果
|
|
829
|
+
* @param {object|undefined} wake dispatcher.notify 的返回
|
|
830
|
+
*/
|
|
831
|
+
function armDeliveryWatch(services, taskId, sessionId, baseline, wake) {
|
|
832
|
+
const watch = services.deliveryWatch
|
|
833
|
+
if (!watch || !baseline || !baseline.ok) return undefined
|
|
834
|
+
// 只有真的「发出去了」才等回执:failed/pending 各有各的显形方式,不该混进来。
|
|
835
|
+
if (!wake || wake.result !== 'sent') return undefined
|
|
836
|
+
try {
|
|
837
|
+
const out = watch.expect({
|
|
838
|
+
taskId,
|
|
839
|
+
sessionId,
|
|
840
|
+
baselineSeq: baseline.seq,
|
|
841
|
+
baselineUserMessages: baseline.userMessages,
|
|
842
|
+
wakeId: wake.wakeId,
|
|
843
|
+
})
|
|
844
|
+
return out?.ok ? out : undefined
|
|
845
|
+
} catch {
|
|
846
|
+
return undefined
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 执行者状态(「任务在跑吗、为什么没动」)。
|
|
852
|
+
*
|
|
853
|
+
* ## 三条事实分开说,别混成一句「进行中」
|
|
854
|
+
* 1. `live`:会话现在在不在宿主里活着(不在线 ⇒ 压根没法跑)
|
|
855
|
+
* 2. `lastWake`:最近一次唤醒的结果(sent 只是「交出去了」,不代表对方读了)
|
|
856
|
+
* 3. `deliveryError`:有没有投递失败留痕(这是**一等状态**,会一直显示到被解决)
|
|
857
|
+
*
|
|
858
|
+
* @param {object} ctx
|
|
859
|
+
* @param {object} services
|
|
860
|
+
* @param {object} task
|
|
861
|
+
* @returns {{ sessionId: string|null, live: boolean, lastWake: object|null, deliveryError: string|null }}
|
|
862
|
+
*/
|
|
863
|
+
function buildExecutorStatus(ctx, services, task) {
|
|
864
|
+
const sessionId = task?.ownerId ?? task?.assignee ?? null
|
|
865
|
+
if (!sessionId) return { sessionId: null, live: false, lastWake: null, deliveryError: null }
|
|
866
|
+
let live = false
|
|
867
|
+
try { live = Boolean(getAgent(ctx, sessionId)) } catch { live = false }
|
|
868
|
+
|
|
869
|
+
let lastWake = null
|
|
870
|
+
try {
|
|
871
|
+
const rows = readTaskWakes(services, task.id)
|
|
872
|
+
if (rows.length) {
|
|
873
|
+
const w = rows[rows.length - 1]
|
|
874
|
+
lastWake = {
|
|
875
|
+
at: w.at ?? null,
|
|
876
|
+
result: w.result ?? null,
|
|
877
|
+
detail: w.detail ?? null,
|
|
878
|
+
reason: w.reason ?? null,
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} catch { lastWake = null }
|
|
882
|
+
|
|
883
|
+
const deliveryError = typeof task?.delivery?.error === 'string' && task.delivery.error
|
|
884
|
+
? task.delivery.error
|
|
885
|
+
: null
|
|
886
|
+
return { sessionId, live, lastWake, deliveryError }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* 确保「执行某个任务的 Bot」有一个团队(需求4,2026-11-09)。
|
|
891
|
+
*
|
|
892
|
+
* 用户原话:「任务创建完成应该自动建一个团队,可以只有一个人,后续我再增加」。
|
|
893
|
+
*
|
|
894
|
+
* ## 为什么是「每 Bot 一个团队」而不是「每任务一个团队」
|
|
895
|
+
* 任务会不断产生,团队是组织结构、不该跟着任务膨胀。一个 Bot 一个团队 =
|
|
896
|
+
* 它的固定协作单元,人后续往里加人即可;同一个 Bot 的多个任务共享这个团队。
|
|
897
|
+
*
|
|
898
|
+
* ## 幂等
|
|
899
|
+
* 已经有以该 Bot 为负责人的团队 → 直接返回那个(不重复建)。
|
|
900
|
+
*
|
|
901
|
+
* ## 失败不阻断
|
|
902
|
+
* 建团队失败**不影响任务本身**(任务已经建好了)。只回一个 `autoTeam.reason`,
|
|
903
|
+
* 让用户知道「团队没建上,但不影响干活」。
|
|
904
|
+
*
|
|
905
|
+
* @param {object} services
|
|
906
|
+
* @param {string} botSessionId 执行者会话 id
|
|
907
|
+
* @param {string} by 谁触发的(用于留痕)
|
|
908
|
+
* @returns {Promise<{ team?: object, created?: boolean, reason?: string } | undefined>}
|
|
909
|
+
*/
|
|
910
|
+
async function ensureTeamForBot(services, botSessionId, by) {
|
|
911
|
+
const teams = services.teams
|
|
912
|
+
if (!teams || typeof teams.create !== 'function' || typeof botSessionId !== 'string' || !botSessionId) {
|
|
913
|
+
return undefined
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const existing = teams.list({ type: 'big' }).find((t) => t.leadId === botSessionId && t.status === 'open')
|
|
917
|
+
if (existing) return { team: existing, created: false }
|
|
918
|
+
|
|
919
|
+
// 团队名用 Bot 的真名(roster 里查),查不到就用会话 id 缩写
|
|
920
|
+
let botName = ''
|
|
921
|
+
try {
|
|
922
|
+
const bots = services.roster?.list?.() ?? []
|
|
923
|
+
const hit = bots.find((b) => b.sessionId === botSessionId)
|
|
924
|
+
if (hit?.name) botName = hit.name
|
|
925
|
+
} catch { /* roster 不在就算了 */ }
|
|
926
|
+
const name = `${botName || `Bot ${String(botSessionId).slice(-8)}`} 的团队`
|
|
927
|
+
|
|
928
|
+
const team = teams.create({
|
|
929
|
+
name,
|
|
930
|
+
type: 'big',
|
|
931
|
+
leadId: botSessionId,
|
|
932
|
+
// 「可以只有一个人」:成员列表先空着,负责人就是唯一成员,人后续再加
|
|
933
|
+
memberIds: [],
|
|
934
|
+
purpose: '由任务派活自动创建;后续可在「团队」页加人或调整权限',
|
|
935
|
+
// 人建的团队默认「仅负责人可管理」,自动建的沿用同一安全默认
|
|
936
|
+
adminPolicy: 'lead-only',
|
|
937
|
+
by,
|
|
938
|
+
})
|
|
939
|
+
return { team, created: true }
|
|
940
|
+
} catch (error) {
|
|
941
|
+
return { reason: `自动建团队失败:${error?.message ?? error}` }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* 投递能力预检(2026-11-09 修复「任务指派后卡在排队中、失败无声」)。
|
|
947
|
+
*
|
|
948
|
+
* ## 现场问题
|
|
949
|
+
* 任务建好并指派给 `bot-6b654c19…`,`wakes.jsonl` 记录 `result=sent`(我们把消息
|
|
950
|
+
* 交出去了),但 Bot 那一轮立刻失败:
|
|
951
|
+
* `agent "bot-…" has no provider/model: set AgentOptions.provider and
|
|
952
|
+
* AgentOptions.model or record a model selection`
|
|
953
|
+
* 失败发生在**宿主内部**,信封(wake)早已记为成功 ⇒ 任务停在 pending、
|
|
954
|
+
* 阶段判定没有 `delivery` 依据 ⇒ 页面显示「排队中」,人看不到任何错误。
|
|
955
|
+
* **这是最坏的一类失败:静默失败。**
|
|
956
|
+
*
|
|
957
|
+
* ## 处置
|
|
958
|
+
* 唤醒**之前**先确认「这个会话真的能跑起来」——即它是否已有模型路由。
|
|
959
|
+
* 没有就**不发唤醒**,而是把原因写进 `task.delivery.error`:
|
|
960
|
+
* - 阶段判定(`src/stage.js`)读到 delivery.error ⇒ 派 `person/delivery`
|
|
961
|
+
* - 页面显示「输入投递失败:…」,人立刻知道要做什么(去给 Bot 补 model)
|
|
962
|
+
*
|
|
963
|
+
* @param {object} ctx
|
|
964
|
+
* @param {string} sessionId
|
|
965
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
966
|
+
*/
|
|
967
|
+
function assertDeliverable(ctx, sessionId) {
|
|
968
|
+
const agent = getAgent(ctx, sessionId)
|
|
969
|
+
// 会话不 live 不算硬失败 —— ensureLive() 会先复活它,复活失败时才报错。
|
|
970
|
+
if (!agent) return { ok: true }
|
|
971
|
+
try {
|
|
972
|
+
// 只有「真的带 model 访问器」的 agent 才能判定;测试替身/老宿主没有访问器时
|
|
973
|
+
// **一律放行** —— 预检的职责是拦住已知必败的投递,不是给所有投递加一道门。
|
|
974
|
+
if (typeof agent.model?.current !== 'function') return { ok: true }
|
|
975
|
+
const selection = agent.model.current()
|
|
976
|
+
if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
|
|
977
|
+
return { ok: true }
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
ok: false,
|
|
981
|
+
reason: `会话 ${sessionId} 没有模型路由(provider/model 均为空)——`
|
|
982
|
+
+ '请在「Bot 档案」给这个 Bot 选一个模型,或把它的会话模型补上再派活',
|
|
983
|
+
}
|
|
984
|
+
} catch (error) {
|
|
985
|
+
return { ok: false, reason: `读取会话模型失败:${error?.message ?? error}` }
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* 把投递失败落到任务上(`task.delivery.error`),让阶段机看得见。
|
|
991
|
+
* 失败**不吞**:写不进去时返回 false,调用方仍会把 reason 回给用户。
|
|
992
|
+
*
|
|
993
|
+
* @param {object} services
|
|
994
|
+
* @param {string} taskId
|
|
995
|
+
* @param {string} reason
|
|
996
|
+
* @param {string} by
|
|
997
|
+
* @returns {Promise<boolean>}
|
|
998
|
+
*/
|
|
999
|
+
async function markDeliveryFailure(services, taskId, reason, by) {
|
|
1000
|
+
try {
|
|
1001
|
+
const board = services.board
|
|
1002
|
+
if (!board) return false
|
|
1003
|
+
const current = board.get(taskId)
|
|
1004
|
+
if (!current) return false
|
|
1005
|
+
// ⚠️ **合并写入**,不是整体覆盖(2026-11-09)。
|
|
1006
|
+
// `board.set` 对 `delivery` 是 `next[k] = fields[k]` —— 整体替换。
|
|
1007
|
+
// 直接写 `{ error, at, by }` 会冲掉这个字段上已有的其它键(将来若加
|
|
1008
|
+
// attempt/最后一次成功时间等,会被静默抹掉)。先读再展开才是安全写法。
|
|
1009
|
+
const prev = (current.delivery && typeof current.delivery === 'object') ? current.delivery : {}
|
|
1010
|
+
await board.update({
|
|
1011
|
+
id: taskId,
|
|
1012
|
+
expectedRev: current.rev,
|
|
1013
|
+
action: 'set',
|
|
1014
|
+
byId: by,
|
|
1015
|
+
delivery: { ...prev, error: String(reason), at: Date.now(), by },
|
|
1016
|
+
})
|
|
1017
|
+
return true
|
|
1018
|
+
} catch {
|
|
1019
|
+
return false
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* 清掉投递失败留痕(**重试成功后必须清**,2026-11-09 修复)。
|
|
1025
|
+
*
|
|
1026
|
+
* ## 为什么必须有它(现场缺陷)
|
|
1027
|
+
* `stage.js:hasDeliveryFailure()` 的判定是「`task.delivery.error` 存在且非空」,
|
|
1028
|
+
* 而全项目**只有写入、没有人清除**(`grep delivery:` 只有两处 set)。
|
|
1029
|
+
* 后果:一次投递失败之后,即使人点「重试唤醒」投递成功、Bot 正常把活干完,
|
|
1030
|
+
* 任务也**永远**停在 `person/delivery`「输入投递失败:…」——
|
|
1031
|
+
* 阶段机被一条过期事实永久劫持,人看到的是假红字。
|
|
1032
|
+
*
|
|
1033
|
+
* 清除口径(故意保守):
|
|
1034
|
+
* - 只在**这一次投递确实成功**(`sent` 或 `skipped` 幂等命中)时清
|
|
1035
|
+
* - `pending`(已排队未送达)不清 —— 还没真投出去,失败留痕仍是事实
|
|
1036
|
+
* - `failed` 当然不清
|
|
1037
|
+
*
|
|
1038
|
+
* @param {object} services
|
|
1039
|
+
* @param {string} taskId
|
|
1040
|
+
* @param {{ result?: string }} [wake]
|
|
1041
|
+
* @returns {Promise<boolean>} 是否真的清了
|
|
1042
|
+
*/
|
|
1043
|
+
async function clearDeliveryFailure(services, taskId, wake) {
|
|
1044
|
+
const result = wake?.result
|
|
1045
|
+
if (result !== 'sent' && result !== 'skipped') return false
|
|
1046
|
+
try {
|
|
1047
|
+
const board = services.board
|
|
1048
|
+
if (!board) return false
|
|
1049
|
+
const current = board.get(taskId)
|
|
1050
|
+
if (!current) return false
|
|
1051
|
+
const prev = current.delivery
|
|
1052
|
+
if (!prev || typeof prev !== 'object') return false
|
|
1053
|
+
if (typeof prev.error !== 'string' || prev.error === '') return false
|
|
1054
|
+
await board.update({
|
|
1055
|
+
id: taskId,
|
|
1056
|
+
expectedRev: current.rev,
|
|
1057
|
+
action: 'set',
|
|
1058
|
+
byId: 'cs-bots',
|
|
1059
|
+
delivery: { ...prev, error: undefined, clearedAt: Date.now(), clearedBy: wake?.target ?? 'cs-bots' },
|
|
1060
|
+
})
|
|
1061
|
+
return true
|
|
1062
|
+
} catch {
|
|
1063
|
+
return false
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* 执行 Bot 的开工准备命令(改造07 · 借鉴 Super-agent 的 prepare command)。
|
|
1069
|
+
*
|
|
1070
|
+
* 安全边界(与 verify 的 kind:'command' 同口径):
|
|
1071
|
+
* - 第一段必须在 CMD_WHITELIST 内(拒绝任意二进制执行)
|
|
1072
|
+
* - 用 execFile 传参数组,**绝不经过 shell** → 无注入面
|
|
1073
|
+
* - 有硬超时(默认 10 分钟,与 Super-agent 的 prepare 一致)
|
|
1074
|
+
* - cwd 固定为 Bot 的工作目录(Bot 的写入边界)
|
|
1075
|
+
*
|
|
1076
|
+
* @param {{ prepareCommand?: string, cwd?: string }} bot
|
|
1077
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
1078
|
+
* @returns {Promise<object>}
|
|
1079
|
+
*/
|
|
1080
|
+
async function runPrepareCommand(bot, opts = {}) {
|
|
1081
|
+
const raw = typeof bot?.prepareCommand === 'string' ? bot.prepareCommand.trim() : ''
|
|
1082
|
+
if (!raw) return { ok: true, skipped: true }
|
|
1083
|
+
const cwd = typeof bot?.cwd === 'string' && bot.cwd ? bot.cwd : undefined
|
|
1084
|
+
if (!cwd) return { ok: false, command: raw, error: 'bot-cwd-missing' }
|
|
1085
|
+
|
|
1086
|
+
// 只按空白切分,不做 shell 展开(不做引号/管道/重定向——那都是注入面)。
|
|
1087
|
+
const argv = raw.split(/\s+/).filter(Boolean)
|
|
1088
|
+
if (argv.length === 0) return { ok: true, skipped: true }
|
|
1089
|
+
if (!CMD_WHITELIST.includes(argv[0])) {
|
|
1090
|
+
return {
|
|
1091
|
+
ok: false,
|
|
1092
|
+
command: raw,
|
|
1093
|
+
cwd,
|
|
1094
|
+
error: `prepare-command-not-allowed:${argv[0]}`,
|
|
1095
|
+
stderr: `允许的命令:${CMD_WHITELIST.join(' | ')}`,
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 10 * 60 * 1000
|
|
1100
|
+
return new Promise((resolvePromise) => {
|
|
1101
|
+
let done = false
|
|
1102
|
+
const finish = (out) => {
|
|
1103
|
+
if (done) return
|
|
1104
|
+
done = true
|
|
1105
|
+
resolvePromise({ command: raw, cwd, ...out })
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
execFile(argv[0], argv.slice(1), { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
1109
|
+
const so = String(stdout ?? '')
|
|
1110
|
+
const se = String(stderr ?? '')
|
|
1111
|
+
if (error) {
|
|
1112
|
+
const timedOut = error.killed === true || error.signal === 'SIGTERM'
|
|
1113
|
+
finish({
|
|
1114
|
+
ok: false,
|
|
1115
|
+
exitCode: typeof error.code === 'number' ? error.code : -1,
|
|
1116
|
+
stdout: so,
|
|
1117
|
+
stderr: se,
|
|
1118
|
+
error: timedOut ? 'timeout' : String(error?.message ?? error),
|
|
1119
|
+
})
|
|
1120
|
+
return
|
|
1121
|
+
}
|
|
1122
|
+
finish({ ok: true, exitCode: 0, stdout: so, stderr: se })
|
|
1123
|
+
})
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
finish({ ok: false, error: String(error?.message ?? error) })
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* 把 `ensureLive` 结果压成响应新增字段:`revived:boolean` + 失败时 `reviveReason`。
|
|
1132
|
+
* 原本就 live 时只多一个 `revived:false`(契约允许的新增字段),不产生任何副作用。
|
|
1133
|
+
* @param {{ live?: boolean, revived?: boolean, reason?: string } | undefined} revive
|
|
1134
|
+
*/
|
|
1135
|
+
function reviveFields(revive) {
|
|
1136
|
+
const out = { revived: Boolean(revive?.revived) }
|
|
1137
|
+
if (revive && revive.live === false) out.reviveReason = revive.reason ?? 'revive-failed'
|
|
1138
|
+
return out
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1142
|
+
// 改造06 §2:团队 L2 记忆 / 交付物文件预览 / 任务结论摘要 / CS-BOTS 工作区
|
|
1143
|
+
//
|
|
1144
|
+
// 设计依据:`design/CONTRACT-bots06.md`(字段名/文案/状态码逐字照做)。
|
|
1145
|
+
// 这四块都是**纯新增**端点:不改任何既有路由的行为,只追加私有 helper + 分支。
|
|
1146
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1147
|
+
|
|
1148
|
+
/** 团队记忆的标签(L2 层,一个团队一个 tag)。 */
|
|
1149
|
+
function teamMemoryTag(teamId) {
|
|
1150
|
+
return `team:${teamId}`
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* 读某团队的 L2 记忆(按 `at` 升序)。
|
|
1155
|
+
* 从 memory 服务读(`list({ layer:'L2' })` 后按 tags 精确匹配),不碰记忆文件。
|
|
1156
|
+
*/
|
|
1157
|
+
function readTeamMemories(services, teamId) {
|
|
1158
|
+
if (!services?.memory) return []
|
|
1159
|
+
const tag = teamMemoryTag(teamId)
|
|
1160
|
+
let rows
|
|
1161
|
+
try {
|
|
1162
|
+
rows = services.memory.list({ layer: 'L2', includeArchived: true })
|
|
1163
|
+
} catch {
|
|
1164
|
+
return []
|
|
1165
|
+
}
|
|
1166
|
+
return rows
|
|
1167
|
+
.filter((row) => Array.isArray(row?.tags) && row.tags.includes(tag))
|
|
1168
|
+
.map((row) => ({
|
|
1169
|
+
id: row.id,
|
|
1170
|
+
layer: 'L2',
|
|
1171
|
+
content: row.content,
|
|
1172
|
+
by: row.createdBy ?? row.by,
|
|
1173
|
+
at: row.createdAt ?? row.at,
|
|
1174
|
+
tags: Array.isArray(row.tags) ? [...row.tags] : [],
|
|
1175
|
+
}))
|
|
1176
|
+
.sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0))
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** 团队记忆渲染文本:`- [时间] content(by)`;空列表 → 空串。 */
|
|
1180
|
+
function buildTeamMemoryText(memories) {
|
|
1181
|
+
return (Array.isArray(memories) ? memories : [])
|
|
1182
|
+
.map((m) => `- [${formatAnchorTime(m.at)}] ${m.content}(${m.by ?? '?'})`)
|
|
1183
|
+
.join('\n')
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** 简单扩展名 → MIME 映射(只覆盖预览场景常见的文本类型)。 */
|
|
1187
|
+
const MIME_BY_EXT = {
|
|
1188
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
1189
|
+
'.json': 'application/json; charset=utf-8',
|
|
1190
|
+
'.jsonl': 'application/x-ndjson; charset=utf-8',
|
|
1191
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
1192
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
1193
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
1194
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
1195
|
+
'.ts': 'text/typescript; charset=utf-8',
|
|
1196
|
+
'.css': 'text/css; charset=utf-8',
|
|
1197
|
+
'.html': 'text/html; charset=utf-8',
|
|
1198
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
1199
|
+
'.log': 'text/plain; charset=utf-8',
|
|
1200
|
+
'.yml': 'text/yaml; charset=utf-8',
|
|
1201
|
+
'.yaml': 'text/yaml; charset=utf-8',
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** 按扩展名取 MIME(未知 → `application/octet-stream`)。 */
|
|
1205
|
+
function mimeFor(path) {
|
|
1206
|
+
const m = /\.[A-Za-z0-9]+$/.exec(String(path ?? ''))
|
|
1207
|
+
const ext = m ? m[0].toLowerCase() : ''
|
|
1208
|
+
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* 粗判二进制:NUL 字节或过高的非文本比例即判为不可预览。
|
|
1213
|
+
* 只用内置能力,不引第三方依赖。
|
|
1214
|
+
*/
|
|
1215
|
+
function isBinaryBuffer(buf) {
|
|
1216
|
+
const n = buf?.length ?? 0
|
|
1217
|
+
if (n === 0) return false
|
|
1218
|
+
const sample = buf.subarray(0, Math.min(n, 8192))
|
|
1219
|
+
let suspicious = 0
|
|
1220
|
+
for (let i = 0; i < sample.length; i += 1) {
|
|
1221
|
+
const b = sample[i]
|
|
1222
|
+
if (b === 0) return true
|
|
1223
|
+
// 控制字符(除常见的 \t \n \r)占比高 → 二进制
|
|
1224
|
+
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious += 1
|
|
1225
|
+
}
|
|
1226
|
+
return suspicious / sample.length > 0.02
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** 按码点截断(不切断代理对,避免产生半个字符)。 */
|
|
1230
|
+
function truncateCodepoints(text, max) {
|
|
1231
|
+
const s = String(text ?? '')
|
|
1232
|
+
const limit = Number.isFinite(max) && max >= 0 ? max : 200
|
|
1233
|
+
const arr = Array.from(s)
|
|
1234
|
+
return arr.length > limit ? arr.slice(0, limit).join('') : s
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** 任务状态 → 中文(与契约 §3 的 progressText 口径一致)。 */
|
|
1238
|
+
const STATUS_TEXT = {
|
|
1239
|
+
pending: '待办',
|
|
1240
|
+
in_progress: '进行中',
|
|
1241
|
+
blocked: '阻塞',
|
|
1242
|
+
completed: '已完成',
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** 唤醒记录里是否属于该任务(按投递文案的任务锚定串或 dedupKey 中的 taskId 判定)。 */
|
|
1246
|
+
function wakeBelongsToTask(row, taskId) {
|
|
1247
|
+
const anchor = `任务 ${taskId}:`
|
|
1248
|
+
const text = typeof row?.text === 'string' ? row.text : ''
|
|
1249
|
+
if (text.includes(anchor)) return true
|
|
1250
|
+
const key = typeof row?.dedupKey === 'string' ? row.dedupKey : ''
|
|
1251
|
+
return key.includes(`:${taskId}:`) || key.endsWith(`:${taskId}`)
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** 唤醒投递文本的首段(首行非空内容),截 200 字;无文本 → null。 */
|
|
1255
|
+
function wakeExcerpt(text, max = 200) {
|
|
1256
|
+
const s = typeof text === 'string' ? text : ''
|
|
1257
|
+
for (const line of s.split('\n')) {
|
|
1258
|
+
const t = line.trim()
|
|
1259
|
+
if (t) return truncateCodepoints(t, max)
|
|
1260
|
+
}
|
|
1261
|
+
return null
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* 从最近一条 L4 笔记里提取「下一步」行。
|
|
1266
|
+
* 关键词:下一步 / todo / 计划 / 接下来 / 待(大小写不敏感)。
|
|
1267
|
+
* 无匹配 → null;命中行截 200 字。
|
|
1268
|
+
*/
|
|
1269
|
+
function extractNextStep(content, max = 200) {
|
|
1270
|
+
const s = typeof content === 'string' ? content : ''
|
|
1271
|
+
const lines = s.split('\n')
|
|
1272
|
+
for (const line of lines) {
|
|
1273
|
+
const t = line.trim()
|
|
1274
|
+
if (!t) continue
|
|
1275
|
+
if (/下一步|todo|计划|接下来|待/i.test(t)) return truncateCodepoints(t, max)
|
|
1276
|
+
}
|
|
1277
|
+
return null
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** 任务结论摘要的固定说明文案(契约逐字)。 */
|
|
1281
|
+
const SUMMARY_NOTE = '结论由唤醒记录与 L4 任务笔记提炼;队长可通过任务笔记补充下一步方向'
|
|
1282
|
+
|
|
1283
|
+
/** 取某任务最近一条 L4 笔记(notesForTask 已是倒序);无 → null。 */
|
|
1284
|
+
function latestTaskNote(services, taskId) {
|
|
1285
|
+
const mem = services?.memory
|
|
1286
|
+
if (!mem || typeof mem.notesForTask !== 'function') return null
|
|
1287
|
+
try {
|
|
1288
|
+
const rows = mem.notesForTask({ taskId })
|
|
1289
|
+
return Array.isArray(rows) && rows.length ? rows[0] : null
|
|
1290
|
+
} catch {
|
|
1291
|
+
return null
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* 读某任务的唤醒记录。
|
|
1297
|
+
* 优先用 dispatcher 暴露的读取能力(`list()` 读盘,进程重启后仍可审计);
|
|
1298
|
+
* 若该能力缺失或读盘失败,再退回直接读 `wakes.jsonl`(契约 §2.3 的兜底口径)。
|
|
1299
|
+
*/
|
|
1300
|
+
function readTaskWakes(services, taskId) {
|
|
1301
|
+
const dispatcher = services?.dispatcher
|
|
1302
|
+
if (dispatcher && typeof dispatcher.list === 'function') {
|
|
1303
|
+
try {
|
|
1304
|
+
const rows = dispatcher.list({})
|
|
1305
|
+
if (Array.isArray(rows)) return rows.filter((row) => wakeBelongsToTask(row, taskId))
|
|
1306
|
+
} catch {
|
|
1307
|
+
/* 落到下面的读盘兜底 */
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const dir = anchorStateDir(services)
|
|
1311
|
+
if (!dir) return []
|
|
1312
|
+
try {
|
|
1313
|
+
const { rows } = readJsonl(statePath(dir, 'wakes.jsonl'))
|
|
1314
|
+
return rows.filter((row) => wakeBelongsToTask(row, taskId))
|
|
1315
|
+
} catch {
|
|
1316
|
+
return []
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** 唤醒记录 → 摘要行 `{ at, result, excerpt }`。 */
|
|
1321
|
+
function toStepConclusion(row) {
|
|
1322
|
+
const at = row?.updatedAt ?? row?.sentAt ?? row?.scheduledAt ?? null
|
|
1323
|
+
return {
|
|
1324
|
+
at: Number.isFinite(Number(at)) ? Number(at) : null,
|
|
1325
|
+
result: row?.result ?? 'unknown',
|
|
1326
|
+
excerpt: wakeExcerpt(row?.text),
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** 状态 → 中文(未知状态原样返回,不静默丢信息)。 */
|
|
1331
|
+
function statusText(status) {
|
|
1332
|
+
return STATUS_TEXT[status] ?? String(status ?? '未知')
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* 注册全部 API 路由。
|
|
1338
|
+
*
|
|
1339
|
+
* @param {object} ctx 宿主上下文
|
|
1340
|
+
* @param {{ warn?: Function }} [deps]
|
|
1341
|
+
* @returns {(() => void) | undefined} disposer
|
|
1342
|
+
*/
|
|
1343
|
+
export function installApi(ctx, { warn } = {}) {
|
|
1344
|
+
/** 模块级告警出口:ensureLive 这类模块级函数也要能把 preset 装配失败说出来。 */
|
|
1345
|
+
moduleWarn = warn ?? (() => {})
|
|
1346
|
+
// ⚠️ 修复(2026-11-04 测试,P0):此前本 API **完全没有鉴权**——
|
|
1347
|
+
// 公网 `curl -X POST http://<IP>/cs-bots/api/tasks` 直接 200,
|
|
1348
|
+
// 任何人都能建任务/改记忆/写房间/改 Bot 注册表,并借此绕过下游的受保护区写闸门。
|
|
1349
|
+
// 现在所有请求先过 fence.judgeHttp:宿主不认证就一律 403。
|
|
1350
|
+
const fence = createFence({ ctx, warn })
|
|
1351
|
+
|
|
1352
|
+
const handler = async (request, response) => {
|
|
1353
|
+
const services = getServices(ctx)
|
|
1354
|
+
// 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
|
|
1355
|
+
const verdict = fence.judgeHttp(request)
|
|
1356
|
+
if (!verdict.ok) {
|
|
1357
|
+
sendJson(response, verdict.status ?? 403, { ok: false, reason: verdict.reason ?? 'unauthorized' })
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
let url
|
|
1361
|
+
try {
|
|
1362
|
+
url = new URL(request.url || '/', 'http://localhost')
|
|
1363
|
+
} catch {
|
|
1364
|
+
sendJson(response, 400, { ok: false, reason: 'bad-url' })
|
|
1365
|
+
return
|
|
1366
|
+
}
|
|
1367
|
+
// 去掉前缀,得到相对路径
|
|
1368
|
+
const path = url.pathname.startsWith(API_BASE) ? url.pathname.slice(API_BASE.length) || '/' : url.pathname
|
|
1369
|
+
|
|
1370
|
+
try {
|
|
1371
|
+
// ── 读:状态总览 ──────────────────────────────────────────────────────
|
|
1372
|
+
if (request.method === 'GET' && path === '/status') {
|
|
1373
|
+
const out = { ok: true, services: {}, compat: services.compat ?? null }
|
|
1374
|
+
// 状态面要覆盖**全部**域服务:新增模块若不上报,页面「系统体检」就会
|
|
1375
|
+
// 显示「一切正常」而实际某功能没装配 —— 那是本平台最忌讳的静默失败。
|
|
1376
|
+
const SERVICE_KEYS = [
|
|
1377
|
+
'roster', 'board', 'dispatcher', 'room', 'memory',
|
|
1378
|
+
'library', 'discipline', 'todo', 'scratch', 'prompts', 'suggest', 'reviewer',
|
|
1379
|
+
'metrics', 'verify', 'cycle', 'teams', 'subscription',
|
|
1380
|
+
// 投递回执看护(2026-11-09):不上报的话,它装配失败时
|
|
1381
|
+
// 「系统体检」会显示一切正常,而实际没有任何回执追踪 —— 静默失败。
|
|
1382
|
+
'deliveryWatch',
|
|
1383
|
+
]
|
|
1384
|
+
for (const key of SERVICE_KEYS) {
|
|
1385
|
+
const svc = services[key]
|
|
1386
|
+
out.services[key] = svc
|
|
1387
|
+
? {
|
|
1388
|
+
available: true,
|
|
1389
|
+
stats: typeof svc.stats === 'function' ? svc.stats() : undefined,
|
|
1390
|
+
stateDir: svc.paths?.stateDir,
|
|
1391
|
+
// 看护没有状态目录(期望表只在内存),补一句人话说明
|
|
1392
|
+
...(key === 'deliveryWatch'
|
|
1393
|
+
? { note: '投递回执看护(只在内存;重启后旧期望失效是设计如此)' }
|
|
1394
|
+
: {}),
|
|
1395
|
+
}
|
|
1396
|
+
: { available: false }
|
|
1397
|
+
}
|
|
1398
|
+
sendJson(response, 200, out)
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ── 任务板 ────────────────────────────────────────────────────────────
|
|
1403
|
+
if (request.method === 'GET' && path === '/tasks') {
|
|
1404
|
+
const status = qp(url, 'status')
|
|
1405
|
+
const onlyReady = qp(url, 'ready') === '1'
|
|
1406
|
+
const attention = qp(url, 'attention') === '1'
|
|
1407
|
+
const priority = qn(url, 'priority')
|
|
1408
|
+
const source = qp(url, 'source')
|
|
1409
|
+
const sort = qp(url, 'sort')
|
|
1410
|
+
const byId = qp(url, 'byId')
|
|
1411
|
+
const opts = { status: status ?? undefined, attention: attention || undefined, priority: priority ?? undefined, source: source ?? undefined, sort: sort ?? undefined, byId }
|
|
1412
|
+
const tasks = await withService(services, 'board', (b) =>
|
|
1413
|
+
onlyReady ? b.ready() : b.list(opts),
|
|
1414
|
+
)
|
|
1415
|
+
// 改造07(借鉴 Super-agent stage table):每个任务附带执行阶段(纯派生,不落盘)。
|
|
1416
|
+
// 前端用 stageInfo.group 上色、needsPerson 判断「该我动手」。
|
|
1417
|
+
const enriched = attachStages(tasks)
|
|
1418
|
+
sendJson(response, 200, {
|
|
1419
|
+
ok: true,
|
|
1420
|
+
tasks: enriched,
|
|
1421
|
+
stageSummary: stageSummary(tasks),
|
|
1422
|
+
})
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
if (request.method === 'POST' && path === '/tasks') {
|
|
1427
|
+
const body = (await readBody(request)) ?? {}
|
|
1428
|
+
// `mandateBasis`/`mandateSender` 是扁平参数(HTTP 与工具调用同形),
|
|
1429
|
+
// 落到 board 时组装成 `mandate` 对象 —— 校验在 board 侧统一做。
|
|
1430
|
+
// 与 `src/tools.js` 的 bot_task_create 保持**同一套参数名**,
|
|
1431
|
+
// 否则同一个概念会出现两种形状,调用方必须记两套规则。
|
|
1432
|
+
const { mandateBasis, mandateSender, longrun, phases, ...rest } = body
|
|
1433
|
+
const mandate = {}
|
|
1434
|
+
if (mandateBasis !== undefined && mandateBasis !== null) mandate.basis = mandateBasis
|
|
1435
|
+
if (mandateSender !== undefined && mandateSender !== null) mandate.sender = mandateSender
|
|
1436
|
+
|
|
1437
|
+
// ── 长任务「一次到位」(2026-11-09 需求3)───────────────────────────
|
|
1438
|
+
// 用户原话:「建任务的时候选择长任务就是长表单一次到位」。
|
|
1439
|
+
// 此前 longrun 只能建完再转(`/tasks/evolve mode=longrun`),于是想直接
|
|
1440
|
+
// 建长任务的人必须先建一条普通任务、再转一次 —— 两步操作、两次留痕,
|
|
1441
|
+
// 而且第一步派活时唤醒文案里**没有阶段清单**,执行者第一轮就看不到。
|
|
1442
|
+
//
|
|
1443
|
+
// 判定:显式 `longrun:true` 或给了 `phases` 数组 ⇒ 按长任务建。
|
|
1444
|
+
// 校验放在**建任务之前**(非法 phases 不该留下一条半成品任务)。
|
|
1445
|
+
const wantsLongrun = longrun === true || (Array.isArray(phases) && phases.length > 0)
|
|
1446
|
+
const normalizedPhases = wantsLongrun ? normalizePhases(phases, 'TASK_BAD_PHASES') : undefined
|
|
1447
|
+
|
|
1448
|
+
const task = await withService(services, 'board', (b) =>
|
|
1449
|
+
b.create({
|
|
1450
|
+
...rest,
|
|
1451
|
+
byId: rest.byId ?? 'ui',
|
|
1452
|
+
...(Object.keys(mandate).length > 0 ? { mandate } : {}),
|
|
1453
|
+
}),
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
// ── 需求4:任务建好自动配一个团队(可以只有一个人,后续再加)────────
|
|
1457
|
+
// 有负责人(assignee)才有意义:团队要有 lead,而 lead 就是执行这个活的人。
|
|
1458
|
+
// 没指派时**不建**(建一个没有负责人的团队等于制造垃圾数据)。
|
|
1459
|
+
// 失败不阻断:任务已经建好了,团队没建上只是少一份组织关系。
|
|
1460
|
+
let autoTeam
|
|
1461
|
+
if (rest.assignee && rest.autoTeam !== false) {
|
|
1462
|
+
autoTeam = await ensureTeamForBot(services, rest.assignee, rest.byId ?? 'ui')
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// 阶段清单落在 task-meta.jsonl(与 evolve 同一份存储、同一形状)⇒
|
|
1466
|
+
// `buildTaskAnchorText` 自动带上,第一次唤醒就可见,不需要再转。
|
|
1467
|
+
if (wantsLongrun) {
|
|
1468
|
+
appendTaskMeta(services, {
|
|
1469
|
+
kind: 'longrun',
|
|
1470
|
+
taskId: task.id,
|
|
1471
|
+
phases: normalizedPhases,
|
|
1472
|
+
note: '创建时长任务',
|
|
1473
|
+
by: rest.byId ?? 'ui',
|
|
1474
|
+
at: Date.now(),
|
|
1475
|
+
})
|
|
1476
|
+
sendJson(response, 200, {
|
|
1477
|
+
ok: true, task, phases: normalizedPhases, longrun: true, autoTeam,
|
|
1478
|
+
note: '已按长任务创建;此后每次唤醒都会带上阶段清单',
|
|
1479
|
+
})
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
sendJson(response, 200, { ok: true, task, autoTeam })
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (request.method === 'POST' && path === '/tasks/update') {
|
|
1488
|
+
const body = (await readBody(request)) ?? {}
|
|
1489
|
+
const task = await withService(services, 'board', (b) =>
|
|
1490
|
+
b.update({ ...body, byId: body.byId ?? 'ui' }),
|
|
1491
|
+
)
|
|
1492
|
+
sendJson(response, 200, { ok: true, task })
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (request.method === 'POST' && path === '/tasks/accept') {
|
|
1497
|
+
const body = (await readBody(request)) ?? {}
|
|
1498
|
+
const task = await withService(services, 'board', (b) =>
|
|
1499
|
+
b.accept({ ...body, by: body.by ?? 'ui' }),
|
|
1500
|
+
)
|
|
1501
|
+
sendJson(response, 200, { ok: true, task })
|
|
1502
|
+
return
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// ── agent 预设(建 Bot 时可选)─────────────────────────────────────────
|
|
1506
|
+
if (request.method === 'GET' && path === '/presets') {
|
|
1507
|
+
let presets = []
|
|
1508
|
+
let defaultId
|
|
1509
|
+
try {
|
|
1510
|
+
presets = await listPresets(ctx)
|
|
1511
|
+
defaultId = defaultPreset(ctx)
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
sendJson(response, 200, { ok: true, presets: [], defaultId: undefined, note: `预设服务不可用:${error?.message ?? error}` })
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
sendJson(response, 200, {
|
|
1517
|
+
ok: true,
|
|
1518
|
+
presets: presets.map((p) => ({
|
|
1519
|
+
id: p.id,
|
|
1520
|
+
name: p.name ?? p.id,
|
|
1521
|
+
description: p.description,
|
|
1522
|
+
order: p.order,
|
|
1523
|
+
broken: p.broken,
|
|
1524
|
+
})),
|
|
1525
|
+
defaultId,
|
|
1526
|
+
note: presets.length ? undefined : '宿主未提供 agentPresets 服务(Bot 将使用默认预设)',
|
|
1527
|
+
})
|
|
1528
|
+
return
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ── Bot 注册表 ────────────────────────────────────────────────────────
|
|
1532
|
+
// ── 工作区补挂(2026-11-09:「主页面左侧栏还是看不到工作区和对话」)──────
|
|
1533
|
+
//
|
|
1534
|
+
// 新建的 Bot 会话现在会自动挂进「cwd 对应的工作区」(kernel.attachToWorkspace)。
|
|
1535
|
+
// 但**存量 Bot**(本次修复之前建的)从来没挂过,于是永远躺在「未分组」里。
|
|
1536
|
+
// 这个端点做一次性补挂:遍历 roster,把每个 Bot 的会话挂到它 cwd 的工作区。
|
|
1537
|
+
//
|
|
1538
|
+
// 为什么不在装配时自动跑:attachSession 会**写**宿主的工作区注册表,
|
|
1539
|
+
// 插件装配期做写操作是不可接受的副作用(用户没要求、失败还难查)。
|
|
1540
|
+
// 做成显式端点(页面「Bot 档案」有按钮,也可手动调)。
|
|
1541
|
+
if (request.method === 'POST' && path === '/roster/attach-workspaces') {
|
|
1542
|
+
const bots = await withService(services, 'roster', (r) => r.list({ includeRetired: false }))
|
|
1543
|
+
const results = []
|
|
1544
|
+
for (const bot of bots) {
|
|
1545
|
+
if (typeof bot.cwd !== 'string' || bot.cwd.trim() === '') {
|
|
1546
|
+
results.push({ name: bot.name, ok: false, reason: '没有工作目录' })
|
|
1547
|
+
continue
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const attached = await attachToWorkspace(ctx, bot.cwd, bot.sessionId)
|
|
1551
|
+
results.push({
|
|
1552
|
+
name: bot.name,
|
|
1553
|
+
cwd: bot.cwd,
|
|
1554
|
+
sessionId: bot.sessionId,
|
|
1555
|
+
ok: attached,
|
|
1556
|
+
reason: attached ? null : '宿主未提供 workspaceRegistry(版本过旧)',
|
|
1557
|
+
})
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
results.push({ name: bot.name, ok: false, reason: `挂载失败:${error?.message ?? error}` })
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const okCount = results.filter((r) => r.ok).length
|
|
1563
|
+
sendJson(response, 200, {
|
|
1564
|
+
ok: true,
|
|
1565
|
+
total: results.length,
|
|
1566
|
+
attached: okCount,
|
|
1567
|
+
failed: results.length - okCount,
|
|
1568
|
+
results,
|
|
1569
|
+
note: okCount > 0
|
|
1570
|
+
? `已把 ${okCount} 个 Bot 的会话挂进各自工作目录对应的工作区,刷新左侧栏即可看到`
|
|
1571
|
+
: '没有需要补挂的(或宿主版本过旧不支持工作区)',
|
|
1572
|
+
})
|
|
1573
|
+
return
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (request.method === 'GET' && path === '/roster') {
|
|
1577
|
+
const includeRetired = qp(url, 'includeRetired') === '1'
|
|
1578
|
+
const bots = await withService(services, 'roster', (r) => [
|
|
1579
|
+
...r.list(),
|
|
1580
|
+
...(includeRetired ? r.listRetired() : []),
|
|
1581
|
+
])
|
|
1582
|
+
sendJson(response, 200, { ok: true, bots })
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (request.method === 'POST' && path === '/roster') {
|
|
1587
|
+
const body = (await readBody(request)) ?? {}
|
|
1588
|
+
// cwd 兜底(契约 bots06 §2.4):未提供/空白时用 settings.botWorkspaceRoot;
|
|
1589
|
+
// 两者都空则保持原行为 —— roster 自己抛 400。
|
|
1590
|
+
const bodyWithFallback = { ...body }
|
|
1591
|
+
if (typeof body.cwd !== 'string' || body.cwd.trim() === '') {
|
|
1592
|
+
const fallback = services.settings?.botWorkspaceRoot
|
|
1593
|
+
if (typeof fallback === 'string' && fallback.trim() !== '') bodyWithFallback.cwd = fallback.trim()
|
|
1594
|
+
}
|
|
1595
|
+
const bot = await withService(services, 'roster', (r) =>
|
|
1596
|
+
r.create({ ...bodyWithFallback, byId: body.byId ?? 'ui' }),
|
|
1597
|
+
)
|
|
1598
|
+
// 预制记忆同步(建 Bot 与改档案必须一致)
|
|
1599
|
+
const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
|
|
1600
|
+
// 同目录共享检测:**允许**,但必须告知(两个 Bot 在同一目录会互相覆盖文件)
|
|
1601
|
+
let sharedWith = []
|
|
1602
|
+
try {
|
|
1603
|
+
const all = services.roster?.list?.() ?? []
|
|
1604
|
+
sharedWith = all
|
|
1605
|
+
.filter((b) => b.id !== bot.id && b.state !== 'retired' && String(b.cwd) === String(bot.cwd))
|
|
1606
|
+
.map((b) => ({ id: b.id, name: b.name }))
|
|
1607
|
+
} catch {
|
|
1608
|
+
/* 检测失败不影响创建 */
|
|
1609
|
+
}
|
|
1610
|
+
sendJson(response, 200, {
|
|
1611
|
+
ok: true,
|
|
1612
|
+
bot,
|
|
1613
|
+
...(memory ? { memory } : {}),
|
|
1614
|
+
...(sharedWith.length
|
|
1615
|
+
? {
|
|
1616
|
+
warnings: [
|
|
1617
|
+
`目录 ${bot.cwd} 已被 ${sharedWith.map((b) => b.name).join('、')} 使用 —— 两个 Bot 会互相覆盖文件`,
|
|
1618
|
+
],
|
|
1619
|
+
sharedWith,
|
|
1620
|
+
}
|
|
1621
|
+
: {}),
|
|
1622
|
+
})
|
|
1623
|
+
return
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ── Bot 档案(身份 / 经验 / 技能 / 预制记忆)──────────────────────────
|
|
1627
|
+
if (request.method === 'GET' && path === '/roster/profile') {
|
|
1628
|
+
const id = qp(url, 'id')
|
|
1629
|
+
if (!id) {
|
|
1630
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
const detail = await withService(services, 'roster', (r) => r.getProfile(id))
|
|
1634
|
+
sendJson(response, 200, {
|
|
1635
|
+
ok: true,
|
|
1636
|
+
bot: detail.bot,
|
|
1637
|
+
profile: detail.profile ?? {},
|
|
1638
|
+
profilePath: detail.profilePath,
|
|
1639
|
+
managedText: detail.managedText,
|
|
1640
|
+
hasOutside: detail.hasOutside,
|
|
1641
|
+
profileError: detail.profileError,
|
|
1642
|
+
})
|
|
1643
|
+
return
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
if (request.method === 'POST' && path === '/roster/profile') {
|
|
1647
|
+
const body = (await readBody(request)) ?? {}
|
|
1648
|
+
if (!body.id) {
|
|
1649
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
const bot = await withService(services, 'roster', (r) =>
|
|
1653
|
+
r.setProfile({ id: body.id, profile: body.profile ?? {}, byId: body.byId ?? 'ui', expectedRev: body.expectedRev }),
|
|
1654
|
+
)
|
|
1655
|
+
// 预制记忆同步(与建 Bot 共用同一段逻辑)
|
|
1656
|
+
const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
|
|
1657
|
+
sendJson(response, 200, { ok: true, bot, ...(memory ? { memory } : {}) })
|
|
1658
|
+
return
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (request.method === 'POST' && path === '/roster/manage') {
|
|
1662
|
+
const body = (await readBody(request)) ?? {}
|
|
1663
|
+
const byId = body.byId ?? 'ui'
|
|
1664
|
+
const result = await withService(services, 'roster', (r) => {
|
|
1665
|
+
switch (body.action) {
|
|
1666
|
+
case 'rename':
|
|
1667
|
+
return r.rename({ id: body.id, name: body.name, expectedRev: body.expectedRev, byId })
|
|
1668
|
+
case 'retire':
|
|
1669
|
+
return r.retire({ id: body.id, reason: body.reason, byId })
|
|
1670
|
+
case 'setCwd':
|
|
1671
|
+
return r.setCwd({ id: body.id, cwd: body.cwd, expectedRev: body.expectedRev, byId })
|
|
1672
|
+
// 改造07(借鉴 Super-agent prepare command):设置/清除开工准备命令
|
|
1673
|
+
case 'prepare':
|
|
1674
|
+
return r.setPrepareCommand({
|
|
1675
|
+
id: body.id,
|
|
1676
|
+
prepareCommand: body.prepareCommand,
|
|
1677
|
+
expectedRev: body.expectedRev,
|
|
1678
|
+
byId,
|
|
1679
|
+
})
|
|
1680
|
+
case 'claim':
|
|
1681
|
+
return r.claim({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
1682
|
+
case 'release':
|
|
1683
|
+
return r.release({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
1684
|
+
case 'heartbeat':
|
|
1685
|
+
return r.heartbeat({ id: body.id, owner: byId, expectedRev: body.expectedRev })
|
|
1686
|
+
default:
|
|
1687
|
+
throw new Error(`未知 action: ${body.action}`)
|
|
1688
|
+
}
|
|
1689
|
+
})
|
|
1690
|
+
sendJson(response, 200, { ok: true, bot: result })
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// ── 房间 ──────────────────────────────────────────────────────────────
|
|
1695
|
+
if (request.method === 'GET' && path === '/rooms') {
|
|
1696
|
+
// includeArchived=1 时连归档房一起返回(超级管理员的"回收站"视图)
|
|
1697
|
+
const rooms = await withService(services, 'room', (r) =>
|
|
1698
|
+
r.list({ includeArchived: qp(url, 'includeArchived') === '1' }),
|
|
1699
|
+
)
|
|
1700
|
+
sendJson(response, 200, { ok: true, rooms })
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (request.method === 'POST' && path === '/rooms') {
|
|
1705
|
+
const body = (await readBody(request)) ?? {}
|
|
1706
|
+
const room = await withService(services, 'room', (r) =>
|
|
1707
|
+
r.create({ ...body, byId: body.byId ?? 'ui' }),
|
|
1708
|
+
)
|
|
1709
|
+
sendJson(response, 200, { ok: true, room })
|
|
1710
|
+
return
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (request.method === 'GET' && path === '/rooms/messages') {
|
|
1714
|
+
const roomId = qp(url, 'roomId')
|
|
1715
|
+
if (!roomId) {
|
|
1716
|
+
sendJson(response, 400, { ok: false, reason: 'roomId 必填' })
|
|
1717
|
+
return
|
|
1718
|
+
}
|
|
1719
|
+
const messages = await withService(services, 'room', (r) =>
|
|
1720
|
+
r.listMessages({
|
|
1721
|
+
roomId,
|
|
1722
|
+
sinceSeq: qn(url, 'sinceSeq'),
|
|
1723
|
+
limit: qn(url, 'limit'),
|
|
1724
|
+
withBody: qp(url, 'withBody') === '1',
|
|
1725
|
+
}),
|
|
1726
|
+
)
|
|
1727
|
+
sendJson(response, 200, { ok: true, messages })
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (request.method === 'POST' && path === '/rooms/post') {
|
|
1732
|
+
const body = (await readBody(request)) ?? {}
|
|
1733
|
+
const message = await withService(services, 'room', (r) =>
|
|
1734
|
+
r.post({ ...body, from: body.from ?? 'ui' }),
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
// ── 发言后自动唤醒房间内的 Bot ──
|
|
1738
|
+
//
|
|
1739
|
+
// 为什么需要这步:主管报"我在群聊里说话,Bot 没有反应"。
|
|
1740
|
+
// 根因是 /rooms/post 只落消息、不做任何投递,房间变成"死信箱"——
|
|
1741
|
+
// 消息写进去了,但没有任何机制告诉 Bot "有人在说话"。
|
|
1742
|
+
//
|
|
1743
|
+
// 唤醒策略(与 dispatcher 的 dedupKey 一致):
|
|
1744
|
+
// - 只唤醒**非本人**的成员(不唤醒发送者自己);
|
|
1745
|
+
// - 房间设置 `allowBotAutoPost` 为 false 时跳过(给用户关开关的权利);
|
|
1746
|
+
// - dedupKey 用消息 ID,天然幂等,同一消息不会重复唤醒;
|
|
1747
|
+
// - dispatcher 缺失时降级为「只落消息不唤醒」,不抛错。
|
|
1748
|
+
const room = await withService(services, 'room', (r) => r.get(message.roomId))
|
|
1749
|
+
const senderSession = body.from ?? 'ui'
|
|
1750
|
+
const members = room?.members ?? []
|
|
1751
|
+
const skipSelf = (senderSession !== 'ui' && senderSession)
|
|
1752
|
+
const toWake = members.filter((m) => m !== skipSelf)
|
|
1753
|
+
const wakeByTarget = new Map()
|
|
1754
|
+
if (toWake.length > 0 && room?.settings?.allowBotAutoPost !== false) {
|
|
1755
|
+
for (const target of toWake) {
|
|
1756
|
+
const result = await withService(services, 'dispatcher', (d) => d.notify({
|
|
1757
|
+
target,
|
|
1758
|
+
reason: 'room-message',
|
|
1759
|
+
dedupKey: `room-msg:${message.id}:${target}`,
|
|
1760
|
+
text: `[房间「${room.name ?? message.roomId}」有新消息] ${message.text.slice(0, 200)}`,
|
|
1761
|
+
})).catch(() => {
|
|
1762
|
+
// dispatcher 降级:唤醒失败不影响消息已落盘的事实
|
|
1763
|
+
return undefined
|
|
1764
|
+
})
|
|
1765
|
+
wakeByTarget.set(target, result)
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ── 补投(契约 bots05 §2.5):首轮投递失败的成员,先复活会话再用新
|
|
1770
|
+
// dedupKey 重投一次(`room-msg-retry:<msgId>:<target>`)。
|
|
1771
|
+
//
|
|
1772
|
+
// 为什么只补投**首轮失败**的:首轮已成功(sent/pending)的成员若再投一次,
|
|
1773
|
+
// 用户会收到同一消息的两次唤醒 —— 那是噪音,不是修复。
|
|
1774
|
+
// 为什么只补投本平台注册的 Bot 会话:房间成员也可能是主对话/人类会话,
|
|
1775
|
+
// 盲目 resume 会把非 Bot 会话拉起来(副作用不可控)。
|
|
1776
|
+
const revives = []
|
|
1777
|
+
for (const target of toWake) {
|
|
1778
|
+
const first = wakeByTarget.get(target)
|
|
1779
|
+
const firstFailed = !first || first.ok === false
|
|
1780
|
+
if (!firstFailed) continue
|
|
1781
|
+
if (!services.roster?.hasSession?.(target)) continue
|
|
1782
|
+
const revive = await ensureLive(ctx, target)
|
|
1783
|
+
const entry = { target, live: revive.live, revived: revive.revived, retried: false }
|
|
1784
|
+
if (!revive.live) {
|
|
1785
|
+
entry.reason = revive.reason ?? 'revive-failed'
|
|
1786
|
+
} else {
|
|
1787
|
+
entry.wake = await withService(services, 'dispatcher', (d) => d.notify({
|
|
1788
|
+
target,
|
|
1789
|
+
reason: 'room-message',
|
|
1790
|
+
dedupKey: `room-msg-retry:${message.id}:${target}`,
|
|
1791
|
+
text: `[房间「${room.name ?? message.roomId}」有新消息] ${message.text.slice(0, 200)}`,
|
|
1792
|
+
})).catch((error) => ({ ok: false, result: 'failed', reason: String(error?.message ?? error) }))
|
|
1793
|
+
entry.retried = true
|
|
1794
|
+
}
|
|
1795
|
+
revives.push(entry)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// ── 改造07(借鉴 Super-agent Subscription 扇出):
|
|
1799
|
+
// 订阅该房间的任务,自动收到这条消息作为上下文。
|
|
1800
|
+
// **只加上下文、不改任务状态、不授予开工权力**——谁有权开工仍由 claim 决定。
|
|
1801
|
+
let fanout
|
|
1802
|
+
if (services.subscription) {
|
|
1803
|
+
try {
|
|
1804
|
+
fanout = await services.subscription.fanout({
|
|
1805
|
+
roomId: message.roomId,
|
|
1806
|
+
message,
|
|
1807
|
+
wake: false, // 上下文到达不等于要打扰执行者
|
|
1808
|
+
by: body.from ?? 'ui',
|
|
1809
|
+
})
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
fanout = { error: `fanout-failed:${error?.message ?? error}` }
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
sendJson(response, 200, { ok: true, message, wakes: toWake.length, revives, fanout })
|
|
1816
|
+
return
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (request.method === 'GET' && path === '/rooms/body') {
|
|
1820
|
+
const roomId = qp(url, 'roomId')
|
|
1821
|
+
const messageId = qp(url, 'messageId')
|
|
1822
|
+
if (!roomId || !messageId) {
|
|
1823
|
+
sendJson(response, 400, { ok: false, reason: 'roomId 与 messageId 必填' })
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
const text = await withService(services, 'room', (r) => r.readBody({ roomId, messageId }))
|
|
1827
|
+
sendJson(response, 200, { ok: true, text })
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ── 记忆 ──────────────────────────────────────────────────────────────
|
|
1832
|
+
if (request.method === 'GET' && path === '/memory') {
|
|
1833
|
+
const layer = qp(url, 'layer')
|
|
1834
|
+
const query = qp(url, 'query')
|
|
1835
|
+
const botId = qp(url, 'botId')
|
|
1836
|
+
const entries = await withService(services, 'memory', (m) =>
|
|
1837
|
+
query ? m.search({
|
|
1838
|
+
query,
|
|
1839
|
+
layers: layer ? [layer] : undefined,
|
|
1840
|
+
limit: qn(url, 'limit'),
|
|
1841
|
+
botId,
|
|
1842
|
+
includeArchived: qp(url, 'includeArchived') === '1',
|
|
1843
|
+
})
|
|
1844
|
+
: m.list({
|
|
1845
|
+
layer,
|
|
1846
|
+
limit: qn(url, 'limit'),
|
|
1847
|
+
botId,
|
|
1848
|
+
cwd: qp(url, 'cwd'),
|
|
1849
|
+
includeArchived: qp(url, 'includeArchived') === '1',
|
|
1850
|
+
onlyArchived: qp(url, 'onlyArchived') === '1',
|
|
1851
|
+
}),
|
|
1852
|
+
)
|
|
1853
|
+
sendJson(response, 200, { ok: true, entries })
|
|
1854
|
+
return
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
if (request.method === 'POST' && path === '/memory') {
|
|
1858
|
+
const body = (await readBody(request)) ?? {}
|
|
1859
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
1860
|
+
m.add({ ...body, by: body.by ?? 'ui' }),
|
|
1861
|
+
)
|
|
1862
|
+
sendJson(response, 200, { ok: true, entry })
|
|
1863
|
+
return
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (request.method === 'GET' && path === '/memory/stats') {
|
|
1867
|
+
const stats = await withService(services, 'memory', (m) => m.stats())
|
|
1868
|
+
sendJson(response, 200, { ok: true, stats })
|
|
1869
|
+
return
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (request.method === 'GET' && path === '/memory/context') {
|
|
1873
|
+
// cwd:按项目裁剪注入内容(不传=全局口径,与改造前逐字一致)
|
|
1874
|
+
const text = await withService(services, 'memory', (m) => m.contextFor({
|
|
1875
|
+
branch: qp(url, 'branch'),
|
|
1876
|
+
cwd: qp(url, 'cwd'),
|
|
1877
|
+
}))
|
|
1878
|
+
sendJson(response, 200, { ok: true, text })
|
|
1879
|
+
return
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ── 成果聚合(只读)────────────────────────────────────────────────────
|
|
1883
|
+
if (request.method === 'GET' && path === '/artifacts') {
|
|
1884
|
+
const q = qp(url, 'q')
|
|
1885
|
+
const ownerId = qp(url, 'ownerId')
|
|
1886
|
+
const status = qp(url, 'status')
|
|
1887
|
+
const limit = qn(url, 'limit')
|
|
1888
|
+
const cap = limit !== undefined && limit > 0 ? Math.min(limit, 1000) : 200
|
|
1889
|
+
const kw = q ? String(q).toLowerCase() : ''
|
|
1890
|
+
const rows = await withService(services, 'board', (b) => b.list({ status: status ?? undefined }))
|
|
1891
|
+
const artifacts = []
|
|
1892
|
+
let itemCount = 0
|
|
1893
|
+
let danglingSkipped = 0
|
|
1894
|
+
for (const t of rows) {
|
|
1895
|
+
if (ownerId && t.ownerId !== ownerId) continue
|
|
1896
|
+
const items = normalizeArtifactItems(t.artifacts)
|
|
1897
|
+
if (items.length === 0) continue
|
|
1898
|
+
if (kw) {
|
|
1899
|
+
const hay = `${t.subject ?? ''} ${items.join(' ')}`.toLowerCase()
|
|
1900
|
+
if (!hay.includes(kw)) continue
|
|
1901
|
+
}
|
|
1902
|
+
itemCount += items.length
|
|
1903
|
+
artifacts.push({
|
|
1904
|
+
taskId: t.id,
|
|
1905
|
+
subject: t.subject,
|
|
1906
|
+
status: t.status,
|
|
1907
|
+
ownerId: t.ownerId,
|
|
1908
|
+
accepted: t.accepted === true,
|
|
1909
|
+
completedAt: t.completedAt ?? t.createdAt,
|
|
1910
|
+
items,
|
|
1911
|
+
})
|
|
1912
|
+
}
|
|
1913
|
+
artifacts.sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0))
|
|
1914
|
+
const sliced = artifacts.slice(0, cap)
|
|
1915
|
+
sendJson(response, 200, {
|
|
1916
|
+
ok: true,
|
|
1917
|
+
artifacts: sliced,
|
|
1918
|
+
stats: { tasks: sliced.length, items: sliced.reduce((n, a) => n + a.items.length, 0) },
|
|
1919
|
+
note: `已扫描 ${rows.length} 个任务${danglingSkipped ? `,跳过 ${danglingSkipped} 个悬空引用` : ''}`,
|
|
1920
|
+
})
|
|
1921
|
+
return
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// ── 数据浏览(只读,绝不提供写接口)────────────────────────────────────
|
|
1925
|
+
if (request.method === 'GET' && path === '/data/files') {
|
|
1926
|
+
const dirParam = qp(url, 'dir')
|
|
1927
|
+
const boardSvc = services.board
|
|
1928
|
+
const stateDir = boardSvc?.paths?.stateDir ?? services.roster?.paths?.stateDir
|
|
1929
|
+
if (!stateDir) {
|
|
1930
|
+
sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
let root = stateDir
|
|
1934
|
+
if (dirParam !== undefined && dirParam !== '') {
|
|
1935
|
+
if (!safeSegment(dirParam)) {
|
|
1936
|
+
sendJson(response, 400, { ok: false, reason: '非法子目录名' })
|
|
1937
|
+
return
|
|
1938
|
+
}
|
|
1939
|
+
root = join(stateDir, dirParam)
|
|
1940
|
+
}
|
|
1941
|
+
const files = []
|
|
1942
|
+
let note
|
|
1943
|
+
try {
|
|
1944
|
+
for (const name of readdirSync(root)) {
|
|
1945
|
+
if (!/\.(jsonl|json)$/i.test(name)) continue
|
|
1946
|
+
if (!safeSegment(name)) continue
|
|
1947
|
+
let st
|
|
1948
|
+
try {
|
|
1949
|
+
st = statSync(join(root, name))
|
|
1950
|
+
} catch {
|
|
1951
|
+
continue
|
|
1952
|
+
}
|
|
1953
|
+
if (!st.isFile()) continue
|
|
1954
|
+
const kind = /\.jsonl$/i.test(name) ? 'jsonl' : 'json'
|
|
1955
|
+
let lines = 0
|
|
1956
|
+
let truncated = false
|
|
1957
|
+
if (kind === 'jsonl') {
|
|
1958
|
+
if (st.size > 8 * 1024 * 1024) {
|
|
1959
|
+
lines = null
|
|
1960
|
+
truncated = true
|
|
1961
|
+
} else {
|
|
1962
|
+
try {
|
|
1963
|
+
const text = readFileSync(join(root, name), 'utf8')
|
|
1964
|
+
lines = text === '' ? 0 : text.split('\n').filter((x) => x.trim() !== '').length
|
|
1965
|
+
} catch {
|
|
1966
|
+
lines = null
|
|
1967
|
+
truncated = true
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
const row = { name, bytes: st.size, lines, mtime: st.mtimeMs, kind }
|
|
1972
|
+
if (truncated) row.truncated = true
|
|
1973
|
+
files.push(row)
|
|
1974
|
+
}
|
|
1975
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
1976
|
+
note = `共 ${files.length} 个文件(只读浏览;写请走域服务与 CAS)`
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
note = `目录不可读:${error?.message ?? error}`
|
|
1979
|
+
}
|
|
1980
|
+
sendJson(response, 200, { ok: true, stateDir: root, files, note })
|
|
1981
|
+
return
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// ── 单文件只读预览(契约 bots06 §2.2)────────────────────────────────
|
|
1985
|
+
// 只允许在状态目录内读:先按段校验(防 `..` / 通配符),再 resolve 后
|
|
1986
|
+
// 断言仍在 stateDir 内(防符号链接与绝对路径逃逸)。**绝不写任何文件**。
|
|
1987
|
+
if (request.method === 'GET' && path === '/data/file') {
|
|
1988
|
+
const rel = qp(url, 'path')
|
|
1989
|
+
if (!rel) {
|
|
1990
|
+
sendJson(response, 400, { ok: false, reason: 'path 必填' })
|
|
1991
|
+
return
|
|
1992
|
+
}
|
|
1993
|
+
const raw = String(rel)
|
|
1994
|
+
// 含 `..` 一律拒绝(段级判定对绝对路径不适用,但仍先挡穿越)。
|
|
1995
|
+
if (raw.includes('..')) {
|
|
1996
|
+
sendJson(response, 400, { ok: false, reason: '非法路径' })
|
|
1997
|
+
return
|
|
1998
|
+
}
|
|
1999
|
+
// 相对路径:逐段必须都是安全片段(空段 = 绝对路径 / 重复斜杠,另作处理)。
|
|
2000
|
+
const isAbs = raw.startsWith('/')
|
|
2001
|
+
if (!isAbs) {
|
|
2002
|
+
const segs = raw.split('/')
|
|
2003
|
+
if (segs.some((s) => s === '' || s === '.' || s === '..' || !SAFE_SEGMENT.test(s))) {
|
|
2004
|
+
sendJson(response, 400, { ok: false, reason: '非法路径' })
|
|
2005
|
+
return
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
const stateDir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
|
|
2009
|
+
if (!stateDir) {
|
|
2010
|
+
sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
|
|
2011
|
+
return
|
|
2012
|
+
}
|
|
2013
|
+
const root = resolve(stateDir)
|
|
2014
|
+
const abs = resolve(root, raw)
|
|
2015
|
+
// 必须在 stateDir 内(含 stateDir 本身,但那必然是目录 → 后面 404)。
|
|
2016
|
+
if (abs !== root && !abs.startsWith(root + pathSep)) {
|
|
2017
|
+
sendJson(response, 403, { ok: false, reason: '越权访问(路径超出状态目录)' })
|
|
2018
|
+
return
|
|
2019
|
+
}
|
|
2020
|
+
let st
|
|
2021
|
+
try {
|
|
2022
|
+
st = statSync(abs)
|
|
2023
|
+
} catch {
|
|
2024
|
+
sendJson(response, 404, { ok: false, reason: '文件不存在' })
|
|
2025
|
+
return
|
|
2026
|
+
}
|
|
2027
|
+
if (!st.isFile()) {
|
|
2028
|
+
sendJson(response, 404, { ok: false, reason: '文件不存在' })
|
|
2029
|
+
return
|
|
2030
|
+
}
|
|
2031
|
+
let buf
|
|
2032
|
+
try {
|
|
2033
|
+
buf = readFileSync(abs)
|
|
2034
|
+
} catch {
|
|
2035
|
+
sendJson(response, 404, { ok: false, reason: '文件不存在' })
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
if (isBinaryBuffer(buf)) {
|
|
2039
|
+
sendJson(response, 415, { ok: false, reason: '不支持的文件类型(仅预览文本)' })
|
|
2040
|
+
return
|
|
2041
|
+
}
|
|
2042
|
+
const content = buf.toString('utf8')
|
|
2043
|
+
const bytes = buf.length
|
|
2044
|
+
const PREVIEW_MAX = 100 * 1024
|
|
2045
|
+
let shown = content
|
|
2046
|
+
let truncated = false
|
|
2047
|
+
if (bytes > PREVIEW_MAX) {
|
|
2048
|
+
truncated = true
|
|
2049
|
+
shown = buf.subarray(0, PREVIEW_MAX).toString('utf8')
|
|
2050
|
+
}
|
|
2051
|
+
sendJson(response, 200, {
|
|
2052
|
+
ok: true,
|
|
2053
|
+
path: raw,
|
|
2054
|
+
content: shown,
|
|
2055
|
+
bytes,
|
|
2056
|
+
truncated,
|
|
2057
|
+
...(truncated
|
|
2058
|
+
? { shownBytes: Buffer.byteLength(shown, 'utf8'), totalBytes: bytes }
|
|
2059
|
+
: {}),
|
|
2060
|
+
mime: mimeFor(raw),
|
|
2061
|
+
})
|
|
2062
|
+
return
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
if (request.method === 'GET' && path === '/data/rows') {
|
|
2066
|
+
const name = qp(url, 'name')
|
|
2067
|
+
if (!name) {
|
|
2068
|
+
sendJson(response, 400, { ok: false, reason: 'name 必填' })
|
|
2069
|
+
return
|
|
2070
|
+
}
|
|
2071
|
+
if (!safeSegment(name) || !/\.(jsonl|json)$/i.test(name)) {
|
|
2072
|
+
sendJson(response, 400, { ok: false, reason: '非法文件名' })
|
|
2073
|
+
return
|
|
2074
|
+
}
|
|
2075
|
+
const dirParam = qp(url, 'dir')
|
|
2076
|
+
if (dirParam !== undefined && dirParam !== '' && !safeSegment(dirParam)) {
|
|
2077
|
+
sendJson(response, 400, { ok: false, reason: '非法子目录名' })
|
|
2078
|
+
return
|
|
2079
|
+
}
|
|
2080
|
+
const boardSvc = services.board
|
|
2081
|
+
const stateDir = boardSvc?.paths?.stateDir ?? services.roster?.paths?.stateDir
|
|
2082
|
+
if (!stateDir) {
|
|
2083
|
+
sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
|
|
2084
|
+
return
|
|
2085
|
+
}
|
|
2086
|
+
const root = dirParam ? join(stateDir, dirParam) : stateDir
|
|
2087
|
+
const abs = join(root, name)
|
|
2088
|
+
const limitRaw = qn(url, 'limit')
|
|
2089
|
+
const limit = limitRaw !== undefined && limitRaw > 0 ? Math.min(limitRaw, 200) : 50
|
|
2090
|
+
const offsetRaw = qn(url, 'offset')
|
|
2091
|
+
const offset = offsetRaw !== undefined && offsetRaw > 0 ? offsetRaw : 0
|
|
2092
|
+
let text
|
|
2093
|
+
try {
|
|
2094
|
+
text = readFileSync(abs, 'utf8')
|
|
2095
|
+
} catch (error) {
|
|
2096
|
+
sendJson(response, 404, { ok: false, reason: `读取失败:${error?.message ?? error}` })
|
|
2097
|
+
return
|
|
2098
|
+
}
|
|
2099
|
+
const all = text.split('\n').filter((line) => line.trim() !== '')
|
|
2100
|
+
const page = all.slice(offset, offset + limit)
|
|
2101
|
+
const rowsOut = page.map((raw, i) => {
|
|
2102
|
+
const row = { _line: offset + i + 1, _raw: raw.length > 4000 ? raw.slice(0, 4000) : raw }
|
|
2103
|
+
if (raw.length > 4000) row._truncated = true
|
|
2104
|
+
if (/\.jsonl$/i.test(name)) {
|
|
2105
|
+
try {
|
|
2106
|
+
JSON.parse(raw)
|
|
2107
|
+
} catch {
|
|
2108
|
+
row._parseError = true
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return row
|
|
2112
|
+
})
|
|
2113
|
+
sendJson(response, 200, {
|
|
2114
|
+
ok: true,
|
|
2115
|
+
name,
|
|
2116
|
+
total: all.length,
|
|
2117
|
+
offset,
|
|
2118
|
+
rows: rowsOut,
|
|
2119
|
+
note: `共 ${all.length} 行,显示 ${all.length ? offset + 1 : 0}-${Math.min(offset + limit, all.length)}`,
|
|
2120
|
+
})
|
|
2121
|
+
return
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// ── 任务依赖图(只读)──────────────────────────────────────────────────
|
|
2125
|
+
if (request.method === 'GET' && path === '/tasks/graph') {
|
|
2126
|
+
const id = qp(url, 'id')
|
|
2127
|
+
const all = await withService(services, 'board', (b) =>
|
|
2128
|
+
b.list({}).filter((t) => t.status !== 'deleted'))
|
|
2129
|
+
const byIdMap = new Map(all.map((t) => [t.id, t]))
|
|
2130
|
+
const dependents = new Map()
|
|
2131
|
+
let danglingEdges = 0
|
|
2132
|
+
for (const t of all) {
|
|
2133
|
+
for (const dep of t.blockedBy ?? []) {
|
|
2134
|
+
if (!byIdMap.has(dep)) {
|
|
2135
|
+
danglingEdges += 1
|
|
2136
|
+
continue
|
|
2137
|
+
}
|
|
2138
|
+
if (!dependents.has(dep)) dependents.set(dep, [])
|
|
2139
|
+
dependents.get(dep).push(t.id)
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
let picked
|
|
2143
|
+
if (id) {
|
|
2144
|
+
if (!byIdMap.has(id)) {
|
|
2145
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2146
|
+
return
|
|
2147
|
+
}
|
|
2148
|
+
const keep = new Set()
|
|
2149
|
+
const up = [id]
|
|
2150
|
+
while (up.length) {
|
|
2151
|
+
const cur = up.pop()
|
|
2152
|
+
if (keep.has(cur)) continue
|
|
2153
|
+
keep.add(cur)
|
|
2154
|
+
const t = byIdMap.get(cur)
|
|
2155
|
+
if (!t) continue
|
|
2156
|
+
for (const dep of t.blockedBy ?? []) {
|
|
2157
|
+
if (byIdMap.has(dep) && !keep.has(dep)) up.push(dep)
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
const down = [id]
|
|
2161
|
+
while (down.length) {
|
|
2162
|
+
const cur = down.pop()
|
|
2163
|
+
for (const child of dependents.get(cur) ?? []) {
|
|
2164
|
+
if (!keep.has(child)) {
|
|
2165
|
+
keep.add(child)
|
|
2166
|
+
down.push(child)
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
picked = all.filter((t) => keep.has(t.id))
|
|
2171
|
+
} else {
|
|
2172
|
+
picked = all
|
|
2173
|
+
}
|
|
2174
|
+
// 字段恒定出现(前端画图不判 undefined):ownerId/priority 缺省给 null/0
|
|
2175
|
+
const nodes = picked.map((t) => ({
|
|
2176
|
+
id: t.id,
|
|
2177
|
+
subject: t.subject,
|
|
2178
|
+
status: t.status,
|
|
2179
|
+
ownerId: t.ownerId ?? null,
|
|
2180
|
+
progress: t.progress ?? 0,
|
|
2181
|
+
priority: t.priority ?? 0,
|
|
2182
|
+
}))
|
|
2183
|
+
const pickedIds = new Set(nodes.map((n) => n.id))
|
|
2184
|
+
const edges = []
|
|
2185
|
+
for (const t of picked) {
|
|
2186
|
+
for (const dep of t.blockedBy ?? []) {
|
|
2187
|
+
if (!pickedIds.has(dep)) continue
|
|
2188
|
+
edges.push({ from: t.id, to: dep, kind: 'blockedBy' })
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
sendJson(response, 200, {
|
|
2192
|
+
ok: true,
|
|
2193
|
+
nodes,
|
|
2194
|
+
edges,
|
|
2195
|
+
note: `节点 ${nodes.length} 个,边 ${edges.length} 条${danglingEdges ? `,忽略悬空依赖 ${danglingEdges} 条` : ''}`,
|
|
2196
|
+
})
|
|
2197
|
+
return
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
|
|
2201
|
+
if (request.method === 'GET' && path === '/tasks/detail') {
|
|
2202
|
+
const id = qp(url, 'id')
|
|
2203
|
+
if (!id) {
|
|
2204
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2205
|
+
return
|
|
2206
|
+
}
|
|
2207
|
+
const detail = await withService(services, 'board', (b) => ({
|
|
2208
|
+
task: b.get(id),
|
|
2209
|
+
history: typeof b.history === 'function' ? b.history(id) : [],
|
|
2210
|
+
}))
|
|
2211
|
+
if (!detail.task) {
|
|
2212
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2213
|
+
return
|
|
2214
|
+
}
|
|
2215
|
+
sendJson(response, 200, {
|
|
2216
|
+
ok: true,
|
|
2217
|
+
// 改造07(借鉴 Super-agent stage table):阶段机结果附在详情里
|
|
2218
|
+
stageInfo: deriveStage(detail.task),
|
|
2219
|
+
...detail,
|
|
2220
|
+
})
|
|
2221
|
+
return
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (request.method === 'POST' && path === '/tasks/assign') {
|
|
2225
|
+
const body = (await readBody(request)) ?? {}
|
|
2226
|
+
if (!body.id || !body.ownerId) {
|
|
2227
|
+
sendJson(response, 400, { ok: false, reason: 'id 与 ownerId 必填' })
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
// 指派 = 用 ownerId 作为 claim 的 byId(board 的 claim 把 by 记为 owner)
|
|
2231
|
+
// 改造07:claim 前先跑 Bot 的 prepare command(借鉴 Super-agent)。
|
|
2232
|
+
// 准备失败 → **不 claim**,任务保持原状(绝不产生「claim 了但没准备好」的半成品)。
|
|
2233
|
+
const bot = await withService(services, 'roster', (r) => r.get(body.ownerId))
|
|
2234
|
+
let prepare = { ok: true, skipped: true }
|
|
2235
|
+
if (bot && typeof bot.prepareCommand === 'string' && bot.prepareCommand.trim()) {
|
|
2236
|
+
prepare = await runPrepareCommand(bot)
|
|
2237
|
+
if (!prepare.ok) {
|
|
2238
|
+
const current = await withService(services, 'board', (b) => b.get(body.id))
|
|
2239
|
+
sendJson(response, 200, {
|
|
2240
|
+
ok: false,
|
|
2241
|
+
code: 'PREPARE_FAILED',
|
|
2242
|
+
task: current ?? undefined,
|
|
2243
|
+
prepare,
|
|
2244
|
+
reason: `开工准备失败(${prepare.error ?? '未知原因'}),任务未认领`
|
|
2245
|
+
+ `。准备命令:${prepare.command ?? bot.prepareCommand}`,
|
|
2246
|
+
})
|
|
2247
|
+
return
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
const task = await withService(services, 'board', (b) =>
|
|
2251
|
+
b.update({
|
|
2252
|
+
id: body.id,
|
|
2253
|
+
expectedRev: body.expectedRev,
|
|
2254
|
+
action: 'claim',
|
|
2255
|
+
byId: body.ownerId,
|
|
2256
|
+
...(body.leaseMs ? { leaseMs: body.leaseMs } : {}),
|
|
2257
|
+
}),
|
|
2258
|
+
)
|
|
2259
|
+
// 指派后立刻定向唤醒(dedupKey 幂等:同任务同原因只唤一次)
|
|
2260
|
+
let wake
|
|
2261
|
+
let revive
|
|
2262
|
+
if (services.dispatcher && body.wake !== false) {
|
|
2263
|
+
// 契约 bots05 §2.3:投递前先确保会话 live(重启后宿主懒加载 → 否则必然
|
|
2264
|
+
// agent-not-live)。复活失败**不阻断**:仍照原样 notify,让 wake 如实报 reason。
|
|
2265
|
+
revive = await ensureLive(ctx, body.ownerId)
|
|
2266
|
+
// 2026-11-09:投递能力预检 —— 没有模型路由的会话**唤醒了也必然失败**,
|
|
2267
|
+
// 且失败发生在宿主内部、wake 仍记 sent ⇒ 任务静默卡在「排队中」。
|
|
2268
|
+
// 这里改成「先判、不投、把原因写到任务上」,让它以 person/delivery 显形。
|
|
2269
|
+
const deliverable = assertDeliverable(ctx, body.ownerId)
|
|
2270
|
+
if (!deliverable.ok) {
|
|
2271
|
+
const marked = await markDeliveryFailure(services, task.id, deliverable.reason, body.ownerId)
|
|
2272
|
+
wake = { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: body.ownerId }
|
|
2273
|
+
sendJson(response, 200, {
|
|
2274
|
+
ok: true, task, wake, prepare, marked, ...reviveFields(revive),
|
|
2275
|
+
})
|
|
2276
|
+
return
|
|
2277
|
+
}
|
|
2278
|
+
// 投递前取基线:投递后靠它判断「执行者到底动没动」
|
|
2279
|
+
const assignBaseline = await takeWatermarkBaseline(ctx, body.ownerId)
|
|
2280
|
+
wake = services.dispatcher.notify({
|
|
2281
|
+
target: body.ownerId,
|
|
2282
|
+
reason: 'task-assigned',
|
|
2283
|
+
dedupKey: `task-assigned:${task.id}:${task.rev}`,
|
|
2284
|
+
// 2026-11-09:改成**锚定文案**(与 /tasks/wake、补充说明同一套)。
|
|
2285
|
+
// 原来的 `新任务 t-x:标题\n描述` 有三处不如它:
|
|
2286
|
+
// ① 长任务的阶段清单不在里面 —— 第一次派活执行者看不到路线(需求3 的核心)
|
|
2287
|
+
// ② 没有写入边界 / 依赖 / 授权依据(mandate)—— 越界风险
|
|
2288
|
+
// ③ 与后面每次唤醒的文案不同源,容易出现「第一轮和后面几轮看到的活不一样」
|
|
2289
|
+
text: buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id)),
|
|
2290
|
+
})
|
|
2291
|
+
// wake 层就失败了(agent-not-live / dispatcher 拒收)→ 同样落到任务上。
|
|
2292
|
+
if (wake && wake.result === 'failed') {
|
|
2293
|
+
await markDeliveryFailure(services, task.id, wake.reason ?? 'wake-failed', body.ownerId)
|
|
2294
|
+
} else if (wake) {
|
|
2295
|
+
// 2026-11-09:投递真的成功了 ⇒ 清掉上一次的失败留痕(否则任务会
|
|
2296
|
+
// 永远停在「输入投递失败」,阶段机被过期事实劫持,见
|
|
2297
|
+
// `clearDeliveryFailure` 的说明)。
|
|
2298
|
+
await clearDeliveryFailure(services, task.id, wake)
|
|
2299
|
+
}
|
|
2300
|
+
// 发出去了就等回执:过了宽限期会话水位还没涨 ⇒ 判「未开始动作」
|
|
2301
|
+
armDeliveryWatch(services, task.id, body.ownerId, assignBaseline, wake)
|
|
2302
|
+
}
|
|
2303
|
+
sendJson(response, 200, { ok: true, task, wake, prepare, ...reviveFields(revive) })
|
|
2304
|
+
return
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (request.method === 'POST' && path === '/tasks/expire-leases') {
|
|
2308
|
+
// ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
|
|
2309
|
+
// owner 崩溃/失联后任务永久停在 in_progress。现在通过 API 暴露回收能力。
|
|
2310
|
+
const body = (await readBody(request)) ?? {}
|
|
2311
|
+
const recycled = await withService(services, 'board', (b) =>
|
|
2312
|
+
b.expireLeases({ now: Number.isFinite(body.now) ? body.now : undefined }),
|
|
2313
|
+
)
|
|
2314
|
+
sendJson(response, 200, { ok: true, recycled, note: recycled.length ? `回收了 ${recycled.length} 个过期任务` : '没有过期租约需要回收' })
|
|
2315
|
+
return
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
if (request.method === 'POST' && path === '/tasks/abort') {
|
|
2319
|
+
const body = (await readBody(request)) ?? {}
|
|
2320
|
+
if (!body.id) {
|
|
2321
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2322
|
+
return
|
|
2323
|
+
}
|
|
2324
|
+
// 终止 = block + 原因(board 的 block 会置 blocked 并留 reason;可 reopen)
|
|
2325
|
+
const task = await withService(services, 'board', (b) =>
|
|
2326
|
+
b.update({
|
|
2327
|
+
id: body.id,
|
|
2328
|
+
expectedRev: body.expectedRev,
|
|
2329
|
+
action: 'block',
|
|
2330
|
+
note: body.reason ?? '用户终止',
|
|
2331
|
+
byId: body.byId ?? 'ui',
|
|
2332
|
+
}),
|
|
2333
|
+
)
|
|
2334
|
+
sendJson(response, 200, { ok: true, task })
|
|
2335
|
+
return
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
if (request.method === 'POST' && path === '/tasks/edit') {
|
|
2339
|
+
const body = (await readBody(request)) ?? {}
|
|
2340
|
+
if (!body.id) {
|
|
2341
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2342
|
+
return
|
|
2343
|
+
}
|
|
2344
|
+
const task = await withService(services, 'board', (b) =>
|
|
2345
|
+
b.update({ ...body, action: 'edit', byId: body.byId ?? 'ui' }),
|
|
2346
|
+
)
|
|
2347
|
+
sendJson(response, 200, { ok: true, task })
|
|
2348
|
+
return
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
if (request.method === 'GET' && path === '/tasks/history') {
|
|
2352
|
+
const id = qp(url, 'id')
|
|
2353
|
+
const history = await withService(services, 'board', (b) =>
|
|
2354
|
+
typeof b.history === 'function' ? b.history(id) : [],
|
|
2355
|
+
)
|
|
2356
|
+
sendJson(response, 200, { ok: true, history })
|
|
2357
|
+
return
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// ── 任务目标锚(契约 bots04 §2.1):创建时冻结的目标原文 + 锚定文案 ──
|
|
2361
|
+
if (request.method === 'GET' && path === '/tasks/brief') {
|
|
2362
|
+
const id = qp(url, 'id')
|
|
2363
|
+
if (!id) {
|
|
2364
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2365
|
+
return
|
|
2366
|
+
}
|
|
2367
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
2368
|
+
if (!task) {
|
|
2369
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2370
|
+
return
|
|
2371
|
+
}
|
|
2372
|
+
const notes = readTaskNotes(services, task.id)
|
|
2373
|
+
sendJson(response, 200, {
|
|
2374
|
+
ok: true,
|
|
2375
|
+
taskId: task.id,
|
|
2376
|
+
anchor: buildTaskAnchor(task),
|
|
2377
|
+
notes,
|
|
2378
|
+
text: buildTaskAnchorText(task, notes, readLongrunMeta(services, task.id)),
|
|
2379
|
+
note: '目标锚来自任务创建时的原文;补充说明另存,不会覆盖目标',
|
|
2380
|
+
})
|
|
2381
|
+
return
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// ── 补充说明(契约 bots04 §2.2):append-only,永不覆盖目标 ──────────
|
|
2385
|
+
if (request.method === 'POST' && path === '/tasks/note') {
|
|
2386
|
+
const body = (await readBody(request)) ?? {}
|
|
2387
|
+
if (!body.id) {
|
|
2388
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2389
|
+
return
|
|
2390
|
+
}
|
|
2391
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
2392
|
+
if (!text) {
|
|
2393
|
+
sendJson(response, 400, { ok: false, reason: '补充说明不能为空' })
|
|
2394
|
+
return
|
|
2395
|
+
}
|
|
2396
|
+
if (text.length > ANCHOR_TEXT_MAX) {
|
|
2397
|
+
sendJson(response, 400, { ok: false, reason: `补充说明过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
|
|
2398
|
+
return
|
|
2399
|
+
}
|
|
2400
|
+
const task = await withService(services, 'board', (b) => b.get(body.id))
|
|
2401
|
+
if (!task) {
|
|
2402
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
|
|
2403
|
+
return
|
|
2404
|
+
}
|
|
2405
|
+
// 唤醒前置检查放在落盘**之前**:dispatcher 缺失时直接 503,
|
|
2406
|
+
// 避免「说明已落盘但唤醒静默失败」的半成品状态。
|
|
2407
|
+
const shouldWake = body.wake !== false && Boolean(task.ownerId)
|
|
2408
|
+
const dispatcher = shouldWake
|
|
2409
|
+
? await withService(services, 'dispatcher', (d) => d)
|
|
2410
|
+
: undefined
|
|
2411
|
+
|
|
2412
|
+
const file = taskNotesPath(services)
|
|
2413
|
+
if (!file) {
|
|
2414
|
+
const error = new Error('状态目录不可用(task-notes.jsonl 无法落盘)')
|
|
2415
|
+
error.status = 503
|
|
2416
|
+
throw error
|
|
2417
|
+
}
|
|
2418
|
+
const { rows } = readJsonl(file)
|
|
2419
|
+
const note = {
|
|
2420
|
+
id: nextTaskNoteId(rows),
|
|
2421
|
+
taskId: task.id,
|
|
2422
|
+
text,
|
|
2423
|
+
by: typeof body.by === 'string' && body.by.trim() ? body.by.trim() : 'ui',
|
|
2424
|
+
at: Date.now(),
|
|
2425
|
+
}
|
|
2426
|
+
if (!appendJsonl(file, note)) {
|
|
2427
|
+
const error = new Error(`补充说明落盘失败(${file})`)
|
|
2428
|
+
error.status = 503
|
|
2429
|
+
throw error
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
let wake = null
|
|
2433
|
+
let revive
|
|
2434
|
+
if (shouldWake) {
|
|
2435
|
+
// 契约 bots05 §2.3:投递前先确保 owner 会话 live(失败不阻断,wake 会如实报错)。
|
|
2436
|
+
revive = await ensureLive(ctx, task.ownerId)
|
|
2437
|
+
// 唤醒文案 = 完整锚定文案(含刚追加的这条)+ 【新增补充说明】<text>
|
|
2438
|
+
// 这正是「长任务不失真」的落点:Bot 每次被唤醒都看到全量目标原文。
|
|
2439
|
+
const notes = readTaskNotes(services, task.id)
|
|
2440
|
+
services.deliveryWatch?.cancelByTask?.(task.id)
|
|
2441
|
+
const noteBaseline = await takeWatermarkBaseline(ctx, task.ownerId)
|
|
2442
|
+
wake = dispatcher.notify({
|
|
2443
|
+
target: task.ownerId,
|
|
2444
|
+
reason: 'task-assigned',
|
|
2445
|
+
dedupKey: `task-note:${task.id}:${note.id}`,
|
|
2446
|
+
text: `${buildTaskAnchorText(task, notes, readLongrunMeta(services, task.id))}\n\n【新增补充说明】${note.text}`,
|
|
2447
|
+
})
|
|
2448
|
+
armDeliveryWatch(services, task.id, task.ownerId, noteBaseline, wake)
|
|
2449
|
+
}
|
|
2450
|
+
sendJson(response, 200, { ok: true, note, wake, ...reviveFields(revive) })
|
|
2451
|
+
return
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// ── 补充说明历史(契约 bots04 §2.3):只读,按 at 升序 ────────────────
|
|
2455
|
+
if (request.method === 'GET' && path === '/tasks/notes') {
|
|
2456
|
+
const id = qp(url, 'id')
|
|
2457
|
+
if (!id) {
|
|
2458
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2459
|
+
return
|
|
2460
|
+
}
|
|
2461
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
2462
|
+
if (!task) {
|
|
2463
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2464
|
+
return
|
|
2465
|
+
}
|
|
2466
|
+
sendJson(response, 200, { ok: true, taskId: task.id, notes: readTaskNotes(services, task.id) })
|
|
2467
|
+
return
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// ── 任务结论摘要(契约 bots06 §2.3):只读 ────────────────────────────
|
|
2471
|
+
if (request.method === 'GET' && path === '/tasks/summary') {
|
|
2472
|
+
const id = qp(url, 'id')
|
|
2473
|
+
if (!id) {
|
|
2474
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2475
|
+
return
|
|
2476
|
+
}
|
|
2477
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
2478
|
+
if (!task) {
|
|
2479
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2480
|
+
return
|
|
2481
|
+
}
|
|
2482
|
+
const note = latestTaskNote(services, task.id)
|
|
2483
|
+
const lastNoteContent = note ? String(note.content ?? '') : null
|
|
2484
|
+
const stepConclusions = readTaskWakes(services, task.id).map(toStepConclusion)
|
|
2485
|
+
sendJson(response, 200, {
|
|
2486
|
+
ok: true,
|
|
2487
|
+
taskId: task.id,
|
|
2488
|
+
currentProgress: {
|
|
2489
|
+
status: task.status,
|
|
2490
|
+
progress: task.progress ?? 0,
|
|
2491
|
+
progressText: `${task.progress ?? 0}% · ${statusText(task.status)}`,
|
|
2492
|
+
lastNote: lastNoteContent,
|
|
2493
|
+
},
|
|
2494
|
+
// 改造07(借鉴 Super-agent stage table):阶段机结果
|
|
2495
|
+
stageInfo: deriveStage(task),
|
|
2496
|
+
stepConclusions,
|
|
2497
|
+
nextStep: lastNoteContent ? extractNextStep(lastNoteContent) : null,
|
|
2498
|
+
// ── 执行者状态(2026-11-09:「任务显示正在执行中,但我看不到过程」)────
|
|
2499
|
+
// 「投递成功」不等于「真的在跑」。这里把三件事一次说清:
|
|
2500
|
+
// 会话在不在线 / 最近一次唤醒的结果 / 有没有投递失败
|
|
2501
|
+
// 这样用户能区分「Bot 在干活」和「Bot 根本没动」。
|
|
2502
|
+
executor: buildExecutorStatus(ctx, services, task),
|
|
2503
|
+
note: SUMMARY_NOTE,
|
|
2504
|
+
})
|
|
2505
|
+
return
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// ── 来源跳转(契约 bots04 §2.5):任务 → 消息出处 ──────────────────
|
|
2509
|
+
//
|
|
2510
|
+
// 只读端点:**不写任何文件**(与 §2.1/§2.3 同口径)。
|
|
2511
|
+
// 房间侧信息全部来自 room 服务:
|
|
2512
|
+
// - `get(roomId)` 拿名字、判断房间是否还在(已删 → exists:false);
|
|
2513
|
+
// - `listMessages({roomId, sinceSeq: seq-1, limit:1, withBody:true})`
|
|
2514
|
+
// 精确判断那条消息是否存在(seq 连续 → 取不到就是真的不存在)。
|
|
2515
|
+
// 房间已删时 `listMessages` 会抛 `ROOM_NOT_FOUND`:那是**房间不存在**
|
|
2516
|
+
// 的事实,不是「消息不存在」,两者必须区分开(契约要求不静默、不猜测)。
|
|
2517
|
+
if (request.method === 'GET' && path === '/tasks/source') {
|
|
2518
|
+
const id = qp(url, 'id')
|
|
2519
|
+
if (!id) {
|
|
2520
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2521
|
+
return
|
|
2522
|
+
}
|
|
2523
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
2524
|
+
if (!task) {
|
|
2525
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2526
|
+
return
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const parsed = parseSourceSeq(task.sourceSeq)
|
|
2530
|
+
let payload = buildSourcePayload(task, parsed)
|
|
2531
|
+
|
|
2532
|
+
if (parsed?.kind === 'room') {
|
|
2533
|
+
const room = await withService(services, 'room', (r) => r.get(parsed.roomId))
|
|
2534
|
+
const roomExists = room !== undefined && room !== null
|
|
2535
|
+
let messageExists = null
|
|
2536
|
+
let messageAt = null
|
|
2537
|
+
let preview = null
|
|
2538
|
+
|
|
2539
|
+
if (roomExists) {
|
|
2540
|
+
// seq 连续:sinceSeq = seq-1 + limit 1 只会命中 seq 那一条
|
|
2541
|
+
const rows = await withService(services, 'room', (r) =>
|
|
2542
|
+
r.listMessages({ roomId: parsed.roomId, sinceSeq: parsed.seq - 1, limit: 1, withBody: true }),
|
|
2543
|
+
)
|
|
2544
|
+
const hit = Array.isArray(rows) ? rows.find((m) => Number(m?.seq) === parsed.seq) : undefined
|
|
2545
|
+
messageExists = hit !== undefined
|
|
2546
|
+
messageAt = hit?.at ?? null
|
|
2547
|
+
preview = hit ? sourcePreview(hit) : null
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
payload = {
|
|
2551
|
+
...payload,
|
|
2552
|
+
room: {
|
|
2553
|
+
...payload.room,
|
|
2554
|
+
name: room?.name ?? null,
|
|
2555
|
+
exists: roomExists,
|
|
2556
|
+
messageExists,
|
|
2557
|
+
at: messageAt,
|
|
2558
|
+
preview,
|
|
2559
|
+
},
|
|
2560
|
+
// 房间已删也如实返回 jump(数据不丢,是否可跳由前端决定 —— 契约 §2.5)
|
|
2561
|
+
jump: payload.jump,
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
sendJson(response, 200, payload)
|
|
2566
|
+
return
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// ── 手动重试唤醒(契约 bots05 §2.4):专治「重试看起来没反应」 ──────
|
|
2570
|
+
//
|
|
2571
|
+
// 为什么需要单独端点:/tasks/assign 的 dedupKey 是 `task-assigned:<id>:<rev>`,
|
|
2572
|
+
// 重试时 rev 没变 ⇒ 命中去重 ⇒ skipped,人点「重试」看起来像没反应。
|
|
2573
|
+
if (request.method === 'POST' && path === '/tasks/wake') {
|
|
2574
|
+
const body = (await readBody(request)) ?? {}
|
|
2575
|
+
if (!body.id) {
|
|
2576
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2577
|
+
return
|
|
2578
|
+
}
|
|
2579
|
+
const task = await withService(services, 'board', (b) => b.get(body.id))
|
|
2580
|
+
if (!task) {
|
|
2581
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
|
|
2582
|
+
return
|
|
2583
|
+
}
|
|
2584
|
+
if (!task.ownerId) {
|
|
2585
|
+
sendJson(response, 400, { ok: false, reason: '任务没有负责人,无法唤醒' })
|
|
2586
|
+
return
|
|
2587
|
+
}
|
|
2588
|
+
// dispatcher 缺失 → 503(先于复活副作用,避免「复活了却没地方投递」)。
|
|
2589
|
+
const dispatcher = await withService(services, 'dispatcher', (d) => d)
|
|
2590
|
+
const revive = await ensureLive(ctx, task.ownerId)
|
|
2591
|
+
const force = body.force !== false
|
|
2592
|
+
const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
|
|
2593
|
+
? body.dedupKey.trim()
|
|
2594
|
+
: (force ? `task-wake:${task.id}:${Date.now()}` : `task-wake:${task.id}:${task.rev}`)
|
|
2595
|
+
// 2026-11-09:同一道投递能力预检(手动重试也不该往死会话里投)。
|
|
2596
|
+
const deliverable = assertDeliverable(ctx, task.ownerId)
|
|
2597
|
+
if (!deliverable.ok) {
|
|
2598
|
+
const marked = await markDeliveryFailure(services, task.id, deliverable.reason, task.ownerId)
|
|
2599
|
+
sendJson(response, 200, {
|
|
2600
|
+
ok: true,
|
|
2601
|
+
taskId: task.id,
|
|
2602
|
+
ownerId: task.ownerId,
|
|
2603
|
+
live: revive.live,
|
|
2604
|
+
revived: revive.revived,
|
|
2605
|
+
marked,
|
|
2606
|
+
wake: { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: task.ownerId },
|
|
2607
|
+
})
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2610
|
+
// 人点「重试」= 重新建立一次期望(旧的那条自然被覆盖)
|
|
2611
|
+
services.deliveryWatch?.cancelByTask?.(task.id)
|
|
2612
|
+
const wakeBaseline = await takeWatermarkBaseline(ctx, task.ownerId)
|
|
2613
|
+
const wake = dispatcher.notify({
|
|
2614
|
+
target: task.ownerId,
|
|
2615
|
+
reason: 'task-assigned',
|
|
2616
|
+
dedupKey,
|
|
2617
|
+
text: buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id)),
|
|
2618
|
+
})
|
|
2619
|
+
if (wake && wake.result === 'failed') {
|
|
2620
|
+
await markDeliveryFailure(services, task.id, wake.reason ?? 'wake-failed', task.ownerId)
|
|
2621
|
+
} else if (wake) {
|
|
2622
|
+
// 2026-11-09:手动重试成功 ⇒ 清掉上次的失败留痕(否则任务永久停在
|
|
2623
|
+
// 「输入投递失败」),见 `clearDeliveryFailure` 的说明。
|
|
2624
|
+
await clearDeliveryFailure(services, task.id, wake)
|
|
2625
|
+
}
|
|
2626
|
+
armDeliveryWatch(services, task.id, task.ownerId, wakeBaseline, wake)
|
|
2627
|
+
sendJson(response, 200, {
|
|
2628
|
+
ok: true,
|
|
2629
|
+
taskId: task.id,
|
|
2630
|
+
ownerId: task.ownerId,
|
|
2631
|
+
live: revive.live,
|
|
2632
|
+
revived: revive.revived,
|
|
2633
|
+
...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
|
|
2634
|
+
wake,
|
|
2635
|
+
})
|
|
2636
|
+
return
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
2640
|
+
// 改造06 · 任务变更中心(契约 CONTRACT-bots06 §2)
|
|
2641
|
+
//
|
|
2642
|
+
// 用户原话:「完成的任务应该可以纠错或者优化或者更改或其他变更,变更成长任务」
|
|
2643
|
+
// 「任务可以变更负责人」。
|
|
2644
|
+
// 两条铁律:
|
|
2645
|
+
// · 变更必须有理由(note 必填);没有理由的变更不许进历史。
|
|
2646
|
+
// · 证据不能丢(reopen 前先快照,见 reopenWithSnapshot)。
|
|
2647
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
2648
|
+
|
|
2649
|
+
// ── 换负责人(契约 §2.1)────────────────────────────────────────────
|
|
2650
|
+
if (request.method === 'POST' && path === '/tasks/reassign') {
|
|
2651
|
+
const body = (await readBody(request)) ?? {}
|
|
2652
|
+
if (!body.id) {
|
|
2653
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2654
|
+
return
|
|
2655
|
+
}
|
|
2656
|
+
if (!body.ownerId || typeof body.ownerId !== 'string' || !body.ownerId.trim()) {
|
|
2657
|
+
sendJson(response, 400, { ok: false, reason: 'ownerId 必填' })
|
|
2658
|
+
return
|
|
2659
|
+
}
|
|
2660
|
+
const ownerId = body.ownerId.trim()
|
|
2661
|
+
const by = typeof body.byId === 'string' && body.byId.trim() ? body.byId.trim() : 'ui'
|
|
2662
|
+
let task = await withService(services, 'board', (b) => b.get(body.id))
|
|
2663
|
+
if (!task) {
|
|
2664
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
|
|
2665
|
+
return
|
|
2666
|
+
}
|
|
2667
|
+
// ② 已完成任务先拦:不让用户撞 board.claim 的 BOARD_ALREADY_COMPLETED,
|
|
2668
|
+
// 而是给一条「接下来该点哪」的可操作提示。
|
|
2669
|
+
if (task.status === 'completed') {
|
|
2670
|
+
const error = new Error('任务已完成,请先「重开」或「派生优化单」再换负责人')
|
|
2671
|
+
error.status = 400
|
|
2672
|
+
error.code = 'TASK_COMPLETED'
|
|
2673
|
+
throw error
|
|
2674
|
+
}
|
|
2675
|
+
// 前端会带 expectedRev(CAS):先于任何变更校验,避免覆盖别人的改动。
|
|
2676
|
+
if (body.expectedRev !== undefined && body.expectedRev !== null && task.rev !== body.expectedRev) {
|
|
2677
|
+
const error = new Error(
|
|
2678
|
+
`任务 ${task.id} 版本冲突:期望 rev=${body.expectedRev},实际 rev=${task.rev} —— 请刷新后重试`,
|
|
2679
|
+
)
|
|
2680
|
+
error.status = 400
|
|
2681
|
+
error.code = 'BOARD_REV_CONFLICT'
|
|
2682
|
+
throw error
|
|
2683
|
+
}
|
|
2684
|
+
const from = task.ownerId ?? task.lease?.ownerId ?? null
|
|
2685
|
+
// dispatcher 缺失时**先** 503(此操作无副作用):避免「任务已换人但唤不醒」
|
|
2686
|
+
// 或「租约已交还却换不了人」的半成品状态。
|
|
2687
|
+
const shouldWake = body.wake !== false
|
|
2688
|
+
const dispatcher = shouldWake
|
|
2689
|
+
? await withService(services, 'dispatcher', (d) => d)
|
|
2690
|
+
: undefined
|
|
2691
|
+
// ③ 原租约仍有效且持有人不是新负责人 → 以**原持有者身份**交还
|
|
2692
|
+
// (board.release 要求 byId === lease.ownerId,平台只是代为操作)。
|
|
2693
|
+
const lease = task.lease
|
|
2694
|
+
const leaseLive = Boolean(lease)
|
|
2695
|
+
&& Number.isFinite(Number(lease.expiresAt))
|
|
2696
|
+
&& Number(lease.expiresAt) > Date.now()
|
|
2697
|
+
if (leaseLive && lease.ownerId && lease.ownerId !== ownerId) {
|
|
2698
|
+
task = await withService(services, 'board', (b) => b.update({
|
|
2699
|
+
id: task.id,
|
|
2700
|
+
expectedRev: task.rev,
|
|
2701
|
+
action: 'release',
|
|
2702
|
+
byId: String(lease.ownerId),
|
|
2703
|
+
}))
|
|
2704
|
+
}
|
|
2705
|
+
// ④ 以新负责人身份领取(force:true 绕过 blockedBy 未完成)。
|
|
2706
|
+
let next
|
|
2707
|
+
try {
|
|
2708
|
+
next = await withService(services, 'board', (b) => b.update({
|
|
2709
|
+
id: task.id,
|
|
2710
|
+
expectedRev: task.rev,
|
|
2711
|
+
action: 'claim',
|
|
2712
|
+
byId: ownerId,
|
|
2713
|
+
force: true,
|
|
2714
|
+
...(body.leaseMs !== undefined && body.leaseMs !== null ? { leaseMs: body.leaseMs } : {}),
|
|
2715
|
+
}))
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
// WIP 超限:**原样回传内核错误码**,只补一句人话,绝不吞成「success」。
|
|
2718
|
+
if (error?.code === 'BOARD_WIP_EXCEEDED' || error?.code === 'BOARD_WIP_LIMIT') {
|
|
2719
|
+
const wrapped = new Error(
|
|
2720
|
+
`${error.message}(提示:先让该 Bot 交付或释放已有任务,或在设置里调高 wipLimitPerBot 后再换负责人)`,
|
|
2721
|
+
)
|
|
2722
|
+
wrapped.status = 400
|
|
2723
|
+
wrapped.code = error.code
|
|
2724
|
+
throw wrapped
|
|
2725
|
+
}
|
|
2726
|
+
throw error
|
|
2727
|
+
}
|
|
2728
|
+
// ⑤ 唤醒:确保新负责人会话 live(失败不阻断,wake 会如实报错误原因)。
|
|
2729
|
+
let wake = null
|
|
2730
|
+
let revive
|
|
2731
|
+
if (shouldWake) {
|
|
2732
|
+
revive = await ensureLive(ctx, ownerId)
|
|
2733
|
+
wake = dispatcher.notify({
|
|
2734
|
+
target: ownerId,
|
|
2735
|
+
reason: 'task-assigned',
|
|
2736
|
+
dedupKey: `task-reassign:${next.id}:${next.rev}`,
|
|
2737
|
+
text: buildTaskAnchorText(
|
|
2738
|
+
next,
|
|
2739
|
+
readTaskNotes(services, next.id),
|
|
2740
|
+
readLongrunMeta(services, next.id),
|
|
2741
|
+
),
|
|
2742
|
+
})
|
|
2743
|
+
}
|
|
2744
|
+
// ⑥ 变更历史(append-only;与快照/重开共用同一份 task-meta.jsonl)。
|
|
2745
|
+
appendTaskMeta(services, {
|
|
2746
|
+
kind: 'reassign', taskId: next.id, from, to: ownerId, by, at: Date.now(),
|
|
2747
|
+
})
|
|
2748
|
+
// ⑦ 回显:from/to 让前端能显示「已从 A 换到 B」。
|
|
2749
|
+
sendJson(response, 200, {
|
|
2750
|
+
ok: true,
|
|
2751
|
+
task: next,
|
|
2752
|
+
from,
|
|
2753
|
+
to: ownerId,
|
|
2754
|
+
wake,
|
|
2755
|
+
...reviveFields(revive),
|
|
2756
|
+
})
|
|
2757
|
+
return
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// ── 变更中心(契约 §2.2):reopen | followup | longrun ──────────────
|
|
2761
|
+
if (request.method === 'POST' && path === '/tasks/evolve') {
|
|
2762
|
+
const body = (await readBody(request)) ?? {}
|
|
2763
|
+
if (!body.id) {
|
|
2764
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2765
|
+
return
|
|
2766
|
+
}
|
|
2767
|
+
const mode = typeof body.mode === 'string' ? body.mode.trim() : ''
|
|
2768
|
+
if (!EVOLVE_MODES.includes(mode)) {
|
|
2769
|
+
const error = new Error(`mode 必须是 ${EVOLVE_MODES.join(' | ')}(收到 ${JSON.stringify(body.mode)})`)
|
|
2770
|
+
error.status = 400
|
|
2771
|
+
error.code = 'EVOLVE_BAD_MODE'
|
|
2772
|
+
throw error
|
|
2773
|
+
}
|
|
2774
|
+
// note 三种模式都必填:没有理由的变更不许进历史。
|
|
2775
|
+
const note = typeof body.note === 'string' ? body.note.trim() : ''
|
|
2776
|
+
if (!note) {
|
|
2777
|
+
const error = new Error('变更说明不能为空(没有理由的变更不许进历史)')
|
|
2778
|
+
error.status = 400
|
|
2779
|
+
error.code = 'EVOLVE_NOTE_REQUIRED'
|
|
2780
|
+
throw error
|
|
2781
|
+
}
|
|
2782
|
+
if (note.length > EVOLVE_NOTE_MAX) {
|
|
2783
|
+
const error = new Error(`变更说明过长(最多 ${EVOLVE_NOTE_MAX} 字)`)
|
|
2784
|
+
error.status = 400
|
|
2785
|
+
error.code = 'EVOLVE_NOTE_TOO_LONG'
|
|
2786
|
+
throw error
|
|
2787
|
+
}
|
|
2788
|
+
const by = typeof body.byId === 'string' && body.byId.trim() ? body.byId.trim() : 'ui'
|
|
2789
|
+
const task = await withService(services, 'board', (b) => b.get(body.id))
|
|
2790
|
+
if (!task) {
|
|
2791
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
|
|
2792
|
+
return
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// mode=reopen(纠错 / 继续做):快照 → 内核 reopen → 记历史。
|
|
2796
|
+
if (mode === 'reopen') {
|
|
2797
|
+
const { task: reopened, snapshot } = await reopenWithSnapshot(services, task, by, note)
|
|
2798
|
+
sendJson(response, 200, {
|
|
2799
|
+
ok: true,
|
|
2800
|
+
task: reopened,
|
|
2801
|
+
snapshot,
|
|
2802
|
+
note: '已重开;上一轮交付物已存档,见 changes',
|
|
2803
|
+
})
|
|
2804
|
+
return
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// mode=followup(优化 / 修复单):原任务**保持已完成**,派生一张新任务。
|
|
2808
|
+
if (mode === 'followup') {
|
|
2809
|
+
const prefix = evolvePrefixFor(note)
|
|
2810
|
+
// 幂等键:note 的 sha1 前缀 —— 连点两次同一说明只建一个;
|
|
2811
|
+
// 不同说明是不同诉求,允许各建一张(不能用 task.rev,否则第二次派生
|
|
2812
|
+
// 会静默命中第一张,用户看不到自己新写的理由)。
|
|
2813
|
+
const seq = createHash('sha1').update(note).digest('hex').slice(0, 12)
|
|
2814
|
+
const sourceSeq = `evolve:${task.id}:${seq}`
|
|
2815
|
+
const originDesc = typeof task.description === 'string' ? task.description : ''
|
|
2816
|
+
const description = [
|
|
2817
|
+
originDesc.trim() ? originDesc : null,
|
|
2818
|
+
`【变更说明】${note}`,
|
|
2819
|
+
`【来源】原任务 ${task.id}:${task.subject}`,
|
|
2820
|
+
].filter((part) => part !== null).join('\n\n')
|
|
2821
|
+
const createInput = {
|
|
2822
|
+
subject: `${prefix}${task.subject}`,
|
|
2823
|
+
description,
|
|
2824
|
+
byId: by,
|
|
2825
|
+
sourceSeq,
|
|
2826
|
+
}
|
|
2827
|
+
// priority 继承原任务;assignee 继承 assignee ?? ownerId(契约 §2.2)。
|
|
2828
|
+
if (task.priority !== undefined) createInput.priority = task.priority
|
|
2829
|
+
const assignee = task.assignee ?? task.ownerId
|
|
2830
|
+
if (assignee) createInput.assignee = assignee
|
|
2831
|
+
const created = await withService(services, 'board', (b) => b.create(createInput))
|
|
2832
|
+
// 幂等命中(连点)时不重复记历史:同一条 followup 只留一行。
|
|
2833
|
+
const already = readTaskMeta(services, task.id).some(
|
|
2834
|
+
(row) => row.kind === 'followup' && row.newTaskId === created.id,
|
|
2835
|
+
)
|
|
2836
|
+
if (!already) {
|
|
2837
|
+
appendTaskMeta(services, {
|
|
2838
|
+
kind: 'followup',
|
|
2839
|
+
taskId: task.id,
|
|
2840
|
+
newTaskId: created.id,
|
|
2841
|
+
note,
|
|
2842
|
+
by,
|
|
2843
|
+
at: Date.now(),
|
|
2844
|
+
})
|
|
2845
|
+
}
|
|
2846
|
+
sendJson(response, 200, {
|
|
2847
|
+
ok: true,
|
|
2848
|
+
task: created,
|
|
2849
|
+
origin: task.id,
|
|
2850
|
+
prefix,
|
|
2851
|
+
note: `已派生 ${created.id}(幂等键 ${sourceSeq},重复点不会建两个)`,
|
|
2852
|
+
})
|
|
2853
|
+
return
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// mode=longrun(转成长任务):阶段清单进 task-meta,唤醒文案自动带上。
|
|
2857
|
+
const phases = normalizePhases(body.phases, 'EVOLVE_BAD_PHASES')
|
|
2858
|
+
// ① reopen:true 时按 reopen 流程重开(快照必须在内核清空 artifacts 之前落盘)。
|
|
2859
|
+
let current = task
|
|
2860
|
+
if (body.reopen === true) {
|
|
2861
|
+
current = (await reopenWithSnapshot(services, task, by, note)).task
|
|
2862
|
+
}
|
|
2863
|
+
// ② 记 longrun(契约 §2.2 longrun 步骤 2;不动任务本体)。
|
|
2864
|
+
appendTaskMeta(services, {
|
|
2865
|
+
kind: 'longrun', taskId: task.id, phases, note, by, at: Date.now(),
|
|
2866
|
+
})
|
|
2867
|
+
// ③ 记一条补充说明,内容 = 【转长任务】 + note + 阶段清单(步骤 4)。
|
|
2868
|
+
appendTaskNoteRow(
|
|
2869
|
+
services,
|
|
2870
|
+
task.id,
|
|
2871
|
+
`【转长任务】${note}\n阶段清单:\n${phases.map((phase, index) => `${index + 1}. ${phase}`).join('\n')}`,
|
|
2872
|
+
by,
|
|
2873
|
+
)
|
|
2874
|
+
sendJson(response, 200, {
|
|
2875
|
+
ok: true,
|
|
2876
|
+
task: current,
|
|
2877
|
+
phases,
|
|
2878
|
+
note: '已标记为长任务;后续每次唤醒都会带上阶段清单',
|
|
2879
|
+
})
|
|
2880
|
+
return
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// ── 变更历史(契约 §2.3):只读,按 at 升序 ─────────────────────────
|
|
2884
|
+
if (request.method === 'GET' && path === '/tasks/changes') {
|
|
2885
|
+
const id = qp(url, 'id')
|
|
2886
|
+
if (!id) {
|
|
2887
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
2888
|
+
return
|
|
2889
|
+
}
|
|
2890
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
2891
|
+
if (!task) {
|
|
2892
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
2893
|
+
return
|
|
2894
|
+
}
|
|
2895
|
+
const rows = readTaskMeta(services, task.id)
|
|
2896
|
+
// 契约 §2.3 的条目只暴露 kind/at/...(taskId 由外层给,逐条不重复)。
|
|
2897
|
+
const changes = rows.map(({ taskId, ...rest }) => rest)
|
|
2898
|
+
const longrunRows = rows.filter((row) => row.kind === 'longrun' && Array.isArray(row.phases))
|
|
2899
|
+
const lastLongrun = longrunRows.length ? longrunRows[longrunRows.length - 1] : null
|
|
2900
|
+
const snapshotRows = rows.filter((row) => row.kind === 'snapshot' && row.before)
|
|
2901
|
+
const lastSnapshot = snapshotRows.length ? snapshotRows[snapshotRows.length - 1] : null
|
|
2902
|
+
sendJson(response, 200, {
|
|
2903
|
+
ok: true,
|
|
2904
|
+
taskId: task.id,
|
|
2905
|
+
changes,
|
|
2906
|
+
longrun: lastLongrun ? { phases: [...lastLongrun.phases], at: lastLongrun.at } : null,
|
|
2907
|
+
latestSnapshot: lastSnapshot ? lastSnapshot.before : null,
|
|
2908
|
+
})
|
|
2909
|
+
return
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// ── 直发消息(契约 bots04 §2.4):从协作平台直接给某个 Bot 会话说话 ──
|
|
2913
|
+
if (request.method === 'POST' && path === '/bots/message') {
|
|
2914
|
+
const body = (await readBody(request)) ?? {}
|
|
2915
|
+
if (!body.botId && !body.sessionId) {
|
|
2916
|
+
sendJson(response, 400, { ok: false, reason: 'botId 或 sessionId 必填' })
|
|
2917
|
+
return
|
|
2918
|
+
}
|
|
2919
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
2920
|
+
if (!text) {
|
|
2921
|
+
sendJson(response, 400, { ok: false, reason: '消息不能为空' })
|
|
2922
|
+
return
|
|
2923
|
+
}
|
|
2924
|
+
if (text.length > ANCHOR_TEXT_MAX) {
|
|
2925
|
+
sendJson(response, 400, { ok: false, reason: `消息过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
|
|
2926
|
+
return
|
|
2927
|
+
}
|
|
2928
|
+
let sessionId
|
|
2929
|
+
if (body.botId) {
|
|
2930
|
+
const bot = await withService(services, 'roster', (r) => r.get(body.botId))
|
|
2931
|
+
if (!bot) {
|
|
2932
|
+
sendJson(response, 404, { ok: false, reason: `Bot ${body.botId} 不存在` })
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
sessionId = bot.sessionId
|
|
2936
|
+
} else {
|
|
2937
|
+
sessionId = body.sessionId
|
|
2938
|
+
}
|
|
2939
|
+
// 未知 sessionId 仍允许(可能是主对话),但必须如实告知 knownBot:false。
|
|
2940
|
+
const knownBot = await withService(services, 'roster', (r) => r.hasSession(sessionId))
|
|
2941
|
+
|
|
2942
|
+
let prefix = ''
|
|
2943
|
+
if (body.taskId) {
|
|
2944
|
+
const task = await withService(services, 'board', (b) => b.get(body.taskId))
|
|
2945
|
+
if (!task) {
|
|
2946
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${body.taskId} 不存在` })
|
|
2947
|
+
return
|
|
2948
|
+
}
|
|
2949
|
+
prefix = `${buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id))}\n\n`
|
|
2950
|
+
}
|
|
2951
|
+
const deliveredText = prefix + text
|
|
2952
|
+
const hash = createHash('sha1')
|
|
2953
|
+
.update(`${body.taskId ?? ''}${text}`)
|
|
2954
|
+
.digest('hex')
|
|
2955
|
+
.slice(0, 16)
|
|
2956
|
+
const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
|
|
2957
|
+
? body.dedupKey.trim()
|
|
2958
|
+
: `ui-msg:${hash}`
|
|
2959
|
+
const dispatcher = await withService(services, 'dispatcher', (d) => d)
|
|
2960
|
+
// 契约 bots05 §2.3:投递前先确保目标会话 live(失败不阻断,wake 会如实报错)。
|
|
2961
|
+
const revive = await ensureLive(ctx, sessionId)
|
|
2962
|
+
const wake = dispatcher.notify({
|
|
2963
|
+
target: sessionId,
|
|
2964
|
+
reason: 'room-message',
|
|
2965
|
+
dedupKey,
|
|
2966
|
+
text: deliveredText,
|
|
2967
|
+
})
|
|
2968
|
+
sendJson(response, 200, {
|
|
2969
|
+
ok: true,
|
|
2970
|
+
sessionId,
|
|
2971
|
+
knownBot,
|
|
2972
|
+
wake,
|
|
2973
|
+
text: deliveredText,
|
|
2974
|
+
...reviveFields(revive),
|
|
2975
|
+
})
|
|
2976
|
+
return
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// ── 手动复活 Bot 会话(契约 bots05 §2.2):重启 dsh-web 后一键拉起来 ──
|
|
2980
|
+
if (request.method === 'POST' && path === '/bots/revive') {
|
|
2981
|
+
const body = (await readBody(request)) ?? {}
|
|
2982
|
+
// 批量:遍历未退役的 Bot,**串行** ensureLive(最多 REVIVE_MAX 个)。
|
|
2983
|
+
if (body.all === true) {
|
|
2984
|
+
const bots = await withService(services, 'roster', (r) => r.list())
|
|
2985
|
+
const targets = bots.filter((b) => b?.state !== 'retired').slice(0, REVIVE_MAX)
|
|
2986
|
+
const revived = []
|
|
2987
|
+
const live = []
|
|
2988
|
+
const failed = []
|
|
2989
|
+
for (const bot of targets) {
|
|
2990
|
+
const result = await ensureLive(ctx, bot.sessionId)
|
|
2991
|
+
if (result.live) live.push(bot.sessionId)
|
|
2992
|
+
if (result.revived) revived.push(bot.sessionId)
|
|
2993
|
+
if (!result.live) failed.push({ sessionId: bot.sessionId, reason: result.reason })
|
|
2994
|
+
}
|
|
2995
|
+
sendJson(response, 200, { ok: true, total: targets.length, revived, live, failed })
|
|
2996
|
+
return
|
|
2997
|
+
}
|
|
2998
|
+
// 单个:botId 解析出 sessionId(找不到 404),或直接给 sessionId。
|
|
2999
|
+
let sessionId
|
|
3000
|
+
if (body.botId) {
|
|
3001
|
+
const bot = await withService(services, 'roster', (r) => r.get(body.botId))
|
|
3002
|
+
if (!bot) {
|
|
3003
|
+
sendJson(response, 404, { ok: false, reason: `Bot ${body.botId} 不存在` })
|
|
3004
|
+
return
|
|
3005
|
+
}
|
|
3006
|
+
sessionId = bot.sessionId
|
|
3007
|
+
} else if (typeof body.sessionId === 'string' && body.sessionId.trim()) {
|
|
3008
|
+
sessionId = body.sessionId.trim()
|
|
3009
|
+
} else {
|
|
3010
|
+
sendJson(response, 400, { ok: false, reason: 'botId / sessionId / all 三选一必填' })
|
|
3011
|
+
return
|
|
3012
|
+
}
|
|
3013
|
+
const revive = await ensureLive(ctx, sessionId)
|
|
3014
|
+
// ok:true = 请求已处理;复活是否成功看 live(失败给 reason,不是 500)。
|
|
3015
|
+
sendJson(response, 200, {
|
|
3016
|
+
ok: true,
|
|
3017
|
+
sessionId,
|
|
3018
|
+
live: revive.live,
|
|
3019
|
+
revived: revive.revived,
|
|
3020
|
+
...(revive.live ? {} : { reason: revive.reason ?? 'revive-failed' }),
|
|
3021
|
+
})
|
|
3022
|
+
return
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
// ── 房间:成员管理 / 转发 ─────────────────────────────────────────────
|
|
3026
|
+
if (request.method === 'POST' && path === '/rooms/update') {
|
|
3027
|
+
const body = (await readBody(request)) ?? {}
|
|
3028
|
+
const room = await withService(services, 'room', (r) =>
|
|
3029
|
+
r.update({ ...body, byId: body.byId ?? 'ui' }),
|
|
3030
|
+
)
|
|
3031
|
+
sendJson(response, 200, { ok: true, room })
|
|
3032
|
+
return
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
if (request.method === 'POST' && path === '/rooms/join') {
|
|
3036
|
+
const body = (await readBody(request)) ?? {}
|
|
3037
|
+
const room = await withService(services, 'room', (r) =>
|
|
3038
|
+
r.join({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
|
|
3039
|
+
)
|
|
3040
|
+
sendJson(response, 200, { ok: true, room })
|
|
3041
|
+
return
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (request.method === 'POST' && path === '/rooms/leave') {
|
|
3045
|
+
const body = (await readBody(request)) ?? {}
|
|
3046
|
+
const room = await withService(services, 'room', (r) =>
|
|
3047
|
+
r.leave({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
|
|
3048
|
+
)
|
|
3049
|
+
sendJson(response, 200, { ok: true, room })
|
|
3050
|
+
return
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
if (request.method === 'POST' && path === '/rooms/relay') {
|
|
3054
|
+
const body = (await readBody(request)) ?? {}
|
|
3055
|
+
const result = await withService(services, 'room', (r) =>
|
|
3056
|
+
r.relay({ roomId: body.roomId, messageId: body.messageId, targets: body.targets }),
|
|
3057
|
+
)
|
|
3058
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
3059
|
+
return
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// ── 记忆:改 / 归档 / 恢复 ────────────────────────────────────────────
|
|
3063
|
+
if (request.method === 'POST' && path === '/memory/update') {
|
|
3064
|
+
const body = (await readBody(request)) ?? {}
|
|
3065
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
3066
|
+
m.update({ ...body, by: body.by ?? 'ui' }),
|
|
3067
|
+
)
|
|
3068
|
+
sendJson(response, 200, { ok: true, entry })
|
|
3069
|
+
return
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (request.method === 'POST' && path === '/memory/remove') {
|
|
3073
|
+
const body = (await readBody(request)) ?? {}
|
|
3074
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
3075
|
+
m.remove({ ...body, by: body.by ?? 'ui' }),
|
|
3076
|
+
)
|
|
3077
|
+
sendJson(response, 200, { ok: true, entry })
|
|
3078
|
+
return
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
if (request.method === 'POST' && path === '/memory/archive') {
|
|
3082
|
+
const body = (await readBody(request)) ?? {}
|
|
3083
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
3084
|
+
m.archive({ ...body, by: body.by ?? 'ui' }),
|
|
3085
|
+
)
|
|
3086
|
+
sendJson(response, 200, { ok: true, entry })
|
|
3087
|
+
return
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
if (request.method === 'POST' && path === '/memory/restore') {
|
|
3091
|
+
const body = (await readBody(request)) ?? {}
|
|
3092
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
3093
|
+
m.restore({ ...body, by: body.by ?? 'ui' }),
|
|
3094
|
+
)
|
|
3095
|
+
sendJson(response, 200, { ok: true, entry })
|
|
3096
|
+
return
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// ── 目录浏览(宿主原生选择器不可用时的兜底,也是唯一能「新建/重命名」的路径)──
|
|
3100
|
+
if (request.method === 'GET' && path === '/fs/roots') {
|
|
3101
|
+
const r = await withService(services, 'dirbrowse', (d) => d.roots())
|
|
3102
|
+
sendJson(response, 200, { ok: true, ...r })
|
|
3103
|
+
return
|
|
3104
|
+
}
|
|
3105
|
+
if (request.method === 'GET' && path === '/fs/list') {
|
|
3106
|
+
const dir = qp(url, 'path')
|
|
3107
|
+
const showHidden = qp(url, 'showHidden') === '1'
|
|
3108
|
+
const listing = await withService(services, 'dirbrowse', (d) =>
|
|
3109
|
+
d.list({ ...(dir ? { path: dir } : {}), showHidden }),
|
|
3110
|
+
)
|
|
3111
|
+
sendJson(response, 200, { ok: true, ...listing })
|
|
3112
|
+
return
|
|
3113
|
+
}
|
|
3114
|
+
if (request.method === 'POST' && path === '/fs/mkdir') {
|
|
3115
|
+
const body = (await readBody(request)) ?? {}
|
|
3116
|
+
const created = await withService(services, 'dirbrowse', (d) => d.mkdir(body))
|
|
3117
|
+
sendJson(response, 200, { ok: true, ...created })
|
|
3118
|
+
return
|
|
3119
|
+
}
|
|
3120
|
+
if (request.method === 'POST' && path === '/fs/rename') {
|
|
3121
|
+
const body = (await readBody(request)) ?? {}
|
|
3122
|
+
const renamed = await withService(services, 'dirbrowse', (d) => d.rename(body))
|
|
3123
|
+
sendJson(response, 200, { ok: true, ...renamed })
|
|
3124
|
+
return
|
|
3125
|
+
}
|
|
3126
|
+
if (request.method === 'POST' && path === '/fs/validate') {
|
|
3127
|
+
const body = (await readBody(request)) ?? {}
|
|
3128
|
+
const verdict = await withService(services, 'dirbrowse', (d) => d.validateAsBotCwd({ path: body.path }))
|
|
3129
|
+
sendJson(response, 200, { ok: true, verdict })
|
|
3130
|
+
return
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// ── 技能库 / 工作流库 ─────────────────────────────────────────────────
|
|
3134
|
+
if (request.method === 'GET' && path === '/library/skills') {
|
|
3135
|
+
const rows = await withService(services, 'library', (l) => l.listSkills({ tag: qp(url, 'tag') }))
|
|
3136
|
+
sendJson(response, 200, { ok: true, skills: rows })
|
|
3137
|
+
return
|
|
3138
|
+
}
|
|
3139
|
+
if (request.method === 'POST' && path === '/library/skills') {
|
|
3140
|
+
const body = (await readBody(request)) ?? {}
|
|
3141
|
+
const skill = await withService(services, 'library', (l) => l.createSkill({ ...body, by: body.by ?? 'ui' }))
|
|
3142
|
+
sendJson(response, 200, { ok: true, skill })
|
|
3143
|
+
return
|
|
3144
|
+
}
|
|
3145
|
+
if (request.method === 'POST' && path === '/library/skills/update') {
|
|
3146
|
+
const body = (await readBody(request)) ?? {}
|
|
3147
|
+
const skill = await withService(services, 'library', (l) => l.updateSkill({ ...body, by: body.by ?? 'ui' }))
|
|
3148
|
+
sendJson(response, 200, { ok: true, skill })
|
|
3149
|
+
return
|
|
3150
|
+
}
|
|
3151
|
+
if (request.method === 'POST' && path === '/library/skills/remove') {
|
|
3152
|
+
const body = (await readBody(request)) ?? {}
|
|
3153
|
+
const result = await withService(services, 'library', (l) => l.removeSkill({ id: body.id, by: body.by ?? 'ui' }))
|
|
3154
|
+
sendJson(response, 200, { ok: true, result })
|
|
3155
|
+
return
|
|
3156
|
+
}
|
|
3157
|
+
if (request.method === 'GET' && path === '/library/workflows') {
|
|
3158
|
+
const rows = await withService(services, 'library', (l) => l.listWorkflows({ tag: qp(url, 'tag') }))
|
|
3159
|
+
sendJson(response, 200, { ok: true, workflows: rows })
|
|
3160
|
+
return
|
|
3161
|
+
}
|
|
3162
|
+
if (request.method === 'POST' && path === '/library/workflows') {
|
|
3163
|
+
const body = (await readBody(request)) ?? {}
|
|
3164
|
+
const workflow = await withService(services, 'library', (l) => l.createWorkflow({ ...body, by: body.by ?? 'ui' }))
|
|
3165
|
+
sendJson(response, 200, { ok: true, workflow })
|
|
3166
|
+
return
|
|
3167
|
+
}
|
|
3168
|
+
if (request.method === 'POST' && path === '/library/workflows/update') {
|
|
3169
|
+
const body = (await readBody(request)) ?? {}
|
|
3170
|
+
const workflow = await withService(services, 'library', (l) => l.updateWorkflow({ ...body, by: body.by ?? 'ui' }))
|
|
3171
|
+
sendJson(response, 200, { ok: true, workflow })
|
|
3172
|
+
return
|
|
3173
|
+
}
|
|
3174
|
+
if (request.method === 'POST' && path === '/library/workflows/remove') {
|
|
3175
|
+
const body = (await readBody(request)) ?? {}
|
|
3176
|
+
const result = await withService(services, 'library', (l) => l.removeWorkflow({ id: body.id, by: body.by ?? 'ui' }))
|
|
3177
|
+
sendJson(response, 200, { ok: true, result })
|
|
3178
|
+
return
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
// ── 设置 ──────────────────────────────────────────────────────────────
|
|
3182
|
+
// ⚠️ 修复(2026-11-04 测试):
|
|
3183
|
+
// ① GET 之前只返回 services.settings(启动时一次性加载的内存副本)⇒
|
|
3184
|
+
// 「保存后立刻 GET 却看不到新值」的假象(磁盘上其实已写对)。
|
|
3185
|
+
// 现改为:`saved` 永远来自磁盘(真相源),`effective` 才是运行时内存值。
|
|
3186
|
+
// ② POST 之前只回 saved.values,调用方无法区分「写进去的」与「当前生效的」⇒
|
|
3187
|
+
// 现补 `effective` 与 `persisted` 两个字段,让 UI 能如实标注。
|
|
3188
|
+
if (request.method === 'GET' && path === '/settings') {
|
|
3189
|
+
const { SETTING_SPECS, loadSettings } = await import('./settings.js')
|
|
3190
|
+
const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
|
|
3191
|
+
const disk = dir ? loadSettings(dir) : { values: {}, diagnostics: { file: '', source: 'unknown', badKeys: [] } }
|
|
3192
|
+
sendJson(response, 200, {
|
|
3193
|
+
ok: true,
|
|
3194
|
+
values: disk.values,
|
|
3195
|
+
saved: disk.values,
|
|
3196
|
+
effective: services.settings ?? {},
|
|
3197
|
+
specs: SETTING_SPECS,
|
|
3198
|
+
diagnostics: disk.diagnostics,
|
|
3199
|
+
baseDir: dir,
|
|
3200
|
+
note: Object.keys(disk.values).length
|
|
3201
|
+
&& JSON.stringify(disk.values) !== JSON.stringify(services.settings ?? {})
|
|
3202
|
+
? '磁盘值与内存生效值不同:内核级参数需重启 DSH 才生效'
|
|
3203
|
+
: undefined,
|
|
3204
|
+
})
|
|
3205
|
+
return
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
if (request.method === 'POST' && path === '/settings') {
|
|
3209
|
+
const body = (await readBody(request)) ?? {}
|
|
3210
|
+
const { saveSettings } = await import('./settings.js')
|
|
3211
|
+
const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
|
|
3212
|
+
const saved = saveSettings(dir, body)
|
|
3213
|
+
if (!saved.ok) {
|
|
3214
|
+
sendJson(response, 400, { ok: false, reason: saved.reason })
|
|
3215
|
+
return
|
|
3216
|
+
}
|
|
3217
|
+
sendJson(response, 200, {
|
|
3218
|
+
ok: true,
|
|
3219
|
+
values: saved.values,
|
|
3220
|
+
saved: saved.values,
|
|
3221
|
+
effective: services.settings ?? {},
|
|
3222
|
+
persisted: true,
|
|
3223
|
+
note: '已写入 settings.json;内核级参数需重启 DSH 生效',
|
|
3224
|
+
})
|
|
3225
|
+
return
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// ── 手动唤醒 ──────────────────────────────────────────────────────────
|
|
3229
|
+
if (request.method === 'POST' && path === '/wakes/notify') {
|
|
3230
|
+
const body = (await readBody(request)) ?? {}
|
|
3231
|
+
const result = await withService(services, 'dispatcher', (d) => d.notify(body))
|
|
3232
|
+
sendJson(response, 200, { ok: true, result })
|
|
3233
|
+
return
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// ── 唤醒审计 ──────────────────────────────────────────────────────────
|
|
3237
|
+
if (request.method === 'GET' && path === '/wakes') {
|
|
3238
|
+
const wakes = await withService(services, 'dispatcher', (d) =>
|
|
3239
|
+
d.list({ target: qp(url, 'target'), limit: qn(url, 'limit') ?? 100 }),
|
|
3240
|
+
)
|
|
3241
|
+
sendJson(response, 200, { ok: true, wakes })
|
|
344
3242
|
return
|
|
345
3243
|
}
|
|
346
3244
|
|
|
347
|
-
|
|
3245
|
+
|
|
3246
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3247
|
+
// v2 新增端点(2026-11-04,五大功能)
|
|
3248
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
3249
|
+
|
|
3250
|
+
// ── 特性①:房间超级管理员 ────────────────────────────────────────────
|
|
3251
|
+
if (request.method === 'POST' && path === '/rooms/archive') {
|
|
348
3252
|
const body = (await readBody(request)) ?? {}
|
|
349
|
-
const
|
|
350
|
-
r.
|
|
3253
|
+
const room = await withService(services, 'room', (r) =>
|
|
3254
|
+
r.archiveRoom({ ...body, byId: body.byId ?? OWNER }),
|
|
351
3255
|
)
|
|
352
|
-
sendJson(response, 200, { ok: true,
|
|
3256
|
+
sendJson(response, 200, { ok: true, room })
|
|
353
3257
|
return
|
|
354
3258
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return
|
|
362
|
-
}
|
|
363
|
-
const text = await withService(services, 'room', (r) => r.readBody({ roomId, messageId }))
|
|
364
|
-
sendJson(response, 200, { ok: true, text })
|
|
3259
|
+
if (request.method === 'POST' && path === '/rooms/unarchive') {
|
|
3260
|
+
const body = (await readBody(request)) ?? {}
|
|
3261
|
+
const room = await withService(services, 'room', (r) =>
|
|
3262
|
+
r.unarchiveRoom({ ...body, byId: body.byId ?? OWNER }),
|
|
3263
|
+
)
|
|
3264
|
+
sendJson(response, 200, { ok: true, room })
|
|
365
3265
|
return
|
|
366
3266
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const query = qp(url, 'query')
|
|
372
|
-
const entries = await withService(services, 'memory', (m) =>
|
|
373
|
-
query ? m.search({ query, layers: layer ? [layer] : undefined, limit: qn(url, 'limit') })
|
|
374
|
-
: m.list({ layer, limit: qn(url, 'limit') }),
|
|
3267
|
+
if (request.method === 'POST' && path === '/rooms/remove') {
|
|
3268
|
+
const body = (await readBody(request)) ?? {}
|
|
3269
|
+
const result = await withService(services, 'room', (r) =>
|
|
3270
|
+
r.removeRoom({ ...body, byId: body.byId ?? OWNER }),
|
|
375
3271
|
)
|
|
376
|
-
sendJson(response, 200, { ok: true,
|
|
3272
|
+
sendJson(response, 200, { ok: true, result })
|
|
377
3273
|
return
|
|
378
3274
|
}
|
|
379
|
-
|
|
380
|
-
if (request.method === 'POST' && path === '/memory') {
|
|
3275
|
+
if (request.method === 'POST' && path === '/rooms/member-role') {
|
|
381
3276
|
const body = (await readBody(request)) ?? {}
|
|
382
|
-
const
|
|
383
|
-
|
|
3277
|
+
const room = await withService(services, 'room', (r) =>
|
|
3278
|
+
r.setMemberRole({ ...body, byId: body.byId ?? OWNER }),
|
|
384
3279
|
)
|
|
385
|
-
sendJson(response, 200, { ok: true,
|
|
3280
|
+
sendJson(response, 200, { ok: true, room })
|
|
386
3281
|
return
|
|
387
3282
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
3283
|
+
if (request.method === 'POST' && path === '/rooms/kick') {
|
|
3284
|
+
const body = (await readBody(request)) ?? {}
|
|
3285
|
+
const room = await withService(services, 'room', (r) =>
|
|
3286
|
+
r.kickMember({ ...body, byId: body.byId ?? OWNER }),
|
|
3287
|
+
)
|
|
3288
|
+
sendJson(response, 200, { ok: true, room })
|
|
392
3289
|
return
|
|
393
3290
|
}
|
|
394
3291
|
|
|
395
|
-
// ──
|
|
396
|
-
if (request.method === 'GET' && path === '/
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
task: b.get(id),
|
|
404
|
-
history: typeof b.history === 'function' ? b.history(id) : [],
|
|
3292
|
+
// ── 特性②:分级纪律体系 ──────────────────────────────────────────────
|
|
3293
|
+
if (request.method === 'GET' && path === '/disciplines') {
|
|
3294
|
+
const items = await withService(services, 'discipline', (d) => d.listDisciplines({
|
|
3295
|
+
scope: qp(url, 'scope'),
|
|
3296
|
+
scopeId: qp(url, 'scopeId'),
|
|
3297
|
+
kind: qp(url, 'kind'),
|
|
3298
|
+
query: qp(url, 'query'),
|
|
3299
|
+
enabled: qp(url, 'enabled') === undefined ? undefined : qp(url, 'enabled') === '1',
|
|
405
3300
|
}))
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
3301
|
+
sendJson(response, 200, { ok: true, items, scopes: DISCIPLINE_SCOPES, kinds: DISCIPLINE_KINDS })
|
|
3302
|
+
return
|
|
3303
|
+
}
|
|
3304
|
+
if (request.method === 'POST' && path === '/disciplines') {
|
|
3305
|
+
const body = (await readBody(request)) ?? {}
|
|
3306
|
+
const item = await withService(services, 'discipline', (d) =>
|
|
3307
|
+
d.createDiscipline({ ...body, by: body.by ?? OWNER }),
|
|
3308
|
+
)
|
|
3309
|
+
sendJson(response, 200, { ok: true, item })
|
|
3310
|
+
return
|
|
3311
|
+
}
|
|
3312
|
+
if (request.method === 'POST' && path === '/disciplines/update') {
|
|
3313
|
+
const body = (await readBody(request)) ?? {}
|
|
3314
|
+
const item = await withService(services, 'discipline', (d) =>
|
|
3315
|
+
d.updateDiscipline({ ...body, by: body.by ?? OWNER }),
|
|
3316
|
+
)
|
|
3317
|
+
sendJson(response, 200, { ok: true, item })
|
|
3318
|
+
return
|
|
3319
|
+
}
|
|
3320
|
+
if (request.method === 'POST' && path === '/disciplines/remove') {
|
|
3321
|
+
const body = (await readBody(request)) ?? {}
|
|
3322
|
+
const result = await withService(services, 'discipline', (d) =>
|
|
3323
|
+
d.removeDiscipline({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }),
|
|
3324
|
+
)
|
|
3325
|
+
sendJson(response, 200, { ok: true, result })
|
|
3326
|
+
return
|
|
3327
|
+
}
|
|
3328
|
+
if (request.method === 'POST' && path === '/disciplines/import') {
|
|
3329
|
+
const body = (await readBody(request)) ?? {}
|
|
3330
|
+
const result = await withService(services, 'discipline', (d) =>
|
|
3331
|
+
d.importDisciplines({ items: body.items ?? [], mode: body.mode, by: body.by ?? OWNER }),
|
|
3332
|
+
)
|
|
3333
|
+
sendJson(response, 200, { ok: true, result })
|
|
3334
|
+
return
|
|
3335
|
+
}
|
|
3336
|
+
// 合并预览:给定 bot/cwd/room,看"这个对象实际会看到哪些纪律"
|
|
3337
|
+
if (request.method === 'GET' && path === '/disciplines/resolve') {
|
|
3338
|
+
const resolved = await withService(services, 'discipline', (d) => d.resolveDisciplines({
|
|
3339
|
+
botId: qp(url, 'botId'),
|
|
3340
|
+
cwd: qp(url, 'cwd'),
|
|
3341
|
+
roomId: qp(url, 'roomId'),
|
|
3342
|
+
}))
|
|
3343
|
+
sendJson(response, 200, { ok: true, ...resolved })
|
|
3344
|
+
return
|
|
3345
|
+
}
|
|
3346
|
+
if (request.method === 'GET' && path === '/disciplines/preview') {
|
|
3347
|
+
const resolved = await withService(services, 'discipline', (d) => d.renderDisciplinePrompt({
|
|
3348
|
+
botId: qp(url, 'botId'),
|
|
3349
|
+
cwd: qp(url, 'cwd'),
|
|
3350
|
+
roomId: qp(url, 'roomId'),
|
|
3351
|
+
tokenBudget: qn(url, 'tokenBudget'),
|
|
3352
|
+
}))
|
|
3353
|
+
sendJson(response, 200, { ok: true, ...resolved })
|
|
411
3354
|
return
|
|
412
3355
|
}
|
|
413
3356
|
|
|
414
|
-
|
|
3357
|
+
// ── 特性③:Bot 档案增强(导入记忆 / 关联技能)────────────────────────
|
|
3358
|
+
if (request.method === 'POST' && path === '/roster/profile/memory') {
|
|
415
3359
|
const body = (await readBody(request)) ?? {}
|
|
416
|
-
if (!body.id
|
|
417
|
-
sendJson(response, 400, { ok: false, reason: 'id
|
|
3360
|
+
if (!body.id) {
|
|
3361
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
418
3362
|
return
|
|
419
3363
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
b.update({
|
|
3364
|
+
const out = await withService(services, 'roster', (r) =>
|
|
3365
|
+
r.addProfileMemory({
|
|
423
3366
|
id: body.id,
|
|
3367
|
+
entries: body.entries ?? [],
|
|
3368
|
+
dedup: body.dedup !== false,
|
|
3369
|
+
byId: body.byId ?? OWNER,
|
|
424
3370
|
expectedRev: body.expectedRev,
|
|
425
|
-
action: 'claim',
|
|
426
|
-
byId: body.ownerId,
|
|
427
|
-
...(body.leaseMs ? { leaseMs: body.leaseMs } : {}),
|
|
428
3371
|
}),
|
|
429
3372
|
)
|
|
430
|
-
|
|
431
|
-
let wake
|
|
432
|
-
if (services.dispatcher && body.wake !== false) {
|
|
433
|
-
wake = services.dispatcher.notify({
|
|
434
|
-
target: body.ownerId,
|
|
435
|
-
reason: 'task-assigned',
|
|
436
|
-
dedupKey: `task-assigned:${task.id}:${task.rev}`,
|
|
437
|
-
text: `新任务 ${task.id}:${task.subject}\n${task.description ?? ''}`.trim(),
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
sendJson(response, 200, { ok: true, task, wake })
|
|
3373
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
441
3374
|
return
|
|
442
3375
|
}
|
|
443
|
-
|
|
444
|
-
if (request.method === 'POST' && path === '/tasks/abort') {
|
|
3376
|
+
if (request.method === 'POST' && path === '/roster/skills/link') {
|
|
445
3377
|
const body = (await readBody(request)) ?? {}
|
|
446
3378
|
if (!body.id) {
|
|
447
3379
|
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
448
3380
|
return
|
|
449
3381
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
b.update({
|
|
3382
|
+
const bot = await withService(services, 'roster', (r) =>
|
|
3383
|
+
r.linkSkills({
|
|
453
3384
|
id: body.id,
|
|
3385
|
+
skills: body.skills ?? [],
|
|
3386
|
+
byId: body.byId ?? OWNER,
|
|
454
3387
|
expectedRev: body.expectedRev,
|
|
455
|
-
action: 'block',
|
|
456
|
-
note: body.reason ?? '用户终止',
|
|
457
|
-
byId: body.byId ?? 'ui',
|
|
458
3388
|
}),
|
|
459
3389
|
)
|
|
460
|
-
sendJson(response, 200, { ok: true,
|
|
3390
|
+
sendJson(response, 200, { ok: true, bot })
|
|
461
3391
|
return
|
|
462
3392
|
}
|
|
463
3393
|
|
|
464
|
-
|
|
3394
|
+
// ── 特性④:记忆档案完整增删改查 ──────────────────────────────────────
|
|
3395
|
+
if (request.method === 'GET' && path === '/memory/detail') {
|
|
3396
|
+
const id = qp(url, 'id')
|
|
3397
|
+
const layer = qp(url, 'layer')
|
|
3398
|
+
if (!id || !layer) {
|
|
3399
|
+
sendJson(response, 400, { ok: false, reason: 'id 与 layer 必填' })
|
|
3400
|
+
return
|
|
3401
|
+
}
|
|
3402
|
+
const entry = await withService(services, 'memory', (m) => m.get({ layer, id }))
|
|
3403
|
+
const history = await withService(services, 'memory', (m) => m.history(id))
|
|
3404
|
+
sendJson(response, 200, { ok: true, entry, history })
|
|
3405
|
+
return
|
|
3406
|
+
}
|
|
3407
|
+
if (request.method === 'POST' && path === '/memory/purge') {
|
|
3408
|
+
// 彻底删除(不可恢复):与 /memory/remove(软删除,可恢复)区分开
|
|
465
3409
|
const body = (await readBody(request)) ?? {}
|
|
466
|
-
|
|
3410
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
3411
|
+
m.purge({ layer: body.layer, id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
|
|
3412
|
+
sendJson(response, 200, { ok: true, purged: entry })
|
|
3413
|
+
return
|
|
3414
|
+
}
|
|
3415
|
+
if (request.method === 'POST' && path === '/memory/migrate-cwd') {
|
|
3416
|
+
const body = (await readBody(request)) ?? {}
|
|
3417
|
+
const result = await withService(services, 'memory', (m) =>
|
|
3418
|
+
m.migrateCwd({ cwd: body.cwd, by: body.by ?? OWNER, now: body.now }))
|
|
3419
|
+
sendJson(response, 200, { ok: true, result })
|
|
3420
|
+
return
|
|
3421
|
+
}
|
|
3422
|
+
if (request.method === 'POST' && path === '/memory/import') {
|
|
3423
|
+
const body = (await readBody(request)) ?? {}
|
|
3424
|
+
const rows = Array.isArray(body.entries) ? body.entries : []
|
|
3425
|
+
const added = []
|
|
3426
|
+
const failed = []
|
|
3427
|
+
for (const row of rows) {
|
|
3428
|
+
try {
|
|
3429
|
+
const entry = await withService(services, 'memory', (m) => m.add({
|
|
3430
|
+
layer: row.layer ?? body.layer ?? 'L4',
|
|
3431
|
+
content: row.content ?? row,
|
|
3432
|
+
by: body.by ?? OWNER,
|
|
3433
|
+
tags: row.tags,
|
|
3434
|
+
explicit: row.explicit,
|
|
3435
|
+
}))
|
|
3436
|
+
const id = Array.isArray(entry) ? entry[0]?.id : entry?.id
|
|
3437
|
+
if (id) added.push(id)
|
|
3438
|
+
} catch (error) {
|
|
3439
|
+
failed.push({ content: String(row?.content ?? row).slice(0, 60), reason: error?.message ?? String(error) })
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
sendJson(response, 200, { ok: true, added: added.length, ids: added, failed })
|
|
3443
|
+
return
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// ── 特性⑤:技能导入 / 导出 ───────────────────────────────────────────
|
|
3447
|
+
if (request.method === 'POST' && path === '/library/skills/parse') {
|
|
3448
|
+
const body = (await readBody(request)) ?? {}
|
|
3449
|
+
const parsed = await withService(services, 'library', (l) =>
|
|
3450
|
+
l.parseSkillMarkdown({ text: body.text ?? '', filename: body.filename }),
|
|
3451
|
+
)
|
|
3452
|
+
sendJson(response, 200, { ok: true, parsed })
|
|
3453
|
+
return
|
|
3454
|
+
}
|
|
3455
|
+
if (request.method === 'POST' && path === '/library/skills/import') {
|
|
3456
|
+
const body = (await readBody(request)) ?? {}
|
|
3457
|
+
const result = await withService(services, 'library', (l) =>
|
|
3458
|
+
l.importSkills({
|
|
3459
|
+
items: body.items ?? [],
|
|
3460
|
+
mode: body.mode ?? 'skip',
|
|
3461
|
+
by: body.by ?? OWNER,
|
|
3462
|
+
}),
|
|
3463
|
+
)
|
|
3464
|
+
sendJson(response, 200, { ok: true, result })
|
|
3465
|
+
return
|
|
3466
|
+
}
|
|
3467
|
+
if (request.method === 'POST' && path === '/library/skills/scan') {
|
|
3468
|
+
const body = (await readBody(request)) ?? {}
|
|
3469
|
+
const result = await withService(services, 'library', (l) =>
|
|
3470
|
+
l.scanSkillsDir({ dir: body.dir }),
|
|
3471
|
+
)
|
|
3472
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
3473
|
+
return
|
|
3474
|
+
}
|
|
3475
|
+
if (request.method === 'GET' && path === '/library/skills/detail') {
|
|
3476
|
+
const id = qp(url, 'id')
|
|
3477
|
+
if (!id) {
|
|
467
3478
|
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
468
3479
|
return
|
|
469
3480
|
}
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
3481
|
+
const skill = await withService(services, 'library', (l) => l.getSkill(id))
|
|
3482
|
+
sendJson(response, 200, { ok: true, skill })
|
|
3483
|
+
return
|
|
3484
|
+
}
|
|
3485
|
+
if (request.method === 'GET' && path === '/library/skills/export') {
|
|
3486
|
+
const ids = qp(url, 'ids')
|
|
3487
|
+
const out = await withService(services, 'library', (l) =>
|
|
3488
|
+
l.exportSkills({ ids: ids ? ids.split(',').filter(Boolean) : undefined }),
|
|
3489
|
+
)
|
|
3490
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3491
|
+
return
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// ── 特性⑥:四轨待办 ──────────────────────────────────────────────────
|
|
3495
|
+
if (request.method === 'GET' && path === '/todos') {
|
|
3496
|
+
const out = await withService(services, 'todo', (t) => t.listTodos({
|
|
3497
|
+
track: qp(url, 'track'),
|
|
3498
|
+
cwd: qp(url, 'cwd'),
|
|
3499
|
+
date: qp(url, 'date'),
|
|
3500
|
+
status: qp(url, 'status'),
|
|
3501
|
+
query: qp(url, 'query'),
|
|
3502
|
+
all: qp(url, 'all') === '1',
|
|
3503
|
+
past: qp(url, 'past') === '1',
|
|
3504
|
+
expired: qp(url, 'expired') === '1',
|
|
3505
|
+
limit: qn(url, 'limit'),
|
|
3506
|
+
}))
|
|
3507
|
+
// listTodos 返回 {items,total,truncated,defaultView,today,hint};
|
|
3508
|
+
// 原样展开(页面读 items)+ 保留 todos 别名(老调用方兼容)
|
|
3509
|
+
sendJson(response, 200, { ok: true, ...out, todos: out.items ?? [] })
|
|
3510
|
+
return
|
|
3511
|
+
}
|
|
3512
|
+
if (request.method === 'POST' && path === '/todos') {
|
|
3513
|
+
const body = (await readBody(request)) ?? {}
|
|
3514
|
+
const todo = await withService(services, 'todo', (t) => t.createTodo({ ...body, by: body.by ?? OWNER }))
|
|
3515
|
+
sendJson(response, 200, { ok: true, todo })
|
|
3516
|
+
return
|
|
3517
|
+
}
|
|
3518
|
+
if (request.method === 'POST' && path === '/todos/update') {
|
|
3519
|
+
const body = (await readBody(request)) ?? {}
|
|
3520
|
+
const todo = await withService(services, 'todo', (t) => t.updateTodo({ ...body, by: body.by ?? OWNER }))
|
|
3521
|
+
sendJson(response, 200, { ok: true, todo })
|
|
3522
|
+
return
|
|
3523
|
+
}
|
|
3524
|
+
if (request.method === 'POST' && path === '/todos/promote') {
|
|
3525
|
+
// 「待办 → 任务」升级:待办是个人备忘,任务是要交给别人/Bot 干的协作单元。
|
|
3526
|
+
// 两者是漏斗上下游,所以必须有一条**确定性**的升级通道(不是让用户手抄一遍)。
|
|
3527
|
+
// 幂等靠 board 的 sourceSeq=`todo:<id>`:连点两次也只会产生一个任务。
|
|
3528
|
+
const body = (await readBody(request)) ?? {}
|
|
3529
|
+
const result = await withService(services, 'todo', async (t) => {
|
|
3530
|
+
const todo = t.getTodo(body.id)
|
|
3531
|
+
if (!todo) {
|
|
3532
|
+
const error = new Error(`待办不存在:${body.id}`)
|
|
3533
|
+
error.status = 404
|
|
3534
|
+
error.code = 'TODO_NOT_FOUND'
|
|
3535
|
+
throw error
|
|
3536
|
+
}
|
|
3537
|
+
if (body.expectedRev !== undefined && todo.rev !== body.expectedRev) {
|
|
3538
|
+
const error = new Error(`待办 CAS 冲突:期望 rev=${body.expectedRev},实际 rev=${todo.rev}`)
|
|
3539
|
+
error.status = 409
|
|
3540
|
+
error.code = 'TODO_REV_CONFLICT'
|
|
3541
|
+
throw error
|
|
3542
|
+
}
|
|
3543
|
+
const task = await withService(services, 'board', (b) => b.create({
|
|
3544
|
+
subject: String(todo.content).split(String.fromCharCode(10))[0].slice(0, 120),
|
|
3545
|
+
description: todo.content,
|
|
3546
|
+
priority: body.priority,
|
|
3547
|
+
dueAt: todo.due ? Date.parse(`${todo.due}T00:00:00+08:00`) : undefined,
|
|
3548
|
+
byId: body.by ?? OWNER,
|
|
3549
|
+
sourceSeq: `todo:${todo.id}`,
|
|
3550
|
+
}))
|
|
3551
|
+
// 升级后把待办标成"进行中",避免它又出现在「要关注」里被重复安排
|
|
3552
|
+
let next = todo
|
|
3553
|
+
if (todo.status === 'pending') {
|
|
3554
|
+
next = t.updateTodo({ id: todo.id, expectedRev: todo.rev, status: 'doing', by: body.by ?? OWNER })
|
|
3555
|
+
}
|
|
3556
|
+
return { task, todo: next }
|
|
3557
|
+
})
|
|
3558
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
3559
|
+
return
|
|
3560
|
+
}
|
|
3561
|
+
if (request.method === 'POST' && path === '/todos/remove') {
|
|
3562
|
+
const body = (await readBody(request)) ?? {}
|
|
3563
|
+
const result = await withService(services, 'todo', (t) =>
|
|
3564
|
+
t.removeTodo({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
|
|
3565
|
+
sendJson(response, 200, { ok: true, result })
|
|
3566
|
+
return
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
// ── 特性⑦:便签 ──────────────────────────────────────────────────────
|
|
3570
|
+
if (request.method === 'GET' && path === '/scratch') {
|
|
3571
|
+
const out = await withService(services, 'scratch', (s) => s.read({ scope: qp(url, 'scope') }))
|
|
3572
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3573
|
+
return
|
|
3574
|
+
}
|
|
3575
|
+
if (request.method === 'POST' && path === '/scratch') {
|
|
3576
|
+
const body = (await readBody(request)) ?? {}
|
|
3577
|
+
const out = await withService(services, 'scratch', (s) =>
|
|
3578
|
+
s.write({ scope: body.scope, text: body.text ?? '', by: body.by ?? OWNER }))
|
|
3579
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3580
|
+
return
|
|
3581
|
+
}
|
|
3582
|
+
if (request.method === 'GET' && path === '/scratch/scopes') {
|
|
3583
|
+
const scopes = await withService(services, 'scratch', (s) => s.listScopes())
|
|
3584
|
+
sendJson(response, 200, { ok: true, scopes })
|
|
3585
|
+
return
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
// ── 特性⑧:提示词库 + 注入轨 ──────────────────────────────────────────
|
|
3589
|
+
if (request.method === 'GET' && path === '/prompts') {
|
|
3590
|
+
const items = await withService(services, 'prompts', (p) => p.listPrompts({
|
|
3591
|
+
category: qp(url, 'category'),
|
|
3592
|
+
tag: qp(url, 'tag'),
|
|
3593
|
+
query: qp(url, 'query'),
|
|
3594
|
+
enabled: qp(url, 'enabled') === undefined ? undefined : qp(url, 'enabled') === '1',
|
|
3595
|
+
limit: qn(url, 'limit'),
|
|
3596
|
+
}))
|
|
3597
|
+
const categories = await withService(services, 'prompts', (p) => p.listCategories())
|
|
3598
|
+
sendJson(response, 200, { ok: true, items, categories })
|
|
3599
|
+
return
|
|
3600
|
+
}
|
|
3601
|
+
if (request.method === 'POST' && path === '/prompts') {
|
|
3602
|
+
const body = (await readBody(request)) ?? {}
|
|
3603
|
+
const item = await withService(services, 'prompts', (p) => p.createPrompt({ ...body, by: body.by ?? OWNER }))
|
|
3604
|
+
sendJson(response, 200, { ok: true, item })
|
|
3605
|
+
return
|
|
3606
|
+
}
|
|
3607
|
+
if (request.method === 'POST' && path === '/prompts/update') {
|
|
3608
|
+
const body = (await readBody(request)) ?? {}
|
|
3609
|
+
const item = await withService(services, 'prompts', (p) => p.updatePrompt({ ...body, by: body.by ?? OWNER }))
|
|
3610
|
+
sendJson(response, 200, { ok: true, item })
|
|
3611
|
+
return
|
|
3612
|
+
}
|
|
3613
|
+
if (request.method === 'POST' && path === '/prompts/remove') {
|
|
3614
|
+
const body = (await readBody(request)) ?? {}
|
|
3615
|
+
const result = await withService(services, 'prompts', (p) =>
|
|
3616
|
+
p.removePrompt({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
|
|
3617
|
+
sendJson(response, 200, { ok: true, result })
|
|
3618
|
+
return
|
|
3619
|
+
}
|
|
3620
|
+
if (request.method === 'POST' && path === '/prompts/categories/add') {
|
|
3621
|
+
const body = (await readBody(request)) ?? {}
|
|
3622
|
+
const categories = await withService(services, 'prompts', (p) =>
|
|
3623
|
+
p.addCategory({ name: body.name, by: body.by ?? OWNER }))
|
|
3624
|
+
sendJson(response, 200, { ok: true, categories })
|
|
3625
|
+
return
|
|
3626
|
+
}
|
|
3627
|
+
if (request.method === 'POST' && path === '/prompts/categories/remove') {
|
|
3628
|
+
const body = (await readBody(request)) ?? {}
|
|
3629
|
+
const categories = await withService(services, 'prompts', (p) =>
|
|
3630
|
+
p.removeCategory({ name: body.name, by: body.by ?? OWNER }))
|
|
3631
|
+
sendJson(response, 200, { ok: true, categories })
|
|
3632
|
+
return
|
|
3633
|
+
}
|
|
3634
|
+
if (request.method === 'POST' && path === '/prompts/inject') {
|
|
3635
|
+
const body = (await readBody(request)) ?? {}
|
|
3636
|
+
const injection = await withService(services, 'prompts', (p) => p.inject({
|
|
3637
|
+
promptId: body.promptId,
|
|
3638
|
+
text: body.text,
|
|
3639
|
+
sessionId: body.sessionId,
|
|
3640
|
+
times: body.times,
|
|
3641
|
+
every: body.every,
|
|
3642
|
+
immediate: body.immediate,
|
|
3643
|
+
by: body.by ?? OWNER,
|
|
3644
|
+
}))
|
|
3645
|
+
sendJson(response, 200, { ok: true, injection })
|
|
3646
|
+
return
|
|
3647
|
+
}
|
|
3648
|
+
if (request.method === 'POST' && path === '/prompts/injection/stop') {
|
|
3649
|
+
const body = (await readBody(request)) ?? {}
|
|
3650
|
+
const result = await withService(services, 'prompts', (p) => p.stopInjection({ id: body.id, by: body.by ?? OWNER }))
|
|
3651
|
+
sendJson(response, 200, { ok: true, result })
|
|
3652
|
+
return
|
|
3653
|
+
}
|
|
3654
|
+
if (request.method === 'GET' && path === '/prompts/injections') {
|
|
3655
|
+
const injections = await withService(services, 'prompts', (p) => p.listInjections({ sessionId: qp(url, 'sessionId') }))
|
|
3656
|
+
sendJson(response, 200, { ok: true, injections })
|
|
3657
|
+
return
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// ── 特性⑨:建议确认队列 + 审查调度 ───────────────────────────────────
|
|
3661
|
+
if (request.method === 'GET' && path === '/suggestions') {
|
|
3662
|
+
const items = await withService(services, 'suggest', (s) => s.listSuggestions({
|
|
3663
|
+
kind: qp(url, 'kind'),
|
|
3664
|
+
status: qp(url, 'status') ?? 'pending',
|
|
3665
|
+
limit: qn(url, 'limit'),
|
|
3666
|
+
}))
|
|
3667
|
+
sendJson(response, 200, { ok: true, items })
|
|
3668
|
+
return
|
|
3669
|
+
}
|
|
3670
|
+
if (request.method === 'POST' && path === '/suggestions/propose') {
|
|
3671
|
+
const body = (await readBody(request)) ?? {}
|
|
3672
|
+
const suggestion = await withService(services, 'suggest', (sg) => sg.suggest({
|
|
3673
|
+
kind: body.kind,
|
|
3674
|
+
layer: body.layer,
|
|
3675
|
+
payload: body.payload,
|
|
3676
|
+
reason: body.reason,
|
|
3677
|
+
source: body.source ?? 'model',
|
|
3678
|
+
confidence: body.confidence,
|
|
3679
|
+
by: body.by ?? OWNER,
|
|
3680
|
+
}))
|
|
3681
|
+
sendJson(response, 200, { ok: true, suggestion })
|
|
3682
|
+
return
|
|
3683
|
+
}
|
|
3684
|
+
if (request.method === 'POST' && path === '/suggestions/accept') {
|
|
3685
|
+
const body = (await readBody(request)) ?? {}
|
|
3686
|
+
// apply 回调把建议真正写进目标域(memory / library / discipline)
|
|
3687
|
+
const applied = await withService(services, 'suggest', (s) => s.accept({
|
|
3688
|
+
id: body.id,
|
|
3689
|
+
by: body.by ?? OWNER,
|
|
3690
|
+
apply: (sg) => applySuggestion(services, sg, body.by ?? OWNER),
|
|
3691
|
+
}))
|
|
3692
|
+
sendJson(response, 200, { ok: true, suggestion: applied })
|
|
3693
|
+
return
|
|
3694
|
+
}
|
|
3695
|
+
if (request.method === 'POST' && path === '/suggestions/reject') {
|
|
3696
|
+
const body = (await readBody(request)) ?? {}
|
|
3697
|
+
const item = await withService(services, 'suggest', (s) =>
|
|
3698
|
+
s.reject({ id: body.id, reason: body.reason, by: body.by ?? OWNER }))
|
|
3699
|
+
sendJson(response, 200, { ok: true, suggestion: item })
|
|
3700
|
+
return
|
|
3701
|
+
}
|
|
3702
|
+
if (request.method === 'GET' && path === '/review/status') {
|
|
3703
|
+
const status = await withService(services, 'reviewer', (r) => r.status({ sessionId: qp(url, 'sessionId') }))
|
|
3704
|
+
sendJson(response, 200, { ok: true, status })
|
|
3705
|
+
return
|
|
3706
|
+
}
|
|
3707
|
+
if (request.method === 'POST' && path === '/review/note-turn') {
|
|
3708
|
+
const body = (await readBody(request)) ?? {}
|
|
3709
|
+
const out = await withService(services, 'reviewer', (r) => r.noteTurn({ sessionId: body.sessionId }))
|
|
3710
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3711
|
+
return
|
|
3712
|
+
}
|
|
3713
|
+
if (request.method === 'POST' && path === '/review/mark-reviewed') {
|
|
3714
|
+
const body = (await readBody(request)) ?? {}
|
|
3715
|
+
const out = await withService(services, 'reviewer', (r) =>
|
|
3716
|
+
r.markReviewed({ sessionId: body.sessionId, by: body.by ?? OWNER }))
|
|
3717
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3718
|
+
return
|
|
3719
|
+
}
|
|
3720
|
+
// ── 需求4:智能体定期归纳 —— 人点一下,把「该归纳了」变成一次真唤醒 ────────
|
|
3721
|
+
//
|
|
3722
|
+
// 为什么需要它:reviewer 只做被动计数(每回合 +1,到 interval 就 due),
|
|
3723
|
+
// 它不自己起定时器(宿主生命周期不可控)。所以「定期归纳」的闭环落点是:
|
|
3724
|
+
// UI/接口看到 due → 人点「让它归纳」→ 唤醒该会话 → 会话自己用
|
|
3725
|
+
// bot_cycle_start(kind=agent) + bot_cycle_review 一次性提交并自动闭环。
|
|
3726
|
+
// 归纳出来的建议仍然走 bot_suggestion_propose(确认制,人采纳才落库),
|
|
3727
|
+
// 不存在「智能体自己改自己的长期纪律」这条暗道。
|
|
3728
|
+
if (request.method === 'POST' && path === '/review/urge') {
|
|
3729
|
+
const body = (await readBody(request)) ?? {}
|
|
3730
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
3731
|
+
if (!sessionId) {
|
|
3732
|
+
sendJson(response, 400, { ok: false, reason: 'sessionId 必填' })
|
|
3733
|
+
return
|
|
3734
|
+
}
|
|
3735
|
+
const st = await withService(services, 'reviewer', (r) => r.status({ sessionId }))
|
|
3736
|
+
if (st.known === false) {
|
|
3737
|
+
sendJson(response, 200, {
|
|
3738
|
+
ok: false,
|
|
3739
|
+
code: 'REVIEW_UNKNOWN_SESSION',
|
|
3740
|
+
reason: `会话 ${sessionId} 还没被计数过(未记录任何回合),没有可归纳的内容`,
|
|
3741
|
+
})
|
|
3742
|
+
return
|
|
3743
|
+
}
|
|
3744
|
+
if (st.due !== true && body.force !== true) {
|
|
3745
|
+
sendJson(response, 200, {
|
|
3746
|
+
ok: false,
|
|
3747
|
+
code: 'REVIEW_NOT_DUE',
|
|
3748
|
+
reason: `会话 ${sessionId} 才累计 ${st.turnsSinceLastReview} 回合(阈值 ${st.interval}),还没到该归纳的时候`,
|
|
3749
|
+
status: st,
|
|
3750
|
+
})
|
|
3751
|
+
return
|
|
3752
|
+
}
|
|
3753
|
+
const bots = services.roster?.list?.({ includeRetired: true }) ?? []
|
|
3754
|
+
const bot = bots.find((b) => b.sessionId === sessionId || b.id === sessionId)
|
|
3755
|
+
const text = '[cs-bots 归纳提醒] 你的会话已累计 ' + `${st.turnsSinceLastReview}` + ' 个回合没有归纳了。\n'
|
|
3756
|
+
+ '请现在做一次「事后归纳」(这是你自己的 PDCA,不需要人来跟):\n'
|
|
3757
|
+
+ ' 1. 用 bot_cycle_start 开一个 kind=agent 的周期,taskId 填你当前主任务的 id,objective 写这次归纳要解决什么;\n'
|
|
3758
|
+
+ ` 2. 用 bot_cycle_review 一次性提交(id + summary + lessons[] + standards[]),提交后周期自动闭环;\n`
|
|
3759
|
+
+ ' 3. standards 里只写**你确实想让它长期生效**的纪律/原则,它们会进建议队列等人采纳,不会自动落库。\n'
|
|
3760
|
+
+ '做完之后调 POST /review/mark-reviewed 把计数清零。'
|
|
3761
|
+
const revive = await ensureLive(ctx, sessionId)
|
|
3762
|
+
let wake
|
|
3763
|
+
try {
|
|
3764
|
+
const dispatcher = await withService(services, 'dispatcher', (d) => d)
|
|
3765
|
+
wake = dispatcher.notify({
|
|
3766
|
+
target: sessionId,
|
|
3767
|
+
reason: 'timer',
|
|
3768
|
+
dedupKey: `review-urge:${sessionId}:${Date.now()}`,
|
|
3769
|
+
text,
|
|
3770
|
+
})
|
|
3771
|
+
} catch (error) {
|
|
3772
|
+
sendJson(response, 503, {
|
|
3773
|
+
ok: false,
|
|
3774
|
+
code: 'REVIEW_URGE_NO_DISPATCHER',
|
|
3775
|
+
reason: `没有可用的唤醒投递器:${error?.message ?? error}`,
|
|
3776
|
+
})
|
|
3777
|
+
return
|
|
3778
|
+
}
|
|
3779
|
+
sendJson(response, 200, {
|
|
3780
|
+
ok: true,
|
|
3781
|
+
sessionId,
|
|
3782
|
+
...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
|
|
3783
|
+
status: st,
|
|
3784
|
+
live: revive.live,
|
|
3785
|
+
revived: revive.revived,
|
|
3786
|
+
...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
|
|
3787
|
+
wake,
|
|
3788
|
+
})
|
|
474
3789
|
return
|
|
475
3790
|
}
|
|
476
3791
|
|
|
477
|
-
if (request.method === '
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
)
|
|
482
|
-
sendJson(response, 200, { ok: true, history })
|
|
3792
|
+
if (request.method === 'POST' && path === '/review/interval') {
|
|
3793
|
+
const body = (await readBody(request)) ?? {}
|
|
3794
|
+
const out = await withService(services, 'reviewer', (r) => r.setInterval({ interval: body.interval }))
|
|
3795
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
483
3796
|
return
|
|
484
3797
|
}
|
|
485
3798
|
|
|
486
|
-
// ──
|
|
487
|
-
if (request.method === 'POST' && path === '/
|
|
3799
|
+
// ── 特性⑩:技能管理器(来源/启用/自定义目录/浏览)────────────────────
|
|
3800
|
+
if (request.method === 'POST' && path === '/library/skills/enable') {
|
|
488
3801
|
const body = (await readBody(request)) ?? {}
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
)
|
|
492
|
-
sendJson(response, 200, { ok: true,
|
|
3802
|
+
const skill = await withService(services, 'library', (l) => l.setSkillEnabled({
|
|
3803
|
+
id: body.id, enabled: body.enabled, by: body.by ?? OWNER, expectedRev: body.expectedRev,
|
|
3804
|
+
}))
|
|
3805
|
+
sendJson(response, 200, { ok: true, skill })
|
|
493
3806
|
return
|
|
494
3807
|
}
|
|
495
|
-
|
|
496
|
-
if (request.method === 'POST' && path === '/rooms/join') {
|
|
3808
|
+
if (request.method === 'POST' && path === '/library/skills/source') {
|
|
497
3809
|
const body = (await readBody(request)) ?? {}
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
)
|
|
501
|
-
sendJson(response, 200, { ok: true,
|
|
3810
|
+
const skill = await withService(services, 'library', (l) => l.setSkillSource({
|
|
3811
|
+
id: body.id, source: body.source, by: body.by ?? OWNER, expectedRev: body.expectedRev,
|
|
3812
|
+
}))
|
|
3813
|
+
sendJson(response, 200, { ok: true, skill })
|
|
502
3814
|
return
|
|
503
3815
|
}
|
|
504
|
-
|
|
505
|
-
|
|
3816
|
+
if (request.method === 'GET' && path === '/library/skills/dirs') {
|
|
3817
|
+
const dirs = await withService(services, 'library', (l) => l.listSkillDirs())
|
|
3818
|
+
sendJson(response, 200, { ok: true, dirs })
|
|
3819
|
+
return
|
|
3820
|
+
}
|
|
3821
|
+
if (request.method === 'POST' && path === '/library/skills/dirs/add') {
|
|
506
3822
|
const body = (await readBody(request)) ?? {}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
)
|
|
510
|
-
sendJson(response, 200, { ok: true, room })
|
|
3823
|
+
const out = await withService(services, 'library', (l) =>
|
|
3824
|
+
l.addSkillDir({ dir: body.dir, label: body.label, by: body.by ?? OWNER }))
|
|
3825
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
511
3826
|
return
|
|
512
3827
|
}
|
|
513
|
-
|
|
514
|
-
if (request.method === 'POST' && path === '/rooms/relay') {
|
|
3828
|
+
if (request.method === 'POST' && path === '/library/skills/dirs/remove') {
|
|
515
3829
|
const body = (await readBody(request)) ?? {}
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
)
|
|
519
|
-
|
|
3830
|
+
const out = await withService(services, 'library', (l) =>
|
|
3831
|
+
l.removeSkillDir({ dir: body.dir, by: body.by ?? OWNER }))
|
|
3832
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3833
|
+
return
|
|
3834
|
+
}
|
|
3835
|
+
if (request.method === 'GET' && path === '/library/skills/browse') {
|
|
3836
|
+
const out = await withService(services, 'library', (l) =>
|
|
3837
|
+
l.browseSkillDir({ dir: qp(url, 'dir'), sub: qp(url, 'sub') }))
|
|
3838
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3839
|
+
return
|
|
3840
|
+
}
|
|
3841
|
+
if (request.method === 'POST' && path === '/library/skills/scan-all') {
|
|
3842
|
+
const out = await withService(services, 'library', (l) => l.scanAllSkillDirs())
|
|
3843
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
520
3844
|
return
|
|
521
3845
|
}
|
|
522
3846
|
|
|
523
|
-
// ──
|
|
524
|
-
if (request.method === '
|
|
3847
|
+
// ── PDCA-2:验收证据(C 的机器闸门)────────────────────────────────────
|
|
3848
|
+
if (request.method === 'GET' && path === '/verify/history') {
|
|
3849
|
+
const items = await withService(services, 'verify', (v) => v.history(Number(qp(url, 'limit')) ?? 50))
|
|
3850
|
+
sendJson(response, 200, { ok: true, items })
|
|
3851
|
+
return
|
|
3852
|
+
}
|
|
3853
|
+
if (request.method === 'GET' && path === '/verify/stats') {
|
|
3854
|
+
const stats = await withService(services, 'verify', (v) => v.stats())
|
|
3855
|
+
sendJson(response, 200, { ok: true, stats })
|
|
3856
|
+
return
|
|
3857
|
+
}
|
|
3858
|
+
if (request.method === 'POST' && path === '/verify/check') {
|
|
525
3859
|
const body = (await readBody(request)) ?? {}
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
)
|
|
529
|
-
sendJson(response, 200, { ok: true, entry })
|
|
3860
|
+
const out = await withService(services, 'verify', (v) => v.check({ ...body, by: body.by ?? OWNER }))
|
|
3861
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
530
3862
|
return
|
|
531
3863
|
}
|
|
532
|
-
|
|
533
|
-
if (request.method === 'POST' && path === '/memory/remove') {
|
|
3864
|
+
if (request.method === 'POST' && path === '/verify/evaluate') {
|
|
534
3865
|
const body = (await readBody(request)) ?? {}
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
)
|
|
538
|
-
sendJson(response, 200, { ok: true, entry })
|
|
3866
|
+
const out = await withService(services, 'verify', (v) => v.evaluate(body.artifacts ?? []))
|
|
3867
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
539
3868
|
return
|
|
540
3869
|
}
|
|
541
|
-
|
|
542
|
-
if (request.method === 'POST' && path === '/memory/archive') {
|
|
3870
|
+
if (request.method === 'POST' && path === '/verify/task') {
|
|
543
3871
|
const body = (await readBody(request)) ?? {}
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
)
|
|
547
|
-
sendJson(response, 200, { ok: true, entry })
|
|
3872
|
+
const out = await withService(services, 'verify', (v) =>
|
|
3873
|
+
v.verifyTask({ id: body.id, artifacts: body.artifacts ?? [], writeScopes: body.writeScopes ?? [] }, { by: body.by ?? OWNER }))
|
|
3874
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
548
3875
|
return
|
|
549
3876
|
}
|
|
550
3877
|
|
|
551
|
-
|
|
3878
|
+
// ── PDCA-3:测量(metrics)──────────────────────────────────────────────
|
|
3879
|
+
if (request.method === 'GET' && path === '/metrics') {
|
|
3880
|
+
const out = await withService(services, 'metrics', (m) => m.stats())
|
|
3881
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3882
|
+
return
|
|
3883
|
+
}
|
|
3884
|
+
if (request.method === 'GET' && path === '/metrics/series') {
|
|
3885
|
+
const out = await withService(services, 'metrics', (m) => m.series({
|
|
3886
|
+
kind: qp(url, 'kind'), taskId: qp(url, 'taskId'),
|
|
3887
|
+
since: qp(url, 'since') === undefined ? undefined : Number(qp(url, 'since')),
|
|
3888
|
+
limit: qp(url, 'limit') === undefined ? undefined : Number(qp(url, 'limit')),
|
|
3889
|
+
}))
|
|
3890
|
+
sendJson(response, 200, { ok: true, items: out })
|
|
3891
|
+
return
|
|
3892
|
+
}
|
|
3893
|
+
if (request.method === 'GET' && path === '/metrics/aggregate') {
|
|
3894
|
+
const out = await withService(services, 'metrics', (m) => m.aggregate({
|
|
3895
|
+
kind: qp(url, 'kind'),
|
|
3896
|
+
since: qp(url, 'since') === undefined ? undefined : Number(qp(url, 'since')),
|
|
3897
|
+
}))
|
|
3898
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3899
|
+
return
|
|
3900
|
+
}
|
|
3901
|
+
if (request.method === 'POST' && path === '/metrics/record') {
|
|
552
3902
|
const body = (await readBody(request)) ?? {}
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
)
|
|
556
|
-
sendJson(response, 200, { ok: true, entry })
|
|
3903
|
+
const out = await withService(services, 'metrics', (m) => m.record({ ...body, by: body.by ?? OWNER }))
|
|
3904
|
+
sendJson(response, 200, { ok: true, record: out })
|
|
557
3905
|
return
|
|
558
3906
|
}
|
|
559
3907
|
|
|
560
|
-
// ──
|
|
561
|
-
if (request.method === 'GET' && path === '/
|
|
562
|
-
const
|
|
563
|
-
|
|
3908
|
+
// ── PDCA-4:复盘与标准化(Act)──────────────────────────────────────────
|
|
3909
|
+
if (request.method === 'GET' && path === '/cycles') {
|
|
3910
|
+
const items = await withService(services, 'cycle', (c) => c.list({
|
|
3911
|
+
status: qp(url, 'status'), phase: qp(url, 'phase'), taskId: qp(url, 'taskId'),
|
|
3912
|
+
// 需求4:kind 过滤——人只看自己的长任务周期,AI 的归纳周期另开视图
|
|
3913
|
+
kind: qp(url, 'kind'),
|
|
3914
|
+
}))
|
|
3915
|
+
sendJson(response, 200, { ok: true, items, phases: CYCLE_PHASES, kinds: CYCLE_KINDS })
|
|
564
3916
|
return
|
|
565
3917
|
}
|
|
566
|
-
if (request.method === '
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
)
|
|
572
|
-
sendJson(response, 200, { ok: true, ...listing })
|
|
3918
|
+
if (request.method === 'POST' && path === '/cycles') {
|
|
3919
|
+
const body = (await readBody(request)) ?? {}
|
|
3920
|
+
const out = await withService(services, 'cycle', (c) =>
|
|
3921
|
+
c.start({ ...body, by: body.by ?? OWNER }))
|
|
3922
|
+
sendJson(response, 200, { ok: true, cycle: out })
|
|
573
3923
|
return
|
|
574
3924
|
}
|
|
575
|
-
if (request.method === 'POST' && path === '/
|
|
3925
|
+
if (request.method === 'POST' && path === '/cycles/advance') {
|
|
576
3926
|
const body = (await readBody(request)) ?? {}
|
|
577
|
-
const
|
|
578
|
-
|
|
3927
|
+
const out = await withService(services, 'cycle', (c) =>
|
|
3928
|
+
c.advance({ ...body, by: body.by ?? OWNER }))
|
|
3929
|
+
sendJson(response, 200, { ok: true, cycle: out })
|
|
579
3930
|
return
|
|
580
3931
|
}
|
|
581
|
-
if (request.method === 'POST' && path === '/
|
|
3932
|
+
if (request.method === 'POST' && path === '/cycles/check') {
|
|
582
3933
|
const body = (await readBody(request)) ?? {}
|
|
583
|
-
const
|
|
584
|
-
|
|
3934
|
+
const out = await withService(services, 'cycle', (c) =>
|
|
3935
|
+
c.check({ ...body, by: body.by ?? OWNER }))
|
|
3936
|
+
sendJson(response, 200, { ok: true, cycle: out })
|
|
585
3937
|
return
|
|
586
3938
|
}
|
|
587
|
-
if (request.method === 'POST' && path === '/
|
|
3939
|
+
if (request.method === 'POST' && path === '/cycles/review') {
|
|
588
3940
|
const body = (await readBody(request)) ?? {}
|
|
589
|
-
const
|
|
590
|
-
|
|
3941
|
+
const out = await withService(services, 'cycle', (c) =>
|
|
3942
|
+
c.review({ ...body, by: body.by ?? OWNER }))
|
|
3943
|
+
sendJson(response, 200, { ok: true, ...out })
|
|
3944
|
+
return
|
|
3945
|
+
}
|
|
3946
|
+
if (request.method === 'GET' && path === '/cycles/stats') {
|
|
3947
|
+
const stats = await withService(services, 'cycle', (c) => c.stats())
|
|
3948
|
+
sendJson(response, 200, { ok: true, stats })
|
|
591
3949
|
return
|
|
592
3950
|
}
|
|
593
3951
|
|
|
594
|
-
// ──
|
|
595
|
-
if (request.method === 'GET' && path === '/
|
|
596
|
-
const
|
|
597
|
-
|
|
3952
|
+
// ── 大/小团队(Lead 聚合管理)──────────────────────────────────────────
|
|
3953
|
+
if (request.method === 'GET' && path === '/teams') {
|
|
3954
|
+
const items = await withService(services, 'teams', (t) => t.list({
|
|
3955
|
+
type: qp(url, 'type'), status: qp(url, 'status'),
|
|
3956
|
+
}))
|
|
3957
|
+
sendJson(response, 200, {
|
|
3958
|
+
ok: true, items,
|
|
3959
|
+
types: TEAM_TYPES, roles: TEAM_ROLES, policies: TEAM_ADMIN_POLICIES,
|
|
3960
|
+
})
|
|
598
3961
|
return
|
|
599
3962
|
}
|
|
600
|
-
if (request.method === 'POST' && path === '/
|
|
3963
|
+
if (request.method === 'POST' && path === '/teams') {
|
|
601
3964
|
const body = (await readBody(request)) ?? {}
|
|
602
|
-
const
|
|
603
|
-
|
|
3965
|
+
const out = await withService(services, 'teams', (t) =>
|
|
3966
|
+
t.create({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3967
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
604
3968
|
return
|
|
605
3969
|
}
|
|
606
|
-
if (request.method === 'POST' && path === '/
|
|
3970
|
+
if (request.method === 'POST' && path === '/teams/add') {
|
|
607
3971
|
const body = (await readBody(request)) ?? {}
|
|
608
|
-
const
|
|
609
|
-
|
|
3972
|
+
const out = await withService(services, 'teams', (t) =>
|
|
3973
|
+
t.addMember({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3974
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
610
3975
|
return
|
|
611
3976
|
}
|
|
612
|
-
if (request.method === 'POST' && path === '/
|
|
3977
|
+
if (request.method === 'POST' && path === '/teams/remove') {
|
|
613
3978
|
const body = (await readBody(request)) ?? {}
|
|
614
|
-
const
|
|
615
|
-
|
|
3979
|
+
const out = await withService(services, 'teams', (t) =>
|
|
3980
|
+
t.removeMember({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3981
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
616
3982
|
return
|
|
617
3983
|
}
|
|
618
|
-
if (request.method === '
|
|
619
|
-
const
|
|
620
|
-
|
|
3984
|
+
if (request.method === 'POST' && path === '/teams/role') {
|
|
3985
|
+
const body = (await readBody(request)) ?? {}
|
|
3986
|
+
const out = await withService(services, 'teams', (t) =>
|
|
3987
|
+
t.setRole({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3988
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
621
3989
|
return
|
|
622
3990
|
}
|
|
623
|
-
if (request.method === 'POST' && path === '/
|
|
3991
|
+
if (request.method === 'POST' && path === '/teams/note') {
|
|
624
3992
|
const body = (await readBody(request)) ?? {}
|
|
625
|
-
const
|
|
626
|
-
|
|
3993
|
+
const out = await withService(services, 'teams', (t) =>
|
|
3994
|
+
t.note({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3995
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
627
3996
|
return
|
|
628
3997
|
}
|
|
629
|
-
if (request.method === 'POST' && path === '/
|
|
3998
|
+
if (request.method === 'POST' && path === '/teams/close') {
|
|
630
3999
|
const body = (await readBody(request)) ?? {}
|
|
631
|
-
const
|
|
632
|
-
|
|
4000
|
+
const out = await withService(services, 'teams', (t) =>
|
|
4001
|
+
t.close({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
4002
|
+
sendJson(response, 200, { ok: true, team: out })
|
|
633
4003
|
return
|
|
634
4004
|
}
|
|
635
|
-
|
|
4005
|
+
// 管理策略(2026-11-09「管理员权限边界从页面控制」):CAS 改团队的 adminPolicy。
|
|
4006
|
+
if (request.method === 'POST' && path === '/teams/policy') {
|
|
636
4007
|
const body = (await readBody(request)) ?? {}
|
|
637
|
-
|
|
638
|
-
|
|
4008
|
+
if (!body.id) {
|
|
4009
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
4010
|
+
return
|
|
4011
|
+
}
|
|
4012
|
+
if (!TEAM_ADMIN_POLICIES.includes(body.adminPolicy)) {
|
|
4013
|
+
sendJson(response, 400, {
|
|
4014
|
+
ok: false,
|
|
4015
|
+
code: 'TEAM_BAD_POLICY',
|
|
4016
|
+
reason: `adminPolicy 非法(合法:${TEAM_ADMIN_POLICIES.join(' | ')})`,
|
|
4017
|
+
})
|
|
4018
|
+
return
|
|
4019
|
+
}
|
|
4020
|
+
const out = await withService(services, 'teams', (t) =>
|
|
4021
|
+
t.setPolicy({
|
|
4022
|
+
id: body.id,
|
|
4023
|
+
expectedRev: body.expectedRev,
|
|
4024
|
+
adminPolicy: body.adminPolicy,
|
|
4025
|
+
by: body.by ?? OWNER,
|
|
4026
|
+
})).catch(markTeamError)
|
|
4027
|
+
sendJson(response, 200, { ok: true, team: out, adminPolicy: out.adminPolicy })
|
|
639
4028
|
return
|
|
640
4029
|
}
|
|
641
4030
|
|
|
642
|
-
// ──
|
|
643
|
-
if (request.method === 'GET' && path === '/
|
|
644
|
-
const
|
|
4031
|
+
// ── CS-BOTS 工作区(契约 bots06 §2.4):只读 ───────────────────────────
|
|
4032
|
+
if (request.method === 'GET' && path === '/workspace') {
|
|
4033
|
+
const root = typeof services.settings?.botWorkspaceRoot === 'string'
|
|
4034
|
+
? services.settings.botWorkspaceRoot
|
|
4035
|
+
: null
|
|
4036
|
+
let exists = false
|
|
4037
|
+
if (root) {
|
|
4038
|
+
try {
|
|
4039
|
+
exists = existsSync(root) && statSync(root).isDirectory()
|
|
4040
|
+
} catch {
|
|
4041
|
+
exists = false
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
const dshHome = resolve(process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`)
|
|
645
4045
|
sendJson(response, 200, {
|
|
646
4046
|
ok: true,
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
4047
|
+
root,
|
|
4048
|
+
exists,
|
|
4049
|
+
dshHome,
|
|
4050
|
+
note: 'CS-BOTS 所有会话的默认工作区:shared/ 放交付物,scratch/ 放草稿,archive/ 放归档',
|
|
651
4051
|
})
|
|
652
4052
|
return
|
|
653
4053
|
}
|
|
654
4054
|
|
|
655
|
-
|
|
4055
|
+
// ── 团队 L2 记忆(契约 bots06 §2.1)────────────────────────────────
|
|
4056
|
+
// 读:只从 memory 服务取 L2 + tag=team:<id>,按 at 升序;不写任何文件。
|
|
4057
|
+
if (request.method === 'GET' && path === '/teams/memory') {
|
|
4058
|
+
const id = qp(url, 'id')
|
|
4059
|
+
if (!id) {
|
|
4060
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
4061
|
+
return
|
|
4062
|
+
}
|
|
4063
|
+
const team = await withService(services, 'teams', (t) => t.get(id))
|
|
4064
|
+
if (!team) {
|
|
4065
|
+
sendJson(response, 404, { ok: false, reason: `团队 ${id} 不存在` })
|
|
4066
|
+
return
|
|
4067
|
+
}
|
|
4068
|
+
const memories = readTeamMemories(services, id)
|
|
4069
|
+
sendJson(response, 200, {
|
|
4070
|
+
ok: true,
|
|
4071
|
+
teamId: id,
|
|
4072
|
+
memories,
|
|
4073
|
+
text: buildTeamMemoryText(memories),
|
|
4074
|
+
note: '团队记忆属 L2 团队层,全队可见;任务进度另记在 L4 任务笔记',
|
|
4075
|
+
})
|
|
4076
|
+
return
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
// 写:append-only;权限只认队长(leadId)或管理员('user')。
|
|
4080
|
+
if (request.method === 'POST' && path === '/teams/memory') {
|
|
656
4081
|
const body = (await readBody(request)) ?? {}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const saved = saveSettings(dir, body)
|
|
660
|
-
if (!saved.ok) {
|
|
661
|
-
sendJson(response, 400, { ok: false, reason: saved.reason })
|
|
4082
|
+
if (!body.id) {
|
|
4083
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
662
4084
|
return
|
|
663
4085
|
}
|
|
4086
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
4087
|
+
if (!text) {
|
|
4088
|
+
sendJson(response, 400, { ok: false, reason: '团队记忆不能为空' })
|
|
4089
|
+
return
|
|
4090
|
+
}
|
|
4091
|
+
if (text.length > ANCHOR_TEXT_MAX) {
|
|
4092
|
+
sendJson(response, 400, { ok: false, reason: `团队记忆过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
|
|
4093
|
+
return
|
|
4094
|
+
}
|
|
4095
|
+
const team = await withService(services, 'teams', (t) => t.get(body.id))
|
|
4096
|
+
if (!team) {
|
|
4097
|
+
sendJson(response, 404, { ok: false, reason: `团队 ${body.id} 不存在` })
|
|
4098
|
+
return
|
|
4099
|
+
}
|
|
4100
|
+
const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : ''
|
|
4101
|
+
if (by !== team.leadId && by !== OWNER) {
|
|
4102
|
+
sendJson(response, 403, { ok: false, reason: '只有队长或管理员能写团队记忆' })
|
|
4103
|
+
return
|
|
4104
|
+
}
|
|
4105
|
+
// 落盘走 memory 服务(L2 + tag=team:<id>);memory 缺失时 503。
|
|
4106
|
+
const entry = await withService(services, 'memory', (m) =>
|
|
4107
|
+
m.add({ layer: 'L2', content: text, by, tags: [teamMemoryTag(body.id)] }))
|
|
4108
|
+
const saved = Array.isArray(entry) ? entry[0] : entry
|
|
664
4109
|
sendJson(response, 200, {
|
|
665
4110
|
ok: true,
|
|
666
|
-
|
|
667
|
-
|
|
4111
|
+
memory: {
|
|
4112
|
+
id: saved?.id,
|
|
4113
|
+
content: saved?.content ?? text,
|
|
4114
|
+
by: saved?.createdBy ?? by,
|
|
4115
|
+
at: saved?.createdAt ?? Date.now(),
|
|
4116
|
+
},
|
|
4117
|
+
note: '已写入团队记忆(L2,全队可见)',
|
|
668
4118
|
})
|
|
669
4119
|
return
|
|
670
4120
|
}
|
|
671
4121
|
|
|
672
|
-
// ──
|
|
673
|
-
|
|
4122
|
+
// ── 需求2:任务对话内容查看(2026-11-08)──────────────────────────────
|
|
4123
|
+
// 用户反馈:「任务栏页面指派 bot 后,无法追踪任务进度,无法看到产出物,
|
|
4124
|
+
// 没有跳转对话也没有对话内容查看」
|
|
4125
|
+
// ⇒ 在任务详情里直接读执行者的会话历史,不必跳走。
|
|
4126
|
+
if (request.method === 'GET' && path === '/tasks/conversation') {
|
|
4127
|
+
const id = qp(url, 'id')
|
|
4128
|
+
if (!id) {
|
|
4129
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
4130
|
+
return
|
|
4131
|
+
}
|
|
4132
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
4133
|
+
if (!task) {
|
|
4134
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
4135
|
+
return
|
|
4136
|
+
}
|
|
4137
|
+
// 找执行者会话:assignee → ownerId → 平台伪身份排除
|
|
4138
|
+
const candidates = [task.assignee, task.ownerId, task.anchor?.ownerId]
|
|
4139
|
+
.filter((v) => typeof v === 'string' && v && !['ui', 'user', 'anyone', 'system'].includes(v))
|
|
4140
|
+
if (candidates.length === 0) {
|
|
4141
|
+
sendJson(response, 200, {
|
|
4142
|
+
ok: false,
|
|
4143
|
+
reason: '这个任务还没有执行者(未派活),没有对话可看',
|
|
4144
|
+
code: 'NO_EXECUTOR',
|
|
4145
|
+
})
|
|
4146
|
+
return
|
|
4147
|
+
}
|
|
4148
|
+
// 把候选 sessionId 映射到 Bot 记录(拿真名)
|
|
4149
|
+
let bot
|
|
4150
|
+
try {
|
|
4151
|
+
const all = services.roster?.list?.({ includeRetired: true }) ?? []
|
|
4152
|
+
bot = all.find((b) => candidates.includes(b.sessionId) || candidates.includes(b.id))
|
|
4153
|
+
} catch { bot = undefined }
|
|
4154
|
+
const sessionId = bot?.sessionId ?? candidates[0]
|
|
4155
|
+
const limit = qn(url, 'limit')
|
|
4156
|
+
const out = await readSessionMessages(ctx, sessionId, { limit: limit ?? 40 })
|
|
4157
|
+
sendJson(response, 200, {
|
|
4158
|
+
...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
|
|
4159
|
+
sessionId,
|
|
4160
|
+
...out,
|
|
4161
|
+
})
|
|
4162
|
+
return
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
// ── 改造07:任务订阅(借鉴 Super-agent Subscription 扇出)─────────────────
|
|
4166
|
+
// GET /subscriptions —— 列出订阅(按 taskId/roomId/state 过滤)
|
|
4167
|
+
if (request.method === 'GET' && path === '/subscriptions') {
|
|
4168
|
+
const subs = await withService(services, 'subscription', (s) => s.list({
|
|
4169
|
+
taskId: qp(url, 'taskId'),
|
|
4170
|
+
roomId: qp(url, 'roomId'),
|
|
4171
|
+
state: qp(url, 'state'),
|
|
4172
|
+
}))
|
|
4173
|
+
sendJson(response, 200, { ok: true, subs, stats: services.subscription?.stats?.() })
|
|
4174
|
+
return
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
// POST /subscriptions —— 订阅(任务接收房间后续消息)
|
|
4178
|
+
if (request.method === 'POST' && path === '/subscriptions') {
|
|
674
4179
|
const body = (await readBody(request)) ?? {}
|
|
675
|
-
|
|
676
|
-
|
|
4180
|
+
if (!body.taskId || !body.roomId) {
|
|
4181
|
+
sendJson(response, 400, { ok: false, reason: 'taskId 与 roomId 必填' })
|
|
4182
|
+
return
|
|
4183
|
+
}
|
|
4184
|
+
const sub = await withService(services, 'subscription', (s) =>
|
|
4185
|
+
s.subscribe({ ...body, by: body.by ?? OWNER }))
|
|
4186
|
+
sendJson(response, 200, { ok: true, sub })
|
|
677
4187
|
return
|
|
678
4188
|
}
|
|
679
4189
|
|
|
680
|
-
//
|
|
681
|
-
if (request.method === '
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
4190
|
+
// POST /subscriptions/unsubscribe —— 取消订阅
|
|
4191
|
+
if (request.method === 'POST' && path === '/subscriptions/unsubscribe') {
|
|
4192
|
+
const body = (await readBody(request)) ?? {}
|
|
4193
|
+
if (!body.id) {
|
|
4194
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
4195
|
+
return
|
|
4196
|
+
}
|
|
4197
|
+
const sub = await withService(services, 'subscription', (s) =>
|
|
4198
|
+
s.unsubscribe({ ...body, by: body.by ?? OWNER }))
|
|
4199
|
+
sendJson(response, 200, { ok: true, sub })
|
|
4200
|
+
return
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
// ── 改造07:穿透面板(下层 AI 基础设施查看)─────────────────────────────
|
|
4204
|
+
// GET /deepdive/blocks —— 列出 7 个下层区块 + 概览统计
|
|
4205
|
+
if (request.method === 'GET' && path === '/deepdive/blocks') {
|
|
4206
|
+
const blocks = []
|
|
4207
|
+
// 注册表:Bot 数量 + 各状态计数
|
|
4208
|
+
await withService(services, 'roster', (r) => {
|
|
4209
|
+
const rows = typeof r.list === 'function' ? r.list({ includeRetired: true }) : []
|
|
4210
|
+
const byState = {}
|
|
4211
|
+
for (const row of rows) {
|
|
4212
|
+
const st = row?.state ?? row?.status ?? 'unknown'
|
|
4213
|
+
byState[st] = (byState[st] ?? 0) + 1
|
|
4214
|
+
}
|
|
4215
|
+
blocks.push({
|
|
4216
|
+
id: 'registry', label: '注册表', description: 'Bot 注册表 / 任务板 / 团队注册',
|
|
4217
|
+
stats: { bots: rows.length, states: byState },
|
|
4218
|
+
})
|
|
4219
|
+
})
|
|
4220
|
+
// 唤醒桥:Dispatcher 统计
|
|
4221
|
+
const dStats = services.dispatcher?.stats?.()
|
|
4222
|
+
if (dStats) {
|
|
4223
|
+
blocks.push({
|
|
4224
|
+
id: 'wake', label: '唤醒桥', description: 'Dispatcher 队列 / 去重 / 背压',
|
|
4225
|
+
stats: { total: dStats.total, sent: dStats.sent, skipped: dStats.skipped, failed: dStats.failed, pending: dStats.pending },
|
|
4226
|
+
})
|
|
4227
|
+
}
|
|
4228
|
+
// 存储:各 jsonl 健康
|
|
4229
|
+
const storeFiles = []
|
|
4230
|
+
for (const f of ['board.jsonl', 'roster.jsonl', 'wakes.jsonl', 'memory.jsonl', 'cycle.jsonl', 'todo.jsonl', 'rooms']) {
|
|
4231
|
+
try {
|
|
4232
|
+
const p = statePath(services.board?.paths?.stateDir ?? '', f)
|
|
4233
|
+
const st = existsSync(p) ? statSync(p) : null
|
|
4234
|
+
storeFiles.push({ name: f, exists: Boolean(st), size: st ? st.size : 0, mtime: st ? st.mtimeMs : null })
|
|
4235
|
+
} catch {}
|
|
4236
|
+
}
|
|
4237
|
+
blocks.push({
|
|
4238
|
+
id: 'store', label: '存储', description: 'jsonl 事件流 / 快照 / 原子写',
|
|
4239
|
+
stats: { files: storeFiles.filter((f) => f.exists).length, total: storeFiles.length },
|
|
4240
|
+
})
|
|
4241
|
+
// 路由:复杂度路由规则(静态展示)
|
|
4242
|
+
blocks.push({
|
|
4243
|
+
id: 'routing', label: '路由', description: '复杂度路由 T0-T3 / 免费付费策略',
|
|
4244
|
+
stats: { tiers: ['T0', 'T1', 'T2', 'T3'], default: 'free_only (fail-closed)' },
|
|
4245
|
+
})
|
|
4246
|
+
// 验证:验收统计
|
|
4247
|
+
const vStats = services.verify?.stats?.()
|
|
4248
|
+
blocks.push({
|
|
4249
|
+
id: 'verify', label: '验证', description: '验收证据 / 退出码 / 测试结果',
|
|
4250
|
+
stats: vStats ?? { note: 'verify 服务未装配' },
|
|
4251
|
+
})
|
|
4252
|
+
// 建议:建议队列
|
|
4253
|
+
const sugStats = services.suggest?.stats?.()
|
|
4254
|
+
blocks.push({
|
|
4255
|
+
id: 'suggest', label: '建议', description: '建议确认队列 / 纪律提议',
|
|
4256
|
+
stats: sugStats ?? { note: 'suggest 服务未装配' },
|
|
4257
|
+
})
|
|
4258
|
+
// 订阅:任务 ↔ 房间订阅关系(服务不可用时计数为 0,绝不抛)
|
|
4259
|
+
const subStats = services.subscription?.stats?.()
|
|
4260
|
+
blocks.push({
|
|
4261
|
+
id: 'subscription', label: '订阅', description: '任务订阅、房间扇出',
|
|
4262
|
+
stats: {
|
|
4263
|
+
total: subStats?.total ?? 0,
|
|
4264
|
+
active: subStats?.active ?? 0,
|
|
4265
|
+
dropped: subStats?.dropped ?? 0,
|
|
4266
|
+
fanouts: subStats?.fanouts ?? 0,
|
|
4267
|
+
},
|
|
4268
|
+
})
|
|
4269
|
+
sendJson(response, 200, { ok: true, blocks })
|
|
4270
|
+
return
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
// GET /deepdive/block/:id —— 某区块详情 + 可执行操作
|
|
4274
|
+
if (request.method === 'GET' && path.startsWith('/deepdive/block/')) {
|
|
4275
|
+
const id = decodeURIComponent(path.slice('/deepdive/block/'.length))
|
|
4276
|
+
const data = {}
|
|
4277
|
+
const operations = []
|
|
4278
|
+
try {
|
|
4279
|
+
if (id === 'registry') {
|
|
4280
|
+
const rows = await withService(services, 'roster', (r) => r.list({ includeRetired: true }))
|
|
4281
|
+
data.bots = rows
|
|
4282
|
+
operations.push({ id: 'revive', label: '重启全部 Bot 会话', body: {} })
|
|
4283
|
+
} else if (id === 'wake') {
|
|
4284
|
+
data.stats = services.dispatcher?.stats?.()
|
|
4285
|
+
data.recent = services.dispatcher?.list?.({ limit: 20 }) ?? []
|
|
4286
|
+
operations.push({ id: 'flush', label: '强制投递队列', body: {} })
|
|
4287
|
+
} else if (id === 'store') {
|
|
4288
|
+
const dir = services.board?.paths?.stateDir ?? ''
|
|
4289
|
+
const files = []
|
|
4290
|
+
try {
|
|
4291
|
+
for (const entry of readdirSync(dir)) {
|
|
4292
|
+
const p = join(dir, entry)
|
|
4293
|
+
const st = statSync(p)
|
|
4294
|
+
files.push({ name: entry, size: st.size, mtime: st.mtimeMs })
|
|
4295
|
+
}
|
|
4296
|
+
} catch {}
|
|
4297
|
+
data.files = files.slice(0, 50)
|
|
4298
|
+
data.stateDir = dir
|
|
4299
|
+
operations.push({ id: 'replay', label: '重建内存态(replay)', body: {} })
|
|
4300
|
+
} else if (id === 'routing') {
|
|
4301
|
+
data.tiers = ['T0', 'T1', 'T2', 'T3']
|
|
4302
|
+
data.description = 'T0 一句话问答 / T1 常规改动 / T2 跨文件或多步 / T3 架构级。缺省 = free_only(只走免费渠道,fail-closed)。'
|
|
4303
|
+
} else if (id === 'verify') {
|
|
4304
|
+
data.stats = services.verify?.stats?.()
|
|
4305
|
+
data.records = services.verify?.list?.({ limit: 20 }) ?? []
|
|
4306
|
+
operations.push({ id: 'recheck', label: '手动重跑验收', body: {} })
|
|
4307
|
+
} else if (id === 'suggest') {
|
|
4308
|
+
data.stats = services.suggest?.stats?.()
|
|
4309
|
+
data.pending = services.suggest?.list?.({ status: 'pending', limit: 20 }) ?? []
|
|
4310
|
+
} else if (id === 'subscription') {
|
|
4311
|
+
// 订阅:任务 ↔ 房间订阅关系。
|
|
4312
|
+
// ⚠️ 服务未装配时**不能抛** —— 穿透面板是「管理/查看」,不是硬依赖。
|
|
4313
|
+
if (!services.subscription) {
|
|
4314
|
+
sendJson(response, 200, {
|
|
4315
|
+
ok: true, block: id,
|
|
4316
|
+
available: false, reason: '订阅服务未装配', data: {}, operations: [],
|
|
4317
|
+
})
|
|
4318
|
+
return
|
|
4319
|
+
}
|
|
4320
|
+
data.stats = services.subscription.stats()
|
|
4321
|
+
data.subscriptions = services.subscription.list({}).slice(0, 100)
|
|
4322
|
+
operations.push({ id: 'unsubscribe', label: '取消某订阅', body: { id: '' } })
|
|
4323
|
+
} else {
|
|
4324
|
+
sendJson(response, 404, { ok: false, reason: `未知区块: ${id}` })
|
|
4325
|
+
return
|
|
4326
|
+
}
|
|
4327
|
+
} catch (error) {
|
|
4328
|
+
sendJson(response, 500, { ok: false, reason: `区块详情读取失败:${error?.message ?? error}` })
|
|
4329
|
+
return
|
|
4330
|
+
}
|
|
4331
|
+
sendJson(response, 200, { ok: true, block: id, data, operations })
|
|
4332
|
+
return
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
// POST /deepdive/op —— 区块操作(手动唤醒/flush/replay 等)
|
|
4336
|
+
if (request.method === 'POST' && path === '/deepdive/op') {
|
|
4337
|
+
const body = (await readBody(request)) ?? {}
|
|
4338
|
+
const { block, op } = body
|
|
4339
|
+
try {
|
|
4340
|
+
if (block === 'wake' && op === 'flush') {
|
|
4341
|
+
const result = services.dispatcher?.flush?.({ force: true })
|
|
4342
|
+
sendJson(response, 200, { ok: true, text: `已强制投递:sent=${result?.sent ?? 0}, failed=${result?.failed ?? 0}`, result })
|
|
4343
|
+
} else if (block === 'store' && op === 'replay') {
|
|
4344
|
+
const result = services.board?.replay?.()
|
|
4345
|
+
sendJson(response, 200, { ok: true, text: `replay 完成:${JSON.stringify(result ?? {})}`, result })
|
|
4346
|
+
} else if (block === 'registry' && op === 'revive') {
|
|
4347
|
+
sendJson(response, 200, { ok: true, text: 'Bot 会话重启需要内核支持,请通过页面操作' })
|
|
4348
|
+
} else if (block === 'verify' && op === 'recheck') {
|
|
4349
|
+
sendJson(response, 200, { ok: true, text: '验收重跑需要在任务详情中操作' })
|
|
4350
|
+
} else if (block === 'subscription' && op === 'unsubscribe') {
|
|
4351
|
+
const result = services.subscription?.unsubscribe(body.id)
|
|
4352
|
+
if (!result?.ok) {
|
|
4353
|
+
sendJson(response, 400, { ok: false, reason: result?.reason ?? '取消订阅失败' })
|
|
4354
|
+
return
|
|
4355
|
+
}
|
|
4356
|
+
sendJson(response, 200, { ok: true, text: `已取消订阅 ${body.id}`, result })
|
|
4357
|
+
} else {
|
|
4358
|
+
sendJson(response, 400, { ok: false, reason: `不支持的操作:${block}/${op}` })
|
|
4359
|
+
}
|
|
4360
|
+
} catch (error) {
|
|
4361
|
+
sendJson(response, 400, { ok: false, reason: `操作失败:${error?.message ?? error}` })
|
|
4362
|
+
}
|
|
686
4363
|
return
|
|
687
4364
|
}
|
|
688
4365
|
|