@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/lan.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LAN-facing HTTP sync endpoint.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately a separate listener from the GUI web server: the GUI
|
|
5
|
+
* binds one loopback literal (the app keeps it off the network), while syncing
|
|
6
|
+
* must be reachable from other machines. It therefore binds 0.0.0.0 on its own
|
|
7
|
+
* port and only answers requests that present the sync header (and the shared
|
|
8
|
+
* token when one is configured).
|
|
9
|
+
*
|
|
10
|
+
* GET /v1/health liveness probe
|
|
11
|
+
* GET /v1/info identity + session count
|
|
12
|
+
* GET /v1/sessions session index (metadata only)
|
|
13
|
+
* GET /v1/sessions/:id one session: header + replay-validated events
|
|
14
|
+
* POST /v1/sessions import a session from another device
|
|
15
|
+
*/
|
|
16
|
+
import http from 'node:http'
|
|
17
|
+
import { SessionSyncError, sessionSignature } from './sessions.js'
|
|
18
|
+
|
|
19
|
+
const MAX_BODY_BYTES = 256 * 1024 * 1024
|
|
20
|
+
const SYNC_HEADER = 'x-dsh-session-sync'
|
|
21
|
+
|
|
22
|
+
function sendJson(res, status, value) {
|
|
23
|
+
if (res.headersSent) return
|
|
24
|
+
const body = JSON.stringify(value)
|
|
25
|
+
res.writeHead(status, {
|
|
26
|
+
'content-type': 'application/json; charset=utf-8',
|
|
27
|
+
'cache-control': 'no-store',
|
|
28
|
+
'content-length': Buffer.byteLength(body),
|
|
29
|
+
})
|
|
30
|
+
res.end(body)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJsonBody(req) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks = []
|
|
36
|
+
let size = 0
|
|
37
|
+
req.on('data', (chunk) => {
|
|
38
|
+
size += chunk.length
|
|
39
|
+
if (size > MAX_BODY_BYTES) {
|
|
40
|
+
reject(new SessionSyncError('too-large', '会话数据超过 256MB 上限', 413))
|
|
41
|
+
req.destroy()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
chunks.push(chunk)
|
|
45
|
+
})
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
if (size === 0) {
|
|
48
|
+
resolve({})
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
53
|
+
} catch {
|
|
54
|
+
reject(new SessionSyncError('bad-json', '请求体不是合法 JSON', 400))
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
req.on('error', (error) => reject(error))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class LanServer {
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {number} options.port - preferred port; next free ports are tried.
|
|
65
|
+
* @param {string} [options.token] - optional shared secret.
|
|
66
|
+
* @param {import('./sessions.js').SessionBridge} options.bridge
|
|
67
|
+
* @param {() => object} options.info - identity fields for /v1/info.
|
|
68
|
+
* @param {(message: string) => void} [options.log]
|
|
69
|
+
*/
|
|
70
|
+
constructor({ port, token, bridge, info, duplicateOf, log }) {
|
|
71
|
+
this.preferredPort = port
|
|
72
|
+
this.port = null
|
|
73
|
+
this.token = token ?? null
|
|
74
|
+
this.bridge = bridge
|
|
75
|
+
this.info = info
|
|
76
|
+
this.duplicateOf = duplicateOf
|
|
77
|
+
this.log = log ?? (() => {})
|
|
78
|
+
this.server = null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async start() {
|
|
82
|
+
if (this.server !== null) return this.port
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
this._handle(req, res).catch((error) => {
|
|
85
|
+
const status = error instanceof SessionSyncError ? error.status : 500
|
|
86
|
+
const code = error instanceof SessionSyncError ? error.code : 'internal'
|
|
87
|
+
if (!(error instanceof SessionSyncError)) {
|
|
88
|
+
this.log(`同步请求失败: ${error?.stack ?? error}`)
|
|
89
|
+
}
|
|
90
|
+
sendJson(res, status, { ok: false, error: { code, message: String(error?.message ?? error) } })
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
server.on('clientError', (_error, socket) => {
|
|
94
|
+
try {
|
|
95
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
|
|
96
|
+
} catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
this.server = server
|
|
101
|
+
const base = Number.isInteger(this.preferredPort) && this.preferredPort >= 0 ? this.preferredPort : 47802
|
|
102
|
+
if (base === 0) {
|
|
103
|
+
// Explicit ephemeral port (tests, or a NAT'd host that forwards).
|
|
104
|
+
await listen(server, 0)
|
|
105
|
+
this.port = server.address().port
|
|
106
|
+
this.log(`局域网同步服务已监听 0.0.0.0:${this.port}`)
|
|
107
|
+
return this.port
|
|
108
|
+
}
|
|
109
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
110
|
+
const candidate = base + attempt
|
|
111
|
+
if (candidate > 65535) break
|
|
112
|
+
try {
|
|
113
|
+
await listen(server, candidate)
|
|
114
|
+
this.port = candidate
|
|
115
|
+
this.log(`局域网同步服务已监听 0.0.0.0:${candidate}`)
|
|
116
|
+
return candidate
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error?.code !== 'EADDRINUSE') throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`端口 ${base} 起连续 16 个端口都被占用,无法启动局域网同步服务`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _handle(req, res) {
|
|
125
|
+
const url = new URL(req.url ?? '/', 'http://dsh-session-sync.internal')
|
|
126
|
+
if (req.method === 'OPTIONS') {
|
|
127
|
+
res.writeHead(204, {
|
|
128
|
+
'access-control-allow-origin': '*',
|
|
129
|
+
'access-control-allow-headers': `${SYNC_HEADER}, content-type, x-dsh-session-sync-token`,
|
|
130
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
131
|
+
})
|
|
132
|
+
res.end()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (url.pathname === '/v1/health') {
|
|
136
|
+
sendJson(res, 200, { ok: true, service: 'dsh-session-sync', proto: 1 })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (req.headers[SYNC_HEADER] === undefined) {
|
|
140
|
+
throw new SessionSyncError('unauthorized', '缺少同步请求头', 401)
|
|
141
|
+
}
|
|
142
|
+
if (this.token !== null && req.headers['x-dsh-session-sync-token'] !== this.token) {
|
|
143
|
+
throw new SessionSyncError('unauthorized', '同步口令不匹配', 401)
|
|
144
|
+
}
|
|
145
|
+
if (url.pathname === '/v1/info') {
|
|
146
|
+
sendJson(res, 200, { ok: true, ...this.info() })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (url.pathname === '/v1/sessions' && req.method === 'GET') {
|
|
150
|
+
const limit = Number(url.searchParams.get('limit') ?? '200')
|
|
151
|
+
const sessions = await this.bridge.list({ limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200 })
|
|
152
|
+
sendJson(res, 200, { ok: true, sessions })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (url.pathname.startsWith('/v1/sessions/') && req.method === 'GET') {
|
|
156
|
+
const id = decodeURIComponent(url.pathname.slice('/v1/sessions/'.length))
|
|
157
|
+
if (id.length === 0 || id.includes('/')) throw new SessionSyncError('bad-id', '会话 id 无效', 400)
|
|
158
|
+
const payload = await this.bridge.exportSession(id)
|
|
159
|
+
sendJson(res, 200, { ok: true, ...payload })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (url.pathname === '/v1/sessions' && req.method === 'POST') {
|
|
163
|
+
const body = await readJsonBody(req)
|
|
164
|
+
// Dedupe before writing: the same conversation pushed twice (or pushed
|
|
165
|
+
// back after being pulled) must not become a second local copy. Callers
|
|
166
|
+
// that really want a copy ask for it explicitly with `?force=1`.
|
|
167
|
+
const force = url.searchParams.get('force') === '1'
|
|
168
|
+
if (!force && Array.isArray(body?.events)) {
|
|
169
|
+
const signature = sessionSignature(body.session, body.events)
|
|
170
|
+
const duplicate = this.duplicateOf?.(signature) ?? null
|
|
171
|
+
if (duplicate !== null && duplicate !== undefined) {
|
|
172
|
+
sendJson(res, 200, { ok: true, skipped: true, id: duplicate, signature })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const imported = await this.bridge.importSession(body)
|
|
177
|
+
this.log(`已接收来自 ${req.socket.remoteAddress} 的会话 → ${imported.id}`)
|
|
178
|
+
sendJson(res, 200, { ok: true, ...imported })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
throw new SessionSyncError('not-found', `未知接口: ${req.method} ${url.pathname}`, 404)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
close() {
|
|
185
|
+
const server = this.server
|
|
186
|
+
this.server = null
|
|
187
|
+
this.port = null
|
|
188
|
+
if (server !== null) {
|
|
189
|
+
try {
|
|
190
|
+
server.close()
|
|
191
|
+
server.closeAllConnections?.()
|
|
192
|
+
} catch {
|
|
193
|
+
/* ignore */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function listen(server, port) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const onError = (error) => {
|
|
202
|
+
server.off('listening', onListening)
|
|
203
|
+
reject(error)
|
|
204
|
+
}
|
|
205
|
+
const onListening = () => {
|
|
206
|
+
server.off('error', onError)
|
|
207
|
+
resolve()
|
|
208
|
+
}
|
|
209
|
+
server.once('error', onError)
|
|
210
|
+
server.once('listening', onListening)
|
|
211
|
+
server.listen(port, '0.0.0.0')
|
|
212
|
+
})
|
|
213
|
+
}
|
package/lib/logfile.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline helpers for the JSONL session logs DSH keeps in `$DSH_HOME/sessions`.
|
|
3
|
+
*
|
|
4
|
+
* `zlib.zstdDecompressSync` only decodes the *first* Zstandard frame of a file,
|
|
5
|
+
* and DSH appends one frame per write batch, so reading a stored log needs a
|
|
6
|
+
* frame walker first. Writing back emits a single frame, which every decoder
|
|
7
|
+
* (including DSH's streaming reader) accepts.
|
|
8
|
+
*/
|
|
9
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
const MAGIC = 0xfd2fb528
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split a buffer into its concatenated Zstandard frames.
|
|
15
|
+
*
|
|
16
|
+
* @param {Buffer} buffer - raw `*.jsonl.zstd` content.
|
|
17
|
+
* @returns {Buffer[]} one slice per frame, in file order.
|
|
18
|
+
*/
|
|
19
|
+
export function splitZstdFrames(buffer) {
|
|
20
|
+
const frames = []
|
|
21
|
+
let offset = 0
|
|
22
|
+
while (offset + 4 <= buffer.length) {
|
|
23
|
+
const start = offset
|
|
24
|
+
if (buffer.readUInt32LE(offset) !== MAGIC) {
|
|
25
|
+
// A torn tail (crash mid-write) is the only legitimate reason to stop;
|
|
26
|
+
// keep what was decoded so far instead of refusing the whole log.
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
offset += 4
|
|
30
|
+
const descriptor = buffer.readUInt8(offset)
|
|
31
|
+
offset += 1
|
|
32
|
+
const fcsFlag = descriptor >> 6
|
|
33
|
+
const singleSegment = (descriptor & 0x20) !== 0
|
|
34
|
+
const checksum = (descriptor & 0x04) !== 0
|
|
35
|
+
const dictFlag = descriptor & 0x03
|
|
36
|
+
const dictSize = dictFlag === 0 ? 0 : dictFlag === 1 ? 1 : dictFlag === 2 ? 2 : 4
|
|
37
|
+
if (singleSegment === false) offset += 1
|
|
38
|
+
offset += dictSize
|
|
39
|
+
offset += fcsFlag === 0 ? (singleSegment ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (offset + 3 > buffer.length) return frames
|
|
42
|
+
const header = buffer.readUIntLE(offset, 3)
|
|
43
|
+
offset += 3
|
|
44
|
+
const last = (header & 0x01) !== 0
|
|
45
|
+
const type = (header >> 1) & 0x03
|
|
46
|
+
const size = header >> 3
|
|
47
|
+
if (type === 0) offset += size
|
|
48
|
+
else if (type === 1) offset += 1
|
|
49
|
+
else if (type === 2) offset += size
|
|
50
|
+
else throw new Error('zstd frame uses a reserved block type')
|
|
51
|
+
if (offset > buffer.length) return frames
|
|
52
|
+
if (last) break
|
|
53
|
+
}
|
|
54
|
+
if (checksum) offset += 4
|
|
55
|
+
frames.push(buffer.subarray(start, Math.min(offset, buffer.length)))
|
|
56
|
+
if (offset >= buffer.length) break
|
|
57
|
+
}
|
|
58
|
+
return frames
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decode a whole stored log (all frames) into its JSONL text.
|
|
63
|
+
*
|
|
64
|
+
* @param {Buffer} buffer - raw `*.jsonl.zstd` content.
|
|
65
|
+
* @returns {string} newline-delimited JSON text without a trailing newline requirement.
|
|
66
|
+
*/
|
|
67
|
+
export function decodeStoredLog(buffer) {
|
|
68
|
+
const parts = splitZstdFrames(buffer).map((frame) => zstdDecompressSync(frame))
|
|
69
|
+
return Buffer.concat(parts).toString('utf8')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encode JSONL text as a single Zstandard frame.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} text - newline-delimited JSON.
|
|
76
|
+
* @returns {Buffer} compressed bytes ready to write as `session.v4.jsonl.zstd`.
|
|
77
|
+
*/
|
|
78
|
+
export function encodeStoredLog(text) {
|
|
79
|
+
return zstdCompressSync(Buffer.from(text, 'utf8'))
|
|
80
|
+
}
|
package/lib/probe.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet HTTP fallback for networks where UDP broadcast (and even unicast UDP)
|
|
3
|
+
* cannot cross a router or a firewall.
|
|
4
|
+
*
|
|
5
|
+
* Only *explicit* targets are probed — a bare IP from the "cross-subnet" box or
|
|
6
|
+
* a `host:port` from the manual list — never a whole CIDR: 254 hosts times a
|
|
7
|
+
* port range would be a port scan, not a discovery handshake.
|
|
8
|
+
*
|
|
9
|
+
* The probe is a normal `GET /v1/info` against the same API every peer already
|
|
10
|
+
* exposes on the LAN; the reply carries the device id, so the rest of the
|
|
11
|
+
* plugin can treat an HTTP-discovered device exactly like a broadcast one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Sync port the plugin starts on, plus the ports it falls back to (16 tries). */
|
|
15
|
+
export const SYNC_PORT_BASE = 47802
|
|
16
|
+
export const SYNC_PORT_TRIES = 16
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SYNC_PORTS = Array.from({ length: SYNC_PORT_TRIES }, (_, index) => SYNC_PORT_BASE + index)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Split `192.168.1.20:47802` into host and port. IPv6 literals in brackets are
|
|
22
|
+
* rejected on purpose: the LAN API is IPv4-only (the discovery socket is udp4).
|
|
23
|
+
*
|
|
24
|
+
* @param {string} spec
|
|
25
|
+
* @returns {{host: string, port: number|null} | null}
|
|
26
|
+
*/
|
|
27
|
+
export function parseTarget(spec) {
|
|
28
|
+
const text = String(spec ?? '').trim()
|
|
29
|
+
if (text.length === 0) return null
|
|
30
|
+
const match = /^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?$/.exec(text)
|
|
31
|
+
if (match === null) return null
|
|
32
|
+
const host = match[1]
|
|
33
|
+
const parts = host.split('.').map(Number)
|
|
34
|
+
if (parts.some((part) => part < 0 || part > 255)) return null
|
|
35
|
+
if (match[2] === undefined) return { host, port: null }
|
|
36
|
+
const port = Number(match[2])
|
|
37
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
38
|
+
return { host, port }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Try the candidate ports of one host and return the first `/v1/info` answer.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} host
|
|
45
|
+
* @param {number[]} ports
|
|
46
|
+
* @param {{token?: string|null, timeoutMs?: number, header?: string}} options
|
|
47
|
+
* @returns {Promise<{host: string, port: number, info: object} | null>}
|
|
48
|
+
*/
|
|
49
|
+
export async function probeHost(host, ports, options = {}) {
|
|
50
|
+
const timeoutMs = options.timeoutMs ?? 450
|
|
51
|
+
const headers = { 'x-dsh-session-sync': '1' }
|
|
52
|
+
if (typeof options.token === 'string' && options.token.length > 0) headers['x-dsh-session-sync-token'] = options.token
|
|
53
|
+
// Ports are tried concurrently: a filtered port stalls the full timeout, so
|
|
54
|
+
// sequential probing of 16 ports would take 7s on a quiet host.
|
|
55
|
+
const attempt = async (port) => {
|
|
56
|
+
const controller = new AbortController()
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
58
|
+
timer.unref?.()
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(`http://${host}:${port}/v1/info`, { headers, signal: controller.signal })
|
|
61
|
+
if (!response.ok) return null
|
|
62
|
+
const info = await response.json()
|
|
63
|
+
if (info !== null && typeof info === 'object' && typeof info.id === 'string' && info.id.length > 0) {
|
|
64
|
+
return { host, port, info }
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const settled = await Promise.allSettled(ports.map((port) => attempt(port)))
|
|
74
|
+
for (const entry of settled) {
|
|
75
|
+
if (entry.status === 'fulfilled' && entry.value !== null) return entry.value
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Probe a list of explicit targets, stopping at the first answer per host.
|
|
82
|
+
* Sequential on purpose: a handful of addresses, and we do not want a burst.
|
|
83
|
+
*
|
|
84
|
+
* @param {Array<{host: string, port: number|null}>} targets
|
|
85
|
+
* @param {{token?: string|null, timeoutMs?: number, ports?: number[]}} options
|
|
86
|
+
* @returns {Promise<Array<{host: string, port: number, info: object}>>}
|
|
87
|
+
*/
|
|
88
|
+
export async function probeTargets(targets, options = {}) {
|
|
89
|
+
const ports = options.ports ?? DEFAULT_SYNC_PORTS
|
|
90
|
+
const found = []
|
|
91
|
+
const seen = new Set()
|
|
92
|
+
for (const target of targets) {
|
|
93
|
+
if (target === null || typeof target.host !== 'string') continue
|
|
94
|
+
if (seen.has(target.host)) continue
|
|
95
|
+
seen.add(target.host)
|
|
96
|
+
const result = await probeHost(target.host, target.port === null ? ports : [target.port], options)
|
|
97
|
+
if (result !== null) found.push(result)
|
|
98
|
+
}
|
|
99
|
+
return found
|
|
100
|
+
}
|
package/lib/repair.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair session logs that an older plugin build imported incorrectly.
|
|
3
|
+
*
|
|
4
|
+
* Those copies carried the source's inherited `session/end-seed` marker into an
|
|
5
|
+
* unseeded header, so DSH rejects them with
|
|
6
|
+
* "released v2 unseeded Session contains an inherited end-seed marker".
|
|
7
|
+
* Dropping the inherited prefix (the source's own conversation is kept) and
|
|
8
|
+
* renumbering `seq` makes the log valid again.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { decodeStoredLog, encodeStoredLog } from './logfile.js'
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ dropped: number, events: number }} RepairReport */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Remove an inherited prefix from decoded JSONL text.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} text - decoded log including its header line.
|
|
21
|
+
* @returns {{ text: string, report: RepairReport } | null} null when nothing needs repair.
|
|
22
|
+
*/
|
|
23
|
+
export function repairLogText(text) {
|
|
24
|
+
const lines = text.split('\n').filter((line) => line.length > 0)
|
|
25
|
+
if (lines.length < 2) return null
|
|
26
|
+
let header
|
|
27
|
+
try {
|
|
28
|
+
header = JSON.parse(lines[0])
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
if (header?.type !== 'session' || header.version !== 4) return null
|
|
33
|
+
if (header.isSeeded === true) return null
|
|
34
|
+
|
|
35
|
+
let cut = -1
|
|
36
|
+
for (const [index, line] of lines.slice(1).entries()) {
|
|
37
|
+
let event
|
|
38
|
+
try {
|
|
39
|
+
event = JSON.parse(line)
|
|
40
|
+
} catch {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (event?.type === 'session/end-seed' && event.data?.inherited === true) cut = index
|
|
44
|
+
}
|
|
45
|
+
if (cut < 0) return null
|
|
46
|
+
|
|
47
|
+
const kept = lines.slice(cut + 2).map((line, index) => {
|
|
48
|
+
const event = JSON.parse(line)
|
|
49
|
+
return JSON.stringify({ ...event, seq: index })
|
|
50
|
+
})
|
|
51
|
+
return {
|
|
52
|
+
text: [JSON.stringify(header), ...kept].join('\n') + '\n',
|
|
53
|
+
report: { dropped: cut + 1, events: kept.length },
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every `session*.jsonl.zstd` under a sessions root, with its lock state.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} root - `$DSH_HOME/sessions`.
|
|
61
|
+
* @returns {{ file: string, dir: string, locked: boolean }[]}
|
|
62
|
+
*/
|
|
63
|
+
export function listStoredLogs(root) {
|
|
64
|
+
const found = []
|
|
65
|
+
let slugs = []
|
|
66
|
+
try {
|
|
67
|
+
slugs = fs.readdirSync(root, { withFileTypes: true })
|
|
68
|
+
} catch {
|
|
69
|
+
return found
|
|
70
|
+
}
|
|
71
|
+
for (const slug of slugs) {
|
|
72
|
+
if (!slug.isDirectory()) continue
|
|
73
|
+
const slugDir = path.join(root, slug.name)
|
|
74
|
+
let sessions = []
|
|
75
|
+
try {
|
|
76
|
+
sessions = fs.readdirSync(slugDir, { withFileTypes: true })
|
|
77
|
+
} catch {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
for (const session of sessions) {
|
|
81
|
+
if (!session.isDirectory()) continue
|
|
82
|
+
const dir = path.join(slugDir, session.name)
|
|
83
|
+
let files = []
|
|
84
|
+
try {
|
|
85
|
+
files = fs.readdirSync(dir)
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const file = files.find((name) => name.endsWith('.jsonl.zstd'))
|
|
90
|
+
if (file === undefined) continue
|
|
91
|
+
found.push({ file: path.join(dir, file), dir, locked: files.includes('session.lock') })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return found
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Repair one stored log in place, keeping a `*.bak` next to it.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} file - path to `session.v4.jsonl.zstd`.
|
|
101
|
+
* @param {{ dryRun?: boolean }} [options]
|
|
102
|
+
* @returns {RepairReport | null} null when the log needed no repair.
|
|
103
|
+
*/
|
|
104
|
+
export function repairStoredLog(file, options = {}) {
|
|
105
|
+
const raw = fs.readFileSync(file)
|
|
106
|
+
let text
|
|
107
|
+
try {
|
|
108
|
+
text = decodeStoredLog(raw)
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
const repaired = repairLogText(text)
|
|
113
|
+
if (repaired === null) return null
|
|
114
|
+
if (options.dryRun === true) return repaired.report
|
|
115
|
+
fs.writeFileSync(`${file}.bak`, raw)
|
|
116
|
+
const tmp = `${file}.tmp`
|
|
117
|
+
fs.writeFileSync(tmp, encodeStoredLog(repaired.text))
|
|
118
|
+
fs.renameSync(tmp, file)
|
|
119
|
+
return repaired.report
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Repair every unlocked stored log under a sessions root.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} root - `$DSH_HOME/sessions`.
|
|
126
|
+
* @param {{ dryRun?: boolean, log?: (message: string) => void }} [options]
|
|
127
|
+
* @returns {{ scanned: number, repaired: number, locked: number }}
|
|
128
|
+
*/
|
|
129
|
+
export function repairStoredSessions(root, options = {}) {
|
|
130
|
+
const log = options.log ?? (() => {})
|
|
131
|
+
const summary = { scanned: 0, repaired: 0, locked: 0 }
|
|
132
|
+
for (const entry of listStoredLogs(root)) {
|
|
133
|
+
summary.scanned += 1
|
|
134
|
+
if (entry.locked) summary.locked += 1
|
|
135
|
+
// `session.lock` is an empty marker DSH leaves behind, not a reliable
|
|
136
|
+
// "someone is writing" signal — and a corrupt log cannot be open anyway,
|
|
137
|
+
// because opening it fails validation. Repair it and keep the backup.
|
|
138
|
+
let report = null
|
|
139
|
+
try {
|
|
140
|
+
report = repairStoredLog(entry.file, { dryRun: options.dryRun })
|
|
141
|
+
} catch (error) {
|
|
142
|
+
log(`修复失败 ${entry.file}: ${error?.message ?? error}`)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (report === null) continue
|
|
146
|
+
summary.repaired += 1
|
|
147
|
+
log(`已修复 ${path.basename(entry.dir)}:丢弃继承前缀 ${report.dropped} 条,保留 ${report.events} 条`)
|
|
148
|
+
}
|
|
149
|
+
return summary
|
|
150
|
+
}
|