@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 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.1",
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
- fetch('/api/message-ops/' + path, {
390
- method: 'POST',
391
- headers: { 'content-type': 'application/json' },
392
- body: JSON.stringify(body),
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() : { messages: [] }))
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
- if (m && m.id != null) index.set(String(m.id), m.seq)
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
- p.then(resolve).catch(() => {})
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('/api/message-ops/revert', {
693
- method: 'POST', headers: { 'content-type': 'application/json' },
694
- body: JSON.stringify({ sessionId, seq }),
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 text = fullText != null ? fullText : (summary && summary.title ? '' : null)
718
- if (text == null) { notifyDone(t('quote.unavailable'), 'warn'); return }
719
- const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
720
- const ok = inputActions.insertText(quoted, span)
721
- notifyDone(ok ? t('quote.done') : t('quote.unavailable'), ok ? 'ok' : 'warn')
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
- setMarkers(activeMarkers(data.messages || []))
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
- fetch('/api/message-ops/restore', {
806
- method: 'POST', headers: { 'content-type': 'application/json' },
807
- body: JSON.stringify({ sessionId, seq: row.seq }),
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
- const label = t('dock.title', { n: String(markers.length) })
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
- window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title } }))
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 { isSessionId, sessionIdVariants, findSessionDirs, readSessionFile, readSessionFileAsync, listMessages, computeShadowed } from "./session-file.js";
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
- const sessions = ctx.get("sessions");
88
- if (!sessions || typeof sessions.get !== "function") return undefined;
89
- for (const variant of sessionIdVariants(sessionId)) {
90
- const s = sessions.get(variant);
91
- if (s !== undefined) return s;
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
- const agents = ctx.get("agents");
99
- if (!agents || typeof agents.get !== "function") return false;
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
- for (const variant of sessionIdVariants(sessionId)) {
102
- if (agents.get(variant)) return true;
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
- } catch { /* registry absent: treat as idle */ }
105
- return false;
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 session = resolveSession(targetCtx, sessionId);
157
- if (session === undefined) throw new OpsError("session not found in registry (is it loaded?)", 404);
158
- const plan = mode === "delete" ? planDelete(session.surface, seq) : planRevert(session.surface, seq);
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 event = applySurfaceReplace(session, plan.startSeq, plan.endSeq, plan.shadowedSeqs, notice);
163
- flushSessions(targetCtx, session);
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: event && event.seq != null ? event.seq : null,
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 result = applyRestore(session, plan, {
191
- flush: () => { flushSessions(targetCtx, session); },
192
- });
193
- return { ok: true, ...result, range: { startSeq: plan.startSeq, endSeq: plan.endSeq } };
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.parentId ?? "n/a"}), kept ${r.kept ?? "?"} event(s)`;
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
- export function applySurfaceReplace(session, startSeq, endSeq, sourceEventSeqs, noticeText) {
79
- const data = { message: { role: "system", content: [{ type: "text", text: noticeText }] } };
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
- if (replayable.length === 0) {
131
- throw new OpsError(`restore: no replayable user/assistant messages in shadowed range ${startSeq}..${endSeq}`, 409);
132
- }
133
- return { restoreSeq, startSeq, endSeq, replayable, skipped };
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 = `[消息恢复] 重放 seq ${plan.startSeq}..${plan.endSeq} 的 ${plan.replayable.length} 条消息` +
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
+ }
@@ -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
- // 区间内只有 tool 事件 → 无可重放
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
- assert.throws(() => planRestore(onlyTool, 1), (e) => e instanceof OpsError && e.status === 409)
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 存在时注册全部 6 条路由;tools 缺失走 inject 等待', () => {
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
+ });