dsh-mutipleagent 0.0.0-stage → 0.4.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 +214 -0
- package/CONTRIBUTING.md +49 -0
- package/LICENSE +21 -0
- package/README.en.md +125 -0
- package/README.md +302 -2
- package/SECURITY.md +27 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/assets/agents/MAGPIE-LICENSE +21 -0
- package/assets/agents/README.md +47 -0
- package/assets/agents/antigravity.png +0 -0
- package/assets/agents/claude.png +0 -0
- package/assets/agents/codex.png +0 -0
- package/assets/agents/copilot.svg +1 -0
- package/assets/agents/dsh.svg +1 -0
- package/assets/agents/gemini.png +0 -0
- package/assets/agents/grok.svg +1 -0
- package/assets/agents/hermes.svg +1 -0
- package/assets/agents/kimi.svg +1 -0
- package/assets/agents/omp.svg +7 -0
- package/assets/agents/opencode.svg +1 -0
- package/assets/agents/pi.svg +1 -0
- package/assets/kenney/LICENSE-furniture-kit.txt +23 -0
- package/assets/kenney/LICENSE-mini-characters.txt +28 -0
- package/assets/kenney/characters/Textures/colormap.png +0 -0
- package/assets/kenney/characters/character-female-a.glb +0 -0
- package/assets/kenney/characters/character-female-b.glb +0 -0
- package/assets/kenney/characters/character-female-c.glb +0 -0
- package/assets/kenney/characters/character-female-d.glb +0 -0
- package/assets/kenney/characters/character-female-e.glb +0 -0
- package/assets/kenney/characters/character-female-f.glb +0 -0
- package/assets/kenney/characters/character-male-a.glb +0 -0
- package/assets/kenney/characters/character-male-b.glb +0 -0
- package/assets/kenney/characters/character-male-c.glb +0 -0
- package/assets/kenney/characters/character-male-d.glb +0 -0
- package/assets/kenney/characters/character-male-e.glb +0 -0
- package/assets/kenney/characters/character-male-f.glb +0 -0
- package/assets/kenney/furniture/benchCushion.glb +0 -0
- package/assets/kenney/furniture/bookcaseOpen.glb +0 -0
- package/assets/kenney/furniture/books.glb +0 -0
- package/assets/kenney/furniture/cabinetTelevision.glb +0 -0
- package/assets/kenney/furniture/cardboardBoxClosed.glb +0 -0
- package/assets/kenney/furniture/chairCushion.glb +0 -0
- package/assets/kenney/furniture/chairDesk.glb +0 -0
- package/assets/kenney/furniture/chairModernCushion.glb +0 -0
- package/assets/kenney/furniture/coatRackStanding.glb +0 -0
- package/assets/kenney/furniture/computerKeyboard.glb +0 -0
- package/assets/kenney/furniture/computerMouse.glb +0 -0
- package/assets/kenney/furniture/computerScreen.glb +0 -0
- package/assets/kenney/furniture/desk.glb +0 -0
- package/assets/kenney/furniture/doorway.glb +0 -0
- package/assets/kenney/furniture/kitchenBar.glb +0 -0
- package/assets/kenney/furniture/kitchenCabinet.glb +0 -0
- package/assets/kenney/furniture/kitchenCoffeeMachine.glb +0 -0
- package/assets/kenney/furniture/kitchenFridgeLarge.glb +0 -0
- package/assets/kenney/furniture/kitchenMicrowave.glb +0 -0
- package/assets/kenney/furniture/kitchenSink.glb +0 -0
- package/assets/kenney/furniture/lampRoundFloor.glb +0 -0
- package/assets/kenney/furniture/lampSquareTable.glb +0 -0
- package/assets/kenney/furniture/laptop.glb +0 -0
- package/assets/kenney/furniture/loungeChairRelax.glb +0 -0
- package/assets/kenney/furniture/loungeSofa.glb +0 -0
- package/assets/kenney/furniture/plantSmall1.glb +0 -0
- package/assets/kenney/furniture/plantSmall2.glb +0 -0
- package/assets/kenney/furniture/plantSmall3.glb +0 -0
- package/assets/kenney/furniture/pottedPlant.glb +0 -0
- package/assets/kenney/furniture/radio.glb +0 -0
- package/assets/kenney/furniture/rugRectangle.glb +0 -0
- package/assets/kenney/furniture/rugRound.glb +0 -0
- package/assets/kenney/furniture/sideTable.glb +0 -0
- package/assets/kenney/furniture/speaker.glb +0 -0
- package/assets/kenney/furniture/stoolBar.glb +0 -0
- package/assets/kenney/furniture/table.glb +0 -0
- package/assets/kenney/furniture/tableCoffee.glb +0 -0
- package/assets/kenney/furniture/tableRound.glb +0 -0
- package/assets/kenney/furniture/televisionModern.glb +0 -0
- package/assets/kenney/furniture/toaster.glb +0 -0
- package/assets/kenney/furniture/trashcan.glb +0 -0
- package/assets/kenney/furniture/wallWindow.glb +0 -0
- package/assets/logo.svg +12 -0
- package/assets/scene-icons/arcade.png +0 -0
- package/assets/scene-icons/cafe.png +0 -0
- package/assets/scene-icons/castle.png +0 -0
- package/assets/scene-icons/christmas.png +0 -0
- package/assets/scene-icons/graveyard.png +0 -0
- package/assets/scene-icons/map.png +0 -0
- package/assets/scene-icons/office.png +0 -0
- package/assets/scene-icons/pirate.png +0 -0
- package/assets/scene-icons/station.png +0 -0
- package/assets/scenes/arcade.jpg +0 -0
- package/assets/scenes/cafe.jpg +0 -0
- package/assets/scenes/castle.jpg +0 -0
- package/assets/scenes/christmas.jpg +0 -0
- package/assets/scenes/graveyard.jpg +0 -0
- package/assets/scenes/office.jpg +0 -0
- package/assets/scenes/pirate.jpg +0 -0
- package/assets/scenes/station.jpg +0 -0
- package/client.js +144 -0
- package/cordis.patch.yml +3 -0
- package/harness.cjs +49 -0
- package/package.json +89 -4
- package/public/app.js +1 -0
- package/public/chunks/chunk-MU3LEDFF.js +4305 -0
- package/public/demo-icons/arrow-counterclockwise.svg +4 -0
- package/public/demo-icons/arrow-right.svg +3 -0
- package/public/demo-icons/bell.svg +3 -0
- package/public/demo-icons/box-seam-fill.svg +3 -0
- package/public/demo-icons/chat-left-text-fill.svg +3 -0
- package/public/demo-icons/check-lg.svg +3 -0
- package/public/demo-icons/chevron-down.svg +3 -0
- package/public/demo-icons/cpu-fill.svg +4 -0
- package/public/demo-icons/crosshair.svg +3 -0
- package/public/demo-icons/diagram-3-fill.svg +3 -0
- package/public/demo-icons/file-earmark-code-fill.svg +3 -0
- package/public/demo-icons/fullscreen.svg +3 -0
- package/public/demo-icons/pause-fill.svg +3 -0
- package/public/demo-icons/people-fill.svg +3 -0
- package/public/demo-icons/pie-chart-fill.svg +3 -0
- package/public/demo-icons/play-fill.svg +3 -0
- package/public/demo-icons/search.svg +3 -0
- package/public/demo-icons/shield-check.svg +4 -0
- package/public/demo-icons/stack.svg +4 -0
- package/public/demo-icons/x-lg.svg +3 -0
- package/public/demo.css +225 -0
- package/public/demo.html +67 -0
- package/public/demo.js +7 -0
- package/public/index.html +59 -0
- package/public/style.css +15 -0
- package/public/vendor/office.js +1631 -0
- package/src/codex.js +674 -0
- package/src/demo.js +103 -0
- package/src/harness-watcher.js +584 -0
- package/src/server.js +239 -0
- package/vendor/licenses/BOOTSTRAP-ICONS-LICENSE.txt +21 -0
- package/vendor/licenses/ESBUILD-LICENSE.txt +21 -0
- package/vendor/licenses/MESHOPTIMIZER-LICENSE.txt +22 -0
- package/vendor/licenses/PLAYWRIGHT-LICENSE.txt +202 -0
- package/vendor/licenses/PLAYWRIGHT-NOTICE.txt +5 -0
- package/vendor/licenses/THREE-LICENSE.txt +21 -0
package/src/demo.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { CodexWatcher } = require('./codex');
|
|
3
|
+
|
|
4
|
+
// 演示完全在内存运行,不打开本机 Codex 数据目录。
|
|
5
|
+
class DemoWatcher extends CodexWatcher {
|
|
6
|
+
constructor() {
|
|
7
|
+
super({ codexHome: '' });
|
|
8
|
+
this.records = new Map();
|
|
9
|
+
this.step = 0;
|
|
10
|
+
this.epoch = Date.now();
|
|
11
|
+
this.rootId = 'demo-main';
|
|
12
|
+
this.agent(this.rootId).relationship = 'root';
|
|
13
|
+
this.agent(this.rootId).cwd = '演示项目 / checkout-service';
|
|
14
|
+
this.plan = [
|
|
15
|
+
['demo-main', 'task_started', {}],
|
|
16
|
+
['demo-main', 'user_message', { message: '检查结算服务:分别分析接口契约和测试缺口,最后汇总发现与建议。' }],
|
|
17
|
+
['demo-main', 'agent_message', { message: '我会把接口契约和测试缺口交给两位子 Agent 并行检查,再汇总结果。' }],
|
|
18
|
+
[
|
|
19
|
+
'demo-main',
|
|
20
|
+
'collab_agent_spawn_end',
|
|
21
|
+
{
|
|
22
|
+
call_id: 'spawn-api',
|
|
23
|
+
new_thread_id: 'demo-api',
|
|
24
|
+
new_agent_nickname: '林 · 接口分析',
|
|
25
|
+
new_agent_role: '接口检查',
|
|
26
|
+
prompt: '只读检查结算接口契约,返回问题与依据。',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
['demo-api', 'task_started', {}],
|
|
30
|
+
[
|
|
31
|
+
'demo-main',
|
|
32
|
+
'collab_agent_spawn_end',
|
|
33
|
+
{
|
|
34
|
+
call_id: 'spawn-test',
|
|
35
|
+
new_thread_id: 'demo-test',
|
|
36
|
+
new_agent_nickname: '夏 · 测试审阅',
|
|
37
|
+
new_agent_role: '测试检查',
|
|
38
|
+
prompt: '只读检查测试覆盖,列出需要补充的边界场景。',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
['demo-test', 'task_started', {}],
|
|
42
|
+
['demo-main', 'collab_waiting_begin', { call_id: 'wait-team' }],
|
|
43
|
+
['demo-api', 'agent_message', { message: '正在对照请求字段和错误响应,已定位到金额精度的边界。' }],
|
|
44
|
+
['demo-test', 'agent_message', { message: '正在检查重复请求、空购物车和库存不足场景。' }],
|
|
45
|
+
[
|
|
46
|
+
'demo-api',
|
|
47
|
+
'task_complete',
|
|
48
|
+
{
|
|
49
|
+
last_agent_message:
|
|
50
|
+
'接口检查完成。\n\n1. 金额使用最小货币单位的整数表示,避免浮点误差。\n2. 重复请求需要同一个幂等键。\n3. 库存不足应返回明确的业务错误。\n\n以上均为演示内容。',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'demo-test',
|
|
55
|
+
'task_complete',
|
|
56
|
+
{
|
|
57
|
+
last_agent_message:
|
|
58
|
+
'测试检查完成。\n\n建议补充:\n• 空购物车提交。\n• 幂等键重复使用。\n• 库存不足时无扣款。\n• 请求金额与服务端计算不一致。\n\n以上均为演示内容。',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
['demo-main', 'collab_waiting_end', { call_id: 'wait-team' }],
|
|
62
|
+
['demo-main', 'agent_message', { message: '两位子 Agent 已完成检查,我正在整理共同结论和后续建议。' }],
|
|
63
|
+
[
|
|
64
|
+
'demo-main',
|
|
65
|
+
'task_complete',
|
|
66
|
+
{
|
|
67
|
+
last_agent_message:
|
|
68
|
+
'协作检查已完成(演示)。\n\n接口与测试检查都指向两个重点:金额的一致性和重复请求的幂等性。\n\n建议优先补充金额校验、幂等处理及对应边界测试,再验证库存不足时的失败路径。\n\n本界面没有执行任何代码检查;这些消息用于展示分配、并行执行、汇报和总结。',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
];
|
|
72
|
+
for (let i = 0; i < 7; i++) this.advance();
|
|
73
|
+
}
|
|
74
|
+
advance() {
|
|
75
|
+
if (this.step >= this.plan.length) return;
|
|
76
|
+
const [id, type, extra] = this.plan[this.step];
|
|
77
|
+
const d = { type: 'event_msg', timestamp: new Date(this.epoch + this.step * 2200).toISOString(), payload: { type, ...extra } };
|
|
78
|
+
this.records.set(this.step, d);
|
|
79
|
+
this.process(this.agent(id), d, { file: '', offset: this.step, length: 0, at: Date.parse(d.timestamp) });
|
|
80
|
+
this.step++;
|
|
81
|
+
this.emit('change');
|
|
82
|
+
}
|
|
83
|
+
start() {
|
|
84
|
+
this.timer = setInterval(() => this.advance(), 2200);
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
tick() {}
|
|
88
|
+
roots() {
|
|
89
|
+
return [{ id: this.rootId, title: '结算服务协作检查', cwd: '演示项目 / checkout-service', status: this.agent(this.rootId).status, at: this.epoch }];
|
|
90
|
+
}
|
|
91
|
+
snapshot() {
|
|
92
|
+
const data = super.snapshot(this.rootId);
|
|
93
|
+
data.mode = 'demo';
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
fullText(sessionId, eventId) {
|
|
97
|
+
const e = this.events.get(eventId);
|
|
98
|
+
if (!e || e.sessionId !== sessionId) return null;
|
|
99
|
+
const p = this.records.get(e._ref.offset)?.payload || {};
|
|
100
|
+
return { id: e.id, sessionId, type: e.type, text: p.message || p.last_agent_message || p.prompt || e.preview };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
module.exports = { DemoWatcher };
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
const { createHash } = require('node:crypto');
|
|
5
|
+
const ROOT_LIMIT = 500;
|
|
6
|
+
const TEAM_LIMIT = 128;
|
|
7
|
+
const TITLE_REFRESH_INTERVAL = 30000;
|
|
8
|
+
const terminal = new Set(['done', 'error', 'interrupted', 'closed']);
|
|
9
|
+
const textOf = content =>
|
|
10
|
+
Array.isArray(content)
|
|
11
|
+
? content
|
|
12
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
13
|
+
.map(block => block.text)
|
|
14
|
+
.join('\n')
|
|
15
|
+
: '';
|
|
16
|
+
const short = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, 180) : '');
|
|
17
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
18
|
+
const eventId = (sessionId, seq, type) => 'dsh:' + encodeURIComponent(sessionId) + ':' + seq + ':' + type;
|
|
19
|
+
// 日志指纹用于跳过未变化会话的重复折叠;DSH 只提供全量快照,这里不改变读取语义。
|
|
20
|
+
const logStamp = log => {
|
|
21
|
+
const events = Array.isArray(log?.events) ? log.events : [];
|
|
22
|
+
const last = events[events.length - 1];
|
|
23
|
+
return [log?.session?.id || '', log?.inheritedEventCount ?? 0, events.length, last?.seq ?? -1, last?.time ?? 0].join(':');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function messageArguments(event) {
|
|
27
|
+
if (event.type !== 'tool/call' || event.data?.name !== 'send_message') return null;
|
|
28
|
+
try {
|
|
29
|
+
const args = JSON.parse(event.data.arguments);
|
|
30
|
+
return typeof args.agent_id === 'string' && typeof args.message === 'string' ? args : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function visibleText(event, kind) {
|
|
37
|
+
if (kind === 'user') return event.type === 'user/message' && event.data?.source?.kind === 'user' ? textOf(event.data.content) : '';
|
|
38
|
+
if (kind === 'assistant') return event.type === 'assistant/message' ? textOf(event.data?.message?.content) : '';
|
|
39
|
+
if (kind === 'message')
|
|
40
|
+
return event.type === 'user/message' && ['agent-message', 'subagent-settled'].includes(event.data?.source?.kind) ? textOf(event.data.content) : '';
|
|
41
|
+
if (kind === 'sent') return messageArguments(event)?.message || '';
|
|
42
|
+
if (kind === 'tool') return event.type === 'tool/call' && typeof event.data?.name === 'string' ? event.data.name : '';
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createdAtValue(value) {
|
|
47
|
+
const numeric = typeof value === 'number' ? value : Number(value);
|
|
48
|
+
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
49
|
+
const parsed = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
50
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
51
|
+
}
|
|
52
|
+
function newestRoots(records) {
|
|
53
|
+
return [...records]
|
|
54
|
+
.filter(record => record.header.origin !== 'subagent')
|
|
55
|
+
.sort((a, b) => createdAtValue(b.header.createdAt) - createdAtValue(a.header.createdAt));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function newAgent(header) {
|
|
59
|
+
const parentId = header.origin === 'subagent' ? header.parentSession || null : null;
|
|
60
|
+
return {
|
|
61
|
+
id: header.id,
|
|
62
|
+
key: header.id,
|
|
63
|
+
agent: 'dsh',
|
|
64
|
+
parentId,
|
|
65
|
+
rootId: null,
|
|
66
|
+
name: parentId ? '子 Agent ' + header.id.slice(-6) : '主 Agent',
|
|
67
|
+
role: parentId ? '子 Agent' : '协调与汇总',
|
|
68
|
+
cwd: typeof header.cwd === 'string' ? header.cwd : '',
|
|
69
|
+
model: '',
|
|
70
|
+
status: 'unknown',
|
|
71
|
+
activity: '',
|
|
72
|
+
task: '',
|
|
73
|
+
taskEventId: null,
|
|
74
|
+
taskSessionId: null,
|
|
75
|
+
reportEventId: null,
|
|
76
|
+
reports: [],
|
|
77
|
+
startedAt: header.createdAt || null,
|
|
78
|
+
turnStartedAt: null,
|
|
79
|
+
lastEventAt: null,
|
|
80
|
+
endedAt: null,
|
|
81
|
+
relationship: parentId ? 'confirmed' : 'root',
|
|
82
|
+
subagent: Boolean(parentId),
|
|
83
|
+
loading: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rootOf(id, records) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
while (records.has(id)) {
|
|
90
|
+
if (seen.has(id)) return null;
|
|
91
|
+
seen.add(id);
|
|
92
|
+
const header = records.get(id).header;
|
|
93
|
+
// 普通 fork 也有 parentSession,只有明确的 subagent origin 才建立团队关系。
|
|
94
|
+
if (header.origin !== 'subagent') return id;
|
|
95
|
+
if (!header.parentSession) return null;
|
|
96
|
+
id = header.parentSession;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fold(log, records) {
|
|
102
|
+
const agent = newAgent(log.session);
|
|
103
|
+
agent.loading = false;
|
|
104
|
+
const events = [],
|
|
105
|
+
refs = new Map(),
|
|
106
|
+
calls = new Map();
|
|
107
|
+
let lastAssistant = null,
|
|
108
|
+
lastSent = null,
|
|
109
|
+
descriptorSeen = false,
|
|
110
|
+
ownTurn = false;
|
|
111
|
+
const add = (raw, type, text = '', extra = {}, kind) => {
|
|
112
|
+
const id = eventId(log.session.id, raw.seq, type);
|
|
113
|
+
const result = { id, sessionId: agent.id, targetId: null, type, at: raw.time, preview: short(text), hasText: Boolean(kind && text), ...extra };
|
|
114
|
+
events.push(result);
|
|
115
|
+
if (kind && text) refs.set(id, { owner: log.session.id, seq: raw.seq, kind, hash: digest(text), sessionId: result.sessionId, type });
|
|
116
|
+
return result;
|
|
117
|
+
};
|
|
118
|
+
const inherited = Number.isSafeInteger(log.inheritedEventCount) ? log.inheritedEventCount : 0;
|
|
119
|
+
for (const raw of log.events.slice(inherited)) {
|
|
120
|
+
const data = raw.data || {};
|
|
121
|
+
// fork cut 后的合成收尾属于父轮次,不能当成子 Agent 已执行或失败。
|
|
122
|
+
if (inherited && !ownTurn && ['tool/result', 'step/end', 'turn/end'].includes(raw.type)) continue;
|
|
123
|
+
agent.lastEventAt = raw.time;
|
|
124
|
+
// 替换用的模型上下文副本不属于用户已见的原始对话。
|
|
125
|
+
if (raw.surfaceOp && raw.surfaceOp !== 'append') continue;
|
|
126
|
+
if (raw.type === 'turn/start') {
|
|
127
|
+
ownTurn = true;
|
|
128
|
+
agent.turnStartedAt = raw.time;
|
|
129
|
+
agent.endedAt = null;
|
|
130
|
+
agent.status = 'thinking';
|
|
131
|
+
agent.activity = '';
|
|
132
|
+
lastAssistant = lastSent = null;
|
|
133
|
+
calls.clear();
|
|
134
|
+
} else if (raw.type === 'user/message') {
|
|
135
|
+
if (data.source?.kind === 'user') {
|
|
136
|
+
const text = visibleText(raw, 'user');
|
|
137
|
+
if (!text) continue;
|
|
138
|
+
const item = add(raw, 'task', text, {}, 'user');
|
|
139
|
+
agent.task = short(text);
|
|
140
|
+
agent.taskEventId = item.id;
|
|
141
|
+
agent.taskSessionId = agent.id;
|
|
142
|
+
} else if (['agent-message', 'subagent-settled'].includes(data.source?.kind)) {
|
|
143
|
+
const sender = data.source.senderSessionId;
|
|
144
|
+
const senderHeader = records.get(sender)?.header;
|
|
145
|
+
const related = agent.parentId === sender || (senderHeader?.origin === 'subagent' && senderHeader.parentSession === agent.id);
|
|
146
|
+
if (related) add(raw, 'message', visibleText(raw, 'message'), { sessionId: sender, targetId: agent.id }, 'message');
|
|
147
|
+
}
|
|
148
|
+
} else if (raw.type === 'assistant/message') {
|
|
149
|
+
const text = visibleText(raw, 'assistant');
|
|
150
|
+
lastAssistant = text ? { raw, text, kind: 'assistant' } : null;
|
|
151
|
+
if (text) {
|
|
152
|
+
add(raw, 'progress', text, {}, 'assistant');
|
|
153
|
+
if (!terminal.has(agent.status)) agent.status = 'typing';
|
|
154
|
+
}
|
|
155
|
+
if (typeof data.message?.source?.model === 'string') agent.model = data.message.source.model;
|
|
156
|
+
} else if (raw.type === 'tool/call') {
|
|
157
|
+
const name = typeof data.name === 'string' ? data.name : '';
|
|
158
|
+
calls.set(data.callId, { name, raw });
|
|
159
|
+
agent.status = name === 'wait' || name === 'wait_agent' ? 'waiting_agents' : name === 'ask_user' ? 'waiting' : 'tool';
|
|
160
|
+
agent.activity = name;
|
|
161
|
+
add(raw, agent.status === 'waiting_agents' ? 'wait' : agent.status === 'waiting' ? 'approval' : 'tool', name, {}, 'tool');
|
|
162
|
+
} else if (raw.type === 'tool/result') {
|
|
163
|
+
const callId = data.message?.source?.callId || data.message?.toolCallId;
|
|
164
|
+
const call = calls.get(callId),
|
|
165
|
+
name = call?.name;
|
|
166
|
+
calls.delete(callId);
|
|
167
|
+
if (data.message?.isError || data.error) add(raw, 'error', name ? name + ' 执行失败' : '工具执行失败');
|
|
168
|
+
else if (call) {
|
|
169
|
+
const sent = messageArguments(call.raw);
|
|
170
|
+
if (
|
|
171
|
+
sent &&
|
|
172
|
+
(sent.agent_id === agent.parentId ||
|
|
173
|
+
(records.get(sent.agent_id)?.header.parentSession === agent.id && records.get(sent.agent_id)?.header.origin === 'subagent'))
|
|
174
|
+
) {
|
|
175
|
+
// 调用结果成功后才表示消息已接收;单有工具请求不能证明投递成功。
|
|
176
|
+
add(call.raw, 'message', sent.message, { targetId: sent.agent_id, at: raw.time }, 'sent');
|
|
177
|
+
if (sent.agent_id === agent.parentId) lastSent = { raw: call.raw, text: sent.message, kind: 'sent' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!terminal.has(agent.status)) {
|
|
181
|
+
agent.status = calls.size ? 'tool' : 'thinking';
|
|
182
|
+
agent.activity = calls.size ? [...calls.values()].at(-1).name : '';
|
|
183
|
+
}
|
|
184
|
+
} else if (raw.type === 'request/context') {
|
|
185
|
+
if (typeof data.model === 'string') agent.model = data.model;
|
|
186
|
+
} else if (raw.type === 'subagent/descriptor' && !descriptorSeen) {
|
|
187
|
+
descriptorSeen = true;
|
|
188
|
+
if (data.version === 3 && ['one-shot', 'continuable'].includes(data.mode)) {
|
|
189
|
+
if (typeof data.label === 'string') agent.name = data.label;
|
|
190
|
+
if (typeof data.agentModel === 'string') agent.model = data.agentModel;
|
|
191
|
+
}
|
|
192
|
+
} else if (raw.type === 'subagent/catalog') {
|
|
193
|
+
const child = records.get(data.childId)?.header;
|
|
194
|
+
if ([0, 1].includes(data.version) && child?.origin === 'subagent' && child.parentSession === agent.id) {
|
|
195
|
+
add(raw, 'assign', typeof data.label === 'string' ? data.label : '分配子 Agent', { targetId: child.id });
|
|
196
|
+
}
|
|
197
|
+
} else if (raw.type === 'turn/end') {
|
|
198
|
+
const reason = data.reason?.kind;
|
|
199
|
+
agent.status =
|
|
200
|
+
reason === 'completed'
|
|
201
|
+
? 'done'
|
|
202
|
+
: ['aborted', 'interrupted', 'forked'].includes(reason)
|
|
203
|
+
? 'interrupted'
|
|
204
|
+
: ['error', 'max-tokens', 'refusal'].includes(reason)
|
|
205
|
+
? 'error'
|
|
206
|
+
: reason === 'blocked'
|
|
207
|
+
? 'waiting'
|
|
208
|
+
: 'unknown';
|
|
209
|
+
agent.activity = reason === 'max-tokens' ? '输出达到上限' : reason === 'blocked' ? '本轮被阻止' : '';
|
|
210
|
+
agent.endedAt = raw.time;
|
|
211
|
+
if (agent.status === 'done') {
|
|
212
|
+
const report = !lastAssistant || lastSent?.raw.seq > lastAssistant.raw.seq ? lastSent : lastAssistant;
|
|
213
|
+
if (report) {
|
|
214
|
+
const item = add(report.raw, 'report', report.text, {}, report.kind);
|
|
215
|
+
agent.reportEventId = item.id;
|
|
216
|
+
agent.reports.push(item.id);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
add(
|
|
220
|
+
raw,
|
|
221
|
+
agent.status === 'done' ? 'complete' : agent.status === 'error' ? 'error' : agent.status === 'interrupted' ? 'interrupted' : 'progress',
|
|
222
|
+
agent.activity,
|
|
223
|
+
);
|
|
224
|
+
calls.clear();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// 长会话只向页面保留近期活动;任务和最近报告的引用仍支持按需全文读取。
|
|
228
|
+
const recent = events.sort((a, b) => a.at - b.at).slice(-160);
|
|
229
|
+
agent.reports = agent.reports.slice(-8);
|
|
230
|
+
const keep = new Set([agent.taskEventId, ...agent.reports, ...recent.map(event => event.id)]);
|
|
231
|
+
const title = short(log.events.findLast(event => event.type === 'session/title')?.data?.title);
|
|
232
|
+
return { agent, title, events: recent, refs: new Map([...refs].filter(([id]) => keep.has(id))) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
class HarnessWatcher extends EventEmitter {
|
|
236
|
+
constructor({ query, interval = 1000 } = {}) {
|
|
237
|
+
super();
|
|
238
|
+
if (!query || typeof query.listSessions !== 'function' || typeof query.readSession !== 'function' || typeof query.readEvent !== 'function') {
|
|
239
|
+
throw new TypeError('HarnessWatcher 需要公开的 sessionQuery 服务');
|
|
240
|
+
}
|
|
241
|
+
this.query = query;
|
|
242
|
+
this.interval = interval;
|
|
243
|
+
this.records = new Map();
|
|
244
|
+
this.cache = new Map();
|
|
245
|
+
this.titles = new Map();
|
|
246
|
+
this.warnings = [];
|
|
247
|
+
this.subscriptions = new Map();
|
|
248
|
+
this.selectedId = null;
|
|
249
|
+
this.revision = 0;
|
|
250
|
+
this.epoch = 0;
|
|
251
|
+
this.stopped = false;
|
|
252
|
+
this.running = false;
|
|
253
|
+
this.pending = null;
|
|
254
|
+
this.queued = false;
|
|
255
|
+
this.signature = '';
|
|
256
|
+
// 需求驱动:没有 SSE 客户端且没有订阅时不扫描宿主会话,页面打开后再立即刷新。
|
|
257
|
+
this.clients = 0;
|
|
258
|
+
this.lastReadAt = 0;
|
|
259
|
+
this.lastReadMs = 0;
|
|
260
|
+
this.memberCount = 0;
|
|
261
|
+
// 本轮因日志游标未变化而复用折叠结果的成员数(/api/health 可观测)。
|
|
262
|
+
this.reused = 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async start() {
|
|
266
|
+
if (this.running) return this;
|
|
267
|
+
this.running = true;
|
|
268
|
+
this.stopped = false;
|
|
269
|
+
const epoch = this.epoch;
|
|
270
|
+
await this.refresh();
|
|
271
|
+
if (this.running && !this.stopped && epoch === this.epoch) {
|
|
272
|
+
this.timer = setInterval(() => {
|
|
273
|
+
if (this.wanted()) void this.refresh();
|
|
274
|
+
}, this.interval);
|
|
275
|
+
this.timer.unref?.();
|
|
276
|
+
}
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 有客户端订阅、或被页面打开时才需要轮询。 */
|
|
281
|
+
wanted() {
|
|
282
|
+
return this.clients > 0 || this.subscriptions.size > 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 由服务端在 SSE 连接增减时调用。 */
|
|
286
|
+
setClients(count) {
|
|
287
|
+
this.clients = Math.max(0, Number(count) || 0);
|
|
288
|
+
if (this.clients > 0) void this.refresh();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 供 /api/health 读取的运行时指标(不要叫 status:基类已有同名的 Agent 状态写法)。 */
|
|
292
|
+
metrics() {
|
|
293
|
+
return {
|
|
294
|
+
sessions: this.records.size,
|
|
295
|
+
members: this.memberCount,
|
|
296
|
+
clients: this.clients,
|
|
297
|
+
subscriptions: this.subscriptions.size,
|
|
298
|
+
lastReadAt: this.lastReadAt || null,
|
|
299
|
+
lastReadMs: this.lastReadMs,
|
|
300
|
+
reused: this.reused,
|
|
301
|
+
revision: this.revision,
|
|
302
|
+
interval: this.interval,
|
|
303
|
+
polling: this.wanted(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
stop() {
|
|
308
|
+
this.running = false;
|
|
309
|
+
this.stopped = true;
|
|
310
|
+
this.epoch++;
|
|
311
|
+
clearInterval(this.timer);
|
|
312
|
+
this.subscriptions.clear();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
subscribe(id) {
|
|
316
|
+
const rootId = id && rootOf(id, this.records);
|
|
317
|
+
if (this.stopped || !rootId || !this.roots().some(root => root.id === rootId)) return () => {};
|
|
318
|
+
this.subscriptions.set(rootId, (this.subscriptions.get(rootId) || 0) + 1);
|
|
319
|
+
void this.refresh();
|
|
320
|
+
let released = false;
|
|
321
|
+
return () => {
|
|
322
|
+
if (released) return;
|
|
323
|
+
released = true;
|
|
324
|
+
const count = this.subscriptions.get(rootId) || 0;
|
|
325
|
+
if (count > 1) this.subscriptions.set(rootId, count - 1);
|
|
326
|
+
else this.subscriptions.delete(rootId);
|
|
327
|
+
if (!this.stopped) void this.refresh();
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
select(id) {
|
|
332
|
+
if (this.stopped) return false;
|
|
333
|
+
const rootId = rootOf(id, this.records);
|
|
334
|
+
if (!rootId || !this.roots().some(root => root.id === rootId)) return false;
|
|
335
|
+
if (this.selectedId !== rootId) {
|
|
336
|
+
this.selectedId = rootId;
|
|
337
|
+
void this.refresh();
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
refresh() {
|
|
343
|
+
if (this.stopped) return Promise.resolve(this);
|
|
344
|
+
if (this.pending) {
|
|
345
|
+
this.queued = true;
|
|
346
|
+
return this.pending;
|
|
347
|
+
}
|
|
348
|
+
const epoch = this.epoch;
|
|
349
|
+
this.pending = (async () => {
|
|
350
|
+
do {
|
|
351
|
+
this.queued = false;
|
|
352
|
+
try {
|
|
353
|
+
await this.read(epoch);
|
|
354
|
+
} catch {
|
|
355
|
+
if (!this.stopped && epoch === this.epoch) {
|
|
356
|
+
this.warnings = ['DSH 会话读取暂时受阻,保留上次状态'];
|
|
357
|
+
this.publish();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} while (this.queued && !this.stopped && epoch === this.epoch);
|
|
361
|
+
return this;
|
|
362
|
+
})().finally(() => {
|
|
363
|
+
this.pending = null;
|
|
364
|
+
});
|
|
365
|
+
return this.pending;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 取一份观察租约。租约只读取 header 与日志游标,事件按需物化,因此未变化的会话
|
|
370
|
+
* 不必克隆整份日志。宿主没有 observeSession 或它抛错时返回 null,由 readSession 兜底。
|
|
371
|
+
*/
|
|
372
|
+
async tryObserve(id) {
|
|
373
|
+
if (typeof this.query.observeSession !== 'function') return null;
|
|
374
|
+
try {
|
|
375
|
+
const lease = await this.query.observeSession(id, { projectionMode: 'none' });
|
|
376
|
+
return lease && lease.header ? lease : null;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 读取一位成员的变化指纹与日志来源。租约路径的指纹由 header、继承事件数与日志游标
|
|
384
|
+
* 组成(冷会话再看持久化修订号),只有指纹变化才展开整份事件并重新折叠。
|
|
385
|
+
*/
|
|
386
|
+
async loadMember(id) {
|
|
387
|
+
const lease = await this.tryObserve(id);
|
|
388
|
+
if (lease) {
|
|
389
|
+
const stamp = 'v2|' + [lease.header.id, lease.inheritedEventCount ?? 0, lease.cursor ?? -1, lease.revision ?? ''].join(':');
|
|
390
|
+
return {
|
|
391
|
+
stamp,
|
|
392
|
+
log: () => ({ session: lease.header, inheritedEventCount: lease.inheritedEventCount ?? 0, events: lease.events }),
|
|
393
|
+
done: () => lease[Symbol.dispose]?.(),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const log = await this.query.readSession(id);
|
|
397
|
+
return { stamp: 'v1|' + logStamp(log), log: () => log, done: () => {} };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async read(epoch) {
|
|
401
|
+
const startedAt = Date.now();
|
|
402
|
+
const listed = await this.query.listSessions();
|
|
403
|
+
if (this.stopped || epoch !== this.epoch) return;
|
|
404
|
+
const records = new Map(listed.map(record => [record.header.id, record]));
|
|
405
|
+
// 语料签名:会话集合变化会影响父子归属,此时所有折叠结果都要重算。
|
|
406
|
+
const corpus = [...records.keys()].sort().join(',') + '|' + records.size;
|
|
407
|
+
const allRoots = newestRoots(records.values());
|
|
408
|
+
const roots = allRoots.slice(0, ROOT_LIMIT);
|
|
409
|
+
const selectedId = roots.some(record => record.header.id === this.selectedId) ? this.selectedId : roots[0]?.header.id || null;
|
|
410
|
+
const activeRoots = new Set([selectedId, ...this.subscriptions.keys()].filter(id => roots.some(record => record.header.id === id)));
|
|
411
|
+
const limited = [],
|
|
412
|
+
warnings = [];
|
|
413
|
+
if (allRoots.length > ROOT_LIMIT) warnings.push('会话较多,仅显示最近 ' + ROOT_LIMIT + ' 个(共 ' + allRoots.length + ' 个)');
|
|
414
|
+
for (const rootId of activeRoots) {
|
|
415
|
+
const team = [...records.values()].filter(record => rootOf(record.header.id, records) === rootId);
|
|
416
|
+
limited.push(...team.sort((a, b) => (a.header.id === rootId ? -1 : b.header.id === rootId ? 1 : 0)).slice(0, TEAM_LIMIT));
|
|
417
|
+
if (team.length > TEAM_LIMIT) warnings.push('团队超过 128 位成员,仅同步前 128 位');
|
|
418
|
+
}
|
|
419
|
+
const cache = new Map();
|
|
420
|
+
let reused = 0;
|
|
421
|
+
// 小批次限制同时读取的完整会话数量,单个成员不可读不阻断其他成员。
|
|
422
|
+
for (let offset = 0; offset < limited.length; offset += 4) {
|
|
423
|
+
const batch = limited.slice(offset, offset + 4);
|
|
424
|
+
const results = await Promise.allSettled(batch.map(record => this.loadMember(record.header.id)));
|
|
425
|
+
if (this.stopped || epoch !== this.epoch) {
|
|
426
|
+
for (const result of results) if (result.status === 'fulfilled') result.value.done();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
for (let index = 0; index < batch.length; index++) {
|
|
430
|
+
const record = batch[index],
|
|
431
|
+
result = results[index];
|
|
432
|
+
try {
|
|
433
|
+
if (result.status === 'rejected') throw result.reason;
|
|
434
|
+
const member = result.value;
|
|
435
|
+
const stamp = corpus + '#' + member.stamp;
|
|
436
|
+
const previous = this.cache.get(record.header.id);
|
|
437
|
+
if (previous?.stamp === stamp) {
|
|
438
|
+
cache.set(record.header.id, previous);
|
|
439
|
+
reused++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
cache.set(record.header.id, { ...fold(member.log(), records), stamp });
|
|
443
|
+
} catch {
|
|
444
|
+
// 读取失败时沿用上一轮内容,但不携带指纹,恢复后一定重算。
|
|
445
|
+
const previous = this.cache.get(record.header.id);
|
|
446
|
+
cache.set(
|
|
447
|
+
record.header.id,
|
|
448
|
+
previous
|
|
449
|
+
? { agent: previous.agent, events: previous.events, refs: previous.refs }
|
|
450
|
+
: { agent: { ...newAgent(record.header), loading: false }, events: [], refs: new Map() },
|
|
451
|
+
);
|
|
452
|
+
warnings.push('有成员会话暂时无法读取,其他成员继续同步');
|
|
453
|
+
} finally {
|
|
454
|
+
if (result.status === 'fulfilled') result.value.done();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const titles = await this.readTitles(roots, cache);
|
|
459
|
+
if (this.stopped || epoch !== this.epoch) return;
|
|
460
|
+
if ([...titles.values()].some(entry => entry.failed)) warnings.push('部分会话标题暂时无法读取,仍可按时间和会话标识选择');
|
|
461
|
+
if (this.selectedId && this.selectedId !== selectedId && roots.some(record => record.header.id === this.selectedId)) {
|
|
462
|
+
this.queued = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.records = records;
|
|
466
|
+
this.cache = cache;
|
|
467
|
+
this.titles = titles;
|
|
468
|
+
this.selectedId = selectedId;
|
|
469
|
+
this.warnings = [...new Set(warnings)];
|
|
470
|
+
this.memberCount = limited.length;
|
|
471
|
+
this.reused = reused;
|
|
472
|
+
this.lastReadAt = Date.now();
|
|
473
|
+
this.lastReadMs = this.lastReadAt - startedAt;
|
|
474
|
+
this.publish();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async readTitles(roots, cache) {
|
|
478
|
+
const now = Date.now(),
|
|
479
|
+
titles = new Map(),
|
|
480
|
+
pending = [];
|
|
481
|
+
for (const { header } of roots) {
|
|
482
|
+
const previous = this.titles.get(header.id);
|
|
483
|
+
const entry = previous?.createdAt === header.createdAt ? previous : null;
|
|
484
|
+
const loaded = cache.get(header.id);
|
|
485
|
+
if (loaded && Object.hasOwn(loaded, 'title')) {
|
|
486
|
+
// 团队正文已经读取,直接复用标题事件,保证当前会话重命名及时显示。
|
|
487
|
+
titles.set(header.id, { title: loaded.title, checkedAt: now, createdAt: header.createdAt });
|
|
488
|
+
} else {
|
|
489
|
+
if (entry) titles.set(header.id, entry);
|
|
490
|
+
if ((!entry || now - entry.checkedAt >= TITLE_REFRESH_INTERVAL) && typeof this.query.readTitleSnapshots === 'function') pending.push(header);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!pending.length) return titles;
|
|
494
|
+
// 宿主标题投影会读取冷会话;只定期批量查询,避免每秒扫描全部会话正文。
|
|
495
|
+
let results;
|
|
496
|
+
try {
|
|
497
|
+
results = await this.query.readTitleSnapshots(pending.map(header => header.id));
|
|
498
|
+
} catch {
|
|
499
|
+
results = [];
|
|
500
|
+
}
|
|
501
|
+
for (const header of pending) {
|
|
502
|
+
const result = results.find(item => item.sessionId === header.id);
|
|
503
|
+
const fulfilled = result?.status === 'fulfilled';
|
|
504
|
+
titles.set(header.id, {
|
|
505
|
+
title: fulfilled ? short(result.value.title?.title) : titles.get(header.id)?.title || '',
|
|
506
|
+
createdAt: header.createdAt,
|
|
507
|
+
checkedAt: now,
|
|
508
|
+
failed: !fulfilled,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return titles;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
roots() {
|
|
515
|
+
return newestRoots(this.records.values())
|
|
516
|
+
.slice(0, ROOT_LIMIT)
|
|
517
|
+
.map(({ header }) => {
|
|
518
|
+
const agent = this.cache.get(header.id)?.agent;
|
|
519
|
+
return {
|
|
520
|
+
id: header.id,
|
|
521
|
+
title: this.titles.get(header.id)?.title || agent?.task || '',
|
|
522
|
+
cwd: header.cwd || '',
|
|
523
|
+
createdAt: header.createdAt,
|
|
524
|
+
status: agent?.status || 'unknown',
|
|
525
|
+
at: agent?.lastEventAt || header.createdAt,
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
snapshot(rootId) {
|
|
531
|
+
const selected = rootId ? rootOf(rootId, this.records) : this.selectedId;
|
|
532
|
+
const members = selected
|
|
533
|
+
? [...this.records.values()]
|
|
534
|
+
.filter(record => rootOf(record.header.id, this.records) === selected)
|
|
535
|
+
.sort((a, b) => (a.header.id === selected ? -1 : b.header.id === selected ? 1 : 0))
|
|
536
|
+
.slice(0, TEAM_LIMIT)
|
|
537
|
+
: [];
|
|
538
|
+
const entries = members.map(record => this.cache.get(record.header.id) || { agent: newAgent(record.header), events: [] });
|
|
539
|
+
const events = entries.flatMap(entry => entry.events).sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
|
|
540
|
+
return {
|
|
541
|
+
mode: 'live',
|
|
542
|
+
source: 'harness',
|
|
543
|
+
provider: 'dsh',
|
|
544
|
+
revision: this.revision,
|
|
545
|
+
rootId: selected || null,
|
|
546
|
+
roots: this.roots(),
|
|
547
|
+
agents: entries.map(({ agent }) => ({
|
|
548
|
+
...agent,
|
|
549
|
+
rootId: selected,
|
|
550
|
+
reports: [...agent.reports],
|
|
551
|
+
events: events
|
|
552
|
+
.filter(event => event.sessionId === agent.id || event.targetId === agent.id)
|
|
553
|
+
.slice(-80)
|
|
554
|
+
.map(event => ({ ...event })),
|
|
555
|
+
})),
|
|
556
|
+
events: events.slice(-160).map(event => ({ ...event })),
|
|
557
|
+
totalEvents: events.length,
|
|
558
|
+
warnings: [...this.warnings],
|
|
559
|
+
updatedAt: Date.now(),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
publish() {
|
|
564
|
+
const snapshot = this.snapshot();
|
|
565
|
+
const signature = JSON.stringify({ ...snapshot, revision: 0, updatedAt: 0 });
|
|
566
|
+
if (signature === this.signature) return;
|
|
567
|
+
this.signature = signature;
|
|
568
|
+
this.revision++;
|
|
569
|
+
this.emit('change');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async fullText(sessionId, id) {
|
|
573
|
+
const ref = [...this.cache.values()].map(entry => entry.refs.get(id)).find(value => value?.sessionId === sessionId);
|
|
574
|
+
if (!ref || this.stopped) return null;
|
|
575
|
+
const epoch = this.epoch;
|
|
576
|
+
const { target } = await this.query.readEvent({ sessionId: ref.owner, seq: ref.seq, before: 0, after: 0 });
|
|
577
|
+
if (this.stopped || epoch !== this.epoch) return null;
|
|
578
|
+
const text = target?.seq === ref.seq ? visibleText(target, ref.kind) : '';
|
|
579
|
+
if (digest(text) !== ref.hash) throw new Error('原始记录已改变,请刷新后重试');
|
|
580
|
+
return { id, sessionId, type: ref.type, text };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
module.exports = { HarnessWatcher };
|