@240xu/dsh-message-ops 0.5.1 → 0.5.7
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/README.md +71 -0
- package/package.json +1 -1
- package/src/client.js +82 -26
- package/src/index.js +253 -35
- package/src/ops-core.js +142 -11
- package/test/ops-core.test.js +149 -4
package/README.md
CHANGED
|
@@ -243,3 +243,74 @@ MIT
|
|
|
243
243
|
- 0.4.2 的 dock 重写误删了 `INPUT_DOCK_SLOT/ID` 常量声明——apply() 引用未定义
|
|
244
244
|
标识符导致**整个客户端激活失败**(页面横幅 Failed to load plugins)。
|
|
245
245
|
已恢复常量;新增 vm 冒烟自测路径(见 .e2e/)。
|
|
246
|
+
|
|
247
|
+
## 0.5.2 · Bug 猎场第一轮修复(P0×1 + P1×3 + P2×6)
|
|
248
|
+
|
|
249
|
+
- **[P0] activeMarkers 被 0.5.0 误删**(与 INPUT_DOCK 同一 commit,0.5.1 只找回常量):
|
|
250
|
+
dock 100% 静默失效(ReferenceError 被 .catch 吞掉)。已恢复函数 + vm 冒烟守卫。
|
|
251
|
+
- **[P1] 工具层未 await async ops**(0.2.1 改 async 后漏同步化):list/restore 必抛
|
|
252
|
+
TypeError、export 渲出空 text。switch 全部 await + 回归测试(异步桩)。
|
|
253
|
+
- **[P1] 引用空块**:assistant 消息 fullText 恒 null,回退 `''` 通过 null 检查 →
|
|
254
|
+
插入空 `> ` 块并报成功。改为按 seq 走新端点 `GET /text` 取单条全文,空串按不可用处理。
|
|
255
|
+
- **[P1] 侧栏菜单卡死**:dispatch 缺 sessionId → 对话框永久「正在读取…」。
|
|
256
|
+
从 data-row-key 提取 sessionId 一并派发。
|
|
257
|
+
- **[P2] seq 索引缓存投毒**:失败必须 `delete`,否则该会话按钮/dock 到刷新前全失效。
|
|
258
|
+
- **[P2] 索引口径**:同 id 取最早 seq(对齐服务端 buildSeqIndex,不再内联取最大)。
|
|
259
|
+
- **[P2] branch 工具渲染**:读 keptEvents/parentSession(原读不存在的 kept/parentId)。
|
|
260
|
+
- **[P2] dock 标题口径**:改报标记 range 内被遮蔽消息数(原报标记数,严重低报)。
|
|
261
|
+
- **[P2] busy 卡死**:runRevert/restoreRow/对话框 run 全部 `Promise.resolve()` 包裹——
|
|
262
|
+
同步抛出也落进 finally;restoreRow 补 finally 双保险。
|
|
263
|
+
- **[P2] dock 陈旧**:新增 `dsh-message-ops:changed` 广播,回滚/恢复成功后 dock 自动重拉。
|
|
264
|
+
- **[P3] 删 en 独有死键 dock.shadowedN;GET 端点补 405 方法校验。**
|
|
265
|
+
|
|
266
|
+
### 0.5.3 · 「打开即 Running」误报修复(实机 P0 级)
|
|
267
|
+
|
|
268
|
+
- isRunning 原查 agents 注册表——会话**在视图中打开**即有条目 → 打开即报
|
|
269
|
+
running → 回撤/删除/分支全部 409 锁死(「按钮没有用」的又一层根因,Playwright
|
|
270
|
+
闭环 e2e 实机抓到:curl 未打开时 false、UI 打开后恒 true)。
|
|
271
|
+
- 改读 `sessions.list.getSnapshot().byId[].running`(host-asserted,与官方侧栏
|
|
272
|
+
spinner、官方分支按钮 disabled 同源);老宿主回退 agents 注册表。
|
|
273
|
+
|
|
274
|
+
### 0.5.4 · 「点回撤打死整个 dsh」P0 修复(包装实测 exit=1 定位)
|
|
275
|
+
|
|
276
|
+
- **根因**:dsh 0.2.0 收紧 v4 行准入(`assertV4SystemMessageFields`)——每个
|
|
277
|
+
`system/message` 的 `data` 必须带**正整数 `turn`/`step`**。回滚/删除/恢复的通知
|
|
278
|
+
事件没带 → 持久层 `encodeEventBatch` 抛出的 SessionFormatError **没有任何层捕获
|
|
279
|
+
→ 整个 dsh 进程退出**(浏览器端表现为「Failed to fetch」,一切 revert 都静默失败;
|
|
280
|
+
此前多次「实例无故死亡」全部是它)。
|
|
281
|
+
- **修复**:新增 `deriveTurnStep(events)`(尾部回溯最近正坐标,兜底 1/1);
|
|
282
|
+
revert/delete 通知与 restore 通知全部附带坐标;`opsCommit` 改 async 读日志派生,
|
|
283
|
+
HTTP/tool 调用点已 await。回归测试断言三处 append 的 data.turn/step 均为正整数。
|
|
284
|
+
- 教训:宿主持久层对未知字段的编码失败会杀进程(记录于 hub——上游应给
|
|
285
|
+
encodeEventBatch 加错误边界),插件侧必须严格遵守 v4 行结构。
|
|
286
|
+
|
|
287
|
+
### 0.5.5 · v4 准入第 3 关:`message.id` 非空字符串
|
|
288
|
+
|
|
289
|
+
- 0.5.4 补 turn/step 后实机复测仍崩:`assertV4SystemMessageFields` 继续要求
|
|
290
|
+
`message.id` 为非空 string(`string(x, name, nonempty=true)`)。两处 notice
|
|
291
|
+
append 的 message.id 现用 `randomUUID()`;回归测试断言三段(turn/step/id)。
|
|
292
|
+
- 行准入全貌(本轮实测三连击):① `data.turn/step` 正整数 → ② `message.id`
|
|
293
|
+
非空字符串 → ③ role=system + content 数组 + block 文本串。
|
|
294
|
+
|
|
295
|
+
### 0.5.6 · dock 死按钮修复:空可重放区间改优雅停用
|
|
296
|
+
|
|
297
|
+
- 实机闭环:dock 上标记若遮蔽区间只含 tool/system 事件(或遮蔽了另一个标记),
|
|
298
|
+
恢复请求恒 409「no replayable user/assistant messages」——按钮永久死、dock
|
|
299
|
+
计数永远清不掉(Playwright 8 连 409 复现)。
|
|
300
|
+
- 改为:planRestore 对空可重放返回空计划;applyRestore 追加**停用 notice**
|
|
301
|
+
(含 restoresSeq → dock 移除该行)+ 如实文案「区间无可重放内容」。
|
|
302
|
+
非标记/越界/非法 seq 仍按原语义 404/409/400。
|
|
303
|
+
|
|
304
|
+
### 0.5.7 · 第四层根因:store-miss 会话的磁盘追加路径
|
|
305
|
+
|
|
306
|
+
- **实机链**:lazy-view 让会话只在磁盘渲染、不进对象层(视图开着 dock 已渲染,
|
|
307
|
+
但 `sessions.get(id)` 双变体 miss、`list()` 里只有别的会话)→ 变更操作 404。
|
|
308
|
+
官方服务端**没有** retain/using 面(那是浏览器侧 ClientSessions 的)。
|
|
309
|
+
- **修复**:store-miss 时改为**直接向日志追加 zstd 帧**(等价持久层
|
|
310
|
+
`appendLines`:encode → open('a') → write+sync+按 size 回滚),事件字段逐项
|
|
311
|
+
镜像引擎金标准(根键序 `type/seq/time/data/sourceEventSeqs/surfaceOp`;
|
|
312
|
+
restore notice `restoresSeq` + `surfaceOp:"append"`;重放无 id)。
|
|
313
|
+
磁盘会话的视图/搜索/dock 全部按帧重读 → 追加即可见;成功后客户端
|
|
314
|
+
openSession 强制重建视图(磁盘追加无 live 投影事件)。
|
|
315
|
+
- live 会话(对象层在册)仍走引擎路径;两条路径共用 turn/step/id 派生与
|
|
316
|
+
空区间停用语义。新回归测试断言可见节点/计划语义/字段镜像(44/44)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@240xu/dsh-message-ops",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Roll back, branch, and export any DSH conversation — from the UI or straight from the agent via a message_ops tool. Non-destructive: logs stay append-only and recoverable. 消息回滚/删除/分支/导出四合一,内置 message_ops agent 工具;非破坏语义,日志只追加可恢复。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/client.js
CHANGED
|
@@ -26,6 +26,9 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
const INPUT_DOCK_ID = 'message-ops-revert-dock'
|
|
27
27
|
const DIALOG_ID = 'message-ops-dialog'
|
|
28
28
|
const EVENT = 'dsh-message-ops:open'
|
|
29
|
+
// 0.5.2(P2):回滚/恢复成功后广播 → RevertDock 重拉标记(否则 dock 只在切会话时刷新)
|
|
30
|
+
const CHANGED_EVENT = 'dsh-message-ops:changed'
|
|
31
|
+
const emitChanged = () => { try { window.dispatchEvent(new CustomEvent(CHANGED_EVENT)) } catch { /* noop */ } }
|
|
29
32
|
const NS = 'dsh-message-ops'
|
|
30
33
|
|
|
31
34
|
const zhDict = {
|
|
@@ -386,11 +389,12 @@ window.__ModuleLoader__.load({
|
|
|
386
389
|
}
|
|
387
390
|
const path = mode === 'restore' ? 'restore' : mode
|
|
388
391
|
const body = { sessionId: target.sessionId, seq: picked }
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
Promise.resolve()
|
|
393
|
+
.then(() => fetch('/api/message-ops/' + path, {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers: { 'content-type': 'application/json' },
|
|
396
|
+
body: JSON.stringify(body),
|
|
397
|
+
}))
|
|
394
398
|
.then(async (res) => {
|
|
395
399
|
let data = {}
|
|
396
400
|
try { data = await res.json() } catch { /* keep {} */ }
|
|
@@ -410,6 +414,12 @@ window.__ModuleLoader__.load({
|
|
|
410
414
|
setBusyMsg('')
|
|
411
415
|
setState('done')
|
|
412
416
|
notifyDone(okMsg)
|
|
417
|
+
emitChanged()
|
|
418
|
+
// 0.5.7:store-miss 的磁盘追加没有 live 投影事件 —— 成功后强制
|
|
419
|
+
// 重建视图(与 restoreRow 同款 openSession 模式),遮蔽即刻可见。
|
|
420
|
+
try {
|
|
421
|
+
if (__uiWorkspace && typeof __uiWorkspace.openSession === 'function') __uiWorkspace.openSession(target.sessionId)
|
|
422
|
+
} catch { /* 视图重建失败不阻断成功反馈 */ }
|
|
413
423
|
})
|
|
414
424
|
.catch((reason) => {
|
|
415
425
|
setBusyMsg('')
|
|
@@ -641,12 +651,14 @@ window.__ModuleLoader__.load({
|
|
|
641
651
|
else if (cached) resolve(cached)
|
|
642
652
|
else {
|
|
643
653
|
const p = fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
|
|
644
|
-
.then((r) => (r.ok ? r.json() :
|
|
654
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
|
|
645
655
|
.then((data) => {
|
|
646
656
|
const index = new Map()
|
|
647
657
|
const full = new Map()
|
|
648
658
|
for (const m of (data && data.messages) || []) {
|
|
649
|
-
|
|
659
|
+
// 0.5.2(P2):同 id 取**最早** seq——与服务端 buildSeqIndex 口径一致
|
|
660
|
+
// (重试链取最早可见节点;此前内联 set 覆盖=取最大,双实现分裂)
|
|
661
|
+
if (m && m.id != null && !index.has(String(m.id))) index.set(String(m.id), m.seq)
|
|
650
662
|
if (m && m.id != null && m.fullText) full.set(String(m.id), m.fullText)
|
|
651
663
|
}
|
|
652
664
|
__seqIndexCache.set(sessionId, index)
|
|
@@ -654,7 +666,9 @@ window.__ModuleLoader__.load({
|
|
|
654
666
|
return index
|
|
655
667
|
})
|
|
656
668
|
__seqIndexCache.set(sessionId, p)
|
|
657
|
-
|
|
669
|
+
// 0.5.2(P2):失败必须清缓存——否则 rejected promise 被永久缓存,
|
|
670
|
+
// 该会话按钮/dock 到刷新页面为止全部失效
|
|
671
|
+
p.then(resolve).catch(() => { __seqIndexCache.delete(sessionId) })
|
|
658
672
|
}
|
|
659
673
|
return () => { alive = false }
|
|
660
674
|
}, [messageId, sessionId])
|
|
@@ -689,16 +703,20 @@ window.__ModuleLoader__.load({
|
|
|
689
703
|
const runRevert = () => {
|
|
690
704
|
if (busy || seq == null || running) return
|
|
691
705
|
setBusy('revert')
|
|
692
|
-
fetch
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
706
|
+
// 0.5.2(P2):Promise.resolve 包裹——fetch/stringify 同步抛出也转为
|
|
707
|
+
// rejection 落进 finally,busy 永不卡死
|
|
708
|
+
Promise.resolve()
|
|
709
|
+
.then(() => fetch('/api/message-ops/revert', {
|
|
710
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
711
|
+
body: JSON.stringify({ sessionId, seq }),
|
|
712
|
+
}))
|
|
696
713
|
.then(async (res) => {
|
|
697
714
|
let data = {}
|
|
698
715
|
try { data = await res.json() } catch { /* keep {} */ }
|
|
699
716
|
if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
|
|
700
717
|
notifyDone(t('done.revert'))
|
|
701
718
|
revealSession()
|
|
719
|
+
emitChanged()
|
|
702
720
|
})
|
|
703
721
|
.catch((reason) => {
|
|
704
722
|
notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
|
|
@@ -714,11 +732,18 @@ window.__ModuleLoader__.load({
|
|
|
714
732
|
return
|
|
715
733
|
}
|
|
716
734
|
const span = inputActions.captureInsertion()
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
735
|
+
const insertQuote = (text) => {
|
|
736
|
+
if (text == null || text === '') { notifyDone(t('quote.unavailable'), 'warn'); return }
|
|
737
|
+
const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
|
|
738
|
+
const ok = inputActions.insertText(quoted, span)
|
|
739
|
+
notifyDone(ok ? t('quote.done') : t('quote.unavailable'), ok ? 'ok' : 'warn')
|
|
740
|
+
}
|
|
741
|
+
// assistant 消息 fullText 恒 null(列表不携带)→ 按 seq 向服务端取单条全文
|
|
742
|
+
if (fullText != null && fullText !== '') { insertQuote(fullText); return }
|
|
743
|
+
fetch('/api/message-ops/text?sessionId=' + encodeURIComponent(sessionId) + '&seq=' + encodeURIComponent(seq))
|
|
744
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
745
|
+
.then((d) => insertQuote(d && d.ok && typeof d.text === 'string' ? d.text : null))
|
|
746
|
+
.catch(() => insertQuote(null))
|
|
722
747
|
} catch (e) {
|
|
723
748
|
notifyDone(t('errorPrefix') + (e && e.message ? e.message : String(e)), 'error')
|
|
724
749
|
}
|
|
@@ -740,6 +765,16 @@ window.__ModuleLoader__.load({
|
|
|
740
765
|
)
|
|
741
766
|
}
|
|
742
767
|
|
|
768
|
+
// 0.4.2 引入(恢复的标记自动离开活跃列表 = opencode clear 语义)。
|
|
769
|
+
// 0.5.0 脚本化编辑曾误删本函数 → dock 100% 静默失效(P0,0.5.2 恢复)。
|
|
770
|
+
function activeMarkers(msgs) {
|
|
771
|
+
const restored = new Set()
|
|
772
|
+
for (const m of msgs || []) {
|
|
773
|
+
if (m && typeof m.restoresSeq === 'number') restored.add(m.restoresSeq)
|
|
774
|
+
}
|
|
775
|
+
return (msgs || []).filter((m) => m.marker && m.sourceKind !== 'compact-checkpoint' && !restored.has(m.seq))
|
|
776
|
+
}
|
|
777
|
+
|
|
743
778
|
function RevertDock(props) {
|
|
744
779
|
const { sessionId, useSessions, inputActions } = props
|
|
745
780
|
const t = (props && props.t) || __t
|
|
@@ -750,6 +785,7 @@ window.__ModuleLoader__.load({
|
|
|
750
785
|
const running = summary ? summary.running === true : false
|
|
751
786
|
const [open, setOpen] = useState(false)
|
|
752
787
|
const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
|
|
788
|
+
const [shadowCount, setShadowCount] = useState(0) // 标记 range 内被遮蔽消息数(标题口径)
|
|
753
789
|
const [restoring, setRestoring] = useState(null)
|
|
754
790
|
const [styleInjected, setStyleInjected] = useState(false)
|
|
755
791
|
|
|
@@ -784,15 +820,28 @@ window.__ModuleLoader__.load({
|
|
|
784
820
|
|
|
785
821
|
useEffect(() => {
|
|
786
822
|
let alive = true
|
|
823
|
+
const onChange = () => load()
|
|
824
|
+
window.addEventListener(CHANGED_EVENT, onChange)
|
|
787
825
|
const load = () => fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
|
|
788
826
|
.then((r) => (r.ok ? r.json() : null))
|
|
789
827
|
.then((data) => {
|
|
790
828
|
if (!alive || !data || !data.ok) return
|
|
791
|
-
|
|
829
|
+
const msgs = data.messages || []
|
|
830
|
+
setMarkers(activeMarkers(msgs))
|
|
831
|
+
// 0.5.2(P2):标题口径 = 标记 range 内 visible=false 的去重 seq 数
|
|
832
|
+
const seen = new Set()
|
|
833
|
+
for (const mk of activeMarkers(msgs)) {
|
|
834
|
+
const start = mk.range && typeof mk.range.start === 'number' ? mk.range.start : mk.seq
|
|
835
|
+
const end = mk.range && typeof mk.range.end === 'number' ? mk.range.end : start
|
|
836
|
+
for (const m of msgs) {
|
|
837
|
+
if (m && m.visible === false && typeof m.seq === 'number' && m.seq >= start && m.seq <= end) seen.add(m.seq)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
setShadowCount(seen.size)
|
|
792
841
|
})
|
|
793
842
|
.catch(() => {})
|
|
794
843
|
load()
|
|
795
|
-
return () => { alive = false }
|
|
844
|
+
return () => { alive = false; window.removeEventListener(CHANGED_EVENT, onChange) }
|
|
796
845
|
}, [sessionId])
|
|
797
846
|
|
|
798
847
|
// items 变化自动折叠(opencode createEffect 同款;必须在早退之前——Hooks 规则)
|
|
@@ -802,10 +851,11 @@ window.__ModuleLoader__.load({
|
|
|
802
851
|
const restoreRow = (row) => {
|
|
803
852
|
if (restoring != null || running) return
|
|
804
853
|
setRestoring(row.seq)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
854
|
+
Promise.resolve()
|
|
855
|
+
.then(() => fetch('/api/message-ops/restore', {
|
|
856
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
857
|
+
body: JSON.stringify({ sessionId, seq: row.seq }),
|
|
858
|
+
}))
|
|
809
859
|
.then(async (res) => {
|
|
810
860
|
let data = {}
|
|
811
861
|
try { data = await res.json() } catch { /* keep {} */ }
|
|
@@ -821,13 +871,15 @@ window.__ModuleLoader__.load({
|
|
|
821
871
|
})
|
|
822
872
|
.catch((reason) => {
|
|
823
873
|
notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
|
|
824
|
-
setRestoring(null)
|
|
825
874
|
})
|
|
875
|
+
.finally(() => setRestoring(null))
|
|
826
876
|
}
|
|
827
877
|
|
|
828
878
|
|
|
829
879
|
const headerKbd = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v) } }
|
|
830
|
-
|
|
880
|
+
// 0.5.2(P2):标题报「被遮蔽消息数」而非标记数——一个 revert 可遮蔽数百条,
|
|
881
|
+
// 用标记数会严重低报(0.4.2 文案引入的错口径);无 range 数据时回退标记数
|
|
882
|
+
const label = t('dock.title', { n: String(shadowCount || markers.length) })
|
|
831
883
|
const preview = markers[0] && markers[0].snippet ? markers[0].snippet : ''
|
|
832
884
|
|
|
833
885
|
const rowEl = (row) => React.createElement('div', { key: row.seq, className: 'mopsRdRow' },
|
|
@@ -899,7 +951,11 @@ window.__ModuleLoader__.load({
|
|
|
899
951
|
var titleEl = row.querySelector('[class*=title]')
|
|
900
952
|
var title = titleEl ? String(titleEl.innerText || '').trim() : ''
|
|
901
953
|
if (!title) return
|
|
902
|
-
|
|
954
|
+
// 0.5.2(P1):必须带 sessionId——否则对话框加载 effect 早退,永久卡「正在读取…」
|
|
955
|
+
var sid = ''
|
|
956
|
+
var rk = row.getAttribute && row.getAttribute('data-row-key')
|
|
957
|
+
if (rk && rk.indexOf('session:') === 0) sid = rk.slice(8)
|
|
958
|
+
window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title, sessionId: sid || undefined } }))
|
|
903
959
|
})
|
|
904
960
|
var sep = document.createElement('div')
|
|
905
961
|
sep.style.cssText = 'height:1px;margin:4px 8px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.2))'
|
package/src/index.js
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
* @module dsh-message-ops
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { zstdCompressSync } from "node:zlib";
|
|
24
|
+
import { open as openFile, truncate as truncateFile, stat as statFile } from "node:fs/promises";
|
|
25
|
+
import { isSessionId, sessionIdVariants, findSessionDirs, readSessionFile, readSessionFileAsync, listMessages, computeShadowed, messageText } from "./session-file.js";
|
|
24
26
|
import { applyBranch } from "./branch.js";
|
|
25
|
-
import {
|
|
27
|
+
import { restoreNoticeText, planRevertFromEvents, planDeleteFromEvents, nextSeqFrom, buildMarkerEvent, buildRestoreNoticeEvent, buildReplayEvent, deriveTurnStep,
|
|
26
28
|
OpsError, planRevert, planDelete, planRestore, applyRestore, applySurfaceReplace, exportMarkdown,
|
|
27
29
|
isTrustedApiRequest, isJsonContentType,
|
|
28
30
|
} from "./ops-core.js";
|
|
@@ -83,26 +85,151 @@ async function readFencedBody(req, res) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/** 从 sessions 注册表解析(两种 id 拼写都试)。 */
|
|
88
|
+
let __resolveSource = null; // 诊断:最近一次 resolveSession 的失败原因
|
|
86
89
|
function resolveSession(ctx, sessionId) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// 宿主自己的取法是 `this.ctx.sessions.get(id)`(api-session-controller 同款);
|
|
91
|
+
// 实测 `ctx.get("sessions")` 在 inject 子 fiber 下解析到的注册表**查不到视图已
|
|
92
|
+
// 加载的会话**(dock 已渲染但 get 双变体 miss —— agents.get 同 ctx 却命中),
|
|
93
|
+
// 故属性优先、ctx.get 兜底,两处都试全变体。
|
|
94
|
+
const candidates = [];
|
|
95
|
+
try { if (ctx && ctx.sessions) candidates.push(["prop", ctx.sessions]); } catch { /* no proxy */ }
|
|
96
|
+
try { const svc = ctx.get("sessions"); if (svc) candidates.push(["get", svc]); } catch { /* absent */ }
|
|
97
|
+
if (candidates.length === 0) { __resolveSource = "no-sessions-service"; return undefined; }
|
|
98
|
+
const variants = sessionIdVariants(sessionId);
|
|
99
|
+
const tried = [];
|
|
100
|
+
for (const [src, svc] of candidates) {
|
|
101
|
+
if (typeof svc.get !== "function") { tried.push(src + ":no-get"); continue; }
|
|
102
|
+
for (const variant of variants) {
|
|
103
|
+
const s = svc.get(variant);
|
|
104
|
+
if (s !== undefined) { __resolveSource = "hit via " + src + ".get " + String(variant).slice(0, 24); return s; }
|
|
105
|
+
}
|
|
106
|
+
// get 键错位兜底:list() 扫描(集群 face 的 list() 返回 live 实例数组,
|
|
107
|
+
// 实测 get 双变体 miss 时 list 里可能有 —— 按 id/sessionId 匹配)
|
|
108
|
+
if (typeof svc.list === "function") {
|
|
109
|
+
try {
|
|
110
|
+
const arr = svc.list();
|
|
111
|
+
if (Array.isArray(arr)) {
|
|
112
|
+
const hit = arr.find((x) => x && (variants.includes(x.id) || variants.includes(x.sessionId)));
|
|
113
|
+
if (hit) { __resolveSource = "hit via " + src + ".list() n=" + arr.length; return hit; }
|
|
114
|
+
tried.push(src + ":list-n=" + arr.length + " ids=" + arr.slice(0, 3).map((x) => String(x && (x.id || x.sessionId) || "?").slice(0, 18)).join("|"));
|
|
115
|
+
} else {
|
|
116
|
+
tried.push(src + ":list-" + typeof arr);
|
|
117
|
+
}
|
|
118
|
+
} catch (e) { tried.push(src + ":list-threw " + String(e && e.message).slice(0, 30)); }
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
tried.push(src + ":miss");
|
|
92
122
|
}
|
|
123
|
+
__resolveSource = tried.join(",") + " tried=" + variants.length + " first=" + String(variants[0]).slice(0, 30);
|
|
93
124
|
return undefined;
|
|
94
125
|
}
|
|
95
126
|
|
|
96
127
|
/** 是否有 live agent 正占用该会话。 */
|
|
128
|
+
/**
|
|
129
|
+
* acquireSession:拿到 live SessionFace(带 .surface/.append)并在用完后释放。
|
|
130
|
+
*
|
|
131
|
+
* 0.5.6(实机第四层根因):lazy-view 让会话**只在磁盘渲染、不进对象层**——
|
|
132
|
+
* 视图开着(dock 已渲染)但 sessions.get(id) 双变体全 miss、list() 里只有别的
|
|
133
|
+
* 会话 → 所有变更操作 404「not found in registry」。官方越界获取通道是
|
|
134
|
+
* retain(target, {source}) → await ready → binding.session(ISessions 契约);
|
|
135
|
+
* 引用在 mutation 完成后 release(final reference 触发 teardown,须先 flush)。
|
|
136
|
+
*/
|
|
137
|
+
async function acquireSession(ctx, sessionId) {
|
|
138
|
+
const direct = resolveSession(ctx, sessionId);
|
|
139
|
+
if (direct !== undefined) return { session: direct, release() {} };
|
|
140
|
+
const owners = [];
|
|
141
|
+
// reflect 层:api-session-controller 把 ISessions(ClientSessions,带 retain)经
|
|
142
|
+
// `rootCtx.reflect.provide("sessions", this)` 挂在根上;普通 ctx.get 取到的是
|
|
143
|
+
// 近端同名对象层(get/list 有、retain 无)——必须走 reflect.get 才是契约面。
|
|
144
|
+
try { if (ctx && ctx.reflect && typeof ctx.reflect.get === "function") owners.push(["reflect", ctx.reflect.get("sessions", false)]); } catch { /* not provided */ }
|
|
145
|
+
try { if (ctx && ctx.sessions) owners.push(["prop", ctx.sessions]); } catch { /* getter threw */ }
|
|
146
|
+
try { const svc = ctx.get("sessions"); if (svc) owners.push(["get", svc]); } catch { /* absent */ }
|
|
147
|
+
const holders = owners.filter(([, o]) => o && typeof o.retain === "function");
|
|
148
|
+
if (holders.length === 0) {
|
|
149
|
+
const why = owners.length === 0
|
|
150
|
+
? "no-owner (prop-absent && get-absent)"
|
|
151
|
+
: owners.map(([src, o]) => src + ":no-retain(" + (o == null ? "null" : typeof o) + ")").join(",");
|
|
152
|
+
__resolveSource = (__resolveSource || "") + " | ISessions:" + why;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const isess = holders[0][1];
|
|
156
|
+
let ref = null;
|
|
157
|
+
try {
|
|
158
|
+
ref = isess.retain(sessionId, { source: "controllerOperation" });
|
|
159
|
+
const binding = await ref.ready;
|
|
160
|
+
const face = binding && binding.session;
|
|
161
|
+
if (!face) { __resolveSource = "retain-ready-no-session-face"; try { ref.release(); } catch { /* */ } return null; }
|
|
162
|
+
__resolveSource = "acquired via ISessions.retain";
|
|
163
|
+
return { session: face, release: () => { try { ref.release(); } catch { /* already released */ } } };
|
|
164
|
+
} catch (e) {
|
|
165
|
+
__resolveSource = "retain-failed: " + String(e && e.message).slice(0, 80);
|
|
166
|
+
if (ref) { try { ref.release(); } catch { /* */ } }
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let __runningSource = null; // 诊断:最近一次 isRunning 的判定来源
|
|
97
172
|
function isRunning(ctx, sessionId) {
|
|
98
|
-
|
|
99
|
-
|
|
173
|
+
// 0.5.4(实机三层根因第 3 层的最终修):与宿主**官方同源**——
|
|
174
|
+
// dsh-api-session-controller/lib/index.js ApiSessionList.summaryFor():
|
|
175
|
+
// running: this.ctx.agents.get(session.id)?.status === "running"
|
|
176
|
+
// 原实现把「agents 注册表有条目」直接当 running —— 会话仅在视图中打开
|
|
177
|
+
// 就有条目 → 打开即 409 锁死所有变更操作。必须再比对 status。
|
|
100
178
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
179
|
+
const candidates = [];
|
|
180
|
+
try { if (ctx && ctx.agents) candidates.push(ctx.agents); } catch { /* no proxy */ }
|
|
181
|
+
try { const svc = ctx.get("agents"); if (svc) candidates.push(svc); } catch { /* absent */ }
|
|
182
|
+
if (candidates.length === 0) { __runningSource = "no-agents-service"; return false; }
|
|
183
|
+
for (const svc of candidates) {
|
|
184
|
+
if (typeof svc.get !== "function") continue;
|
|
185
|
+
for (const variant of sessionIdVariants(sessionId)) {
|
|
186
|
+
const agent = svc.get(variant);
|
|
187
|
+
if (agent && agent.status === "running") {
|
|
188
|
+
__runningSource = "agent-status-running";
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (agent) __runningSource = "agent-status:" + String(agent.status);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!__runningSource) __runningSource = "no-agent-entry";
|
|
195
|
+
return false;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
__runningSource = "threw:" + String(e && e.message).slice(0, 50);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* diskAppend:把新事件作为一个 zstd 帧追加到会话日志(等价持久层 appendLines:
|
|
206
|
+
* encode → open('a') → write+sync,失败按持久层同款按 size 回滚)。
|
|
207
|
+
* 仅用于 store-miss 的 lazy-view 会话——无并发生命周期游标可竞争。
|
|
208
|
+
*/
|
|
209
|
+
async function diskAppend(logPath, newEvents) {
|
|
210
|
+
if (!Array.isArray(newEvents) || newEvents.length === 0) throw new OpsError("diskAppend: empty batch", 400);
|
|
211
|
+
const jsonl = newEvents.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
212
|
+
const frame = zstdCompressSync(Buffer.from(jsonl, "utf8"));
|
|
213
|
+
const handle = await openFile(logPath, "a");
|
|
214
|
+
let closed = false;
|
|
215
|
+
const closeHandle = async () => { if (closed) return; closed = true; await handle.close(); };
|
|
216
|
+
try {
|
|
217
|
+
const { size: before } = await statFile(logPath);
|
|
218
|
+
try {
|
|
219
|
+
await handle.writeFile(frame);
|
|
220
|
+
await handle.sync();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
try {
|
|
223
|
+
await closeHandle();
|
|
224
|
+
await truncateFile(logPath, before);
|
|
225
|
+
} catch (rollbackError) {
|
|
226
|
+
throw new AggregateError([error, rollbackError], `failed to roll back append to "${logPath}"`);
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
103
229
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
230
|
+
} finally {
|
|
231
|
+
await closeHandle();
|
|
232
|
+
}
|
|
106
233
|
}
|
|
107
234
|
|
|
108
235
|
function flushSessions(ctx, session) {
|
|
@@ -143,28 +270,56 @@ async function opsList(targetCtx, sessionId) {
|
|
|
143
270
|
ok: true,
|
|
144
271
|
session: { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession ?? null },
|
|
145
272
|
running: isRunning(targetCtx, sessionId),
|
|
273
|
+
runningSource: __runningSource,
|
|
146
274
|
total: messages.length,
|
|
147
275
|
visibleCount,
|
|
148
276
|
messages,
|
|
149
277
|
};
|
|
150
278
|
}
|
|
151
279
|
|
|
152
|
-
function opsCommit(targetCtx, mode, sessionId, seq) {
|
|
280
|
+
async function opsCommit(targetCtx, mode, sessionId, seq) {
|
|
153
281
|
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
154
282
|
if (!Number.isSafeInteger(seq) || seq < 0) throw new OpsError("invalid seq", 400);
|
|
155
283
|
if (isRunning(targetCtx, sessionId)) throw new OpsError("session is running; stop it first", 409);
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
284
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
285
|
+
const { events } = await readSessionFileAsync(logPath);
|
|
286
|
+
const turnStep = deriveTurnStep(events);
|
|
159
287
|
const notice = mode === "delete"
|
|
160
288
|
? `[消息删除] 已遮蔽 seq ${seq}`
|
|
289
|
+
: null; // revert 文案含 shadowed 数量,规划后拼
|
|
290
|
+
const acq = await acquireSession(targetCtx, sessionId);
|
|
291
|
+
if (acq !== null && acq.session !== undefined) {
|
|
292
|
+
try {
|
|
293
|
+
const session = acq.session;
|
|
294
|
+
// 0.5.4(P0):v4 准入要求 system/message 带正 turn/step——缺字段时持久层
|
|
295
|
+
// encodeEventBatch 的 SessionFormatError 未捕获会**打死整个 dsh 进程**。
|
|
296
|
+
const plan = mode === "delete" ? planDelete(session.surface, seq) : planRevert(session.surface, seq);
|
|
297
|
+
const text = mode === "delete" ? notice
|
|
298
|
+
: `[消息回滚] 已回滚到 seq ${seq}(含)之后的 ${plan.shadowedSeqs.length} 个节点`;
|
|
299
|
+
const event = applySurfaceReplace(session, plan.startSeq, plan.endSeq, plan.shadowedSeqs, text, turnStep);
|
|
300
|
+
flushSessions(targetCtx, session);
|
|
301
|
+
return {
|
|
302
|
+
ok: true, mode, seq,
|
|
303
|
+
shadowedCount: plan.shadowedSeqs.length,
|
|
304
|
+
eventSeq: event && event.seq != null ? event.seq : null,
|
|
305
|
+
};
|
|
306
|
+
} finally {
|
|
307
|
+
acq.release();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// store-miss(lazy-view 只读盘会话)→ 磁盘路径:镜像引擎字段直接追加 zstd 帧
|
|
311
|
+
const plan = mode === "delete" ? planDeleteFromEvents(events, seq) : planRevertFromEvents(events, seq);
|
|
312
|
+
const text = mode === "delete" ? notice
|
|
161
313
|
: `[消息回滚] 已回滚到 seq ${seq}(含)之后的 ${plan.shadowedSeqs.length} 个节点`;
|
|
162
|
-
const
|
|
163
|
-
|
|
314
|
+
const marker = buildMarkerEvent({
|
|
315
|
+
seq: nextSeqFrom(events), time: Date.now(), turnStep, text,
|
|
316
|
+
startSeq: plan.startSeq, endSeq: plan.endSeq, shadowedSeqs: plan.shadowedSeqs,
|
|
317
|
+
});
|
|
318
|
+
await diskAppend(logPath, [marker]);
|
|
164
319
|
return {
|
|
165
|
-
ok: true, mode, seq,
|
|
320
|
+
ok: true, mode, seq, disk: true,
|
|
166
321
|
shadowedCount: plan.shadowedSeqs.length,
|
|
167
|
-
eventSeq:
|
|
322
|
+
eventSeq: marker.seq,
|
|
168
323
|
};
|
|
169
324
|
}
|
|
170
325
|
|
|
@@ -182,15 +337,38 @@ async function opsRestore(targetCtx, sessionId, restoreSeq) {
|
|
|
182
337
|
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
183
338
|
if (!Number.isSafeInteger(restoreSeq) || restoreSeq < 0) throw new OpsError("invalid seq", 400);
|
|
184
339
|
if (isRunning(targetCtx, sessionId)) throw new OpsError("session is running; stop it first", 409);
|
|
185
|
-
const session = resolveSession(targetCtx, sessionId);
|
|
186
|
-
if (session === undefined) throw new OpsError("session not found in registry (is it loaded?)", 404);
|
|
187
340
|
const { logPath } = pickSessionDir(sessionId);
|
|
188
341
|
const { events } = await readSessionFileAsync(logPath);
|
|
189
342
|
const plan = planRestore(events, restoreSeq);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
343
|
+
const acq = await acquireSession(targetCtx, sessionId);
|
|
344
|
+
if (acq !== null && acq.session !== undefined) {
|
|
345
|
+
try {
|
|
346
|
+
const result = applyRestore(acq.session, plan, {
|
|
347
|
+
flush: () => { flushSessions(targetCtx, acq.session); },
|
|
348
|
+
});
|
|
349
|
+
return { ok: true, ...result, range: { startSeq: plan.startSeq, endSeq: plan.endSeq } };
|
|
350
|
+
} finally {
|
|
351
|
+
acq.release();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// store-miss → 磁盘路径:说明事件 + 重放事件批量追加(一个帧)
|
|
355
|
+
let cursor = nextSeqFrom(events);
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const batch = [buildRestoreNoticeEvent({
|
|
358
|
+
seq: cursor++, time: now, turnStep: plan.turnStep,
|
|
359
|
+
text: restoreNoticeText(plan), restoreSeq: plan.restoreSeq,
|
|
360
|
+
})];
|
|
361
|
+
for (const item of plan.replayable) {
|
|
362
|
+
batch.push(buildReplayEvent({ seq: cursor++, time: ++now, item }));
|
|
363
|
+
}
|
|
364
|
+
await diskAppend(logPath, batch);
|
|
365
|
+
return {
|
|
366
|
+
ok: true, disk: true,
|
|
367
|
+
restoredCount: plan.replayable.length,
|
|
368
|
+
skipped: plan.skipped,
|
|
369
|
+
eventSeqs: batch.map((e) => e.seq),
|
|
370
|
+
range: { startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
371
|
+
};
|
|
194
372
|
}
|
|
195
373
|
|
|
196
374
|
async function opsExport(targetCtx, sessionId, seq) {
|
|
@@ -250,15 +428,20 @@ export function createMessageOpsTool({ defineTool, ops, ctx }) {
|
|
|
250
428
|
const sessionId = String(args.sessionId || "").trim();
|
|
251
429
|
try {
|
|
252
430
|
let result;
|
|
431
|
+
// 0.5.2(P1):ops 自 0.2.1 起为 async——不 await 会把 Promise 直接送进 renderResult,
|
|
432
|
+
// list/restore 必抛 TypeError、export 渲出空 text(测试桩曾是同步的故漏检)。
|
|
253
433
|
switch (action) {
|
|
254
|
-
case "list": result = ops.list(ctx, sessionId); break;
|
|
255
|
-
case "revert": result = ops.revert(ctx, sessionId, args.seq); break;
|
|
256
|
-
case "delete": result = ops.delete(ctx, sessionId, args.seq); break;
|
|
257
|
-
case "branch": result = ops.branch(ctx, sessionId, args.upToSeq); break;
|
|
258
|
-
case "restore": result = ops.restore(ctx, sessionId, args.seq); break;
|
|
259
|
-
case "export": result = ops.export(ctx, sessionId, args.seq); break;
|
|
434
|
+
case "list": result = await ops.list(ctx, sessionId); break;
|
|
435
|
+
case "revert": result = await ops.revert(ctx, sessionId, args.seq); break;
|
|
436
|
+
case "delete": result = await ops.delete(ctx, sessionId, args.seq); break;
|
|
437
|
+
case "branch": result = await ops.branch(ctx, sessionId, args.upToSeq); break;
|
|
438
|
+
case "restore": result = await ops.restore(ctx, sessionId, args.seq); break;
|
|
439
|
+
case "export": result = await ops.export(ctx, sessionId, args.seq); break;
|
|
260
440
|
default: throw new OpsError(`unknown action: ${action}`, 400);
|
|
261
441
|
}
|
|
442
|
+
if (action === "branch" && result && typeof result === "object" && result.parentSession == null) {
|
|
443
|
+
result.parentSession = sessionId; // renderResult 需要原始会话 id(branch 返回值不含/可为 undefined)
|
|
444
|
+
}
|
|
262
445
|
return renderResult(action, result);
|
|
263
446
|
} catch (e) {
|
|
264
447
|
return `${action} failed: ${e && e.message ? e.message : e}`;
|
|
@@ -284,7 +467,7 @@ function renderResult(action, r) {
|
|
|
284
467
|
case "delete":
|
|
285
468
|
return `${r.mode} ok: shadowed ${r.shadowedCount} node(s) from seq ${r.seq}; marker event seq ${r.eventSeq}`;
|
|
286
469
|
case "branch":
|
|
287
|
-
return `branch ok: new session ${r.newId} (parent ${r.
|
|
470
|
+
return `branch ok: new session ${r.newId} (parent ${r.parentSession ?? "n/a"}), kept ${r.keptEvents ?? "?"} event(s)`;
|
|
288
471
|
case "restore":
|
|
289
472
|
return `restore ok: replayed ${r.restoredCount} message(s) from range ${r.range.startSeq}..${r.range.endSeq} (${r.skipped} skipped); appended event seqs ${r.eventSeqs.join(", ")}`;
|
|
290
473
|
case "export":
|
|
@@ -327,12 +510,22 @@ function registerToolTolerantly(ctx) {
|
|
|
327
510
|
// ---------------------------------------------------------------------------
|
|
328
511
|
|
|
329
512
|
export function apply(ctx) {
|
|
513
|
+
// 诊断(0.5.4 调试):会话状态服务解析情况
|
|
514
|
+
try {
|
|
515
|
+
const names = ["sessions", "agents", "sessionController", "sessionList", "session-manager"];
|
|
516
|
+
const parts = names.map((n) => { const v = ctx.get(n); return n + "=" + (v == null ? String(v) : typeof v); });
|
|
517
|
+
const ses = ctx.get("sessions");
|
|
518
|
+
const hasList = ses && ses.list != null;
|
|
519
|
+
const hasSnap = hasList && typeof ses.list.getSnapshot === "function";
|
|
520
|
+
console.log("[message-ops] session services: " + parts.join(" ") + " | sessions.list=" + hasList + " getSnapshot=" + hasSnap + (ses ? " keys=" + Object.keys(ses).slice(0, 12).join("|") : ""));
|
|
521
|
+
} catch (e) { console.log("[message-ops] probe failed: " + e); }
|
|
330
522
|
function registerHttp(host, targetCtx) {
|
|
331
523
|
// --- GET /api/message-ops/messages:消息列表(只读) ---------------------
|
|
332
524
|
targetCtx.effect(() => host.register({
|
|
333
525
|
kind: "exact",
|
|
334
526
|
path: "/api/message-ops/messages",
|
|
335
527
|
handler: async (req, res) => {
|
|
528
|
+
if (req.method !== "GET") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
336
529
|
if (!fence(req, res)) return;
|
|
337
530
|
try {
|
|
338
531
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -343,6 +536,31 @@ export function apply(ctx) {
|
|
|
343
536
|
},
|
|
344
537
|
}), "dsh-message-ops: messages route");
|
|
345
538
|
|
|
539
|
+
// --- GET /api/message-ops/text:单条消息全文(0.5.2 引用按钮按需取) ----------
|
|
540
|
+
// assistant 消息的 fullText 不在 messages 列表里(载荷控制),quote 点击时单取。
|
|
541
|
+
targetCtx.effect(() => host.register({
|
|
542
|
+
kind: "exact",
|
|
543
|
+
path: "/api/message-ops/text",
|
|
544
|
+
handler: async (req, res) => {
|
|
545
|
+
if (req.method !== "GET") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
546
|
+
if (!fence(req, res)) return;
|
|
547
|
+
try {
|
|
548
|
+
const url = new URL(req.url, "http://localhost");
|
|
549
|
+
const sessionId = url.searchParams.get("sessionId") || "";
|
|
550
|
+
const seq = Number.parseInt(url.searchParams.get("seq") || "", 10);
|
|
551
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
552
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new OpsError("invalid seq", 400);
|
|
553
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
554
|
+
const { events } = await readSessionFileAsync(logPath);
|
|
555
|
+
const ev = events.find((e) => e && e.seq === seq);
|
|
556
|
+
if (!ev) throw new OpsError("seq not found", 404);
|
|
557
|
+
return sendJson(res, 200, { ok: true, seq, text: messageText(ev) });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
}), "dsh-message-ops: text route");
|
|
563
|
+
|
|
346
564
|
// --- POST revert / delete:live-session surface replace ------------------
|
|
347
565
|
const commitHandler = (mode) => async (req, res) => {
|
|
348
566
|
if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
@@ -351,7 +569,7 @@ export function apply(ctx) {
|
|
|
351
569
|
if (body === undefined) return;
|
|
352
570
|
if (!body || typeof body !== "object") return sendJson(res, 400, { ok: false, error: "invalid json" });
|
|
353
571
|
try {
|
|
354
|
-
return sendJson(res, 200, opsCommit(targetCtx, mode, body.sessionId, body.seq));
|
|
572
|
+
return sendJson(res, 200, await opsCommit(targetCtx, mode, body.sessionId, body.seq));
|
|
355
573
|
} catch (err) {
|
|
356
574
|
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
357
575
|
}
|
package/src/ops-core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
/**
|
|
2
3
|
* dsh-message-ops — 可复用操作核心(纯逻辑,无 HTTP、无 dsh-tools 依赖)。
|
|
3
4
|
*
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
* @module dsh-message-ops/ops-core
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
import { readReplaceOp, messageText } from "./session-file.js";
|
|
20
|
+
import { readReplaceOp, messageText, computeShadowed } from "./session-file.js";
|
|
20
21
|
|
|
21
22
|
// messageText 的单点实现在 session-file.js(listMessages 同用);此处转出保持
|
|
22
23
|
// 既有导入面兼容(ops-core 的 messageText/exportMarkdown 调用方不受影响)。
|
|
@@ -75,8 +76,32 @@ export function _resetReplaceShape() { replaceShape = null; }
|
|
|
75
76
|
* sourceEventSeqs 必须覆盖被遮蔽的全部 surface 节点(引擎
|
|
76
77
|
* assertProvenance 强校验,缺失即抛错)。
|
|
77
78
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* deriveTurnStep:从事件流尾部回溯最近的正整数 turn/step 坐标。
|
|
81
|
+
*
|
|
82
|
+
* 0.5.4(实机 P0):dsh 0.2.0 收紧 v4 准入(assertV4SystemMessageFields)——
|
|
83
|
+
* 每个 system/message 的 data 必须带正整数 turn/step。缺失时持久层
|
|
84
|
+
* encodeEventBatch 抛出的 SessionFormatError **未被任何层捕获 → 打死整个 dsh
|
|
85
|
+
* 进程**(包装实测 exit=1,栈:assertV4SystemMessageFields ← encodeEventBatch
|
|
86
|
+
* ← cordis apply)。通知事件复用最近一次活动的坐标(row-admission 只做
|
|
87
|
+
* positive 校验,无状态机匹配要求);空/旧日志兜底 1/1。
|
|
88
|
+
*/
|
|
89
|
+
export function deriveTurnStep(events) {
|
|
90
|
+
if (Array.isArray(events)) {
|
|
91
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
92
|
+
const d = events[i] && events[i].data;
|
|
93
|
+
if (d && Number.isSafeInteger(d.turn) && d.turn > 0 && Number.isSafeInteger(d.step) && d.step > 0) {
|
|
94
|
+
return { turn: d.turn, step: d.step };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { turn: 1, step: 1 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function applySurfaceReplace(session, startSeq, endSeq, sourceEventSeqs, noticeText, turnStep) {
|
|
102
|
+
const ts = turnStep && Number.isSafeInteger(turnStep.turn) && turnStep.turn > 0
|
|
103
|
+
? turnStep : { turn: 1, step: 1 };
|
|
104
|
+
const data = { turn: ts.turn, step: ts.step, message: { id: randomUUID(), role: "system", content: [{ type: "text", text: noticeText }] } };
|
|
80
105
|
if (replaceShape) {
|
|
81
106
|
return session.append("system/message", data, { surfaceOp: replaceOpFor(replaceShape, startSeq, endSeq), sourceEventSeqs });
|
|
82
107
|
}
|
|
@@ -127,10 +152,11 @@ export function planRestore(events, restoreSeq) {
|
|
|
127
152
|
skipped++; // tool/call 等不可安全重放的事件:跳过并在结果里计数
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
155
|
+
// 0.5.6:区间无可重放消息(如只遮蔽了 tool/system 事件)不再 409 ——
|
|
156
|
+
// dock 会为这类标记常驻显示恢复按钮,409 死按钮 = 永远清不掉(实测 8 连 409)。
|
|
157
|
+
// 改为返回空计划:applyRestore 仍追加说明 notice(含 restoresSeq)→ 标记停用、
|
|
158
|
+
// dock 计数递减;语义如实:0 条重放 + 文案说明区间无 user/assistant 内容。
|
|
159
|
+
return { restoreSeq, startSeq, endSeq, replayable, skipped, turnStep: deriveTurnStep(events) };
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
/**
|
|
@@ -138,16 +164,26 @@ export function planRestore(events, restoreSeq) {
|
|
|
138
164
|
* 引擎不支持取消遮蔽(SurfaceOp 无该变体),故实现为重放:
|
|
139
165
|
* 每条消息以原类型 append、文本加 [恢复] 前缀;先 append 一条 system 说明。
|
|
140
166
|
*/
|
|
167
|
+
/** 恢复说明文案(live append 与磁盘路径共用;空可重放 = 停用语义)。 */
|
|
168
|
+
export function restoreNoticeText(plan) {
|
|
169
|
+
return plan.replayable.length === 0
|
|
170
|
+
? `[消息恢复] seq ${plan.startSeq}..${plan.endSeq} 区间无可重放的 user/assistant 消息(` +
|
|
171
|
+
`内容为 tool/system 事件或空文本)——该回撤标记就此停用,区间保持遮蔽`
|
|
172
|
+
: `[消息恢复] 重放 seq ${plan.startSeq}..${plan.endSeq} 的 ${plan.replayable.length} 条消息` +
|
|
173
|
+
(plan.skipped > 0 ? `(另有 ${plan.skipped} 条不可重放事件已跳过)` : "") +
|
|
174
|
+
`;原区间仍处于遮蔽状态,恢复为重放而非解除遮蔽`;
|
|
175
|
+
}
|
|
176
|
+
|
|
141
177
|
export function applyRestore(session, plan, { flush } = {}) {
|
|
142
178
|
const eventSeqs = [];
|
|
143
|
-
const notice =
|
|
144
|
-
(plan.skipped > 0 ? `(另有 ${plan.skipped} 条不可重放事件已跳过)` : "") +
|
|
145
|
-
`;原区间仍处于遮蔽状态,恢复为重放而非解除遮蔽`;
|
|
179
|
+
const notice = restoreNoticeText(plan);
|
|
146
180
|
// 0.4.2:notice 事件携带 restoresSeq —— dock 据此把被恢复的标记从「活跃回撤」
|
|
147
181
|
// 中移除(对齐 opencode clear 语义:恢复后不再显示为待恢复项)。
|
|
182
|
+
const ts = plan.turnStep && Number.isSafeInteger(plan.turnStep.turn) && plan.turnStep.turn > 0
|
|
183
|
+
? plan.turnStep : { turn: 1, step: 1 };
|
|
148
184
|
const noticeEvent = session.append(
|
|
149
185
|
"system/message",
|
|
150
|
-
{ message: { role: "system", content: [{ type: "text", text: notice }] }, restoresSeq: plan.restoreSeq },
|
|
186
|
+
{ turn: ts.turn, step: ts.step, message: { id: randomUUID(), role: "system", content: [{ type: "text", text: notice }] }, restoresSeq: plan.restoreSeq },
|
|
151
187
|
{ surfaceOp: "append" },
|
|
152
188
|
);
|
|
153
189
|
if (noticeEvent && noticeEvent.seq != null) eventSeqs.push(noticeEvent.seq);
|
|
@@ -296,3 +332,98 @@ export function buildSeqIndex(messages) {
|
|
|
296
332
|
}
|
|
297
333
|
return map;
|
|
298
334
|
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// 0.5.7 · 磁盘路径(lazy-view 会话不进对象层时的回退)
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// 实机第四层根因:视图打开 ≠ 对象层有会话(lazy-view 只读盘渲染)。官方服务端
|
|
340
|
+
// 没有 retain/using 面,故 store-miss 时改为**直接向日志追加 zstd 帧**——与
|
|
341
|
+
// 持久层 JsonlSessionPersistence.appendLines 等价(encode → open('a') →
|
|
342
|
+
// write+sync+失败回滚),事件字段逐项镜像引擎写出的金标准(见 README0.5.7)。
|
|
343
|
+
// 磁盘会话的视图本来就按帧重读,追加即可见;live 会话仍走引擎路径。
|
|
344
|
+
|
|
345
|
+
/** 事件类型是否为 surface 节点(message 类;step/end、turn/end 等结构性事件不是)。 */
|
|
346
|
+
export function isSurfaceMessageType(type) {
|
|
347
|
+
return type === "user/message" || type === "assistant/message"
|
|
348
|
+
|| type === "system/message" || type === "developer/message";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 磁盘可见节点:按事件流推导的 message 类 seq(剔除既有遮蔽)。 */
|
|
352
|
+
export function diskVisibleNodes(events) {
|
|
353
|
+
const shadowed = computeShadowed(events);
|
|
354
|
+
const nodes = [];
|
|
355
|
+
for (const e of events) {
|
|
356
|
+
if (!e || !Number.isSafeInteger(e.seq)) continue;
|
|
357
|
+
if (shadowed.has(e.seq)) continue;
|
|
358
|
+
if (isSurfaceMessageType(e.type)) nodes.push(e.seq);
|
|
359
|
+
}
|
|
360
|
+
return nodes;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 磁盘版 planRevert:target 必须可见,遮蔽 target 之后(含)全部可见节点。 */
|
|
364
|
+
export function planRevertFromEvents(events, targetSeq) {
|
|
365
|
+
const nodes = diskVisibleNodes(events);
|
|
366
|
+
const startIdx = nodes.indexOf(targetSeq);
|
|
367
|
+
if (startIdx === -1) throw new OpsError(`surface replace: start seq ${targetSeq} not found in surface`, 409);
|
|
368
|
+
const shadowedSeqs = nodes.slice(startIdx);
|
|
369
|
+
if (shadowedSeqs.length === 0) throw new OpsError("nothing to revert", 409);
|
|
370
|
+
return { startSeq: targetSeq, endSeq: shadowedSeqs[shadowedSeqs.length - 1], shadowedSeqs };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 磁盘版 planDelete:单条可见遮蔽。 */
|
|
374
|
+
export function planDeleteFromEvents(events, seq) {
|
|
375
|
+
if (!diskVisibleNodes(events).includes(seq)) {
|
|
376
|
+
throw new OpsError(`seq ${seq} not visible on current surface`, 409);
|
|
377
|
+
}
|
|
378
|
+
return { startSeq: seq, endSeq: seq, shadowedSeqs: [seq] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 下一个可用 seq(全量事件 max+1;结构性事件也占 seq)。 */
|
|
382
|
+
export function nextSeqFrom(events) {
|
|
383
|
+
let max = -1;
|
|
384
|
+
for (const e of events) if (e && Number.isSafeInteger(e.seq) && e.seq > max) max = e.seq;
|
|
385
|
+
return max + 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 磁盘回撤/删除标记事件(字段镜像引擎金标准:type/seq/time/data/sourceEventSeqs/surfaceOp)。 */
|
|
389
|
+
export function buildMarkerEvent({ seq, time, turnStep, text, startSeq, endSeq, shadowedSeqs }) {
|
|
390
|
+
return {
|
|
391
|
+
type: "system/message",
|
|
392
|
+
seq,
|
|
393
|
+
time,
|
|
394
|
+
data: {
|
|
395
|
+
turn: turnStep.turn,
|
|
396
|
+
step: turnStep.step,
|
|
397
|
+
message: { id: randomUUID(), role: "system", content: [{ type: "text", text }] },
|
|
398
|
+
},
|
|
399
|
+
sourceEventSeqs: [...shadowedSeqs],
|
|
400
|
+
surfaceOp: { op: "replace", startSeq, endSeq },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 磁盘恢复说明事件(restoresSeq → dock 移除该行;surfaceOp 恒 "append")。 */
|
|
405
|
+
export function buildRestoreNoticeEvent({ seq, time, turnStep, text, restoreSeq }) {
|
|
406
|
+
return {
|
|
407
|
+
type: "system/message",
|
|
408
|
+
seq,
|
|
409
|
+
time,
|
|
410
|
+
data: {
|
|
411
|
+
turn: turnStep.turn,
|
|
412
|
+
step: turnStep.step,
|
|
413
|
+
message: { id: randomUUID(), role: "system", content: [{ type: "text", text }] },
|
|
414
|
+
restoresSeq: restoreSeq,
|
|
415
|
+
},
|
|
416
|
+
surfaceOp: "append",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 磁盘重放事件(镜像引擎:无 id、[恢复] 前缀、surfaceOp "append")。 */
|
|
421
|
+
export function buildReplayEvent({ seq, time, item }) {
|
|
422
|
+
return {
|
|
423
|
+
type: item.type,
|
|
424
|
+
seq,
|
|
425
|
+
time,
|
|
426
|
+
data: { message: { role: item.role, content: [{ type: "text", text: `[恢复] ${item.text}` }] } },
|
|
427
|
+
surfaceOp: "append",
|
|
428
|
+
};
|
|
429
|
+
}
|
package/test/ops-core.test.js
CHANGED
|
@@ -89,17 +89,19 @@ test('planRestore:兼容 start/end 拼写(dsh-src 较新引擎形状)', ()
|
|
|
89
89
|
assert.equal(plan.replayable[0].text, 'a')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test('planRestore:非标记事件 / 越界 seq
|
|
92
|
+
test('planRestore:非标记事件 / 越界 seq 拒绝;空可重放区间改为接受(0.5.6 停用语义)', () => {
|
|
93
93
|
const events = sampleEvents()
|
|
94
94
|
assert.throws(() => planRestore(events, 99), (e) => e instanceof OpsError && e.status === 404)
|
|
95
95
|
assert.throws(() => planRestore(events, 0), (e) => e instanceof OpsError && e.status === 409) // 普通消息非标记
|
|
96
96
|
assert.throws(() => planRestore(events, -1), (e) => e instanceof OpsError && e.status === 400)
|
|
97
|
-
//
|
|
97
|
+
// 0.5.6:区间内只有 tool 事件 → 不再 409,返回空可重放计划(停用语义)
|
|
98
98
|
const onlyTool = [
|
|
99
99
|
{ type: 'tool/call', seq: 0, data: { name: 'bash' } },
|
|
100
100
|
{ type: 'system/message', seq: 1, surfaceOp: { op: 'replace', startSeq: 0, endSeq: 0 }, sourceEventSeqs: [0] },
|
|
101
101
|
]
|
|
102
|
-
|
|
102
|
+
const plan = planRestore(onlyTool, 1)
|
|
103
|
+
assert.equal(plan.replayable.length, 0, '空可重放 = 接受为停用计划')
|
|
104
|
+
assert.equal(plan.startSeq, 0)
|
|
103
105
|
})
|
|
104
106
|
|
|
105
107
|
test('applyRestore:重放 append 带 [恢复] 前缀 + system 说明,flush 被调用', () => {
|
|
@@ -190,7 +192,7 @@ test('apply:无 webServer、无 tools 时走 inject 等待,不抛错', () =>
|
|
|
190
192
|
assert.equal(ctx.registrations.length, 0)
|
|
191
193
|
})
|
|
192
194
|
|
|
193
|
-
test('apply:webServer 存在时注册全部
|
|
195
|
+
test('apply:webServer 存在时注册全部 7 条路由;tools 缺失走 inject 等待', () => {
|
|
194
196
|
const ctx = mockCtx({ webServer: { register: (route) => ctx.registrations.push(route) } })
|
|
195
197
|
assert.doesNotThrow(() => apply(ctx))
|
|
196
198
|
const paths = ctx.registrations.map((r) => r.path).sort()
|
|
@@ -201,6 +203,7 @@ test('apply:webServer 存在时注册全部 6 条路由;tools 缺失走 inje
|
|
|
201
203
|
'/api/message-ops/messages',
|
|
202
204
|
'/api/message-ops/restore',
|
|
203
205
|
'/api/message-ops/revert',
|
|
206
|
+
'/api/message-ops/text',
|
|
204
207
|
])
|
|
205
208
|
// tools 缺失 → 容错等待而非 fatal
|
|
206
209
|
assert.ok(ctx.injects.some(([deps]) => deps[0] === 'tools'))
|
|
@@ -214,3 +217,145 @@ test('apply:tools 服务存在时直接进入注册路径(包缺失时 catch
|
|
|
214
217
|
// 但绝不能让插件 apply 抛错;异步分支也不能产生未处理拒绝。
|
|
215
218
|
assert.equal(registered.length, 0)
|
|
216
219
|
})
|
|
220
|
+
|
|
221
|
+
// ── 0.5.2 回归:工具层必须 await async ops(0.2.1 改 async 后曾漏同步化,
|
|
222
|
+
// list/restore 抛 TypeError、export 渲出空 text;旧桩是同步的故漏检)──────
|
|
223
|
+
test('工具 execute:async ops 被 await(list/branch/restore/export 全路径)', async () => {
|
|
224
|
+
let def
|
|
225
|
+
const defineTool = (d) => { def = d; return d }
|
|
226
|
+
const asyncOps = {
|
|
227
|
+
list: async () => ({ session: { id: 'session-abc', parentSession: null }, running: false, total: 1, visibleCount: 1,
|
|
228
|
+
messages: [{ seq: 10, role: 'user', visible: true, snippet: 'hello' }] }),
|
|
229
|
+
revert: async () => ({ mode: 'revert', shadowedCount: 2, seq: 10, eventSeq: 99 }),
|
|
230
|
+
delete: async () => ({ mode: 'delete', shadowedCount: 1, seq: 10, eventSeq: 98 }),
|
|
231
|
+
branch: async () => ({ newId: 'session-child', keptEvents: 42, parentSession: undefined }),
|
|
232
|
+
restore: async () => ({ restoredCount: 1, skipped: 0, range: { startSeq: 5, endSeq: 6 }, eventSeqs: [7, 8] }),
|
|
233
|
+
export: async () => ({ markdown: '# md' }),
|
|
234
|
+
}
|
|
235
|
+
createMessageOpsTool({ defineTool, ops: asyncOps, ctx: {} })
|
|
236
|
+
assert.ok(def, 'tool defined')
|
|
237
|
+
const outList = await def.execute({ action: 'list', sessionId: 'session-abc' })
|
|
238
|
+
assert.match(String(outList), /session session-abc/)
|
|
239
|
+
assert.doesNotMatch(String(outList), /failed:/)
|
|
240
|
+
const outBranch = await def.execute({ action: 'branch', sessionId: 'session-abc', upToSeq: 10 })
|
|
241
|
+
// 0.5.2 P2:branch 渲染读 keptEvents + parentSession(注入自入参),不再读不存在的 kept/parentId
|
|
242
|
+
assert.match(String(outBranch), /kept 42 event/)
|
|
243
|
+
assert.match(String(outBranch), /parent session-abc/)
|
|
244
|
+
const outRestore = await def.execute({ action: 'restore', sessionId: 'session-abc', seq: 6 })
|
|
245
|
+
assert.match(String(outRestore), /replayed 1 message/)
|
|
246
|
+
const outExport = await def.execute({ action: 'export', sessionId: 'session-abc' })
|
|
247
|
+
assert.equal(String(outExport), '# md')
|
|
248
|
+
// 同步抛出的 ops 仍走错误文案路径
|
|
249
|
+
const errTool = await (async () => {
|
|
250
|
+
let d2
|
|
251
|
+
createMessageOpsTool({ defineTool: (x) => { d2 = x }, ops: { list: () => { throw new Error('boom') } }, ctx: {} })
|
|
252
|
+
return d2.execute({ action: 'list', sessionId: 'session-abc' })
|
|
253
|
+
})()
|
|
254
|
+
assert.match(String(errTool), /list failed: boom/)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ── 0.5.4 P0 回归:system/message 必须带正 turn/step(0.2.0 v4 准入收紧,
|
|
258
|
+
// 缺字段 → 持久层 encodeEventBatch 未捕获 → **整个 dsh 进程 exit=1**)────
|
|
259
|
+
test('applySurfaceReplace/applyRestore 附带正整数 turn/step(v4 准入)', async () => {
|
|
260
|
+
const { applySurfaceReplace, applyRestore, deriveTurnStep } = await import('../src/ops-core.js');
|
|
261
|
+
const appended = [];
|
|
262
|
+
const fakeSession = { append: (type, data, opts) => { appended.push({ type, data, opts }); return { seq: 999 }; } };
|
|
263
|
+
// 1) deriveTurnStep:尾部回溯 / 兜底
|
|
264
|
+
assert.deepEqual(deriveTurnStep([{ data: { turn: 7, step: 3 } }]), { turn: 7, step: 3 });
|
|
265
|
+
assert.deepEqual(deriveTurnStep([{ data: {} }, { data: { turn: 2, step: 5 } }]), { turn: 2, step: 5 });
|
|
266
|
+
assert.deepEqual(deriveTurnStep([]), { turn: 1, step: 1 });
|
|
267
|
+
assert.deepEqual(deriveTurnStep(undefined), { turn: 1, step: 1 });
|
|
268
|
+
// 2) revert/delete 通知
|
|
269
|
+
applySurfaceReplace(fakeSession, 10, 20, [10], '[消息回滚] x', { turn: 9, step: 4 });
|
|
270
|
+
assert.equal(appended[0].type, 'system/message');
|
|
271
|
+
assert.ok(appended[0].data.turn > 0 && Number.isInteger(appended[0].data.turn), 'data.turn 正整数');
|
|
272
|
+
assert.ok(appended[0].data.step > 0 && Number.isInteger(appended[0].data.step), 'data.step 正整数');
|
|
273
|
+
assert.ok(typeof appended[0].data.message.id === 'string' && appended[0].data.message.id.length > 0, 'message.id 非空字符串(v4 准入第 3 关)');
|
|
274
|
+
// 无坐标参数时兜底 1/1(调用方漏传不致崩)
|
|
275
|
+
applySurfaceReplace(fakeSession, 10, 20, [10], 'x2');
|
|
276
|
+
assert.ok(appended[1].data.turn >= 1 && appended[1].data.step >= 1);
|
|
277
|
+
// 3) restore 通知(plan.turnStep)
|
|
278
|
+
applyRestore(fakeSession, {
|
|
279
|
+
restoreSeq: 5, startSeq: 1, endSeq: 3, skipped: 0, turnStep: { turn: 12, step: 6 },
|
|
280
|
+
replayable: [{ type: 'user/message', role: 'user', text: 'hi' }],
|
|
281
|
+
});
|
|
282
|
+
const notice = appended[2];
|
|
283
|
+
assert.equal(notice.type, 'system/message');
|
|
284
|
+
assert.equal(notice.data.turn, 12);
|
|
285
|
+
assert.equal(notice.data.step, 6);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// 0.5.6 回归:空可重放区间 → 优雅停用而非 409(dock 死按钮修复)
|
|
289
|
+
test('planRestore 空可重放区间返回空计划;applyRestore 发停用 notice', async () => {
|
|
290
|
+
const { planRestore, applyRestore } = await import('../src/ops-core.js');
|
|
291
|
+
// 事件流:startSeq..endSeq 内只有一条 tool 事件(非 user/assistant)
|
|
292
|
+
const events = [
|
|
293
|
+
{ seq: 1, type: 'system/message', data: { turn: 1, step: 1, message: {} } },
|
|
294
|
+
{ seq: 8, type: 'tool/result', data: { message: {} } },
|
|
295
|
+
{ seq: 9, type: 'system/message', data: { turn: 1, step: 2, message: {} },
|
|
296
|
+
surfaceOp: { op: 'replace', startSeq: 8, endSeq: 8 } },
|
|
297
|
+
];
|
|
298
|
+
const plan = planRestore(events, 9);
|
|
299
|
+
assert.equal(plan.replayable.length, 0, '无可重放');
|
|
300
|
+
assert.equal(plan.startSeq, 8);
|
|
301
|
+
const appended = [];
|
|
302
|
+
const fakeSession = { append: (type, data, opts) => { appended.push({ type, data, opts }); return { seq: 42 }; } };
|
|
303
|
+
const out = applyRestore(fakeSession, plan);
|
|
304
|
+
assert.equal(out.restoredCount, 0);
|
|
305
|
+
// notice 停用 + restoresSeq 指向标记(dock 由此移除该行)
|
|
306
|
+
assert.equal(appended.length, 1, '只发 notice');
|
|
307
|
+
assert.equal(appended[0].data.restoresSeq, 9);
|
|
308
|
+
assert.match(appended[0].data.message.content[0].text, /无可重放/);
|
|
309
|
+
assert.ok(appended[0].data.turn > 0 && appended[0].data.message.id, 'v4 准入字段齐');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── 0.5.7 磁盘路径回归:字段镜像引擎金标准 + 计划语义一致 ──────────────────
|
|
313
|
+
test('磁盘计划/构建器:可见节点剔除遮蔽、marker 字段逐项镜像金标准', async () => {
|
|
314
|
+
const core = await import('../src/ops-core.js');
|
|
315
|
+
// 事件流:user10 → assistant11 → step/end12(非节点)→ 既有标记 13 遮蔽 11
|
|
316
|
+
const events = [
|
|
317
|
+
{ type: 'user/message', seq: 10, data: { message: { role: 'user', content: [] } } },
|
|
318
|
+
{ type: 'assistant/message', seq: 11, data: { message: { role: 'assistant', content: [] } } },
|
|
319
|
+
{ type: 'step/end', seq: 12, data: {} },
|
|
320
|
+
{ type: 'system/message', seq: 13, data: { turn: 1, step: 1, message: {} },
|
|
321
|
+
surfaceOp: { op: 'replace', startSeq: 11, endSeq: 11 }, sourceEventSeqs: [11] },
|
|
322
|
+
{ type: 'user/message', seq: 14, data: { message: { role: 'user', content: [] } } },
|
|
323
|
+
];
|
|
324
|
+
// 可见节点 = [10, 14](11 已遮蔽;12 非 message;13 是标记本身也是 system 可见)
|
|
325
|
+
const nodes = core.diskVisibleNodes(events);
|
|
326
|
+
assert.deepEqual(nodes, [10, 13, 14], '剔除遮蔽 + 只取 message 类: ' + JSON.stringify(nodes));
|
|
327
|
+
// revert at 14 → 遮蔽 [14](末尾)
|
|
328
|
+
const plan = core.planRevertFromEvents(events, 14);
|
|
329
|
+
assert.deepEqual(plan.shadowedSeqs, [14]);
|
|
330
|
+
assert.equal(plan.endSeq, 14);
|
|
331
|
+
// revert at 10 → 遮蔽 [10, 13, 14]
|
|
332
|
+
const plan2 = core.planRevertFromEvents(events, 10);
|
|
333
|
+
assert.deepEqual(plan2.shadowedSeqs, [10, 13, 14]);
|
|
334
|
+
// 不可见 seq → 409
|
|
335
|
+
assert.throws(() => core.planRevertFromEvents(events, 11), (e) => e.status === 409);
|
|
336
|
+
// nextSeq
|
|
337
|
+
assert.equal(core.nextSeqFrom(events), 15);
|
|
338
|
+
// marker 构建器字段镜像(对照引擎金标准形状)
|
|
339
|
+
const ev = core.buildMarkerEvent({
|
|
340
|
+
seq: 15, time: 1791105740958, turnStep: { turn: 9, step: 6 }, text: 'x',
|
|
341
|
+
startSeq: 14, endSeq: 14, shadowedSeqs: [14],
|
|
342
|
+
});
|
|
343
|
+
assert.deepEqual(Object.keys(ev), ['type', 'seq', 'time', 'data', 'sourceEventSeqs', 'surfaceOp'], '根键序镜像');
|
|
344
|
+
assert.equal(ev.type, 'system/message');
|
|
345
|
+
assert.equal(ev.data.turn, 9);
|
|
346
|
+
assert.equal(ev.data.step, 6);
|
|
347
|
+
assert.ok(ev.data.message.id.length > 0);
|
|
348
|
+
assert.equal(ev.data.message.role, 'system');
|
|
349
|
+
assert.deepEqual(ev.data.message.content[0], { type: 'text', text: 'x' });
|
|
350
|
+
assert.deepEqual(ev.sourceEventSeqs, [14]);
|
|
351
|
+
assert.deepEqual(ev.surfaceOp, { op: 'replace', startSeq: 14, endSeq: 14 });
|
|
352
|
+
assert.equal(Object.keys(ev.surfaceOp).length, 3, 'replace op 恰好 3 键(引擎 isReplaceOp)');
|
|
353
|
+
// restore notice + replay 镜像
|
|
354
|
+
const nt = core.buildRestoreNoticeEvent({ seq: 16, time: 2, turnStep: { turn: 9, step: 6 }, text: 'n', restoreSeq: 13 });
|
|
355
|
+
assert.equal(nt.surfaceOp, 'append');
|
|
356
|
+
assert.equal(nt.data.restoresSeq, 13);
|
|
357
|
+
const rp = core.buildReplayEvent({ seq: 17, time: 3, item: { type: 'user/message', role: 'user', text: 'hi' } });
|
|
358
|
+
assert.equal(rp.surfaceOp, 'append');
|
|
359
|
+
assert.deepEqual(rp.data.message, { role: 'user', content: [{ type: 'text', text: '[恢复] hi' }] });
|
|
360
|
+
assert.ok(!('id' in rp.data.message), '重放镜像引擎:不带 id');
|
|
361
|
+
});
|