dsh-session-conductor 1.3.8 → 1.3.10
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 +20 -1
- package/lib/client-parts/components/panel.js +177 -0
- package/lib/client.js +177 -0
- package/lib/core/config.js +3 -0
- package/lib/core/index-store.js +148 -0
- package/lib/features/member-model/command.js +5 -35
- package/lib/features/member-model/locate.js +49 -3
- package/lib/features/session-messaging/tools.js +7 -1
- package/lib/features/session-ops-routes.js +132 -1
- package/lib/features/session-ops.js +90 -40
- package/lib/features/trash.js +206 -0
- package/lib/features/value-index.js +11 -26
- package/lib/i18n/en.json +24 -9
- package/lib/i18n/zh.json +22 -7
- package/lib/sessions/list-cache.js +11 -29
- package/lib/sessions/list.js +11 -0
- package/package.json +2 -2
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// 就抛 ERR_MODULE_NOT_FOUND,连累整个插件加载失败(本项目在 defineTool / createUserMessage
|
|
18
18
|
// 上踩过同类坑)。因此拿不到宿主能力时**跳过注册并如实留痕**,绝不影响插件其它功能。
|
|
19
19
|
import { log } from "../../shared/log.js";
|
|
20
|
-
import { findTargetAgent,
|
|
20
|
+
import { findTargetAgent, collectTeamMembers } from "./locate.js";
|
|
21
21
|
import { getMemberModelOverride } from "./override.js";
|
|
22
22
|
import { readLastRequestModel, registerSwitchWatch, switchAgentModel, peekLastRequestModel } from "./switch.js";
|
|
23
23
|
|
|
@@ -41,39 +41,9 @@ function memberLabel(found) {
|
|
|
41
41
|
return name ? `${name}(${found.sessionId})` : String(found?.sessionId ?? "?");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* 名册:Team 成员(spawn_teammate 创建的队友,不在普通 agents 列表里)+ 普通 agents。
|
|
46
|
-
* 两条来源都取不到时返回空数组(调用方据此给出可读提示)。
|
|
47
|
-
*/
|
|
48
|
-
function collectMembers(ctx) {
|
|
49
|
-
const out = [];
|
|
50
|
-
const seen = new Set();
|
|
51
|
-
const push = (id, name, extra = {}) => {
|
|
52
|
-
if (!id || seen.has(id)) return;
|
|
53
|
-
seen.add(id);
|
|
54
|
-
out.push({ id, name: name ?? null, ...extra });
|
|
55
|
-
};
|
|
56
|
-
try {
|
|
57
|
-
const teams = ctx?.get?.("agentTeams");
|
|
58
|
-
const caller = pickTeamCaller(ctx);
|
|
59
|
-
if (teams?.listMembers && caller) {
|
|
60
|
-
for (const m of teams.listMembers(caller) ?? []) push(m?.id ?? m?.sessionId, m?.name, { provider: m?.provider, model: m?.model, source: "名册" });
|
|
61
|
-
}
|
|
62
|
-
} catch (error) {
|
|
63
|
-
log(ctx, `模型指令:读 Team 名册失败(跳过该来源): ${String(error?.message ?? error)}`);
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const agents = ctx?.get?.("agents");
|
|
67
|
-
for (const a of agents?.list?.() ?? []) push(a?.session?.id ?? a?.id, a?.session?.title ?? null, { source: "agent" });
|
|
68
|
-
} catch (error) {
|
|
69
|
-
log(ctx, `模型指令:读 agents 列表失败(跳过该来源): ${String(error?.message ?? error)}`);
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
44
|
/** 无参:列成员与各自模型(**便宜来源**:本插件 override → 进程内最近一次请求 → 名册记录值)。 */
|
|
75
|
-
async function renderMemberList(ctx) {
|
|
76
|
-
const members =
|
|
45
|
+
async function renderMemberList(ctx, invocation) {
|
|
46
|
+
const members = collectTeamMembers(ctx, { caller: invocation?.agent ?? null });
|
|
77
47
|
if (members.length === 0) {
|
|
78
48
|
return "没读到任何成员。可能当前不是 Team 成员、名册不可用,或确实还没有队友。";
|
|
79
49
|
}
|
|
@@ -164,8 +134,8 @@ export async function handleModelSwitchCommand(ctx, invocation) {
|
|
|
164
134
|
const raw = String(invocation?.rawInput ?? "").trim();
|
|
165
135
|
const parts = raw.split(/\s+/).filter(Boolean);
|
|
166
136
|
try {
|
|
167
|
-
if (parts.length === 0) return { kind: "success", text: await renderMemberList(ctx) };
|
|
168
|
-
const found = findTargetAgent(ctx, parts[0]);
|
|
137
|
+
if (parts.length === 0) return { kind: "success", text: await renderMemberList(ctx, invocation) };
|
|
138
|
+
const found = findTargetAgent(ctx, parts[0], { caller: invocation?.agent ?? null });
|
|
169
139
|
if (found?.error) return { kind: "error", text: `找不到成员:${found.error}` };
|
|
170
140
|
if (parts.length === 1) return { kind: "success", text: await renderMemberDetail(found) };
|
|
171
141
|
const pair = splitPair(parts[1]);
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* agentTeams.listMembers(caller) 需要一个 Team 成员作为 caller 来解析名册。
|
|
9
9
|
* @returns {object|null} 可用 agent,取不到返回 null
|
|
10
10
|
*/
|
|
11
|
-
export function pickTeamCaller(ctx) {
|
|
11
|
+
export function pickTeamCaller(ctx, preferred = null) {
|
|
12
12
|
const agents = ctx.get("agents");
|
|
13
13
|
if (!agents) return null;
|
|
14
|
+
// 0) 调用方显式给的发起方最可靠:命令/工具都能直接拿到 invocation.agent / exec.agent。
|
|
15
|
+
// currentInitiator() 依赖「当前在某个 agent 的异步链里」,命令处理器里经常拿不到 ——
|
|
16
|
+
// 这正是「工具能切、聊天指令说找不到成员」的根因。
|
|
17
|
+
if (preferred) return preferred;
|
|
14
18
|
try {
|
|
15
19
|
const initiator = agents.currentInitiator?.();
|
|
16
20
|
if (initiator) return initiator;
|
|
@@ -93,14 +97,14 @@ export async function validateModelPair(ctx, provider, model) {
|
|
|
93
97
|
* ③ 标题子串匹配(sessionTitle 快照)。
|
|
94
98
|
* @returns {{sessionId: string, agent: object|null, matched: string, member?: object} | {error: string}}
|
|
95
99
|
*/
|
|
96
|
-
export function findTargetAgent(ctx, target) {
|
|
100
|
+
export function findTargetAgent(ctx, target, { caller: explicitCaller = null } = {}) {
|
|
97
101
|
const raw = String(target ?? "").trim();
|
|
98
102
|
if (!raw) return { error: "target 不能为空(传 sessionId / 成员名 / 标题关键字)" };
|
|
99
103
|
const agents = ctx.get("agents");
|
|
100
104
|
const teams = ctx.get("agentTeams");
|
|
101
105
|
// ① Team 名册:name 或 id 精确匹配优先
|
|
102
106
|
if (teams?.listMembers && agents) {
|
|
103
|
-
const caller = pickTeamCaller(ctx);
|
|
107
|
+
const caller = pickTeamCaller(ctx, explicitCaller);
|
|
104
108
|
if (caller) {
|
|
105
109
|
let members = [];
|
|
106
110
|
try {
|
|
@@ -155,3 +159,45 @@ export function findTargetAgent(ctx, target) {
|
|
|
155
159
|
if (hits.length === 0) return { error: `没有匹配「${raw}」的活跃成员(可用 list_agents 看成员列表)` };
|
|
156
160
|
return { error: `「${raw}」匹配到 ${hits.length} 个成员,请用 sessionId 精确指定:${hits.map((a) => a?.session?.id).join(", ")}` };
|
|
157
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 统一名册:Team 成员(spawn_teammate 创建的队友,不在普通 agents 列表里)+ 普通 agents。
|
|
165
|
+
*
|
|
166
|
+
* 【为什么要抽公共】聊天指令与 AI 工具原先各写一份「谁是成员」:
|
|
167
|
+
* 命令版靠 `pickTeamCaller(ctx)` 猜发起方,在命令处理器里经常拿不到 ⇒ 名册为空 ⇒
|
|
168
|
+
* 报「没有匹配的活跃成员」,而同样的名字走 AI 工具(在 agent 异步链里调用)却能找到。
|
|
169
|
+
* 现在两处都调这一个函数,并且都显式把 invocation.agent / exec.agent 传进来。
|
|
170
|
+
*
|
|
171
|
+
* @param {object} ctx 插件上下文
|
|
172
|
+
* @param {{caller?: object}} [opts] caller = 发起方 agent(命令/工具里直接给,最可靠)
|
|
173
|
+
* @returns {Array<{id, name, provider?, model?, source}>} 去重后的成员列表(取不到返回空数组)
|
|
174
|
+
*/
|
|
175
|
+
export function collectTeamMembers(ctx, { caller: explicitCaller = null } = {}) {
|
|
176
|
+
const out = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const push = (id, name, extra = {}) => {
|
|
179
|
+
if (!id || seen.has(id)) return;
|
|
180
|
+
seen.add(id);
|
|
181
|
+
out.push({ id, name: name ?? null, ...extra });
|
|
182
|
+
};
|
|
183
|
+
const teams = ctx?.get?.("agentTeams");
|
|
184
|
+
const caller = pickTeamCaller(ctx, explicitCaller);
|
|
185
|
+
if (teams?.listMembers && caller) {
|
|
186
|
+
try {
|
|
187
|
+
for (const m of teams.listMembers(caller) ?? []) {
|
|
188
|
+
push(m?.id ?? m?.sessionId, m?.name, { provider: m?.provider, model: m?.model, source: "名册" });
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// 名册不可用:继续用 agents 列表兜底
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const agents = ctx?.get?.("agents");
|
|
196
|
+
for (const a of agents?.list?.() ?? []) {
|
|
197
|
+
push(a?.session?.id ?? a?.id, a?.session?.title ?? null, { source: "agent" });
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// agents 不可用:保持已有结果
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
@@ -68,7 +68,13 @@ export async function readListIndex(ctx) {
|
|
|
68
68
|
const archived = new Set(
|
|
69
69
|
(registry?.archivedSessionIds ?? []).filter((id) => typeof id === "string" && id !== ""),
|
|
70
70
|
);
|
|
71
|
-
|
|
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]) => ({
|
|
72
78
|
id,
|
|
73
79
|
title: e?.title ?? null,
|
|
74
80
|
cwd: e?.cwd ?? null,
|
|
@@ -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
|
-
/**
|
|
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");
|
|
@@ -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
|
-
|
|
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
|
|
400
|
+
const { findSessionLogById, sessionsRootOf } = await import("../sessions/events.js");
|
|
358
401
|
for (const id of ids) {
|
|
359
|
-
//
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
}
|