cs-bots 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +294 -0
- package/README.md +47 -174
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +14 -13
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +3 -3
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +3 -3
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +2 -2
- package/docs/CONTRACT-/345/215/207/347/272/24709-Boss/344/275/223/351/252/214.md +280 -0
- package/docs//345/215/207/347/272/24708-/346/216/245/347/273/255/344/270/216/345/233/236/346/273/232.md +298 -0
- package/package.json +2 -2
- package/src/api.js +1105 -8
- package/src/board.js +104 -11
- package/src/client.js +731 -96
- package/src/dispatcher.js +70 -11
- package/src/index.js +8 -1
- package/src/kernel.js +15 -0
- package/src/room.js +160 -9
- package/src/roster.js +54 -0
- package/src/settings.js +2 -2
- package/src/teams.js +386 -4
- package/src/tools.js +124 -3
package/src/api.js
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
* - **前缀路由**:`/cs-bots/api`(与包名一致,避免和其他插件撞路径)。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
15
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, renameSync } from 'node:fs'
|
|
16
16
|
import { createHash } from 'node:crypto'
|
|
17
17
|
import { join, resolve, sep as pathSep } from 'node:path'
|
|
18
|
-
import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark } from './kernel.js'
|
|
18
|
+
import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark, sessionMeta } from './kernel.js'
|
|
19
19
|
import { createLibrary } from './library.js'
|
|
20
20
|
import { createDirBrowser } from './dirbrowse.js'
|
|
21
21
|
import { getServices } from './registry.js'
|
|
22
22
|
import { createFence } from './fence.js'
|
|
23
|
-
import { appendJsonl, readJsonl, statePath } from './store.js'
|
|
23
|
+
import { appendJsonl, readJsonl, statePath, backupState, migrate, readSchemaVersion, writeSchemaVersion } from './store.js'
|
|
24
24
|
import { deriveStage, attachStages, stageSummary } from './stage.js'
|
|
25
25
|
import { CMD_WHITELIST } from './verify.js'
|
|
26
26
|
import { execFile } from 'node:child_process'
|
|
@@ -474,6 +474,229 @@ function normalizePhases(raw, code = 'EVOLVE_BAD_PHASES') {
|
|
|
474
474
|
})
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
478
|
+
// 升级09 · 阶段产出物(契约 §2.2 / §2.3 / §4.2)
|
|
479
|
+
//
|
|
480
|
+
// 一句话:长任务的阶段清单存在 `kind:'longrun'` 行里,执行者每交一个阶段的
|
|
481
|
+
// 产出物就追加一条 `kind:'phase'` 行。清单与产物**分开存**是刻意的——
|
|
482
|
+
// Boss 改阶段清单(§4.2)不该动已经交出来的证据。
|
|
483
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
484
|
+
|
|
485
|
+
/** 阶段状态(写入端合法值;读取端另有 `pending` = 「未开始」,见 buildPhaseView)。 */
|
|
486
|
+
const PHASE_STATES = ['doing', 'done', 'blocked']
|
|
487
|
+
/** 单阶段产出物条数上限 / 单项长度上限(契约 §2.3)。 */
|
|
488
|
+
const PHASE_ARTIFACTS_MAX = 50
|
|
489
|
+
const PHASE_ARTIFACT_MAX = 2000
|
|
490
|
+
/** 阶段说明长度上限(契约未写死,取与产出物同档的保守值)。 */
|
|
491
|
+
const PHASE_NOTE_MAX = 2000
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 构造一个带 HTTP 状态码的错误(HTTP 与工具面共用同一份,**不写两套判定**)。
|
|
495
|
+
* @param {string} message @param {number} status @param {string} code
|
|
496
|
+
*/
|
|
497
|
+
function phaseError(message, status, code) {
|
|
498
|
+
const error = new Error(message)
|
|
499
|
+
error.status = status
|
|
500
|
+
error.code = code
|
|
501
|
+
return error
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 归一化单阶段的产出物清单(契约 §2.3)。
|
|
506
|
+
*
|
|
507
|
+
* 规则:去空、去重、最多 50 项、每项 ≤ 2000 字。
|
|
508
|
+
* **超限选择报错而不是静默截断**:产出物是证据,悄悄丢掉一条比拒绝写入更糟。
|
|
509
|
+
* @param {unknown} list
|
|
510
|
+
* @returns {string[]}
|
|
511
|
+
*/
|
|
512
|
+
function normalizePhaseArtifacts(list) {
|
|
513
|
+
if (list === undefined || list === null) return []
|
|
514
|
+
if (!Array.isArray(list)) {
|
|
515
|
+
throw phaseError('artifacts 必须是字符串数组(也可以不传)', 400, 'PHASE_BAD_ARTIFACTS')
|
|
516
|
+
}
|
|
517
|
+
const items = normalizeArtifactItems(list)
|
|
518
|
+
.map((item) => item.trim())
|
|
519
|
+
.filter((item) => item !== '')
|
|
520
|
+
if (items.length > PHASE_ARTIFACTS_MAX) {
|
|
521
|
+
throw phaseError(
|
|
522
|
+
`artifacts 最多 ${PHASE_ARTIFACTS_MAX} 项(收到 ${items.length} 项)——请合并或分批汇报`,
|
|
523
|
+
400,
|
|
524
|
+
'PHASE_BAD_ARTIFACTS',
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
const seen = new Set()
|
|
528
|
+
const out = []
|
|
529
|
+
for (const item of items) {
|
|
530
|
+
if (Array.from(item).length > PHASE_ARTIFACT_MAX) {
|
|
531
|
+
throw phaseError(
|
|
532
|
+
`artifacts 单项最多 ${PHASE_ARTIFACT_MAX} 字(收到 ${Array.from(item).length} 字)——请放路径/链接,不要塞整篇内容`,
|
|
533
|
+
400,
|
|
534
|
+
'PHASE_BAD_ARTIFACTS',
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
if (seen.has(item)) continue
|
|
538
|
+
seen.add(item)
|
|
539
|
+
out.push(item)
|
|
540
|
+
}
|
|
541
|
+
return out
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 读某任务的阶段产出物行(按 `at` 升序;同 index 后写的胜出)。 */
|
|
545
|
+
function readPhaseRows(services, taskId) {
|
|
546
|
+
const rows = readTaskMeta(services, taskId).filter(
|
|
547
|
+
(row) => row.kind === 'phase'
|
|
548
|
+
&& Number.isInteger(row.index)
|
|
549
|
+
&& row.index >= 0,
|
|
550
|
+
)
|
|
551
|
+
const byIndex = new Map()
|
|
552
|
+
for (const row of rows) byIndex.set(row.index, row) // 顺序已按 at 升序 ⇒ 后写覆盖前写
|
|
553
|
+
return byIndex
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 拼出契约 §2.2 的完整视图(HTTP 读端点与写端点的回填共用同一份)。
|
|
558
|
+
*
|
|
559
|
+
* @param {object} services
|
|
560
|
+
* @param {object} task board 任务快照
|
|
561
|
+
* @returns {{ taskId: string, phases: object[], currentIndex: number,
|
|
562
|
+
* totalArtifacts: number, isLongrun: boolean, note: string }}
|
|
563
|
+
*/
|
|
564
|
+
function buildPhaseView(services, task) {
|
|
565
|
+
const longrun = readLongrunMeta(services, task.id)
|
|
566
|
+
const titles = longrunPhaseList(longrun)
|
|
567
|
+
const isLongrun = Boolean(longrun)
|
|
568
|
+
if (!isLongrun) {
|
|
569
|
+
return {
|
|
570
|
+
taskId: task.id,
|
|
571
|
+
phases: [],
|
|
572
|
+
currentIndex: 0,
|
|
573
|
+
totalArtifacts: 0,
|
|
574
|
+
isLongrun: false,
|
|
575
|
+
note: '这不是长任务,没有分阶段产物;总产出物见下方',
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const byIndex = readPhaseRows(services, task.id)
|
|
579
|
+
// 只剩「清单里还存在的下标」:Boss 把清单改短后,越界的旧产物行**保留但不展示**
|
|
580
|
+
// (契约 §4.2 —— 不删证据,只是不再显示)。
|
|
581
|
+
const phases = titles.map((title, index) => {
|
|
582
|
+
const row = byIndex.get(index)
|
|
583
|
+
return {
|
|
584
|
+
index,
|
|
585
|
+
title,
|
|
586
|
+
// 没汇报过的阶段给 `pending`(UI 徽标「未开始」)—— 不伪造成 doing/done
|
|
587
|
+
state: PHASE_STATES.includes(row?.state) ? row.state : 'pending',
|
|
588
|
+
artifacts: Array.isArray(row?.artifacts) ? [...row.artifacts] : [],
|
|
589
|
+
note: typeof row?.note === 'string' ? row.note : '',
|
|
590
|
+
by: typeof row?.by === 'string' ? row.by : null,
|
|
591
|
+
at: Number.isFinite(Number(row?.at)) ? Number(row.at) : null,
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
const firstUnfinished = phases.findIndex((phase) => phase.state !== 'done')
|
|
595
|
+
const totalArtifacts = phases.reduce((sum, phase) => sum + phase.artifacts.length, 0)
|
|
596
|
+
const doneCount = phases.filter((phase) => phase.state === 'done').length
|
|
597
|
+
return {
|
|
598
|
+
taskId: task.id,
|
|
599
|
+
phases,
|
|
600
|
+
currentIndex: firstUnfinished === -1 ? phases.length : firstUnfinished,
|
|
601
|
+
totalArtifacts,
|
|
602
|
+
isLongrun: true,
|
|
603
|
+
note: `长任务共 ${phases.length} 个阶段,已完成 ${doneCount} 个;共 ${totalArtifacts} 个阶段产出物`,
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 写一条阶段产出物(契约 §2.3)。
|
|
609
|
+
*
|
|
610
|
+
* **单一实现**:HTTP `POST /tasks/phase` 与工具 `bot_task_phase`(C 的 tools.js)
|
|
611
|
+
* 都走这里 —— 两处各写一遍,早晚漂移成「页面能写、Bot 写不进」这种鬼故事。
|
|
612
|
+
*
|
|
613
|
+
* @param {object} services 域服务集合(需 board)
|
|
614
|
+
* @param {string} taskId
|
|
615
|
+
* @param {{ index: number, state?: string, artifacts?: string[], note?: string,
|
|
616
|
+
* by?: string, expectedRev?: number }} patch
|
|
617
|
+
* @returns {{ ok: true, taskId: string, phase: object, phases: object[],
|
|
618
|
+
* currentIndex: number, totalArtifacts: number, isLongrun: boolean }}
|
|
619
|
+
*/
|
|
620
|
+
function writePhaseRow(services, taskId, patch = {}) {
|
|
621
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
622
|
+
throw phaseError('id 必填(任务 id,如 t-36)', 400, 'PHASE_MISSING_ID')
|
|
623
|
+
}
|
|
624
|
+
const id = taskId.trim()
|
|
625
|
+
const board = services?.board
|
|
626
|
+
if (!board || typeof board.get !== 'function') {
|
|
627
|
+
throw phaseError('board 服务未装配(无法校验任务与阶段清单)', 503, 'BOARD_UNAVAILABLE')
|
|
628
|
+
}
|
|
629
|
+
const task = board.get(id)
|
|
630
|
+
if (!task) throw phaseError(`任务 ${id} 不存在`, 404, 'PHASE_TASK_NOT_FOUND')
|
|
631
|
+
|
|
632
|
+
const longrun = readLongrunMeta(services, id)
|
|
633
|
+
const titles = longrunPhaseList(longrun)
|
|
634
|
+
if (!longrun || titles.length === 0) {
|
|
635
|
+
throw phaseError(
|
|
636
|
+
`任务 ${id} 不是长任务(没有阶段清单)——请先在「转长任务」里定阶段,或用「编辑阶段清单」设置`,
|
|
637
|
+
400,
|
|
638
|
+
'PHASE_NOT_LONGRUN',
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── CAS(可选):给了 expectedRev 就必须对得上,不匹配绝不静默覆盖 ──
|
|
643
|
+
if (patch.expectedRev !== undefined && patch.expectedRev !== null) {
|
|
644
|
+
if (!Number.isInteger(patch.expectedRev)) {
|
|
645
|
+
throw phaseError(`expectedRev 必须是整数(当前 rev=${task.rev})`, 400, 'PHASE_BAD_FIELD')
|
|
646
|
+
}
|
|
647
|
+
if (patch.expectedRev !== task.rev) {
|
|
648
|
+
throw phaseError(
|
|
649
|
+
`CAS 冲突:任务 ${id} 期望 rev=${patch.expectedRev},实际 rev=${task.rev} —— 拒绝静默覆盖,请刷新后重试`,
|
|
650
|
+
409,
|
|
651
|
+
'PHASE_REV_CONFLICT',
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const index = patch.index
|
|
657
|
+
if (!Number.isInteger(index)) {
|
|
658
|
+
throw phaseError(`index 必须是整数(当前合法范围 0..${titles.length - 1})`, 400, 'PHASE_BAD_INDEX')
|
|
659
|
+
}
|
|
660
|
+
if (index < 0 || index > titles.length - 1) {
|
|
661
|
+
throw phaseError(
|
|
662
|
+
`index=${index} 越界:合法范围是 0..${titles.length - 1}`
|
|
663
|
+
+ `(当前阶段清单共 ${titles.length} 个阶段:${titles.map((t, i) => `${i}=${t}`).join(';')})`,
|
|
664
|
+
400,
|
|
665
|
+
'PHASE_BAD_INDEX',
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const state = patch.state === undefined || patch.state === null ? 'done' : patch.state
|
|
670
|
+
if (!PHASE_STATES.includes(state)) {
|
|
671
|
+
throw phaseError(
|
|
672
|
+
`state=${JSON.stringify(patch.state)} 非法(合法:${PHASE_STATES.join(' | ')})`,
|
|
673
|
+
400,
|
|
674
|
+
'PHASE_BAD_STATE',
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const artifacts = normalizePhaseArtifacts(patch.artifacts)
|
|
679
|
+
let note = ''
|
|
680
|
+
if (patch.note !== undefined && patch.note !== null) {
|
|
681
|
+
if (typeof patch.note !== 'string') {
|
|
682
|
+
throw phaseError('note 必须是字符串(也可以不传)', 400, 'PHASE_BAD_NOTE')
|
|
683
|
+
}
|
|
684
|
+
note = patch.note.trim()
|
|
685
|
+
if (Array.from(note).length > PHASE_NOTE_MAX) {
|
|
686
|
+
throw phaseError(`note 最多 ${PHASE_NOTE_MAX} 字(收到 ${Array.from(note).length} 字)`, 400, 'PHASE_BAD_NOTE')
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const by = typeof patch.by === 'string' && patch.by.trim() ? patch.by.trim() : 'ui'
|
|
690
|
+
const at = Date.now()
|
|
691
|
+
|
|
692
|
+
// 落盘失败 ⇒ appendTaskMeta 抛 503(**绝不静默丢证据**)
|
|
693
|
+
appendTaskMeta(services, { kind: 'phase', taskId: id, index, state, artifacts, note, by, at })
|
|
694
|
+
|
|
695
|
+
const view = buildPhaseView(services, task)
|
|
696
|
+
return { ok: true, taskId: id, phase: view.phases[index], phases: view.phases,
|
|
697
|
+
currentIndex: view.currentIndex, totalArtifacts: view.totalArtifacts, isLongrun: view.isLongrun }
|
|
698
|
+
}
|
|
699
|
+
|
|
477
700
|
/** 变更**前**的任务快照(契约 §2.2 reopen 步骤 1)。 */
|
|
478
701
|
function buildTaskSnapshot(task) {
|
|
479
702
|
return {
|
|
@@ -907,6 +1130,620 @@ function buildExecutorStatus(ctx, services, task) {
|
|
|
907
1130
|
* @param {string} by 谁触发的(用于留痕)
|
|
908
1131
|
* @returns {Promise<{ team?: object, created?: boolean, reason?: string } | undefined>}
|
|
909
1132
|
*/
|
|
1133
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1134
|
+
// 升级接续(契约 §8.2):预检 / 迁移
|
|
1135
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1136
|
+
|
|
1137
|
+
/** 升级 09 落地的 schema 版本(0 = 升级前,2 = 本版)。 */
|
|
1138
|
+
const UPGRADE_SCHEMA_TO = 2
|
|
1139
|
+
/**
|
|
1140
|
+
* 「Boss 本人」的身份集合。
|
|
1141
|
+
* `user` 是 api.js 的 OWNER,`ui` 是页面层,`person` 是 board 里的人口径 ——
|
|
1142
|
+
* 三者在旧数据里都代表「Boss 亲手做的」,判定必须一起认,否则迁移会漏。
|
|
1143
|
+
*/
|
|
1144
|
+
const BOSS_IDS = new Set(['ui', 'user', 'person'])
|
|
1145
|
+
/** 契约 §8.2 强制的回填备注原文(一个字都不能改,验收要 grep)。 */
|
|
1146
|
+
const UPGRADE_PHASE_NOTE = '升级迁移:按总产出物回填'
|
|
1147
|
+
|
|
1148
|
+
/** 某身份是不是 Boss。 */
|
|
1149
|
+
function isBossId(id) {
|
|
1150
|
+
return typeof id === 'string' && BOSS_IDS.has(id)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* 读迁移要用到的全部原始事实(**只读**,绝不写盘)。
|
|
1155
|
+
*
|
|
1156
|
+
* ## 为什么单独抽出来
|
|
1157
|
+
* `preflight`(只读体检)与 `migrate`(真写)必须基于**同一份判定**,
|
|
1158
|
+
* 否则「预检说会补 3 条、迁移后补了 5 条」这种前后不一致会直接毁掉 Boss 的信任。
|
|
1159
|
+
*/
|
|
1160
|
+
function collectUpgradeFacts(services) {
|
|
1161
|
+
const dir = anchorStateDir(services) ?? null
|
|
1162
|
+
const facts = {
|
|
1163
|
+
stateDir: dir,
|
|
1164
|
+
boardFile: dir ? statePath(dir, 'board.jsonl') : null,
|
|
1165
|
+
metaFile: dir ? statePath(dir, TASK_META_FILE) : null,
|
|
1166
|
+
notesFile: dir ? statePath(dir, TASK_NOTES_FILE) : null,
|
|
1167
|
+
wakesFile: dir ? statePath(dir, 'wakes.jsonl') : null,
|
|
1168
|
+
boardEvents: [],
|
|
1169
|
+
boardBad: 0,
|
|
1170
|
+
metaRows: [],
|
|
1171
|
+
metaBad: 0,
|
|
1172
|
+
notes: 0,
|
|
1173
|
+
wakes: 0,
|
|
1174
|
+
tasks: [],
|
|
1175
|
+
tasksSource: 'memory',
|
|
1176
|
+
}
|
|
1177
|
+
if (!dir) return facts
|
|
1178
|
+
|
|
1179
|
+
// 事件流(真相源)。坏行**不静默**:交给 store.migrate 去拒绝不完整数据。
|
|
1180
|
+
if (facts.boardFile && existsSync(facts.boardFile)) {
|
|
1181
|
+
const parsed = readJsonl(facts.boardFile)
|
|
1182
|
+
facts.boardEvents = parsed.rows
|
|
1183
|
+
facts.boardBad = parsed.bad ?? 0
|
|
1184
|
+
}
|
|
1185
|
+
if (facts.metaFile && existsSync(facts.metaFile)) {
|
|
1186
|
+
const parsed = readJsonl(facts.metaFile)
|
|
1187
|
+
facts.metaRows = parsed.rows
|
|
1188
|
+
facts.metaBad = parsed.bad ?? 0
|
|
1189
|
+
}
|
|
1190
|
+
if (facts.notesFile && existsSync(facts.notesFile)) {
|
|
1191
|
+
facts.notes = readJsonl(facts.notesFile).rows.length
|
|
1192
|
+
}
|
|
1193
|
+
if (facts.wakesFile && existsSync(facts.wakesFile)) {
|
|
1194
|
+
facts.wakes = readJsonl(facts.wakesFile).rows.length
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// 任务态:优先用内存(已 hydrate 的最新态);事件流存在但内存为空 ⇒ 退回落盘推演。
|
|
1198
|
+
try {
|
|
1199
|
+
const live = services?.board?.list?.({}) ?? []
|
|
1200
|
+
if (Array.isArray(live) && live.length > 0) facts.tasks = live
|
|
1201
|
+
} catch { /* 落到下面的推演 */ }
|
|
1202
|
+
if (facts.tasks.length === 0 && facts.boardEvents.length > 0) {
|
|
1203
|
+
facts.tasksSource = 'jsonl'
|
|
1204
|
+
const byId = new Map()
|
|
1205
|
+
for (const ev of facts.boardEvents) {
|
|
1206
|
+
if (ev && typeof ev.taskId === 'string' && ev.after && typeof ev.after.id === 'string') {
|
|
1207
|
+
byId.set(ev.taskId, ev.after)
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
facts.tasks = [...byId.values()]
|
|
1211
|
+
}
|
|
1212
|
+
return facts
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** 某任务是否「Boss 碰过」—— 契约 §8.2:`createdBy` / `mandate.sender` / 历史 `byId`。 */
|
|
1216
|
+
function bossTouchedOf(facts, services, task) {
|
|
1217
|
+
if (isBossId(task?.createdBy)) return true
|
|
1218
|
+
if (isBossId(task?.mandate?.sender)) return true
|
|
1219
|
+
// 历史(内存里有 history 就查内存,否则查事件流)
|
|
1220
|
+
if (typeof services?.board?.history === 'function') {
|
|
1221
|
+
try {
|
|
1222
|
+
const rows = services.board.history(task.id)
|
|
1223
|
+
if (Array.isArray(rows) && rows.some((e) => isBossId(e?.byId))) return true
|
|
1224
|
+
} catch { /* 落到事件流 */ }
|
|
1225
|
+
}
|
|
1226
|
+
return facts.boardEvents.some((e) => e?.taskId === task.id && isBossId(e?.byId))
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** 任务详情里「旧数据没有」的字段(契约 §8.1 的兼容读清单)。 */
|
|
1230
|
+
const LEGACY_CANDIDATES = Object.freeze([
|
|
1231
|
+
'bossTouched', 'progress', 'labels', 'writeScopes', 'triaged', 'blockedBy', 'progressBy',
|
|
1232
|
+
])
|
|
1233
|
+
|
|
1234
|
+
/** 汇总「哪些旧字段缺、缺多少条」—— 这是 preflight 里 Boss 最需要看的那一段。 */
|
|
1235
|
+
function legacyFieldReport(tasks) {
|
|
1236
|
+
const out = []
|
|
1237
|
+
for (const field of LEGACY_CANDIDATES) {
|
|
1238
|
+
let missing = 0
|
|
1239
|
+
for (const task of tasks) if (task?.[field] === undefined) missing += 1
|
|
1240
|
+
if (missing > 0) out.push({ field, missing, total: tasks.length })
|
|
1241
|
+
}
|
|
1242
|
+
return out
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* 升级预检(契约 §8.2 `GET /upgrade/preflight`)—— **只读,一个字节都不写**。
|
|
1247
|
+
*
|
|
1248
|
+
* 回答 Boss 三个问题:现在有多少数据?升级会动什么?有什么风险?
|
|
1249
|
+
*/
|
|
1250
|
+
function upgradePreflight(services) {
|
|
1251
|
+
const facts = collectUpgradeFacts(services)
|
|
1252
|
+
const tasks = facts.tasks
|
|
1253
|
+
|
|
1254
|
+
let needBoss = 0
|
|
1255
|
+
for (const task of tasks) {
|
|
1256
|
+
if (task?.bossTouched !== true && bossTouchedOf(facts, services, task)) needBoss += 1
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// 「已完成的长任务但没有阶段行」= 契约 §8.2 第 2 条要回填的那批
|
|
1260
|
+
const longrunTasks = tasks.filter((t) => t && readLongrunMeta(services, t.id))
|
|
1261
|
+
const phaseRowsByTask = new Map()
|
|
1262
|
+
for (const row of facts.metaRows) {
|
|
1263
|
+
if (row?.kind !== 'phase' || typeof row.taskId !== 'string') continue
|
|
1264
|
+
phaseRowsByTask.set(row.taskId, (phaseRowsByTask.get(row.taskId) ?? 0) + 1)
|
|
1265
|
+
}
|
|
1266
|
+
const needPhase = longrunTasks.filter(
|
|
1267
|
+
(t) => t.status === 'completed' && (phaseRowsByTask.get(t.id) ?? 0) === 0,
|
|
1268
|
+
).length
|
|
1269
|
+
|
|
1270
|
+
const willDo = []
|
|
1271
|
+
if (needBoss > 0) willDo.push(`为 ${needBoss} 个任务补 bossTouched`)
|
|
1272
|
+
if (needPhase > 0) willDo.push(`为 ${needPhase} 个已完成长任务按总产出物回填阶段产物`)
|
|
1273
|
+
if (tasks.length > 0) willDo.push(`把 board 事件流 schema 版本记到 ${UPGRADE_SCHEMA_TO}(现有任务 rev/id/字段原样保留)`)
|
|
1274
|
+
if (willDo.length === 0) willDo.push('无需迁移:数据已是新版形态')
|
|
1275
|
+
|
|
1276
|
+
// 风险:只说真的会挡路的那几条,不凑数
|
|
1277
|
+
const risks = []
|
|
1278
|
+
if (facts.boardBad > 0) {
|
|
1279
|
+
risks.push(`board.jsonl 有 ${facts.boardBad} 行无法解析(撕裂写/损坏)——迁移会拒绝在不完整数据上动手,请先修文件`)
|
|
1280
|
+
}
|
|
1281
|
+
if (facts.metaBad > 0) {
|
|
1282
|
+
risks.push(`task-meta.jsonl 有 ${facts.metaBad} 行无法解析——阶段产物回填可能漏读,建议先备份核对`)
|
|
1283
|
+
}
|
|
1284
|
+
if (!facts.stateDir) risks.push('状态目录不可用:既不能预检也不能迁移')
|
|
1285
|
+
if (facts.tasksSource === 'jsonl') risks.push('内存里没有任务(板尚未 hydrate)——本报告由事件流推演,迁移前建议先重启一次让板加载完整')
|
|
1286
|
+
if (risks.length === 0) risks.push('无阻塞风险(迁移前仍会自动整目录备份)')
|
|
1287
|
+
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true,
|
|
1290
|
+
stateDir: facts.stateDir,
|
|
1291
|
+
schemaVersion: facts.stateDir ? readSchemaVersion(facts.stateDir, 'board') : 0,
|
|
1292
|
+
targetVersion: UPGRADE_SCHEMA_TO,
|
|
1293
|
+
counts: {
|
|
1294
|
+
tasks: tasks.length,
|
|
1295
|
+
longrun: longrunTasks.length,
|
|
1296
|
+
phaseRows: facts.metaRows.filter((r) => r?.kind === 'phase').length,
|
|
1297
|
+
notes: facts.notes,
|
|
1298
|
+
wakes: facts.wakes,
|
|
1299
|
+
boardEvents: facts.boardEvents.length,
|
|
1300
|
+
bossTouched: tasks.filter((t) => t?.bossTouched === true).length,
|
|
1301
|
+
},
|
|
1302
|
+
legacy: legacyFieldReport(tasks),
|
|
1303
|
+
willDo,
|
|
1304
|
+
risks,
|
|
1305
|
+
backupHint: facts.stateDir
|
|
1306
|
+
? `迁移前会自动整目录备份到 ${facts.stateDir}.bak-<时间>-upgrade09;要手动回滚就 restoreState(stateDir, 备份路径, { confirm: true })`
|
|
1307
|
+
: '状态目录不可用,无法备份',
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* 升级迁移(契约 §8.2 `POST /upgrade/migrate`)—— 幂等、先备份、**保留 rev/id/字段**。
|
|
1313
|
+
*
|
|
1314
|
+
* ## 为什么不能「重写整个 board 文件」
|
|
1315
|
+
* `board.jsonl` 是 CAS 幂等键与 rev 语义的真相源。若整体覆盖重写,
|
|
1316
|
+
* 稍有字段遗漏就会让任务的 `rev` 与前端拿到的对不上 —— Boss 需求 9
|
|
1317
|
+
* 「任务原样存活」当场破功。所以这里**逐条合并**:只往事件里补缺失字段,
|
|
1318
|
+
* `rev` / `id` / 其余字段**一个字不动**,然后用 `store.migrate` 原子写回。
|
|
1319
|
+
*
|
|
1320
|
+
* ## 幂等
|
|
1321
|
+
* schema 版本已是目标值 ⇒ `store.migrate` 返回 `migrated:false`,不重写文件;
|
|
1322
|
+
* 阶段回填按「该任务是否已有 phase 行」判定,第二次跑必然是 0 条。
|
|
1323
|
+
*
|
|
1324
|
+
* ## 失败
|
|
1325
|
+
* 先 `backupState()`。任何一步失败都返回明确理由 + 备份路径,
|
|
1326
|
+
* 让 Boss 能一键回滚,而不是卡在「写了一半」的状态。
|
|
1327
|
+
*
|
|
1328
|
+
* @returns {Promise<object>}
|
|
1329
|
+
*/
|
|
1330
|
+
async function upgradeMigrate(services, by) {
|
|
1331
|
+
const facts = collectUpgradeFacts(services)
|
|
1332
|
+
if (!facts.stateDir) {
|
|
1333
|
+
const error = new Error('状态目录不可用:无法备份,拒绝迁移')
|
|
1334
|
+
error.status = 500
|
|
1335
|
+
error.code = 'UPGRADE_NO_STATE_DIR'
|
|
1336
|
+
throw error
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ① 先备份(契约硬要求)。备份失败 ⇒ 一个字节都不动。
|
|
1340
|
+
//
|
|
1341
|
+
// ⚠️ label 带上次幂等迁移的计数:`store.backupState` 的目标路径只精确到**秒**
|
|
1342
|
+
//(`<dir>.bak-YYYYMMDD-HHmmss-<label>`),同一秒内跑两次迁移会**覆盖同一份备份** ——
|
|
1343
|
+
// 第二次就把「升级前的原始状态」盖成「升级后的状态」,回滚点当场消失。
|
|
1344
|
+
// 所以这里按「本次真正要动的任务数 + 当前毫秒」做标签,保证两次备份路径必不相同。
|
|
1345
|
+
const backupTag = `upgrade09-${facts.tasks.length}-${Date.now()}`
|
|
1346
|
+
const backup = backupState(facts.stateDir, backupTag)
|
|
1347
|
+
if (!backup?.ok) {
|
|
1348
|
+
const error = new Error(`迁移中止:备份失败(${backup?.reason ?? '未知原因'})——未写入任何改动`)
|
|
1349
|
+
error.status = 500
|
|
1350
|
+
error.code = 'UPGRADE_BACKUP_FAILED'
|
|
1351
|
+
throw error
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const report = {
|
|
1355
|
+
ok: true,
|
|
1356
|
+
backupPath: backup.path,
|
|
1357
|
+
bossTouched: { updated: 0, skipped: 0 },
|
|
1358
|
+
phases: { backfilled: 0, tasks: [] },
|
|
1359
|
+
schema: null,
|
|
1360
|
+
replayed: null,
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ② bossTouched 回填:逐条合并进**每个任务的最后一条事件**的 `after`,
|
|
1364
|
+
// rev/id/其它字段原样保留(这是「不破坏 rev 语义」的关键)。
|
|
1365
|
+
const needBoss = new Map()
|
|
1366
|
+
for (const task of facts.tasks) {
|
|
1367
|
+
if (!task || typeof task.id !== 'string') continue
|
|
1368
|
+
if (task.bossTouched === true) { report.bossTouched.skipped += 1; continue }
|
|
1369
|
+
const touched = bossTouchedOf(facts, services, task)
|
|
1370
|
+
if (touched) needBoss.set(task.id, true)
|
|
1371
|
+
else needBoss.set(task.id, false)
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// 只对「确实要补 true」的任务动手;判 false 的不写(少改动 = 少风险)
|
|
1375
|
+
const targets = new Set([...needBoss.entries()].filter(([, v]) => v === true).map(([k]) => k))
|
|
1376
|
+
|
|
1377
|
+
if (facts.boardEvents.length === 0) {
|
|
1378
|
+
// 快照-only 的状态目录(没有 board.jsonl):**不建空 jsonl**。
|
|
1379
|
+
// `store.migrate` 会写出一个空的真相源文件,虽然 hydrate 仍会回落到快照,
|
|
1380
|
+
// 但「空 jsonl + 有快照」是后续所有操作的雷区(任何一次 jsonl 写入都会以
|
|
1381
|
+
// 「空板」为基线)。这里直接记 schema 版本,并把这件事报给 Boss。
|
|
1382
|
+
const ver = facts.stateDir ? writeSchemaVersion(facts.stateDir, 'board', UPGRADE_SCHEMA_TO) : false
|
|
1383
|
+
report.schema = { ok: true, migrated: false, version: ver ? UPGRADE_SCHEMA_TO : readSchemaVersion(facts.stateDir, 'board'), skipped: '无 board.jsonl(快照-only),仅记录 schema 版本' }
|
|
1384
|
+
if (targets.size > 0) report.bossTouched.pending = targets.size
|
|
1385
|
+
} else {
|
|
1386
|
+
// 把「每个任务的最后一条事件」的行号先算出来(只认字段齐全的事件)
|
|
1387
|
+
const lastIndex = new Map()
|
|
1388
|
+
facts.boardEvents.forEach((ev, index) => {
|
|
1389
|
+
if (ev && typeof ev.taskId === 'string' && ev.after && typeof ev.after.id === 'string') {
|
|
1390
|
+
lastIndex.set(ev.taskId, index)
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1393
|
+
const currentVersion = readSchemaVersion(facts.stateDir, 'board')
|
|
1394
|
+
|
|
1395
|
+
if (targets.size > 0) {
|
|
1396
|
+
// ⚠️ 为什么这里可能**不能**直接走 store.migrate:它是版本门控的 ——
|
|
1397
|
+
// 当前版本已等于 to 时它直接 `migrated:false` 且**完全不调 migrateFn**。
|
|
1398
|
+
// 首次迁移跑完后 schema 已是 v2,此时若**又新增**了 Boss 建的任务
|
|
1399
|
+
// (新任务没有 bossTouched),再跑迁移就会被版本门挡掉 ⇒ 永远补不上。
|
|
1400
|
+
// 所以:版本还没到 v2 时走 store.migrate(复用它的原子写 + 坏行保护);
|
|
1401
|
+
// 版本已到 v2 但仍有要补的任务 ⇒ 必须绕过门控自己写,否则静默漏补。
|
|
1402
|
+
const merge = (rows) => {
|
|
1403
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1404
|
+
const ev = rows[i]
|
|
1405
|
+
if (!ev || !targets.has(ev.taskId)) continue
|
|
1406
|
+
if (lastIndex.get(ev.taskId) !== i) continue
|
|
1407
|
+
if (!ev.after || typeof ev.after !== 'object') continue
|
|
1408
|
+
if (ev.after.bossTouched === true) continue
|
|
1409
|
+
// ⚠️ 只加一个字段;rev / id / 其余字段**一律不动**
|
|
1410
|
+
ev.after.bossTouched = true
|
|
1411
|
+
report.bossTouched.updated += 1
|
|
1412
|
+
}
|
|
1413
|
+
return rows
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (currentVersion === UPGRADE_SCHEMA_TO) {
|
|
1417
|
+
// ⚠️ 绕门控前必须自查坏行(等价于 store.migrate 内部那道抛错保护)。
|
|
1418
|
+
// 原因:`facts.boardEvents` 来自 `readJsonl()`,它**会跳过解析不了的坏行**
|
|
1419
|
+
// 只把条数记进 `facts.boardBad`。下面是把整份事件流在内存里改完再整体重写,
|
|
1420
|
+
// 一旦文件里真有坏行,坏行会被**永久抹掉** —— 而这条分支恰恰是最常走的
|
|
1421
|
+
// (schema 已 v2 之后每次迁移都走它)。宁可拒绝,也不静默丢证据。
|
|
1422
|
+
if (facts.boardBad > 0) {
|
|
1423
|
+
const error = new Error(
|
|
1424
|
+
`迁移中止:board.jsonl 有 ${facts.boardBad} 行无法解析——`
|
|
1425
|
+
+ '拒绝在不完整数据上重写(否则坏行会被永久抹掉)。'
|
|
1426
|
+
+ `请先修文件,或从备份 ${backup.path} 恢复。未写入任何改动。`,
|
|
1427
|
+
)
|
|
1428
|
+
error.status = 500
|
|
1429
|
+
error.code = 'UPGRADE_BAD_ROWS'
|
|
1430
|
+
throw error
|
|
1431
|
+
}
|
|
1432
|
+
// 绕门控:按 store.migrate 的同一套原子写形态(tmp → rename)改回去
|
|
1433
|
+
const jsonlPath = statePath(facts.stateDir, 'board.jsonl')
|
|
1434
|
+
const nextRows = merge(facts.boardEvents.slice())
|
|
1435
|
+
const tmp = `${jsonlPath}.tmp-upgrade09`
|
|
1436
|
+
writeFileSync(tmp, nextRows.map((r) => JSON.stringify(r)).join('\n') + (nextRows.length ? '\n' : ''), 'utf8')
|
|
1437
|
+
renameSync(tmp, jsonlPath)
|
|
1438
|
+
report.schema = { ok: true, migrated: true, version: UPGRADE_SCHEMA_TO, bypassedVersionGate: true }
|
|
1439
|
+
report.bossTouched.bypassedGate = true
|
|
1440
|
+
} else {
|
|
1441
|
+
report.schema = migrate(facts.stateDir, {
|
|
1442
|
+
name: 'board',
|
|
1443
|
+
to: UPGRADE_SCHEMA_TO,
|
|
1444
|
+
migrateFn: merge,
|
|
1445
|
+
})
|
|
1446
|
+
}
|
|
1447
|
+
} else {
|
|
1448
|
+
// 没有要补的任务,但仍要把 schema 版本记上(幂等:已是最新则不改文件)
|
|
1449
|
+
report.schema = migrate(facts.stateDir, {
|
|
1450
|
+
name: 'board', to: UPGRADE_SCHEMA_TO, migrateFn: (rows) => rows,
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// ③ 已完成长任务 + 没有任何 phase 行 ⇒ 按 task.artifacts 回填一条末阶段产物
|
|
1456
|
+
const phaseRowsByTask = new Map()
|
|
1457
|
+
for (const row of facts.metaRows) {
|
|
1458
|
+
if (row?.kind !== 'phase' || typeof row.taskId !== 'string') continue
|
|
1459
|
+
phaseRowsByTask.set(row.taskId, (phaseRowsByTask.get(row.taskId) ?? 0) + 1)
|
|
1460
|
+
}
|
|
1461
|
+
for (const task of facts.tasks) {
|
|
1462
|
+
if (!task || task.status !== 'completed') continue
|
|
1463
|
+
if ((phaseRowsByTask.get(task.id) ?? 0) > 0) continue // 契约:已有 phase 行不动
|
|
1464
|
+
const longrun = readLongrunMeta(services, task.id)
|
|
1465
|
+
const titles = longrunPhaseList(longrun)
|
|
1466
|
+
if (titles.length === 0) continue
|
|
1467
|
+
const artifacts = Array.isArray(task.artifacts) ? task.artifacts.filter(
|
|
1468
|
+
(a) => typeof a === 'string' && a.trim() !== '',
|
|
1469
|
+
) : []
|
|
1470
|
+
const record = {
|
|
1471
|
+
kind: 'phase',
|
|
1472
|
+
taskId: task.id,
|
|
1473
|
+
index: titles.length - 1,
|
|
1474
|
+
state: 'done',
|
|
1475
|
+
artifacts,
|
|
1476
|
+
note: UPGRADE_PHASE_NOTE,
|
|
1477
|
+
by: by,
|
|
1478
|
+
at: Date.now(),
|
|
1479
|
+
}
|
|
1480
|
+
if (!appendJsonl(facts.metaFile, record)) {
|
|
1481
|
+
const error = new Error(
|
|
1482
|
+
`迁移中止:阶段产物回填落盘失败(${facts.metaFile})——`
|
|
1483
|
+
+ `已备份在 ${backup.path},可用 restoreState 回滚`,
|
|
1484
|
+
)
|
|
1485
|
+
error.status = 500
|
|
1486
|
+
error.code = 'UPGRADE_PHASE_APPEND_FAILED'
|
|
1487
|
+
throw error
|
|
1488
|
+
}
|
|
1489
|
+
report.phases.backfilled += 1
|
|
1490
|
+
report.phases.tasks.push(task.id)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ④ 内存态跟磁盘对齐:迁移改了事件流,板必须重放,否则后续快照会用旧内存覆盖认知
|
|
1494
|
+
try {
|
|
1495
|
+
if (typeof services?.board?.replay === 'function') report.replayed = services.board.replay()
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
report.replayError = `迁移已落盘,但内存重放失败:${error?.message ?? error}——建议重启以加载完整状态`
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
return report
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* 给团队自动配一个「议事厅」(契约 §3)。
|
|
1505
|
+
*
|
|
1506
|
+
* ## 为什么
|
|
1507
|
+
* 建了团队却没人知道去哪儿说话 —— 团队页只是名单,真正的协作发生在房间。
|
|
1508
|
+
* 所以建团队时顺手把房间建好、把人拉全、把关联记到团队上。
|
|
1509
|
+
*
|
|
1510
|
+
* ## 失败不阻断
|
|
1511
|
+
* 房间服务未装配、房间重名、房间目录不可写……都只回 `roomError` 一句人话。
|
|
1512
|
+
* **团队已经建成了**,因为「房间没配上」就回滚团队,等于用次要功能否掉主要功能。
|
|
1513
|
+
*
|
|
1514
|
+
* ## mode 必须是 admin
|
|
1515
|
+
* `room.js` 只在 `mode:'admin'` 下接受 `adminId`(`everyone` 里传会直接
|
|
1516
|
+
* `ROOM_ADMIN_ID_NOT_APPLICABLE`)。契约要求 `adminId = leadId`,
|
|
1517
|
+
* 所以这里固定用 admin 模式 —— 议事厅由负责人主持,语义也对。
|
|
1518
|
+
*
|
|
1519
|
+
* @param {object} services
|
|
1520
|
+
* @param {object} team teams.create 的返回值(含 id / name / leadId / members)
|
|
1521
|
+
* @param {string} by
|
|
1522
|
+
* @returns {{ room?: object, roomError?: string, attached?: boolean }}
|
|
1523
|
+
*/
|
|
1524
|
+
async function ensureTeamRoom(services, team, by) {
|
|
1525
|
+
if (!team || typeof team.id !== 'string') return {}
|
|
1526
|
+
const roomSvc = services?.room
|
|
1527
|
+
if (!roomSvc || typeof roomSvc.create !== 'function') {
|
|
1528
|
+
return { roomError: '房间服务未装配(services.room 缺失)——团队已建好,可稍后在房间页手动建' }
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// 幂等:团队已有关联房间 ⇒ 不重复建(重复调 /teams 保存不该刷出一串议事厅)
|
|
1532
|
+
if (typeof team.roomId === 'string' && team.roomId) {
|
|
1533
|
+
try {
|
|
1534
|
+
const existing = await roomSvc.get(team.roomId)
|
|
1535
|
+
if (existing) return { room: { id: existing.id, name: existing.name }, attached: false }
|
|
1536
|
+
} catch { /* 房间没了就往下重建 */ }
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const lead = team.leadId
|
|
1540
|
+
const roomName = `${team.name} · 议事厅`
|
|
1541
|
+
// 负责人必须在成员里(admin 模式下 he 是唯一能发言的人;不在成员表里就等于没人能说话)
|
|
1542
|
+
const members = [...new Set([...(lead ? [lead] : []), ...(Array.isArray(team.members) ? team.members : [])])]
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
const room = await roomSvc.create({
|
|
1546
|
+
name: roomName,
|
|
1547
|
+
members,
|
|
1548
|
+
mode: 'admin',
|
|
1549
|
+
...(lead ? { adminId: lead } : {}),
|
|
1550
|
+
purpose: `团队「${team.name}」的自动议事厅:负责人主持,成员在此对齐`,
|
|
1551
|
+
byId: by,
|
|
1552
|
+
})
|
|
1553
|
+
// 关联落库(失败只告警:房间已经能用,关联丢了顶多下次再建一间)
|
|
1554
|
+
let attachError
|
|
1555
|
+
if (services.teams && typeof services.teams.attachRoom === 'function') {
|
|
1556
|
+
try {
|
|
1557
|
+
await services.teams.attachRoom({ id: team.id, roomId: room.id, by })
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
attachError = `议事厅已建好(${room.id})但未能记到团队上:${error?.message ?? error}`
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return {
|
|
1563
|
+
room: { id: room.id, name: room.name },
|
|
1564
|
+
attached: !attachError,
|
|
1565
|
+
...(attachError ? { roomError: attachError } : {}),
|
|
1566
|
+
}
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return {
|
|
1569
|
+
roomError: `自动建议事厅失败:${error?.message ?? error}`
|
|
1570
|
+
+ '——团队已建好,去「房间」页手动建一个并在团队页关联即可',
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* 发消息/唤醒时的「自动激活」(契约 §7.2)。
|
|
1577
|
+
*
|
|
1578
|
+
* ## 现场
|
|
1579
|
+
* Boss 给一个停在 pending 的任务发补充说明,说明存下来了、也唤醒了执行者,
|
|
1580
|
+
* 但任务状态还是 pending —— 因为**没人接活**,执行者收到消息也没活可干;
|
|
1581
|
+
* 已经 in_progress 的任务则因为租约到期被系统收回,Boss 一发消息就发现
|
|
1582
|
+
* 「怎么又变回待领了」。两件事的共同点是:**人发了消息,机器没有跟着动**。
|
|
1583
|
+
*
|
|
1584
|
+
* ## 做法
|
|
1585
|
+
* - `in_progress` + 有负责人 ⇒ 自动续租(心跳)。失败**不回滚**刚存的补充说明
|
|
1586
|
+
* (说明是人的话,不能因为续租失败就丢掉),只把原因回给页面。
|
|
1587
|
+
* - `pending` + 有负责人 ⇒ 自动接活(claim),成功后由调用方继续唤醒。
|
|
1588
|
+
* - `blocked` ⇒ **不自动解除**(解除阻塞是人的判断,不是机器的),
|
|
1589
|
+
* 只回一句「需要先解除阻塞」。
|
|
1590
|
+
*
|
|
1591
|
+
* 租约本身保留(不引入新状态),只是页面不再把它当回事。
|
|
1592
|
+
*
|
|
1593
|
+
* @param {object} services
|
|
1594
|
+
* @param {object} task board 任务快照
|
|
1595
|
+
* @param {string} by 操作者
|
|
1596
|
+
* @returns {Promise<{ claimed?: object, heartbeat?: object, reason?: string }>}
|
|
1597
|
+
*/
|
|
1598
|
+
async function autoActivate(services, task, by) {
|
|
1599
|
+
const out = {}
|
|
1600
|
+
if (!task || typeof task.id !== 'string') return out
|
|
1601
|
+
const board = services?.board
|
|
1602
|
+
if (!board || typeof board.update !== 'function') {
|
|
1603
|
+
return { reason: 'board 服务未装配,无法自动激活' }
|
|
1604
|
+
}
|
|
1605
|
+
const ownerId = task.ownerId ?? task.assignee ?? null
|
|
1606
|
+
if (!ownerId) {
|
|
1607
|
+
return { reason: '任务还没有负责人,先指派给一个 Bot 才能自动激活' }
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// blocked:**绝不自动解除** —— 解除阻塞是人的判断
|
|
1611
|
+
if (task.status === 'blocked') {
|
|
1612
|
+
return { reason: '任务处于阻塞状态,需要先解除阻塞:点「解除阻塞」,再发消息' }
|
|
1613
|
+
}
|
|
1614
|
+
if (task.status === 'completed' || task.status === 'deleted') {
|
|
1615
|
+
return { reason: `任务已${task.status === 'completed' ? '完成' : '删除'},无需激活` }
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// ① 已在进行中:续租(心跳)。失败不改变任何已落盘的事实。
|
|
1619
|
+
if (task.status === 'in_progress' && task.lease) {
|
|
1620
|
+
try {
|
|
1621
|
+
await board.update({ id: task.id, expectedRev: task.rev, action: 'heartbeat', byId: ownerId })
|
|
1622
|
+
return { heartbeat: { ownerId, ok: true } }
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
return {
|
|
1625
|
+
heartbeat: { ownerId, ok: false },
|
|
1626
|
+
reason: `续租失败:${error?.message ?? error}`
|
|
1627
|
+
+ '(补充说明已保存,任务状态未改)',
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ② 待领 / 在进行中但租约已丢:替他接活
|
|
1633
|
+
if (task.status === 'pending' || task.status === 'in_progress') {
|
|
1634
|
+
try {
|
|
1635
|
+
await board.update({ id: task.id, expectedRev: task.rev, action: 'claim', byId: ownerId })
|
|
1636
|
+
return { claimed: { ownerId, ok: true } }
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
return { reason: `自动接活失败:${error?.message ?? error}` }
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
return out
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* 任务诊断(契约 §5):把「为什么这个任务没产出」翻译成一句人话 + 一个下一步按钮。
|
|
1647
|
+
*
|
|
1648
|
+
* ## 现场
|
|
1649
|
+
* Boss 报:「调研专家《调研专家测试001》已多次指派,subagent 持续失败,无产出。」
|
|
1650
|
+
* 查 `wakes.jsonl` 全是 `result:'failed' / detail:'agent-not-live'` ——
|
|
1651
|
+
* 也就是**执行者会话根本不在**,于是唤醒全部失败、任务永远停在 pending、
|
|
1652
|
+
* 页面上什么都看不见(静默失败)。
|
|
1653
|
+
*
|
|
1654
|
+
* ## 做法
|
|
1655
|
+
* 不猜。逐项读真实服务:roster(Bot 是谁)、getAgent(会话在不在)、
|
|
1656
|
+
* `agent.model.current()`(有没有模型路由)、dispatcher.list(唤醒有没有失败)。
|
|
1657
|
+
* 每一项的失败都映射成中文理由 + 下一步。输出 `verdict`:
|
|
1658
|
+
* `ok` = 可以干活;`blocked` = 有明确的卡点(点建议里的按钮就能解)。
|
|
1659
|
+
*
|
|
1660
|
+
* @param {object} ctx @param {object} services @param {object} task
|
|
1661
|
+
*/
|
|
1662
|
+
async function diagnoseTask(ctx, services, task) {
|
|
1663
|
+
const taskId = task?.id ?? task?.taskId ?? '(未知)'
|
|
1664
|
+
const ownerId = task?.ownerId ?? task?.assignee ?? null
|
|
1665
|
+
|
|
1666
|
+
// ── 1) 执行者是谁(roster)──────────────────────────────────────────
|
|
1667
|
+
let botName = ownerId ? String(ownerId).slice(-8) : ''
|
|
1668
|
+
let rosterPreset
|
|
1669
|
+
try {
|
|
1670
|
+
const bots = services?.roster?.list?.() ?? []
|
|
1671
|
+
const hit = bots.find((b) => b?.sessionId === ownerId)
|
|
1672
|
+
if (hit?.name) botName = hit.name
|
|
1673
|
+
if (hit?.preset) rosterPreset = String(hit.preset)
|
|
1674
|
+
} catch { /* roster 不在就继续,名字退化成 id 缩写 */ }
|
|
1675
|
+
|
|
1676
|
+
// ── 2) 会话在不在(getAgent 是真查,不判 roster.state 这种缓存)──────
|
|
1677
|
+
let live = false
|
|
1678
|
+
try { live = Boolean(getAgent(ctx, ownerId)) } catch { live = false }
|
|
1679
|
+
|
|
1680
|
+
// ── 3) 模型路由(assertDeliverable 同一套判定 —— 不写两遍)─────────
|
|
1681
|
+
const deliverable = live ? assertDeliverable(ctx, ownerId) : { ok: true, reason: '(会话不在线,先恢复会话再判定模型路由)' }
|
|
1682
|
+
|
|
1683
|
+
// ── 4) preset 是否真的能解析(老数据可能存了个已删的 preset id)─────
|
|
1684
|
+
let presetResolved = true
|
|
1685
|
+
try {
|
|
1686
|
+
const session = sessionMeta(ctx, ownerId)
|
|
1687
|
+
const wanted = rosterPreset ?? (typeof session?.agentPreset === 'string' ? session.agentPreset : undefined)
|
|
1688
|
+
if (wanted) {
|
|
1689
|
+
const known = await listPresets(ctx)
|
|
1690
|
+
presetResolved = Array.isArray(known) && known.some((p) => p && (p.id === wanted || p.name === wanted))
|
|
1691
|
+
}
|
|
1692
|
+
} catch { presetResolved = rosterPreset !== undefined }
|
|
1693
|
+
const hasModelRoute = Boolean(deliverable?.ok)
|
|
1694
|
+
|
|
1695
|
+
// ── 5) 唤醒记录(真实落盘的 wakes.jsonl)───────────────────────────
|
|
1696
|
+
const wakes = readTaskWakes(services, taskId)
|
|
1697
|
+
.slice(-10)
|
|
1698
|
+
.map((row) => {
|
|
1699
|
+
const at = row?.updatedAt ?? row?.sentAt ?? row?.scheduledAt ?? null
|
|
1700
|
+
return {
|
|
1701
|
+
at: Number.isFinite(Number(at)) ? Number(at) : null,
|
|
1702
|
+
result: row?.result ?? 'unknown',
|
|
1703
|
+
reason: row?.detail ?? row?.reason ?? null,
|
|
1704
|
+
}
|
|
1705
|
+
})
|
|
1706
|
+
|
|
1707
|
+
// ── 6) verdict + advice(两句:一句说清,一句说下一步点哪)─────────
|
|
1708
|
+
const blockers = []
|
|
1709
|
+
if (!live) blockers.push('agent-not-live')
|
|
1710
|
+
if (deliverable && deliverable.ok === false) blockers.push('undeliverable')
|
|
1711
|
+
if (!presetResolved) blockers.push('preset-unresolved')
|
|
1712
|
+
if (wakes.some((w) => w.result === 'failed')) blockers.push('wake-failed')
|
|
1713
|
+
|
|
1714
|
+
const verdict = blockers.length ? 'blocked' : 'ok'
|
|
1715
|
+
let advice
|
|
1716
|
+
if (!live) {
|
|
1717
|
+
advice = `执行者「${botName}」的会话当前不在线,唤醒消息投不出去 —— 点「重新派活」重新拉起会话;若连续失败,去「Bot 注册表」看它是否已退役或被回收。`
|
|
1718
|
+
} else if (deliverable && deliverable.ok === false) {
|
|
1719
|
+
advice = `「${botName}」没有模型路由,唤醒后第一轮必败 —— 去「Bot 档案」给它选一个模型,保存后再点「重新派活」。`
|
|
1720
|
+
} else if (!presetResolved) {
|
|
1721
|
+
advice = `「${botName}」用的预制体(preset)已不存在 —— 去「Bot 档案」换一个预制体,否则模型不会加载。`
|
|
1722
|
+
} else if (wakes.some((w) => w.result === 'failed')) {
|
|
1723
|
+
advice = `最近有唤醒失败记录 —— 先点「重新派活」重发,再点「刷新」确认状态;若仍然失败,看下面「唤醒记录」里的 reason 逐条排查。`
|
|
1724
|
+
} else {
|
|
1725
|
+
advice = `执行者「${botName}」已在线且模型路由正常 —— 直接点「唤醒」重发任务即可。`
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
return {
|
|
1729
|
+
ok: true,
|
|
1730
|
+
taskId,
|
|
1731
|
+
ownerId,
|
|
1732
|
+
bot: {
|
|
1733
|
+
name: botName,
|
|
1734
|
+
hasSession: live,
|
|
1735
|
+
preset: rosterPreset ?? null,
|
|
1736
|
+
presetResolved,
|
|
1737
|
+
hasModelRoute,
|
|
1738
|
+
},
|
|
1739
|
+
live: { ok: live, reason: live ? '会话在线' : '会话不在线(agent-not-live)' },
|
|
1740
|
+
deliverable,
|
|
1741
|
+
wakes,
|
|
1742
|
+
verdict,
|
|
1743
|
+
advice,
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
910
1747
|
async function ensureTeamForBot(services, botSessionId, by) {
|
|
911
1748
|
const teams = services.teams
|
|
912
1749
|
if (!teams || typeof teams.create !== 'function' || typeof botSessionId !== 'string' || !botSessionId) {
|
|
@@ -1349,8 +2186,22 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
1349
2186
|
// 现在所有请求先过 fence.judgeHttp:宿主不认证就一律 403。
|
|
1350
2187
|
const fence = createFence({ ctx, warn })
|
|
1351
2188
|
|
|
2189
|
+
// ── 升级09 §2.3:把「写阶段产出物」挂到 services 上 ─────────────────────
|
|
2190
|
+
// `src/tools.js` 的 `bot_task_phase`(C 交付)读的是 `services.phaseWrite`;
|
|
2191
|
+
// HTTP `POST /tasks/phase` 与它调用**同一份** writePhaseRow,
|
|
2192
|
+
// 不写两套校验 —— 否则「页面能写、Bot 写不进」这种漂移必现。
|
|
2193
|
+
// 注入放在这里(而不是 index.js)是因为写入逻辑属于 API 层,
|
|
2194
|
+
// 且必须复用 api.js 内那份 appendTaskMeta 实现。
|
|
2195
|
+
const mountPhaseWrite = (services) => {
|
|
2196
|
+
if (!services || typeof services !== 'object') return
|
|
2197
|
+
services.phaseWrite = (taskId, patch) => writePhaseRow(services, taskId, patch)
|
|
2198
|
+
}
|
|
2199
|
+
mountPhaseWrite(getServices(ctx))
|
|
2200
|
+
|
|
1352
2201
|
const handler = async (request, response) => {
|
|
1353
2202
|
const services = getServices(ctx)
|
|
2203
|
+
// 幂等重挂:services 若在 installApi 之后才注册,这里补上(写同一个 key)
|
|
2204
|
+
mountPhaseWrite(services)
|
|
1354
2205
|
// 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
|
|
1355
2206
|
const verdict = fence.judgeHttp(request)
|
|
1356
2207
|
if (!verdict.ok) {
|
|
@@ -1369,6 +2220,18 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
1369
2220
|
|
|
1370
2221
|
try {
|
|
1371
2222
|
// ── 读:状态总览 ──────────────────────────────────────────────────────
|
|
2223
|
+
// ── 升级接续(契约 §8.2):先看后动,动前先备份 ──────────────────────
|
|
2224
|
+
if (request.method === 'GET' && path === '/upgrade/preflight') {
|
|
2225
|
+
sendJson(response, 200, upgradePreflight(services))
|
|
2226
|
+
return
|
|
2227
|
+
}
|
|
2228
|
+
if (request.method === 'POST' && path === '/upgrade/migrate') {
|
|
2229
|
+
const body = (await readBody(request)) ?? {}
|
|
2230
|
+
const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : OWNER
|
|
2231
|
+
sendJson(response, 200, await upgradeMigrate(services, by))
|
|
2232
|
+
return
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1372
2235
|
if (request.method === 'GET' && path === '/status') {
|
|
1373
2236
|
const out = { ok: true, services: {}, compat: services.compat ?? null }
|
|
1374
2237
|
// 状态面要覆盖**全部**域服务:新增模块若不上报,页面「系统体检」就会
|
|
@@ -1812,7 +2675,30 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
1812
2675
|
}
|
|
1813
2676
|
}
|
|
1814
2677
|
|
|
1815
|
-
|
|
2678
|
+
// ── t-39 第2批 C:发言同步给 DSH 原生 team 的 Lead(Boss 体验 item 5)──
|
|
2679
|
+
//
|
|
2680
|
+
// Boss 在 cs-bots 议事厅说话时,原生 team 的 Lead 感知不到 —— 两套团队模型
|
|
2681
|
+
// 是分离的。这一步把消息也投递给绑定的原生 Lead(root session 路径,合法)。
|
|
2682
|
+
//
|
|
2683
|
+
// 诚实原则:同步失败**不报** ok:false(房间消息本身成功了),而是
|
|
2684
|
+
// `leadSync.synced:false` + `reason`,页面据此显示「同步失败:<原因>」。
|
|
2685
|
+
// 绝不装成功 —— 那比不显示更糟。
|
|
2686
|
+
let leadSync
|
|
2687
|
+
try {
|
|
2688
|
+
leadSync = await withService(services, 'teams', (t) =>
|
|
2689
|
+
t.syncToNativeLead({
|
|
2690
|
+
roomId: message.roomId,
|
|
2691
|
+
messageId: message.id,
|
|
2692
|
+
text: message.text,
|
|
2693
|
+
by: body.from ?? 'ui',
|
|
2694
|
+
dispatcher: services.dispatcher,
|
|
2695
|
+
}))
|
|
2696
|
+
} catch (error) {
|
|
2697
|
+
// 同步是附加动作:异常也必须变成可见的 synced:false,而不是 500
|
|
2698
|
+
leadSync = { ok: true, synced: false, reason: 'SYNC_ERROR', detail: String(error?.message ?? error) }
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
sendJson(response, 200, { ok: true, message, wakes: toWake.length, revives, fanout, leadSync })
|
|
1816
2702
|
return
|
|
1817
2703
|
}
|
|
1818
2704
|
|
|
@@ -2197,6 +3083,95 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2197
3083
|
return
|
|
2198
3084
|
}
|
|
2199
3085
|
|
|
3086
|
+
// ── 阶段产出物(契约 §2.2 / §2.3 / §4.2)────────────────────────────
|
|
3087
|
+
if (request.method === 'GET' && path === '/tasks/phases') {
|
|
3088
|
+
const id = qp(url, 'id')
|
|
3089
|
+
if (!id) {
|
|
3090
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
3091
|
+
return
|
|
3092
|
+
}
|
|
3093
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
3094
|
+
if (!task) {
|
|
3095
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
3096
|
+
return
|
|
3097
|
+
}
|
|
3098
|
+
sendJson(response, 200, { ok: true, ...buildPhaseView(services, task) })
|
|
3099
|
+
return
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
if (request.method === 'POST' && path === '/tasks/phase') {
|
|
3103
|
+
const body = (await readBody(request)) ?? {}
|
|
3104
|
+
const out = writePhaseRow(services, body.id, {
|
|
3105
|
+
index: Number.isInteger(body.index) ? body.index : Number(body.index),
|
|
3106
|
+
state: body.state,
|
|
3107
|
+
artifacts: body.artifacts,
|
|
3108
|
+
note: body.note,
|
|
3109
|
+
by: body.by ?? OWNER,
|
|
3110
|
+
...(body.expectedRev === undefined ? {} : { expectedRev: body.expectedRev }),
|
|
3111
|
+
})
|
|
3112
|
+
sendJson(response, 200, out)
|
|
3113
|
+
return
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
if (request.method === 'POST' && path === '/tasks/phases') {
|
|
3117
|
+
const body = (await readBody(request)) ?? {}
|
|
3118
|
+
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
3119
|
+
if (!id) {
|
|
3120
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
3121
|
+
return
|
|
3122
|
+
}
|
|
3123
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
3124
|
+
if (!task) {
|
|
3125
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
3126
|
+
return
|
|
3127
|
+
}
|
|
3128
|
+
if (body.expectedRev !== undefined && body.expectedRev !== null) {
|
|
3129
|
+
if (!Number.isInteger(body.expectedRev)) {
|
|
3130
|
+
sendJson(response, 400, { ok: false, reason: `expectedRev 必须是整数(当前 rev=${task.rev})` })
|
|
3131
|
+
return
|
|
3132
|
+
}
|
|
3133
|
+
if (body.expectedRev !== task.rev) {
|
|
3134
|
+
sendJson(response, 409, {
|
|
3135
|
+
ok: false,
|
|
3136
|
+
reason: `CAS 冲突:任务 ${id} 期望 rev=${body.expectedRev},实际 rev=${task.rev} —— 请刷新后重试`,
|
|
3137
|
+
})
|
|
3138
|
+
return
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const phases = normalizePhases(body.phases, 'TASK_BAD_PHASES')
|
|
3142
|
+
const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : OWNER
|
|
3143
|
+
let note = 'Boss 调整阶段清单'
|
|
3144
|
+
if (body.note !== undefined && body.note !== null) {
|
|
3145
|
+
if (typeof body.note !== 'string') {
|
|
3146
|
+
sendJson(response, 400, { ok: false, reason: 'note 必须是字符串(也可以不传)' })
|
|
3147
|
+
return
|
|
3148
|
+
}
|
|
3149
|
+
const text = body.note.trim()
|
|
3150
|
+
if (Array.from(text).length > LONGRUN_PHASE_MAX) {
|
|
3151
|
+
sendJson(response, 400, {
|
|
3152
|
+
ok: false,
|
|
3153
|
+
reason: `note 最多 ${LONGRUN_PHASE_MAX} 字(收到 ${Array.from(text).length} 字)`,
|
|
3154
|
+
})
|
|
3155
|
+
return
|
|
3156
|
+
}
|
|
3157
|
+
if (text) note = text
|
|
3158
|
+
}
|
|
3159
|
+
// 只追加一条 `kind:'longrun'` 行 —— `kind:'phase'` 行**一根都不动**(契约 §4.2):
|
|
3160
|
+
// Boss 改的是计划,已交的证据不因此消失;清单变短时越界产物只是不再展示。
|
|
3161
|
+
appendTaskMeta(services, { kind: 'longrun', taskId: id, phases, note, by, at: Date.now() })
|
|
3162
|
+
const view = buildPhaseView(services, { ...task, id })
|
|
3163
|
+
sendJson(response, 200, {
|
|
3164
|
+
ok: true,
|
|
3165
|
+
taskId: id,
|
|
3166
|
+
phases: view.phases,
|
|
3167
|
+
currentIndex: view.currentIndex,
|
|
3168
|
+
totalArtifacts: view.totalArtifacts,
|
|
3169
|
+
isLongrun: view.isLongrun,
|
|
3170
|
+
note,
|
|
3171
|
+
})
|
|
3172
|
+
return
|
|
3173
|
+
}
|
|
3174
|
+
|
|
2200
3175
|
// ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
|
|
2201
3176
|
if (request.method === 'GET' && path === '/tasks/detail') {
|
|
2202
3177
|
const id = qp(url, 'id')
|
|
@@ -2221,6 +3196,21 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2221
3196
|
return
|
|
2222
3197
|
}
|
|
2223
3198
|
|
|
3199
|
+
if (request.method === 'GET' && path === '/tasks/diagnose') {
|
|
3200
|
+
const id = qp(url, 'id')
|
|
3201
|
+
if (!id) {
|
|
3202
|
+
sendJson(response, 400, { ok: false, reason: 'id 必填' })
|
|
3203
|
+
return
|
|
3204
|
+
}
|
|
3205
|
+
const task = await withService(services, 'board', (b) => b.get(id))
|
|
3206
|
+
if (!task) {
|
|
3207
|
+
sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
|
|
3208
|
+
return
|
|
3209
|
+
}
|
|
3210
|
+
sendJson(response, 200, await diagnoseTask(ctx, services, task))
|
|
3211
|
+
return
|
|
3212
|
+
}
|
|
3213
|
+
|
|
2224
3214
|
if (request.method === 'POST' && path === '/tasks/assign') {
|
|
2225
3215
|
const body = (await readBody(request)) ?? {}
|
|
2226
3216
|
if (!body.id || !body.ownerId) {
|
|
@@ -2431,6 +3421,8 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2431
3421
|
|
|
2432
3422
|
let wake = null
|
|
2433
3423
|
let revive
|
|
3424
|
+
// 契约 §7.2:人发了话,机器要跟着动(详见 autoActivate 注释)
|
|
3425
|
+
const activated = await autoActivate(services, task, note.by)
|
|
2434
3426
|
if (shouldWake) {
|
|
2435
3427
|
// 契约 bots05 §2.3:投递前先确保 owner 会话 live(失败不阻断,wake 会如实报错)。
|
|
2436
3428
|
revive = await ensureLive(ctx, task.ownerId)
|
|
@@ -2447,7 +3439,7 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2447
3439
|
})
|
|
2448
3440
|
armDeliveryWatch(services, task.id, task.ownerId, noteBaseline, wake)
|
|
2449
3441
|
}
|
|
2450
|
-
sendJson(response, 200, { ok: true, note, wake, ...reviveFields(revive) })
|
|
3442
|
+
sendJson(response, 200, { ok: true, note, wake, activated, ...reviveFields(revive) })
|
|
2451
3443
|
return
|
|
2452
3444
|
}
|
|
2453
3445
|
|
|
@@ -2588,10 +3580,16 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2588
3580
|
// dispatcher 缺失 → 503(先于复活副作用,避免「复活了却没地方投递」)。
|
|
2589
3581
|
const dispatcher = await withService(services, 'dispatcher', (d) => d)
|
|
2590
3582
|
const revive = await ensureLive(ctx, task.ownerId)
|
|
3583
|
+
// 契约 §7.2:唤醒前顺手把租约续上(进行中 ⇒ 心跳;待领 ⇒ 自动接活)。
|
|
3584
|
+
// ⚠️ 顺序很重要(回归 R15):**先算 dedupKey,再 autoActivate**。
|
|
3585
|
+
// autoActivate 会 bump rev(心跳/认领都写事件),若先激活再算键,
|
|
3586
|
+
// `force:false` 的幂等键 `task-wake:<id>:<rev>` 就会跟着漂,
|
|
3587
|
+
// 契约 §8「同一任务同一 rev 的重试只唤醒一次」被破坏。
|
|
2591
3588
|
const force = body.force !== false
|
|
2592
3589
|
const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
|
|
2593
3590
|
? body.dedupKey.trim()
|
|
2594
3591
|
: (force ? `task-wake:${task.id}:${Date.now()}` : `task-wake:${task.id}:${task.rev}`)
|
|
3592
|
+
const activated = await autoActivate(services, task, task.ownerId)
|
|
2595
3593
|
// 2026-11-09:同一道投递能力预检(手动重试也不该往死会话里投)。
|
|
2596
3594
|
const deliverable = assertDeliverable(ctx, task.ownerId)
|
|
2597
3595
|
if (!deliverable.ok) {
|
|
@@ -2602,6 +3600,7 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2602
3600
|
ownerId: task.ownerId,
|
|
2603
3601
|
live: revive.live,
|
|
2604
3602
|
revived: revive.revived,
|
|
3603
|
+
activated,
|
|
2605
3604
|
marked,
|
|
2606
3605
|
wake: { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: task.ownerId },
|
|
2607
3606
|
})
|
|
@@ -2630,6 +3629,7 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
2630
3629
|
ownerId: task.ownerId,
|
|
2631
3630
|
live: revive.live,
|
|
2632
3631
|
revived: revive.revived,
|
|
3632
|
+
activated,
|
|
2633
3633
|
...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
|
|
2634
3634
|
wake,
|
|
2635
3635
|
})
|
|
@@ -3964,14 +4964,35 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
3964
4964
|
const body = (await readBody(request)) ?? {}
|
|
3965
4965
|
const out = await withService(services, 'teams', (t) =>
|
|
3966
4966
|
t.create({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
|
|
3967
|
-
|
|
4967
|
+
// 契约 §3:建团队就配议事厅。失败不阻断团队创建,只回一句 roomError。
|
|
4968
|
+
const roomOut = await ensureTeamRoom(services, out, body.by ?? OWNER)
|
|
4969
|
+
sendJson(response, 200, {
|
|
4970
|
+
ok: true, team: out,
|
|
4971
|
+
...(roomOut.room ? { room: roomOut.room } : {}),
|
|
4972
|
+
...(roomOut.roomError ? { roomError: roomOut.roomError } : {}),
|
|
4973
|
+
})
|
|
3968
4974
|
return
|
|
3969
4975
|
}
|
|
3970
4976
|
if (request.method === 'POST' && path === '/teams/add') {
|
|
3971
4977
|
const body = (await readBody(request)) ?? {}
|
|
4978
|
+
const by = body.by ?? OWNER
|
|
3972
4979
|
const out = await withService(services, 'teams', (t) =>
|
|
3973
|
-
t.addMember({ ...body, by
|
|
3974
|
-
|
|
4980
|
+
t.addMember({ ...body, by })).catch(markTeamError)
|
|
4981
|
+
if (out?.ok === false) {
|
|
4982
|
+
sendJson(response, out.status ?? 400, out)
|
|
4983
|
+
return
|
|
4984
|
+
}
|
|
4985
|
+
// 契约 §3:新成员也要进议事厅(否则加了人、人却不在那个房间)
|
|
4986
|
+
let roomError
|
|
4987
|
+
if (typeof out?.roomId === 'string' && out.roomId && body.memberId) {
|
|
4988
|
+
try {
|
|
4989
|
+
await withService(services, 'room', (r) =>
|
|
4990
|
+
r.join({ roomId: out.roomId, sessionId: body.memberId, byId: by }))
|
|
4991
|
+
} catch (error) {
|
|
4992
|
+
roomError = `成员已加入团队,但未能拉进议事厅(${out.roomId}):${error?.message ?? error}`
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
sendJson(response, 200, { ok: true, team: out, ...(roomError ? { roomError } : {}) })
|
|
3975
4996
|
return
|
|
3976
4997
|
}
|
|
3977
4998
|
if (request.method === 'POST' && path === '/teams/remove') {
|
|
@@ -4002,6 +5023,82 @@ export function installApi(ctx, { warn } = {}) {
|
|
|
4002
5023
|
sendJson(response, 200, { ok: true, team: out })
|
|
4003
5024
|
return
|
|
4004
5025
|
}
|
|
5026
|
+
// ── 跨团队路由(t-39 第2批 A · HTTP 出口)───────────────────────────
|
|
5027
|
+
//
|
|
5028
|
+
// 为什么必须有这一条:页面上的「📤 发协作请求」按钮(client.js)就是打这个
|
|
5029
|
+
// 路径的。只有 MCP 工具 `bot_team_route` 而没有 HTTP 出口 ⇒ Boss 点按钮 404。
|
|
5030
|
+
//
|
|
5031
|
+
// 复用 `teams.deliverRoute()` —— 与工具层**同一套序列编排**(决定 → 投递 →
|
|
5032
|
+
// 留痕)。绝不在这里重写一遍,否则两条路的行为会漂。
|
|
5033
|
+
if (request.method === 'POST' && path === '/teams/route') {
|
|
5034
|
+
const body = (await readBody(request)) ?? {}
|
|
5035
|
+
const out = await withService(services, 'teams', (t) =>
|
|
5036
|
+
t.deliverRoute({
|
|
5037
|
+
...body,
|
|
5038
|
+
by: body.by ?? OWNER,
|
|
5039
|
+
// dispatcher 从装配层取出后注入:TeamsService 不反向依赖 dispatcher
|
|
5040
|
+
dispatcher: services.dispatcher,
|
|
5041
|
+
})).catch(markTeamError)
|
|
5042
|
+
// refused 是**正常的业务结论**,不是服务器错误 ⇒ 200 带 decision:'refused'。
|
|
5043
|
+
// 用 4xx 会让前端把它当异常处理,丢掉 reason 的可读性(DecisionKind 语义)。
|
|
5044
|
+
sendJson(response, 200, out)
|
|
5045
|
+
return
|
|
5046
|
+
}
|
|
5047
|
+
|
|
5048
|
+
// ── 团队唤醒成本统计(t-39 第2批 D · HTTP 出口)─────────────────────
|
|
5049
|
+
//
|
|
5050
|
+
// 页面团队卡片要显示「今日唤醒 N 次」。数据源是 dispatcher 的 wakes.jsonl
|
|
5051
|
+
// 按 teamId 聚合(`byTeam`)。不传 id ⇒ 返回全部团队的横向对比。
|
|
5052
|
+
//
|
|
5053
|
+
// `today` = 今日 0 点(本地时区)之后;`total` = 该团队历史累计。
|
|
5054
|
+
if (request.method === 'GET' && path === '/teams/wake-stats') {
|
|
5055
|
+
const id = qp(url, 'id')
|
|
5056
|
+
const dispatcher = services.dispatcher
|
|
5057
|
+
if (!dispatcher || typeof dispatcher.byTeam !== 'function') {
|
|
5058
|
+
sendJson(response, 503, { ok: false, reason: 'DISPATCHER_UNAVAILABLE' })
|
|
5059
|
+
return
|
|
5060
|
+
}
|
|
5061
|
+
const since = qn(url, 'since')
|
|
5062
|
+
const nowMs = Date.now()
|
|
5063
|
+
const midnight = since ?? new Date(nowMs).setHours(0, 0, 0, 0)
|
|
5064
|
+
const todayAgg = dispatcher.byTeam({ since: midnight })
|
|
5065
|
+
const allAgg = dispatcher.byTeam({})
|
|
5066
|
+
|
|
5067
|
+
const pick = (agg) => {
|
|
5068
|
+
if (!id) {
|
|
5069
|
+
return {
|
|
5070
|
+
teams: agg.teams,
|
|
5071
|
+
unassigned: agg.unassigned,
|
|
5072
|
+
total: agg.total,
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
const hit = agg.teams.find((t) => t.teamId === id)
|
|
5076
|
+
return hit ?? { teamId: id, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: 0, targetsList: [] }
|
|
5077
|
+
}
|
|
5078
|
+
const today = pick(todayAgg)
|
|
5079
|
+
const all = pick(allAgg)
|
|
5080
|
+
if (id) {
|
|
5081
|
+
sendJson(response, 200, {
|
|
5082
|
+
ok: true,
|
|
5083
|
+
id,
|
|
5084
|
+
today: today.count ?? 0,
|
|
5085
|
+
total: all.count ?? 0,
|
|
5086
|
+
byResult: {
|
|
5087
|
+
sent: today.sent ?? 0, skipped: today.skipped ?? 0,
|
|
5088
|
+
failed: today.failed ?? 0, pending: today.pending ?? 0,
|
|
5089
|
+
},
|
|
5090
|
+
byResultAllTime: {
|
|
5091
|
+
sent: all.sent ?? 0, skipped: all.skipped ?? 0,
|
|
5092
|
+
failed: all.failed ?? 0, pending: all.pending ?? 0,
|
|
5093
|
+
},
|
|
5094
|
+
since: midnight,
|
|
5095
|
+
})
|
|
5096
|
+
return
|
|
5097
|
+
}
|
|
5098
|
+
sendJson(response, 200, { ok: true, since: midnight, today, all })
|
|
5099
|
+
return
|
|
5100
|
+
}
|
|
5101
|
+
|
|
4005
5102
|
// 管理策略(2026-11-09「管理员权限边界从页面控制」):CAS 改团队的 adminPolicy。
|
|
4006
5103
|
if (request.method === 'POST' && path === '/teams/policy') {
|
|
4007
5104
|
const body = (await readBody(request)) ?? {}
|