dsh-llm-fallbacks 0.4.4 → 0.5.1
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/README.md +86 -25
- package/README.zh-CN.md +86 -25
- package/dist/client/SubagentRoleBadge.d.ts +40 -15
- package/dist/client/fallbacks-store.d.ts +0 -53
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +77 -150
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +2 -2
- package/dist/gateway.d.ts +2 -55
- package/dist/index.d.ts +6 -27
- package/dist/index.js +713 -129
- package/dist/role-notice.d.ts +156 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +151 -0
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/subagents-seam.d.ts +292 -0
- package/dist/time-slots.d.ts +3 -2
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +27 -25
- package/scripts/build-client.ts +1 -1
- package/scripts/repair-session-logs.ts +2040 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +583 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -342
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish.ts — publish a V3 successor generation beside a pre-V3 session log.
|
|
3
|
+
*
|
|
4
|
+
* A session log is a concatenated-Zstandard-frame container. This module owns
|
|
5
|
+
* the physical framing of the successor, the fail-closed publication order, and
|
|
6
|
+
* the read-back proof that makes `{ verified: true }` honest.
|
|
7
|
+
*
|
|
8
|
+
* FRAMING (SSOT: `@deepseek-ai/dsh-session-persistence-jsonl`, pinned checkout
|
|
9
|
+
* `packages/session/session-persistence-jsonl/src/{zstd.ts,generation.ts,format.ts}`):
|
|
10
|
+
* - frame 1 decodes to EXACTLY ONE header line (the released reader asserts
|
|
11
|
+
* `plaintext.indexOf(0x0A) === plaintext.length - 1` for frame 1);
|
|
12
|
+
* - every following frame carries an event batch;
|
|
13
|
+
* - every frame is independently decodable and checksummed
|
|
14
|
+
* (`ZSTD_c_checksumFlag`), exactly like the released writer.
|
|
15
|
+
* The successor's records come from the RELEASED encoder
|
|
16
|
+
* (`encodeCurrentHeader` / `encodeCurrentEvent`), not from the input rows, so
|
|
17
|
+
* the written generation is whatever the catalog considers current.
|
|
18
|
+
*
|
|
19
|
+
* COMPRESSION — Node's `node:zlib` zstd API, no `zstd` CLI (measured on
|
|
20
|
+
* v24.18.0: `zstdCompress`/`zstdDecompress` exist and are used here):
|
|
21
|
+
* - writing uses `zstdCompressSync` with the checksum parameter, the same
|
|
22
|
+
* frame the released writer emits;
|
|
23
|
+
* - READ-BACK needs a frame splitter, because Node's public zstd API decodes
|
|
24
|
+
* only the FIRST frame of a concatenated stream and silently ignores the
|
|
25
|
+
* rest (verified in this environment; the released package hits the same
|
|
26
|
+
* limit and therefore ships its own `scanZstdFrames` + per-frame decoder).
|
|
27
|
+
* The superseded `scripts/repair-fallbacks-switch-logs.ts` shelled out to the
|
|
28
|
+
* `zstd` CLI for the same reason; that would put a host binary on the success
|
|
29
|
+
* path, so this module keeps the pure-Node scanner instead. Cost: the tool
|
|
30
|
+
* requires Node >= 22.15 (the release that added `node:zlib` zstd); the repo
|
|
31
|
+
* runtime floor is Node 22 and the pinned harness runs Node 24.
|
|
32
|
+
*
|
|
33
|
+
* FAIL-CLOSED PUBLICATION ORDER (`publishSuccessor`):
|
|
34
|
+
* 1. refuse without a resolved catalog (no oracle -> no write);
|
|
35
|
+
* 2. read the original and refuse when it no longer matches the digest of the
|
|
36
|
+
* revision the CALLER decoded its rows from — compared BEFORE anything is
|
|
37
|
+
* staged, so a concurrent append can never be published as a verified
|
|
38
|
+
* successor;
|
|
39
|
+
* 3. take a defensive copy of the caller's rows — they alias `form` /
|
|
40
|
+
* `summary` / `sections` objects owned by the caller, and the released
|
|
41
|
+
* restore is documented as validating an artifact it may mutate in place;
|
|
42
|
+
* 4. re-run the released restore under the STRICT recovery policy: a repaired
|
|
43
|
+
* log must not silently truncate at a swallowed refusal (the recoverable
|
|
44
|
+
* policy classification uses records the first issue and drops the rows
|
|
45
|
+
* after it until a `turn/end` row surfaces it);
|
|
46
|
+
* 5. write to a noncanonical temporary name, verify it by reading the frames
|
|
47
|
+
* back through the same catalog with `validation: 'current'` (STRONGER than
|
|
48
|
+
* the host's own header restore, which runs `{ recovery: 'strict',
|
|
49
|
+
* validation: 'transformed' }`), then publish it exclusively with `link` so
|
|
50
|
+
* an existing successor is never overwritten with different bytes; the
|
|
51
|
+
* result records whether this call CREATED the target or ACCEPTED an
|
|
52
|
+
* already-identical one;
|
|
53
|
+
* 6. remove the temporary and re-verify the original's digest. When the source
|
|
54
|
+
* moved after a publication this call CREATED, the successor is unlinked
|
|
55
|
+
* again (the store is left as found) and the failure says so. When it moved
|
|
56
|
+
* after ACCEPTING a pre-existing identical successor, that file is left
|
|
57
|
+
* alone, the failure names it, and it is raised as
|
|
58
|
+
* {@link PublishedFromStaleSourceError} so no caller can report "nothing was
|
|
59
|
+
* published".
|
|
60
|
+
* A failure before step 5 writes nothing canonical; the temporary is removed on
|
|
61
|
+
* every error path this process handles (a SIGKILL or a failing `rm` can still
|
|
62
|
+
* strand it, which is why discovery reports such names).
|
|
63
|
+
*/
|
|
64
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
65
|
+
import { link, readFile, rm, writeFile } from 'node:fs/promises'
|
|
66
|
+
import { dirname, join, resolve } from 'node:path'
|
|
67
|
+
import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
68
|
+
import type { CatalogHandle, ReleasedArtifact, ReleasedCatalog } from './catalog.ts'
|
|
69
|
+
import { restoreRows } from './catalog.ts'
|
|
70
|
+
import type { ParsedRow } from './rules.ts'
|
|
71
|
+
|
|
72
|
+
/** Zstandard frame magic (little-endian `0xFD2FB528`). */
|
|
73
|
+
const ZSTD_MAGIC = 0xFD2FB528
|
|
74
|
+
|
|
75
|
+
/** The released writer's checksummed frame options (`ZSTD_c_checksumFlag`). */
|
|
76
|
+
const ZSTD_CHECKSUM_OPTIONS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } }
|
|
77
|
+
|
|
78
|
+
/** What one successful publication did with the canonical target. */
|
|
79
|
+
export type PublicationOutcome = 'created' | 'accepted'
|
|
80
|
+
|
|
81
|
+
/** Result of one successful publication. */
|
|
82
|
+
export interface PublishedSuccessor {
|
|
83
|
+
/** Generation the successor carries (the restored artifact header version). */
|
|
84
|
+
generation: number
|
|
85
|
+
/** Always `true`: the successor was read back through the catalog. */
|
|
86
|
+
verified: true
|
|
87
|
+
/** Canonical path of the published successor (actionable for rollback). */
|
|
88
|
+
targetPath: string
|
|
89
|
+
/** `created` when this call linked the target, `accepted` when identical bytes were already there. */
|
|
90
|
+
outcome: PublicationOutcome
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A publication that HAPPENED but whose source generation moved afterwards: the
|
|
95
|
+
* successor is on disk (and the host will prefer it) while the rows it was built
|
|
96
|
+
* from are a stale snapshot. Raised instead of a plain error so no caller can
|
|
97
|
+
* report "nothing was published" and so the message can name the file to delete.
|
|
98
|
+
*/
|
|
99
|
+
export class PublishedFromStaleSourceError extends Error {
|
|
100
|
+
constructor(
|
|
101
|
+
message: string,
|
|
102
|
+
/** Canonical successor left on disk; deleting it rolls the publication back. */
|
|
103
|
+
readonly successorPath: string,
|
|
104
|
+
/** Whether this call created it or accepted an identical pre-existing one. */
|
|
105
|
+
readonly outcome: PublicationOutcome,
|
|
106
|
+
) {
|
|
107
|
+
super(message)
|
|
108
|
+
this.name = 'PublishedFromStaleSourceError'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The largest plaintext this decoder will materialize for ONE Zstandard frame.
|
|
114
|
+
*
|
|
115
|
+
* The frame scanner already reads the declared content size, so a frame whose
|
|
116
|
+
* declaration exceeds this ceiling is refused WITHOUT decompressing it, and the
|
|
117
|
+
* decoder is capped at the declared size (or at this ceiling when the frame
|
|
118
|
+
* declares none) so a lying frame cannot expand past what it promised.
|
|
119
|
+
*
|
|
120
|
+
* What the ceiling actually bounds is a session's TOTAL plaintext, not one event:
|
|
121
|
+
* `encodeZstdFrames` batches every event of a generation into a SINGLE frame, so a
|
|
122
|
+
* successor's event frame grows with the whole session. Measured on this machine:
|
|
123
|
+
* 13.9 MB is the largest COMPRESSED log file (not a frame), while the largest frame
|
|
124
|
+
* the host actually decodes is 21.73 MiB — this tool's own successor for that log —
|
|
125
|
+
* so the ceiling carries ~23.6x headroom. A session whose total plaintext
|
|
126
|
+
* legitimately exceeded it would be refused as `decompress-failed` with a coded
|
|
127
|
+
* exit instead of exhausting memory.
|
|
128
|
+
*/
|
|
129
|
+
export const MAX_FRAME_PLAINTEXT_BYTES = 512 * 1024 * 1024
|
|
130
|
+
|
|
131
|
+
/** One structurally complete Zstandard frame inside a concatenated container. */
|
|
132
|
+
interface ZstdFrameRange {
|
|
133
|
+
/** Inclusive frame start. */
|
|
134
|
+
start: number
|
|
135
|
+
/** Exclusive frame end. */
|
|
136
|
+
end: number
|
|
137
|
+
/** Content size the frame header declares, or `null` when it declares none. */
|
|
138
|
+
declaredBytes: number | null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/* physical framing */
|
|
143
|
+
/* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
/** Serialize one record as one JSONL line, refusing a non-JSON value. */
|
|
146
|
+
function recordLine(record: Record<string, unknown>, subject: string): string {
|
|
147
|
+
const text: unknown = JSON.stringify(record)
|
|
148
|
+
if (typeof text !== 'string') throw new Error(`${subject} is not lossless JSON`)
|
|
149
|
+
return `${text}\n`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encode one successor generation as a concatenated-frame container: the first
|
|
154
|
+
* frame holds EXACTLY the header line, the second (when the log has events)
|
|
155
|
+
* holds the event batch.
|
|
156
|
+
*
|
|
157
|
+
* The event batch is folded one line at a time into a byte chunk list: each
|
|
158
|
+
* event's JSON string is dropped as soon as its chunk exists, so those strings
|
|
159
|
+
* never accumulate. The chunk list is then concatenated ONCE for the compressor,
|
|
160
|
+
* so the batch's whole plaintext DOES exist at that moment — as that single
|
|
161
|
+
* contiguous buffer, which is the batch's peak (the per-record chunks it was built
|
|
162
|
+
* from are still referenced by the list while it lives). What the fold avoids is a
|
|
163
|
+
* SECOND copy of the batch, not every copy: no `join('')` string is built beside
|
|
164
|
+
* the chunks. The bytes are the same ones a `join('')` + encode would produce.
|
|
165
|
+
*
|
|
166
|
+
* @param headerRecord released current header record.
|
|
167
|
+
* @param eventRecords released current event records, in log order.
|
|
168
|
+
* @returns the complete container bytes.
|
|
169
|
+
*/
|
|
170
|
+
export function encodeZstdFrames(
|
|
171
|
+
headerRecord: Record<string, unknown>,
|
|
172
|
+
eventRecords: Iterable<Record<string, unknown>>,
|
|
173
|
+
): Buffer {
|
|
174
|
+
const frames = [compressFrame(recordLine(headerRecord, 'session header'))]
|
|
175
|
+
const batch: Buffer[] = []
|
|
176
|
+
let index = 0
|
|
177
|
+
for (const record of eventRecords) {
|
|
178
|
+
batch.push(Buffer.from(recordLine(record, `session event ${index}`), 'utf8'))
|
|
179
|
+
index += 1
|
|
180
|
+
}
|
|
181
|
+
if (batch.length > 0) frames.push(compressFrame(Buffer.concat(batch)))
|
|
182
|
+
return Buffer.concat(frames)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Decode every frame of one concatenated container, in file order, ONE FRAME AT A
|
|
187
|
+
* TIME: the consumer receives frame N's plaintext and decides when to ask for frame
|
|
188
|
+
* N+1, so this generator holds ONE frame's plaintext at a time and never two. That
|
|
189
|
+
* bounds the FRAME, not the session: the event batch is a single frame, so it IS the
|
|
190
|
+
* session's whole event plaintext (see {@link MAX_FRAME_PLAINTEXT_BYTES}). The frame
|
|
191
|
+
* STRUCTURE (the scanner) is still walked before the first frame's plaintext is
|
|
192
|
+
* produced, so a torn or corrupt container is refused up front.
|
|
193
|
+
*
|
|
194
|
+
* Bounded: the scanner reports each frame's DECLARED content size, a declaration
|
|
195
|
+
* above {@link MAX_FRAME_PLAINTEXT_BYTES} is refused before any decompression, and
|
|
196
|
+
* the decompressor itself is capped so a frame that lies about its size cannot
|
|
197
|
+
* expand past its own promise. A refusal here is a `decompress-failed` log (a
|
|
198
|
+
* coded exit), never an out-of-memory crash.
|
|
199
|
+
*
|
|
200
|
+
* @param bytes container bytes (an original log or a published generation).
|
|
201
|
+
* @yields the plaintext of each frame, in file order.
|
|
202
|
+
*/
|
|
203
|
+
export function* decodeZstdFrameTexts(bytes: Buffer): Generator<string> {
|
|
204
|
+
for (const [index, { start, end, declaredBytes }] of scanZstdFrames(bytes).entries()) {
|
|
205
|
+
if (declaredBytes !== null && declaredBytes > MAX_FRAME_PLAINTEXT_BYTES) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`refusing to decompress Zstandard frame ${index} at byte ${start}: it declares ${declaredBytes} bytes of `
|
|
208
|
+
+ `plaintext, above this tool's ${MAX_FRAME_PLAINTEXT_BYTES}-byte frame ceiling`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
let plaintext: Buffer
|
|
212
|
+
try {
|
|
213
|
+
plaintext = zstdDecompressSync(bytes.subarray(start, end), {
|
|
214
|
+
// A frame may legitimately declare zero bytes (an empty batch), and
|
|
215
|
+
// `maxOutputLength` must be at least 1.
|
|
216
|
+
maxOutputLength: Math.max(declaredBytes ?? MAX_FRAME_PLAINTEXT_BYTES, 1),
|
|
217
|
+
})
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw new Error(`corrupt Zstandard session log: frame ${index} at byte ${start} failed to decode`, { cause: error })
|
|
220
|
+
}
|
|
221
|
+
if (declaredBytes !== null && plaintext.length !== declaredBytes) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`corrupt Zstandard session log: frame ${index} at byte ${start} declares ${declaredBytes} bytes but decoded `
|
|
224
|
+
+ `${plaintext.length}`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
yield plaintext.toString('utf8')
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Materializing convenience over {@link decodeZstdFrameTexts}: every frame's
|
|
233
|
+
* plaintext as one array. Specification code uses it as the independent read-back
|
|
234
|
+
* oracle; nothing on the tool's own path needs the whole container in memory, so
|
|
235
|
+
* production code consumes the generator instead.
|
|
236
|
+
*
|
|
237
|
+
* @param bytes container bytes.
|
|
238
|
+
* @returns the plaintext of each frame, in file order.
|
|
239
|
+
*/
|
|
240
|
+
export function decodeZstdFrames(bytes: Buffer): string[] {
|
|
241
|
+
return [...decodeZstdFrameTexts(bytes)]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Compress one frame with the released writer's checksummed options. */
|
|
245
|
+
function compressFrame(text: string | Buffer): Buffer {
|
|
246
|
+
return zstdCompressSync(typeof text === 'string' ? Buffer.from(text, 'utf8') : text, ZSTD_CHECKSUM_OPTIONS)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Locate every complete frame without decompressing its blocks, so a torn or
|
|
251
|
+
* corrupt container is refused before any row is trusted. Adapted from the
|
|
252
|
+
* released `scanZstdFrames` (see the module docblock); unlike the released
|
|
253
|
+
* metadata scanner this one has no torn-tail mode: a published generation must
|
|
254
|
+
* be complete.
|
|
255
|
+
*/
|
|
256
|
+
function scanZstdFrames(buffer: Buffer): ZstdFrameRange[] {
|
|
257
|
+
if (buffer.length === 0) throw new Error('empty session log: no Zstandard frame')
|
|
258
|
+
const frames: ZstdFrameRange[] = []
|
|
259
|
+
let offset = 0
|
|
260
|
+
while (offset < buffer.length) {
|
|
261
|
+
const start = offset
|
|
262
|
+
if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
263
|
+
throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
|
|
264
|
+
}
|
|
265
|
+
offset += 4
|
|
266
|
+
if (offset === buffer.length) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
|
|
267
|
+
const descriptor = buffer.readUInt8(offset)
|
|
268
|
+
offset += 1
|
|
269
|
+
if ((descriptor & 0x18) !== 0) {
|
|
270
|
+
throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
|
|
271
|
+
}
|
|
272
|
+
const contentSizeFlag = descriptor >>> 6
|
|
273
|
+
const singleSegment = (descriptor & 0x20) !== 0
|
|
274
|
+
const checksum = (descriptor & 0x04) !== 0
|
|
275
|
+
const dictionaryFlag = descriptor & 0x03
|
|
276
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
|
277
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
278
|
+
const contentSizeOffset = offset + (singleSegment ? 0 : 1) + dictionaryBytes
|
|
279
|
+
offset += (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
280
|
+
if (offset > buffer.length) throw new Error(`torn Zstandard session log: truncated frame header at byte ${start}`)
|
|
281
|
+
// The declared content size is what bounds this frame's expansion; reading it
|
|
282
|
+
// here (rather than discarding it) is what lets a lying frame be refused.
|
|
283
|
+
// Spec quirk: a 2-byte field encodes `Frame_Content_Size - 256`.
|
|
284
|
+
const declaredValue = contentSizeBytes === 0
|
|
285
|
+
? null
|
|
286
|
+
: sizeToNumber(buffer, contentSizeOffset, contentSizeBytes)
|
|
287
|
+
const declaredBytes = declaredValue === null
|
|
288
|
+
? null
|
|
289
|
+
: contentSizeBytes === 2 ? declaredValue + 256 : declaredValue
|
|
290
|
+
for (;;) {
|
|
291
|
+
if (buffer.length - offset < 3) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
|
|
292
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
293
|
+
offset += 3
|
|
294
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
295
|
+
const blockType = (blockHeader >>> 1) & 0x03
|
|
296
|
+
const blockSize = blockHeader >>> 3
|
|
297
|
+
if (blockType === 0x03) {
|
|
298
|
+
throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`)
|
|
299
|
+
}
|
|
300
|
+
const payloadBytes = blockType === 0x01 ? 1 : blockSize
|
|
301
|
+
if (buffer.length - offset < payloadBytes) {
|
|
302
|
+
throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
|
|
303
|
+
}
|
|
304
|
+
offset += payloadBytes
|
|
305
|
+
if (lastBlock) break
|
|
306
|
+
}
|
|
307
|
+
if (checksum) {
|
|
308
|
+
if (buffer.length - offset < 4) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
|
|
309
|
+
offset += 4
|
|
310
|
+
}
|
|
311
|
+
frames.push({ start, end: offset, declaredBytes })
|
|
312
|
+
}
|
|
313
|
+
return frames
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read one frame header's content-size field (little-endian, 1/2/4/8 bytes) as a
|
|
318
|
+
* number, or `null` when it is not representable as one.
|
|
319
|
+
*/
|
|
320
|
+
function sizeToNumber(buffer: Buffer, offset: number, width: number): number | null {
|
|
321
|
+
let value = 0
|
|
322
|
+
for (let index = 0; index < width; index += 1) value += buffer.readUInt8(offset + index) * 2 ** (8 * index)
|
|
323
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Canonical successor filename for one generation (SSOT:
|
|
328
|
+
* `generationLogFilename` in `@deepseek-ai/dsh-session-persistence-jsonl`):
|
|
329
|
+
* version 0 keeps the suffix-only name, later generations carry `v<N>`.
|
|
330
|
+
*
|
|
331
|
+
* Exported because this is the ONE implementation of the rule: the CLI's
|
|
332
|
+
* report-mode existing-successor probe resolves the same name through this
|
|
333
|
+
* function, which it receives from `runRepair`'s dynamic import of this module
|
|
334
|
+
* (a static import would defeat the `node:zlib` zstd runtime probe that must run
|
|
335
|
+
* first). `publishSuccessor` derives its target from the RESTORED header's
|
|
336
|
+
* version, the CLI's probe from the resolved catalog's declared
|
|
337
|
+
* `currentVersion` — see the probe's comment in `repair-session-logs.ts`.
|
|
338
|
+
*/
|
|
339
|
+
export function successorFilename(version: number): string {
|
|
340
|
+
return version === 0 ? 'session.jsonl.zstd' : `session.v${version}.jsonl.zstd`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* ------------------------------------------------------------------ */
|
|
344
|
+
/* publication */
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Require a resolved catalog: the fail-closed guard every `--apply` path must
|
|
349
|
+
* pass before it touches a log. Exported so the CLI can refuse with the same
|
|
350
|
+
* message the publisher would raise.
|
|
351
|
+
*
|
|
352
|
+
* @param catalog handle from `resolveCatalog`, or its absence.
|
|
353
|
+
* @returns the handle, when present.
|
|
354
|
+
*/
|
|
355
|
+
export function assertCatalog(catalog: CatalogHandle | null | undefined): CatalogHandle {
|
|
356
|
+
if (catalog === null || catalog === undefined) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
'no released session-format catalog resolved: refusing to write a successor generation. '
|
|
359
|
+
+ 'Pass --catalog <path> or set DSH_SESSION_FORMAT_CATALOG to the @deepseek-ai/dsh-session-format-catalog package.',
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
return catalog
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Publish the successor generation of a pre-V3 log, verified by reading it back
|
|
367
|
+
* through the released catalog.
|
|
368
|
+
*
|
|
369
|
+
* @param logPath the ORIGINAL log file (never modified; only read).
|
|
370
|
+
* @param normalizedRows the log's rows after the rule registry normalized them.
|
|
371
|
+
* @param catalog resolved released catalog, or `null` — an absent oracle refuses.
|
|
372
|
+
* @param sourceDigest sha256 of the bytes the CALLER decoded `normalizedRows`
|
|
373
|
+
* from. Compared with the file before anything is staged, so a revision the
|
|
374
|
+
* rows were not built from can never be published as verified.
|
|
375
|
+
* @returns the published generation, its verification, its path and whether this
|
|
376
|
+
* call created the target or accepted an identical pre-existing one.
|
|
377
|
+
*/
|
|
378
|
+
export async function publishSuccessor(
|
|
379
|
+
logPath: string,
|
|
380
|
+
normalizedRows: readonly ParsedRow[],
|
|
381
|
+
catalog: CatalogHandle | null,
|
|
382
|
+
sourceDigest: string,
|
|
383
|
+
): Promise<PublishedSuccessor> {
|
|
384
|
+
const handle = assertCatalog(catalog)
|
|
385
|
+
const originalBytes = await readFile(logPath)
|
|
386
|
+
const originalDigest = sha256(originalBytes)
|
|
387
|
+
if (originalDigest !== sourceDigest) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`the source generation changed since it was read: ${logPath} is now ${originalDigest} but the rows to publish `
|
|
390
|
+
+ `came from ${sourceDigest}. Nothing was written; re-run so the successor is built from the current revision.`,
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Defensive copy: the caller's rows alias `form` / `summary` / `sections`
|
|
395
|
+
// objects, and the released restore may normalize the artifact in place.
|
|
396
|
+
const rows = structuredClone(normalizedRows)
|
|
397
|
+
const artifact = restoreRows(handle.catalog, rows, { recovery: 'strict', validation: 'transformed' })
|
|
398
|
+
const generation = artifact.header.version
|
|
399
|
+
const targetPath = join(dirname(logPath), successorFilename(generation))
|
|
400
|
+
if (resolve(targetPath) === resolve(logPath)) {
|
|
401
|
+
throw new Error(`refusing to publish the successor generation over its own source: ${logPath}`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The released encoder's records are handed to `encodeZstdFrames` one at a time
|
|
405
|
+
// (lazily): materializing them all first would hold a second copy of the session
|
|
406
|
+
// alongside the restored artifact it was built from.
|
|
407
|
+
const encodedEvents = function* (): Generator<Record<string, unknown>> {
|
|
408
|
+
for (const event of artifact.events) yield handle.catalog.encodeCurrentEvent(event)
|
|
409
|
+
}
|
|
410
|
+
const bytes = encodeZstdFrames(
|
|
411
|
+
handle.catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
|
|
412
|
+
encodedEvents(),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
const temporaryPath = join(dirname(logPath), `session.repair.${randomBytes(6).toString('hex')}.jsonl.zstd.tmp`)
|
|
416
|
+
let outcome: PublicationOutcome
|
|
417
|
+
try {
|
|
418
|
+
// Inside the guarded region: a failure MID-write (ENOSPC, quota) leaves a
|
|
419
|
+
// truncated temporary behind, and the cleanup below is what removes it. The
|
|
420
|
+
// cleanup covers every error path THIS PROCESS handles; a SIGKILL or a failing
|
|
421
|
+
// `rm` can still strand the temporary, which is why discovery reports such
|
|
422
|
+
// names instead of ignoring them.
|
|
423
|
+
await writeFile(temporaryPath, bytes, { flag: 'wx', mode: 0o600 })
|
|
424
|
+
// Read the staged generation back with the host's current-generation policy
|
|
425
|
+
// BEFORE it becomes visible under its canonical name.
|
|
426
|
+
verifyGeneration(handle.catalog, await readFile(temporaryPath), generation)
|
|
427
|
+
outcome = await publishExclusive(temporaryPath, targetPath, sha256(bytes))
|
|
428
|
+
} catch (error) {
|
|
429
|
+
try {
|
|
430
|
+
await rm(temporaryPath, { force: true })
|
|
431
|
+
} catch (cleanupFailure) {
|
|
432
|
+
throw new AggregateError(
|
|
433
|
+
[error, cleanupFailure],
|
|
434
|
+
`failed to remove the staged generation ${temporaryPath} after an earlier failure`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
throw error
|
|
438
|
+
}
|
|
439
|
+
// The target holds the verified bytes; a redundant temporary link cannot turn
|
|
440
|
+
// a successful publication into a failure.
|
|
441
|
+
try {
|
|
442
|
+
await rm(temporaryPath, { force: true })
|
|
443
|
+
} catch {
|
|
444
|
+
// The canonical target is already published and verified.
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (sha256(await readFile(logPath)) !== originalDigest) {
|
|
448
|
+
await settleStalePublication(logPath, targetPath, outcome)
|
|
449
|
+
}
|
|
450
|
+
return { generation, verified: true, targetPath, outcome }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Settle a publication whose source generation moved AFTER it landed.
|
|
455
|
+
*
|
|
456
|
+
* The publication HAPPENED, so reporting "nothing was published" would be a lie,
|
|
457
|
+
* and leaving a successor this call CREATED would leave the store changed after a
|
|
458
|
+
* failed run. A created target is therefore removed again (the store is as it
|
|
459
|
+
* was) while an ACCEPTED pre-existing one is never touched; either way the failure
|
|
460
|
+
* names the path so rollback is actionable. Two cases raise
|
|
461
|
+
* {@link PublishedFromStaleSourceError} — an ACCEPTED pre-existing successor (not
|
|
462
|
+
* this call's to delete) and a CREATED one whose own unlink failed — because in
|
|
463
|
+
* both a successor IS on disk and no caller may describe it as "nothing was
|
|
464
|
+
* published"; a created target that was successfully removed raises a plain error,
|
|
465
|
+
* where that wording is true.
|
|
466
|
+
*
|
|
467
|
+
* Always throws. Exported because this is the decision table the fix round added,
|
|
468
|
+
* and it is reachable in production only by an actual race.
|
|
469
|
+
*/
|
|
470
|
+
export async function settleStalePublication(
|
|
471
|
+
logPath: string,
|
|
472
|
+
targetPath: string,
|
|
473
|
+
outcome: PublicationOutcome,
|
|
474
|
+
): Promise<never> {
|
|
475
|
+
if (outcome === 'created') {
|
|
476
|
+
try {
|
|
477
|
+
await rm(targetPath, { force: true })
|
|
478
|
+
} catch (error) {
|
|
479
|
+
// The unlink itself failed, so the file this call created IS still on disk:
|
|
480
|
+
// say that (and name it) instead of letting the raw fs error surface as
|
|
481
|
+
// "nothing was published" while a successor exists.
|
|
482
|
+
throw new PublishedFromStaleSourceError(
|
|
483
|
+
`the source generation changed during publication: ${logPath}, and the successor this call created `
|
|
484
|
+
+ `(${targetPath}) could NOT be removed (${error instanceof Error ? error.message : String(error)}) — `
|
|
485
|
+
+ 'delete it manually to roll the publication back.',
|
|
486
|
+
targetPath,
|
|
487
|
+
outcome,
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
throw new Error(
|
|
491
|
+
`the source generation changed during publication: ${logPath}. The successor this call created `
|
|
492
|
+
+ `(${targetPath}) was removed; nothing was published.`,
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
throw new PublishedFromStaleSourceError(
|
|
496
|
+
`the source generation changed during publication: ${logPath}. The successor ${targetPath} was already `
|
|
497
|
+
+ 'published by an earlier run and now holds a stale snapshot of this session; it was left in place because '
|
|
498
|
+
+ 'this run did not create it — delete it to roll the publication back.',
|
|
499
|
+
targetPath,
|
|
500
|
+
outcome,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Read one complete published generation back through the catalog.
|
|
506
|
+
*
|
|
507
|
+
* `validation: 'current'` runs every installed current-format check, which is
|
|
508
|
+
* STRICTER than the host's own header restore for a current generation: the host
|
|
509
|
+
* restores with `{ recovery: 'strict', validation: 'transformed' }`, while this
|
|
510
|
+
* read-back holds the staged bytes to the full current-format validation before
|
|
511
|
+
* they become visible under a canonical name.
|
|
512
|
+
*
|
|
513
|
+
* @param catalog released catalog to read with.
|
|
514
|
+
* @param bytes published container bytes.
|
|
515
|
+
* @param expectedGeneration generation the filename promises.
|
|
516
|
+
*/
|
|
517
|
+
function verifyGeneration(
|
|
518
|
+
catalog: ReleasedCatalog,
|
|
519
|
+
bytes: Buffer,
|
|
520
|
+
expectedGeneration: number,
|
|
521
|
+
): ReleasedArtifact {
|
|
522
|
+
// The staged container is read back frame by frame: no array of every frame's
|
|
523
|
+
// plaintext, no joined event batch and no split of it — each frame's lines are
|
|
524
|
+
// decoded as that frame is decompressed.
|
|
525
|
+
let restore: ReturnType<ReleasedCatalog['createRestore']> | null = null
|
|
526
|
+
for (const frame of decodeZstdFrameTexts(bytes)) {
|
|
527
|
+
if (restore === null) {
|
|
528
|
+
if (frame.indexOf('\n') !== frame.length - 1) {
|
|
529
|
+
throw new Error('published generation frame 1 is not exactly one header line')
|
|
530
|
+
}
|
|
531
|
+
restore = catalog.createRestore(JSON.parse(frame.slice(0, -1)), {
|
|
532
|
+
recovery: 'strict',
|
|
533
|
+
validation: 'current',
|
|
534
|
+
})
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
for (const line of frame.split('\n')) {
|
|
538
|
+
if (line.length === 0) continue
|
|
539
|
+
restore.decodeRow(JSON.parse(line))
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (restore === null) throw new Error('published generation carries no Zstandard frame')
|
|
543
|
+
const artifact = restore.finish()
|
|
544
|
+
if (artifact.header.version !== expectedGeneration) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`published generation reads back as v${artifact.header.version}, expected v${expectedGeneration}`,
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
return artifact
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Publish the staged bytes exclusively: `link` never replaces an existing
|
|
554
|
+
* successor, and an existing target is accepted only when it already carries
|
|
555
|
+
* exactly these bytes (a re-run of the same repair).
|
|
556
|
+
*
|
|
557
|
+
* @returns `created` when this call linked the target, `accepted` when identical
|
|
558
|
+
* bytes were already published.
|
|
559
|
+
*/
|
|
560
|
+
async function publishExclusive(
|
|
561
|
+
temporaryPath: string,
|
|
562
|
+
targetPath: string,
|
|
563
|
+
digest: string,
|
|
564
|
+
): Promise<PublicationOutcome> {
|
|
565
|
+
try {
|
|
566
|
+
await link(temporaryPath, targetPath)
|
|
567
|
+
return 'created'
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
570
|
+
}
|
|
571
|
+
const existing = await readFile(targetPath)
|
|
572
|
+
if (sha256(existing) !== digest) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
`refusing to replace the existing successor generation ${targetPath}: it holds different bytes`,
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
return 'accepted'
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Hex sha256 of one byte buffer. */
|
|
581
|
+
function sha256(bytes: Buffer): string {
|
|
582
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
583
|
+
}
|