@240xu/dsh-message-ops 0.2.2
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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +5 -0
- package/package.json +51 -0
- package/src/branch.js +54 -0
- package/src/client.js +560 -0
- package/src/index.js +436 -0
- package/src/ops-core.js +269 -0
- package/src/session-file.js +230 -0
- package/test/fence.test.js +208 -0
- package/test/ops-core.test.js +216 -0
- package/test/session-file.test.js +110 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-message-ops 服务端:消息回滚 / 消息删除 / 消息分支 / 导出 / 回滚恢复
|
|
3
|
+
* 的 HTTP 路由 + Agent 工具 message_ops。
|
|
4
|
+
*
|
|
5
|
+
* 五操作一条龙(同名插件,一个注入点):
|
|
6
|
+
* POST /api/message-ops/revert 回滚:surface replace 遮蔽 targetSeq..末尾
|
|
7
|
+
* POST /api/message-ops/delete 删除:surface replace 遮蔽单条 seq
|
|
8
|
+
* POST /api/message-ops/branch 分支:磁盘级 fork(新会话,parentSession 关联)
|
|
9
|
+
* GET /api/message-ops/messages 消息列表(磁盘真相 + 可见性标注)
|
|
10
|
+
* GET /api/message-ops/export 导出 Markdown(seq 可选上界,附件下载)
|
|
11
|
+
* POST /api/message-ops/restore 回滚恢复:重放被遮蔽的 user/assistant 消息
|
|
12
|
+
*
|
|
13
|
+
* 回滚/删除走 DSH 原生 surface replace(append-only,日志完整可恢复;
|
|
14
|
+
* 约束由引擎 assertProvenance 校验,违规如实上报 409)。
|
|
15
|
+
* 运行中的会话一律拒绝(409),由用户先停止再操作。
|
|
16
|
+
*
|
|
17
|
+
* Agent 工具:action revert/delete/branch/list/restore 共用 HTTP 同一套
|
|
18
|
+
* 核心逻辑(src/ops-core.js)。工具注册是容错的——ctx 里没有 tools 服务、
|
|
19
|
+
* 或 @deepseek-ai/dsh-tools 包不可解析时,只跳过工具注册,不影响 HTTP。
|
|
20
|
+
* @module dsh-message-ops
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { isSessionId, sessionIdVariants, findSessionDirs, readSessionFile, readSessionFileAsync, listMessages, computeShadowed } from "./session-file.js";
|
|
24
|
+
import { applyBranch } from "./branch.js";
|
|
25
|
+
import {
|
|
26
|
+
OpsError, planRevert, planDelete, planRestore, applyRestore, applySurfaceReplace, exportMarkdown,
|
|
27
|
+
isTrustedApiRequest, isJsonContentType,
|
|
28
|
+
} from "./ops-core.js";
|
|
29
|
+
|
|
30
|
+
export const name = "dsh-message-ops";
|
|
31
|
+
// 保持 inject 为空:tools 是可选增强而非硬依赖(cordis 的 inject 是
|
|
32
|
+
// 硬依赖声明,缺服务会让整个插件 apply 永远不执行)。工具注册在
|
|
33
|
+
// apply 内部容错探测,见 registerToolTolerantly。
|
|
34
|
+
export const inject = [];
|
|
35
|
+
|
|
36
|
+
function sendJson(res, status, body) {
|
|
37
|
+
const payload = JSON.stringify(body);
|
|
38
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
39
|
+
res.end(payload);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 评审 P2:无上限的 body 缓存可打爆进程
|
|
43
|
+
|
|
44
|
+
async function readJsonBody(req) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for await (const c of req) {
|
|
48
|
+
total += c.length;
|
|
49
|
+
if (total > MAX_BODY_BYTES) throw new OpsError("request body too large", 413);
|
|
50
|
+
chunks.push(c);
|
|
51
|
+
}
|
|
52
|
+
if (chunks.length === 0) return {};
|
|
53
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
54
|
+
catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 统一入口围栏:全部 /api/message-ops/* 路由先过这一层。 */
|
|
58
|
+
function fence(req, res) {
|
|
59
|
+
if (!isTrustedApiRequest(req)) {
|
|
60
|
+
sendJson(res, 403, { ok: false, error: "untrusted request origin (loopback Host + same-origin only)" });
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 写操作围栏:入口信任 + Content-Type 必须 application/json(CSRF 纵深)。 */
|
|
67
|
+
function writeFence(req, res) {
|
|
68
|
+
if (!fence(req, res)) return false;
|
|
69
|
+
if (!isJsonContentType(req)) {
|
|
70
|
+
sendJson(res, 415, { ok: false, error: "content-type must be application/json" });
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** POST body 读取(信任围栏之后):413/400 语义区分。 */
|
|
77
|
+
async function readFencedBody(req, res) {
|
|
78
|
+
try { return await readJsonBody(req); }
|
|
79
|
+
catch (err) {
|
|
80
|
+
sendJson(res, err instanceof OpsError ? err.status : 400, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 从 sessions 注册表解析(两种 id 拼写都试)。 */
|
|
86
|
+
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;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 是否有 live agent 正占用该会话。 */
|
|
97
|
+
function isRunning(ctx, sessionId) {
|
|
98
|
+
const agents = ctx.get("agents");
|
|
99
|
+
if (!agents || typeof agents.get !== "function") return false;
|
|
100
|
+
try {
|
|
101
|
+
for (const variant of sessionIdVariants(sessionId)) {
|
|
102
|
+
if (agents.get(variant)) return true;
|
|
103
|
+
}
|
|
104
|
+
} catch { /* registry absent: treat as idle */ }
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function flushSessions(ctx, session) {
|
|
109
|
+
const sessions = ctx.get("sessions");
|
|
110
|
+
if (sessions && typeof sessions.flush === "function") {
|
|
111
|
+
try { return sessions.flush(session); } catch { /* flush 失败不回滚已接受的事件 */ }
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 核心操作:HTTP 与 message_ops 工具共用(返回普通对象,失败抛 OpsError)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** 多 project slug 命中同一 id 时的静默 dirs[0] 是隐患(评审 P2):显式报歧义。 */
|
|
121
|
+
function pickSessionDir(sessionId) {
|
|
122
|
+
const dirs = findSessionDirs(sessionId);
|
|
123
|
+
if (dirs.length === 0) throw new OpsError("session log not found", 404);
|
|
124
|
+
if (dirs.length > 1) throw new OpsError(`ambiguous session id: found in ${dirs.length} project slugs (${dirs.map((d) => d.dir).join(", ")})`, 409);
|
|
125
|
+
return dirs[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function opsList(targetCtx, sessionId) {
|
|
129
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
130
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
131
|
+
// 大日志的逐帧解压走异步让出路径,避免阻塞 GUI 事件循环(评审 P1)。
|
|
132
|
+
const { header, events } = await readSessionFileAsync(logPath);
|
|
133
|
+
const messages = listMessages(events);
|
|
134
|
+
const shadowed = computeShadowed(events);
|
|
135
|
+
const live = resolveSession(targetCtx, sessionId);
|
|
136
|
+
const visibleNodes = live && live.surface && Array.isArray(live.surface.nodes) ? new Set(live.surface.nodes) : null;
|
|
137
|
+
let visibleCount = 0;
|
|
138
|
+
for (const m of messages) {
|
|
139
|
+
m.visible = visibleNodes ? visibleNodes.has(m.seq) : !shadowed.has(m.seq);
|
|
140
|
+
if (m.visible) visibleCount++;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
session: { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession ?? null },
|
|
145
|
+
running: isRunning(targetCtx, sessionId),
|
|
146
|
+
total: messages.length,
|
|
147
|
+
visibleCount,
|
|
148
|
+
messages,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function opsCommit(targetCtx, mode, sessionId, seq) {
|
|
153
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
154
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new OpsError("invalid seq", 400);
|
|
155
|
+
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);
|
|
159
|
+
const notice = mode === "delete"
|
|
160
|
+
? `[消息删除] 已遮蔽 seq ${seq}`
|
|
161
|
+
: `[消息回滚] 已回滚到 seq ${seq}(含)之后的 ${plan.shadowedSeqs.length} 个节点`;
|
|
162
|
+
const event = applySurfaceReplace(session, plan.startSeq, plan.endSeq, plan.shadowedSeqs, notice);
|
|
163
|
+
flushSessions(targetCtx, session);
|
|
164
|
+
return {
|
|
165
|
+
ok: true, mode, seq,
|
|
166
|
+
shadowedCount: plan.shadowedSeqs.length,
|
|
167
|
+
eventSeq: event && event.seq != null ? event.seq : null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function opsBranch(targetCtx, sessionId, upToSeq) {
|
|
172
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
173
|
+
if (!Number.isSafeInteger(upToSeq) || upToSeq < 0) throw new OpsError("invalid upToSeq", 400);
|
|
174
|
+
if (isRunning(targetCtx, sessionId)) {
|
|
175
|
+
throw new OpsError("session is running; stop it first (the log may be mid-append)", 409);
|
|
176
|
+
}
|
|
177
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
178
|
+
return { ok: true, ...applyBranch(logPath, upToSeq) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function opsRestore(targetCtx, sessionId, restoreSeq) {
|
|
182
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
183
|
+
if (!Number.isSafeInteger(restoreSeq) || restoreSeq < 0) throw new OpsError("invalid seq", 400);
|
|
184
|
+
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
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
188
|
+
const { events } = await readSessionFileAsync(logPath);
|
|
189
|
+
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 } };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function opsExport(targetCtx, sessionId, seq) {
|
|
197
|
+
if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
|
|
198
|
+
if (seq !== undefined && (!Number.isSafeInteger(seq) || seq < 0)) throw new OpsError("invalid seq", 400);
|
|
199
|
+
const { logPath } = pickSessionDir(sessionId);
|
|
200
|
+
const { header, events, frameCount, partial } = await readSessionFileAsync(logPath);
|
|
201
|
+
let markdown = exportMarkdown(header, events, seq);
|
|
202
|
+
// 超阈值提示(partial 语义):内容完整,仅告知本次解压耗时可能较长。
|
|
203
|
+
if (partial) {
|
|
204
|
+
markdown += `\n> 注:本日志共 ${frameCount} 个事件帧(超过 ${500} 帧阈值),本次导出已完整读取,但大日志解压耗时较长。\n`;
|
|
205
|
+
}
|
|
206
|
+
return { header, markdown, upToSeq: seq, frameCount, partial };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Agent 工具 message_ops(容错注册)
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 构造 message_ops 工具定义。defineTool 由调用方注入(@deepseek-ai/dsh-tools),
|
|
215
|
+
* 这里保持零静态依赖、可在测试里用桩验证。ops 为核心操作表,签名:
|
|
216
|
+
* (targetCtx, sessionId[, seq]) → 结果对象,失败抛 OpsError。
|
|
217
|
+
*/
|
|
218
|
+
export function createMessageOpsTool({ defineTool, ops, ctx }) {
|
|
219
|
+
return defineTool({
|
|
220
|
+
name: "message_ops",
|
|
221
|
+
description:
|
|
222
|
+
"Manage DSH conversation messages: list messages with visibility, revert to a seq (surface replace shadows the tail), delete a single message, fork a branch from any seq, restore (replay) messages shadowed by a previous revert, or export the conversation as Markdown. Sessions must not be running for mutating actions.",
|
|
223
|
+
parameters: {
|
|
224
|
+
action: {
|
|
225
|
+
type: "string",
|
|
226
|
+
required: true,
|
|
227
|
+
enum: ["list", "revert", "delete", "branch", "restore", "export"],
|
|
228
|
+
description: "Which operation to perform.",
|
|
229
|
+
},
|
|
230
|
+
sessionId: {
|
|
231
|
+
type: "string",
|
|
232
|
+
required: true,
|
|
233
|
+
description: "Target session id (uuid or session-<uuid> form).",
|
|
234
|
+
},
|
|
235
|
+
seq: {
|
|
236
|
+
type: "integer",
|
|
237
|
+
description: "revert/delete: the target seq. restore: the seq of the revert/delete marker event. export: optional upper bound (inclusive). Ignored by list/branch.",
|
|
238
|
+
},
|
|
239
|
+
upToSeq: {
|
|
240
|
+
type: "integer",
|
|
241
|
+
description: "branch: fork keeps events up to this seq (inclusive).",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
output: {
|
|
245
|
+
schema: { type: "string" },
|
|
246
|
+
render(_args, value) { return [{ type: "text", text: value }]; },
|
|
247
|
+
},
|
|
248
|
+
async execute(args) {
|
|
249
|
+
const action = args.action;
|
|
250
|
+
const sessionId = String(args.sessionId || "").trim();
|
|
251
|
+
try {
|
|
252
|
+
let result;
|
|
253
|
+
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;
|
|
260
|
+
default: throw new OpsError(`unknown action: ${action}`, 400);
|
|
261
|
+
}
|
|
262
|
+
return renderResult(action, result);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return `${action} failed: ${e && e.message ? e.message : e}`;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 工具结果 → 模型可读的多行文本。 */
|
|
271
|
+
function renderResult(action, r) {
|
|
272
|
+
switch (action) {
|
|
273
|
+
case "list": {
|
|
274
|
+
const lines = [
|
|
275
|
+
`session ${r.session.id}${r.session.parentSession ? ` (branch of ${r.session.parentSession})` : ""}`,
|
|
276
|
+
`running: ${r.running}; total: ${r.total}; visible: ${r.visibleCount}`,
|
|
277
|
+
];
|
|
278
|
+
for (const m of r.messages) {
|
|
279
|
+
lines.push(`seq ${m.seq} [${m.visible ? "visible" : "shadowed"}] ${m.role}: ${m.snippet}`);
|
|
280
|
+
}
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
case "revert":
|
|
284
|
+
case "delete":
|
|
285
|
+
return `${r.mode} ok: shadowed ${r.shadowedCount} node(s) from seq ${r.seq}; marker event seq ${r.eventSeq}`;
|
|
286
|
+
case "branch":
|
|
287
|
+
return `branch ok: new session ${r.newId} (parent ${r.parentId ?? "n/a"}), kept ${r.kept ?? "?"} event(s)`;
|
|
288
|
+
case "restore":
|
|
289
|
+
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
|
+
case "export":
|
|
291
|
+
return r.markdown;
|
|
292
|
+
default:
|
|
293
|
+
return JSON.stringify(r);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 容错工具注册:ctx 已带 tools 服务就直接注册;没有就等它出现
|
|
299
|
+
* (ctx.inject 子 fiber,永不 fatal);dsh-tools 包解析失败也只跳过。
|
|
300
|
+
*/
|
|
301
|
+
function registerToolTolerantly(ctx) {
|
|
302
|
+
function tryRegister(targetCtx) {
|
|
303
|
+
const tools = targetCtx.get("tools");
|
|
304
|
+
if (!tools || typeof tools.register !== "function") return false;
|
|
305
|
+
// 动态 import:包缺失时只跳过工具,不拖垮插件的 HTTP 面。
|
|
306
|
+
import("@deepseek-ai/dsh-tools")
|
|
307
|
+
.then(({ defineTool }) => {
|
|
308
|
+
const ops = {
|
|
309
|
+
list: (c, id) => opsList(c, id),
|
|
310
|
+
revert: (c, id, seq) => opsCommit(c, "revert", id, seq),
|
|
311
|
+
delete: (c, id, seq) => opsCommit(c, "delete", id, seq),
|
|
312
|
+
branch: (c, id, upToSeq) => opsBranch(c, id, upToSeq),
|
|
313
|
+
restore: (c, id, seq) => opsRestore(c, id, seq),
|
|
314
|
+
export: (c, id, seq) => opsExport(c, id, seq),
|
|
315
|
+
};
|
|
316
|
+
tools.register(createMessageOpsTool({ defineTool, ops, ctx: targetCtx }));
|
|
317
|
+
})
|
|
318
|
+
.catch(() => { /* dsh-tools 不可解析:跳过工具注册,不影响其余功能 */ });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
if (tryRegister(ctx)) return;
|
|
322
|
+
ctx.inject(["tools"], (sub) => { tryRegister(sub); });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// 插件入口:HTTP 路由 + 容错工具注册
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export function apply(ctx) {
|
|
330
|
+
function registerHttp(host, targetCtx) {
|
|
331
|
+
// --- GET /api/message-ops/messages:消息列表(只读) ---------------------
|
|
332
|
+
targetCtx.effect(() => host.register({
|
|
333
|
+
kind: "exact",
|
|
334
|
+
path: "/api/message-ops/messages",
|
|
335
|
+
handler: async (req, res) => {
|
|
336
|
+
if (!fence(req, res)) return;
|
|
337
|
+
try {
|
|
338
|
+
const url = new URL(req.url, "http://localhost");
|
|
339
|
+
return sendJson(res, 200, await opsList(targetCtx, url.searchParams.get("sessionId") || ""));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}), "dsh-message-ops: messages route");
|
|
345
|
+
|
|
346
|
+
// --- POST revert / delete:live-session surface replace ------------------
|
|
347
|
+
const commitHandler = (mode) => async (req, res) => {
|
|
348
|
+
if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
349
|
+
if (!writeFence(req, res)) return;
|
|
350
|
+
const body = await readFencedBody(req, res);
|
|
351
|
+
if (body === undefined) return;
|
|
352
|
+
if (!body || typeof body !== "object") return sendJson(res, 400, { ok: false, error: "invalid json" });
|
|
353
|
+
try {
|
|
354
|
+
return sendJson(res, 200, opsCommit(targetCtx, mode, body.sessionId, body.seq));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
targetCtx.effect(() => host.register({
|
|
361
|
+
kind: "exact", path: "/api/message-ops/revert", handler: commitHandler("revert"),
|
|
362
|
+
}), "dsh-message-ops: revert route");
|
|
363
|
+
|
|
364
|
+
targetCtx.effect(() => host.register({
|
|
365
|
+
kind: "exact", path: "/api/message-ops/delete", handler: commitHandler("delete"),
|
|
366
|
+
}), "dsh-message-ops: delete route");
|
|
367
|
+
|
|
368
|
+
// --- POST branch:磁盘级 fork(唯一非破坏操作) ---------------------------
|
|
369
|
+
targetCtx.effect(() => host.register({
|
|
370
|
+
kind: "exact",
|
|
371
|
+
path: "/api/message-ops/branch",
|
|
372
|
+
handler: async (req, res) => {
|
|
373
|
+
if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
374
|
+
if (!writeFence(req, res)) return;
|
|
375
|
+
const body = await readFencedBody(req, res);
|
|
376
|
+
if (body === undefined) return;
|
|
377
|
+
if (!body || typeof body !== "object") return sendJson(res, 400, { ok: false, error: "invalid json" });
|
|
378
|
+
try {
|
|
379
|
+
return sendJson(res, 200, opsBranch(targetCtx, body.sessionId, body.upToSeq));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
}), "dsh-message-ops: branch route");
|
|
385
|
+
|
|
386
|
+
// --- POST restore:回滚恢复(重放语义,见 ops-core.js 注释) --------------
|
|
387
|
+
targetCtx.effect(() => host.register({
|
|
388
|
+
kind: "exact",
|
|
389
|
+
path: "/api/message-ops/restore",
|
|
390
|
+
handler: async (req, res) => {
|
|
391
|
+
if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
392
|
+
if (!writeFence(req, res)) return;
|
|
393
|
+
const body = await readFencedBody(req, res);
|
|
394
|
+
if (body === undefined) return;
|
|
395
|
+
if (!body || typeof body !== "object") return sendJson(res, 400, { ok: false, error: "invalid json" });
|
|
396
|
+
try {
|
|
397
|
+
return sendJson(res, 200, await opsRestore(targetCtx, body.sessionId, body.seq));
|
|
398
|
+
} catch (err) {
|
|
399
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
}), "dsh-message-ops: restore route");
|
|
403
|
+
|
|
404
|
+
// --- GET export:Markdown 附件下载 ----------------------------------------
|
|
405
|
+
targetCtx.effect(() => host.register({
|
|
406
|
+
kind: "exact",
|
|
407
|
+
path: "/api/message-ops/export",
|
|
408
|
+
handler: async (req, res) => {
|
|
409
|
+
if (!fence(req, res)) return;
|
|
410
|
+
try {
|
|
411
|
+
const url = new URL(req.url, "http://localhost");
|
|
412
|
+
const sessionId = url.searchParams.get("sessionId") || "";
|
|
413
|
+
const rawSeq = url.searchParams.get("seq");
|
|
414
|
+
const seq = rawSeq === null || rawSeq === "" ? undefined : Number(rawSeq);
|
|
415
|
+
const { header, markdown, upToSeq } = await opsExport(targetCtx, sessionId, seq);
|
|
416
|
+
const suffix = upToSeq === undefined ? "full" : `seq-${upToSeq}`;
|
|
417
|
+
res.writeHead(200, {
|
|
418
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
419
|
+
"content-disposition": `attachment; filename="${header.id}-${suffix}.md"`,
|
|
420
|
+
});
|
|
421
|
+
res.end(markdown);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}), "dsh-message-ops: export route");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const ws = ctx.get("webServer");
|
|
430
|
+
if (ws !== undefined) registerHttp(ws, ctx);
|
|
431
|
+
else ctx.inject(["webServer"], (sub) => registerHttp(sub.webServer, sub));
|
|
432
|
+
|
|
433
|
+
registerToolTolerantly(ctx);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export default { name, inject, apply };
|