dsh-session-conductor 1.3.8 → 1.3.9

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.
@@ -9,7 +9,8 @@
9
9
  import { log } from '../shared/log.js';
10
10
  import { MSG_NEED_SESSION_ID } from '../shared/constants.js';
11
11
  import { titleString, foldTitle, workspaceNameOf, archiveTitleWithWs, stripArchiveWsPrefix } from './rename/title.js';
12
- import { unarchiveSession, deleteSession, undoLastMessage, deleteBatchSessions, deleteByRule } from './session-ops.js';
12
+ import { unarchiveSession, deleteSession, undoLastMessage, deleteBatchSessions, deleteByRule, emptyTrash, purgeExpiredTrash } from './session-ops.js';
13
+ import { listTrash, latestBatch, restoreSessions, retentionDays, setRetentionDays, loadTrash } from './trash.js';
13
14
 
14
15
  /** 按规则删除时「超期未活跃天数」入参上限(约 10 年):防离谱值把全部会话一起圈进去。 */
15
16
  const MAX_INACTIVE_DAYS = 3650;
@@ -229,3 +230,133 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
229
230
  },
230
231
  });
231
232
  }
233
+
234
+ /**
235
+ * 注册回收站相关路由。
236
+ *
237
+ * 设计要点:删除只是「打标记」,所以还原几乎零成本(文件从没动过);
238
+ * 真正删盘只发生在「到期清理」与「一键清空」,两者都要二次确认或由定时器触发。
239
+ * @param {object} deps
240
+ * @param {object} deps.webServer 宿主 webServer 服务
241
+ * @param {object} deps.ctx 插件 ctx
242
+ * @param {Function} deps.send 响应发送工具
243
+ * @param {Function} deps.readJson 请求体解析工具
244
+ * @param {Function} deps.invalidateSessionListCache 会话列表缓存失效(还原后要立刻重新出现)
245
+ */
246
+ export function registerTrashRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache }) {
247
+ const touchList = () => {
248
+ try { invalidateSessionListCache?.(); } catch { /* 失效失败不影响回收站操作 */ }
249
+ };
250
+
251
+ // ---------- GET /api/session-conductor/trash ----------
252
+ webServer.register({
253
+ kind: "exact",
254
+ path: "/api/session-conductor/trash",
255
+ handler: async (req, res) => {
256
+ try {
257
+ if ((req.method ?? "GET") !== "GET") return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
258
+ send(res, 200, {
259
+ ok: true,
260
+ retentionDays: retentionDays(ctx),
261
+ items: listTrash(ctx),
262
+ latestBatch: latestBatch(ctx),
263
+ });
264
+ } catch (error) {
265
+ send(res, 500, { ok: false, error: { code: "trash-list-failed", message: String(error?.message ?? error) } });
266
+ }
267
+ },
268
+ });
269
+
270
+ // ---------- POST /api/session-conductor/trash/restore ----------
271
+ // 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
+ },
287
+ });
288
+
289
+ // ---------- POST /api/session-conductor/trash/restore-last-batch ----------
290
+ // 撤销:还原最近一个批次的全部删除(一次删除操作 = 一个 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
+ },
305
+ });
306
+
307
+ // ---------- POST /api/session-conductor/trash/empty ----------
308
+ // 一键清空:立即真删(不可恢复),要求 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
+ },
325
+ });
326
+
327
+ // ---------- POST /api/session-conductor/trash/retention ----------
328
+ // 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
+ },
345
+ });
346
+
347
+ // ---------- POST /api/session-conductor/trash/purge ----------
348
+ // 手动触发到期清理(面板打开时也会自动触发一次;这里供脚本/排查用)
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
+ },
361
+ });
362
+ }
@@ -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 { markDeleted, forgetTrashed, expiredIds, loadTrash, saveTrashNow } from "./trash.js";
22
+ import { invalidateSessionListCache } from "../sessions/list-cache.js";
21
23
  import { buildSessionListCached } from "../sessions/list.js";
22
24
 
23
25
  // ---------- 插件开关配置(:落盘 <DSH_HOME>/session-conductor/config.json)----------
@@ -105,9 +107,10 @@ export async function unarchiveSession(ctx, sessionId) {
105
107
  });
106
108
  }
107
109
 
108
- /** 删除会话(per-session 串行锁):拒绝运行中/续跑中 → detach → 取消归档 → 记账清理 → 删磁盘日志 → 清理遗留定时器。
109
- * 同一会话的并发删除请求排队串行执行(withDeleteLock),不做交错。 */
110
- export async function deleteSession(ctx, sessionId) {
110
+ /** 真删(物理删除):拒绝运行中/续跑中 → detach → 取消归档 → 记账清理 → 删磁盘日志 → 清理遗留定时器。
111
+ * 同一会话的并发删除排队串行执行(withDeleteLock),不做交错。
112
+ * ⚠️ 只由回收站调用(到期清理 / 一键清空),面板上的「删除」走 deleteSession(只打标记)。 */
113
+ export async function deleteSessionPhysically(ctx, sessionId) {
111
114
  return withDeleteLock(sessionId, async () => {
112
115
  const sessions = ctx.get("sessions");
113
116
  const workspaceRegistry = ctx.get("workspaceRegistry");
@@ -195,6 +198,68 @@ export async function deleteSession(ctx, sessionId) {
195
198
  });
196
199
  }
197
200
 
201
+ // ---------- 删除 = 打回收站标记 ----------
202
+
203
+ /**
204
+ * 会话快照(回收站列表展示用):只查便宜来源(内存 live → 落盘索引),**不跑全量列表构建**。
205
+ * 预检/快照的成本上限是「读一次内存或索引」,不是「扫一遍所有会话日志」。
206
+ */
207
+ 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
+ }
219
+ }
220
+
221
+ /**
222
+ * 删除会话 = **只打回收站标记**(会话文件原样保留)。
223
+ * 运行中/续跑中仍然拒绝(删一个正在跑的会话没有意义)。
224
+ * 真正删盘只发生在:回收站到期(保留天数可配,默认 7 天)或「一键清空」。
225
+ */
226
+ export async function deleteSession(ctx, sessionId) {
227
+ const result = await deleteBatchSessions(ctx, [sessionId]);
228
+ if (result.deleted.length > 0) return { ok: true, trashed: true, batchId: result.batchId };
229
+ const reason = result.skipped[0]?.reason ?? "error";
230
+ const message = reason === "running" ? "会话正在运行,无法删除" : "删除失败:会话不存在或不可读";
231
+ return { ok: false, error: { code: reason, message } };
232
+ }
233
+
234
+ /** 到期真删:把超过保留天数的条目从磁盘上删掉(只由定时器/手动清理调用)。 */
235
+ export async function purgeExpiredTrash(ctx, now = Date.now()) {
236
+ const ids = expiredIds(ctx, now);
237
+ const deleted = [];
238
+ for (const id of ids) {
239
+ try {
240
+ const result = await deleteSessionPhysically(ctx, id);
241
+ if (result?.ok) { forgetTrashed(ctx, [id]); deleted.push(id); }
242
+ } catch { /* 单条失败跳过,下次再试 */ }
243
+ }
244
+ if (deleted.length > 0) { saveTrashNow(ctx); invalidateSessionListCache(); }
245
+ return { deleted, remaining: ids.length - deleted.length };
246
+ }
247
+
248
+ /** 一键清空:立即真删回收站里的全部会话(不可恢复)。 */
249
+ export async function emptyTrash(ctx) {
250
+ 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
+ }
258
+ saveTrashNow(ctx);
259
+ invalidateSessionListCache();
260
+ return { deleted, remaining: ids.length - deleted.length };
261
+ }
262
+
198
263
  // ---------- 撤回最后一条用户消息 ----------
199
264
 
200
265
  /**
@@ -329,48 +394,33 @@ export async function deleteBatchSessions(ctx, sessionIds) {
329
394
  const ids = [...new Set(
330
395
  (Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === "string" && id !== "")
331
396
  )];
332
- const deleted = [];
333
397
  const skipped = [];
334
- // 修复:预检会话是否存在(live 或已持久化),不存在的进 skipped(not-found),
335
- // 不再被误报为「已删除」。persistence 不可用/返回空(如测试 mock)时保守处理:
336
- // 仅当「明确知道不存在」才跳过,否则交给 deleteSession 正常判定。
337
- const persistence = ctx.get("sessionPersistence");
338
- const persistIds = new Set();
339
- let canCheckPersist = false;
340
- try {
341
- // 持久化会话权威来源 = persistence.list()。
342
- // ⚠️ 宿主返回的是 **SessionPersistenceSnapshot**(`{ header, revision, ... }`),
343
- // 不是裸 header —— 只认 `m.id` 会得到空集合,于是每个 id 都被判成 not-found,
344
- // 批量删除「全部跳过」正是这么来的(列表侧早就按 `entry.header ?? entry` 兼容了)。
345
- if (persistence?.list) {
346
- const metas = await persistence.list();
347
- if (Array.isArray(metas)) {
348
- canCheckPersist = true;
349
- for (const m of metas) {
350
- const id = m?.id ?? m?.header?.id;
351
- if (typeof id === "string" && id !== "") persistIds.add(id);
352
- }
353
- }
354
- }
355
- } catch { canCheckPersist = false; }
398
+ const targets = [];
356
399
  const sessionsSvc = ctx.get("sessions");
357
- const liveIds = new Set(sessionsSvc?.list?.()?.map((s) => s.id) ?? []);
400
+ const { findSessionLogById, sessionsRootOf } = await import("../sessions/events.js");
358
401
  for (const id of ids) {
359
- // 仅当列表查询可用且明确不在其中 → not-found;否则尝试删除
360
- const knownMissing = canCheckPersist ? (!liveIds.has(id) && !persistIds.has(id)) : false;
361
- if (knownMissing) {
362
- skipped.push({ sessionId: id, reason: "not-found" });
363
- continue;
364
- }
365
- try {
366
- const deleteResult = await deleteSession(ctx, id);
367
- if (deleteResult.ok) deleted.push(id);
368
- else skipped.push({ sessionId: id, reason: deleteResult.error?.code ?? "error" });
369
- } catch {
370
- skipped.push({ sessionId: id, reason: "error" });
402
+ // 运行中 / 续跑中:拒绝(删一个正在跑的会话没有意义)
403
+ const live = sessionsSvc?.get?.(id);
404
+ if (live && hasOpenTurn(live.events)) { skipped.push({ sessionId: id, reason: "running" }); continue; }
405
+ if (continueJobs.has(id)) { skipped.push({ sessionId: id, reason: "running" }); continue; }
406
+ // 存在性:live 或磁盘上确实有日志;都没有才 not-found(避免往回收站里塞垃圾 id)
407
+ let exists = Boolean(live);
408
+ if (!exists) {
409
+ try { exists = Boolean(findSessionLogById(id, sessionsRootOf())); } catch { exists = false; }
371
410
  }
411
+ if (!exists) { skipped.push({ sessionId: id, reason: "not-found" }); continue; }
412
+ targets.push(id);
372
413
  }
373
- return { requested: ids.length, deleted, skipped };
414
+ if (targets.length === 0) return { requested: ids.length, deleted: [], skipped, batchId: null };
415
+
416
+ // 快照(回收站列表要显示标题/目录/删除时间)+ 统一批次号
417
+ const metaById = {};
418
+ for (const id of targets) metaById[id] = await describeForTrash(ctx, id);
419
+ const { batchId, marked } = markDeleted(ctx, targets, metaById);
420
+
421
+ // 列表里立刻隐藏(面板/工具都读列表与索引,两处都要失效)
422
+ invalidateSessionListCache();
423
+ return { requested: ids.length, deleted: marked, skipped, batchId };
374
424
  }
375
425
 
376
426
  /**
@@ -0,0 +1,206 @@
1
+ // 会话回收站:删除只打标记,文件原样保留;到期或手动清空才真正删盘。
2
+ //
3
+ // 【为什么这么设计】删除是不可逆操作,而会话日志动辄几十 MB。把「删除」降级为
4
+ // 「在索引里打一个标记」,就同时得到三件事:
5
+ // ① 还原是秒级且零风险(文件从没动过);
6
+ // ② 误删可撤销(按批次整批还原);
7
+ // ③ 真正删盘集中在两个可控时机 —— 到期(保留天数可配,默认 7 天)或一键清空。
8
+ //
9
+ // 【落盘】<DSH_HOME>/storages/dsh-session-conductor/trash.json,原子写(tmp + rename)。
10
+ // 条目:{ [sessionId]: { deletedAt, batchId, title, cwd, updatedAt, archived } }
11
+ // 不存在/损坏都按「空回收站」处理,绝不影响列表与删除本身。
12
+
13
+ import { cfg } from "../core/config.js";
14
+ import { getSection, setSection, saveStoreNow } from "../core/index-store.js";
15
+
16
+ const TRASH_VERSION = 1;
17
+ const DEFAULT_RETENTION_DAYS = 7;
18
+ const MAX_RETENTION_DAYS = 365;
19
+ const DAY_MS = 24 * 3600 * 1000;
20
+
21
+ let trashCache = null;
22
+
23
+ /** 读回收站(进程内只读一次;索引不可用按空处理)。
24
+ * 落盘统一走 <DSH_HOME>/storages/dsh-session-conductor/index.json 的 trash section。 */
25
+ export function loadTrash(ctx) {
26
+ if (trashCache !== null) return trashCache;
27
+ trashCache = { version: TRASH_VERSION, entries: {} };
28
+ try {
29
+ const section = getSection(ctx, "trash");
30
+ if (section?.entries && typeof section.entries === "object") {
31
+ trashCache = { version: TRASH_VERSION, entries: section.entries };
32
+ if (Number.isFinite(section.retentionDays)) trashCache.retentionDays = section.retentionDays;
33
+ }
34
+ } catch {
35
+ // 索引不可用:按空回收站继续
36
+ }
37
+ return trashCache;
38
+ }
39
+
40
+ /** 立即落盘(交给统一索引存储做原子写)。失败只丢内存态,不抛。 */
41
+ export function saveTrashNow(ctx) {
42
+ if (trashCache === null) return;
43
+ try {
44
+ setSection(ctx, "trash", {
45
+ entries: trashCache.entries,
46
+ ...(Number.isFinite(trashCache.retentionDays) ? { retentionDays: trashCache.retentionDays } : {}),
47
+ });
48
+ saveStoreNow(ctx);
49
+ } catch {
50
+ // 写盘失败:下次再试,不影响「已标记」的内存态
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 保留天数:**优先取回收站文件里存的值**(面板上改的就是它),
56
+ * 没设置过才回落到配置 trashRetentionDays / 默认 7 天。范围 1~365。
57
+ */
58
+ export function retentionDays(ctx) {
59
+ const stored = ctx ? Number(loadTrash(ctx).retentionDays) : NaN;
60
+ if (Number.isFinite(stored) && stored >= 1) return Math.min(MAX_RETENTION_DAYS, Math.round(stored));
61
+ const days = Number(cfg?.trashRetentionDays);
62
+ if (!Number.isFinite(days) || days < 1) return DEFAULT_RETENTION_DAYS;
63
+ return Math.min(MAX_RETENTION_DAYS, Math.round(days));
64
+ }
65
+
66
+ /** 设置保留天数(写回收站文件,立即生效)。 */
67
+ export function setRetentionDays(ctx, days) {
68
+ const value = Math.min(MAX_RETENTION_DAYS, Math.max(1, Math.round(Number(days) || DEFAULT_RETENTION_DAYS)));
69
+ const trash = loadTrash(ctx);
70
+ trash.retentionDays = value;
71
+ saveTrashNow(ctx);
72
+ return value;
73
+ }
74
+
75
+ /** 当前被标记删除的会话 id 集合(列表/搜索据此隐藏)。 */
76
+ export function trashedIds(ctx) {
77
+ return new Set(Object.keys(loadTrash(ctx).entries));
78
+ }
79
+
80
+ /** 单个会话是否在回收站里。 */
81
+ export function isTrashed(ctx, sessionId) {
82
+ return typeof sessionId === "string" && sessionId !== "" && sessionId in loadTrash(ctx).entries;
83
+ }
84
+
85
+ /**
86
+ * 回收站列表(按删除时间倒序),附带到期时间与剩余天数。
87
+ * @returns {Array<{sessionId,title,cwd,updatedAt,archived,deletedAt,batchId,expiresAt,remainingDays}>}
88
+ */
89
+ export function listTrash(ctx) {
90
+ const ttl = retentionDays(ctx) * DAY_MS;
91
+ const now = Date.now();
92
+ return Object.entries(loadTrash(ctx).entries)
93
+ .map(([sessionId, entry]) => {
94
+ const deletedAt = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
95
+ const expiresAt = deletedAt + ttl;
96
+ return {
97
+ sessionId,
98
+ title: entry?.title ?? null,
99
+ cwd: entry?.cwd ?? null,
100
+ updatedAt: entry?.updatedAt ?? null,
101
+ archived: entry?.archived === true,
102
+ deletedAt,
103
+ batchId: entry?.batchId ?? "",
104
+ expiresAt,
105
+ remainingDays: Math.max(0, Math.ceil((expiresAt - now) / DAY_MS)),
106
+ };
107
+ })
108
+ .sort((a, b) => b.deletedAt - a.deletedAt);
109
+ }
110
+
111
+ /** 生成一个批次号(一次删除操作 = 一个批次,撤销按批次整批还原)。 */
112
+ export function newBatchId() {
113
+ return `b${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
114
+ }
115
+
116
+ /**
117
+ * 标记删除(不碰文件)。
118
+ * @param {object} ctx
119
+ * @param {Array<string>} sessionIds
120
+ * @param {object} [metaById] 会话快照(title/cwd/updatedAt/archived),用于回收站列表展示
121
+ * @param {string} [batchId] 指定批次(批量删除传同一个)
122
+ * @returns {{batchId:string, marked:string[]}}
123
+ */
124
+ export function markDeleted(ctx, sessionIds, metaById = {}, batchId = newBatchId()) {
125
+ const trash = loadTrash(ctx);
126
+ const marked = [];
127
+ const now = Date.now();
128
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
129
+ if (typeof id !== "string" || id === "") continue;
130
+ const meta = metaById?.[id] ?? {};
131
+ trash.entries[id] = {
132
+ deletedAt: now,
133
+ batchId,
134
+ title: meta.title ?? null,
135
+ cwd: meta.cwd ?? null,
136
+ updatedAt: typeof meta.updatedAt === "number" ? meta.updatedAt : null,
137
+ archived: meta.archived === true,
138
+ };
139
+ marked.push(id);
140
+ }
141
+ if (marked.length > 0) saveTrashNow(ctx);
142
+ return { batchId, marked };
143
+ }
144
+
145
+ /**
146
+ * 还原指定会话(删标记,文件本来就在)。
147
+ * @returns {{restored:string[], missing:string[]}}
148
+ */
149
+ export function restoreSessions(ctx, sessionIds) {
150
+ const trash = loadTrash(ctx);
151
+ const restored = [];
152
+ const missing = [];
153
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
154
+ if (typeof id !== "string" || id === "") continue;
155
+ if (id in trash.entries) {
156
+ delete trash.entries[id];
157
+ restored.push(id);
158
+ } else {
159
+ missing.push(id);
160
+ }
161
+ }
162
+ if (restored.length > 0) saveTrashNow(ctx);
163
+ return { restored, missing };
164
+ }
165
+
166
+ /** 最近一个批次(撤销用)。空回收站返回 null。 */
167
+ export function latestBatch(ctx) {
168
+ const entries = Object.entries(loadTrash(ctx).entries);
169
+ if (entries.length === 0) return null;
170
+ let best = null;
171
+ for (const [sessionId, entry] of entries) {
172
+ const at = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
173
+ if (best === null || at > best.deletedAt) {
174
+ best = { sessionId, deletedAt: at, batchId: entry?.batchId ?? "" };
175
+ }
176
+ }
177
+ if (best === null) return null;
178
+ const ids = entries.filter(([, e]) => (e?.batchId ?? "") === best.batchId).map(([id]) => id);
179
+ return { batchId: best.batchId, deletedAt: best.deletedAt, ids };
180
+ }
181
+
182
+ /** 已到期(超过保留天数)的会话 id。 */
183
+ export function expiredIds(ctx, now = Date.now()) {
184
+ const ttl = retentionDays(ctx) * DAY_MS;
185
+ return Object.entries(loadTrash(ctx).entries)
186
+ .filter(([, entry]) => {
187
+ const at = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
188
+ return at > 0 && now - at >= ttl;
189
+ })
190
+ .map(([id]) => id);
191
+ }
192
+
193
+ /** 从回收站移除条目(真删完成后调用;不落盘,由调用方统一保存)。 */
194
+ export function forgetTrashed(ctx, sessionIds) {
195
+ const trash = loadTrash(ctx);
196
+ let removed = 0;
197
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
198
+ if (id in trash.entries) { delete trash.entries[id]; removed += 1; }
199
+ }
200
+ return removed;
201
+ }
202
+
203
+ /** 测试钩子:清空内存态。 */
204
+ export function resetTrashForTest() {
205
+ trashCache = null;
206
+ }
@@ -13,13 +13,10 @@
13
13
  // 【落盘】<DSH_HOME>/storages/dsh-session-conductor/value-index.json,原子写(tmp + rename),
14
14
  // 节流 2 秒合并多次写入;任何异常都退化为「没有索引」,绝不影响筛选本身。
15
15
 
16
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
17
- import path from "node:path";
18
-
19
- import { cfg, resolveDshHome } from "../core/config.js";
16
+ import { cfg } from "../core/config.js";
17
+ import { getSection, setSection, saveStoreNow } from "../core/index-store.js";
20
18
 
21
19
  const VALUE_INDEX_VERSION = 1;
22
- const VALUE_INDEX_SUBDIR = ["storages", "dsh-session-conductor"];
23
20
  const DEFAULT_TTL_HOURS = 24;
24
21
  const SAVE_DEBOUNCE_MS = 2000;
25
22
 
@@ -29,27 +26,18 @@ let indexPath = null;
29
26
  let indexDirty = false;
30
27
  let indexTimer = null;
31
28
 
32
- /** 索引文件绝对路径(首次解析后缓存)。 */
33
- export function valueIndexFilePath(ctx) {
34
- if (indexPath !== null) return indexPath;
35
- const home = resolveDshHome(ctx, cfg);
36
- indexPath = home ? path.join(home, ...VALUE_INDEX_SUBDIR, "value-index.json") : "";
37
- return indexPath;
38
- }
39
-
40
- /** 读索引(进程内只读一次;文件不存在/损坏都按空索引处理,不向上抛)。 */
29
+ /** 读索引(进程内只读一次;索引不可用都按空处理,不向上抛)。
30
+ * 落盘统一走 <DSH_HOME>/storages/dsh-session-conductor/index.json 的 value section。 */
41
31
  export function loadValueIndex(ctx) {
42
32
  if (indexCache !== null) return indexCache;
43
33
  indexCache = { version: VALUE_INDEX_VERSION, entries: {} };
44
- const file = valueIndexFilePath(ctx);
45
- if (!file) return indexCache;
46
34
  try {
47
- const parsed = JSON.parse(readFileSync(file, "utf8"));
48
- if (parsed?.version === VALUE_INDEX_VERSION && parsed.entries && typeof parsed.entries === "object") {
49
- indexCache = { version: VALUE_INDEX_VERSION, entries: parsed.entries };
35
+ const section = getSection(ctx, "value");
36
+ if (section?.entries && typeof section.entries === "object") {
37
+ indexCache = { version: VALUE_INDEX_VERSION, entries: section.entries };
50
38
  }
51
39
  } catch {
52
- // 首次运行 / 文件损坏:按空索引继续,本次算完会覆盖写入
40
+ // 索引不可用:按空索引继续
53
41
  }
54
42
  return indexCache;
55
43
  }
@@ -112,13 +100,10 @@ export function forgetValueVerdict(ctx, sessionId) {
112
100
 
113
101
  /** 立即落盘(原子写:tmp + rename)。失败只丢缓存,不抛。 */
114
102
  export function saveValueIndexNow(ctx) {
115
- const file = valueIndexFilePath(ctx);
116
- if (!file || indexCache === null) return;
103
+ if (indexCache === null) return;
117
104
  try {
118
- mkdirSync(path.dirname(file), { recursive: true });
119
- const tmpPath = `${file}.tmp-${process.pid}`;
120
- writeFileSync(tmpPath, JSON.stringify(indexCache), "utf8");
121
- renameSync(tmpPath, file);
105
+ setSection(ctx, "value", { entries: indexCache.entries });
106
+ saveStoreNow(ctx);
122
107
  indexDirty = false;
123
108
  } catch {
124
109
  // 写盘失败(只读卷/磁盘满):本次不落盘,下次继续尝试
package/lib/i18n/en.json CHANGED
@@ -13,6 +13,21 @@
13
13
  "tab.archived": "Archived",
14
14
  "tab.active": "Active",
15
15
  "tab.help": "Help",
16
+ "tab.trash": "Trash",
17
+ "trash.retention.label": "Keep",
18
+ "trash.retention.unit": "days",
19
+ "trash.retention.save": "Save",
20
+ "trash.undo": "Undo last delete",
21
+ "trash.undo.title": "Restore the latest batch ({n})",
22
+ "trash.restoreSelected": "Restore selected ({n})",
23
+ "trash.selectAll": "Select all",
24
+ "trash.empty": "Empty trash",
25
+ "trash.emptyConfirm": "Emptying the trash permanently deletes {n} sessions (irreversible). Continue?",
26
+ "trash.restore": "Restore",
27
+ "trash.emptyList": "Trash is empty",
28
+ "trash.hint": "Deleting only marks the session; files stay until the retention period ends or you empty the trash",
29
+ "trash.remaining": "{n} day(s) left",
30
+ "trash.nothingToUndo": "Nothing to undo",
16
31
  "list.empty": "No sessions yet",
17
32
  "list.archivedEmpty": "Nothing archived",
18
33
  "list.activeEmpty": "No active sessions (live/running)",
@@ -62,8 +77,8 @@
62
77
  "action.analyze": "Analyze",
63
78
  "action.busy": "Working…",
64
79
  "action.retry": "Retry",
65
- "confirm.delete.title": "Permanently delete session \"{title}\"?",
66
- "confirm.delete.body": "This cannot be undone (the session log will be removed too).",
80
+ "confirm.delete.title": "Delete session \"{title}\"?",
81
+ "confirm.delete.body": "The session moves to the Trash and can be restored from the Trash tab at any time. Files are only removed when the retention period (default 7 days) expires or you empty the trash.",
67
82
  "error.action": "Action failed: {message}",
68
83
  "meta.time.now": "just now",
69
84
  "meta.time.minutes": "{n}m ago",
@@ -71,8 +86,8 @@
71
86
  "meta.time.days": "{n}d ago",
72
87
  "meta.time.months": "{n}mo ago",
73
88
  "meta.time.years": "{n}y ago",
74
- "help.delete.title": "Delete session (added by this plugin)",
75
- "help.delete.desc": "The built-in UI has no way to delete a session. This plugin adds it: refuses to delete a running session, double-checks before deleting, removes the log for good.",
89
+ "help.delete.title": "Delete sessions (added by this plugin)",
90
+ "help.delete.desc": "The built-in UI has no way to delete a session; this plugin adds it. Deleting only marks the session - files stay untouched: it goes to the Trash tab, where you can restore one, restore several, or undo the latest batch. Running sessions are refused. Files are really removed only when the retention period expires (configurable, default 7 days) or you empty the trash.",
76
91
  "help.autoRename.title": "Auto-rename (added by this plugin)",
77
92
  "help.autoRename.desc": "Turn on \"Auto-rename\" for a session and after each finished turn the plugin asks an AI to compare recent session content against the current title; when the topic has clearly drifted it generates a new title and renames automatically. Titles carry a live state suffix: \"(运行中)\" = turn in progress, \"(已中断)\" = crashed/errored and waiting to resume; updated at turn boundaries. Rate-limited (5 min interval, needs a few new messages) to keep token costs low.",
78
93
  "help.autoContinue.title": "Auto-resume (added by this plugin)",
@@ -99,10 +114,10 @@
99
114
  "search.hits": "{n} sessions matched",
100
115
  "search.close": "Collapse results",
101
116
  "deleteBatch.action": "Delete selected ({n})",
102
- "deleteBatch.confirm.title": "Permanently delete the {n} selected sessions?",
103
- "deleteBatch.confirm.body": "This cannot be undone (logs are removed too); running sessions are skipped automatically.",
104
- "deleteBatch.done": "Deleted {deleted}{skippedText}",
105
- "deleteBatch.skipped": ", skipped {n} (running/undeletable)",
117
+ "deleteBatch.confirm.title": "Delete the {n} selected sessions?",
118
+ "deleteBatch.confirm.body": "They move to the Trash and can be restored anytime. Running sessions are skipped automatically.",
119
+ "deleteBatch.done": "Moved {deleted} to the Trash{skippedText}",
120
+ "deleteBatch.skipped": ", skipped {n} (running / not deletable)",
106
121
  "deleteByRule.action": "Filter",
107
122
  "deleteByRule.label.archivedOnly": "Archived only",
108
123
  "deleteByRule.label.inactiveDays": "Inactive for more than {n} days (0 = any)",
@@ -202,4 +217,4 @@
202
217
  "armCurNone": "Current: not selected",
203
218
  "renderFail": "Settings render failed: "
204
219
  }
205
- }
220
+ }