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.
- package/README.md +2 -1
- package/lib/apply.js +79 -15
- package/lib/client-parts/components/processing.js +18 -11
- package/lib/client.js +18 -11
- package/lib/core/config.js +42 -22
- package/lib/core/index-store.js +28 -23
- package/lib/eio-repair.js +2 -1
- package/lib/features/continue/eligibility.js +1 -1
- package/lib/features/member-model/command.js +38 -50
- package/lib/features/member-model/create-teammate.js +208 -0
- package/lib/features/member-model/locate.js +78 -18
- package/lib/features/member-model/switch.js +144 -65
- package/lib/features/memory-recall/index.js +39 -30
- package/lib/features/rename/analysis.js +25 -16
- package/lib/features/rename/title.js +26 -14
- package/lib/features/rename/value.js +38 -26
- package/lib/features/session-messaging/logic.js +7 -14
- package/lib/features/session-ops-routes.js +178 -253
- package/lib/features/session-ops.js +32 -29
- package/lib/repair.js +5 -3
- package/lib/seq-gap-repair.js +5 -5
- package/lib/sessions/events.js +11 -5
- package/lib/sessions/interruption.js +2 -2
- package/lib/sessions/lookup.js +50 -0
- package/lib/sessions/route.js +1 -1
- package/lib/shared/atomic-file.js +38 -0
- package/lib/shared/json-store.js +54 -0
- package/lib/shared/timing.js +37 -0
- package/lib/zstd-frames.js +6 -11
- package/package.json +1 -1
- package/skills/dsh-session-conductor-functions.md +1 -1
- package/skills/dsh-session-conductor.md +1 -1
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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,
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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) {
|
|
242
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
try {
|
|
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
|
}
|