@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.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.
- package/CHANGELOG.md +74 -0
- package/CHANGELOG_ZH.md +59 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/README_ZH.md +185 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +9 -0
- package/lib/client.js +4413 -0
- package/lib/discovery.js +354 -0
- package/lib/index.js +1053 -0
- package/lib/lan.js +213 -0
- package/lib/logfile.js +80 -0
- package/lib/probe.js +100 -0
- package/lib/repair.js +150 -0
- package/lib/sessions.js +501 -0
- package/lib/state.js +236 -0
- package/package.json +64 -4
- package/screenshots.json +1 -0
- package/scripts/repair-sessions.mjs +31 -0
package/lib/sessions.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-store bridge.
|
|
3
|
+
*
|
|
4
|
+
* Two ways to reach the local session store:
|
|
5
|
+
* 1. through the harness services (`ctx.sessionQuery`, `ctx.sessionPersistence`)
|
|
6
|
+
* which is what the GUI itself uses — preferred, because an imported
|
|
7
|
+
* session then shows up exactly like a locally created one;
|
|
8
|
+
* 2. straight from `$DSH_HOME/sessions` when the services are unavailable
|
|
9
|
+
* (standalone harness, older build). Reading still works; importing needs
|
|
10
|
+
* the persistence service, so that path reports a clear error.
|
|
11
|
+
*
|
|
12
|
+
* Wire format for one session (`exportSession`):
|
|
13
|
+
* { format: 'dsh-session-sync/1', session: <header>, events: [<...>] }
|
|
14
|
+
* `events` is the replay-validated raw log exactly as `readSession` returns it.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import crypto from 'node:crypto'
|
|
19
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
20
|
+
import { dshHome } from './state.js'
|
|
21
|
+
|
|
22
|
+
export const WIRE_FORMAT = 'dsh-session-sync/1'
|
|
23
|
+
|
|
24
|
+
export class SessionSyncError extends Error {
|
|
25
|
+
constructor(code, message, status = 400) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.name = 'SessionSyncError'
|
|
28
|
+
this.code = code
|
|
29
|
+
this.status = status
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Drop the inherited prefix a forked/summarised source carries.
|
|
36
|
+
*
|
|
37
|
+
* A seeded session's log ends its inherited block with
|
|
38
|
+
* `{ type: 'session/end-seed', data: { inherited: true } }`. Copying those events
|
|
39
|
+
* into a fresh, unseeded log makes DSH reject the copy with
|
|
40
|
+
* "released v2 unseeded Session contains an inherited end-seed marker", so an
|
|
41
|
+
* import keeps only the events the source session authored itself.
|
|
42
|
+
*
|
|
43
|
+
* @param {object[]} events - source event list, in seq order.
|
|
44
|
+
* @returns {object[]} the source's own events (possibly the same array).
|
|
45
|
+
*/
|
|
46
|
+
export function stripInheritedPrefix(events) {
|
|
47
|
+
let cut = -1
|
|
48
|
+
for (const [index, event] of events.entries()) {
|
|
49
|
+
if (event?.type === 'session/end-seed' && isPlainObject(event.data) && event.data.inherited === true) cut = index
|
|
50
|
+
}
|
|
51
|
+
if (cut < 0) return events
|
|
52
|
+
return events.slice(cut + 1).filter(
|
|
53
|
+
(event) => !(event?.type === 'session/end-seed' && isPlainObject(event.data) && event.data.inherited === true),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pick the working directory an imported session should claim: an explicit
|
|
59
|
+
* override, then the source directory when it also exists on this machine, then
|
|
60
|
+
* the current process directory. A v4 header needs an absolute path that the
|
|
61
|
+
* local UI can reopen.
|
|
62
|
+
*/
|
|
63
|
+
function importCwd(source, options) {
|
|
64
|
+
if (typeof options.cwd === 'string' && path.isAbsolute(options.cwd)) return options.cwd
|
|
65
|
+
if (typeof source.cwd === 'string' && path.isAbsolute(source.cwd) && fs.existsSync(source.cwd)) return source.cwd
|
|
66
|
+
return process.cwd()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPlainObject(value) {
|
|
70
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map session id -> {file, dir, mtime, size} for every stored session. */
|
|
74
|
+
function scanStore(storeRoot) {
|
|
75
|
+
const index = new Map()
|
|
76
|
+
let slugs
|
|
77
|
+
try {
|
|
78
|
+
slugs = fs.readdirSync(storeRoot, { withFileTypes: true })
|
|
79
|
+
} catch {
|
|
80
|
+
return index
|
|
81
|
+
}
|
|
82
|
+
for (const slug of slugs) {
|
|
83
|
+
if (!slug.isDirectory()) continue
|
|
84
|
+
const slugDir = path.join(storeRoot, slug.name)
|
|
85
|
+
let sessions
|
|
86
|
+
try {
|
|
87
|
+
sessions = fs.readdirSync(slugDir, { withFileTypes: true })
|
|
88
|
+
} catch {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
for (const entry of sessions) {
|
|
92
|
+
if (!entry.isDirectory()) continue
|
|
93
|
+
const dir = path.join(slugDir, entry.name)
|
|
94
|
+
let files
|
|
95
|
+
try {
|
|
96
|
+
files = fs.readdirSync(dir)
|
|
97
|
+
} catch {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const file = files.find((name) => name.endsWith('.jsonl.zstd'))
|
|
101
|
+
if (file === undefined) continue
|
|
102
|
+
const full = path.join(dir, file)
|
|
103
|
+
let stat
|
|
104
|
+
try {
|
|
105
|
+
stat = fs.statSync(full)
|
|
106
|
+
} catch {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
index.set(entry.name, { file: full, dir, mtime: stat.mtimeMs, size: stat.size, slug: slug.name })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return index
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Read the header line (first zstd frame) without decompressing the whole log. */
|
|
116
|
+
function readHeader(file) {
|
|
117
|
+
let fd
|
|
118
|
+
try {
|
|
119
|
+
fd = fs.openSync(file, 'r')
|
|
120
|
+
const chunk = Buffer.allocUnsafe(Math.min(256 * 1024, Math.max(4096, fs.fstatSync(fd).size)))
|
|
121
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, 0)
|
|
122
|
+
const text = zstdDecompressSync(chunk.subarray(0, read)).toString('utf8')
|
|
123
|
+
const line = text.split('\n', 1)[0]
|
|
124
|
+
const header = JSON.parse(line)
|
|
125
|
+
return isPlainObject(header) ? header : undefined
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
} finally {
|
|
129
|
+
if (fd !== undefined) {
|
|
130
|
+
try {
|
|
131
|
+
fs.closeSync(fd)
|
|
132
|
+
} catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Titles recorded in the projection cache, which is cheap and always current. */
|
|
140
|
+
function cachedTitles(storeRoot) {
|
|
141
|
+
const out = new Map()
|
|
142
|
+
const dir = path.join(path.dirname(storeRoot), 'storages', 'session_projcache', 'sessions')
|
|
143
|
+
let names
|
|
144
|
+
try {
|
|
145
|
+
names = fs.readdirSync(dir)
|
|
146
|
+
} catch {
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
for (const name of names) {
|
|
150
|
+
if (!name.endsWith('.json')) continue
|
|
151
|
+
try {
|
|
152
|
+
const doc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))
|
|
153
|
+
const title = doc?.record?.rows?.title?.val
|
|
154
|
+
if (typeof title === 'string' && title.trim().length > 0) out.set(name.slice(0, -'.json'.length), title.trim())
|
|
155
|
+
} catch {
|
|
156
|
+
/* a broken cache file only costs us a title */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function titleFromEvents(events) {
|
|
163
|
+
for (const event of events) {
|
|
164
|
+
if (event?.type !== 'message' || !isPlainObject(event.data)) continue
|
|
165
|
+
const role = event.data.role
|
|
166
|
+
if (role !== 'user') continue
|
|
167
|
+
const content = event.data.content
|
|
168
|
+
const text = typeof content === 'string'
|
|
169
|
+
? content
|
|
170
|
+
: Array.isArray(content)
|
|
171
|
+
? content.map((part) => (typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : '')).join(' ')
|
|
172
|
+
: ''
|
|
173
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
174
|
+
if (flat.length > 0) return flat.slice(0, 80)
|
|
175
|
+
}
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sourceCreatedAt(source) {
|
|
180
|
+
const value = source?.createdAt
|
|
181
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : Date.now()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Content fingerprint of a session, stable across the id rewrite every import
|
|
186
|
+
* performs.
|
|
187
|
+
*
|
|
188
|
+
* Two devices with auto-pull enabled would otherwise bounce the same
|
|
189
|
+
* conversation back and forth: A pushes to B, B's auto-pull imports it under a
|
|
190
|
+
* fresh id, A's auto-pull imports that copy under another fresh id, forever.
|
|
191
|
+
* Fingerprinting the payload lets the importer notice "I already have this
|
|
192
|
+
* conversation" without trusting ids.
|
|
193
|
+
*
|
|
194
|
+
* @param {object} session - wire session header.
|
|
195
|
+
* @param {object[]} events - wire events.
|
|
196
|
+
* @returns {string} short hex digest.
|
|
197
|
+
*/
|
|
198
|
+
export function sessionSignature(session, events) {
|
|
199
|
+
const list = Array.isArray(events) ? events : []
|
|
200
|
+
const first = list[0]
|
|
201
|
+
const last = list[list.length - 1]
|
|
202
|
+
const shape = [
|
|
203
|
+
typeof session?.createdAt === 'number' ? session.createdAt : null,
|
|
204
|
+
typeof session?.agentPreset === 'string' ? session.agentPreset : null,
|
|
205
|
+
list.length,
|
|
206
|
+
typeof first?.seq === 'number' ? first.seq : 0,
|
|
207
|
+
typeof first?.time === 'number' ? first.time : null,
|
|
208
|
+
typeof first?.type === 'string' ? first.type : null,
|
|
209
|
+
preview(first),
|
|
210
|
+
typeof last?.time === 'number' ? last.time : null,
|
|
211
|
+
typeof last?.type === 'string' ? last.type : null,
|
|
212
|
+
]
|
|
213
|
+
return crypto.createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 20)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function preview(event) {
|
|
217
|
+
const data = event?.data
|
|
218
|
+
if (typeof data === 'string') return data.slice(0, 160)
|
|
219
|
+
if (isPlainObject(data)) {
|
|
220
|
+
const content = data.content ?? data.text ?? data.message ?? null
|
|
221
|
+
if (typeof content === 'string') return content.slice(0, 160)
|
|
222
|
+
if (Array.isArray(content)) {
|
|
223
|
+
const text = content
|
|
224
|
+
.map((part) => (typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : ''))
|
|
225
|
+
.join('')
|
|
226
|
+
return text.slice(0, 160)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* SHA-256 of one wire payload, so the receiver can prove it got the bytes the
|
|
234
|
+
* sender meant to send. Hashed over exactly what travels on the wire
|
|
235
|
+
* (`{format, session, events}`); `JSON.stringify` of a parsed object keeps the
|
|
236
|
+
* sender's key order, so both sides hash the same string.
|
|
237
|
+
*
|
|
238
|
+
* @param {object} session - wire session header.
|
|
239
|
+
* @param {object[]} events - wire events.
|
|
240
|
+
* @returns {string} 64-char lowercase hex digest.
|
|
241
|
+
*/
|
|
242
|
+
export function sessionDigest(session, events) {
|
|
243
|
+
return crypto
|
|
244
|
+
.createHash('sha256')
|
|
245
|
+
.update(JSON.stringify({ format: WIRE_FORMAT, session, events }))
|
|
246
|
+
.digest('hex')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function checksumMatches(payload) {
|
|
250
|
+
const expected = typeof payload?.sha256 === 'string' ? payload.sha256.toLowerCase() : null
|
|
251
|
+
if (expected === null || expected.length === 0) return { verified: false, digest: null, ok: true }
|
|
252
|
+
const actual = sessionDigest(payload.session, payload.events)
|
|
253
|
+
return { verified: true, digest: actual, ok: actual === expected }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export class SessionBridge {
|
|
257
|
+
/**
|
|
258
|
+
* @param {object} ctx - host plugin context.
|
|
259
|
+
* @param {{ home?: string, onImported?: (signature: string, localId: string) => void, onExported?: (signature: string, localId: string) => void }} [options]
|
|
260
|
+
*/
|
|
261
|
+
constructor(ctx, options = {}) {
|
|
262
|
+
const home = dshHome(options)
|
|
263
|
+
this.ctx = ctx
|
|
264
|
+
this.onImported = typeof options.onImported === 'function' ? options.onImported : null
|
|
265
|
+
this.onExported = typeof options.onExported === 'function' ? options.onExported : null
|
|
266
|
+
this.home = home
|
|
267
|
+
this.storeRoot = path.join(home, 'sessions')
|
|
268
|
+
this._titleCache = new Map()
|
|
269
|
+
this._titleCacheAt = 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
get query() {
|
|
273
|
+
const service = this.ctx?.get?.('sessionQuery') ?? this.ctx?.sessionQuery
|
|
274
|
+
return service
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get persistence() {
|
|
278
|
+
const service = this.ctx?.get?.('sessionPersistence') ?? this.ctx?.sessionPersistence
|
|
279
|
+
return service
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get hasPersistence() {
|
|
283
|
+
return this.persistence !== undefined && this.persistence !== null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Number of stored sessions, without reading any log or title. */
|
|
287
|
+
async count() {
|
|
288
|
+
const persistence = this.persistence
|
|
289
|
+
if (persistence !== undefined && persistence !== null) {
|
|
290
|
+
try {
|
|
291
|
+
const all = await persistence.list()
|
|
292
|
+
return Array.isArray(all) ? all.length : 0
|
|
293
|
+
} catch {
|
|
294
|
+
/* fall through to the directory scan */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return scanStore(this.storeRoot).size
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Header + event log for one session, replay-validated when services exist. */
|
|
301
|
+
async read(id) {
|
|
302
|
+
const query = this.query
|
|
303
|
+
if (query !== undefined && query !== null) {
|
|
304
|
+
const snapshot = await query.readSession(id)
|
|
305
|
+
return {
|
|
306
|
+
session: snapshot.session ?? snapshot.header,
|
|
307
|
+
events: snapshot.events ?? [],
|
|
308
|
+
inheritedEventCount: snapshot.inheritedEventCount ?? 0,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const scanned = scanStore(this.storeRoot).get(id)
|
|
312
|
+
if (scanned === undefined) throw new SessionSyncError('session-not-found', `会话不存在: ${id}`, 404)
|
|
313
|
+
const header = readHeader(scanned.file)
|
|
314
|
+
if (header === undefined) throw new SessionSyncError('session-unreadable', `无法解析会话头: ${id}`, 500)
|
|
315
|
+
return { session: header, events: [], inheritedEventCount: 0 }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* List sessions newest-first with the metadata the UI needs.
|
|
320
|
+
* @param {{ limit?: number }} [options]
|
|
321
|
+
*/
|
|
322
|
+
async list(options = {}) {
|
|
323
|
+
const limit = options.limit ?? 200
|
|
324
|
+
const query = this.query
|
|
325
|
+
if (query !== undefined && query !== null) {
|
|
326
|
+
const records = await query.listSessions()
|
|
327
|
+
const sliced = records.slice(0, limit)
|
|
328
|
+
const ids = sliced.map((record) => record.header.id)
|
|
329
|
+
const titles = await this._titles(ids)
|
|
330
|
+
const scanned = scanStore(this.storeRoot)
|
|
331
|
+
return sliced.map((record) => {
|
|
332
|
+
const header = record.header
|
|
333
|
+
const disk = scanned.get(header.id)
|
|
334
|
+
return {
|
|
335
|
+
id: header.id,
|
|
336
|
+
title: titles.get(header.id) ?? null,
|
|
337
|
+
cwd: header.cwd ?? null,
|
|
338
|
+
createdAt: header.createdAt ?? null,
|
|
339
|
+
updatedAt: disk?.mtime ?? null,
|
|
340
|
+
sizeBytes: disk?.size ?? null,
|
|
341
|
+
live: record.live === true,
|
|
342
|
+
persisted: record.persisted === true,
|
|
343
|
+
parentSession: header.parentSession ?? null,
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
return this._fallbackList(limit)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async _fallbackList(limit) {
|
|
351
|
+
const scanned = scanStore(this.storeRoot)
|
|
352
|
+
const titles = cachedTitles(this.storeRoot)
|
|
353
|
+
const rows = []
|
|
354
|
+
for (const [id, disk] of scanned) {
|
|
355
|
+
const header = readHeader(disk.file)
|
|
356
|
+
rows.push({
|
|
357
|
+
id,
|
|
358
|
+
title: titles.get(id) ?? null,
|
|
359
|
+
cwd: header?.cwd ?? null,
|
|
360
|
+
createdAt: header?.createdAt ?? null,
|
|
361
|
+
updatedAt: disk.mtime,
|
|
362
|
+
sizeBytes: disk.size,
|
|
363
|
+
live: false,
|
|
364
|
+
persisted: true,
|
|
365
|
+
parentSession: header?.parentSession ?? null,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
369
|
+
return rows.slice(0, limit)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _titles(ids) {
|
|
373
|
+
const now = Date.now()
|
|
374
|
+
const cache = this._titleCache
|
|
375
|
+
if (now - this._titleCacheAt > 60_000) {
|
|
376
|
+
cache.clear()
|
|
377
|
+
this._titleCacheAt = now
|
|
378
|
+
}
|
|
379
|
+
// Most sessions never write a `session/title` event; DSH keeps their names
|
|
380
|
+
// in its projection cache. Fold that in so the list stops looking untitled.
|
|
381
|
+
const projected = cachedTitles(this.storeRoot)
|
|
382
|
+
const fallback = (id) => projected.get(id) ?? null
|
|
383
|
+
const missing = ids.filter((id) => !cache.has(id))
|
|
384
|
+
const query = this.query
|
|
385
|
+
if (query !== undefined && query !== null && missing.length > 0) {
|
|
386
|
+
try {
|
|
387
|
+
for (let offset = 0; offset < missing.length; offset += 100) {
|
|
388
|
+
const batch = missing.slice(offset, offset + 100)
|
|
389
|
+
const snapshots = await query.readTitleSnapshots(batch)
|
|
390
|
+
for (const result of snapshots) {
|
|
391
|
+
// readTitleSnapshots returns settled results, not bare snapshots:
|
|
392
|
+
// {status:'fulfilled', value:{session,title}} | {status:'rejected'}.
|
|
393
|
+
const value = result !== null && typeof result === 'object' && 'status' in result
|
|
394
|
+
? (result.status === 'fulfilled' ? result.value : null)
|
|
395
|
+
: result
|
|
396
|
+
const id = value?.session?.id
|
|
397
|
+
if (typeof id !== 'string') continue
|
|
398
|
+
const title = value?.title
|
|
399
|
+
cache.set(id, typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback(id))
|
|
400
|
+
}
|
|
401
|
+
for (const id of batch) if (!cache.has(id)) cache.set(id, fallback(id))
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
for (const id of missing) if (!cache.has(id)) cache.set(id, fallback(id))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return new Map(ids.map((id) => [id, cache.get(id) ?? fallback(id)]))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Latest title for one session, or null when the log never named it. */
|
|
411
|
+
async titleOf(id) {
|
|
412
|
+
const titles = await this._titles([id])
|
|
413
|
+
return titles.get(id) ?? null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Export one session in the wire format used between devices. */
|
|
417
|
+
async exportSession(id) {
|
|
418
|
+
const snapshot = await this.read(id)
|
|
419
|
+
const header = snapshot.session
|
|
420
|
+
if (!isPlainObject(header)) throw new SessionSyncError('session-unreadable', `会话头无效: ${id}`, 500)
|
|
421
|
+
// Exporting is the moment this conversation leaves the machine: fingerprint
|
|
422
|
+
// it so a copy coming back later is recognised as "already mine".
|
|
423
|
+
this.onExported?.(sessionSignature(header, snapshot.events), id)
|
|
424
|
+
return {
|
|
425
|
+
format: WIRE_FORMAT,
|
|
426
|
+
session: header,
|
|
427
|
+
events: snapshot.events,
|
|
428
|
+
sha256: sessionDigest(header, snapshot.events),
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Store a session received from another device as a normal local session.
|
|
434
|
+
*
|
|
435
|
+
* A new id is minted so two devices can each keep their own copy without
|
|
436
|
+
* fighting over the same log; parent links are dropped because the parent
|
|
437
|
+
* may not exist here.
|
|
438
|
+
*
|
|
439
|
+
* @param {{ session: object, events: object[] }} payload - wire payload.
|
|
440
|
+
* @param {{ cwd?: string }} [options]
|
|
441
|
+
* @returns {Promise<{ id: string, events: number }>}
|
|
442
|
+
*/
|
|
443
|
+
async importSession(payload, options = {}) {
|
|
444
|
+
if (!isPlainObject(payload?.session)) throw new SessionSyncError('bad-payload', '缺少会话头', 400)
|
|
445
|
+
if (!Array.isArray(payload?.events)) throw new SessionSyncError('bad-payload', '缺少事件列表', 400)
|
|
446
|
+
const persistence = this.persistence
|
|
447
|
+
if (persistence === undefined || persistence === null) {
|
|
448
|
+
throw new SessionSyncError('no-persistence', '当前运行环境没有会话存储服务,无法导入', 501)
|
|
449
|
+
}
|
|
450
|
+
const checksum = checksumMatches(payload)
|
|
451
|
+
if (!checksum.ok) {
|
|
452
|
+
throw new SessionSyncError(
|
|
453
|
+
'checksum-mismatch',
|
|
454
|
+
`会话校验失败:sha256 不匹配(期望 ${String(payload.sha256).slice(0, 12)}…,实际 ${checksum.digest.slice(0, 12)}…)`,
|
|
455
|
+
409,
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
const source = payload.session
|
|
459
|
+
const id = `session-${crypto.randomUUID()}`
|
|
460
|
+
// The v4 header accepts exactly these keys — `type` is storage-owned
|
|
461
|
+
// framing that dsh-session-persistence-jsonl writes for us, and one stray
|
|
462
|
+
// key makes the whole import fail with "unexpected field".
|
|
463
|
+
const header = {
|
|
464
|
+
version: 4,
|
|
465
|
+
id,
|
|
466
|
+
// Keep the source creation time: it is what makes a copied session
|
|
467
|
+
// recognisable, and it keeps the timeline honest in the local list.
|
|
468
|
+
createdAt: sourceCreatedAt(source),
|
|
469
|
+
cwd: importCwd(source, options),
|
|
470
|
+
isSeeded: false,
|
|
471
|
+
delegationDepth: 0,
|
|
472
|
+
agentPreset: typeof source.agentPreset === 'string' && source.agentPreset.length > 0
|
|
473
|
+
? source.agentPreset
|
|
474
|
+
: 'standard',
|
|
475
|
+
}
|
|
476
|
+
// An imported copy is always unseeded; keep only what the source authored
|
|
477
|
+
// itself, otherwise the inherited end-seed marker makes the log unreadable.
|
|
478
|
+
const own = stripInheritedPrefix(payload.events.filter((event) => isPlainObject(event)))
|
|
479
|
+
const events = own.map((event, index) => ({ ...event, seq: index }))
|
|
480
|
+
const handle = await persistence.create(header)
|
|
481
|
+
let closed = false
|
|
482
|
+
try {
|
|
483
|
+
if (events.length > 0) await handle.append(events)
|
|
484
|
+
await handle.flush()
|
|
485
|
+
} finally {
|
|
486
|
+
if (!closed) {
|
|
487
|
+
closed = true
|
|
488
|
+
await handle.close().catch(() => undefined)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const signature = sessionSignature(source, payload.events)
|
|
492
|
+
this.onImported?.(signature, id)
|
|
493
|
+
return {
|
|
494
|
+
id,
|
|
495
|
+
events: events.length,
|
|
496
|
+
signature,
|
|
497
|
+
sha256: checksum.digest ?? sessionDigest(source, payload.events),
|
|
498
|
+
verified: checksum.verified,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|