@240xu/dsh-message-ops 0.5.2 → 0.5.8

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
@@ -262,3 +262,55 @@ MIT
262
262
  同步抛出也落进 finally;restoreRow 补 finally 双保险。
263
263
  - **[P2] dock 陈旧**:新增 `dsh-message-ops:changed` 广播,回滚/恢复成功后 dock 自动重拉。
264
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.2",
3
+ "version": "0.5.8",
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
@@ -415,6 +415,11 @@ window.__ModuleLoader__.load({
415
415
  setState('done')
416
416
  notifyDone(okMsg)
417
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 { /* 视图重建失败不阻断成功反馈 */ }
418
423
  })
419
424
  .catch((reason) => {
420
425
  setBusyMsg('')
package/src/index.js CHANGED
@@ -20,9 +20,11 @@
20
20
  * @module dsh-message-ops
21
21
  */
22
22
 
23
+ import { zstdCompressSync } from "node:zlib";
24
+ import { open as openFile, truncate as truncateFile, stat as statFile } from "node:fs/promises";
23
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
+ }
103
193
  }
104
- } catch { /* registry absent: treat as idle */ }
105
- return false;
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;
229
+ }
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,39 @@ 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 baseTime = Date.now();
357
+ const batch = [buildRestoreNoticeEvent({
358
+ seq: cursor++, time: baseTime, turnStep: plan.turnStep,
359
+ text: restoreNoticeText(plan), restoreSeq: plan.restoreSeq,
360
+ })];
361
+ let offset = 0;
362
+ for (const item of plan.replayable) {
363
+ batch.push(buildReplayEvent({ seq: cursor++, time: baseTime + (++offset), item }));
364
+ }
365
+ await diskAppend(logPath, batch);
366
+ return {
367
+ ok: true, disk: true,
368
+ restoredCount: plan.replayable.length,
369
+ skipped: plan.skipped,
370
+ eventSeqs: batch.map((e) => e.seq),
371
+ range: { startSeq: plan.startSeq, endSeq: plan.endSeq },
372
+ };
194
373
  }
195
374
 
196
375
  async function opsExport(targetCtx, sessionId, seq) {
@@ -332,6 +511,15 @@ function registerToolTolerantly(ctx) {
332
511
  // ---------------------------------------------------------------------------
333
512
 
334
513
  export function apply(ctx) {
514
+ // 诊断(0.5.4 调试):会话状态服务解析情况
515
+ try {
516
+ const names = ["sessions", "agents", "sessionController", "sessionList", "session-manager"];
517
+ const parts = names.map((n) => { const v = ctx.get(n); return n + "=" + (v == null ? String(v) : typeof v); });
518
+ const ses = ctx.get("sessions");
519
+ const hasList = ses && ses.list != null;
520
+ const hasSnap = hasList && typeof ses.list.getSnapshot === "function";
521
+ console.log("[message-ops] session services: " + parts.join(" ") + " | sessions.list=" + hasList + " getSnapshot=" + hasSnap + (ses ? " keys=" + Object.keys(ses).slice(0, 12).join("|") : ""));
522
+ } catch (e) { console.log("[message-ops] probe failed: " + e); }
335
523
  function registerHttp(host, targetCtx) {
336
524
  // --- GET /api/message-ops/messages:消息列表(只读) ---------------------
337
525
  targetCtx.effect(() => host.register({
@@ -382,7 +570,7 @@ export function apply(ctx) {
382
570
  if (body === undefined) return;
383
571
  if (!body || typeof body !== "object") return sendJson(res, 400, { ok: false, error: "invalid json" });
384
572
  try {
385
- return sendJson(res, 200, opsCommit(targetCtx, mode, body.sessionId, body.seq));
573
+ return sendJson(res, 200, await opsCommit(targetCtx, mode, body.sessionId, body.seq));
386
574
  } catch (err) {
387
575
  return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
388
576
  }
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 被调用', () => {
@@ -251,3 +253,109 @@ test('工具 execute:async ops 被 await(list/branch/restore/export 全路
251
253
  })()
252
254
  assert.match(String(errTool), /list failed: boom/)
253
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
+ });