dsh-session-conductor 1.3.9 → 1.3.11

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.
@@ -41,194 +41,141 @@ export function shouldInvalidateListCache(method, body) {
41
41
  * @param {Function} deps.invalidateSessionListCache 会话列表缓存失效
42
42
  */
43
43
  export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache }) {
44
- /**
45
- * 写请求才失效列表缓存(只读预览跳过);判定见 shouldInvalidateListCache。
46
- * 调用点必须在 readJson 之后 —— 要看到 body.dryRun 才能判定。
47
- */
48
- const invalidateUnlessDryRun = (req, body) => {
49
- if (shouldInvalidateListCache(req.method, body)) invalidateSessionListCache();
50
- };
44
+ /**
45
+ * 写请求才失效列表缓存(只读预览跳过);判定见 shouldInvalidateListCache。
46
+ * 必须在 readJson 之后调用 —— 要看到 body.dryRun 才能判定。
47
+ */
48
+ const invalidateUnlessDryRun = (req, body) => {
49
+ if (shouldInvalidateListCache(req.method, body)) invalidateSessionListCache();
50
+ };
51
51
 
52
- // ---------- POST /api/session-conductor/archive ----------
53
- webServer.register({
54
- kind: "exact",
55
- path: "/api/session-conductor/archive",
56
- handler: async (req, res) => {
57
- try {
58
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
59
- const body = await readJson(req);
60
- invalidateUnlessDryRun(req, body);
61
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
62
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
63
- }
64
- const registry = ctx.get("workspaceRegistry");
65
- if (!registry) throw new Error("workspaceRegistry 服务不可用");
66
- // 归档时给标题加「[工作区名] 」前缀(数据层带前缀,面板显示剥离,已归档视图按前缀分组)。
67
- // 读会话 → 取原标题 → 加前缀 → rename(幂等:已带前缀不叠加)。失败不阻断归档。
68
- try {
69
- const sessions = ctx.get("sessions");
70
- const titleService = ctx.get("sessionTitle");
71
- const session = sessions?.get(body.sessionId);
72
- const cwd = session?.header?.cwd ?? null;
73
- const ws = workspaceNameOf(ctx, cwd);
74
- const currentTitle = titleString(titleService?.get(session)) ?? (session ? foldTitle(session.events) : null) ?? "";
75
- if (titleService && ws !== "" && currentTitle !== "") {
76
- const { title: newTitle } = archiveTitleWithWs(currentTitle, ws);
77
- if (newTitle !== currentTitle) {
78
- titleService.rename(session, newTitle);
79
- // 归档改的是标题(加工作区前缀)与归档态 —— 两者都在列表里可见,必须作废缓存
80
- try {
81
- const { markSessionListStale } = await import("../sessions/list-cache.js");
82
- markSessionListStale(ctx, body.sessionId);
83
- } catch { /* 作废失败不影响归档 */ }
84
- log(ctx, `归档加工作区前缀 ${body.sessionId}: "${currentTitle}" → "${newTitle}"`);
85
- }
86
- }
87
- } catch (titleError) {
88
- log(ctx, `归档标题加前缀失败(不阻断归档): ${String(titleError?.message ?? titleError)}`);
89
- }
90
- await registry.archiveSession(body.sessionId);
91
- send(res, 200, { ok: true });
92
- } catch (error) {
93
- send(res, 400, { ok: false, error: { code: "archive-failed", message: String(error?.message ?? error) } });
94
- }
95
- },
96
- });
52
+ /** 读 body + 按需失效列表缓存:各路由共用的前置步骤。 */
53
+ const prepareBody = async (req) => {
54
+ const body = await readJson(req);
55
+ invalidateUnlessDryRun(req, body);
56
+ return body;
57
+ };
97
58
 
98
- // ---------- POST /api/session-conductor/unarchive ----------
99
- webServer.register({
100
- kind: "exact",
101
- path: "/api/session-conductor/unarchive",
102
- handler: async (req, res) => {
103
- try {
104
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
105
- const body = await readJson(req);
106
- invalidateUnlessDryRun(req, body);
107
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
108
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
109
- }
110
- // 取消归档时去掉「[工作区名] 」前缀还原原标题(数据层带前缀,取消归档即还原)。
111
- try {
112
- const sessions = ctx.get("sessions");
113
- const titleService = ctx.get("sessionTitle");
114
- const session = sessions?.get(body.sessionId);
115
- const currentTitle = titleString(titleService?.get(session)) ?? (session ? foldTitle(session.events) : null) ?? "";
116
- if (titleService && currentTitle !== "") {
117
- const { title: stripped } = stripArchiveWsPrefix(currentTitle);
118
- if (stripped !== currentTitle) {
119
- titleService.rename(session, stripped);
120
- try {
121
- const { markSessionListStale } = await import("../sessions/list-cache.js");
122
- markSessionListStale(ctx, body.sessionId);
123
- } catch { /* 作废失败不影响取消归档 */ }
124
- log(ctx, `取消归档还原标题 ${body.sessionId}: "${currentTitle}" → "${stripped}"`);
125
- }
126
- }
127
- } catch (titleError) {
128
- log(ctx, `取消归档还原标题失败(不阻断): ${String(titleError?.message ?? titleError)}`);
129
- }
130
- await unarchiveSession(ctx, body.sessionId);
131
- send(res, 200, { ok: true });
132
- } catch (error) {
133
- send(res, 400, { ok: false, error: { code: "unarchive-failed", message: String(error?.message ?? error) } });
59
+ /**
60
+ * 注册一条路由:统一 try/catch + 读 body + 失效缓存(可选校验 sessionId)。
61
+ * 原先 6 条路由各抄一遍这段样板,既重复又让业务代码多两层缩进。
62
+ * @param {string} path 路由路径
63
+ * @param {string} failCode 失败时的错误码
64
+ * @param {(body: object, res: object) => Promise<void>} run 业务体
65
+ * @param {{requireSessionId?: boolean}} [opts] requireSessionId=false 用于批量/按规则接口
66
+ */
67
+ const route = (path, failCode, run, { requireSessionId = true } = {}) => webServer.register({
68
+ kind: "exact",
69
+ path,
70
+ handler: async (req, res) => {
71
+ try {
72
+ const body = await prepareBody(req);
73
+ if (requireSessionId && (typeof body?.sessionId !== "string" || body.sessionId === "")) {
74
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
134
75
  }
135
- },
136
- });
76
+ await run(body, res);
77
+ } catch (error) {
78
+ send(res, 400, { ok: false, error: { code: failCode, message: String(error?.message ?? error) } });
79
+ }
80
+ },
81
+ });
137
82
 
138
- // ---------- POST /api/session-conductor/delete ----------
139
- webServer.register({
140
- kind: "exact",
141
- path: "/api/session-conductor/delete",
142
- handler: async (req, res) => {
143
- try {
144
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
145
- const body = await readJson(req);
146
- invalidateUnlessDryRun(req, body);
147
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
148
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
149
- }
150
- const deleteResult = await deleteSession(ctx, body.sessionId);
151
- if (!deleteResult.ok) return send(res, 409, deleteResult);
152
- send(res, 200, { ok: true });
153
- } catch (error) {
154
- send(res, 400, { ok: false, error: { code: "delete-failed", message: String(error?.message ?? error) } });
155
- }
156
- },
157
- });
83
+ /**
84
+ * 归档 / 取消归档共用的标题改写:读原标题 → 变换 → rename → 作废列表缓存。
85
+ * 失败只记日志、不阻断主流程(归档状态本身仍要落库)。
86
+ * @param {(currentTitle: string, workspaceName: string) => string|null} transform 返回 null 表示不改
87
+ */
88
+ const rewriteTitleForArchive = async (sessionId, transform, label) => {
89
+ try {
90
+ const sessions = ctx.get("sessions");
91
+ const titleService = ctx.get("sessionTitle");
92
+ const session = sessions?.get(sessionId);
93
+ const cwd = session?.header?.cwd ?? null;
94
+ const currentTitle = titleString(titleService?.get(session)) ?? (session ? foldTitle(session.events) : null) ?? "";
95
+ if (!titleService || currentTitle === "") return;
96
+ const nextTitle = transform(currentTitle, workspaceNameOf(ctx, cwd));
97
+ if (nextTitle === null || nextTitle === currentTitle) return;
98
+ titleService.rename(session, nextTitle);
99
+ // 标题是列表里可见的属性:改完必须让内存缓存与落盘索引一起作废
100
+ try {
101
+ const { markSessionListStale } = await import("../sessions/list-cache.js");
102
+ markSessionListStale(ctx, sessionId);
103
+ } catch { /* 作废失败不影响归档 */ }
104
+ log(ctx, `${label} ${sessionId}: "${currentTitle}" → "${nextTitle}"`);
105
+ } catch (titleError) {
106
+ log(ctx, `${label}失败(不阻断): ${String(titleError?.message ?? titleError)}`);
107
+ }
108
+ };
158
109
 
159
- // ---------- POST /api/session-conductor/undo-message ----------
160
- // 撤回最后一条用户消息:直接操作会话日志文件,删除最后一条 user/message
161
- // 及其后的整轮回复;dryRun=true 只返回预览(消息文本/将删事件数)不执行;二次确认由前端做。
162
- webServer.register({
163
- kind: "exact",
164
- path: "/api/session-conductor/undo-message",
165
- handler: async (req, res) => {
166
- try {
167
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
168
- const body = await readJson(req);
169
- invalidateUnlessDryRun(req, body);
170
- if (typeof body?.sessionId !== "string" || body.sessionId === "") {
171
- return send(res, 400, { ok: false, error: { code: "bad-request", message: MSG_NEED_SESSION_ID } });
172
- }
173
- const undoResult = await undoLastMessage(ctx, body.sessionId, { dryRun: body?.dryRun === true });
174
- if (!undoResult.ok) return send(res, 409, undoResult);
175
- send(res, 200, undoResult);
176
- } catch (error) {
177
- send(res, 400, { ok: false, error: { code: "undo-failed", message: String(error?.message ?? error) } });
178
- }
179
- },
180
- });
110
+ // ---------- POST /api/session-conductor/archive ----------
111
+ // 归档时给标题加「[工作区名] 」前缀(数据层带前缀,面板显示剥离,已归档视图按前缀分组)。
112
+ route("/api/session-conductor/archive", "archive-failed", async (body, res) => {
113
+ const registry = ctx.get("workspaceRegistry");
114
+ if (!registry) throw new Error("workspaceRegistry 服务不可用");
115
+ await rewriteTitleForArchive(
116
+ body.sessionId,
117
+ (currentTitle, workspaceName) => (workspaceName === "" ? null : archiveTitleWithWs(currentTitle, workspaceName).title),
118
+ "归档加工作区前缀",
119
+ );
120
+ await registry.archiveSession(body.sessionId);
121
+ send(res, 200, { ok: true });
122
+ });
181
123
 
182
- // ---------- POST /api/session-conductor/delete-batch ----------
183
- // 批量删除:逐条复用删除链路(per-session 串行锁/幂等),运行中跳过不整体失败
184
- webServer.register({
185
- kind: "exact",
186
- path: "/api/session-conductor/delete-batch",
187
- handler: async (req, res) => {
188
- try {
189
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
190
- const body = await readJson(req);
191
- invalidateUnlessDryRun(req, body);
192
- // 空数组([])是合法幂等请求,应返回空结果而非报错;仅「未传/非数组」才视为参数缺失
193
- if (body?.sessionIds !== void 0 && !Array.isArray(body.sessionIds)) {
194
- return send(res, 400, { ok: false, error: { code: "bad-request", message: "sessionIds 必须是数组" } });
195
- }
196
- const batchResult = await deleteBatchSessions(ctx, body?.sessionIds ?? []);
197
- send(res, 200, { ok: true, ...batchResult });
198
- } catch (error) {
199
- send(res, 400, { ok: false, error: { code: "delete-batch-failed", message: String(error?.message ?? error) } });
200
- }
201
- },
202
- });
124
+ // ---------- POST /api/session-conductor/unarchive ----------
125
+ // 取消归档时去掉「[工作区名] 」前缀还原原标题(数据层带前缀,取消归档即还原)。
126
+ route("/api/session-conductor/unarchive", "unarchive-failed", async (body, res) => {
127
+ await rewriteTitleForArchive(
128
+ body.sessionId,
129
+ (currentTitle) => stripArchiveWsPrefix(currentTitle).title,
130
+ "取消归档还原标题",
131
+ );
132
+ await unarchiveSession(ctx, body.sessionId);
133
+ send(res, 200, { ok: true });
134
+ });
203
135
 
204
- // ---------- POST /api/session-conductor/delete-by-rule ----------
205
- // 按条件删除:归档状态/超期未活跃/cwd 前缀;dryRun=true 预览不执行
206
- webServer.register({
207
- kind: "exact",
208
- path: "/api/session-conductor/delete-by-rule",
209
- handler: async (req, res) => {
210
- try {
211
- // 失效列表缓存改到读取 body 之后:dryRun 预览是只读请求,不该打掉缓存(见 invalidateUnlessDryRun)。
212
- const body = await readJson(req);
213
- invalidateUnlessDryRun(req, body);
214
- const ruleDeleteResult = await deleteByRule(ctx, {
215
- archivedOnly: body?.archivedOnly === true,
216
- inactiveDays: typeof body?.inactiveDays === "number" && Number.isFinite(body.inactiveDays)
217
- ? Math.min(MAX_INACTIVE_DAYS, Math.max(0, Math.round(body.inactiveDays)))
218
- : 0,
219
- cwdPrefix: typeof body?.cwdPrefix === "string" ? body.cwdPrefix : "",
220
- // 低价值条件必须**透传**:面板一直有「仅低价值会话」勾选框并把它放进请求体,
221
- // 但路由原先没往下传 ⇒ deleteByRule 里 lowValue 恒为 false,这个条件等于没写,
222
- // 勾了也和没勾一样(四个条件里唯一失效的一个)。
223
- lowValue: body?.lowValue === true,
224
- dryRun: body?.dryRun === true,
225
- });
226
- send(res, 200, ruleDeleteResult);
227
- } catch (error) {
228
- send(res, 400, { ok: false, error: { code: "delete-by-rule-failed", message: String(error?.message ?? error) } });
229
- }
230
- },
136
+ // ---------- POST /api/session-conductor/delete ----------
137
+ // 删除 = 打回收站标记(文件不动),真删由回收站到期/清空执行
138
+ route("/api/session-conductor/delete", "delete-failed", async (body, res) => {
139
+ const deleteResult = await deleteSession(ctx, body.sessionId);
140
+ if (!deleteResult.ok) return send(res, 409, deleteResult);
141
+ send(res, 200, { ok: true });
142
+ });
143
+
144
+ // ---------- POST /api/session-conductor/undo-message ----------
145
+ // 撤回最后一条用户消息:直接操作会话日志文件,删除最后一条 user/message 及其后的整轮回复;
146
+ // dryRun=true 只返回预览(消息文本/将删事件数)不执行;二次确认由前端做。
147
+ route("/api/session-conductor/undo-message", "undo-failed", async (body, res) => {
148
+ const undoResult = await undoLastMessage(ctx, body.sessionId, { dryRun: body?.dryRun === true });
149
+ if (!undoResult.ok) return send(res, 409, undoResult);
150
+ send(res, 200, undoResult);
151
+ });
152
+
153
+ // ---------- POST /api/session-conductor/delete-batch ----------
154
+ // 批量删除:一次操作 = 一个批次(回收站里可整批撤销),运行中的跳过不整体失败
155
+ route("/api/session-conductor/delete-batch", "delete-batch-failed", async (body, res) => {
156
+ // 空数组([])是合法幂等请求,应返回空结果而非报错;仅「未传/非数组」才视为参数缺失
157
+ if (body?.sessionIds !== void 0 && !Array.isArray(body.sessionIds)) {
158
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "sessionIds 必须是数组" } });
159
+ }
160
+ const batchResult = await deleteBatchSessions(ctx, body?.sessionIds ?? []);
161
+ send(res, 200, { ok: true, ...batchResult });
162
+ }, { requireSessionId: false });
163
+
164
+ // ---------- POST /api/session-conductor/delete-by-rule ----------
165
+ // 按条件筛选:归档状态 / 超期未活跃 / cwd 前缀 / 低价值;dryRun=true 只回命中不删
166
+ route("/api/session-conductor/delete-by-rule", "delete-by-rule-failed", async (body, res) => {
167
+ const ruleDeleteResult = await deleteByRule(ctx, {
168
+ archivedOnly: body?.archivedOnly === true,
169
+ inactiveDays: typeof body?.inactiveDays === "number" && Number.isFinite(body.inactiveDays)
170
+ ? Math.min(MAX_INACTIVE_DAYS, Math.max(0, Math.round(body.inactiveDays)))
171
+ : 0,
172
+ cwdPrefix: typeof body?.cwdPrefix === "string" ? body.cwdPrefix : "",
173
+ // 低价值条件必须**透传**:面板会发 lowValue,路由漏传过一次 ⇒ 该条件恒 false、勾了等于没勾
174
+ lowValue: body?.lowValue === true,
175
+ dryRun: body?.dryRun === true,
231
176
  });
177
+ send(res, 200, ruleDeleteResult);
178
+ }, { requireSessionId: false });
232
179
  }
233
180
 
234
181
  /**
@@ -248,7 +195,29 @@ export function registerTrashRoutes({ webServer, ctx, send, readJson, invalidate
248
195
  try { invalidateSessionListCache?.(); } catch { /* 失效失败不影响回收站操作 */ }
249
196
  };
250
197
 
198
+ /**
199
+ * 注册一条回收站路由:统一 try/catch + 读 body。
200
+ * 与 session-ops 那组同款收敛——原先每条各抄一遍,重复且多一层缩进。
201
+ * @param {string} path 路由路径
202
+ * @param {string} failCode 失败错误码
203
+ * @param {number} failStatus 失败 HTTP 状态码
204
+ * @param {(body: object, res: object) => Promise<void>|void} run 业务体
205
+ */
206
+ const route = (path, failCode, failStatus, run) => webServer.register({
207
+ kind: "exact",
208
+ path,
209
+ handler: async (req, res) => {
210
+ try {
211
+ const body = await readJson(req);
212
+ await run(body, res);
213
+ } catch (error) {
214
+ send(res, failStatus, { ok: false, error: { code: failCode, message: String(error?.message ?? error) } });
215
+ }
216
+ },
217
+ });
218
+
251
219
  // ---------- GET /api/session-conductor/trash ----------
220
+ // 回收站列表 + 当前保留天数 + 最近批次(撤销用)
252
221
  webServer.register({
253
222
  kind: "exact",
254
223
  path: "/api/session-conductor/trash",
@@ -269,94 +238,50 @@ export function registerTrashRoutes({ webServer, ctx, send, readJson, invalidate
269
238
 
270
239
  // ---------- POST /api/session-conductor/trash/restore ----------
271
240
  // body: { sessionIds: string[] } → 还原指定会话(删标记,文件本来就在)
272
- webServer.register({
273
- kind: "exact",
274
- path: "/api/session-conductor/trash/restore",
275
- handler: async (req, res) => {
276
- try {
277
- const body = await readJson(req);
278
- const ids = Array.isArray(body?.sessionIds) ? body.sessionIds : [];
279
- if (ids.length === 0) return send(res, 400, { ok: false, error: { code: "bad-request", message: "sessionIds 不能为空" } });
280
- const result = restoreSessions(ctx, ids);
281
- if (result.restored.length > 0) touchList();
282
- send(res, 200, { ok: true, ...result });
283
- } catch (error) {
284
- send(res, 400, { ok: false, error: { code: "restore-failed", message: String(error?.message ?? error) } });
285
- }
286
- },
241
+ route("/api/session-conductor/trash/restore", "restore-failed", 400, (body, res) => {
242
+ const ids = Array.isArray(body?.sessionIds) ? body.sessionIds : [];
243
+ if (ids.length === 0) return send(res, 400, { ok: false, error: { code: "bad-request", message: "sessionIds 不能为空" } });
244
+ const restoreResult = restoreSessions(ctx, ids);
245
+ if (restoreResult.restored.length > 0) touchList();
246
+ send(res, 200, { ok: true, ...restoreResult });
287
247
  });
288
248
 
289
249
  // ---------- POST /api/session-conductor/trash/restore-last-batch ----------
290
250
  // 撤销:还原最近一个批次的全部删除(一次删除操作 = 一个 batchId)
291
- webServer.register({
292
- kind: "exact",
293
- path: "/api/session-conductor/trash/restore-last-batch",
294
- handler: async (req, res) => {
295
- try {
296
- const batch = latestBatch(ctx);
297
- if (!batch) return send(res, 200, { ok: true, batchId: null, restored: [], message: "回收站是空的" });
298
- const result = restoreSessions(ctx, batch.ids);
299
- if (result.restored.length > 0) touchList();
300
- send(res, 200, { ok: true, batchId: batch.batchId, restored: result.restored });
301
- } catch (error) {
302
- send(res, 400, { ok: false, error: { code: "restore-batch-failed", message: String(error?.message ?? error) } });
303
- }
304
- },
251
+ route("/api/session-conductor/trash/restore-last-batch", "restore-batch-failed", 400, (_body, res) => {
252
+ const batch = latestBatch(ctx);
253
+ if (!batch) return send(res, 200, { ok: true, batchId: null, restored: [], message: "回收站是空的" });
254
+ const restoreResult = restoreSessions(ctx, batch.ids);
255
+ if (restoreResult.restored.length > 0) touchList();
256
+ send(res, 200, { ok: true, batchId: batch.batchId, restored: restoreResult.restored });
305
257
  });
306
258
 
307
259
  // ---------- POST /api/session-conductor/trash/empty ----------
308
260
  // 一键清空:立即真删(不可恢复),要求 body.confirm === true
309
- webServer.register({
310
- kind: "exact",
311
- path: "/api/session-conductor/trash/empty",
312
- handler: async (req, res) => {
313
- try {
314
- const body = await readJson(req);
315
- if (body?.confirm !== true) {
316
- return send(res, 400, { ok: false, error: { code: "need-confirm", message: "清空回收站不可恢复,需传 confirm=true" } });
317
- }
318
- const result = await emptyTrash(ctx);
319
- touchList();
320
- send(res, 200, { ok: true, ...result });
321
- } catch (error) {
322
- send(res, 400, { ok: false, error: { code: "empty-failed", message: String(error?.message ?? error) } });
323
- }
324
- },
261
+ route("/api/session-conductor/trash/empty", "empty-failed", 400, async (body, res) => {
262
+ if (body?.confirm !== true) {
263
+ return send(res, 400, { ok: false, error: { code: "need-confirm", message: "清空回收站不可恢复,需传 confirm=true" } });
264
+ }
265
+ const emptyResult = await emptyTrash(ctx);
266
+ touchList();
267
+ send(res, 200, { ok: true, ...emptyResult });
325
268
  });
326
269
 
327
270
  // ---------- POST /api/session-conductor/trash/retention ----------
328
271
  // body: { days: number } → 设置保留天数(1~365)
329
- webServer.register({
330
- kind: "exact",
331
- path: "/api/session-conductor/trash/retention",
332
- handler: async (req, res) => {
333
- try {
334
- const body = await readJson(req);
335
- const days = Number(body?.days);
336
- if (!Number.isFinite(days) || days < 1) {
337
- return send(res, 400, { ok: false, error: { code: "bad-request", message: "days 必须是 ≥1 的数字" } });
338
- }
339
- const value = setRetentionDays(ctx, days);
340
- send(res, 200, { ok: true, retentionDays: value });
341
- } catch (error) {
342
- send(res, 400, { ok: false, error: { code: "retention-failed", message: String(error?.message ?? error) } });
343
- }
344
- },
272
+ route("/api/session-conductor/trash/retention", "retention-failed", 400, (body, res) => {
273
+ const days = Number(body?.days);
274
+ if (!Number.isFinite(days) || days < 1) {
275
+ return send(res, 400, { ok: false, error: { code: "bad-request", message: "days 必须是 ≥1 的数字" } });
276
+ }
277
+ send(res, 200, { ok: true, retentionDays: setRetentionDays(ctx, days) });
345
278
  });
346
279
 
347
280
  // ---------- POST /api/session-conductor/trash/purge ----------
348
281
  // 手动触发到期清理(面板打开时也会自动触发一次;这里供脚本/排查用)
349
- webServer.register({
350
- kind: "exact",
351
- path: "/api/session-conductor/trash/purge",
352
- handler: async (req, res) => {
353
- try {
354
- const result = await purgeExpiredTrash(ctx);
355
- if (result.deleted.length > 0) touchList();
356
- send(res, 200, { ok: true, ...result });
357
- } catch (error) {
358
- send(res, 400, { ok: false, error: { code: "purge-failed", message: String(error?.message ?? error) } });
359
- }
360
- },
282
+ route("/api/session-conductor/trash/purge", "purge-failed", 400, async (_body, res) => {
283
+ const purgeResult = await purgeExpiredTrash(ctx);
284
+ if (purgeResult.deleted.length > 0) touchList();
285
+ send(res, 200, { ok: true, ...purgeResult });
361
286
  });
362
287
  }
@@ -1,7 +1,7 @@
1
1
  /** 会话级操作:归档恢复、删除(单个/批量/按规则)、撤回最后一条消息。 */
2
2
 
3
3
  import { rm, readdir } from "node:fs/promises";
4
- import { readFileSync, copyFileSync, writeFileSync, renameSync, mkdirSync, chmodSync, statSync } from "node:fs";
4
+ import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { hasOpenTurn } from "../sessions/turn.js";
7
7
  import { lazyRepair, lazyZstd, lazyValue } from "../core/lazy.js";
@@ -18,6 +18,8 @@ import {
18
18
  } from "../core/config.js";
19
19
  import { sessionEventsOf } from "../sessions/events.js";
20
20
  import { detachSessionAgent } from "../sessions/detach.js";
21
+ import { sessionMetaOf } from "../sessions/lookup.js";
22
+ import { replaceFileSync } from "../shared/atomic-file.js";
21
23
  import { markDeleted, forgetTrashed, expiredIds, loadTrash, saveTrashNow } from "./trash.js";
22
24
  import { invalidateSessionListCache } from "../sessions/list-cache.js";
23
25
  import { buildSessionListCached } from "../sessions/list.js";
@@ -205,17 +207,9 @@ export async function deleteSessionPhysically(ctx, sessionId) {
205
207
  * 预检/快照的成本上限是「读一次内存或索引」,不是「扫一遍所有会话日志」。
206
208
  */
207
209
  async function describeForTrash(ctx, sessionId) {
208
- try {
209
- const live = ctx.get("sessions")?.get?.(sessionId);
210
- if (live) {
211
- return { title: live.title ?? null, cwd: live.header?.cwd ?? null, updatedAt: null, archived: false };
212
- }
213
- const { loadListDiskCache } = await import("../sessions/list-cache.js");
214
- const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
215
- return { title: entry?.title ?? null, cwd: entry?.cwd ?? null, updatedAt: entry?.updatedAt ?? null, archived: false };
216
- } catch {
217
- return {};
218
- }
210
+ const meta = await sessionMetaOf(ctx, sessionId);
211
+ if (!meta) return {};
212
+ return { title: meta.title, cwd: meta.cwd, updatedAt: meta.updatedAt, archived: meta.archived };
219
213
  }
220
214
 
221
215
  /**
@@ -232,32 +226,44 @@ export async function deleteSession(ctx, sessionId) {
232
226
  }
233
227
 
234
228
  /** 到期真删:把超过保留天数的条目从磁盘上删掉(只由定时器/手动清理调用)。 */
235
- export async function purgeExpiredTrash(ctx, now = Date.now()) {
236
- const ids = expiredIds(ctx, now);
229
+ /**
230
+ * 逐条真删:到期清理与一键清空共用同一条链路(单条失败只跳过,不影响其余)。
231
+ * @returns {{deleted: string[], remaining: number}}
232
+ */
233
+ async function purgeMany(ctx, ids) {
237
234
  const deleted = [];
238
235
  for (const id of ids) {
239
236
  try {
240
237
  const result = await deleteSessionPhysically(ctx, id);
241
- if (result?.ok) { forgetTrashed(ctx, [id]); deleted.push(id); }
242
- } catch { /* 单条失败跳过,下次再试 */ }
238
+ if (result?.ok) {
239
+ forgetTrashed(ctx, [id]);
240
+ deleted.push(id);
241
+ }
242
+ } catch {
243
+ // 单条失败跳过:下一次清理会再试,不阻断其余会话
244
+ }
243
245
  }
244
- if (deleted.length > 0) { saveTrashNow(ctx); invalidateSessionListCache(); }
245
246
  return { deleted, remaining: ids.length - deleted.length };
246
247
  }
247
248
 
249
+ /** 到期真删:把超过保留天数的条目从磁盘上删掉(只由定时器/手动清理调用)。 */
250
+ export async function purgeExpiredTrash(ctx, now = Date.now()) {
251
+ const ids = expiredIds(ctx, now);
252
+ const result = await purgeMany(ctx, ids);
253
+ if (result.deleted.length > 0) {
254
+ saveTrashNow(ctx);
255
+ invalidateSessionListCache();
256
+ }
257
+ return result;
258
+ }
259
+
248
260
  /** 一键清空:立即真删回收站里的全部会话(不可恢复)。 */
249
261
  export async function emptyTrash(ctx) {
250
262
  const ids = Object.keys(loadTrash(ctx).entries);
251
- const deleted = [];
252
- for (const id of ids) {
253
- try {
254
- const result = await deleteSessionPhysically(ctx, id);
255
- if (result?.ok) { forgetTrashed(ctx, [id]); deleted.push(id); }
256
- } catch { /* 单条失败跳过 */ }
257
- }
263
+ const result = await purgeMany(ctx, ids);
258
264
  saveTrashNow(ctx);
259
265
  invalidateSessionListCache();
260
- return { deleted, remaining: ids.length - deleted.length };
266
+ return result;
261
267
  }
262
268
 
263
269
  // ---------- 撤回最后一条用户消息 ----------
@@ -376,10 +382,7 @@ export async function undoLastMessage(ctx, sessionId, { dryRun = false } = {}) {
376
382
  // 6. 多帧写回(header 一帧 + body 一帧,带 checksum),原子替换
377
383
  const { encodeSessionText } = await lazyRepair();
378
384
  const newBuf = await encodeSessionText(newText);
379
- const undoTmpPath = filePath + ".undotmp";
380
- writeFileSync(undoTmpPath, newBuf);
381
- try { chmodSync(undoTmpPath, statSync(filePath).mode); } catch { /* CIFS 无 chmod,尽力 */ }
382
- renameSync(undoTmpPath, filePath);
385
+ replaceFileSync(filePath, newBuf, { tmpSuffix: ".undotmp" });
383
386
 
384
387
  return { ok: true, preview, removedLineCount, removedEventCount, remainingEventCount: check.eventCount, backup: bak };
385
388
  });
package/lib/repair.js CHANGED
@@ -280,7 +280,8 @@ export async function repairCorruptSessions(ctx, { dryRun = false } = {}) {
280
280
  report.fixed += 1;
281
281
  report.backups.push(backupPath);
282
282
  report.sessions.push({ ...entry, fixedBlocks: fixedCount, backup: backupPath });
283
- try { ctx.logger?.info?.(`dsh-session-conductor: 已修复会话 ${id}(${fixedCount} 个块)`); } catch {}
283
+ // 日志失败不影响修复结果本身
284
+ try { ctx.logger?.info?.(`dsh-session-conductor: 已修复会话 ${id}(${fixedCount} 个块)`); } catch { /* 日志失败忽略 */ }
284
285
  } catch (error) {
285
286
  report.errors.push({ id, error: `修复失败: ${String(error?.message ?? error)}` });
286
287
  report.sessions.push(entry);
@@ -394,8 +395,9 @@ export async function scanDualFormatSessions() {
394
395
  const zstd = findSessionLog(sessionDir);
395
396
  if (!plain || !zstd) continue;
396
397
  let plainBytes = 0, zstdBytes = 0;
397
- try { plainBytes = statSync(plain).size; } catch {}
398
- try { zstdBytes = statSync(zstd).size; } catch {}
398
+ // stat 失败(文件刚被移走等)按 0 字节展示,不阻断扫描
399
+ try { plainBytes = statSync(plain).size; } catch { /* 读不到按 0 */ }
400
+ try { zstdBytes = statSync(zstd).size; } catch { /* 读不到按 0 */ }
399
401
  report.dual.push({ id: sess, dir: sessionDir, plainBytes, zstdBytes });
400
402
  }
401
403
  }