dsh-session-cloud 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.
@@ -20,6 +20,11 @@ export interface SessionLocationLike {
20
20
  readonly kind: string;
21
21
  readonly path: string;
22
22
  }
23
+ /**
24
+ * 引擎/恢复消费的持久化端口。宿主可能是 v0(rc.1:listSnapshots/locate 齐备)
25
+ * 或 v1(0.1.3-alpha.1+:list() 返回快照、locate 私有化),由
26
+ * sync/persistence-port.ts 做运行时归一;本接口始终是 v0 形态。
27
+ */
23
28
  export interface SessionPersistenceLike {
24
29
  listSnapshots(signal?: AbortSignal): Promise<SessionSnapshotLike[]>;
25
30
  list(signal?: AbortSignal): Promise<SessionHeaderLike[]>;
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { runConnectionCheck } from './status.js';
5
5
  import { SyncEngine } from './sync/engine.js';
6
6
  import { ProtocolError, SyncClient } from './sync/http-client.js';
7
7
  import { bootstrapKdf } from './sync/kdf.js';
8
+ import { createPersistencePort } from './sync/persistence-port.js';
8
9
  import { countRemoteOnlySessions, decodeMappingDelete, decodeRestoreRequest, deleteMapping, encodeCatalog, encodeMappings, encodeRestoreResult, encodeStartupNotice, fetchCatalog, markRestoredSynced, restoreSessions, } from './sync/restore.js';
9
10
  import { DomainStateStore, JsonFileStateStore, cloudSyncDomainSpec } from './sync/state.js';
10
11
  export const name = 'cloud-sync';
@@ -146,6 +147,10 @@ export async function apply(ctx, config) {
146
147
  function selfDevice() {
147
148
  return current().deviceName || os.hostname();
148
149
  }
150
+ /** sessionPersistence 双版本端口(v0 透传 / v1 list+布局推导,见 persistence-port.ts)。 */
151
+ function persistencePort() {
152
+ return createPersistencePort(ctx.sessionPersistence, path.join(dshHome(ctx), 'sessions'));
153
+ }
149
154
  /** 本机工作区 canonical path 列表(workspaceRegistry 可选注入,缺失即无尾段建议)。 */
150
155
  function workspacePaths() {
151
156
  return ctx.get('workspaceRegistry')?.list().map((workspace) => workspace.path) ?? [];
@@ -160,7 +165,7 @@ export async function apply(ctx, config) {
160
165
  return {
161
166
  client,
162
167
  key,
163
- persistence: ctx.sessionPersistence,
168
+ persistence: persistencePort(),
164
169
  state: stateStore,
165
170
  workspacePaths: workspacePaths(),
166
171
  // 恢复落位后的 workspace 挂载(web 侧边栏可见性)。复刻 dsh 自己创建会话的
@@ -275,7 +280,7 @@ export async function apply(ctx, config) {
275
280
  salt,
276
281
  device,
277
282
  state: stateStore,
278
- persistence: ctx.sessionPersistence,
283
+ persistence: persistencePort(),
279
284
  getConfig: () => current(),
280
285
  onEvent: (event) => {
281
286
  if (event.kind === 'error' || event.kind === 'conflict') {
@@ -248,7 +248,20 @@ export class SyncEngine {
248
248
  await store.putSession(id, state);
249
249
  return;
250
250
  }
251
- const stat = await fsp.stat(location.path);
251
+ let stat;
252
+ try {
253
+ stat = await fsp.stat(location.path);
254
+ }
255
+ catch (error) {
256
+ if (error.code !== 'ENOENT')
257
+ throw error;
258
+ // v1 宿主 list() 含「已创建未物化」的会话(首个 append 前无文件),
259
+ // 也可能撞上并发删除。记 revision 静默跳过:物化或内容变更会改
260
+ // revision,下一轮 scan 自然重新调度。
261
+ state.localRevision = revision;
262
+ await store.putSession(id, state);
263
+ return;
264
+ }
252
265
  if (stat.size < state.uploadedBytes) {
253
266
  // 文件变短(防御:正常不可达)——回退全量重传
254
267
  Object.assign(state, freshSessionState());
@@ -0,0 +1,39 @@
1
+ import type { SessionHeaderLike, SessionLocationLike, SessionSnapshotLike } from '../dsh-types.js';
2
+ /**
3
+ * 宿主原始形态:v0 时 `listSnapshots` 存在且 `list()` 返回 header 数组;
4
+ * v1(0.1.3-alpha.1+)时 `listSnapshots` 缺失且 `list()` 返回快照数组。
5
+ * 运行时以 `listSnapshots` 探测分支,`list()` 元素类型按分支收窄。
6
+ */
7
+ interface RawPersistence {
8
+ listSnapshots?(signal?: AbortSignal): Promise<SessionSnapshotLike[]>;
9
+ list(signal?: AbortSignal): Promise<readonly unknown[]>;
10
+ locate?(meta: SessionHeaderLike): SessionLocationLike | undefined;
11
+ }
12
+ /**
13
+ * 复刻 dsh session-persistence-jsonl 的 projectKey(format.ts:228):
14
+ * 分隔符折叠为 `-`,不安全码位转 `~XXXX`(大写十六进制 4 位),
15
+ * 首部 `-` 剥离、截断到 251 码位、整体包 `--…--`。
16
+ */
17
+ export declare function projectKeyOf(cwd: string): string;
18
+ /**
19
+ * 复刻 dsh 的 encodeSegment(format.ts:202):安全字符直通,其余转
20
+ * `~XXXX`;`.`/`..` 整体转义。会话 id(session-<uuid>)实际恒为直通。
21
+ */
22
+ export declare function encodeSegmentOf(raw: string): string;
23
+ /**
24
+ * 推导 jsonl 后端的会话目录(不动文件系统):无 cwd 落 `_no-cwd`。
25
+ * 与 dsh projectDir/sessionDir(format.ts:257/270)同构。
26
+ */
27
+ export declare function sessionDirOf(root: string, cwd: string | undefined, id: string): string;
28
+ /**
29
+ * 把宿主 sessionPersistence 归一为引擎/恢复消费的端口。
30
+ * @param raw - ctx.sessionPersistence 原始服务(v0 或 v1 形态)。
31
+ * @param sessionsRoot - jsonl 后端 root(dsh bundle 恒配 dshHomePath('sessions'))。
32
+ * @returns 端口对象;v0 的 locate 缺失且非 v1 布局可推导时 listSnapshots 仍可用。
33
+ */
34
+ export declare function createPersistencePort(raw: RawPersistence, sessionsRoot: string): {
35
+ listSnapshots(signal?: AbortSignal): Promise<SessionSnapshotLike[]>;
36
+ list(signal?: AbortSignal): Promise<SessionHeaderLike[]>;
37
+ locate(meta: SessionHeaderLike): SessionLocationLike | undefined;
38
+ };
39
+ export {};
@@ -0,0 +1,114 @@
1
+ /**
2
+ * sessionPersistence 双版本适配层(docs/02 §6 集成面的版本兼容)。
3
+ *
4
+ * 背景:dsh 0.1.3-alpha.1 起 SessionPersistence 收敛了消费者 API——
5
+ * `listSnapshots()` 并入 `list()`(直接返回带 revision 的快照),`locate()`
6
+ * 被私有化(上游注释明确「日志访问走 session handle 的 read」)。插件按
7
+ * 发布版 rc.1 的 API 编写,这里在运行时探测宿主形态并归一到引擎/恢复
8
+ * 消费的 SessionPersistenceLike 端口:
9
+ * - v0(rc.1 及以前):listSnapshots/list/locate 原样透传;
10
+ * - v1(0.1.3-alpha.1 起):list() 即快照枚举;落位路径无法再问宿主,
11
+ * 按 jsonl 后端的磁盘布局推导(<root>/<projectKey>/<id>/session.jsonl[.zstd],
12
+ * 复刻 dsh format.ts 的 projectKey/encodeSegment 编码)。
13
+ */
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /**
17
+ * 复刻 dsh session-persistence-jsonl 的 projectKey(format.ts:228):
18
+ * 分隔符折叠为 `-`,不安全码位转 `~XXXX`(大写十六进制 4 位),
19
+ * 首部 `-` 剥离、截断到 251 码位、整体包 `--…--`。
20
+ */
21
+ export function projectKeyOf(cwd) {
22
+ if (cwd.length === 0)
23
+ throw new Error('cannot encode an empty project path');
24
+ let readable = '';
25
+ let separatorRun = false;
26
+ for (let i = 0; i < cwd.length; i++) {
27
+ const code = cwd.charCodeAt(i);
28
+ const ch = String.fromCharCode(code);
29
+ if (ch === '/' || ch === '\\' || ch === ':') {
30
+ if (!separatorRun)
31
+ readable += '-';
32
+ separatorRun = true;
33
+ }
34
+ else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
35
+ readable += ch;
36
+ separatorRun = false;
37
+ }
38
+ else {
39
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
40
+ separatorRun = false;
41
+ }
42
+ }
43
+ const slug = readable.replace(/^-+/, '') || 'root';
44
+ return `--${slug.slice(0, 251)}--`;
45
+ }
46
+ /**
47
+ * 复刻 dsh 的 encodeSegment(format.ts:202):安全字符直通,其余转
48
+ * `~XXXX`;`.`/`..` 整体转义。会话 id(session-<uuid>)实际恒为直通。
49
+ */
50
+ export function encodeSegmentOf(raw) {
51
+ if (raw.length === 0)
52
+ throw new Error('cannot encode an empty path segment');
53
+ if (raw === '.')
54
+ return '~002E';
55
+ if (raw === '..')
56
+ return '~002E~002E';
57
+ let out = '';
58
+ for (let i = 0; i < raw.length; i++) {
59
+ const code = raw.charCodeAt(i);
60
+ const ch = String.fromCharCode(code);
61
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
62
+ out += ch;
63
+ }
64
+ else {
65
+ out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * 推导 jsonl 后端的会话目录(不动文件系统):无 cwd 落 `_no-cwd`。
72
+ * 与 dsh projectDir/sessionDir(format.ts:257/270)同构。
73
+ */
74
+ export function sessionDirOf(root, cwd, id) {
75
+ const project = cwd === undefined ? join(root, '_no-cwd') : join(root, projectKeyOf(cwd));
76
+ return join(project, encodeSegmentOf(id));
77
+ }
78
+ /** 推导会话 artifact 位置:zstd 存在优先;仅明文存在则指明文(引擎会标记 skipped-plain)。 */
79
+ function locateDerived(root, meta) {
80
+ 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') };
85
+ }
86
+ /**
87
+ * 把宿主 sessionPersistence 归一为引擎/恢复消费的端口。
88
+ * @param raw - ctx.sessionPersistence 原始服务(v0 或 v1 形态)。
89
+ * @param sessionsRoot - jsonl 后端 root(dsh bundle 恒配 dshHomePath('sessions'))。
90
+ * @returns 端口对象;v0 的 locate 缺失且非 v1 布局可推导时 listSnapshots 仍可用。
91
+ */
92
+ export function createPersistencePort(raw, sessionsRoot) {
93
+ const v0 = typeof raw.listSnapshots === 'function';
94
+ return {
95
+ async listSnapshots(signal) {
96
+ if (v0)
97
+ return await raw.listSnapshots(signal);
98
+ // v1:list() 即快照枚举(header + revision)
99
+ return (await raw.list(signal));
100
+ },
101
+ async list(signal) {
102
+ const entries = await raw.list(signal);
103
+ // v0 返回 header 数组,v1 返回快照数组——按形态归一为 header
104
+ return v0
105
+ ? entries
106
+ : entries.map((snapshot) => snapshot.header);
107
+ },
108
+ locate(meta) {
109
+ if (v0)
110
+ return raw.locate?.(meta);
111
+ return locateDerived(sessionsRoot, meta);
112
+ },
113
+ };
114
+ }
@@ -3,7 +3,9 @@
3
3
  * 经 locate() 落位、映射学习。与 cordis 解耦,纯函数 + 显式 deps 便于单测。
4
4
  *
5
5
  * 硬约束(§4.3):
6
- * - 落位路径一律由 persistence.locate(header) 计算,绝不自行复刻 projectKey 编码;
6
+ * - 落位路径一律由 persistence 端口的 locate(header) 计算,本模块绝不自行
7
+ * 复刻 projectKey 编码——v0 宿主透传服务方法,v1 宿主(locate 已私有化)
8
+ * 由 persistence-port.ts 的布局推导兜底;
7
9
  * - 对文件字节的唯一改写是首帧 JSON 的 cwd 字段,事件正文一字节不动;
8
10
  * - 落位后唯一的 dsh 调用是 workspaceRegistry 挂载(公开服务方法)——
9
11
  * sessionQuery 的 list() 走 persistence.list() 扫盘,文件就位即被发现;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-cloud",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "E2E-encrypted cloud mirror plugin for deepseek-harness sessions",
5
5
  "type": "module",
6
6
  "publishConfig": {