dsh-session-conductor 1.3.10 → 1.3.13

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.
@@ -21,42 +21,54 @@ const VALUE_LLM_MAX_TOKENS = 120;
21
21
  * @param {Function} [onError] 日志回调
22
22
  * @returns {Promise<object>} 会话 id → {value:"high"|"medium"|"low", reason}
23
23
  */
24
+ /** 价值判定用的系统提示词(长常量提到模块级,函数体只留编排)。 */
25
+ const VALUE_SYSTEM_PROMPT = "你是 DSH(DeepSeek Harness)会话价值分析师。给定一个会话的标题与最后回复片段,判断它在长期保留意义上的价值。高价值=含独有信息/未完成任务/学习成果/资产/待继续工作;低价值=灰尘/重复/过时空壳/可安全丢弃。输出严格 JSON,无 Markdown:{\"value\":\"high\"|\"medium\"|\"low\",\"reason\":\"一句话中文理由\"}";
26
+
27
+ /**
28
+ * 单个会话的 LLM 价值判定。
29
+ * 无模型路由 / 输出解析失败都返回 null(由调用方的规则评分兜底)。
30
+ * @returns {Promise<{value:string, reason?:string}|null>}
31
+ */
32
+ async function judgeSessionValue(llm, session, texts) {
33
+ const route = resolveRoute(session, llm);
34
+ if (!route?.provider || !route.model) return null; // 无模型路由 → 跳过
35
+ const title = session.title || session.cwd || session.id || "(无标题)";
36
+ const lastText = (texts?.[session.id] || "").slice(0, VALUE_PROMPT_TEXT_MAX_CHARS);
37
+ const text = [`会话标题:${title}`, "最后回复片段:", lastText].join("\n");
38
+ const assembler = new BlockAssembler();
39
+ for await (const chunk of llm.stream({
40
+ provider: route.provider,
41
+ model: route.model,
42
+ messages: [createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor" } })],
43
+ system: VALUE_SYSTEM_PROMPT,
44
+ maxTokens: VALUE_LLM_MAX_TOKENS,
45
+ sessionId: session.id,
46
+ purpose: "session-conductor-value-analysis",
47
+ signal: AbortSignal.timeout(AUTO_RENAME_TIMEOUT_MS),
48
+ })) assembler.push(chunk);
49
+ const raw = assembler.blocks().filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
50
+ return parseValueJson(raw);
51
+ }
52
+
24
53
  export async function analyzeValueWithLlm(ctx, sessions, texts, onError) {
25
54
  const out = {};
26
- let llm;
55
+ let llm = null;
27
56
  try {
28
57
  llm = ctx.get("llm");
29
- } catch { llm = null; }
58
+ } catch {
59
+ llm = null;
60
+ }
30
61
  if (!llm) {
31
62
  if (typeof onError === "function") onError("会话价值 LLM 判断跳过:llm 服务不可用");
32
63
  return out;
33
64
  }
34
- for (const s of sessions ?? []) {
65
+ for (const session of sessions ?? []) {
35
66
  try {
36
- const route = resolveRoute(s, llm);
37
- if (!route?.provider || !route.model) continue; // 无模型路由 → 跳过,规则兜底
38
- const title = s.title || s.cwd || s.id || "(无标题)";
39
- const last = (texts?.[s.id] || "").slice(0, VALUE_PROMPT_TEXT_MAX_CHARS);
40
- const system = "你是 DSH(DeepSeek Harness)会话价值分析师。给定一个会话的标题与最后回复片段,判断它在长期保留意义上的价值。高价值=含独有信息/未完成任务/学习成果/资产/待继续工作;低价值=灰尘/重复/过时空壳/可安全丢弃。输出严格 JSON,无 Markdown:{\"value\":\"high\"|\"medium\"|\"low\",\"reason\":\"一句话中文理由\"}";
41
- const text = [`会话标题:${title}`, "最后回复片段:", last].join("\n");
42
- const assembler = new BlockAssembler();
43
- for await (const chunk of llm.stream({
44
- provider: route.provider,
45
- model: route.model,
46
- messages: [createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor" } })],
47
- system,
48
- maxTokens: VALUE_LLM_MAX_TOKENS,
49
- sessionId: s.id,
50
- purpose: "session-conductor-value-analysis",
51
- signal: AbortSignal.timeout(AUTO_RENAME_TIMEOUT_MS),
52
- })) assembler.push(chunk);
53
- const blocks = assembler.blocks();
54
- const raw = blocks.filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
55
- const parsed = parseValueJson(raw);
56
- if (parsed) out[s.id] = { value: parsed.value, reason: parsed.reason || "" };
67
+ const parsed = await judgeSessionValue(llm, session, texts);
68
+ if (parsed) out[session.id] = { value: parsed.value, reason: parsed.reason || "" };
57
69
  } catch (error) {
58
- if (typeof onError === "function") onError(`会话价值 LLM 判断失败 ${s.id}: ${String(error?.message ?? error)}`);
59
- /* 单个会话失败跳过,规则兜底 */
70
+ if (typeof onError === "function") onError(`会话价值 LLM 判断失败 ${session.id}: ${String(error?.message ?? error)}`);
71
+ // 单个会话失败跳过,规则兜底
60
72
  }
61
73
  }
62
74
  return out;
@@ -12,6 +12,7 @@
12
12
  // —— 自己写日志等于拿对方会话的完整性冒险。因此本域**不碰任何日志文件**。
13
13
 
14
14
  import { log } from "../../shared/log.js";
15
+ import { sessionMetaOf } from "../../sessions/lookup.js";
15
16
 
16
17
  /** 单条消息长度上限(字符):够写交接要点,不够塞长文档(长内容应落共享文件)。 */
17
18
  export const TEXT_LIMIT = 8 * 1024;
@@ -143,7 +144,7 @@ export function currentSessionId(agent) {
143
144
  /**
144
145
  * 调用方会话的工作目录。
145
146
  * 用途:`create_session` 不传 cwd/workspaceId 时的缺省值 —— 新会话落在**发起它的那个会话**
146
- * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录 `/volume1/@appstore/DeepSeekHarness-NAS`,
147
+ * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录(即本机程序目录),
147
148
  * 对干活毫无意义)。
148
149
  */
149
150
  export function callerCwd(agent) {
@@ -173,19 +174,11 @@ export async function listSessions(ctx) {
173
174
  */
174
175
  export async function describeTarget(ctx, sessionId) {
175
176
  try {
176
- const live = ctx.get?.("sessions")?.get?.(sessionId);
177
- if (live) {
178
- const cwd = String(live.header?.cwd ?? "(无工作区)");
179
- return `(进行中 | ${cwd})`;
180
- }
181
- const { loadListDiskCache } = await import("../../sessions/list-cache.js");
182
- const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
183
- if (entry) {
184
- const cwd = String(entry.cwd ?? "(无工作区)");
185
- const updated = typeof entry.updatedAt === "number" ? new Date(entry.updatedAt).toISOString() : "未知";
186
- return `(已落盘 | ${cwd} | 最后活动 ${updated})`;
187
- }
188
- return "(⚠️ 内存与落盘索引里都没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
177
+ const meta = await sessionMetaOf(ctx, sessionId);
178
+ if (!meta) return "(⚠️ 内存与落盘索引里都没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
179
+ if (meta.live) return `(进行中 | ${String(meta.cwd ?? "(无工作区)")})`;
180
+ const updated = meta.updatedAt ? new Date(meta.updatedAt).toISOString() : "未知";
181
+ return `(已落盘 | ${String(meta.cwd ?? "(无工作区)")} | 最后活动 ${updated})`;
189
182
  } catch (error) {
190
183
  return `(目标存在性无法核对:${shortError(error)})`;
191
184
  }
@@ -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
  }