@michengai/dsh-archive-manager 0.1.19 → 0.1.21

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  Published release notes are retained below; new versions are added without removing earlier entries.
6
6
 
7
+ ## 0.1.21 — 2026-08-31
8
+
9
+ - Preserved the projection-cache `put()` Promise contract when a tombstone suppresses a deleted Session write, so DSH `v0.1.2-alpha.2` cold-read write-back remains fail-soft instead of throwing a synchronous `TypeError`.
10
+ - Added direct `put(...).catch(...)` regression coverage for alpha.2 while retaining the `putSoft` path used by DSH `v0.1.1-rc.2`, and clarified the separate safe cache domain and migration behavior.
11
+
12
+ Published package: [`@michengai/dsh-archive-manager@0.1.21`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.21).
13
+
14
+ ## 0.1.20 — 2026-08-31
15
+
16
+ - Kept IM and other non-filesystem-safe Session ids unchanged while persisting projection checkpoints under fixed-length safe keys, preventing DSH `v0.1.2-alpha.2` from using colons as Windows filenames.
17
+ - Added resumable, add-only cache import from the DSH `v0.1.1-rc.2` whole-file format and from clean alpha per-record profiles, so existing users retain cached projections across either upgrade path.
18
+ - Rebuild missing archived-session projections from the retained transcript on demand and refresh the client list, so legacy archive markers remain fully visible whether their old cache row exists or not.
19
+
20
+ Published package: [`@michengai/dsh-archive-manager@0.1.20`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.20).
21
+
7
22
  ## 0.1.19 — 2026-08-30
8
23
 
9
24
  - Fixed the workspace-row **+** action on DSH `v0.1.2-alpha.1` by resolving the `uiWorkspace` service when the action runs, removing its dependency on plugin load order and avoiding the removed legacy `startSession` API.
@@ -4,6 +4,21 @@
4
4
 
5
5
  以下发布说明会持续保留;新增版本时不再删除较早记录。
6
6
 
7
+ ## 0.1.21 — 2026-08-31
8
+
9
+ - 墓碑拦截已删除会话的投影缓存写入时仍保持 `put()` 的 Promise 契约,使 DSH `v0.1.2-alpha.2` 冷读写回继续按 fail-soft 方式处理,而不会同步抛出 `TypeError`。
10
+ - 补充 alpha.2 直接调用 `put(...).catch(...)` 的回归测试,同时保留 DSH `v0.1.1-rc.2` 使用的 `putSoft` 路径覆盖,并澄清独立安全缓存域与迁移行为。
11
+
12
+ 已发布包:[`@michengai/dsh-archive-manager@0.1.21`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.21)。
13
+
14
+ ## 0.1.20 — 2026-08-31
15
+
16
+ - 保持 IM 等不适合作文件名的会话 ID 不变,将投影检查点改用固定长度安全键落盘,避免 DSH `v0.1.2-alpha.2` 在 Windows 上把冒号直接用作文件名。
17
+ - 支持从 DSH `v0.1.1-rc.2` 单文件缓存和干净的 alpha 分记录缓存断点续迁、只补缺失项,老用户沿任一升级路径都能保留已有投影缓存。
18
+ - 归档标记仍在但旧缓存行缺失时,按需从保留的会话原文重建投影并刷新客户端列表;无论老归档是否带缓存,都能完整显示。
19
+
20
+ 已发布包:[`@michengai/dsh-archive-manager@0.1.20`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.20)。
21
+
7
22
  ## 0.1.19 — 2026-08-30
8
23
 
9
24
  - 修复 DSH `v0.1.2-alpha.1` 中工作区行 **+** 操作:在操作执行时再获取 `uiWorkspace` 服务,避免受插件加载顺序影响,也不再落入已移除的旧版 `startSession` API。
package/lib/client.js CHANGED
@@ -74,6 +74,7 @@ window.__ModuleLoader__.load({
74
74
  parse(value) {
75
75
  if (typeof value !== "object" || value === null || Array.isArray(value) || !Array.isArray(value.items)) throw new TypeError("result.items must be an array");
76
76
  if (value.items.some((item) => typeof item !== "object" || item === null || typeof item.sessionId !== "string" || typeof item.createdAt !== "number" || !Number.isFinite(item.createdAt))) throw new TypeError("items must contain sessionId/createdAt objects");
77
+ if (value.repairedSessionIds !== void 0 && (!Array.isArray(value.repairedSessionIds) || value.repairedSessionIds.some((id) => typeof id !== "string"))) throw new TypeError("repairedSessionIds must be a string array");
77
78
  return value;
78
79
  }
79
80
  };
@@ -3512,6 +3513,13 @@ window.__ModuleLoader__.load({
3512
3513
  if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3513
3514
  const result = await registry.archivedSessionMetadata();
3514
3515
  if (!result.ok) throw new Error(result.error.message);
3516
+ if (result.value.repairedSessionIds?.length > 0 && typeof ctx.sessions.refresh === "function") {
3517
+ try {
3518
+ await ctx.sessions.refresh();
3519
+ } catch (error) {
3520
+ console.warn("archive-manager: repaired archived projections but session list refresh failed:", error);
3521
+ }
3522
+ }
3515
3523
  return result.value;
3516
3524
  };
3517
3525
  const browserInjected = () => ({
package/lib/projcache.js CHANGED
@@ -1,20 +1,161 @@
1
- import { SessionProjectionCache } from "@deepseek-ai/dsh-session-projection-cache";
1
+ import { createHash } from "node:crypto";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import {
4
+ SessionProjectionCache,
5
+ checkpointIdentity,
6
+ checkpointRecord,
7
+ projectionCacheDomainSpec
8
+ } from "@deepseek-ai/dsh-session-projection-cache";
9
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
2
10
  import { trackTombstone } from "./tombstone.js";
11
+
12
+ /**
13
+ * Archive Manager owns a separate cache domain so DSH alpha.2 never asks the
14
+ * JSON backend to turn a logical IM id such as `im:qq:dm:...` into a Windows
15
+ * filename. The public Session id remains unchanged; only the storage key is
16
+ * a fixed-length SHA-256 token and the original id is carried in the value.
17
+ *
18
+ * On rc.2 the runtime has no per-record layout, so the same domain remains a
19
+ * single JSON file. After that user upgrades to alpha.2, every already-safe
20
+ * hashed key can be migrated by the official backend without hitting the
21
+ * legacy colon bug.
22
+ */
23
+ const storedCheckpointRecord = checkpointRecord.extend({
24
+ sessionId: checkpointIdentity.shape.cwd.unwrap()
25
+ });
26
+ const safeDomainInput = {
27
+ name: "session_projcache_archive_manager",
28
+ version: 1,
29
+ tables: { sessions: domainTable(storedCheckpointRecord) },
30
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
31
+ };
32
+ const safeProjectionCacheDomainSpec = defineDomain(safeDomainInput);
33
+
34
+ /** The last official whole-file cache format used by DSH rc.2. */
35
+ const legacyProjectionCacheDomainSpec = defineDomain({
36
+ name: "session_projcache",
37
+ version: 3,
38
+ tables: { sessions: domainTable(checkpointRecord) }
39
+ });
40
+
41
+ function projectionCacheStorageKey(sessionId) {
42
+ return `session_${createHash("sha256").update(sessionId, "utf8").digest("base64url")}`;
43
+ }
44
+
45
+ function unwrapStoredCheckpoint(stored) {
46
+ return { identity: stored.identity, rows: stored.rows };
47
+ }
48
+
49
+ /** Map the Session-id table contract onto path-safe physical keys. */
50
+ class SafeSessionTable {
51
+ constructor(table) {
52
+ this.table = table;
53
+ }
54
+ get size() {
55
+ return [...this.keys()].length;
56
+ }
57
+ get(sessionId) {
58
+ const stored = this.table.get(projectionCacheStorageKey(sessionId));
59
+ if (stored === void 0 || stored.sessionId !== sessionId) return void 0;
60
+ return unwrapStoredCheckpoint(stored);
61
+ }
62
+ has(sessionId) {
63
+ return this.get(sessionId) !== void 0;
64
+ }
65
+ *entries() {
66
+ for (const [key, stored] of this.table.entries()) {
67
+ if (projectionCacheStorageKey(stored.sessionId) !== key) continue;
68
+ yield [stored.sessionId, unwrapStoredCheckpoint(stored)];
69
+ }
70
+ }
71
+ *keys() {
72
+ for (const [sessionId] of this.entries()) yield sessionId;
73
+ }
74
+ *values() {
75
+ for (const [, value] of this.entries()) yield value;
76
+ }
77
+ async put(sessionId, record) {
78
+ const key = projectionCacheStorageKey(sessionId);
79
+ const existing = this.table.get(key);
80
+ if (existing !== void 0 && existing.sessionId !== sessionId) {
81
+ throw new Error(`projection-cache storage-key collision for "${sessionId}"`);
82
+ }
83
+ await this.table.put(key, { sessionId, identity: record.identity, rows: record.rows });
84
+ }
85
+ async delete(sessionId) {
86
+ const key = projectionCacheStorageKey(sessionId);
87
+ const existing = this.table.get(key);
88
+ if (existing === void 0 || existing.sessionId !== sessionId) return false;
89
+ return this.table.delete(key);
90
+ }
91
+ }
92
+
93
+ async function readSourceRecords(ctx, spec, label) {
94
+ let domain;
95
+ try {
96
+ domain = await ctx.storageDomain.open(spec);
97
+ return { opened: true, records: [...domain.table("sessions").entries()] };
98
+ } catch (error) {
99
+ ctx.logger.warn(`archive-manager projcache: ${label} cache import skipped: ${String(error)}`);
100
+ return { opened: false, records: [] };
101
+ } finally {
102
+ try {
103
+ await domain?.close();
104
+ } catch (error) {
105
+ ctx.logger.warn(`archive-manager projcache: ${label} cache close failed: ${String(error)}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ async function importMissingRecords(ctx, target, records, label) {
111
+ let imported = 0;
112
+ for (const [sessionId, record] of records) {
113
+ if (target.has(sessionId)) continue;
114
+ try {
115
+ await target.put(sessionId, record);
116
+ imported += 1;
117
+ } catch (error) {
118
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${sessionId}" import failed: ${String(error)}`);
119
+ }
120
+ }
121
+ return imported;
122
+ }
123
+
124
+ /**
125
+ * Seed the compatibility domain without opening alpha.2's unsafe per-record
126
+ * source while the v3 whole-file cache contains rows. An empty v3 source may
127
+ * fall through to the current source; source-open failures stay fail-soft.
128
+ * Imports are add-only: a newer compatibility-domain checkpoint always wins,
129
+ * and a partially completed import resumes on the next startup.
130
+ */
131
+ async function importPreviousProjectionCache(ctx, target) {
132
+ const legacy = await readSourceRecords(ctx, legacyProjectionCacheDomainSpec, "legacy v3");
133
+ if (!legacy.opened) return 0;
134
+ if (legacy.records.length > 0) return importMissingRecords(ctx, target, legacy.records, "legacy v3");
135
+ const sameSpec = projectionCacheDomainSpec.version === legacyProjectionCacheDomainSpec.version
136
+ && projectionCacheDomainSpec.layout === legacyProjectionCacheDomainSpec.layout;
137
+ if (sameSpec) return 0;
138
+ const current = await readSourceRecords(ctx, projectionCacheDomainSpec, "current");
139
+ if (!current.opened) return 0;
140
+ return importMissingRecords(ctx, target, current.records, "current");
141
+ }
3
142
  //#region lib/types/index.js
4
143
  /**
5
144
  * dsh-archive-manager projcache 半边。
6
145
  *
7
- * `ArchiveProjectionCache` 继承上游 `SessionProjectionCache`
8
- * (服务名 `sessionProjectionCache`,同域、同 fail-soft 写路径),
9
- * 为归档管理的 `deleteSession` 增加三处守护:
146
+ * `ArchiveProjectionCache` 继承上游 `SessionProjectionCache`,保留服务名
147
+ * `sessionProjectionCache` 与 fail-soft 写路径,但使用独立、路径安全的
148
+ * `session_projcache_archive_manager` 兼容域,为归档管理的
149
+ * `deleteSession` 增加三处守护:
10
150
  *
11
- * - `delete(id)` - 永久移除一个会话的缓存投影行
12
- * (`session_projcache` 域上的 `table.delete`),写入前先登记墓碑。
151
+ * - `delete(id)` - 永久移除兼容域中该会话经 SHA-256 映射的缓存投影行,
152
+ * 写入前先登记墓碑。
13
153
  * - `whenIdle()` - 全部在途公开写入落定后 resolve。
14
154
  * - 墓碑挡住已删除会话的写入:迟到的写入要么被直接拦截,要么在
15
155
  * 落定后补删自己的残留行,不会复活已删除的缓存条目。守护覆盖
16
- * `write()`(dispose 写后路径)与 `putSoft()`(`coldSnapshot` 的
17
- * 冷读写回路径),两条落盘路径都纳入 `whenIdle` 跟踪。
156
+ * `write()`(dispose 写后路径)与 `put()`(rc.2 的 `putSoft` 最终调用
157
+ * 它,alpha.2 的 `coldSnapshot` 则直接调用它),两代落盘路径都纳入
158
+ * `whenIdle` 跟踪。
18
159
  *
19
160
  * 默认导出是 Service 子类(与上游 `@deepseek-ai/dsh-session-projection-cache`
20
161
  * 包同形),profile 补丁可直接替换 `session-projection-cache` 服务行,
@@ -31,6 +172,18 @@ var ArchiveProjectionCache = class extends SessionProjectionCache {
31
172
  constructor(ctx, config) {
32
173
  super(ctx, config);
33
174
  }
175
+ /**
176
+ * Replace the official cache domain with the path-safe compatibility
177
+ * domain, then preserve the upstream listener/write implementation.
178
+ */
179
+ async [Service.init]() {
180
+ const domain = await this.ctx.storageDomain.open(safeProjectionCacheDomainSpec);
181
+ this.ctx.effect(() => () => domain.close(), "sessionProjectionCache.domainClose");
182
+ const table = new SafeSessionTable(domain.table("sessions"));
183
+ this.table = table;
184
+ await importPreviousProjectionCache(this.ctx, table);
185
+ this.installWritePath();
186
+ }
34
187
  /** 跟踪公开写入路径,避免依赖上游私有 `flushSoft` 的实现细节。 */
35
188
  write(session) {
36
189
  const task = this.writeCore(session);
@@ -47,22 +200,29 @@ var ArchiveProjectionCache = class extends SessionProjectionCache {
47
200
  if (this.deletedSessionIds.has(session.id)) await this.requireTable().delete(session.id);
48
201
  }
49
202
  /**
50
- * 冷读写回路径(上游 `coldSnapshot` 经 `putSoft` 落盘)与 `write` 同守:
51
- * 已删除直接放弃;删除落在写回进行中则在其落定后补删残留行。
52
- * 同样纳入 `whenIdle` 跟踪。
203
+ * 守住所有底层写入。rc.2 的冷读经 `putSoft -> put`,alpha.2 则
204
+ * 直接经 `put`;把墓碑与 whenIdle 跟踪放在这里可同时兼容两代实现。
53
205
  */
54
- async putSoft(id, identity, rows, what) {
55
- if (this.deletedSessionIds.has(id)) return;
56
- const task = this.putSoftCore(id, identity, rows, what);
206
+ put(id, identity, rows) {
207
+ // alpha.2 coldSnapshot unconditionally chains `.catch()` onto put().
208
+ // A blocked write must therefore preserve the upstream Promise contract.
209
+ if (this.deletedSessionIds.has(id)) return Promise.resolve();
210
+ const task = this.putCore(id, identity, rows);
57
211
  this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
58
212
  return task;
59
213
  }
60
- async putSoftCore(id, identity, rows, what) {
61
- await super.putSoft(id, identity, rows, what);
214
+ async putCore(id, identity, rows) {
215
+ await super.put(id, identity, rows);
216
+ if (this.deletedSessionIds.has(id)) await this.requireTable().delete(id);
217
+ }
218
+ /** rc.2 公开的 fail-soft 辅助在 alpha.2 已移除;保留同形兼容入口。 */
219
+ async putSoft(id, identity, rows, what) {
220
+ const upstream = SessionProjectionCache.prototype.putSoft;
221
+ if (typeof upstream === "function") return upstream.call(this, id, identity, rows, what);
62
222
  try {
63
- if (this.deletedSessionIds.has(id)) await this.requireTable().delete(id);
223
+ await this.put(id, identity, rows);
64
224
  } catch (error) {
65
- this.ctx.logger.warn(`archive-manager projcache: cold-read write-back cleanup for "${id}" failed: ${String(error)}`);
225
+ this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`);
66
226
  }
67
227
  }
68
228
  /**
@@ -89,4 +249,12 @@ var ArchiveProjectionCache = class extends SessionProjectionCache {
89
249
  }
90
250
  };
91
251
  //#endregion
92
- export { ArchiveProjectionCache, ArchiveProjectionCache as default };
252
+ export {
253
+ ArchiveProjectionCache,
254
+ ArchiveProjectionCache as default,
255
+ SafeSessionTable,
256
+ importPreviousProjectionCache,
257
+ legacyProjectionCacheDomainSpec,
258
+ projectionCacheStorageKey,
259
+ safeProjectionCacheDomainSpec
260
+ };
package/lib/workspace.js CHANGED
@@ -109,6 +109,7 @@ const archivedSessionMetadataSchema = {
109
109
  parse(value) {
110
110
  if (typeof value !== "object" || value === null || Array.isArray(value) || !Array.isArray(value.items)) throw new TypeError("result.items must be an array");
111
111
  if (value.items.some((item) => typeof item !== "object" || item === null || typeof item.sessionId !== "string" || typeof item.createdAt !== "number" || !Number.isFinite(item.createdAt))) throw new TypeError("items must contain sessionId/createdAt objects");
112
+ if (value.repairedSessionIds !== void 0 && (!Array.isArray(value.repairedSessionIds) || value.repairedSessionIds.some((id) => typeof id !== "string"))) throw new TypeError("repairedSessionIds must be a string array");
112
113
  return value;
113
114
  }
114
115
  };
@@ -250,18 +251,47 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
250
251
  markRemoteMethod(this, "archivedSessionMetadata");
251
252
  registerHostRemote(this.ctx);
252
253
  }
253
- /** 归档设置页创建时间排序所需的最小元数据;列表摘要本身只暴露 updatedAt。 */
254
+ /**
255
+ * 归档设置页创建时间排序所需的最小元数据。老用户可能仍有会话原文和
256
+ * 归档标记、却没有投影缓存;这里按需从完整日志重建一次,再通知客户端
257
+ * 刷新会话列表。已有缓存不读原文,新老 DSH 的缓存布局都走同一 put。
258
+ */
254
259
  async archivedSessionMetadata() {
255
260
  const items = [];
261
+ const repairedSessionIds = [];
256
262
  for (const sessionId of [...new Set(this.requireState().archivedSessionIds)]) {
257
263
  try {
258
264
  const header = await this.readSessionHeader(sessionId);
265
+ if (await this.repairArchivedProjection(header)) repairedSessionIds.push(sessionId);
259
266
  if (typeof header.createdAt === "number" && Number.isFinite(header.createdAt)) items.push({ sessionId, createdAt: header.createdAt });
260
267
  } catch (error) {
261
268
  this.ctx.logger.warn(`archive-manager: could not read creation time for archived session "${sessionId}": ${String(error)}`);
262
269
  }
263
270
  }
264
- return { items };
271
+ return { items, ...repairedSessionIds.length === 0 ? {} : { repairedSessionIds } };
272
+ }
273
+ /** 从会话原文补齐缺失的派生缓存;任何失败都只降级为原有无摘要列表。 */
274
+ async repairArchivedProjection(header) {
275
+ const cache = this.ctx.get("sessionProjectionCache");
276
+ const persistence = this.ctx.get("sessionPersistence");
277
+ const projections = this.ctx.get("sessionProjections");
278
+ if (cache === void 0 || typeof cache.cachedSnapshot !== "function" || typeof cache.put !== "function") return false;
279
+ if (persistence === void 0 || typeof persistence.readFrom !== "function" || projections === void 0 || typeof projections.restore !== "function") return false;
280
+ try {
281
+ if (cache.cachedSnapshot(header) !== void 0) return false;
282
+ const stored = await persistence.readFrom(header.id, 0);
283
+ const meta = stored.meta ?? header;
284
+ const restored = projections.restore({}, stored.events, 0, meta);
285
+ if (restored === void 0 || typeof restored !== "object" || restored.checkpoint === void 0) return false;
286
+ await cache.put(header.id, {
287
+ createdAt: meta.createdAt,
288
+ ...meta.cwd === void 0 ? {} : { cwd: meta.cwd }
289
+ }, restored.checkpoint);
290
+ return true;
291
+ } catch (error) {
292
+ this.ctx.logger.warn(`archive-manager: projection repair for archived session "${header.id}" failed: ${String(error)}`);
293
+ return false;
294
+ }
265
295
  }
266
296
  /**
267
297
  * 把一个会话移出注册表全局归档集合,恢复其正常可见性(其记账位从未
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-archive-manager",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
@@ -42,6 +42,7 @@
42
42
  "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5 <0.2.0",
43
43
  "@deepseek-ai/dsh-session": ">=0.1.0-rc.5 <0.2.0",
44
44
  "@deepseek-ai/dsh-session-projection-cache": ">=0.1.0-rc.5 <0.2.0",
45
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.5 <0.2.0",
45
46
  "@deepseek-ai/dsh-spill-local": ">=0.1.0-rc.5 <0.2.0",
46
47
  "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.5 <0.2.0",
47
48
  "@deepseek-ai/dsh-typert-registry": ">=0.1.0-rc.5 <0.2.0",
@@ -59,6 +60,7 @@
59
60
  "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
60
61
  "@deepseek-ai/dsh-client-web-react": "0.1.0-rc.6",
61
62
  "@deepseek-ai/dsh-session-projection-cache": "0.1.0-rc.6",
63
+ "@deepseek-ai/dsh-storage-domain": "0.1.0-rc.6",
62
64
  "@deepseek-ai/dsh-spill-local": "0.1.0-rc.6",
63
65
  "@deepseek-ai/dsh-typert-protocol": "0.1.0-rc.6",
64
66
  "@deepseek-ai/dsh-typert-registry": "0.1.0-rc.6",