dsh-session-cloud 0.1.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * kdf 引导(docs/03 §5.1):解析本机派生参数,并支持跨设备发现。
3
+ *
4
+ * 解析顺序:
5
+ * 1. 本机名下 sidecar(keys/<device>/kdf.json)存在 → 直接使用;
6
+ * 2. 缺失 → 拉取 keys/ 下他机 sidecar 逐个试派生:任一云端 meta 对象能用
7
+ * 该密钥解开即验证通过,采纳该 sidecar(镜像写回本机名下,后续启动直连);
8
+ * 3. 他机 sidecar 存在但云端没有任何 meta(尚无会话,无从验证)→ 采纳首个
9
+ * 候选——口令不符也无害(没有对象需要解开),口令相符则免去镜像步骤;
10
+ * 4. 全部验证失败或云端没有 sidecar → 生成新 salt 上传本机名下。
11
+ *
12
+ * 与 cordis 解耦,deps 注入便于单测。
13
+ */
14
+ import { decodeKdfSidecar, decryptMeta, deriveKey, encodeKdfSidecar, generateSalt, KDF_DEFAULTS } from '../crypto/envelope.js';
15
+ import { ProtocolError } from './http-client.js';
16
+ /** 验证口令时最多尝试解密的 meta 对象数(多云端口令并存时避免整表扫描)。 */
17
+ const VERIFY_META_LIMIT = 5;
18
+ function ownKey(device) {
19
+ return `keys/${device}/kdf.json`;
20
+ }
21
+ function isNotFound(error) {
22
+ return error instanceof ProtocolError && error.status === 404;
23
+ }
24
+ /** 云端全部 meta 对象的 key(sessions/<device>/<id>.meta.enc)。 */
25
+ async function listMetaKeys(client) {
26
+ const objects = await client.list('sessions/');
27
+ return objects.filter((object) => object.key.endsWith('.meta.enc')).map((object) => object.key);
28
+ }
29
+ /** 用候选密钥试解任一 meta 对象:解开即口令匹配。 */
30
+ async function verifyKey(client, key, metaKeys) {
31
+ for (const metaKey of metaKeys.slice(0, VERIFY_META_LIMIT)) {
32
+ try {
33
+ const bytes = await client.download(metaKey);
34
+ const sessionId = metaKey.split('/').at(-1).slice(0, -'.meta.enc'.length);
35
+ decryptMeta(key, sessionId, bytes);
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ if (error instanceof ProtocolError && error.status !== 404)
40
+ throw error;
41
+ // 解密失败(口令不符)或对象恰好被删(404):换下一个 meta 继续
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ /** kdf 引导(见模块注释的解析顺序)。 */
47
+ export async function bootstrapKdf(deps) {
48
+ const { client, device, passphrase } = deps;
49
+ // 1. 本机 sidecar
50
+ const own = ownKey(device);
51
+ try {
52
+ const { salt, params } = decodeKdfSidecar(await client.download(own));
53
+ return { key: deriveKey(passphrase, salt, params), salt };
54
+ }
55
+ catch (error) {
56
+ if (!isNotFound(error))
57
+ throw error;
58
+ }
59
+ // 2/3. 他机 sidecar 逐个试派生
60
+ let candidates = [];
61
+ try {
62
+ const objects = await client.list('keys/');
63
+ const foreignKeys = objects.map((object) => object.key).filter((key) => key.endsWith('/kdf.json') && key !== own);
64
+ candidates = await Promise.all(foreignKeys.map(async (key) => ({ key, bytes: await client.download(key) })));
65
+ }
66
+ catch (error) {
67
+ if (!isNotFound(error))
68
+ throw error;
69
+ }
70
+ if (candidates.length > 0) {
71
+ let metaKeys = [];
72
+ try {
73
+ metaKeys = await listMetaKeys(client);
74
+ }
75
+ catch {
76
+ // 列表失败按无可验证对象处理(下方采纳语义不受影响)
77
+ }
78
+ for (const candidate of candidates) {
79
+ let salt;
80
+ let params;
81
+ try {
82
+ ;
83
+ ({ salt, params } = decodeKdfSidecar(candidate.bytes));
84
+ }
85
+ catch {
86
+ continue; // 损坏的 sidecar:跳过换下一个
87
+ }
88
+ const derived = deriveKey(passphrase, salt, params);
89
+ if (metaKeys.length === 0 || await verifyKey(client, derived, metaKeys)) {
90
+ // 采纳:镜像写回本机名下,后续启动直连本机 sidecar
91
+ await client.overwrite(own, candidate.bytes);
92
+ deps.onAdopt?.(`已采纳 ${candidate.key} 的派生参数(口令校验${metaKeys.length === 0 ? '跳过:云端尚无会话' : '通过'})`);
93
+ return { key: derived, salt };
94
+ }
95
+ }
96
+ }
97
+ // 4. 全部未命中:生成新 salt 上传本机名下
98
+ const params = KDF_DEFAULTS;
99
+ const salt = generateSalt();
100
+ await client.overwrite(own, encodeKdfSidecar(salt, params));
101
+ return { key: deriveKey(passphrase, salt, params), salt };
102
+ }
@@ -0,0 +1,136 @@
1
+ import type { SessionHeaderLike, SessionPersistenceLike } from '../dsh-types.js';
2
+ import type { SyncClient } from './http-client.js';
3
+ import type { StateStore } from './state.js';
4
+ /** 本机支持的会话格式版本(dsh SESSION_FORMAT_VERSION,pre-release 恒为 0)。 */
5
+ export declare const LOCAL_FORMAT_VERSION = 0;
6
+ /** 恢复目录的一行(Host 计算好解析结果,Client 只负责呈现与改选)。 */
7
+ export interface CatalogEntry {
8
+ sessionId: string;
9
+ device: string;
10
+ title: string;
11
+ updatedAt: number;
12
+ eventCount: number;
13
+ /** 来源 cwd(meta 记录的原样路径,可能在本机不存在) */
14
+ cwd: string;
15
+ formatVersion: number;
16
+ existsLocal: boolean;
17
+ versionIncompatible: boolean;
18
+ /** 解析出的本机落位 cwd;未解析为 null(原样落位,恢复后未分组) */
19
+ resolvedCwd: string | null;
20
+ resolution: 'mapping' | 'suggested' | 'none';
21
+ }
22
+ /** restoreCatalogJson 的载荷(Host→Client)。at 回显 restoreListRequestedAt。 */
23
+ export interface RestoreCatalog {
24
+ at: number;
25
+ /** 本设备名(Client 据此默认勾选其他设备的会话) */
26
+ selfDevice: string;
27
+ sessions: CatalogEntry[];
28
+ /** '' 或错误码(unconfigured / unreachable / …),非空时 sessions 无意义 */
29
+ error: string;
30
+ }
31
+ /** restoreRequestJson 的载荷(Client→Host)。targetCwd 为 null 表示原样落位。 */
32
+ export interface RestoreRequest {
33
+ at: number;
34
+ items: {
35
+ sessionId: string;
36
+ device: string;
37
+ targetCwd: string | null;
38
+ }[];
39
+ }
40
+ /** restoreResultJson 的载荷(Host→Client)。at 回显请求的 at。 */
41
+ export interface RestoreResult {
42
+ at: number;
43
+ ok: number;
44
+ failed: number;
45
+ firstError: string;
46
+ }
47
+ /** startupNoticeJson 的载荷(Host→Client,启动检查发现他机增量时写)。 */
48
+ export interface StartupNotice {
49
+ at: number;
50
+ count: number;
51
+ }
52
+ /** mappingDeleteJson 的载荷(Client→Host):删除一条已学习映射。 */
53
+ export interface MappingDelete {
54
+ at: number;
55
+ from: string;
56
+ }
57
+ /** 路径映射的一对(state.global.pathMappings 的元素形态)。 */
58
+ export interface MappingPair {
59
+ from: string;
60
+ to: string;
61
+ }
62
+ export declare function encodeCatalog(catalog: RestoreCatalog): string;
63
+ export declare function decodeRestoreRequest(raw: string): RestoreRequest | undefined;
64
+ export declare function encodeRestoreResult(result: RestoreResult): string;
65
+ export declare function encodeMappings(mappings: MappingPair[]): string;
66
+ export declare function decodeMappingDelete(raw: string): MappingDelete | undefined;
67
+ export declare function encodeStartupNotice(notice: StartupNotice): string;
68
+ export interface Resolution {
69
+ kind: 'mapping' | 'suggested' | 'none';
70
+ cwd: string | null;
71
+ }
72
+ /** 尾段名:路径最后一个非空段('/home/alice/p/proj' → 'proj')。 */
73
+ export declare function tailSegment(cwd: string): string;
74
+ /**
75
+ * 解析来源 cwd 的本机落位:
76
+ * 1. 已学习映射按前缀命中(首个命中即用,子路径按比例平移);
77
+ * 2. 未命中 → 来源路径尾段名在本机工作区 canonical path 中找同名目录作建议;
78
+ * 3. 皆无 → none(调用方呈现「恢复后未分组」,仍可原样落位)。
79
+ * 改选目录由 Client 交互完成,结果作为 targetCwd 随恢复请求回来,不在这层。
80
+ */
81
+ export declare function resolveTargetCwd(sourceCwd: string, mappings: readonly MappingPair[], workspacePaths: readonly string[]): Resolution;
82
+ /** 从完整日志明文中解析首帧首行的 SessionHeader。 */
83
+ export declare function parseHeaderFromLog(logBytes: Buffer): Promise<SessionHeaderLike>;
84
+ /**
85
+ * 改写日志明文的 header.cwd:解压首帧 → 首行 JSON 就地改 cwd 字段
86
+ * (JSON.parse 保序,stringify 只动值不动键序)→ 按 dsh 的 checksum 参数重压缩
87
+ * 替换首帧,其余帧字节原样拼接。
88
+ */
89
+ export declare function rewriteCwd(logBytes: Buffer, newCwd: string): Promise<Buffer>;
90
+ /** 学习一对映射:同 from 的旧条目被替换,新条目排到最前(首个命中)。 */
91
+ export declare function learnMapping(state: StateStore, from: string, to: string): Promise<MappingPair[]>;
92
+ /** 删除一条映射(设置卡「路径映射」区的删除按钮)。 */
93
+ export declare function deleteMapping(state: StateStore, from: string): Promise<MappingPair[]>;
94
+ export interface CatalogDeps {
95
+ client: SyncClient;
96
+ /** scrypt 派生密钥(kdf 引导完成后) */
97
+ key: Buffer;
98
+ persistence: SessionPersistenceLike;
99
+ state: StateStore;
100
+ /** 本机工作区 canonical path 列表(workspaceRegistry 缺失时传空数组) */
101
+ workspacePaths: string[];
102
+ /** 单个 meta 解密失败时回调(口令不匹配的兜底呈现走状态行,不打断目录) */
103
+ onWarn?: (message: string) => void;
104
+ }
105
+ /**
106
+ * 拉取全设备云端目录:列出 sessions/ 下全部 meta 对象,逐个下载解密,
107
+ * 标注 existsLocal / versionIncompatible / 路径解析结果,按 updatedAt 倒序。
108
+ */
109
+ export declare function fetchCatalog(deps: CatalogDeps): Promise<CatalogEntry[]>;
110
+ export interface RestoreDeps {
111
+ client: SyncClient;
112
+ key: Buffer;
113
+ persistence: SessionPersistenceLike;
114
+ state: StateStore;
115
+ /**
116
+ * 落位后把会话挂进 workspace(web 侧边栏可见性的成员注册)。
117
+ * 实现负责 resolveByPath/create/attachSession;缺省跳过(无 workspaceRegistry 环境)。
118
+ */
119
+ attachWorkspace?: (sessionId: string, cwd: string) => Promise<void>;
120
+ onWarn?: (message: string) => void;
121
+ }
122
+ /**
123
+ * 恢复选中会话:逐条下载 log.enc → 链式解密 → 需要时改写 header.cwd →
124
+ * locate() 计算落位 → 原子写入(已存在拒绝覆盖)→ workspace 挂载 →
125
+ * 学习实际使用的映射对。单条失败不中断其余(01 §5.4 与 syncAll 同语义)。
126
+ */
127
+ export declare function restoreSessions(deps: RestoreDeps, items: RestoreRequest['items']): Promise<RestoreResult>;
128
+ /**
129
+ * 恢复后抑制立刻回传(§4.5):把刚落位的会话以当前 revision 记入 state,
130
+ * 引擎下一轮 scan 不会把它当变更上传;待本机续写产生新 revision 后才作为
131
+ * 本设备名下的独立对象全量首传(state 偏移为 0,链式段从头开始,meta 折叠
132
+ * 也会从首帧重新累积,无需在这里预填 title/eventCount)。
133
+ */
134
+ export declare function markRestoredSynced(deps: Pick<RestoreDeps, 'persistence' | 'state'>, sessionIds: string[]): Promise<void>;
135
+ /** 启动检查(01 §5.5):其他设备名下存在本地没有的会话数。 */
136
+ export declare function countRemoteOnlySessions(deps: CatalogDeps, selfDevice: string): Promise<number>;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * 下行恢复(docs/02 §4.3):云端目录拉取、路径归位解析、header.cwd 单字段改写、
3
+ * 经 locate() 落位、映射学习。与 cordis 解耦,纯函数 + 显式 deps 便于单测。
4
+ *
5
+ * 硬约束(§4.3):
6
+ * - 落位路径一律由 persistence.locate(header) 计算,绝不自行复刻 projectKey 编码;
7
+ * - 对文件字节的唯一改写是首帧 JSON 的 cwd 字段,事件正文一字节不动;
8
+ * - 落位后唯一的 dsh 调用是 workspaceRegistry 挂载(公开服务方法)——
9
+ * sessionQuery 的 list() 走 persistence.list() 扫盘,文件就位即被发现;
10
+ * 但 web 侧边栏按 workspace 成员关系分组,不挂载的会话只进「未分组」桶。
11
+ */
12
+ import fsp from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { decryptLog, decryptMeta } from '../crypto/envelope.js';
15
+ import { logKey } from './engine.js';
16
+ import { scanZstdFrames, zstdCompressAsync, zstdDecompressAsync, ZSTD_CHECKSUM_OPTIONS } from './frames.js';
17
+ /** 本机支持的会话格式版本(dsh SESSION_FORMAT_VERSION,pre-release 恒为 0)。 */
18
+ export const LOCAL_FORMAT_VERSION = 0;
19
+ // ---- JSON 载荷的编解码(两端各自拼写,不跨半 import;Host 侧编码 + 防御性解码) ----
20
+ export function encodeCatalog(catalog) {
21
+ return JSON.stringify(catalog);
22
+ }
23
+ export function decodeRestoreRequest(raw) {
24
+ try {
25
+ const parsed = JSON.parse(raw);
26
+ if (typeof parsed.at !== 'number' || !Array.isArray(parsed.items))
27
+ return undefined;
28
+ return parsed;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export function encodeRestoreResult(result) {
35
+ return JSON.stringify(result);
36
+ }
37
+ export function encodeMappings(mappings) {
38
+ return JSON.stringify(mappings);
39
+ }
40
+ export function decodeMappingDelete(raw) {
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ if (typeof parsed.at !== 'number' || typeof parsed.from !== 'string')
44
+ return undefined;
45
+ return parsed;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ export function encodeStartupNotice(notice) {
52
+ return JSON.stringify(notice);
53
+ }
54
+ /** 尾段名:路径最后一个非空段('/home/alice/p/proj' → 'proj')。 */
55
+ export function tailSegment(cwd) {
56
+ const segments = cwd.split('/').filter((segment) => segment.length > 0);
57
+ return segments[segments.length - 1] ?? '';
58
+ }
59
+ /**
60
+ * 解析来源 cwd 的本机落位:
61
+ * 1. 已学习映射按前缀命中(首个命中即用,子路径按比例平移);
62
+ * 2. 未命中 → 来源路径尾段名在本机工作区 canonical path 中找同名目录作建议;
63
+ * 3. 皆无 → none(调用方呈现「恢复后未分组」,仍可原样落位)。
64
+ * 改选目录由 Client 交互完成,结果作为 targetCwd 随恢复请求回来,不在这层。
65
+ */
66
+ export function resolveTargetCwd(sourceCwd, mappings, workspacePaths) {
67
+ if (sourceCwd === '')
68
+ return { kind: 'none', cwd: null };
69
+ for (const mapping of mappings) {
70
+ if (sourceCwd === mapping.from)
71
+ return { kind: 'mapping', cwd: mapping.to };
72
+ if (sourceCwd.startsWith(mapping.from + '/')) {
73
+ return { kind: 'mapping', cwd: mapping.to + sourceCwd.slice(mapping.from.length) };
74
+ }
75
+ }
76
+ const tail = tailSegment(sourceCwd);
77
+ if (tail !== '') {
78
+ const hit = workspacePaths.find((workspacePath) => tailSegment(workspacePath) === tail);
79
+ if (hit !== undefined)
80
+ return { kind: 'suggested', cwd: hit };
81
+ }
82
+ return { kind: 'none', cwd: null };
83
+ }
84
+ // ---- header.cwd 单字段改写(§4.3 铁律 2 的唯一例外) ----
85
+ /** 从完整日志明文中解析首帧首行的 SessionHeader。 */
86
+ export async function parseHeaderFromLog(logBytes) {
87
+ const scan = scanZstdFrames(logBytes, 1);
88
+ if (scan.frames.length === 0)
89
+ throw new Error('session log has no complete first frame');
90
+ const first = await zstdDecompressAsync(logBytes.subarray(scan.frames[0].start, scan.frames[0].end));
91
+ const text = first.toString('utf8');
92
+ const newline = text.indexOf('\n');
93
+ const headerLine = newline === -1 ? text : text.slice(0, newline);
94
+ const parsed = JSON.parse(headerLine);
95
+ if (parsed.type !== 'session' || typeof parsed.id !== 'string' || typeof parsed.version !== 'number') {
96
+ throw new Error('first line is not a session header');
97
+ }
98
+ return parsed;
99
+ }
100
+ /**
101
+ * 改写日志明文的 header.cwd:解压首帧 → 首行 JSON 就地改 cwd 字段
102
+ * (JSON.parse 保序,stringify 只动值不动键序)→ 按 dsh 的 checksum 参数重压缩
103
+ * 替换首帧,其余帧字节原样拼接。
104
+ */
105
+ export async function rewriteCwd(logBytes, newCwd) {
106
+ const scan = scanZstdFrames(logBytes, 1);
107
+ if (scan.frames.length === 0)
108
+ throw new Error('session log has no complete first frame');
109
+ const frame = scan.frames[0];
110
+ const first = await zstdDecompressAsync(logBytes.subarray(frame.start, frame.end));
111
+ const text = first.toString('utf8');
112
+ const newline = text.indexOf('\n');
113
+ const headerLine = newline === -1 ? text : text.slice(0, newline);
114
+ const rest = newline === -1 ? '' : text.slice(newline);
115
+ const parsed = JSON.parse(headerLine);
116
+ if (parsed.type !== 'session')
117
+ throw new Error('first line is not a session header');
118
+ parsed.cwd = newCwd;
119
+ const rewritten = Buffer.from(JSON.stringify(parsed) + rest, 'utf8');
120
+ const compressed = await zstdCompressAsync(rewritten, ZSTD_CHECKSUM_OPTIONS);
121
+ return Buffer.concat([compressed, logBytes.subarray(frame.end)]);
122
+ }
123
+ // ---- 映射学习(state.global.pathMappings,首个命中生效) ----
124
+ /** 学习一对映射:同 from 的旧条目被替换,新条目排到最前(首个命中)。 */
125
+ export async function learnMapping(state, from, to) {
126
+ const mappings = state.getGlobal().pathMappings.filter((pair) => pair.from !== from);
127
+ mappings.unshift({ from, to });
128
+ await state.setGlobal({ pathMappings: mappings });
129
+ return mappings;
130
+ }
131
+ /** 删除一条映射(设置卡「路径映射」区的删除按钮)。 */
132
+ export async function deleteMapping(state, from) {
133
+ const mappings = state.getGlobal().pathMappings.filter((pair) => pair.from !== from);
134
+ await state.setGlobal({ pathMappings: mappings });
135
+ return mappings;
136
+ }
137
+ const META_SUFFIX = '.meta.enc';
138
+ /**
139
+ * 拉取全设备云端目录:列出 sessions/ 下全部 meta 对象,逐个下载解密,
140
+ * 标注 existsLocal / versionIncompatible / 路径解析结果,按 updatedAt 倒序。
141
+ */
142
+ export async function fetchCatalog(deps) {
143
+ const objects = await deps.client.list('sessions/');
144
+ const metaKeys = objects.filter((object) => object.key.endsWith(META_SUFFIX));
145
+ const localIds = new Set((await deps.persistence.list()).map((header) => header.id));
146
+ const mappings = deps.state.getGlobal().pathMappings;
147
+ const entries = [];
148
+ for (const object of metaKeys) {
149
+ // key 形态:sessions/<device>/<sessionId>.meta.enc
150
+ const segments = object.key.split('/');
151
+ const device = segments[1] ?? '';
152
+ const sessionId = segments[segments.length - 1].slice(0, -META_SUFFIX.length);
153
+ let meta;
154
+ try {
155
+ meta = decryptMeta(deps.key, sessionId, await deps.client.download(object.key));
156
+ }
157
+ catch (error) {
158
+ deps.onWarn?.(`meta 解密失败,跳过 ${object.key}:${String(error)}`);
159
+ continue;
160
+ }
161
+ const existsLocal = localIds.has(sessionId);
162
+ const resolution = existsLocal || meta.cwd === ''
163
+ ? { kind: 'none', cwd: null }
164
+ : resolveTargetCwd(meta.cwd, mappings, deps.workspacePaths);
165
+ entries.push({
166
+ sessionId,
167
+ device,
168
+ title: meta.title,
169
+ updatedAt: meta.updatedAt,
170
+ eventCount: meta.eventCount,
171
+ cwd: meta.cwd,
172
+ formatVersion: meta.formatVersion,
173
+ existsLocal,
174
+ versionIncompatible: meta.formatVersion > LOCAL_FORMAT_VERSION,
175
+ resolvedCwd: resolution.cwd,
176
+ resolution: resolution.kind,
177
+ });
178
+ }
179
+ // 最后更新倒序(01 §5.4)
180
+ entries.sort((a, b) => b.updatedAt - a.updatedAt);
181
+ return entries;
182
+ }
183
+ /**
184
+ * 恢复选中会话:逐条下载 log.enc → 链式解密 → 需要时改写 header.cwd →
185
+ * locate() 计算落位 → 原子写入(已存在拒绝覆盖)→ workspace 挂载 →
186
+ * 学习实际使用的映射对。单条失败不中断其余(01 §5.4 与 syncAll 同语义)。
187
+ */
188
+ export async function restoreSessions(deps, items) {
189
+ let ok = 0;
190
+ let failed = 0;
191
+ let firstError = '';
192
+ for (const item of items) {
193
+ try {
194
+ await restoreOne(deps, item);
195
+ ok += 1;
196
+ }
197
+ catch (error) {
198
+ failed += 1;
199
+ firstError ||= `${item.sessionId}: ${String(error)}`.slice(0, 300);
200
+ deps.onWarn?.(`恢复失败 ${item.sessionId}:${String(error)}`);
201
+ }
202
+ }
203
+ return { at: 0, ok, failed, firstError };
204
+ }
205
+ async function restoreOne(deps, item) {
206
+ const encrypted = await deps.client.download(logKey(item.device, item.sessionId));
207
+ let plaintext = decryptLog(deps.key, item.sessionId, encrypted);
208
+ const header = await parseHeaderFromLog(plaintext);
209
+ // 落位身份以日志内 header 为准:云端 key 与内容不匹配时拒绝,防止写错目录
210
+ if (header.id !== item.sessionId)
211
+ throw new Error(`cloud object identity mismatch: key says ${item.sessionId}, header says ${header.id}`);
212
+ const sourceCwd = header.cwd ?? '';
213
+ // 路径归位:目标与来源不同才改写首帧 cwd,并学习实际使用的映射对(§4.3 步骤 4)。
214
+ // 显式指定的目标目录必须先验证存在:写盘后失败无法回滚(同 id 重试会被拒绝覆盖)
215
+ let locatedHeader = header;
216
+ if (item.targetCwd !== null && item.targetCwd !== sourceCwd) {
217
+ if (sourceCwd === '')
218
+ throw new Error('来源会话无 cwd,无法建立映射');
219
+ if (!(await isExistingDirectory(item.targetCwd))) {
220
+ throw new Error(`目标目录不存在或不是目录:${item.targetCwd}`);
221
+ }
222
+ plaintext = await rewriteCwd(plaintext, item.targetCwd);
223
+ locatedHeader = { ...header, cwd: item.targetCwd };
224
+ await learnMapping(deps.state, sourceCwd, item.targetCwd);
225
+ }
226
+ const location = deps.persistence.locate(locatedHeader);
227
+ if (!location)
228
+ throw new Error('persistence backend exposes no file artifact');
229
+ // 本地已存在同 id 不允许覆盖(01 §5.4:想覆盖需先在 dsh 内删除本地会话)
230
+ try {
231
+ await fsp.stat(location.path);
232
+ throw new Error('local session already exists at target');
233
+ }
234
+ catch (error) {
235
+ if (error.code !== 'ENOENT')
236
+ throw error;
237
+ }
238
+ await fsp.mkdir(path.dirname(location.path), { recursive: true });
239
+ const tmp = `${location.path}.tmp-${process.pid}`;
240
+ await fsp.writeFile(tmp, plaintext);
241
+ await fsp.rename(tmp, location.path);
242
+ // workspace 挂载(web 侧边栏按成员关系分组)。目录存在的落位 cwd 才尝试挂载:
243
+ // 原样落位到本机不存在的来源路径时按 §4.3 语义保持「未分组」,不算失败。
244
+ // 挂载失败仅告警不回滚——文件已就位,会话至少出现在「未分组」桶,重试 attach
245
+ // 也没有意义(同 cwd 再挂靠 registry 幂等,但失败原因通常是目录状态异常)
246
+ const effectiveCwd = locatedHeader.cwd ?? '';
247
+ if (deps.attachWorkspace && effectiveCwd !== '' && await isExistingDirectory(effectiveCwd)) {
248
+ try {
249
+ await deps.attachWorkspace(item.sessionId, effectiveCwd);
250
+ }
251
+ catch (error) {
252
+ deps.onWarn?.(`workspace 挂载失败 ${item.sessionId}(会话已落位,将出现在未分组):${String(error)}`);
253
+ }
254
+ }
255
+ return location.path;
256
+ }
257
+ async function isExistingDirectory(target) {
258
+ try {
259
+ return (await fsp.stat(target)).isDirectory();
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ /**
266
+ * 恢复后抑制立刻回传(§4.5):把刚落位的会话以当前 revision 记入 state,
267
+ * 引擎下一轮 scan 不会把它当变更上传;待本机续写产生新 revision 后才作为
268
+ * 本设备名下的独立对象全量首传(state 偏移为 0,链式段从头开始,meta 折叠
269
+ * 也会从首帧重新累积,无需在这里预填 title/eventCount)。
270
+ */
271
+ export async function markRestoredSynced(deps, sessionIds) {
272
+ if (sessionIds.length === 0)
273
+ return;
274
+ const wanted = new Set(sessionIds);
275
+ const snapshots = await deps.persistence.listSnapshots();
276
+ for (const snapshot of snapshots) {
277
+ if (!wanted.has(snapshot.header.id))
278
+ continue;
279
+ await deps.state.putSession(snapshot.header.id, {
280
+ uploadedBytes: 0,
281
+ remoteBytes: 0,
282
+ lastSegmentIndex: -1,
283
+ lastTag: '',
284
+ localRevision: snapshot.revision,
285
+ eventCount: 0,
286
+ updatedAt: 0,
287
+ status: 'ok',
288
+ });
289
+ }
290
+ }
291
+ /** 启动检查(01 §5.5):其他设备名下存在本地没有的会话数。 */
292
+ export async function countRemoteOnlySessions(deps, selfDevice) {
293
+ const entries = await fetchCatalog(deps);
294
+ return entries.filter((entry) => entry.device !== selfDevice && !entry.existsLocal).length;
295
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 本地同步状态(docs/02 §7):首选 dsh storageDomain 版本化 KV 域,
3
+ * 不可用时降级为 $DSH_HOME 下插件自建子目录的单文件 JSON。
4
+ */
5
+ import { type Domain } from '@deepseek-ai/dsh-storage-domain';
6
+ import { z } from 'zod';
7
+ /** 每会话同步进度。uploadedBytes 是明文读取偏移,remoteBytes 是云端密文长度。 */
8
+ export interface SessionSyncState {
9
+ uploadedBytes: number;
10
+ remoteBytes: number;
11
+ /** 已上传的最后段序号,-1 表示尚未上传任何段 */
12
+ lastSegmentIndex: number;
13
+ /** 最后段的 GCM tag(hex),空串表示尚未上传任何段 */
14
+ lastTag: string;
15
+ /** 上次处理到的 sessionPersistence revision(不透明令牌) */
16
+ localRevision: string;
17
+ /** meta 折叠累积 */
18
+ eventCount: number;
19
+ title?: string;
20
+ updatedAt: number;
21
+ status?: 'ok' | 'skipped-plain' | 'conflict' | 'error';
22
+ error?: string;
23
+ }
24
+ /** 全局状态;pathMappings 预留给 M4 的路径归位学习。 */
25
+ export interface GlobalState {
26
+ lastSyncAt?: number;
27
+ lastError?: string;
28
+ pathMappings: {
29
+ from: string;
30
+ to: string;
31
+ }[];
32
+ }
33
+ export interface StateStore {
34
+ getSession(id: string): SessionSyncState | undefined;
35
+ putSession(id: string, state: SessionSyncState): Promise<void>;
36
+ entries(): IterableIterator<[string, SessionSyncState]>;
37
+ getGlobal(): GlobalState;
38
+ setGlobal(patch: Partial<GlobalState>): Promise<void>;
39
+ close(): Promise<void>;
40
+ }
41
+ export declare const INITIAL_GLOBAL: GlobalState;
42
+ /** storageDomain 实现(首选)。 */
43
+ export declare const cloudSyncDomainSpec: {
44
+ name: string;
45
+ version: number;
46
+ global: {
47
+ schema: z.ZodObject<{
48
+ lastSyncAt: z.ZodOptional<z.ZodNumber>;
49
+ lastError: z.ZodOptional<z.ZodString>;
50
+ pathMappings: z.ZodArray<z.ZodObject<{
51
+ from: z.ZodString;
52
+ to: z.ZodString;
53
+ }, z.core.$strip>>;
54
+ }, z.core.$strip>;
55
+ initial: GlobalState;
56
+ };
57
+ tables: {
58
+ sessions: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, SessionSyncState>;
59
+ };
60
+ };
61
+ type CloudSyncDomain = Domain<typeof cloudSyncDomainSpec>;
62
+ export declare class DomainStateStore implements StateStore {
63
+ private domain;
64
+ constructor(domain: CloudSyncDomain);
65
+ getSession(id: string): SessionSyncState | undefined;
66
+ entries(): IterableIterator<[string, SessionSyncState]>;
67
+ putSession(id: string, state: SessionSyncState): Promise<void>;
68
+ getGlobal(): GlobalState;
69
+ setGlobal(patch: Partial<GlobalState>): Promise<void>;
70
+ close(): Promise<void>;
71
+ }
72
+ /** JSON 单文件降级实现:tmp + rename 原子写。 */
73
+ export declare class JsonFileStateStore implements StateStore {
74
+ private file;
75
+ private sessions;
76
+ private global;
77
+ private writing;
78
+ private constructor();
79
+ /** 打开(或创建)state 文件;损坏时回退为空 state(即全量重传),不阻断启动。 */
80
+ static open(dir: string): Promise<JsonFileStateStore>;
81
+ private persist;
82
+ getSession(id: string): SessionSyncState | undefined;
83
+ entries(): IterableIterator<[string, SessionSyncState]>;
84
+ putSession(id: string, state: SessionSyncState): Promise<void>;
85
+ getGlobal(): GlobalState;
86
+ setGlobal(patch: Partial<GlobalState>): Promise<void>;
87
+ close(): Promise<void>;
88
+ }
89
+ export {};