dsh-session-conductor 1.3.13 → 1.3.16
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 +23 -1
- package/cordis.patch.yml +0 -0
- package/lib/apply.js +28 -1243
- package/lib/client-parts/apply.js +0 -0
- package/lib/client-parts/components/compaction.js +0 -0
- package/lib/client-parts/components/conductor-settings.js +0 -0
- package/lib/client-parts/components/group.js +0 -0
- package/lib/client-parts/components/panel.js +0 -0
- package/lib/client-parts/components/processing.js +0 -0
- package/lib/client-parts/components/session-icon.js +0 -0
- package/lib/client-parts/components/settings.js +0 -0
- package/lib/client-parts/foundation/bootstrap.js +0 -0
- package/lib/client-parts/foundation/i18n.js +0 -0
- package/lib/client-parts/foundation/list-cache.js +0 -0
- package/lib/client-parts/foundation/styles.js +0 -0
- package/lib/core/config.js +0 -0
- package/lib/core/domain.js +0 -0
- package/lib/core/index-store.js +0 -0
- package/lib/core/lazy.js +0 -0
- package/lib/core/state.js +0 -0
- package/lib/core.js +0 -0
- package/lib/eio-repair.js +0 -0
- package/lib/features/base-routes.js +156 -0
- package/lib/features/context-usage/index.js +0 -0
- package/lib/features/continue/eligibility.js +0 -0
- package/lib/features/continue/scan.js +0 -0
- package/lib/features/continue/session.js +0 -0
- package/lib/features/inject/preset.js +0 -0
- package/lib/features/member-model/batch.js +97 -0
- package/lib/features/member-model/command.js +61 -7
- package/lib/features/member-model/create-teammate.js +0 -0
- package/lib/features/member-model/index.js +15 -1
- package/lib/features/member-model/lead.js +73 -0
- package/lib/features/member-model/locate.js +82 -1
- package/lib/features/member-model/override.js +0 -0
- package/lib/features/member-model/permission-command.js +138 -0
- package/lib/features/member-model/permission.js +213 -0
- package/lib/features/member-model/setup.js +0 -0
- package/lib/features/member-model/switch.js +137 -0
- package/lib/features/member-model/tools.js +324 -0
- package/lib/features/memory-recall/index.js +0 -0
- package/lib/features/rename/analysis.js +0 -0
- package/lib/features/rename/title.js +0 -0
- package/lib/features/rename/value.js +0 -0
- package/lib/features/session-action-routes.js +435 -0
- package/lib/features/session-messaging/index.js +0 -0
- package/lib/features/session-messaging/logic.js +0 -0
- package/lib/features/session-messaging/tools.js +0 -0
- package/lib/features/session-ops-routes.js +0 -0
- package/lib/features/settings-routes.js +400 -0
- package/lib/features/trash.js +0 -0
- package/lib/features/value-index.js +0 -0
- package/lib/features/value-report-routes.js +211 -0
- package/lib/group.js +0 -0
- package/lib/i18n/en.json +0 -0
- package/lib/i18n/zh.json +0 -0
- package/lib/index.js +0 -0
- package/lib/repair.js +0 -0
- package/lib/seq-gap-repair.js +0 -0
- package/lib/session-codec.js +0 -0
- package/lib/session-log.js +0 -0
- package/lib/sessions/detach.js +0 -0
- package/lib/sessions/events.js +0 -0
- package/lib/sessions/interruption.js +0 -0
- package/lib/sessions/list-cache.js +0 -0
- package/lib/sessions/list.js +0 -0
- package/lib/sessions/lookup.js +0 -0
- package/lib/sessions/route.js +0 -0
- package/lib/sessions/session-id.js +0 -0
- package/lib/sessions/tail-io.js +30 -0
- package/lib/sessions/turn.js +0 -0
- package/lib/shared/atomic-file.js +0 -0
- package/lib/shared/constants.js +0 -0
- package/lib/shared/json-store.js +0 -0
- package/lib/shared/log.js +0 -0
- package/lib/shared/timing.js +0 -0
- package/lib/shared/util.js +0 -0
- package/lib/template-inject.js +0 -0
- package/lib/value.js +0 -0
- package/lib/zstd-frames.js +0 -0
- package/package.json +1 -1
- package/scripts/check-member-model.mjs +0 -0
- package/skills/dsh-session-conductor-functions.md +0 -0
- package/skills/dsh-session-conductor.md +0 -0
- package/skills/dsh-session-official-doc.md +0 -0
- package/skills/task-completion-report.md +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// dsh-session-conductor — 会话动作与日志修复类 HTTP 路由
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独成文件】原先这些路由全部内联在 lib/apply.js 的 `ctx.inject(["webServer"])` 回调里,
|
|
4
|
+
// 让该回调变成上千行的巨型函数(审计 readability/max-function-length 拦截)。
|
|
5
|
+
// 这里只搬「注册」这部分,handler 体逐字保留、行为不变;依赖由调用方以 deps 传入。
|
|
6
|
+
//
|
|
7
|
+
// 本模块包含的路由(按原文件顺序):
|
|
8
|
+
// POST /search · /auto-rename · /analyze · /continue · /message · /auto-continue
|
|
9
|
+
// · /scan · /detach · /detach-all
|
|
10
|
+
// 以及会话日志修复:/repair-sessions(会话损坏扫描)· /repair-frames(zstd 帧修复)
|
|
11
|
+
// · /repair-seq-gap · /repair-eio · /repair-dual-format
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { log } from '../shared/log.js';
|
|
14
|
+
import { MSG_NEED_SESSION_ID } from '../shared/constants.js';
|
|
15
|
+
import { readJson, send } from '../http.js';
|
|
16
|
+
import { findSessionLog } from '../session-log.js';
|
|
17
|
+
import { invalidateSessionListCache } from '../sessions/list-cache.js';
|
|
18
|
+
import { detachSessionAgent, detachAllIdleSessions } from '../sessions/detach.js';
|
|
19
|
+
import { continueSession, sendMessageToSession } from '../features/continue/session.js';
|
|
20
|
+
import { runAutoScan, runAutoContinueSession } from '../features/continue/scan.js';
|
|
21
|
+
import { runAnalysis } from '../features/rename/analysis.js';
|
|
22
|
+
import { continueJobs } from '../core/state.js';
|
|
23
|
+
import { readSwitch, patchSwitch } from '../core/config.js';
|
|
24
|
+
import { lazyEio, lazyFs, lazyRepair, lazySeqGap } from '../core/lazy.js';
|
|
25
|
+
import { searchSessions } from './search.js';
|
|
26
|
+
import { SEARCH_MIN_QUERY } from './search.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 注册会话动作与日志修复类路由。
|
|
30
|
+
*
|
|
31
|
+
* @param {object} deps
|
|
32
|
+
* @param {object} deps.webServer 宿主 webServer 服务(提供 register)
|
|
33
|
+
* @param {object} deps.ctx 插件 ctx(用于 get 各服务)
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
export function registerSessionActionRoutes({ webServer, ctx }) {
|
|
37
|
+
// ---------- POST /api/session-conductor/search ----------
|
|
38
|
+
// 全文搜索:跨会话搜消息内容,返回命中会话 + 上下文片段
|
|
39
|
+
webServer.register({
|
|
40
|
+
kind: "exact",
|
|
41
|
+
path: "/api/session-conductor/search",
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
45
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
46
|
+
const body = await readJson(req);
|
|
47
|
+
const query = typeof body?.query === "string" ? body.query : "";
|
|
48
|
+
const scope = ["all", "active", "archived"].includes(body?.scope) ? body.scope : "all";
|
|
49
|
+
if (query.trim().length < SEARCH_MIN_QUERY) {
|
|
50
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: `关键词至少 ${SEARCH_MIN_QUERY} 个字符` } });
|
|
51
|
+
}
|
|
52
|
+
const searchResult = await searchSessions(ctx, query, { scope });
|
|
53
|
+
send(res, 200, { ok: true, ...searchResult });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
send(res, 400, { ok: false, error: { code: "search-failed", message: String(error?.message ?? error) } });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
// ---------- POST /api/session-conductor/auto-rename ----------
|
|
60
|
+
webServer.register({
|
|
61
|
+
kind: "exact",
|
|
62
|
+
path: "/api/session-conductor/auto-rename",
|
|
63
|
+
handler: async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
66
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
67
|
+
const body = await readJson(req);
|
|
68
|
+
if (typeof body?.sessionId !== "string" || body.sessionId === "") {
|
|
69
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
|
|
70
|
+
}
|
|
71
|
+
const enabled = body.enabled === true;
|
|
72
|
+
const setting = await patchSwitch(ctx, "autoRename", body.sessionId, { enabled }, { enabled: false });
|
|
73
|
+
let analyzed = null;
|
|
74
|
+
if (enabled) analyzed = await runAnalysis(ctx, body.sessionId); // 开启后立即分析一次
|
|
75
|
+
send(res, 200, { ok: true, enabled: setting.enabled, analyzed });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
send(res, 400, { ok: false, error: { code: "auto-rename-failed", message: String(error?.message ?? error) } });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------- POST /api/session-conductor/analyze ----------
|
|
83
|
+
webServer.register({
|
|
84
|
+
kind: "exact",
|
|
85
|
+
path: "/api/session-conductor/analyze",
|
|
86
|
+
handler: async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
89
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
90
|
+
const body = await readJson(req);
|
|
91
|
+
if (typeof body?.sessionId !== "string" || body.sessionId === "") {
|
|
92
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
|
|
93
|
+
}
|
|
94
|
+
const analysisResult = await runAnalysis(ctx, body.sessionId, {
|
|
95
|
+
manual: true, // 手动 API:跳过限频/新消息数门槛(显式意图即执行)
|
|
96
|
+
model: typeof body?.model === "string" && body.model !== "" ? body.model : undefined,
|
|
97
|
+
});
|
|
98
|
+
if (!analysisResult.ok) return send(res, 409, analysisResult);
|
|
99
|
+
send(res, 200, analysisResult);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
send(res, 400, { ok: false, error: { code: "analyze-failed", message: String(error?.message ?? error) } });
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------- POST /api/session-conductor/continue ----------
|
|
107
|
+
// 手动续跑:异步执行(回合可能耗时数分钟),立即返回 accepted,列表用 continueRunning 反映进行中。
|
|
108
|
+
webServer.register({
|
|
109
|
+
kind: "exact",
|
|
110
|
+
path: "/api/session-conductor/continue",
|
|
111
|
+
handler: async (req, res) => {
|
|
112
|
+
try {
|
|
113
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
114
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
115
|
+
const body = await readJson(req);
|
|
116
|
+
if (typeof body?.sessionId !== "string" || body.sessionId === "") {
|
|
117
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
|
|
118
|
+
}
|
|
119
|
+
const sessionId = body.sessionId;
|
|
120
|
+
const running = continueJobs.has(sessionId) || (() => {
|
|
121
|
+
try {
|
|
122
|
+
const agent = ctx.get("agents")?.get(sessionId);
|
|
123
|
+
return agent?.status === "running";
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
if (running) {
|
|
129
|
+
return send(res, 409, { ok: false, error: { code: "busy", message: "该会话已有续跑/回合在进行中" } });
|
|
130
|
+
}
|
|
131
|
+
continueSession(ctx, sessionId, { auto: false }).then((result) => {
|
|
132
|
+
if (!result?.ok) log(ctx, `手动续跑 ${sessionId} 未成功: ${result?.error?.message ?? "?"}`);
|
|
133
|
+
}).catch((error) => {
|
|
134
|
+
// 手动续跑异步链兜底——continueSession 永不 reject(prepare 段已包 try),
|
|
135
|
+
// 此处再兜一层防未来回归把 rejection 逃逸成 unhandledRejection 杀进程。
|
|
136
|
+
log(ctx, `手动续跑 ${sessionId} 异常: ${String(error?.message ?? error)}`);
|
|
137
|
+
});
|
|
138
|
+
send(res, 200, { ok: true, accepted: true, sessionId });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
send(res, 400, { ok: false, error: { code: "continue-failed", message: String(error?.message ?? error) } });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------- POST /api/session-conductor/message ----------
|
|
146
|
+
// 跨会话消息投递:{targetSessionId, message, fromSessionId?}
|
|
147
|
+
// 向目标会话投递用户消息并唤起(live followup / cold resume+followup),不受"中断"限制。
|
|
148
|
+
webServer.register({
|
|
149
|
+
kind: "exact",
|
|
150
|
+
path: "/api/session-conductor/message",
|
|
151
|
+
handler: async (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
154
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
155
|
+
const body = await readJson(req);
|
|
156
|
+
const sessionId = body?.targetSessionId || body?.sessionId;
|
|
157
|
+
const text = body?.message;
|
|
158
|
+
if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string" || text === "") {
|
|
159
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: "缺少 targetSessionId 或 message" } });
|
|
160
|
+
}
|
|
161
|
+
// 异步执行(回合可能耗时),立即返回 accepted
|
|
162
|
+
const r = await sendMessageToSession(ctx, sessionId, text, { fromSessionId: body?.fromSessionId || "" });
|
|
163
|
+
return send(res, r.ok ? 200 : (r.error?.code === "not-found" ? 404 : 400), r);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return send(res, 400, { ok: false, error: { code: "message-failed", message: String(error?.message ?? error) } });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------- POST /api/session-conductor/auto-continue ----------
|
|
171
|
+
// 开启/关闭某会话的自动续跑(显式 enabled 覆盖全局默认)。
|
|
172
|
+
webServer.register({
|
|
173
|
+
kind: "exact",
|
|
174
|
+
path: "/api/session-conductor/auto-continue",
|
|
175
|
+
handler: async (req, res) => {
|
|
176
|
+
try {
|
|
177
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
178
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
179
|
+
const body = await readJson(req);
|
|
180
|
+
if (typeof body?.sessionId !== "string" || body.sessionId === "") {
|
|
181
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
|
|
182
|
+
}
|
|
183
|
+
const enabled = body.enabled === true;
|
|
184
|
+
// 开关落盘 config.json;先取旧 entry 保留记账字段,enabled 以本次为准。
|
|
185
|
+
const entry = readSwitch("autoContinue", body.sessionId) ?? {};
|
|
186
|
+
await patchSwitch(ctx, "autoContinue", body.sessionId, { ...entry, enabled });
|
|
187
|
+
// 开启开关即触发一次续跑(force 跳过失败重试延迟)——原先要等最长
|
|
188
|
+
// scanIntervalMs(默认 5 分钟)周期扫描才续,用户体感「打开后处理中很久」。
|
|
189
|
+
// 关闭开关不触发;开启是幂等触发点,重复点不会叠加(内部有会话串行锁 + 门槛)。
|
|
190
|
+
if (enabled) {
|
|
191
|
+
runAutoContinueSession(ctx, body.sessionId, { force: true }).catch((error) => {
|
|
192
|
+
log(ctx, `开启自动续跑后立即续跑失败 ${body.sessionId}: ${String(error?.message ?? error)}`);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
send(res, 200, { ok: true, enabled, sessionId: body.sessionId, triggered: enabled });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
send(res, 400, { ok: false, error: { code: "auto-continue-failed", message: String(error?.message ?? error) } });
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------- POST /api/session-conductor/scan ----------
|
|
203
|
+
// 立即扫描一次全部会话并自动续跑(测试/手动触发用)。
|
|
204
|
+
webServer.register({
|
|
205
|
+
kind: "exact",
|
|
206
|
+
path: "/api/session-conductor/scan",
|
|
207
|
+
handler: async (req, res) => {
|
|
208
|
+
try {
|
|
209
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
210
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
211
|
+
runAutoScan(ctx).then((count) => {
|
|
212
|
+
log(ctx, `手动扫描完成(排队 ${count ?? 0})`);
|
|
213
|
+
}).catch((error) => {
|
|
214
|
+
log(ctx, `手动扫描失败: ${String(error?.message ?? error)}`);
|
|
215
|
+
});
|
|
216
|
+
send(res, 200, { ok: true, accepted: true });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
send(res, 400, { ok: false, error: { code: "scan-failed", message: String(error?.message ?? error) } });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---------- POST /api/session-conductor/detach ----------
|
|
224
|
+
// 手动释放(置为不活跃):把 live 空闲会话拆回冷状态(日志保留,可重新挂载)。
|
|
225
|
+
webServer.register({
|
|
226
|
+
kind: "exact",
|
|
227
|
+
path: "/api/session-conductor/detach",
|
|
228
|
+
handler: async (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
231
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
232
|
+
const body = await readJson(req);
|
|
233
|
+
if (typeof body?.sessionId !== "string" || body.sessionId === "") {
|
|
234
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
|
|
235
|
+
}
|
|
236
|
+
const detachResult = await detachSessionAgent(ctx, body.sessionId);
|
|
237
|
+
if (!detachResult.ok) return send(res, 409, detachResult);
|
|
238
|
+
send(res, 200, detachResult);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
send(res, 400, { ok: false, error: { code: "detach-failed", message: String(error?.message ?? error) } });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------- POST /api/session-conductor/detach-all ----------
|
|
246
|
+
// 释放全部 live 空闲会话(跳过运行中 / subagent / 续跑中)。
|
|
247
|
+
webServer.register({
|
|
248
|
+
kind: "exact",
|
|
249
|
+
path: "/api/session-conductor/detach-all",
|
|
250
|
+
handler: async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
253
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
254
|
+
const detachAllResult = await detachAllIdleSessions(ctx);
|
|
255
|
+
send(res, 200, detachAllResult);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
send(res, 400, { ok: false, error: { code: "detach-all-failed", message: String(error?.message ?? error) } });
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ---------- 会话日志修复(扫描/修复损坏会话) ----------
|
|
263
|
+
webServer.register({
|
|
264
|
+
kind: "exact",
|
|
265
|
+
path: "/api/session-conductor/repair-sessions",
|
|
266
|
+
handler: async (req, res) => {
|
|
267
|
+
try {
|
|
268
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
269
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
270
|
+
const body = await readJson(req);
|
|
271
|
+
const { supportsRepair } = await lazyRepair();
|
|
272
|
+
if (!supportsRepair(ctx)) {
|
|
273
|
+
return send(res, 501, { ok: false, error: { code: "unsupported", message: "当前持久化后端不支持修复(需要 jsonl 后端)" } });
|
|
274
|
+
}
|
|
275
|
+
const dryRun = body?.dryRun === true;
|
|
276
|
+
const { scanCorruptSessions, repairCorruptSessions } = await lazyRepair();
|
|
277
|
+
const report = dryRun ? await scanCorruptSessions(ctx) : await repairCorruptSessions(ctx, { dryRun: false });
|
|
278
|
+
if (report.error) return send(res, 500, { ok: false, error: { code: "repair-failed", message: report.error } });
|
|
279
|
+
send(res, 200, { ok: true, dryRun, report });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
send(res, 400, { ok: false, error: { code: "repair-failed", message: String(error?.message ?? error) } });
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------- POST /api/session-conductor/repair-frames ----------
|
|
287
|
+
// 会话日志 zstd 帧修复(救援恢复,):会话列表消失/corrupt Zstandard 时,
|
|
288
|
+
// 扫描并修复帧损坏(逐帧解码→多帧正确写回,自动备份)。与 repair-sessions 互补:
|
|
289
|
+
// repair-sessions 修 tool-result content 结构;repair-frames 修 zstd 帧格式。
|
|
290
|
+
webServer.register({
|
|
291
|
+
kind: "exact",
|
|
292
|
+
path: "/api/session-conductor/repair-frames",
|
|
293
|
+
handler: async (req, res) => {
|
|
294
|
+
try {
|
|
295
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
296
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
297
|
+
const body = await readJson(req);
|
|
298
|
+
const dryRun = body?.dryRun === true;
|
|
299
|
+
const { scanCorruptFrames, repairCorruptFrames } = await lazyRepair();
|
|
300
|
+
const report = dryRun ? await scanCorruptFrames() : await repairCorruptFrames({ dryRun: false });
|
|
301
|
+
send(res, 200, { ok: true, dryRun, report });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
send(res, 400, { ok: false, error: { code: "repair-frames-failed", message: String(error?.message ?? error) } });
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------- POST /api/session-conductor/repair-seq-gap ----------
|
|
309
|
+
// 会话日志 seq-gap + token-surface 修复(实测):
|
|
310
|
+
// seq gap 错位(spliced 拼接未重编号)+ replace 引用缺失 + compaction shadowedRange 未映射
|
|
311
|
+
// 三层叠加导致 history unavailable(token surface: no adjacent shadow price)。
|
|
312
|
+
// dryRun=true 扫描全部会话列出疑似 seq-gap;sessionId 指定则修复单个(自动备份 .seq-gap-backup/)。
|
|
313
|
+
webServer.register({
|
|
314
|
+
kind: "exact",
|
|
315
|
+
path: "/api/session-conductor/repair-seq-gap",
|
|
316
|
+
handler: async (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
319
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
320
|
+
const body = await readJson(req);
|
|
321
|
+
const sessionsRoot = path.join(process.env.DSH_HOME || path.join(process.env.HOME ?? "", ".dsh"), "sessions");
|
|
322
|
+
if (body?.dryRun === true) {
|
|
323
|
+
const { scanSeqGapSessions } = await lazySeqGap();
|
|
324
|
+
const list = await scanSeqGapSessions(sessionsRoot);
|
|
325
|
+
send(res, 200, { ok: true, dryRun: true, corrupt: list, total: list.length });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const sessionId = body?.sessionId;
|
|
329
|
+
if (!sessionId) { send(res, 400, { ok: false, error: { code: "session-id-required", message: "need sessionId" } }); return; }
|
|
330
|
+
// 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
|
|
331
|
+
const { readdirSync, existsSync } = await lazyFs();
|
|
332
|
+
let targetPath = null;
|
|
333
|
+
for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
334
|
+
if (!proj.isDirectory()) continue;
|
|
335
|
+
const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
|
|
336
|
+
if (p) { targetPath = p; break; }
|
|
337
|
+
}
|
|
338
|
+
if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
|
|
339
|
+
const { repairSeqGap } = await lazySeqGap();
|
|
340
|
+
const report = await repairSeqGap(targetPath, { dryRun: false });
|
|
341
|
+
send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
|
|
342
|
+
} catch (error) {
|
|
343
|
+
send(res, 400, { ok: false, error: { code: "repair-seq-gap-failed", message: String(error?.message ?? error) } });
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ---------- POST /api/session-conductor/repair-eio ----------
|
|
349
|
+
// 会话日志 EIO 坏块修复(实测固化,来源 sa6400-nested-vm-io-panic skill):
|
|
350
|
+
// 底层存储(BTRFS csum 损坏 / iSCSI LUN I/O 错误)导致 session.jsonl.zstd 某些 4KB 块
|
|
351
|
+
// 读取抛 EIO(Errno 5)→ 网关 observe 会话报 "EIO: i/o error, read"。
|
|
352
|
+
// 修复 = 块级探测找出第一个 EIO 边界 → 读 [0, 边界) 完好前缀 → 原子写回
|
|
353
|
+
// (BTRFS COW 换新块)→ DSH 加载时 readZstdPrefix/commitRepair 自动收尾 torn tail。
|
|
354
|
+
// dryRun=true 全仓扫描;sessionId 指定则修复单个(全仓定位)。
|
|
355
|
+
webServer.register({
|
|
356
|
+
kind: "exact",
|
|
357
|
+
path: "/api/session-conductor/repair-eio",
|
|
358
|
+
handler: async (req, res) => {
|
|
359
|
+
try {
|
|
360
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
361
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
362
|
+
const body = await readJson(req);
|
|
363
|
+
const { sessionsRootOf: eioSessionsRootOf } = await lazyEio();
|
|
364
|
+
const sessionsRoot = eioSessionsRootOf();
|
|
365
|
+
if (body?.dryRun === true) {
|
|
366
|
+
const { scanEioSessions } = await lazyEio();
|
|
367
|
+
const list = await scanEioSessions(sessionsRoot);
|
|
368
|
+
send(res, 200, { ok: true, dryRun: true, eio: list.eio, total: list.total, healthy: list.healthy, error: list.error });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const sessionId = body?.sessionId;
|
|
372
|
+
if (sessionId) {
|
|
373
|
+
// 定位会话日志路径(sessionId 需含 session- 前缀,cwd 目录未知 → 全仓查找)
|
|
374
|
+
const { readdirSync, existsSync } = await lazyFs();
|
|
375
|
+
let targetPath = null;
|
|
376
|
+
for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
377
|
+
if (!proj.isDirectory()) continue;
|
|
378
|
+
const p = findSessionLog(path.join(sessionsRoot, proj.name, sessionId));
|
|
379
|
+
if (p) { targetPath = p; break; }
|
|
380
|
+
}
|
|
381
|
+
if (!targetPath) { send(res, 404, { ok: false, error: { code: "session-not-found", message: sessionId } }); return; }
|
|
382
|
+
const { repairEioFile } = await lazyEio();
|
|
383
|
+
const report = await repairEioFile(targetPath);
|
|
384
|
+
send(res, report.ok ? 200 : 400, { ok: report.ok, sessionId, report });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const { repairEioSessions } = await lazyEio();
|
|
388
|
+
const report = await repairEioSessions({ dryRun: false });
|
|
389
|
+
send(res, 200, { ok: true, dryRun: false, report });
|
|
390
|
+
} catch (error) {
|
|
391
|
+
send(res, 400, { ok: false, error: { code: "repair-eio-failed", message: String(error?.message ?? error) } });
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ---------- POST /api/session-conductor/repair-dual-format ----------
|
|
397
|
+
// 双格式会话修复:会话目录同时存在 session.jsonl(明文)+ session.jsonl.zstd
|
|
398
|
+
// (压缩)→ 官方 listArtifacts() 抛 encodingMismatch → 会话列表全失败(侧边栏会话消失)。
|
|
399
|
+
// 纯磁盘级扫描(不走 sessionPersistence,后者自身会被 encodingMismatch 阻断),
|
|
400
|
+
// 把多余明文移入 .dual-format-backup/ 保留 zstd 官方格式;dryRun=true 只扫不写。
|
|
401
|
+
webServer.register({
|
|
402
|
+
kind: "exact",
|
|
403
|
+
path: "/api/session-conductor/repair-dual-format",
|
|
404
|
+
handler: async (req, res) => {
|
|
405
|
+
try {
|
|
406
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
407
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
408
|
+
const body = await readJson(req);
|
|
409
|
+
const dryRun = body?.dryRun === true;
|
|
410
|
+
// live 会话跳过:正在写入的会话不应被移动(按 id 查 sessions 服务)
|
|
411
|
+
let skipIds = [];
|
|
412
|
+
try {
|
|
413
|
+
const sessionsSvc = ctx.get("sessions");
|
|
414
|
+
skipIds = (sessionsSvc?.list?.() ?? []).map((s) => s.id);
|
|
415
|
+
} catch {
|
|
416
|
+
// sessions 服务不可用时不做 live 跳过(保守:仍只移非 live 目录)
|
|
417
|
+
}
|
|
418
|
+
const report = dryRun
|
|
419
|
+
? await (await lazyRepair()).scanDualFormatSessions()
|
|
420
|
+
// 修复:原为裸调 `repairDualFormatSessions(...)`,但该名字从未导入 ⇒ 抛
|
|
421
|
+
// ReferenceError 被下面的 catch 变成 400「repair-dual-format-failed」,非 dryRun 的
|
|
422
|
+
// 双格式修复实际从未执行过。改为与上一行同源的懒加载命名空间调用。
|
|
423
|
+
: await (await lazyRepair()).repairDualFormatSessions({ dryRun: false, skipIds });
|
|
424
|
+
if (report.error) {
|
|
425
|
+
send(res, 500, { ok: false, error: { code: "repair-dual-format-failed", message: report.error } });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
send(res, 200, { ok: true, dryRun, skipped: report.skipped ?? 0, report });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
send(res, 400, { ok: false, error: { code: "repair-dual-format-failed", message: String(error?.message ?? error) } });
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|