dsh-plugin-tool-management 0.1.1 → 0.1.3

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.
@@ -0,0 +1,335 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { SessionProjectionCache, checkpointIdentity, checkpointRecord, projectionCacheDomainSpec } from "@deepseek-ai/dsh-session-projection-cache";
4
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
5
+ import { trackTombstone } from "./tombstone.js";
6
+ /**
7
+ * 当前投影缓存写入独立的 v2 域,避免 DSH alpha.2 将包含冒号的逻辑会话 ID
8
+ * 直接映射为 Windows 文件名。旧名 v1 与旧名 v2 域只在启动时读取并补缺迁入,
9
+ * 迁移不删除原始缓存,任意中断都可在后续启动继续。
10
+ */
11
+ const storedCheckpointRecord = checkpointRecord.extend({
12
+ sessionId: checkpointIdentity.shape.cwd.unwrap()
13
+ });
14
+ const safeDomainInput = {
15
+ // 旧版 DSH 不支持同名域的版本迁移。v2 改用新域名,确保升级时不会在
16
+ // 打开目标域之前因旧 v1 元数据而终止启动。
17
+ name: "session_projcache_archive_manager_v2",
18
+ version: 2,
19
+ tables: { sessions: domainTable(storedCheckpointRecord) },
20
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
21
+ };
22
+ const safeProjectionCacheDomainSpec = defineDomain(safeDomainInput);
23
+ /** The last official whole-file cache format used by DSH rc.2. */
24
+ const legacyOptionalIdentityFields = {};
25
+ for (const field of ["isSeeded", "inheritedEventCount"]) {
26
+ // rc.2 的身份 schema 尚未定义这些字段;向 Zod 传入不存在的字段会在
27
+ // 首次解析历史记录时抛错,因此只能按当前运行时实际的 schema 构造掩码。
28
+ if (Object.hasOwn(checkpointIdentity.shape, field))
29
+ legacyOptionalIdentityFields[field] = true;
30
+ }
31
+ const legacyCheckpointRecord = checkpointRecord.extend({
32
+ identity: checkpointIdentity.partial(legacyOptionalIdentityFields)
33
+ });
34
+ const legacyStoredCheckpointRecord = legacyCheckpointRecord.extend({
35
+ sessionId: checkpointIdentity.shape.cwd.unwrap()
36
+ });
37
+ /** v0.1.24 短暂使用过的旧名 v2 安全缓存域。 */
38
+ const legacySafeV2ProjectionCacheDomainSpec = defineDomain({
39
+ name: "session_projcache_archive_manager",
40
+ version: 2,
41
+ tables: { sessions: domainTable(storedCheckpointRecord) },
42
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
43
+ });
44
+ /** v0.1.23 及以前归档管理器自身使用的安全缓存域。 */
45
+ const legacySafeProjectionCacheDomainSpec = defineDomain({
46
+ name: "session_projcache_archive_manager",
47
+ version: 1,
48
+ tables: { sessions: domainTable(legacyStoredCheckpointRecord) },
49
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
50
+ });
51
+ const legacyProjectionCacheDomainSpec = defineDomain({
52
+ name: "session_projcache",
53
+ version: 3,
54
+ tables: { sessions: domainTable(legacyCheckpointRecord) }
55
+ });
56
+ function projectionCacheStorageKey(sessionId) {
57
+ return `session_${createHash("sha256").update(sessionId, "utf8").digest("base64url")}`;
58
+ }
59
+ function unwrapStoredCheckpoint(stored) {
60
+ return { identity: stored.identity, rows: stored.rows };
61
+ }
62
+ /** Map the Session-id table contract onto path-safe physical keys. */
63
+ class SafeSessionTable {
64
+ constructor(table) {
65
+ this.table = table;
66
+ }
67
+ get size() {
68
+ return [...this.keys()].length;
69
+ }
70
+ get(sessionId) {
71
+ const stored = this.table.get(projectionCacheStorageKey(sessionId));
72
+ if (stored === void 0 || stored.sessionId !== sessionId)
73
+ return void 0;
74
+ return unwrapStoredCheckpoint(stored);
75
+ }
76
+ has(sessionId) {
77
+ return this.get(sessionId) !== void 0;
78
+ }
79
+ *entries() {
80
+ for (const [key, stored] of this.table.entries()) {
81
+ if (projectionCacheStorageKey(stored.sessionId) !== key)
82
+ continue;
83
+ yield [stored.sessionId, unwrapStoredCheckpoint(stored)];
84
+ }
85
+ }
86
+ *keys() {
87
+ for (const [sessionId] of this.entries())
88
+ yield sessionId;
89
+ }
90
+ *values() {
91
+ for (const [, value] of this.entries())
92
+ yield value;
93
+ }
94
+ async put(sessionId, record) {
95
+ const key = projectionCacheStorageKey(sessionId);
96
+ const existing = this.table.get(key);
97
+ if (existing !== void 0 && existing.sessionId !== sessionId) {
98
+ throw new Error(`projection-cache storage-key collision for "${sessionId}"`);
99
+ }
100
+ await this.table.put(key, { sessionId, identity: record.identity, rows: record.rows });
101
+ }
102
+ async delete(sessionId) {
103
+ const key = projectionCacheStorageKey(sessionId);
104
+ const existing = this.table.get(key);
105
+ if (existing === void 0 || existing.sessionId !== sessionId)
106
+ return false;
107
+ return this.table.delete(key);
108
+ }
109
+ }
110
+ async function readSourceRecords(ctx, spec, label) {
111
+ let domain;
112
+ try {
113
+ domain = await ctx.storageDomain.open(spec);
114
+ return { opened: true, records: [...domain.table("sessions").entries()] };
115
+ }
116
+ catch (error) {
117
+ ctx.logger.warn(`archive-manager projcache: ${label} cache import skipped: ${String(error)}`);
118
+ return { opened: false, records: [] };
119
+ }
120
+ finally {
121
+ try {
122
+ await domain?.close();
123
+ }
124
+ catch (error) {
125
+ ctx.logger.warn(`archive-manager projcache: ${label} cache close failed: ${String(error)}`);
126
+ }
127
+ }
128
+ }
129
+ async function readLegacySafeRecords(ctx, spec, label) {
130
+ const source = await readSourceRecords(ctx, spec, label);
131
+ const records = [];
132
+ for (const [physicalKey, stored] of source.records) {
133
+ if (stored === null || typeof stored !== "object" || typeof stored.sessionId !== "string") {
134
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${physicalKey}" import skipped because its session id is invalid`);
135
+ continue;
136
+ }
137
+ if (projectionCacheStorageKey(stored.sessionId) !== physicalKey) {
138
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${physicalKey}" import skipped because its storage key does not match the session id`);
139
+ continue;
140
+ }
141
+ records.push([stored.sessionId, unwrapStoredCheckpoint(stored)]);
142
+ }
143
+ return { ...source, records };
144
+ }
145
+ /**
146
+ * 旧版记录没有完整身份时,只能安全补齐未播种会话的零继承切点。
147
+ * 播种会话无法从头部反推出精确切点,宁可跳过并按需重建。
148
+ */
149
+ function normalizeLegacyRecord(record) {
150
+ if (record === null || typeof record !== "object" || record.identity === null || typeof record.identity !== "object")
151
+ return void 0;
152
+ const identity = record.identity;
153
+ const inheritedEventCount = identity.inheritedEventCount;
154
+ const validOffset = Number.isSafeInteger(inheritedEventCount) && inheritedEventCount >= 0;
155
+ if (identity.isSeeded === true) {
156
+ if (!validOffset)
157
+ return void 0;
158
+ return record;
159
+ }
160
+ if (identity.isSeeded !== void 0 && identity.isSeeded !== false)
161
+ return void 0;
162
+ if (inheritedEventCount !== void 0 && inheritedEventCount !== 0)
163
+ return void 0;
164
+ return {
165
+ ...record,
166
+ identity: {
167
+ ...identity,
168
+ isSeeded: false,
169
+ inheritedEventCount: 0
170
+ }
171
+ };
172
+ }
173
+ async function importMissingRecords(ctx, target, records, label) {
174
+ let imported = 0;
175
+ for (const [sessionId, record] of records) {
176
+ if (target.has(sessionId))
177
+ continue;
178
+ try {
179
+ const normalized = normalizeLegacyRecord(record);
180
+ if (normalized === void 0) {
181
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${sessionId}" import skipped because its lifecycle identity is incomplete`);
182
+ continue;
183
+ }
184
+ await target.put(sessionId, normalized);
185
+ imported += 1;
186
+ }
187
+ catch (error) {
188
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${sessionId}" import failed: ${String(error)}`);
189
+ }
190
+ }
191
+ return imported;
192
+ }
193
+ /**
194
+ * 合并归档管理器旧名 v2、旧名 v1、官方新版逐会话与旧版整文件缓存。目标域记录
195
+ * 最高优先,随后按新旧顺序只补缺失记录;任一来源失败时仍继续尝试另一个来源。
196
+ */
197
+ async function importPreviousProjectionCache(ctx, target) {
198
+ const legacySafeV2 = await readLegacySafeRecords(ctx, legacySafeV2ProjectionCacheDomainSpec, "archive-manager v2");
199
+ const legacySafe = await readLegacySafeRecords(ctx, legacySafeProjectionCacheDomainSpec, "archive-manager v1");
200
+ const legacy = await readSourceRecords(ctx, legacyProjectionCacheDomainSpec, "legacy v3");
201
+ const sameSpec = projectionCacheDomainSpec.version === legacyProjectionCacheDomainSpec.version
202
+ && projectionCacheDomainSpec.layout === legacyProjectionCacheDomainSpec.layout;
203
+ let imported = 0;
204
+ if (legacySafeV2.opened)
205
+ imported += await importMissingRecords(ctx, target, legacySafeV2.records, "archive-manager v2");
206
+ if (legacySafe.opened)
207
+ imported += await importMissingRecords(ctx, target, legacySafe.records, "archive-manager v1");
208
+ if (!sameSpec) {
209
+ const current = await readSourceRecords(ctx, projectionCacheDomainSpec, "current");
210
+ if (current.opened)
211
+ imported += await importMissingRecords(ctx, target, current.records, "current");
212
+ }
213
+ if (legacy.opened)
214
+ imported += await importMissingRecords(ctx, target, legacy.records, "legacy v3");
215
+ return imported;
216
+ }
217
+ //#region lib/types/index.js
218
+ /**
219
+ * dsh-archive-manager projcache 半边。
220
+ *
221
+ * `ArchiveProjectionCache` 继承上游 `SessionProjectionCache`,保留服务名
222
+ * `sessionProjectionCache` 与 fail-soft 写路径,但写入独立、路径安全的
223
+ * `session_projcache_archive_manager_v2` 域。旧名 v1 与 v2 域只在启动时
224
+ * 读取并补缺迁入;归档管理的
225
+ * `deleteSession` 增加三处守护:
226
+ *
227
+ * - `delete(id)` - 永久移除兼容域中该会话经 SHA-256 映射的缓存投影行,
228
+ * 写入前先登记墓碑。
229
+ * - `whenIdle()` - 全部在途公开写入落定后 resolve。
230
+ * - 墓碑挡住已删除会话的写入:迟到的写入要么被直接拦截,要么在
231
+ * 落定后补删自己的残留行,不会复活已删除的缓存条目。守护覆盖
232
+ * `write()`(dispose 写后路径)与 `put()`(rc.2 的 `putSoft` 最终调用
233
+ * 它,alpha.2 的 `coldSnapshot` 则直接调用它),两代落盘路径都纳入
234
+ * `whenIdle` 跟踪。
235
+ *
236
+ * 默认导出是 Service 子类(与上游 `@deepseek-ai/dsh-session-projection-cache`
237
+ * 包同形),profile 补丁可直接替换 `session-projection-cache` 服务行,
238
+ * 无需其他接线改动。
239
+ */
240
+ var ArchiveProjectionCache = class extends SessionProjectionCache {
241
+ /** 已永久删除的会话:其投影缓存行不再允许写入。 */
242
+ deletedSessionIds = /* @__PURE__ */ new Set();
243
+ /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
244
+ deletedSessionOrder = [];
245
+ deletedSessionTombstoneLimit = 4096;
246
+ /** 在途写入的队尾(只含已落定的 promise)。 */
247
+ writeTail = Promise.resolve();
248
+ constructor(ctx, config) {
249
+ super(ctx, config);
250
+ }
251
+ /**
252
+ * Replace the official cache domain with the path-safe compatibility
253
+ * domain, then preserve the upstream listener/write implementation.
254
+ */
255
+ async [Service.init]() {
256
+ const domain = await this.ctx.storageDomain.open(safeProjectionCacheDomainSpec);
257
+ this.ctx.effect(() => () => domain.close(), "sessionProjectionCache.domainClose");
258
+ const table = new SafeSessionTable(domain.table("sessions"));
259
+ this.table = table;
260
+ await importPreviousProjectionCache(this.ctx, table);
261
+ this.installWritePath();
262
+ }
263
+ /** 跟踪公开写入路径,避免依赖上游私有 `flushSoft` 的实现细节。 */
264
+ write(session) {
265
+ const task = this.writeCore(session);
266
+ this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
267
+ return task;
268
+ }
269
+ /**
270
+ * 墓碑正确性依赖:super.write(session) 一次整体写入,返回后不再有后续异步落盘。
271
+ * 若上游改成多阶段异步,(C) 补删会漏掉后续写入,deletedSessionIds 挡不住复活。
272
+ */
273
+ async writeCore(session) {
274
+ if (this.deletedSessionIds.has(session.id))
275
+ return;
276
+ await super.write(session);
277
+ if (this.deletedSessionIds.has(session.id))
278
+ await this.requireTable().delete(session.id);
279
+ }
280
+ /**
281
+ * 守住所有底层写入。rc.2 的冷读经 `putSoft -> put`,alpha.2 则
282
+ * 直接经 `put`;把墓碑与 whenIdle 跟踪放在这里可同时兼容两代实现。
283
+ */
284
+ put(id, identity, rows) {
285
+ // alpha.2 coldSnapshot unconditionally chains `.catch()` onto put().
286
+ // A blocked write must therefore preserve the upstream Promise contract.
287
+ if (this.deletedSessionIds.has(id))
288
+ return Promise.resolve();
289
+ const task = this.putCore(id, identity, rows);
290
+ this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
291
+ return task;
292
+ }
293
+ async putCore(id, identity, rows) {
294
+ await super.put(id, identity, rows);
295
+ if (this.deletedSessionIds.has(id))
296
+ await this.requireTable().delete(id);
297
+ }
298
+ /** rc.2 公开的 fail-soft 辅助在 alpha.2 已移除;保留同形兼容入口。 */
299
+ async putSoft(id, identity, rows, what) {
300
+ const upstream = SessionProjectionCache.prototype.putSoft;
301
+ if (typeof upstream === "function")
302
+ return upstream.call(this, id, identity, rows, what);
303
+ try {
304
+ await this.put(id, identity, rows);
305
+ }
306
+ catch (error) {
307
+ this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`);
308
+ }
309
+ }
310
+ /**
311
+ * 全部被跟踪的在途写入落定后 resolve(含失败)。空闲缓存上调用立即返回。
312
+ * @returns 跟踪写入落定后的 resolution。
313
+ */
314
+ whenIdle() {
315
+ return this.writeTail;
316
+ }
317
+ /**
318
+ * 永久移除一个会话的缓存投影行。
319
+ * @param id - 要删除缓存行的会话。
320
+ * @returns 行删除完成后的 resolution。
321
+ */
322
+ async delete(id) {
323
+ trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, id, this.deletedSessionTombstoneLimit);
324
+ await this.requireTable().delete(id);
325
+ }
326
+ /** 撤销墓碑(供测试与同 id 新生命周期复用路径使用)。 */
327
+ clearTombstone(id) {
328
+ this.deletedSessionIds.delete(id);
329
+ const idx = this.deletedSessionOrder.indexOf(id);
330
+ if (idx !== -1)
331
+ this.deletedSessionOrder.splice(idx, 1);
332
+ }
333
+ };
334
+ //#endregion
335
+ export { ArchiveProjectionCache, ArchiveProjectionCache as default, SafeSessionTable, importPreviousProjectionCache, legacyProjectionCacheDomainSpec, legacySafeProjectionCacheDomainSpec, legacySafeV2ProjectionCacheDomainSpec, projectionCacheStorageKey, safeProjectionCacheDomainSpec };
@@ -0,0 +1,32 @@
1
+ //#region lib/types/tombstone.js
2
+ /**
3
+ * 通用墓碑簿记:登记一个已删除 id,并按 FIFO 在上限处淘汰最旧项。
4
+ * 工作区注册表(lib/workspace.js)与投影缓存(lib/projcache.js)共用
5
+ * 同一份语义,避免两处拷贝的淘汰策略分叉。
6
+ */
7
+ /**
8
+ * 登记已删除 id 并执行上限淘汰。
9
+ * @param {Set<string>} ids - 墓碑集合。
10
+ * @param {string[]} order - 插入顺序队列(FIFO 淘汰依据)。
11
+ * @param {string} id - 本次登记的 id。
12
+ * @param {number} limit - 墓碑上限。
13
+ * @returns {string[]} 被淘汰出集合的旧 id 列表,调用方据此清理附属簿记。
14
+ */
15
+ function trackTombstone(ids, order, id, limit) {
16
+ if (!ids.has(id)) {
17
+ ids.add(id);
18
+ order.push(id);
19
+ }
20
+ const evicted = [];
21
+ while (order.length > limit) {
22
+ const oldest = order.shift();
23
+ // id 自身被挤出队列时保留在集合中:宁多挡不错放。
24
+ if (oldest !== void 0 && oldest !== id) {
25
+ ids.delete(oldest);
26
+ evicted.push(oldest);
27
+ }
28
+ }
29
+ return evicted;
30
+ }
31
+ //#endregion
32
+ export { trackTombstone };