dsh-session-cloud 0.1.3 → 0.1.5

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 ADDED
@@ -0,0 +1,63 @@
1
+ # Changelog
2
+
3
+ ## 0.1.5
4
+
5
+ ### 修复
6
+
7
+ - **状态自愈:曾被「ENOENT 静默跳过」污染的会话恢复上传**
8
+ 根因:旧版把 locate 失败的会话按「未物化」静默记录 `localRevision` 却
9
+ 从未上传过一个字节,`needsSync` 因此永远判定「已同步」——即使修复了
10
+ 文件名推导,这些会话也不会重新进同步队列(表现为「成功 0」且无报错)。
11
+ 修复:
12
+ - `needsSync` 自愈判定:`uploadedBytes === 0` 且无恢复抑制标记的条目
13
+ 一律视为待同步,被污染的会话自动重新上传,无需手工清理 state;
14
+ - `markRestoredSynced` 写入 `restoredAt` 标记区分「恢复落位待续写」
15
+ 的合法抑制条目,自愈判定不误伤(恢复后未续写不回传)。
16
+
17
+ ## 0.1.4
18
+
19
+ 适配 dsh 0.1.3-alpha.1(源码版)的两处兼容修复:
20
+
21
+ ### 修复
22
+
23
+ - **恢复会话弹窗所有会话被误标"版本不兼容"、checkbox 无法勾选**
24
+ 根因:`LOCAL_FORMAT_VERSION` 写死为 `0`,而 dsh 的 `SESSION_FORMAT_VERSION`
25
+ 实际为 `2`(rc.1 与 0.1.3-alpha.1 均如此),导致云端目录里每一行都满足
26
+ `formatVersion > 0` 而被禁用。
27
+ 修复:常量更正为 `2`;同时恢复目录的兼容基准改为动态取本机会话 header
28
+ 的 `version`(即本机运行时的 `SESSION_FORMAT_VERSION`),上游未来升级
29
+ 格式版本后随本地新会话自适应,仅本地无任何会话时才落到常量兜底。
30
+
31
+ - **"立即全部同步"成功 0:v1 宿主日志文件名漏了格式代(`vN`)段**
32
+ 根因:dsh 0.1.3-alpha.1 的 jsonl 后端日志文件按格式代命名
33
+ (`session.v2.jsonl.zstd`),而 persistence-port 仍按 rc.1 旧形态推导
34
+ `session.jsonl.zstd`,`stat` 必然 ENOENT;该 ENOENT 又被"未物化会话"
35
+ 的静默跳过逻辑吞掉——会话从未上传,引擎却显示全部已同步。
36
+ 修复:
37
+ - `locateDerived` 按会话 `header.version` 生成当前代文件名
38
+ (`v0 → session.jsonl`、`vN>0 → session.vN.jsonl`,与 dsh
39
+ `session-format/filename.ts` 一致),并按"当前代 zstd → v0 旧形态
40
+ zstd → 当前代明文 → v0 明文"优先级探测;
41
+ - 引擎 ENOENT 分支不再无条件静默:会话目录中存在 canonical 日志
42
+ (`session[.vN].jsonl[.zstd]`)而推导路径缺失时按布局失配报错
43
+ (计入"失败"并展示错误),只有目录中无日志文件(真未物化:v1 后端
44
+ lock 先建目录、首个 append 才物化日志)才静默记录 revision 跳过。
45
+
46
+ ## 0.1.3
47
+
48
+ 适配 dsh 0.1.3-alpha.1 的 `SessionPersistence` API 收敛:
49
+
50
+ - 新增 `persistence-port` 双版本兼容层:运行时探测宿主形态,v0(rc.1)
51
+ 透传 `listSnapshots/list/locate`,v1 以 `list()` 作快照枚举并按 jsonl
52
+ 磁盘布局推导落位路径(复刻 `projectKey`/`encodeSegment` 编码);
53
+ - 引擎对 v1 "已创建未物化" 会话(首个 append 前无日志文件)的 ENOENT
54
+ 静默跳过,不计失败、不进退避。
55
+
56
+ ## 0.1.2
57
+
58
+ - npm 发布链路与 `dsh plugin add` 安装修复。
59
+
60
+ ## 0.1.1 / 0.1.0
61
+
62
+ - 初始版本:端到端加密上传、增量同步、云端恢复对话框、路径映射、
63
+ 启动检查等(M1–M4)。
@@ -6,6 +6,7 @@
6
6
  * → meta 覆写 → 推进本地 state。与 cordis 解耦,纯类便于单测。
7
7
  */
8
8
  import fsp from 'node:fs/promises';
9
+ import path from 'node:path';
9
10
  import { LogEncryptor, encryptMeta, verifyTailSegment } from '../crypto/envelope.js';
10
11
  import { detectEncoding, emptyFold, foldFrame, scanZstdFrames } from './frames.js';
11
12
  import { ProtocolError } from './http-client.js';
@@ -96,6 +97,11 @@ export class SyncEngine {
96
97
  this.backoff.delete(snapshot.header.id);
97
98
  return true;
98
99
  }
100
+ // 自愈:从未上传过任何字节、又不是恢复抑制(restoredAt)的条目,其
101
+ // localRevision 记录不可信——历史上被「ENOENT 静默跳过」污染过的会话
102
+ // 曾因此永远卡在「已同步」假象里。一律视为待同步,宁可重试不可漏传。
103
+ if (state !== undefined && state.uploadedBytes === 0 && !state.restoredAt)
104
+ return true;
99
105
  if (state?.localRevision === snapshot.revision && wait === undefined)
100
106
  return false;
101
107
  return true;
@@ -255,9 +261,20 @@ export class SyncEngine {
255
261
  catch (error) {
256
262
  if (error.code !== 'ENOENT')
257
263
  throw error;
258
- // v1 宿主 list() 含「已创建未物化」的会话(首个 append 前无文件),
259
- // 也可能撞上并发删除。记 revision 静默跳过:物化或内容变更会改
260
- // revision,下一轮 scan 自然重新调度。
264
+ // 区分两种 ENOENT:目录里有 canonical 日志(session[.vN].jsonl[.zstd])而
265
+ // locate 指的路径不存在 = 布局推导失配(上游改名/改布局),静默跳过会把
266
+ // 失配伪装成「已同步」(曾表现为「成功 0」无任何报错),必须抛错暴露;
267
+ // 目录不存在或只有 session.lock(v1 后端 lock 先建目录、首个 append 才
268
+ // 物化日志)= 真未物化,记 revision 静默跳过,物化后 revision 变化自然重调度。
269
+ const dirEntries = await fsp.readdir(path.dirname(location.path)).catch((dirError) => {
270
+ if (dirError.code === 'ENOENT')
271
+ return [];
272
+ throw dirError;
273
+ });
274
+ const hasCanonicalLog = dirEntries.some((name) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(name));
275
+ if (hasCanonicalLog) {
276
+ throw new Error(`session log artifact not found at derived path (layout mismatch?): ${location.path}`);
277
+ }
261
278
  state.localRevision = revision;
262
279
  await store.putSession(id, state);
263
280
  return;
@@ -8,8 +8,9 @@
8
8
  * 消费的 SessionPersistenceLike 端口:
9
9
  * - v0(rc.1 及以前):listSnapshots/list/locate 原样透传;
10
10
  * - v1(0.1.3-alpha.1 起):list() 即快照枚举;落位路径无法再问宿主,
11
- * 按 jsonl 后端的磁盘布局推导(<root>/<projectKey>/<id>/session.jsonl[.zstd],
12
- * 复刻 dsh format.ts 的 projectKey/encodeSegment 编码)。
11
+ * 按 jsonl 后端的磁盘布局推导(<root>/<projectKey>/<id>/session[.vN].jsonl[.zstd],
12
+ * vN 为会话格式代(v0 无标签),复刻 dsh format.ts 的 projectKey/encodeSegment
13
+ * 与 session-format filename.ts 的代命名)。
13
14
  */
14
15
  import { existsSync } from 'node:fs';
15
16
  import { join } from 'node:path';
@@ -75,13 +76,31 @@ export function sessionDirOf(root, cwd, id) {
75
76
  const project = cwd === undefined ? join(root, '_no-cwd') : join(root, projectKeyOf(cwd));
76
77
  return join(project, encodeSegmentOf(id));
77
78
  }
78
- /** 推导会话 artifact 位置:zstd 存在优先;仅明文存在则指明文(引擎会标记 skipped-plain)。 */
79
+ /**
80
+ * 推导会话 artifact 位置:候选按优先级探测——当前代(header.version)zstd →
81
+ * v0 旧形态 zstd(rc.1 数据 / 代升级残留)→ 当前代明文 → v0 明文;都不存在则
82
+ * 推导当前代 zstd 路径(调用方按 ENOENT 语义处理未物化会话)。
83
+ */
79
84
  function locateDerived(root, meta) {
80
85
  const dir = sessionDirOf(root, meta.cwd, meta.id);
81
- const zstd = join(dir, 'session.jsonl.zstd');
82
- if (existsSync(zstd) || !existsSync(join(dir, 'session.jsonl')))
83
- return { kind: 'jsonl', path: zstd };
84
- return { kind: 'jsonl', path: join(dir, 'session.jsonl') };
86
+ const candidates = [
87
+ join(dir, `${logBasenameOf(meta.version)}.zstd`),
88
+ join(dir, 'session.jsonl.zstd'),
89
+ join(dir, logBasenameOf(meta.version)),
90
+ join(dir, 'session.jsonl'),
91
+ ];
92
+ for (const candidate of candidates) {
93
+ if (existsSync(candidate))
94
+ return { kind: 'jsonl', path: candidate };
95
+ }
96
+ return { kind: 'jsonl', path: candidates[0] };
97
+ }
98
+ /**
99
+ * 当前代日志 basename(复刻 dsh session-format filename.ts:14):
100
+ * version 0 保持 `session.jsonl`,之后每代带小写 `.vN` 段。
101
+ */
102
+ function logBasenameOf(version) {
103
+ return version === 0 ? 'session.jsonl' : `session.v${version}.jsonl`;
85
104
  }
86
105
  /**
87
106
  * 把宿主 sessionPersistence 归一为引擎/恢复消费的端口。
@@ -1,8 +1,12 @@
1
1
  import type { SessionHeaderLike, SessionPersistenceLike } from '../dsh-types.js';
2
2
  import type { SyncClient } from './http-client.js';
3
3
  import type { StateStore } from './state.js';
4
- /** 本机支持的会话格式版本(dsh SESSION_FORMAT_VERSION,pre-release 恒为 0)。 */
5
- export declare const LOCAL_FORMAT_VERSION = 0;
4
+ /**
5
+ * 本机支持的会话格式版本兜底值(dsh core/session SESSION_FORMAT_VERSION,
6
+ * rc.1 与 0.1.3-alpha.1 均为 2)。运行时基准优先取本机会话 header 的 version
7
+ * (见 fetchCatalog),本地无任何会话时才落到此常量。
8
+ */
9
+ export declare const LOCAL_FORMAT_VERSION = 2;
6
10
  /** 恢复目录的一行(Host 计算好解析结果,Client 只负责呈现与改选)。 */
7
11
  export interface CatalogEntry {
8
12
  sessionId: string;
@@ -16,8 +16,12 @@ import path from 'node:path';
16
16
  import { decryptLog, decryptMeta } from '../crypto/envelope.js';
17
17
  import { logKey } from './engine.js';
18
18
  import { scanZstdFrames, zstdCompressAsync, zstdDecompressAsync, ZSTD_CHECKSUM_OPTIONS } from './frames.js';
19
- /** 本机支持的会话格式版本(dsh SESSION_FORMAT_VERSION,pre-release 恒为 0)。 */
20
- export const LOCAL_FORMAT_VERSION = 0;
19
+ /**
20
+ * 本机支持的会话格式版本兜底值(dsh core/session 的 SESSION_FORMAT_VERSION,
21
+ * rc.1 与 0.1.3-alpha.1 均为 2)。运行时基准优先取本机会话 header 的 version
22
+ * (见 fetchCatalog),本地无任何会话时才落到此常量。
23
+ */
24
+ export const LOCAL_FORMAT_VERSION = 2;
21
25
  // ---- JSON 载荷的编解码(两端各自拼写,不跨半 import;Host 侧编码 + 防御性解码) ----
22
26
  export function encodeCatalog(catalog) {
23
27
  return JSON.stringify(catalog);
@@ -144,7 +148,11 @@ const META_SUFFIX = '.meta.enc';
144
148
  export async function fetchCatalog(deps) {
145
149
  const objects = await deps.client.list('sessions/');
146
150
  const metaKeys = objects.filter((object) => object.key.endsWith(META_SUFFIX));
147
- const localIds = new Set((await deps.persistence.list()).map((header) => header.id));
151
+ const localHeaders = await deps.persistence.list();
152
+ const localIds = new Set(localHeaders.map((header) => header.id));
153
+ // 兼容基准优先取本机会话 header 的 version(即本机运行时的 SESSION_FORMAT_VERSION,
154
+ // 上游 bump 后随本地新会话自适应);本地一个会话都没有才落到常量兜底
155
+ const localFormatVersion = localHeaders.reduce((max, header) => Math.max(max, header.version), LOCAL_FORMAT_VERSION);
148
156
  const mappings = deps.state.getGlobal().pathMappings;
149
157
  const entries = [];
150
158
  for (const object of metaKeys) {
@@ -173,7 +181,7 @@ export async function fetchCatalog(deps) {
173
181
  cwd: meta.cwd,
174
182
  formatVersion: meta.formatVersion,
175
183
  existsLocal,
176
- versionIncompatible: meta.formatVersion > LOCAL_FORMAT_VERSION,
184
+ versionIncompatible: meta.formatVersion > localFormatVersion,
177
185
  resolvedCwd: resolution.cwd,
178
186
  resolution: resolution.kind,
179
187
  });
@@ -284,6 +292,7 @@ export async function markRestoredSynced(deps, sessionIds) {
284
292
  lastSegmentIndex: -1,
285
293
  lastTag: '',
286
294
  localRevision: snapshot.revision,
295
+ restoredAt: Date.now(),
287
296
  eventCount: 0,
288
297
  updatedAt: 0,
289
298
  status: 'ok',
@@ -14,6 +14,8 @@ export interface SessionSyncState {
14
14
  lastTag: string;
15
15
  /** 上次处理到的 sessionPersistence revision(不透明令牌) */
16
16
  localRevision: string;
17
+ /** 恢复抑制标记(markRestoredSynced 写入):本地续写前不回传 */
18
+ restoredAt?: number;
17
19
  /** meta 折叠累积 */
18
20
  eventCount: number;
19
21
  title?: string;
package/lib/sync/state.js CHANGED
@@ -12,6 +12,7 @@ const sessionStateSchema = z.object({
12
12
  lastSegmentIndex: z.number().int().min(-1),
13
13
  lastTag: z.string(),
14
14
  localRevision: z.string(),
15
+ restoredAt: z.number().optional(),
15
16
  eventCount: z.number().int().nonnegative(),
16
17
  title: z.string().optional(),
17
18
  updatedAt: z.number(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-cloud",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "E2E-encrypted cloud mirror plugin for deepseek-harness sessions",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "lib",
11
- "cordis.patch.yml"
11
+ "cordis.patch.yml",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "exports": {
14
15
  ".": {