dsh-plugin-tool-management 0.1.2 → 0.2.0
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 +226 -136
- package/README_EN.md +231 -136
- package/cordis.patch.yml +18 -0
- package/docs/images/agents-md.png +0 -0
- package/docs/images/history.png +0 -0
- package/docs/images/mcp.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images//345/234/272/346/231/257/350/256/260/345/277/206.png +0 -0
- package/lib/agents-md/service.js +240 -0
- package/lib/client.js +2173 -850
- package/lib/history/projcache.js +335 -0
- package/lib/history/tombstone.js +32 -0
- package/lib/history/workspace.js +892 -0
- package/lib/imports/parsers.js +181 -0
- package/lib/index.js +768 -2
- package/lib/rules/provider.js +121 -0
- package/lib/rules/service.js +1657 -0
- package/lib/skills/core.js +10 -1
- package/lib/skills/service.js +5 -5
- package/package.json +18 -8
- package/screenshots.json +7 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { lstat, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { WorkspaceRegistry } from "@deepseek-ai/dsh-workspace";
|
|
5
|
+
import { sessionDir } from "@deepseek-ai/dsh-spill-local";
|
|
6
|
+
import { trackTombstone } from "./tombstone.js";
|
|
7
|
+
//#region lib/types/index.js
|
|
8
|
+
/**
|
|
9
|
+
* dsh-archive-manager 宿主侧归档会话管理。
|
|
10
|
+
*
|
|
11
|
+
* `deleteSession(sessionId)` 的顺序即语义:校验已知会话;实时会话先
|
|
12
|
+
* flush 再 detach;等待投影缓存写入完成;移除归档标记和工作区记账;
|
|
13
|
+
* 删除投影缓存,级联删除 SUBAGENT 子会话并清理 spill;最后才删除转录
|
|
14
|
+
* 目录。fork 分支虽有 `parentSession`,但属于独立用户会话,不参与级联。
|
|
15
|
+
*
|
|
16
|
+
* 转录工件删除成功后才提交父会话记账清理;任一步失败均保留归档标记、工作区
|
|
17
|
+
* 记账和头部索引,以便再次调用同一入口完成删除。SUBAGENT 级联跨多个转录
|
|
18
|
+
* 工件,无法组成事务:子会话可能已先删除,重试会跳过它们并继续处理仍保留的
|
|
19
|
+
* 父会话。
|
|
20
|
+
* 物理删除成功后必须遗忘父类内存索引(headers / sessionPaths /
|
|
21
|
+
* invalidSessionPaths)并打上删除墓碑:父类 sessionKnown 以 headers.has
|
|
22
|
+
* 短路,indexHeaders 只增不减,stale list() 否则会把已删 id 救活,
|
|
23
|
+
* 进而被 archiveSession 重新写回 archivedSessionIds。
|
|
24
|
+
* 墓碑同时记录被删生命周期的日志身份(createdAt/cwd):其他进程以同 id
|
|
25
|
+
* 重建并落盘新会话时(冷复用),sessionKnown 的探针以身份差异区分新旧
|
|
26
|
+
* 生命周期并撤掉墓碑,避免已重建的会话在本进程永久 UNKNOWN_SESSION。
|
|
27
|
+
* 单会话与批量恢复/删除方法均通过 Typert Remote 暴露给浏览器,并注册到
|
|
28
|
+
* typert.local,避免生产环境只靠 SRC 扫描时 404。
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Compatibility adapter for the official JSONL backend's session-owned layout.
|
|
32
|
+
* A generic locate() path does NOT grant ownership of its parent directory.
|
|
33
|
+
* Unknown backends/layouts keep artifact-only deletion. Do not prune ancestors.
|
|
34
|
+
*/
|
|
35
|
+
function jsonlSessionDirectory(persistence, header, location) {
|
|
36
|
+
if (persistence.name !== "session-persistence-jsonl" ||
|
|
37
|
+
location.kind !== "jsonl")
|
|
38
|
+
return;
|
|
39
|
+
// Official JSONL stores its resolved root at construction. Re-resolving a
|
|
40
|
+
// relative config.root after process.chdir() would point to a different store.
|
|
41
|
+
// This backend-specific field is optional; only absolute config is a safe fallback.
|
|
42
|
+
const root = persistence.root ?? persistence.config?.root;
|
|
43
|
+
if (typeof root !== "string" ||
|
|
44
|
+
!isAbsolute(root) ||
|
|
45
|
+
!isAbsolute(location.path))
|
|
46
|
+
return;
|
|
47
|
+
// 新版使用不可变代际文件;删除整个已验证的会话目录,避免旧代际被重新发现。
|
|
48
|
+
if (!/^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/.test(basename(location.path)))
|
|
49
|
+
return;
|
|
50
|
+
if (typeof header.id !== "string" || header.id.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
// Upstream encodes UTF-16 code units as ~XXXX (including lone surrogates).
|
|
53
|
+
const encode = (text) => text.replace(/[^A-Za-z0-9._-]/g, (ch) => `~${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
|
|
54
|
+
const segment = header.id === "."
|
|
55
|
+
? "~002E"
|
|
56
|
+
: header.id === ".."
|
|
57
|
+
? "~002E~002E"
|
|
58
|
+
: encode(header.id);
|
|
59
|
+
let project = "_no-cwd";
|
|
60
|
+
if (header.cwd !== void 0) {
|
|
61
|
+
if (typeof header.cwd !== "string" || header.cwd.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
const readable = encode(header.cwd.replace(/[\\/:]+/g, "-")).replace(/^-+/, "") || "root";
|
|
64
|
+
project = `--${readable.slice(0, 251)}--`;
|
|
65
|
+
}
|
|
66
|
+
const directory = join(resolve(root), project, segment);
|
|
67
|
+
if (location.path !== join(directory, basename(location.path)))
|
|
68
|
+
return;
|
|
69
|
+
return directory;
|
|
70
|
+
}
|
|
71
|
+
function unknownSessionMessage(sessionId) {
|
|
72
|
+
return `unknown session "${sessionId}" (UNKNOWN_SESSION)`;
|
|
73
|
+
}
|
|
74
|
+
var ArchiveUnknownSessionError = class extends Error {
|
|
75
|
+
sessionId;
|
|
76
|
+
constructor(sessionId) {
|
|
77
|
+
super(unknownSessionMessage(sessionId));
|
|
78
|
+
this.sessionId = sessionId;
|
|
79
|
+
this.name = "ArchiveUnknownSessionError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** 头部投影到“日志身份”字段(与投影缓存的 identity 语义一致)。cwd 缺失统一归一为 null,避免一侧带键一侧不带键时的比较歧义。 */
|
|
83
|
+
function headerIdentity(header) {
|
|
84
|
+
return {
|
|
85
|
+
createdAt: header.createdAt,
|
|
86
|
+
cwd: header.cwd ?? null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const sessionIdSchema = {
|
|
90
|
+
parse(value) {
|
|
91
|
+
if (typeof value !== "string" || value.length === 0)
|
|
92
|
+
throw new TypeError(`sessionId must be a non-empty string, got ${String(value)}`);
|
|
93
|
+
return value;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const workspaceIdSchema = {
|
|
97
|
+
parse(value) {
|
|
98
|
+
if (typeof value !== "string" || value.length === 0)
|
|
99
|
+
throw new TypeError(`workspaceId must be a non-empty string, got ${String(value)}`);
|
|
100
|
+
return value;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const archivedSetSchema = {
|
|
104
|
+
parse(value) {
|
|
105
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
106
|
+
throw new TypeError("result must be an object");
|
|
107
|
+
const ids = value.archivedSessionIds;
|
|
108
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string"))
|
|
109
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
110
|
+
return value;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const deletedSchema = {
|
|
114
|
+
parse(value) {
|
|
115
|
+
if (typeof value !== "object" ||
|
|
116
|
+
value === null ||
|
|
117
|
+
Array.isArray(value) ||
|
|
118
|
+
value.deleted !== true)
|
|
119
|
+
throw new TypeError("deleted must be true");
|
|
120
|
+
return value;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const archivedBatchTargetSchema = {
|
|
124
|
+
parse(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
126
|
+
throw new TypeError("target must be an object");
|
|
127
|
+
if (value.scope === "all" || value.scope === "ungrouped")
|
|
128
|
+
return value;
|
|
129
|
+
if (value.scope === "workspace" &&
|
|
130
|
+
typeof value.workspaceId === "string" &&
|
|
131
|
+
value.workspaceId.length > 0)
|
|
132
|
+
return value;
|
|
133
|
+
if (value.scope === "sessions" &&
|
|
134
|
+
Array.isArray(value.sessionIds) &&
|
|
135
|
+
value.sessionIds.length > 0 &&
|
|
136
|
+
value.sessionIds.every((id) => typeof id === "string" && id.length > 0))
|
|
137
|
+
return value;
|
|
138
|
+
throw new TypeError("target.scope must be all, ungrouped, workspace with a non-empty workspaceId, or sessions with non-empty sessionIds");
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const unarchivedBatchSchema = {
|
|
142
|
+
parse(value) {
|
|
143
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
144
|
+
throw new TypeError("result must be an object");
|
|
145
|
+
if (!Array.isArray(value.archivedSessionIds) ||
|
|
146
|
+
value.archivedSessionIds.some((id) => typeof id !== "string"))
|
|
147
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
148
|
+
if (!Array.isArray(value.unarchivedSessionIds) ||
|
|
149
|
+
value.unarchivedSessionIds.some((id) => typeof id !== "string"))
|
|
150
|
+
throw new TypeError("unarchivedSessionIds must be a string array");
|
|
151
|
+
return value;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const archivedWorkspaceBatchSchema = {
|
|
155
|
+
parse(value) {
|
|
156
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
157
|
+
throw new TypeError("result must be an object");
|
|
158
|
+
if (!Array.isArray(value.archivedSessionIds) ||
|
|
159
|
+
value.archivedSessionIds.some((id) => typeof id !== "string"))
|
|
160
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
161
|
+
if (!Array.isArray(value.archivedSessionIdsAdded) ||
|
|
162
|
+
value.archivedSessionIdsAdded.some((id) => typeof id !== "string"))
|
|
163
|
+
throw new TypeError("archivedSessionIdsAdded must be a string array");
|
|
164
|
+
return value;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const deletedBatchSchema = {
|
|
168
|
+
parse(value) {
|
|
169
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
170
|
+
throw new TypeError("result must be an object");
|
|
171
|
+
for (const key of [
|
|
172
|
+
"requestedSessionIds",
|
|
173
|
+
"deletedSessionIds",
|
|
174
|
+
"skippedSessionIds",
|
|
175
|
+
]) {
|
|
176
|
+
if (!Array.isArray(value[key]) ||
|
|
177
|
+
value[key].some((id) => typeof id !== "string"))
|
|
178
|
+
throw new TypeError(`${key} must be a string array`);
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(value.failures) ||
|
|
181
|
+
value.failures.some((failure) => typeof failure !== "object" ||
|
|
182
|
+
failure === null ||
|
|
183
|
+
typeof failure.sessionId !== "string" ||
|
|
184
|
+
typeof failure.message !== "string"))
|
|
185
|
+
throw new TypeError("failures must contain sessionId/message objects");
|
|
186
|
+
return value;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const archivedSessionMetadataSchema = {
|
|
190
|
+
parse(value) {
|
|
191
|
+
if (typeof value !== "object" ||
|
|
192
|
+
value === null ||
|
|
193
|
+
Array.isArray(value) ||
|
|
194
|
+
!Array.isArray(value.items))
|
|
195
|
+
throw new TypeError("result.items must be an array");
|
|
196
|
+
if (value.items.some((item) => typeof item !== "object" ||
|
|
197
|
+
item === null ||
|
|
198
|
+
typeof item.sessionId !== "string" ||
|
|
199
|
+
typeof item.createdAt !== "number" ||
|
|
200
|
+
!Number.isFinite(item.createdAt)))
|
|
201
|
+
throw new TypeError("items must contain sessionId/createdAt objects");
|
|
202
|
+
if (value.repairedSessionIds !== void 0 &&
|
|
203
|
+
(!Array.isArray(value.repairedSessionIds) ||
|
|
204
|
+
value.repairedSessionIds.some((id) => typeof id !== "string")))
|
|
205
|
+
throw new TypeError("repairedSessionIds must be a string array");
|
|
206
|
+
return value;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* 折叠进 dsh-plugin-tool-management 的归档工作区注册表。
|
|
211
|
+
* 原始实现来自 @michengai/dsh-archive-manager(Apache-2.0),剥离了
|
|
212
|
+
* Typert Remote 暴露层——本插件客户端经 HTTP API 调用,不依赖 typert 远程。
|
|
213
|
+
*/
|
|
214
|
+
function pluginRootDir() {
|
|
215
|
+
try {
|
|
216
|
+
const require = createRequire(import.meta.url);
|
|
217
|
+
return dirname(require.resolve("../../package.json"));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return process.cwd();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function defaultArchivedAtFile() {
|
|
224
|
+
return join(pluginRootDir(), "data", "history-archived-at.json");
|
|
225
|
+
}
|
|
226
|
+
var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
|
|
227
|
+
static inject = [
|
|
228
|
+
"storageDomain",
|
|
229
|
+
"sessionPersistence",
|
|
230
|
+
"sessionProjectionCache",
|
|
231
|
+
];
|
|
232
|
+
/** 本进程内已物理删除的会话;阻止父类把 stale list() 重新编入索引。 */
|
|
233
|
+
deletedSessionIds = /* @__PURE__ */ new Set();
|
|
234
|
+
/** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
|
|
235
|
+
deletedSessionOrder = [];
|
|
236
|
+
/** 墓碑上限:足够挡住 stale list(),又避免长驻进程无限增长。 */
|
|
237
|
+
deletedSessionTombstoneLimit = 4096;
|
|
238
|
+
/** 被删生命周期的日志身份(createdAt/cwd):冷复用探针区分“同 id 新会话”与 stale list() 的依据。 */
|
|
239
|
+
deletedIdentities = /* @__PURE__ */ new Map();
|
|
240
|
+
/** 会话 → 归档时刻(epoch ms);保留期自动删除的基线。 */
|
|
241
|
+
archivedAtMap = /* @__PURE__ */ new Map();
|
|
242
|
+
archivedAtFile = null;
|
|
243
|
+
archivedAtLoaded = false;
|
|
244
|
+
constructor(ctx, config) {
|
|
245
|
+
super(ctx);
|
|
246
|
+
this.archivedAtFile = (config && config.archivedAtFile) || defaultArchivedAtFile();
|
|
247
|
+
}
|
|
248
|
+
async ensureArchivedAtLoaded() {
|
|
249
|
+
if (this.archivedAtLoaded)
|
|
250
|
+
return;
|
|
251
|
+
this.archivedAtLoaded = true;
|
|
252
|
+
try {
|
|
253
|
+
const raw = await readFile(this.archivedAtFile, "utf8");
|
|
254
|
+
const obj = JSON.parse(raw);
|
|
255
|
+
if (obj && typeof obj === "object") {
|
|
256
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
257
|
+
if (typeof k === "string" && Number.isFinite(v))
|
|
258
|
+
this.archivedAtMap.set(k, v);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
/* 缺失文件视为空表 */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async saveArchivedAt() {
|
|
267
|
+
try {
|
|
268
|
+
const obj = {};
|
|
269
|
+
for (const [k, v] of this.archivedAtMap)
|
|
270
|
+
obj[k] = v;
|
|
271
|
+
await writeFile(this.archivedAtFile, JSON.stringify(obj), "utf8");
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
this.ctx.logger?.warn?.(`history: could not persist archived-at ledger: ${String(e)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
archivedAt(sessionId) {
|
|
278
|
+
return this.archivedAtMap.get(sessionId);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 归档一个会话并记录归档时刻(保留期自动删除的基线)。覆盖父类
|
|
282
|
+
* `archiveSession`:官方 UI 的「归档会话」菜单与本插件 HTTP op 都经此入口,
|
|
283
|
+
* 因此所有归档路径的 archivedAt 都被捕获。
|
|
284
|
+
*/
|
|
285
|
+
async archiveSession(sessionId) {
|
|
286
|
+
await super.archiveSession(sessionId);
|
|
287
|
+
await this.ensureArchivedAtLoaded();
|
|
288
|
+
this.archivedAtMap.set(sessionId, Date.now());
|
|
289
|
+
await this.saveArchivedAt();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 列出已归档会话的展示元数据:sessionId / createdAt / cwd / title / archivedAt。
|
|
293
|
+
* title 经投影缓存 best-effort 读取(未播种会话用 inheritedEventCount=0);
|
|
294
|
+
* 任何一步失败只降级为缺字段,不阻断列表。
|
|
295
|
+
*/
|
|
296
|
+
async archivedSessionDetails() {
|
|
297
|
+
await this.ensureArchivedAtLoaded();
|
|
298
|
+
const items = [];
|
|
299
|
+
for (const sessionId of [...new Set(this.requireState().archivedSessionIds)]) {
|
|
300
|
+
const entry = { sessionId, archivedAt: this.archivedAtMap.get(sessionId) };
|
|
301
|
+
try {
|
|
302
|
+
const header = await this.readSessionHeader(sessionId);
|
|
303
|
+
if (typeof header?.createdAt === "number" && Number.isFinite(header.createdAt))
|
|
304
|
+
entry.createdAt = header.createdAt;
|
|
305
|
+
if (typeof header?.cwd === "string" && header.cwd)
|
|
306
|
+
entry.cwd = header.cwd;
|
|
307
|
+
const cache = this.ctx.get("sessionProjectionCache");
|
|
308
|
+
if (cache !== undefined && typeof cache.cachedSnapshot === "function") {
|
|
309
|
+
const snap = cache.cachedSnapshot(header, 0, ["title"]);
|
|
310
|
+
const title = snap?.values?.title;
|
|
311
|
+
if (typeof title === "string")
|
|
312
|
+
entry.title = title;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this.ctx.logger?.warn?.(`history: could not read details for archived session "${sessionId}": ${String(error)}`);
|
|
317
|
+
}
|
|
318
|
+
items.push(entry);
|
|
319
|
+
}
|
|
320
|
+
return { items };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 归档设置页创建时间排序所需的最小元数据。老用户可能仍有会话原文和
|
|
324
|
+
* 归档标记、却没有投影缓存;这里按需从完整日志重建一次,再通知客户端
|
|
325
|
+
* 刷新会话列表。已有缓存不读原文,新老 DSH 的缓存布局都走同一 put。
|
|
326
|
+
*/
|
|
327
|
+
async archivedSessionMetadata() {
|
|
328
|
+
const items = [];
|
|
329
|
+
const repairedSessionIds = [];
|
|
330
|
+
for (const sessionId of [
|
|
331
|
+
...new Set(this.requireState().archivedSessionIds),
|
|
332
|
+
]) {
|
|
333
|
+
try {
|
|
334
|
+
const header = await this.readSessionHeader(sessionId);
|
|
335
|
+
if (await this.repairArchivedProjection(header))
|
|
336
|
+
repairedSessionIds.push(sessionId);
|
|
337
|
+
if (typeof header.createdAt === "number" &&
|
|
338
|
+
Number.isFinite(header.createdAt))
|
|
339
|
+
items.push({ sessionId, createdAt: header.createdAt });
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
this.ctx.logger.warn(`archive-manager: could not read creation time for archived session "${sessionId}": ${String(error)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
items,
|
|
347
|
+
...(repairedSessionIds.length === 0 ? {} : { repairedSessionIds }),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** 从会话原文补齐缺失的派生缓存;任何失败都只降级为原有无摘要列表。 */
|
|
351
|
+
async repairArchivedProjection(header) {
|
|
352
|
+
const cache = this.ctx.get("sessionProjectionCache");
|
|
353
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
354
|
+
const projections = this.ctx.get("sessionProjections");
|
|
355
|
+
if (cache === void 0 ||
|
|
356
|
+
typeof cache.cachedSnapshot !== "function" ||
|
|
357
|
+
typeof cache.put !== "function")
|
|
358
|
+
return false;
|
|
359
|
+
if (persistence === void 0 ||
|
|
360
|
+
(typeof persistence.readFrom !== "function" &&
|
|
361
|
+
typeof persistence.open !== "function") ||
|
|
362
|
+
projections === void 0 ||
|
|
363
|
+
typeof projections.restore !== "function")
|
|
364
|
+
return false;
|
|
365
|
+
try {
|
|
366
|
+
// 未播种会话的继承事件数恒为零,先查缓存可避免读取完整会话原文。
|
|
367
|
+
if (!header.isSeeded && cache.cachedSnapshot(header, 0) !== void 0)
|
|
368
|
+
return false;
|
|
369
|
+
const stored = await this.readStoredProjectionSource(persistence, header.id);
|
|
370
|
+
const meta = stored.meta ?? header;
|
|
371
|
+
if (meta.isSeeded === true && stored.inheritedEventCount === void 0) {
|
|
372
|
+
this.ctx.logger.warn(`archive-manager: projection repair for seeded archived session "${header.id}" skipped because its inherited event count is unavailable`);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
const inheritedEventCount = stored.inheritedEventCount ?? 0;
|
|
376
|
+
if (meta.isSeeded !== true && inheritedEventCount !== 0) {
|
|
377
|
+
this.ctx.logger.warn(`archive-manager: projection repair for unseeded archived session "${header.id}" skipped because its inherited event count is not zero`);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (cache.cachedSnapshot(meta, inheritedEventCount) !== void 0)
|
|
381
|
+
return false;
|
|
382
|
+
const restored = projections.restore({}, stored.events, 0, meta, inheritedEventCount);
|
|
383
|
+
if (restored === void 0 ||
|
|
384
|
+
typeof restored !== "object" ||
|
|
385
|
+
restored.checkpoint === void 0)
|
|
386
|
+
return false;
|
|
387
|
+
await cache.put(header.id, {
|
|
388
|
+
// 支持的宿主头部均有 version;新版缓存校验格式代际,旧版逐字段比较忽略此键。
|
|
389
|
+
formatVersion: meta.version,
|
|
390
|
+
createdAt: meta.createdAt,
|
|
391
|
+
...(meta.cwd === void 0 ? {} : { cwd: meta.cwd }),
|
|
392
|
+
isSeeded: meta.isSeeded ?? false,
|
|
393
|
+
inheritedEventCount,
|
|
394
|
+
}, restored.checkpoint);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
this.ctx.logger.warn(`archive-manager: projection repair for archived session "${header.id}" failed: ${String(error)}`);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** 新版读句柄必须关闭;旧版仍沿用 readFrom,避免激活 Agent 或写入会话日志。 */
|
|
403
|
+
async readStoredProjectionSource(persistence, sessionId) {
|
|
404
|
+
if (typeof persistence.readFrom === "function")
|
|
405
|
+
return persistence.readFrom(sessionId, 0);
|
|
406
|
+
const handle = await persistence.open(sessionId, "read");
|
|
407
|
+
try {
|
|
408
|
+
const { events } = await handle.read(0);
|
|
409
|
+
return {
|
|
410
|
+
meta: handle.header,
|
|
411
|
+
inheritedEventCount: handle.inheritedEventCount,
|
|
412
|
+
events,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
await handle.close();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 把一个会话移出注册表全局归档集合,恢复其正常可见性(其记账位从未
|
|
421
|
+
* 移动,会话在原工作区位置重新出现)。幂等:未归档的已知会话直接返回
|
|
422
|
+
* 当前集合不写入;未知会话与 `archiveSession` 一样抛错。
|
|
423
|
+
* @param sessionId - 要取消归档的会话。
|
|
424
|
+
* @returns 更新后的完整归档集合。
|
|
425
|
+
*/
|
|
426
|
+
async unarchiveSession(sessionId) {
|
|
427
|
+
return this.enqueueOperation(async () => {
|
|
428
|
+
if (!(await this.sessionKnown(sessionId)))
|
|
429
|
+
throw new ArchiveUnknownSessionError(sessionId);
|
|
430
|
+
const state = this.requireState();
|
|
431
|
+
if (!state.archivedSessionIds.includes(sessionId))
|
|
432
|
+
return { archivedSessionIds: [...state.archivedSessionIds] };
|
|
433
|
+
const next = {
|
|
434
|
+
...state,
|
|
435
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
|
|
436
|
+
};
|
|
437
|
+
await this.setState(next);
|
|
438
|
+
await this.ensureArchivedAtLoaded();
|
|
439
|
+
if (this.archivedAtMap.delete(sessionId))
|
|
440
|
+
await this.saveArchivedAt();
|
|
441
|
+
return { archivedSessionIds: [...next.archivedSessionIds] };
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 将一个工作区内所有会话加入归档集合。先确认所有待归档会话仍存在,
|
|
446
|
+
* 再执行单次状态写入,因此未知会话不会导致项目只归档一部分。
|
|
447
|
+
*/
|
|
448
|
+
async archiveWorkspaceSessions(workspaceId) {
|
|
449
|
+
return this.enqueueOperation(async () => {
|
|
450
|
+
workspaceId = workspaceIdSchema.parse(workspaceId);
|
|
451
|
+
const workspace = this.requireTable().get(workspaceId);
|
|
452
|
+
if (workspace === void 0)
|
|
453
|
+
throw new Error(`unknown workspace "${workspaceId}"`);
|
|
454
|
+
const state = this.requireState();
|
|
455
|
+
const archived = new Set(state.archivedSessionIds);
|
|
456
|
+
const archivedSessionIdsAdded = [...new Set(workspace.sessionIds)].filter((sessionId) => !archived.has(sessionId));
|
|
457
|
+
for (const sessionId of archivedSessionIdsAdded) {
|
|
458
|
+
if (!(await this.sessionKnown(sessionId)))
|
|
459
|
+
throw new ArchiveUnknownSessionError(sessionId);
|
|
460
|
+
}
|
|
461
|
+
if (archivedSessionIdsAdded.length === 0)
|
|
462
|
+
return {
|
|
463
|
+
archivedSessionIds: [...state.archivedSessionIds],
|
|
464
|
+
archivedSessionIdsAdded,
|
|
465
|
+
};
|
|
466
|
+
const next = {
|
|
467
|
+
...state,
|
|
468
|
+
archivedSessionIds: [
|
|
469
|
+
...state.archivedSessionIds,
|
|
470
|
+
...archivedSessionIdsAdded,
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
await this.setState(next);
|
|
474
|
+
return {
|
|
475
|
+
archivedSessionIds: [...next.archivedSessionIds],
|
|
476
|
+
archivedSessionIdsAdded,
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* 按宿主权威归档集合一次恢复全部、一个工作区或未分组的归档会话。
|
|
482
|
+
* 目标全部来自已归档集合,因此即使日志已被外部移除,也会清掉陈旧归档标记。
|
|
483
|
+
*/
|
|
484
|
+
async unarchiveSessions(target) {
|
|
485
|
+
return this.enqueueOperation(async () => {
|
|
486
|
+
const unarchivedSessionIds = this.archivedSessionIdsForTarget(target);
|
|
487
|
+
if (unarchivedSessionIds.length === 0)
|
|
488
|
+
return {
|
|
489
|
+
archivedSessionIds: [...this.requireState().archivedSessionIds],
|
|
490
|
+
unarchivedSessionIds: [],
|
|
491
|
+
};
|
|
492
|
+
const restored = new Set(unarchivedSessionIds);
|
|
493
|
+
const state = this.requireState();
|
|
494
|
+
const next = {
|
|
495
|
+
...state,
|
|
496
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => !restored.has(id)),
|
|
497
|
+
};
|
|
498
|
+
await this.setState(next);
|
|
499
|
+
return {
|
|
500
|
+
archivedSessionIds: [...next.archivedSessionIds],
|
|
501
|
+
unarchivedSessionIds,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* 按作用域永久删除归档会话。跨会话文件删除无法组成事务,因此继续处理
|
|
507
|
+
* 后续目标并把成功、并发消失和失败分别返回给客户端。
|
|
508
|
+
*/
|
|
509
|
+
async deleteArchivedSessions(target) {
|
|
510
|
+
return this.enqueueOperation(async () => {
|
|
511
|
+
const requestedSessionIds = this.archivedSessionIdsForTarget(target);
|
|
512
|
+
const deletedSessionIds = [];
|
|
513
|
+
const skippedSessionIds = [];
|
|
514
|
+
const failures = [];
|
|
515
|
+
for (const sessionId of requestedSessionIds) {
|
|
516
|
+
try {
|
|
517
|
+
await this.deleteSessionCore(sessionId);
|
|
518
|
+
deletedSessionIds.push(sessionId);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (error instanceof ArchiveUnknownSessionError) {
|
|
522
|
+
// 转录已消失时仍需清完可达的持久痕迹;只有全部完成才算
|
|
523
|
+
// skipped,否则保留归档标记并作为 failure 暴露,允许重试。
|
|
524
|
+
try {
|
|
525
|
+
await this.cleanupUnknownArchivedSession(sessionId);
|
|
526
|
+
skippedSessionIds.push(sessionId);
|
|
527
|
+
}
|
|
528
|
+
catch (cleanupError) {
|
|
529
|
+
failures.push({ sessionId, message: String(cleanupError) });
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
failures.push({ sessionId, message: String(error) });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
requestedSessionIds,
|
|
538
|
+
deletedSessionIds,
|
|
539
|
+
skippedSessionIds,
|
|
540
|
+
failures,
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 清理已无转录的陈旧归档项。缓存墓碑只在清除在途写入期间短暂持有:
|
|
546
|
+
* workspace 没有可记录的旧 header 身份,永久保留它会挡住未来的冷复用。
|
|
547
|
+
* 归档标记最后清除,前序可失败步骤出错时批量入口仍能再次命中。
|
|
548
|
+
*/
|
|
549
|
+
async cleanupUnknownArchivedSession(sessionId) {
|
|
550
|
+
const projCache = this.ctx.get("sessionProjectionCache");
|
|
551
|
+
await projCache?.whenIdle?.();
|
|
552
|
+
if (projCache !== void 0) {
|
|
553
|
+
await projCache.delete(sessionId);
|
|
554
|
+
// 等待删除期间可能已进入的写回观察到墓碑并完成补删,再允许
|
|
555
|
+
// 同 id 的未来新生命周期写入缓存。
|
|
556
|
+
await projCache.whenIdle?.();
|
|
557
|
+
projCache.clearTombstone?.(sessionId);
|
|
558
|
+
}
|
|
559
|
+
await this.cleanSpill(sessionId);
|
|
560
|
+
await this.removeFromWorkspaceAccounts(sessionId);
|
|
561
|
+
const state = this.requireState();
|
|
562
|
+
if (state.archivedSessionIds.includes(sessionId)) {
|
|
563
|
+
await this.setState({
|
|
564
|
+
...state,
|
|
565
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
await this.ensureArchivedAtLoaded();
|
|
569
|
+
if (this.archivedAtMap.delete(sessionId))
|
|
570
|
+
await this.saveArchivedAt();
|
|
571
|
+
}
|
|
572
|
+
/** 以归档集合顺序解析批量目标,避免依赖浏览器尚未加载完整的摘要投影。 */
|
|
573
|
+
archivedSessionIdsForTarget(target) {
|
|
574
|
+
target = archivedBatchTargetSchema.parse(target);
|
|
575
|
+
const state = this.requireState();
|
|
576
|
+
const archivedSessionIds = [...new Set(state.archivedSessionIds)];
|
|
577
|
+
if (target.scope === "all")
|
|
578
|
+
return archivedSessionIds;
|
|
579
|
+
if (target.scope === "sessions") {
|
|
580
|
+
const selected = new Set(target.sessionIds);
|
|
581
|
+
return archivedSessionIds.filter((id) => selected.has(id));
|
|
582
|
+
}
|
|
583
|
+
if (target.scope === "workspace") {
|
|
584
|
+
const workspace = this.requireTable().get(target.workspaceId);
|
|
585
|
+
if (workspace === void 0)
|
|
586
|
+
throw new Error(`unknown workspace "${target.workspaceId}"`);
|
|
587
|
+
const accounted = new Set(workspace.sessionIds);
|
|
588
|
+
return archivedSessionIds.filter((id) => accounted.has(id));
|
|
589
|
+
}
|
|
590
|
+
const accounted = /* @__PURE__ */ new Set();
|
|
591
|
+
const table = this.requireTable();
|
|
592
|
+
for (const workspaceId of state.workspaceIds) {
|
|
593
|
+
for (const sessionId of table.get(workspaceId)?.sessionIds ?? [])
|
|
594
|
+
accounted.add(sessionId);
|
|
595
|
+
}
|
|
596
|
+
return archivedSessionIds.filter((id) => !accounted.has(id));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 永久删除一个会话及其全部痕迹(转录目录、工作区记账、归档标记、
|
|
600
|
+
* 投影缓存行)。
|
|
601
|
+
* @param sessionId - 要删除的会话。
|
|
602
|
+
* @returns 持久化完成后的 `{ deleted: true }`。
|
|
603
|
+
* @throws {@link ArchiveUnknownSessionError} 会话未知时抛出。
|
|
604
|
+
*/
|
|
605
|
+
async deleteSession(sessionId) {
|
|
606
|
+
return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
|
|
607
|
+
}
|
|
608
|
+
/** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。 */
|
|
609
|
+
async deleteSessionCore(sessionId) {
|
|
610
|
+
if (!(await this.sessionKnown(sessionId)))
|
|
611
|
+
throw new ArchiveUnknownSessionError(sessionId);
|
|
612
|
+
const sessions = this.ctx.get("sessions");
|
|
613
|
+
const live = sessions?.get(sessionId);
|
|
614
|
+
// 先记录被删生命周期的日志身份:目录删除后头部不可再读,
|
|
615
|
+
// 冷复用探针(sessionKnown 墓碑分支)靠它区分同 id 的新生命周期。
|
|
616
|
+
const deletedHeader = this.headers.get(sessionId) ?? live?.header;
|
|
617
|
+
if (live !== void 0) {
|
|
618
|
+
// 持久化屏障先行:不能有未落盘的转录写入与目录删除竞争
|
|
619
|
+
//(持久化后端按批关闭句柄,flush 过的会话不再持有打开的文件)。
|
|
620
|
+
await sessions.flush(live);
|
|
621
|
+
// 从存储分离;`session/disposed` 同步触发,驱动浏览器端的
|
|
622
|
+
// `host/session-removed` 帧并启动投影缓存的最终写后落盘。
|
|
623
|
+
const entry = sessions.liveEntryFor(live);
|
|
624
|
+
sessions.detachEntered(entry);
|
|
625
|
+
}
|
|
626
|
+
else if (sessions !== void 0)
|
|
627
|
+
await this.publishColdSessionRemoval(sessionId, sessions);
|
|
628
|
+
const projCache = this.ctx.get("sessionProjectionCache");
|
|
629
|
+
// dispose 的写后落盘必须先于缓存行删除完成,
|
|
630
|
+
// 否则该行会在删除之后被写回(复活)。
|
|
631
|
+
await projCache?.whenIdle?.();
|
|
632
|
+
if (projCache !== void 0)
|
|
633
|
+
await projCache.delete(sessionId);
|
|
634
|
+
await this.deleteDescendants(sessionId);
|
|
635
|
+
await this.cleanSpill(sessionId);
|
|
636
|
+
await this.removeTranscriptDirectory(sessionId);
|
|
637
|
+
// 只有物理工件删除成功后才能提交记账清理;否则批量目标会因归档标记
|
|
638
|
+
// 或工作区记账提前消失而无法重试。
|
|
639
|
+
const state = this.requireState();
|
|
640
|
+
if (state.archivedSessionIds.includes(sessionId)) {
|
|
641
|
+
await this.setState({
|
|
642
|
+
...state,
|
|
643
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
await this.removeFromWorkspaceAccounts(sessionId);
|
|
647
|
+
// 物理删除已成功:此时再清父类索引。后续记账失败时保留索引,便于重试。
|
|
648
|
+
this.forgetIndexedSession(sessionId);
|
|
649
|
+
await this.ensureArchivedAtLoaded();
|
|
650
|
+
if (this.archivedAtMap.delete(sessionId))
|
|
651
|
+
await this.saveArchivedAt();
|
|
652
|
+
if (deletedHeader !== void 0)
|
|
653
|
+
this.deletedIdentities.set(sessionId, headerIdentity(deletedHeader));
|
|
654
|
+
this.publishDeletedSession(sessionId);
|
|
655
|
+
return { deleted: true };
|
|
656
|
+
}
|
|
657
|
+
/** 删除完成后通知全部客户端;新版不再通过伪造冷会话生命周期触发通知。 */
|
|
658
|
+
publishDeletedSession(sessionId) {
|
|
659
|
+
try {
|
|
660
|
+
this.ctx.emit("api-session/removed", sessionId);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
this.ctx.logger.warn(`archive-manager: session "${sessionId}" deleted but removal notification failed: ${String(error)}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* 从父类内存索引中遗忘已删除会话,并阻止后续 indexHeaders 把它加回。
|
|
668
|
+
* 实时会话以同 id 重新出现时(自定义 id 复用)会撤掉墓碑。
|
|
669
|
+
*/
|
|
670
|
+
clearTombstone(sessionId) {
|
|
671
|
+
this.deletedSessionIds.delete(sessionId);
|
|
672
|
+
this.deletedIdentities.delete(sessionId);
|
|
673
|
+
const idx = this.deletedSessionOrder.indexOf(sessionId);
|
|
674
|
+
if (idx !== -1)
|
|
675
|
+
this.deletedSessionOrder.splice(idx, 1);
|
|
676
|
+
// workspace 与 projection-cache 共同描述同一删除生命周期;新生命周期
|
|
677
|
+
// 被接纳时必须同步撤销两处墓碑,否则缓存写入仍会永久被拦截。
|
|
678
|
+
this.ctx.get("sessionProjectionCache")?.clearTombstone?.(sessionId);
|
|
679
|
+
}
|
|
680
|
+
forgetIndexedSession(sessionId) {
|
|
681
|
+
for (const evicted of trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, sessionId, this.deletedSessionTombstoneLimit))
|
|
682
|
+
this.deletedIdentities.delete(evicted);
|
|
683
|
+
this.headers.delete(sessionId);
|
|
684
|
+
this.sessionPaths.delete(sessionId);
|
|
685
|
+
this.invalidSessionPaths.delete(sessionId);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* 已删除会话对归档/删除入口都视为未知。实时复用同一 id 时撤墓碑,
|
|
689
|
+
* 避免挡住新会话。
|
|
690
|
+
*/
|
|
691
|
+
async sessionKnown(id) {
|
|
692
|
+
if (this.ctx.get("sessions")?.get(id) !== void 0) {
|
|
693
|
+
this.clearTombstone(id);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
if (this.deletedSessionIds.has(id))
|
|
697
|
+
return this.coldReuseKnown(id);
|
|
698
|
+
return super.sessionKnown(id);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 墓碑分支的冷复用探针:其他进程以同 id 重建并落盘的新会话(日志身份
|
|
702
|
+
* 不同)撤墓碑放行并重新编入索引;stale list() 里同生命周期的旧头部
|
|
703
|
+
* 仍视为未知。身份不可考(删除时未取到头部)时保守维持未知。
|
|
704
|
+
*/
|
|
705
|
+
async coldReuseKnown(id) {
|
|
706
|
+
const deletedIdentity = this.deletedIdentities.get(id);
|
|
707
|
+
if (deletedIdentity === void 0)
|
|
708
|
+
return false;
|
|
709
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
710
|
+
if (persistence === void 0 || typeof persistence.list !== "function")
|
|
711
|
+
return false;
|
|
712
|
+
let header;
|
|
713
|
+
try {
|
|
714
|
+
header = (await this.listStoredHeaders()).find((item) => item.id === id);
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
this.ctx.logger.warn(`archive-manager: cold-reuse probe for "${id}" failed: ${String(error)}`);
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
if (header === void 0)
|
|
721
|
+
return false;
|
|
722
|
+
const listed = headerIdentity(header);
|
|
723
|
+
if (listed.createdAt === deletedIdentity.createdAt &&
|
|
724
|
+
listed.cwd === deletedIdentity.cwd)
|
|
725
|
+
return false;
|
|
726
|
+
this.clearTombstone(id);
|
|
727
|
+
await this.indexHeader(header);
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* 父类 indexHeaders 只增不减;跳过墓碑 id,避免 stale persistence.list()
|
|
732
|
+
* 把已删除会话重新编入 headers。
|
|
733
|
+
*/
|
|
734
|
+
async indexHeader(header) {
|
|
735
|
+
if (this.deletedSessionIds.has(header.id))
|
|
736
|
+
return;
|
|
737
|
+
return super.indexHeader(header);
|
|
738
|
+
}
|
|
739
|
+
/** 统一旧版头部数组与 0.1.3 的持久化快照,供父类索引和本插件枚举共用。 */
|
|
740
|
+
async listStoredHeaders() {
|
|
741
|
+
return (await this.ctx.sessionPersistence.list()).map((item) => item.header ?? item);
|
|
742
|
+
}
|
|
743
|
+
async indexHeaders(items) {
|
|
744
|
+
for (const item of items)
|
|
745
|
+
await this.indexHeader(item.header ?? item);
|
|
746
|
+
}
|
|
747
|
+
/** 为未处于实时状态的持久化会话发布相同的移除事件。 */
|
|
748
|
+
async publishColdSessionRemoval(sessionId, sessions) {
|
|
749
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
750
|
+
if (persistence === void 0 || typeof persistence.prepare !== "function")
|
|
751
|
+
return;
|
|
752
|
+
try {
|
|
753
|
+
const preparation = await persistence.prepare(sessionId);
|
|
754
|
+
const detach = sessions.enter(preparation.session);
|
|
755
|
+
try {
|
|
756
|
+
sessions.announce(preparation.session);
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
detach();
|
|
760
|
+
preparation[Symbol.dispose]();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
this.ctx.logger.warn(`archive-manager: could not publish removal for stored session "${sessionId}": ${String(error)}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** 官方 JSONL 已知布局清理会话专属目录;其他后端只删除定位到的工件。 */
|
|
768
|
+
async removeTranscriptDirectory(sessionId) {
|
|
769
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
770
|
+
if (persistence === void 0 || typeof persistence.locate !== "function") {
|
|
771
|
+
throw new Error(`cannot delete session "${sessionId}": the session persistence backend does not expose locate() to resolve its transcript artifact`);
|
|
772
|
+
}
|
|
773
|
+
const header = await this.readSessionHeader(sessionId);
|
|
774
|
+
const location = persistence.locate(header);
|
|
775
|
+
if (location === void 0 || typeof location.path !== "string") {
|
|
776
|
+
throw new Error(`cannot delete session "${sessionId}": the session persistence backend could not resolve its transcript artifact`);
|
|
777
|
+
}
|
|
778
|
+
let target = { path: location.path, kind: "transcript artifact" };
|
|
779
|
+
try {
|
|
780
|
+
const directory = jsonlSessionDirectory(persistence, header, location);
|
|
781
|
+
if (directory !== void 0) {
|
|
782
|
+
target = { path: directory, kind: "session directory" };
|
|
783
|
+
// The configured storage root may itself be an intentional alias, but
|
|
784
|
+
// project/session links must not redirect recursive deletion elsewhere.
|
|
785
|
+
// These checks assume trusted, stable storage ancestors; lstat + rm
|
|
786
|
+
// is not an atomic defense against another process swapping directories.
|
|
787
|
+
for (const path of [dirname(directory), directory]) {
|
|
788
|
+
let stat;
|
|
789
|
+
try {
|
|
790
|
+
stat = await lstat(path);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (error?.code === "ENOENT")
|
|
794
|
+
continue; // Already removed on an earlier attempt.
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
if (stat.isSymbolicLink())
|
|
798
|
+
throw new Error(`refusing to delete through symbolic link "${path}"`);
|
|
799
|
+
if (!stat.isDirectory())
|
|
800
|
+
throw new Error(`expected session storage directory "${path}"`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else if (persistence.name === "session-persistence-jsonl") {
|
|
804
|
+
this.ctx.logger.warn(`archive-manager: session "${sessionId}": JSONL directory ownership could not be verified; falling back to artifact-only deletion at "${location.path}" (parent directory retained)`);
|
|
805
|
+
}
|
|
806
|
+
await rm(target.path, { recursive: true, force: true });
|
|
807
|
+
// 新版 locate() 只是当前代际的诊断路径;以存储观察确认删除已生效,
|
|
808
|
+
// 避免不存在的目标被 force 忽略后,仍把可读会话记为已删除。
|
|
809
|
+
if (typeof persistence.stat === "function" &&
|
|
810
|
+
(await persistence.stat(sessionId)) !== void 0) {
|
|
811
|
+
throw new Error(`session "${sessionId}" is still present in persistence after artifact removal`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
const message = `cannot delete session "${sessionId}": cleanup of ${target.kind} "${target.path}" failed; bookkeeping retained for retry`;
|
|
816
|
+
const detail = `${message}: ${String(error)}`;
|
|
817
|
+
this.ctx.logger.warn(`archive-manager: ${detail}`);
|
|
818
|
+
throw new Error(detail, { cause: error });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/** 把 id 从每个工作区记录中移除,并刷新实体快照。 */
|
|
822
|
+
async removeFromWorkspaceAccounts(sessionId) {
|
|
823
|
+
const table = this.requireTable();
|
|
824
|
+
const state = this.requireState();
|
|
825
|
+
for (const workspaceId of state.workspaceIds) {
|
|
826
|
+
const record = table.get(workspaceId);
|
|
827
|
+
if (record === void 0 || !record.sessionIds.includes(sessionId))
|
|
828
|
+
continue;
|
|
829
|
+
const next = await table.update(workspaceId, (current) => ({
|
|
830
|
+
...current,
|
|
831
|
+
sessionIds: current.sessionIds.filter((id) => id !== sessionId),
|
|
832
|
+
updatedAt: /* @__PURE__ */ new Date().toISOString(),
|
|
833
|
+
}));
|
|
834
|
+
const entity = this.entities.get(workspaceId);
|
|
835
|
+
if (entity !== void 0)
|
|
836
|
+
entity.record = next;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** 尽力而为的级联删除:删除 `sessionId` 的 SUBAGENT 子会话。
|
|
840
|
+
* 仅头部标记 `origin: "subagent"` 的会话参与:单凭 `parentSession` 有歧义
|
|
841
|
+
*(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。 */
|
|
842
|
+
async deleteDescendants(sessionId) {
|
|
843
|
+
try {
|
|
844
|
+
const descendants = [];
|
|
845
|
+
const sessions = this.ctx.get("sessions");
|
|
846
|
+
if (sessions !== void 0)
|
|
847
|
+
for (const session of sessions.list()) {
|
|
848
|
+
if (session.header.parentSession === sessionId &&
|
|
849
|
+
session.header.origin === "subagent")
|
|
850
|
+
descendants.push(session.id);
|
|
851
|
+
}
|
|
852
|
+
for (const header of await this.listStoredHeaders()) {
|
|
853
|
+
if (header.parentSession === sessionId &&
|
|
854
|
+
header.origin === "subagent" &&
|
|
855
|
+
!descendants.includes(header.id))
|
|
856
|
+
descendants.push(header.id);
|
|
857
|
+
}
|
|
858
|
+
for (const childId of descendants) {
|
|
859
|
+
try {
|
|
860
|
+
if (!(await this.sessionKnown(childId)))
|
|
861
|
+
continue;
|
|
862
|
+
await this.deleteSessionCore(childId);
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
if (error instanceof ArchiveUnknownSessionError)
|
|
866
|
+
continue;
|
|
867
|
+
this.ctx.logger.warn(`archive-manager: cascade delete of subagent session "${childId}" (child of "${sessionId}") failed: ${String(error)}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
this.ctx.logger.warn(`archive-manager: descendant enumeration for deleted session "${sessionId}" failed: ${String(error)}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** 尽力而为的 spill 清理:移除该会话作用域的 spill 目录。 */
|
|
876
|
+
async cleanSpill(sessionId) {
|
|
877
|
+
try {
|
|
878
|
+
const spill = this.ctx.get("spillStore");
|
|
879
|
+
if (spill === void 0 || typeof spill.root !== "string")
|
|
880
|
+
return;
|
|
881
|
+
await rm(sessionDir(spill.root, sessionId), {
|
|
882
|
+
recursive: true,
|
|
883
|
+
force: true,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
this.ctx.logger.warn(`archive-manager: spill cleanup for deleted session "${sessionId}" failed: ${String(error)}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
//#endregion
|
|
892
|
+
export { ArchiveWorkspaceRegistry, ArchiveWorkspaceRegistry as default };
|