dsh-session-conductor 1.3.7 → 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.
- package/README.md +7 -1
- package/lib/apply.js +73 -0
- package/lib/client-parts/apply.js +10 -8
- package/lib/client-parts/components/panel.js +321 -120
- package/lib/client-parts/foundation/bootstrap.js +8 -10
- package/lib/client.js +339 -138
- package/lib/core/config.js +7 -0
- package/lib/core/index-store.js +148 -0
- package/lib/features/rename/analysis.js +6 -0
- package/lib/features/rename/title.js +5 -0
- package/lib/features/search.js +5 -3
- package/lib/features/session-messaging/logic.js +20 -6
- package/lib/features/session-messaging/tools.js +14 -1
- package/lib/features/session-ops-routes.js +145 -1
- package/lib/features/session-ops.js +114 -41
- package/lib/features/trash.js +206 -0
- package/lib/features/value-index.js +120 -0
- package/lib/i18n/en.json +29 -14
- package/lib/i18n/zh.json +27 -12
- package/lib/sessions/list-cache.js +41 -29
- package/lib/sessions/list.js +11 -0
- package/lib/value.js +31 -0
- package/package.json +2 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 插件索引的唯一落盘位置:<DSH_HOME>/storages/dsh-session-conductor/index.json
|
|
2
|
+
//
|
|
3
|
+
// 【为什么要收敛】此前每类索引各写一个文件(list-cache.json / value-index.json / trash.json),
|
|
4
|
+
// 于是同一个目录里躺着三套「版本号 + 原子写 + 路径解析」的复制品:改一处忘一处、
|
|
5
|
+
// 备份/迁移要记三份名字、出问题时不知道先看哪个。现在统一成**一个文件、一个版本号、一个原子写**,
|
|
6
|
+
// 各模块只关心自己的 section。
|
|
7
|
+
//
|
|
8
|
+
// 【结构】
|
|
9
|
+
// {
|
|
10
|
+
// version: 1,
|
|
11
|
+
// list: { entries: { [sessionId]: {...} } }, // 会话列表解析缓存
|
|
12
|
+
// value: { entries: { [sessionId]: {...} } }, // 价值判定索引
|
|
13
|
+
// trash: { entries: { [sessionId]: {...} }, retentionDays } // 回收站
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// 【迁移】首次加载若发现旧文件,按 section 合并进来,并把旧文件改名为
|
|
17
|
+
// `<原名>.migrated`(不删、可回滚)。
|
|
18
|
+
//
|
|
19
|
+
// 【写入】原子写(tmp + rename)。整文件重写:当前规模 30~80 KB、序列化 ~1 ms,
|
|
20
|
+
// 各模块的写盘本来就有节流/低频,代价可忽略;等真到上千条再评估换存储。
|
|
21
|
+
|
|
22
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { cfg, resolveDshHome } from "./config.js";
|
|
26
|
+
|
|
27
|
+
const STORE_VERSION = 1;
|
|
28
|
+
const STORE_SUBDIR = ["storages", "dsh-session-conductor"];
|
|
29
|
+
const STORE_FILE = "index.json";
|
|
30
|
+
/** 旧文件 → section 名(迁移用) */
|
|
31
|
+
const LEGACY_FILES = [
|
|
32
|
+
["list-cache.json", "list"],
|
|
33
|
+
["value-index.json", "value"],
|
|
34
|
+
["trash.json", "trash"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
let storeCache = null;
|
|
38
|
+
let storePath = null;
|
|
39
|
+
|
|
40
|
+
/** 索引文件绝对路径(首次解析后缓存)。 */
|
|
41
|
+
export function storeFilePath(ctx) {
|
|
42
|
+
if (storePath !== null) return storePath;
|
|
43
|
+
const home = resolveDshHome(ctx, cfg);
|
|
44
|
+
storePath = home ? path.join(home, ...STORE_SUBDIR, STORE_FILE) : "";
|
|
45
|
+
return storePath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 空结构(各 section 都带默认值,调用方不必判空)。 */
|
|
49
|
+
function emptyStore() {
|
|
50
|
+
return { version: STORE_VERSION, list: { entries: {} }, value: { entries: {} }, trash: { entries: {} } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 读索引(进程内只读一次)。文件缺失/损坏 → 空结构;随后尝试从旧文件迁移一次。
|
|
55
|
+
*/
|
|
56
|
+
export function loadStore(ctx) {
|
|
57
|
+
if (storeCache !== null) return storeCache;
|
|
58
|
+
storeCache = emptyStore();
|
|
59
|
+
const file = storeFilePath(ctx);
|
|
60
|
+
if (!file) return storeCache;
|
|
61
|
+
let loaded = false;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
+
if (parsed && typeof parsed === "object") {
|
|
65
|
+
storeCache = {
|
|
66
|
+
version: STORE_VERSION,
|
|
67
|
+
list: { entries: parsed.list?.entries && typeof parsed.list.entries === "object" ? parsed.list.entries : {} },
|
|
68
|
+
value: { entries: parsed.value?.entries && typeof parsed.value.entries === "object" ? parsed.value.entries : {} },
|
|
69
|
+
trash: {
|
|
70
|
+
entries: parsed.trash?.entries && typeof parsed.trash.entries === "object" ? parsed.trash.entries : {},
|
|
71
|
+
...(Number.isFinite(parsed.trash?.retentionDays) ? { retentionDays: parsed.trash.retentionDays } : {}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
loaded = true;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// 首次运行 / 文件损坏:保持空结构
|
|
78
|
+
}
|
|
79
|
+
if (!loaded) migrateLegacyFiles(ctx);
|
|
80
|
+
return storeCache;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 一次性迁移旧文件:按 section 合并,成功则把旧文件改名 `.migrated`(保留可回滚)。
|
|
85
|
+
* 任一文件解析失败就跳过它(不阻断启动)。
|
|
86
|
+
*/
|
|
87
|
+
function migrateLegacyFiles(ctx) {
|
|
88
|
+
const file = storeFilePath(ctx);
|
|
89
|
+
if (!file) return;
|
|
90
|
+
const dir = path.dirname(file);
|
|
91
|
+
let migratedAny = false;
|
|
92
|
+
for (const [legacyName, section] of LEGACY_FILES) {
|
|
93
|
+
const legacyPath = path.join(dir, legacyName);
|
|
94
|
+
if (!existsSync(legacyPath)) continue;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
97
|
+
const entries = parsed?.entries && typeof parsed.entries === "object" ? parsed.entries : null;
|
|
98
|
+
if (entries) {
|
|
99
|
+
storeCache[section] = {
|
|
100
|
+
entries,
|
|
101
|
+
...(section === "trash" && Number.isFinite(parsed?.retentionDays) ? { retentionDays: parsed.retentionDays } : {}),
|
|
102
|
+
};
|
|
103
|
+
migratedAny = true;
|
|
104
|
+
}
|
|
105
|
+
// 迁移后改名:不删除,出问题可人工回滚
|
|
106
|
+
renameSync(legacyPath, `${legacyPath}.migrated`);
|
|
107
|
+
} catch {
|
|
108
|
+
// 旧文件坏了:跳过,保持空 section
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (migratedAny) saveStoreNow(ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 立即落盘(原子写:tmp + rename)。失败只丢本次写入,不抛。 */
|
|
115
|
+
export function saveStoreNow(ctx) {
|
|
116
|
+
const file = storeFilePath(ctx);
|
|
117
|
+
if (!file || storeCache === null) return false;
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
const tmpPath = `${file}.tmp-${process.pid}`;
|
|
121
|
+
writeFileSync(tmpPath, JSON.stringify(storeCache), "utf8");
|
|
122
|
+
renameSync(tmpPath, file);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 取某个 section(不存在时给默认值)。 */
|
|
130
|
+
export function getSection(ctx, name) {
|
|
131
|
+
const store = loadStore(ctx);
|
|
132
|
+
if (!store[name]) store[name] = { entries: {} };
|
|
133
|
+
if (!store[name].entries || typeof store[name].entries !== "object") store[name].entries = {};
|
|
134
|
+
return store[name];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 写某个 section(只改内存,落盘由调用方决定时机)。 */
|
|
138
|
+
export function setSection(ctx, name, value) {
|
|
139
|
+
const store = loadStore(ctx);
|
|
140
|
+
store[name] = value;
|
|
141
|
+
return store[name];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 测试钩子:清空内存态。 */
|
|
145
|
+
export function resetStoreForTest() {
|
|
146
|
+
storeCache = null;
|
|
147
|
+
storePath = null;
|
|
148
|
+
}
|
|
@@ -173,6 +173,12 @@ export async function analyzeSession(ctx, sessionId, opts = {}) {
|
|
|
173
173
|
} catch (error) {
|
|
174
174
|
return { ok: false, before: currentTitle, after: currentTitle, error: { code: "rename-failed", message: String(error?.message ?? error) } };
|
|
175
175
|
}
|
|
176
|
+
// 改完标题立刻让列表缓存作废:冷会话的标题来自落盘索引(revision 没变就不重算),
|
|
177
|
+
// 不作废就会出现「后端已改名、面板还显示旧标题」。
|
|
178
|
+
try {
|
|
179
|
+
const { markSessionListStale } = await import("../../sessions/list-cache.js");
|
|
180
|
+
markSessionListStale(ctx, session.id);
|
|
181
|
+
} catch { /* 缓存作废失败不影响重命名本身 */ }
|
|
176
182
|
log(ctx, `自动重命名 ${sessionId}: "${currentTitle ?? "(无标题)"}" → "${finalTitle}"`);
|
|
177
183
|
return { ok: true, renamed: true, before: currentTitle, after: finalTitle, title: finalTitle, reason: "对话主题明显偏移,已自动重命名" };
|
|
178
184
|
}
|
|
@@ -150,6 +150,11 @@ export async function refreshTitleState(ctx, session) {
|
|
|
150
150
|
if (next === current) return null;
|
|
151
151
|
try {
|
|
152
152
|
titleService.rename(session, next);
|
|
153
|
+
// 状态后缀变了也是「可见属性变了」,同样要让列表缓存与落盘索引作废
|
|
154
|
+
try {
|
|
155
|
+
const { markSessionListStale } = await import("../../sessions/list-cache.js");
|
|
156
|
+
markSessionListStale(ctx, session.id);
|
|
157
|
+
} catch { /* 作废失败不影响标题刷新 */ }
|
|
153
158
|
return next;
|
|
154
159
|
} catch (error) {
|
|
155
160
|
log(ctx, `标题状态刷新失败 ${session.id}: ${String(error?.message ?? error)}`);
|
package/lib/features/search.js
CHANGED
|
@@ -88,7 +88,9 @@ export function searchEventsText(events, query, { perSessionMax = SEARCH_PER_SES
|
|
|
88
88
|
export async function searchSessions(ctx, query, { scope = "all", maxSessions = SEARCH_MAX_SESSIONS, perSessionMax = SEARCH_PER_SESSION_MAX } = {}) {
|
|
89
89
|
const queryText = String(query ?? "").trim();
|
|
90
90
|
if (queryText.length < SEARCH_MIN_QUERY) return { query: queryText, scanned: 0, hits: [] };
|
|
91
|
-
|
|
91
|
+
// 列表用缓存版本(不 force):一次全量重建本机实测 41~226 秒,而搜索只需要「会话 id 清单」,
|
|
92
|
+
// 新建会话最多晚一个 TTL(默认 5 秒)出现,代价远小于每次都把上百份日志重解一遍。
|
|
93
|
+
const sessions = await buildSessionListCached(ctx);
|
|
92
94
|
const hits = [];
|
|
93
95
|
let scanned = 0;
|
|
94
96
|
for (const s of sessions) {
|
|
@@ -99,7 +101,7 @@ export async function searchSessions(ctx, query, { scope = "all", maxSessions =
|
|
|
99
101
|
try {
|
|
100
102
|
const found = await sessionEventsOf(ctx, s.id);
|
|
101
103
|
if (!found) continue;
|
|
102
|
-
const matches = searchEventsText(found.events,
|
|
104
|
+
const matches = searchEventsText(found.events, queryText, { perSessionMax });
|
|
103
105
|
if (matches.length > 0) {
|
|
104
106
|
hits.push({
|
|
105
107
|
sessionId: s.id,
|
|
@@ -115,5 +117,5 @@ export async function searchSessions(ctx, query, { scope = "all", maxSessions =
|
|
|
115
117
|
/* 单个会话失败跳过(损坏日志等),不中断整体搜索 */
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
|
-
return { query:
|
|
120
|
+
return { query: queryText, scanned, hits };
|
|
119
121
|
}
|
|
@@ -163,15 +163,29 @@ export async function listSessions(ctx) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* best-effort 描述目标会话(dryRun
|
|
167
|
-
*
|
|
166
|
+
* best-effort 描述目标会话(dryRun 用):报告状态与工作区。
|
|
167
|
+
*
|
|
168
|
+
* 【必须只查便宜来源】内存里的 live 会话 → 落盘索引(list-cache.json)。
|
|
169
|
+
* 曾经这里调 listSessions()(= 全量列表构建),冷缓存时一次 dryRun 预检要 41~226 秒 ——
|
|
170
|
+
* 「校验 + 预览」本该是毫秒级的事,却变成卡死。预检的正确成本上限是「读一次内存/索引」,
|
|
171
|
+
* 不是「扫一遍所有会话日志」;查不到就如实说查不到,不为了补一个状态字段去付全量代价。
|
|
172
|
+
* 列表不可用时如实说明原因,**不阻断**(dryRun 的价值在于「早知道」)。
|
|
168
173
|
*/
|
|
169
174
|
export async function describeTarget(ctx, sessionId) {
|
|
170
175
|
try {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 —— 可能写错了,或它是刚建、尚未落盘的会话)";
|
|
175
189
|
} catch (error) {
|
|
176
190
|
return `(目标存在性无法核对:${shortError(error)})`;
|
|
177
191
|
}
|
|
@@ -62,12 +62,25 @@ export async function readListIndex(ctx) {
|
|
|
62
62
|
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
63
63
|
const entries = loadListDiskCache(ctx)?.entries;
|
|
64
64
|
if (!entries || typeof entries !== "object") return null;
|
|
65
|
-
|
|
65
|
+
// 归档态:索引文件里没存(它是宿主持久化的名单),但取它很便宜(一次内存读),
|
|
66
|
+
// 顺手带上 —— 否则面板「先读索引出列表」时按归档筛选会全部落空。
|
|
67
|
+
const registry = ctx.get?.("workspaceRegistry");
|
|
68
|
+
const archived = new Set(
|
|
69
|
+
(registry?.archivedSessionIds ?? []).filter((id) => typeof id === "string" && id !== ""),
|
|
70
|
+
);
|
|
71
|
+
// 回收站里的会话同样从索引直读里排除(否则面板先读索引时又会把它显示出来)
|
|
72
|
+
let hidden = new Set();
|
|
73
|
+
try {
|
|
74
|
+
const { trashedIds } = await import("../features/trash.js");
|
|
75
|
+
hidden = trashedIds(ctx);
|
|
76
|
+
} catch { /* 回收站不可用不影响索引 */ }
|
|
77
|
+
return Object.entries(entries).filter(([id]) => !hidden.has(id)).map(([id, e]) => ({
|
|
66
78
|
id,
|
|
67
79
|
title: e?.title ?? null,
|
|
68
80
|
cwd: e?.cwd ?? null,
|
|
69
81
|
updatedAt: e?.updatedAt ?? e?.createdAt ?? 0,
|
|
70
82
|
parentSession: e?.parentSession ?? null,
|
|
83
|
+
archived: archived.has(id),
|
|
71
84
|
fromIndex: true,
|
|
72
85
|
}));
|
|
73
86
|
}
|
|
@@ -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;
|
|
@@ -75,6 +76,11 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
75
76
|
const { title: newTitle } = archiveTitleWithWs(currentTitle, ws);
|
|
76
77
|
if (newTitle !== currentTitle) {
|
|
77
78
|
titleService.rename(session, newTitle);
|
|
79
|
+
// 归档改的是标题(加工作区前缀)与归档态 —— 两者都在列表里可见,必须作废缓存
|
|
80
|
+
try {
|
|
81
|
+
const { markSessionListStale } = await import("../sessions/list-cache.js");
|
|
82
|
+
markSessionListStale(ctx, body.sessionId);
|
|
83
|
+
} catch { /* 作废失败不影响归档 */ }
|
|
78
84
|
log(ctx, `归档加工作区前缀 ${body.sessionId}: "${currentTitle}" → "${newTitle}"`);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -111,6 +117,10 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
111
117
|
const { title: stripped } = stripArchiveWsPrefix(currentTitle);
|
|
112
118
|
if (stripped !== currentTitle) {
|
|
113
119
|
titleService.rename(session, stripped);
|
|
120
|
+
try {
|
|
121
|
+
const { markSessionListStale } = await import("../sessions/list-cache.js");
|
|
122
|
+
markSessionListStale(ctx, body.sessionId);
|
|
123
|
+
} catch { /* 作废失败不影响取消归档 */ }
|
|
114
124
|
log(ctx, `取消归档还原标题 ${body.sessionId}: "${currentTitle}" → "${stripped}"`);
|
|
115
125
|
}
|
|
116
126
|
}
|
|
@@ -207,6 +217,10 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
207
217
|
? Math.min(MAX_INACTIVE_DAYS, Math.max(0, Math.round(body.inactiveDays)))
|
|
208
218
|
: 0,
|
|
209
219
|
cwdPrefix: typeof body?.cwdPrefix === "string" ? body.cwdPrefix : "",
|
|
220
|
+
// 低价值条件必须**透传**:面板一直有「仅低价值会话」勾选框并把它放进请求体,
|
|
221
|
+
// 但路由原先没往下传 ⇒ deleteByRule 里 lowValue 恒为 false,这个条件等于没写,
|
|
222
|
+
// 勾了也和没勾一样(四个条件里唯一失效的一个)。
|
|
223
|
+
lowValue: body?.lowValue === true,
|
|
210
224
|
dryRun: body?.dryRun === true,
|
|
211
225
|
});
|
|
212
226
|
send(res, 200, ruleDeleteResult);
|
|
@@ -216,3 +230,133 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
216
230
|
},
|
|
217
231
|
});
|
|
218
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,13 +107,13 @@ export async function unarchiveSession(ctx, sessionId) {
|
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
|
|
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");
|
|
114
|
-
const persistence = ctx.get("sessionPersistence");
|
|
115
117
|
|
|
116
118
|
// 0. 运行中 / 续跑中的会话拒绝删除
|
|
117
119
|
const live = sessions?.get(sessionId);
|
|
@@ -140,11 +142,16 @@ export async function deleteSession(ctx, sessionId) {
|
|
|
140
142
|
// 4. 删除磁盘 artifact(session 目录)。sqlite 查询索引会对账自动清理。
|
|
141
143
|
// 先 detach 再删文件:持久化协调器在 session/disposed 后退休该会话,
|
|
142
144
|
// 不会再把它写回磁盘。
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
//
|
|
146
|
+
// ⚠️ 0.2.x 已移除 `persistence.listArtifacts`:原实现整段是死代码 ——
|
|
147
|
+
// 只做了 detach、**文件一个没删**,重启后会话原样回来(表现为「删除成功但没删掉」)。
|
|
148
|
+
// 现在直接按 id 定位日志文件(findSessionLogById 扫 <DSH_HOME>/sessions/<project>/<id>/),
|
|
149
|
+
// 取其所在目录删除。
|
|
150
|
+
try {
|
|
151
|
+
const { findSessionLogById, sessionsRootOf } = await import("../sessions/events.js");
|
|
152
|
+
const logPath = findSessionLogById(sessionId, sessionsRootOf());
|
|
153
|
+
if (logPath) {
|
|
154
|
+
const sessionDir = path.dirname(logPath);
|
|
148
155
|
await rm(sessionDir, { recursive: true, force: true });
|
|
149
156
|
// 尽力清理空的 project 目录(会话删光后不留空壳)
|
|
150
157
|
const projectDir = path.dirname(sessionDir);
|
|
@@ -154,6 +161,20 @@ export async function deleteSession(ctx, sessionId) {
|
|
|
154
161
|
// 目录非空或不可读,保留即可
|
|
155
162
|
}
|
|
156
163
|
}
|
|
164
|
+
} catch {
|
|
165
|
+
// 定位/删除失败不阻断(内存侧已 detach),文件会留下 —— 下次删除会再试
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4.5 同步清理两份**落盘索引**:删掉的会话不能在索引里继续存在。
|
|
169
|
+
// 面板现在先读落盘索引出列表(否则冷缓存要等 41~226 秒),索引不清就会出现
|
|
170
|
+
// 「删掉了、刷新又回来」;价值索引同理(下次筛选还会把它当低价值勾出来)。
|
|
171
|
+
try {
|
|
172
|
+
const { forgetSessionInListCache } = await import("../sessions/list-cache.js");
|
|
173
|
+
forgetSessionInListCache(ctx, sessionId);
|
|
174
|
+
const { forgetValueVerdict } = await import("./value-index.js");
|
|
175
|
+
forgetValueVerdict(ctx, sessionId);
|
|
176
|
+
} catch {
|
|
177
|
+
// 索引清理失败不影响删除本身(下次全量重建会自然 prune 掉)
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
// 5. 清理自动重命名 / 自动续跑开关(config.json 两组同步清,防同名会话重建后误判)
|
|
@@ -177,6 +198,68 @@ export async function deleteSession(ctx, sessionId) {
|
|
|
177
198
|
});
|
|
178
199
|
}
|
|
179
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
|
+
|
|
180
263
|
// ---------- 撤回最后一条用户消息 ----------
|
|
181
264
|
|
|
182
265
|
/**
|
|
@@ -311,43 +394,33 @@ export async function deleteBatchSessions(ctx, sessionIds) {
|
|
|
311
394
|
const ids = [...new Set(
|
|
312
395
|
(Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === "string" && id !== "")
|
|
313
396
|
)];
|
|
314
|
-
const deleted = [];
|
|
315
397
|
const skipped = [];
|
|
316
|
-
|
|
317
|
-
// 不再被误报为「已删除」。persistence 不可用/返回空(如测试 mock)时保守处理:
|
|
318
|
-
// 仅当「明确知道不存在」才跳过,否则交给 deleteSession 正常判定。
|
|
319
|
-
const persistence = ctx.get("sessionPersistence");
|
|
320
|
-
const persistIds = new Set();
|
|
321
|
-
let canCheckPersist = false;
|
|
322
|
-
try {
|
|
323
|
-
// 持久化会话权威来源 = persistence.list()(mock 与真实实现都提供);
|
|
324
|
-
// listArtifacts 仅作补充(可能未实现/返回空,不代表会话不存在)
|
|
325
|
-
if (persistence?.list) {
|
|
326
|
-
const metas = await persistence.list();
|
|
327
|
-
if (Array.isArray(metas)) {
|
|
328
|
-
canCheckPersist = true;
|
|
329
|
-
for (const m of metas) if (m?.id) persistIds.add(m.id);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
} catch { canCheckPersist = false; }
|
|
398
|
+
const targets = [];
|
|
333
399
|
const sessionsSvc = ctx.get("sessions");
|
|
334
|
-
const
|
|
400
|
+
const { findSessionLogById, sessionsRootOf } = await import("../sessions/events.js");
|
|
335
401
|
for (const id of ids) {
|
|
336
|
-
//
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (deleteResult.ok) deleted.push(id);
|
|
345
|
-
else skipped.push({ sessionId: id, reason: deleteResult.error?.code ?? "error" });
|
|
346
|
-
} catch {
|
|
347
|
-
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; }
|
|
348
410
|
}
|
|
411
|
+
if (!exists) { skipped.push({ sessionId: id, reason: "not-found" }); continue; }
|
|
412
|
+
targets.push(id);
|
|
349
413
|
}
|
|
350
|
-
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 };
|
|
351
424
|
}
|
|
352
425
|
|
|
353
426
|
/**
|