@sanqianx/claude-ai-workbench 0.13.2 → 0.13.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.
Files changed (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. package/src/sdk-wave-e.ts +1 -1
@@ -1,612 +1,612 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
- const crypto = require('node:crypto');
6
-
7
- const HEADER = 'workbench-history/v1';
8
- const DELTAS = new Set(['claude/text-delta', 'claude/thinking-delta']);
9
-
10
- // Rows that carry native task facts are evidence, not chat cache: archives
11
- // containing them may not be overwritten by wholesale recovery.
12
- function isNativeFactType(type) {
13
- return type === 'claude/native-trace' || type === 'claude/native-task-state' || type === 'claude/native-task-transcript';
14
- }
15
-
16
- function parseLine(text) {
17
- try { return JSON.parse(text); }
18
- catch { throw Object.assign(new Error('会话历史文件损坏,原文件已保留。'), { status: 503, code: 'HISTORY_CORRUPT' }); }
19
- }
20
-
21
- function compactInto(events, event) {
22
- const previous = events[events.length - 1];
23
- if (previous && DELTAS.has(event.type) && previous.type === event.type && previous.id === event.id) {
24
- previous.text = String(previous.text || '') + String(event.text || '');
25
- // Provenance for later recovery: a merged row spans from its first
26
- // fragment's sequence, so a stale partial buffer can be told to replace
27
- // its fragments with the row instead of concatenating onto it.
28
- if (previous.compactFrom === undefined) previous.compactFrom = previous.sequence;
29
- previous.sequence = event.sequence;
30
- return;
31
- }
32
- events.push({ ...event });
33
- }
34
-
35
- // Read backwards in byte chunks, not by loading every previous turn into RAM.
36
- // Buffer boundaries can bisect UTF-8 characters, so decode only complete lines.
37
- function* reverseLines(fd, end) {
38
- let position = end;
39
- let carry = Buffer.alloc(0);
40
- while (position > 0) {
41
- const start = Math.max(0, position - 65536);
42
- const chunk = Buffer.alloc(position - start);
43
- fs.readSync(fd, chunk, 0, chunk.length, start);
44
- const data = Buffer.concat([chunk, carry]);
45
- let lineEnd = data.length;
46
- for (let i = data.length - 1; i >= 0; i--) {
47
- if (data[i] !== 10) continue;
48
- if (i + 1 < lineEnd) yield { offset: start + i + 1, text: data.subarray(i + 1, lineEnd).toString('utf8') };
49
- lineEnd = i;
50
- }
51
- if (start === 0 && lineEnd > 0) yield { offset: 0, text: data.subarray(0, lineEnd).toString('utf8') };
52
- carry = data.subarray(0, lineEnd);
53
- position = start;
54
- }
55
- }
56
-
57
- class SessionHistory {
58
- constructor({ file, sessionId, contextId, startedAt, incomplete = false, onError }) {
59
- this.file = file;
60
- this.sessionId = sessionId;
61
- this.contextId = contextId;
62
- this.startedAt = startedAt || new Date().toISOString();
63
- this.incomplete = incomplete;
64
- this.onError = onError || (() => {});
65
- this.highWater = 0;
66
- this.pending = [];
67
- this.timer = null;
68
- this.error = null;
69
- this.exists = false;
70
- // Ordered failure lockout: once a durable batch (or a recovery-gap
71
- // marker) fails to land, no newer row may raise the high-water mark past
72
- // the failure head - neither plain batched rows nor later durable facts.
73
- // The failed batch itself is retained verbatim for an in-place retry.
74
- this.durableBarrier = null;
75
- this.failedBatch = null;
76
- // Generation identity is additive: v1 archives simply keep null.
77
- this.fileId = null;
78
- this.createdAt = null;
79
- this.recoveryGap = null;
80
- this.gapRecorded = null;
81
- if (!fs.existsSync(file)) return;
82
- const stat = fs.lstatSync(file);
83
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('会话历史路径不是普通文件。');
84
- const fd = fs.openSync(file, 'r');
85
- try {
86
- const head = Buffer.alloc(Math.min(stat.size, 4096));
87
- fs.readSync(fd, head, 0, head.length, 0);
88
- const header = parseLine(head.toString('utf8').split('\n')[0]);
89
- if (header.schema !== HEADER || header.sessionId !== sessionId || header.contextId !== contextId) throw new Error('会话历史归属不匹配。');
90
- this.incomplete = header.incomplete === true;
91
- this.fileId = header.fileId || null;
92
- this.createdAt = header.createdAt || null;
93
- this.exists = true;
94
- // The high-water mark is the highest sequence the file ever
95
- // journaled, so a trailing legacy checkpoint that lowered the mark can
96
- // never license re-minting a number an earlier row already owns, and a
97
- // persisted recovery-gap marker survives the reopen instead of letting
98
- // a known hole pass as completeness. The marker is honored unless
99
- // every sequence in its interval is present again on disk — refills
100
- // are journaled after the marker, so it is located first (a short
101
- // backward pass: markers sit near the crash point) before the
102
- // numbering scan counts coverage.
103
- let gap = null;
104
- for (const line of reverseLines(fd, stat.size)) {
105
- const item = parseLine(line.text);
106
- if (item.schema === HEADER) break;
107
- if (item.type === 'workbench/history-gap') {
108
- gap = { from: Number(item.from) || 0, to: Number(item.to) || 0, sessionId: item.sessionId || sessionId };
109
- break;
110
- }
111
- }
112
- let highest = 0;
113
- const covered = new Set();
114
- // A verifiably huge interval is not cheaply checkable for refill; it
115
- // stays marked rather than risking a false heal.
116
- const gapVerifiable = Boolean(gap) && gap.from > 0 && gap.to >= gap.from && gap.to - gap.from <= 1_000_000;
117
- for (const line of reverseLines(fd, stat.size)) {
118
- const item = parseLine(line.text);
119
- if (item.schema === HEADER) break;
120
- if (item.type === 'workbench/history-gap') continue;
121
- const own = Number(item.streamSequence ?? item.sequence);
122
- if (Number.isSafeInteger(own) && own > 0 && own > highest) highest = own;
123
- // Coverage counts unique event sequences only. A checkpoint is a
124
- // counter position, not an event: it may raise the restored mark
125
- // (numbers the counter consumed without journaling rows) but it
126
- // proves no content exists and therefore fills no hole. Duplicate
127
- // rows collapse onto one sequence each.
128
- if (!gapVerifiable || item.type === 'workbench/history-checkpoint') continue;
129
- const sequence = Number(item.sequence);
130
- if (!Number.isSafeInteger(sequence) || sequence <= 0) continue;
131
- // A merged delta row carries its whole fragment run: compactFrom is
132
- // stamped only when real fragments with those sequences were folded
133
- // into this row's text, so every sequence in the span is provably
134
- // present by content.
135
- const spanFrom = Number.isSafeInteger(item.compactFrom) && item.compactFrom > 0 && item.compactFrom <= sequence
136
- ? item.compactFrom : sequence;
137
- for (let at = Math.max(spanFrom, gap.from); at <= Math.min(sequence, gap.to); at++) covered.add(at);
138
- }
139
- this.highWater = highest;
140
- if (gap && gap.from > 0 && gap.to >= gap.from) {
141
- let healed = gapVerifiable;
142
- for (let at = gap.from; at <= gap.to && healed; at++) if (!covered.has(at)) healed = false;
143
- if (!healed) {
144
- this.recoveryGap = gap;
145
- this.gapRecorded = { from: gap.from, to: gap.to };
146
- }
147
- }
148
- } finally { fs.closeSync(fd); }
149
- }
150
-
151
- append(event) {
152
- // A legal counter is a positive safe integer; anything else is unstamped
153
- // and mints the next forward slot instead of a coercible oddity.
154
- const provided = Number(event.sequence);
155
- const sequence = Number.isSafeInteger(provided) && provided > 0 ? provided : this.highWater + 1;
156
- if (sequence <= this.highWater) return;
157
- if (this.durableBarrier) {
158
- // Frozen behind a failed durable head: the row stays queued in
159
- // arrival order, but the in-memory mark must not advance - a raised
160
- // mark would let later dedupe or retry logic treat the hole as
161
- // already journaled.
162
- compactInto(this.pending, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence });
163
- if (!this.timer) {
164
- this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
165
- this.timer.unref?.();
166
- }
167
- return;
168
- }
169
- this.highWater = sequence;
170
- compactInto(this.pending, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence });
171
- if (['claude/result', 'claude/aborted', 'claude/error', 'claude/turn-end'].includes(event.type)
172
- || (event.type === 'claude/state' && ['idle', 'failed', 'aborted'].includes(event.state))) this.flush();
173
- else if (!this.timer) {
174
- this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
175
- this.timer.unref?.();
176
- }
177
- }
178
-
179
- _newHeader() {
180
- return {
181
- schema: HEADER, sessionId: this.sessionId, contextId: this.contextId, incomplete: this.incomplete,
182
- fileId: crypto.randomUUID(), createdAt: new Date().toISOString(),
183
- };
184
- }
185
-
186
- // Durable receipt path for facts that must be proven on disk before
187
- // anything is built on them. Success is returned only after write AND
188
- // fsync; a failure reports its exact phase, rolls the file back to the
189
- // last complete row, keeps prior rows and the plain-append pending queue
190
- // intact, and never advances the high-water mark. The counter is strictly
191
- // forward: sequences at or below the mark are rejected, not silently
192
- // dropped or re-minted.
193
- appendDurable(events) {
194
- if (!Array.isArray(events) || events.length === 0) {
195
- return { ok: false, phase: 'input', error: 'appendDurable requires a non-empty events array', highWater: this.highWater };
196
- }
197
- if (this.durableBarrier) {
198
- // A failed head owns the next journal position: newer durable facts
199
- // are refused instead of silently raising the mark over the hole.
200
- // The retained batch is retried only through retryFailedBatch().
201
- return { ok: false, phase: 'frozen', error: `journal is frozen behind failed sequence ${this.durableBarrier.sequence}`, highWater: this.highWater, pendingCount: this.pending.length };
202
- }
203
- const staged = [];
204
- for (const event of events) {
205
- const sequence = Number(event && event.sequence);
206
- if (!event || typeof event !== 'object' || !Number.isSafeInteger(sequence) || sequence <= this.highWater) {
207
- return { ok: false, phase: 'sequence', error: `sequence ${event && event.sequence} is not ahead of high-water ${this.highWater}`, highWater: this.highWater, pendingCount: this.pending.length };
208
- }
209
- // The staged row is an OWNED deep copy taken before any write attempt:
210
- // a retained failed batch must replay the fact as first submitted, not
211
- // whatever the caller (or an onError hook re-entering during the
212
- // failure) later mutates in nested payload objects. A JSON round-trip
213
- // is the file's own serialization semantics; anything it cannot
214
- // express is refused with a fixed code that leaks no field content.
215
- let own;
216
- try {
217
- own = JSON.parse(JSON.stringify(event));
218
- } catch {
219
- return { ok: false, phase: 'input', error: 'HISTORY_FACT_NOT_SERIALIZABLE', highWater: this.highWater, pendingCount: this.pending.length };
220
- }
221
- staged.push(Object.assign(own, { at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence }));
222
- }
223
- for (let i = 1; i < staged.length; i++) {
224
- if (staged[i].sequence <= staged[i - 1].sequence) {
225
- return { ok: false, phase: 'sequence', error: 'appendDurable batch sequences must strictly increase', highWater: this.highWater, pendingCount: this.pending.length };
226
- }
227
- }
228
- const receipt = this._commitDurableRows(staged);
229
- if (receipt.ok) this.highWater = staged[staged.length - 1].sequence;
230
- else this._retainFailedBatch(staged, receipt.phase);
231
- return receipt.ok
232
- ? { ...receipt, sequence: this.highWater, highWater: this.highWater }
233
- : { ...receipt, highWater: this.highWater };
234
- }
235
-
236
- // Retain the exact staged batch that failed so the same facts keep their
237
- // original sequences for an in-place retry; the barrier head is the
238
- // batch's first sequence. Nothing is cleared here - only a successful
239
- // retry (or an explicitly recorded gap at close) may move past it.
240
- _retainFailedBatch(staged, phase) {
241
- if (!this.failedBatch) this.failedBatch = staged;
242
- if (!this.durableBarrier) this.durableBarrier = { sequence: staged[0].sequence, phase: phase || 'write' };
243
- }
244
-
245
- // Same-batch, same-sequence retry of the retained failure. Success lands
246
- // exactly the original rows once (the forward-only counter and the
247
- // rollback guarantee no duplicate), clears the barrier and lets the
248
- // frozen plain rows flush on the next flush(); failure keeps everything
249
- // retryable and the file untouched past the last complete row.
250
- retryFailedBatch() {
251
- if (!this.failedBatch) return { ok: true, phase: 'none', highWater: this.highWater };
252
- const staged = this.failedBatch;
253
- const receipt = this._commitDurableRows(staged);
254
- if (receipt.ok) {
255
- this.highWater = Math.max(this.highWater, staged[staged.length - 1].sequence);
256
- this.failedBatch = null;
257
- this.durableBarrier = null;
258
- return { ...receipt, sequence: this.highWater, highWater: this.highWater };
259
- }
260
- this.durableBarrier = this.durableBarrier || { sequence: staged[0].sequence, phase: receipt.phase };
261
- return { ...receipt, highWater: this.highWater };
262
- }
263
-
264
- // Shared durable journal core: land any queued plain rows that sit BELOW
265
- // the staged head first so the file stays arrival-ordered (a ceiling keeps
266
- // rows above a barrier frozen), then write+fsync the staged rows in one
267
- // 'r+' positioned write with half-line rollback. A stale error with
268
- // nothing still queued below does not veto the attempt: the receipt
269
- // itself is the retry, and a successful write+fsync clears it. A failed
270
- // receipt leaves prior rows and the pending queue intact.
271
- _commitDurableRows(staged) {
272
- this.flush(staged[0].sequence);
273
- // Out-of-order guard: a stale error only vetoes the attempt when rows
274
- // that belong BELOW the staged head could not be landed - writing the
275
- // staged batch past them would break arrival order. Frozen rows above
276
- // the head are the barrier's business, not this write's.
277
- const head = staged[0].sequence;
278
- const blockedBelow = this.pending.some(row => {
279
- const own = Number(row.streamSequence ?? row.sequence);
280
- return !Number.isSafeInteger(own) || own < head;
281
- });
282
- if (this.error && blockedBelow) return { ok: false, phase: 'journal', error: this.error, pendingCount: this.pending.length };
283
- let fd, start;
284
- try {
285
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
286
- if (!this.exists) {
287
- const header = this._newHeader();
288
- fs.writeFileSync(this.file, `${JSON.stringify(header)}\n`, { flag: 'wx', mode: 0o600 });
289
- this.exists = true;
290
- this.fileId = header.fileId;
291
- this.createdAt = header.createdAt;
292
- }
293
- const payload = Buffer.from(staged.map(event => JSON.stringify(event)).join('\n') + '\n');
294
- // 'r+' with explicit positions, not append mode: Windows refuses
295
- // ftruncate on O_APPEND handles, which would turn the half-line
296
- // rollback below into a silent no-op.
297
- fd = fs.openSync(this.file, 'r+', 0o600);
298
- start = fs.fstatSync(fd).size;
299
- let written = 0;
300
- while (written < payload.length) {
301
- const count = fs.writeSync(fd, payload, written, payload.length - written, start + written);
302
- if (!count) throw Object.assign(new Error('history write made no progress'), { code: 'EIO' });
303
- written += count;
304
- }
305
- try { fs.fsyncSync(fd); } catch (fsyncError) { fsyncError.durablePhase = 'fsync'; throw fsyncError; }
306
- this.error = null;
307
- return { ok: true, phase: 'committed', count: staged.length, bytes: payload.length };
308
- } catch (error) {
309
- // No half line and no unproven durable row may survive a failed
310
- // receipt; the rollback keeps every prior complete row so a retry
311
- // rewrites exactly the failed batch.
312
- if (fd !== undefined && start !== undefined) { try { fs.ftruncateSync(fd, start); } catch { /* rollback best effort; error state below still blocks reads */ } }
313
- this.error = error.code || 'HISTORY_DURABLE_WRITE_FAILED';
314
- this.onError(error);
315
- return { ok: false, phase: error.durablePhase === 'fsync' ? 'fsync' : 'write', error: error.message, code: error.code || null, pendingCount: this.pending.length };
316
- } finally { if (fd !== undefined) fs.closeSync(fd); }
317
- }
318
-
319
- // Bounded backward scan of the durable archive: the newest rows inside
320
- // (afterSequence, beforeSequence), at most maxLines of them, without the
321
- // file ever being resident in memory. `complete` is true only when the
322
- // page provably covers every durable row in that window (the scan crossed
323
- // the lower boundary or hit the header) and no mixed numbering-era row
324
- // was met; a page capped by maxLines reports truncated with
325
- // nextBeforeSequence so callers page towards the past instead of
326
- // re-reading the newest rows. Never returns an empty page as a stand-in
327
- // for a complete archive.
328
- durableTail({ afterSequence = 0, maxLines = 5000, beforeSequence } = {}) {
329
- const after = Number(afterSequence);
330
- const cap = Number(maxLines);
331
- const before = beforeSequence === undefined || beforeSequence === null ? Number.POSITIVE_INFINITY : Number(beforeSequence);
332
- if (!Number.isFinite(after) || after < 0) throw Object.assign(new Error('afterSequence must be a non-negative number'), { status: 400 });
333
- if (Number.isNaN(before)) throw Object.assign(new Error('beforeSequence must be a number'), { status: 400 });
334
- if (before <= after) throw Object.assign(new Error('beforeSequence must be greater than afterSequence'), { status: 400 });
335
- if (!Number.isInteger(cap) || cap < 1 || cap > 20000) throw Object.assign(new Error('maxLines must be between 1 and 20000'), { status: 400 });
336
- this.flush();
337
- if (this.error) throw Object.assign(new Error('会话历史写入失败,请检查磁盘与本地日志。'), { status: 503, code: this.error });
338
- if (!this.exists) return { events: [], complete: true, truncated: false, fromSequence: null, toSequence: null, nextBeforeSequence: null, highWater: this.highWater, incomplete: this.incomplete };
339
- const fd = fs.openSync(this.file, 'r');
340
- const collected = [];
341
- let hitBoundary = false, mixedEra = false, smallest = null, largest = null;
342
- try {
343
- const size = fs.fstatSync(fd).size;
344
- for (const line of reverseLines(fd, size)) {
345
- const event = parseLine(line.text);
346
- if (event.schema === HEADER) { hitBoundary = true; break; }
347
- if (event.type === 'workbench/history-checkpoint') { mixedEra = true; continue; }
348
- // A gap marker is known control metadata, not a mixed-numbering-era
349
- // row: the hole it declares is reported separately via recoveryGap.
350
- if (event.type === 'workbench/history-gap') continue;
351
- const sequence = Number(event.sequence);
352
- if (!Number.isFinite(sequence) || sequence <= 0) { mixedEra = true; continue; }
353
- if (sequence <= after) { hitBoundary = true; break; }
354
- if (sequence >= before) continue;
355
- if (collected.length >= cap) break;
356
- collected.push(event);
357
- smallest = sequence;
358
- if (largest === null) largest = sequence;
359
- }
360
- } finally { fs.closeSync(fd); }
361
- const truncated = !hitBoundary;
362
- return {
363
- events: collected.reverse(),
364
- complete: hitBoundary && !mixedEra,
365
- truncated,
366
- fromSequence: smallest,
367
- toSequence: largest,
368
- nextBeforeSequence: truncated && smallest !== null ? smallest : null,
369
- highWater: this.highWater,
370
- incomplete: this.incomplete,
371
- };
372
- }
373
-
374
- // Exact lookup of one committed numbered row, scanned backwards with an
375
- // early exit: rows arrive in ascending order, so the first row numbered
376
- // below the target proves the target absent. Returns null when the row
377
- // cannot be proven present.
378
- durableRow(sequence) {
379
- const target = Number(sequence);
380
- if (!Number.isSafeInteger(target) || target <= 0 || !this.exists) return null;
381
- const fd = fs.openSync(this.file, 'r');
382
- try {
383
- for (const line of reverseLines(fd, fs.fstatSync(fd).size)) {
384
- const item = parseLine(line.text);
385
- if (item.schema === HEADER) break;
386
- if (item.type === 'workbench/history-checkpoint' || item.type === 'workbench/history-gap') continue;
387
- const own = Number(item.sequence);
388
- if (own === target) return item;
389
- if (Number.isSafeInteger(own) && own < target) break;
390
- }
391
- } finally { fs.closeSync(fd); }
392
- return null;
393
- }
394
-
395
- // The recovery gap is control metadata in the journal's own row space and
396
- // earns the same receipt contract as durable facts: the marker counts as
397
- // recorded only after a proven write+fsync, so a failed write keeps the
398
- // original rows and a retryable state instead of silently skipping the
399
- // marker on a later call. The interval is rewritten only when it changes.
400
- recordRecoveryGap(from, to) {
401
- const start = Number(from), end = Number(to);
402
- if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start <= 0 || end < start) {
403
- return { ok: false, phase: 'input', highWater: this.highWater };
404
- }
405
- this.recoveryGap = { from: start, to: end, sessionId: this.sessionId };
406
- if (this.gapRecorded && this.gapRecorded.from === start && this.gapRecorded.to === end) {
407
- return { ok: true, phase: 'already-recorded', highWater: this.highWater };
408
- }
409
- // The marker is control metadata, not a native fact: its failed receipt
410
- // is diagnosed through the return value and onError, but it must not
411
- // raise the sticky read veto - readers keep serving with the in-memory
412
- // gap instead of losing the whole journal to a metadata write.
413
- const priorError = this.error;
414
- const receipt = this._commitDurableRows([{ type: 'workbench/history-gap', from: start, to: end, sessionId: this.sessionId }]);
415
- if (!receipt.ok) this.error = priorError;
416
- if (receipt.ok) {
417
- this.gapRecorded = { from: start, to: end };
418
- // A landed marker declares the hole honestly; rows above the interval
419
- // may flush again. The retained failed batch (if any) stays retained:
420
- // its sequences sit inside the interval and belong to a retry.
421
- if (this.durableBarrier && this.durableBarrier.phase === 'gap-marker') this.durableBarrier = null;
422
- } else if (!this.durableBarrier) {
423
- // While the marker itself cannot be proven on disk, no plain row may
424
- // flush past the unrecorded hole either.
425
- this.durableBarrier = { sequence: start, phase: 'gap-marker' };
426
- }
427
- return { ...receipt, highWater: this.highWater };
428
- }
429
-
430
- containsNativeFacts() {
431
- if (!this.exists) return false;
432
- const fd = fs.openSync(this.file, 'r');
433
- try {
434
- for (const line of reverseLines(fd, fs.fstatSync(fd).size)) {
435
- if (!line.text.includes('"claude/native-')) continue;
436
- if (isNativeFactType(parseLine(line.text).type)) return true;
437
- }
438
- } finally { fs.closeSync(fd); }
439
- return false;
440
- }
441
-
442
- seedRecovered(events, streamSequence) {
443
- // A valid-but-incomplete archive that already carries native facts is
444
- // collected evidence: replacing it with a recovered chat transcript
445
- // would destroy task rows no other source can rebuild. Keep the file
446
- // and the incomplete flag; the refusal is an explicit receipt.
447
- if (this.exists && this.containsNativeFacts()) {
448
- this.incomplete = true;
449
- const refusal = Object.assign(new Error('会话历史含原生任务事实,拒绝恢复覆盖;原文件已保留。'), { code: 'HISTORY_NATIVE_FACTS_REFUSED' });
450
- this.onError(refusal);
451
- return { ok: false, refused: true, reason: 'native-facts', highWater: this.highWater };
452
- }
453
- const recovered = [];
454
- for (const event of events) compactInto(recovered, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence: 0 });
455
- recovered.push({ type: 'workbench/history-checkpoint', streamSequence });
456
- const header = { schema: HEADER, sessionId: this.sessionId, contextId: this.contextId, incomplete: false, fileId: crypto.randomUUID(), createdAt: new Date().toISOString() };
457
- const temporary = `${this.file}.${process.pid}.${crypto.randomUUID()}.recovery.tmp`;
458
- try {
459
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
460
- fs.writeFileSync(temporary, [header, ...recovered].map(item => JSON.stringify(item)).join('\n') + '\n', { mode: 0o600, flag: 'wx' });
461
- fs.renameSync(temporary, this.file);
462
- this.pending = []; this.highWater = streamSequence; this.incomplete = false; this.exists = true; this.error = null;
463
- this.fileId = header.fileId; this.createdAt = header.createdAt;
464
- return { ok: true, refused: false, highWater: this.highWater };
465
- } catch (error) {
466
- this.onError(error);
467
- return { ok: false, refused: false, error: error.message, code: error.code || null, highWater: this.highWater };
468
- } finally { try { fs.unlinkSync(temporary); } catch {} }
469
- }
470
-
471
- // Forward-only: a checkpoint may raise the mark (numbers the counter
472
- // consumed without journaling rows) but may never lower it — old rows
473
- // keep their numbering and nothing re-mints a sequence the archive
474
- // already holds. A frozen journal refuses: the mark must not jump a
475
- // failed head, and the checkpoint row would flush above the barrier.
476
- resetStreamSequence(streamSequence) {
477
- const target = Number(streamSequence);
478
- if (!Number.isSafeInteger(target) || target <= this.highWater) return false;
479
- if (this.durableBarrier) return false;
480
- this.highWater = target;
481
- this.pending.push({ type: 'workbench/history-checkpoint', streamSequence: target });
482
- this.flush();
483
- return true;
484
- }
485
-
486
- // Land queued plain rows. Without a ceiling the call is a free flush: it
487
- // is refused while a durable barrier is armed (rows above a failed head
488
- // must never reach the file first) and re-arms the timer so an unfrozen
489
- // journal drains later. A ceiling (the staged head of a durable commit)
490
- // lands only the rows that sit below it, keeping arrival order even
491
- // during a retry of the retained failed batch. Rows queued while frozen
492
- // never raised the in-memory mark, so a successful flush adopts the
493
- // highest written sequence instead.
494
- flush(ceiling) {
495
- if (this.timer) { clearTimeout(this.timer); this.timer = null; }
496
- const limit = Number.isSafeInteger(ceiling) && ceiling > 0 ? ceiling : null;
497
- if (this.durableBarrier && limit === null) {
498
- if (this.pending.length && !this.timer) {
499
- this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
500
- this.timer.unref?.();
501
- }
502
- return;
503
- }
504
- if (!this.pending.length) return;
505
- let rows = this.pending;
506
- if (limit !== null) {
507
- let split = rows.length;
508
- for (let i = 0; i < rows.length; i++) {
509
- const own = Number(rows[i].streamSequence ?? rows[i].sequence);
510
- if (Number.isSafeInteger(own) && own >= limit) { split = i; break; }
511
- }
512
- if (split === 0) return;
513
- rows = rows.slice(0, split);
514
- }
515
- let fd, start;
516
- try {
517
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
518
- if (!this.exists) {
519
- const header = this._newHeader();
520
- fs.writeFileSync(this.file, `${JSON.stringify(header)}\n`, { flag: 'wx', mode: 0o600 });
521
- this.exists = true;
522
- this.fileId = header.fileId;
523
- this.createdAt = header.createdAt;
524
- }
525
- const payload = Buffer.from(rows.map(event => JSON.stringify(event)).join('\n') + '\n');
526
- fd = fs.openSync(this.file, 'r+', 0o600);
527
- start = fs.fstatSync(fd).size;
528
- let written = 0;
529
- while (written < payload.length) {
530
- const count = fs.writeSync(fd, payload, written, payload.length - written, start + written);
531
- if (!count) throw Object.assign(new Error('history write made no progress'), { code: 'EIO' });
532
- written += count;
533
- }
534
- this.pending = this.pending.slice(rows.length);
535
- let landed = 0;
536
- for (const row of rows) {
537
- const own = Number(row.streamSequence ?? row.sequence);
538
- if (Number.isSafeInteger(own) && own > landed) landed = own;
539
- }
540
- if (landed > this.highWater) this.highWater = landed;
541
- this.error = null;
542
- } catch (error) {
543
- // A failed append must not leave half a JSON line before the retry.
544
- // This archive has one owning service/writer; prior complete rows stay intact.
545
- if (fd !== undefined && start !== undefined) try { fs.ftruncateSync(fd, start); } catch {}
546
- this.error = error.code || 'HISTORY_WRITE_FAILED';
547
- this.onError(error);
548
- } finally { if (fd !== undefined) fs.closeSync(fd); }
549
- }
550
-
551
- read({ before, limit = 20 } = {}) {
552
- const count = Number(limit);
553
- if (!Number.isInteger(count) || count < 1 || count > 100) throw Object.assign(new Error('limit must be between 1 and 100'), { status: 400 });
554
- if (before !== undefined && before !== null && !/^[1-9]\d*$/.test(String(before))) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
555
- this.flush();
556
- if (this.error) throw Object.assign(new Error('会话历史写入失败,请检查磁盘与本地日志。'), { status: 503, code: this.error });
557
- if (!this.exists) return { events: [], nextCursor: null, incomplete: this.incomplete, highWater: this.highWater, ...(this.recoveryGap ? { recoveryGap: this.recoveryGap } : {}) };
558
- const fd = fs.openSync(this.file, 'r');
559
- const selected = [], prelude = [];
560
- let cursor = null, prompts = 0;
561
- try {
562
- const size = fs.fstatSync(fd).size;
563
- const end = before == null ? size : Number(before);
564
- if (!Number.isSafeInteger(end) || end > size) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
565
- if (before != null) {
566
- const previous = Buffer.alloc(1); fs.readSync(fd, previous, 0, 1, end - 1);
567
- if (previous[0] !== 10) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
568
- }
569
- let boundary = null;
570
- for (const line of reverseLines(fd, end)) {
571
- const event = parseLine(line.text);
572
- if (event.schema === HEADER || event.type === 'workbench/history-checkpoint' || event.type === 'workbench/history-gap') continue;
573
- if (prompts >= count) {
574
- if (event.type === 'claude/user-prompt') { cursor = boundary; break; }
575
- prelude.push(event);
576
- continue;
577
- }
578
- selected.push(event);
579
- if (event.type === 'claude/user-prompt') { prompts++; boundary = line.offset; }
580
- }
581
- if (cursor === null) selected.push(...prelude);
582
- } finally { fs.closeSync(fd); }
583
- const events = [];
584
- for (const event of selected.reverse()) compactInto(events, event);
585
- return { events, nextCursor: cursor === null ? null : String(cursor), incomplete: this.incomplete, highWater: this.highWater, ...(this.recoveryGap ? { recoveryGap: this.recoveryGap } : {}) };
586
- }
587
-
588
- // Shutdown contract for a journaled history: a retained failed batch is
589
- // retried in place first; only if that still fails is the hole persisted
590
- // as a recovery-gap marker (never the other way around - the queue is
591
- // never cleared to fake a drain). A landed marker lets the frozen plain
592
- // rows flush as an honest partial refill; a marker that cannot land
593
- // leaves everything queued and the failure diagnosable.
594
- close() {
595
- if (this.timer) { clearTimeout(this.timer); this.timer = null; }
596
- if (this.failedBatch) {
597
- const retried = this.retryFailedBatch();
598
- if (!retried.ok) {
599
- const head = this.failedBatch[0].sequence;
600
- let ceiling = this.failedBatch[this.failedBatch.length - 1].sequence;
601
- for (const row of this.pending) {
602
- const own = Number(row.streamSequence ?? row.sequence);
603
- if (Number.isSafeInteger(own) && own > ceiling) ceiling = own;
604
- }
605
- this.recordRecoveryGap(head, ceiling);
606
- }
607
- }
608
- this.flush();
609
- }
610
- }
611
-
612
- module.exports = { SessionHistory, compactInto, reverseLines };
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const HEADER = 'workbench-history/v1';
8
+ const DELTAS = new Set(['claude/text-delta', 'claude/thinking-delta']);
9
+
10
+ // Rows that carry native task facts are evidence, not chat cache: archives
11
+ // containing them may not be overwritten by wholesale recovery.
12
+ function isNativeFactType(type) {
13
+ return type === 'claude/native-trace' || type === 'claude/native-task-state' || type === 'claude/native-task-transcript';
14
+ }
15
+
16
+ function parseLine(text) {
17
+ try { return JSON.parse(text); }
18
+ catch { throw Object.assign(new Error('会话历史文件损坏,原文件已保留。'), { status: 503, code: 'HISTORY_CORRUPT' }); }
19
+ }
20
+
21
+ function compactInto(events, event) {
22
+ const previous = events[events.length - 1];
23
+ if (previous && DELTAS.has(event.type) && previous.type === event.type && previous.id === event.id) {
24
+ previous.text = String(previous.text || '') + String(event.text || '');
25
+ // Provenance for later recovery: a merged row spans from its first
26
+ // fragment's sequence, so a stale partial buffer can be told to replace
27
+ // its fragments with the row instead of concatenating onto it.
28
+ if (previous.compactFrom === undefined) previous.compactFrom = previous.sequence;
29
+ previous.sequence = event.sequence;
30
+ return;
31
+ }
32
+ events.push({ ...event });
33
+ }
34
+
35
+ // Read backwards in byte chunks, not by loading every previous turn into RAM.
36
+ // Buffer boundaries can bisect UTF-8 characters, so decode only complete lines.
37
+ function* reverseLines(fd, end) {
38
+ let position = end;
39
+ let carry = Buffer.alloc(0);
40
+ while (position > 0) {
41
+ const start = Math.max(0, position - 65536);
42
+ const chunk = Buffer.alloc(position - start);
43
+ fs.readSync(fd, chunk, 0, chunk.length, start);
44
+ const data = Buffer.concat([chunk, carry]);
45
+ let lineEnd = data.length;
46
+ for (let i = data.length - 1; i >= 0; i--) {
47
+ if (data[i] !== 10) continue;
48
+ if (i + 1 < lineEnd) yield { offset: start + i + 1, text: data.subarray(i + 1, lineEnd).toString('utf8') };
49
+ lineEnd = i;
50
+ }
51
+ if (start === 0 && lineEnd > 0) yield { offset: 0, text: data.subarray(0, lineEnd).toString('utf8') };
52
+ carry = data.subarray(0, lineEnd);
53
+ position = start;
54
+ }
55
+ }
56
+
57
+ class SessionHistory {
58
+ constructor({ file, sessionId, contextId, startedAt, incomplete = false, onError }) {
59
+ this.file = file;
60
+ this.sessionId = sessionId;
61
+ this.contextId = contextId;
62
+ this.startedAt = startedAt || new Date().toISOString();
63
+ this.incomplete = incomplete;
64
+ this.onError = onError || (() => {});
65
+ this.highWater = 0;
66
+ this.pending = [];
67
+ this.timer = null;
68
+ this.error = null;
69
+ this.exists = false;
70
+ // Ordered failure lockout: once a durable batch (or a recovery-gap
71
+ // marker) fails to land, no newer row may raise the high-water mark past
72
+ // the failure head - neither plain batched rows nor later durable facts.
73
+ // The failed batch itself is retained verbatim for an in-place retry.
74
+ this.durableBarrier = null;
75
+ this.failedBatch = null;
76
+ // Generation identity is additive: v1 archives simply keep null.
77
+ this.fileId = null;
78
+ this.createdAt = null;
79
+ this.recoveryGap = null;
80
+ this.gapRecorded = null;
81
+ if (!fs.existsSync(file)) return;
82
+ const stat = fs.lstatSync(file);
83
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('会话历史路径不是普通文件。');
84
+ const fd = fs.openSync(file, 'r');
85
+ try {
86
+ const head = Buffer.alloc(Math.min(stat.size, 4096));
87
+ fs.readSync(fd, head, 0, head.length, 0);
88
+ const header = parseLine(head.toString('utf8').split('\n')[0]);
89
+ if (header.schema !== HEADER || header.sessionId !== sessionId || header.contextId !== contextId) throw new Error('会话历史归属不匹配。');
90
+ this.incomplete = header.incomplete === true;
91
+ this.fileId = header.fileId || null;
92
+ this.createdAt = header.createdAt || null;
93
+ this.exists = true;
94
+ // The high-water mark is the highest sequence the file ever
95
+ // journaled, so a trailing legacy checkpoint that lowered the mark can
96
+ // never license re-minting a number an earlier row already owns, and a
97
+ // persisted recovery-gap marker survives the reopen instead of letting
98
+ // a known hole pass as completeness. The marker is honored unless
99
+ // every sequence in its interval is present again on disk — refills
100
+ // are journaled after the marker, so it is located first (a short
101
+ // backward pass: markers sit near the crash point) before the
102
+ // numbering scan counts coverage.
103
+ let gap = null;
104
+ for (const line of reverseLines(fd, stat.size)) {
105
+ const item = parseLine(line.text);
106
+ if (item.schema === HEADER) break;
107
+ if (item.type === 'workbench/history-gap') {
108
+ gap = { from: Number(item.from) || 0, to: Number(item.to) || 0, sessionId: item.sessionId || sessionId };
109
+ break;
110
+ }
111
+ }
112
+ let highest = 0;
113
+ const covered = new Set();
114
+ // A verifiably huge interval is not cheaply checkable for refill; it
115
+ // stays marked rather than risking a false heal.
116
+ const gapVerifiable = Boolean(gap) && gap.from > 0 && gap.to >= gap.from && gap.to - gap.from <= 1_000_000;
117
+ for (const line of reverseLines(fd, stat.size)) {
118
+ const item = parseLine(line.text);
119
+ if (item.schema === HEADER) break;
120
+ if (item.type === 'workbench/history-gap') continue;
121
+ const own = Number(item.streamSequence ?? item.sequence);
122
+ if (Number.isSafeInteger(own) && own > 0 && own > highest) highest = own;
123
+ // Coverage counts unique event sequences only. A checkpoint is a
124
+ // counter position, not an event: it may raise the restored mark
125
+ // (numbers the counter consumed without journaling rows) but it
126
+ // proves no content exists and therefore fills no hole. Duplicate
127
+ // rows collapse onto one sequence each.
128
+ if (!gapVerifiable || item.type === 'workbench/history-checkpoint') continue;
129
+ const sequence = Number(item.sequence);
130
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) continue;
131
+ // A merged delta row carries its whole fragment run: compactFrom is
132
+ // stamped only when real fragments with those sequences were folded
133
+ // into this row's text, so every sequence in the span is provably
134
+ // present by content.
135
+ const spanFrom = Number.isSafeInteger(item.compactFrom) && item.compactFrom > 0 && item.compactFrom <= sequence
136
+ ? item.compactFrom : sequence;
137
+ for (let at = Math.max(spanFrom, gap.from); at <= Math.min(sequence, gap.to); at++) covered.add(at);
138
+ }
139
+ this.highWater = highest;
140
+ if (gap && gap.from > 0 && gap.to >= gap.from) {
141
+ let healed = gapVerifiable;
142
+ for (let at = gap.from; at <= gap.to && healed; at++) if (!covered.has(at)) healed = false;
143
+ if (!healed) {
144
+ this.recoveryGap = gap;
145
+ this.gapRecorded = { from: gap.from, to: gap.to };
146
+ }
147
+ }
148
+ } finally { fs.closeSync(fd); }
149
+ }
150
+
151
+ append(event) {
152
+ // A legal counter is a positive safe integer; anything else is unstamped
153
+ // and mints the next forward slot instead of a coercible oddity.
154
+ const provided = Number(event.sequence);
155
+ const sequence = Number.isSafeInteger(provided) && provided > 0 ? provided : this.highWater + 1;
156
+ if (sequence <= this.highWater) return;
157
+ if (this.durableBarrier) {
158
+ // Frozen behind a failed durable head: the row stays queued in
159
+ // arrival order, but the in-memory mark must not advance - a raised
160
+ // mark would let later dedupe or retry logic treat the hole as
161
+ // already journaled.
162
+ compactInto(this.pending, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence });
163
+ if (!this.timer) {
164
+ this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
165
+ this.timer.unref?.();
166
+ }
167
+ return;
168
+ }
169
+ this.highWater = sequence;
170
+ compactInto(this.pending, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence });
171
+ if (['claude/result', 'claude/aborted', 'claude/error', 'claude/turn-end'].includes(event.type)
172
+ || (event.type === 'claude/state' && ['idle', 'failed', 'aborted'].includes(event.state))) this.flush();
173
+ else if (!this.timer) {
174
+ this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
175
+ this.timer.unref?.();
176
+ }
177
+ }
178
+
179
+ _newHeader() {
180
+ return {
181
+ schema: HEADER, sessionId: this.sessionId, contextId: this.contextId, incomplete: this.incomplete,
182
+ fileId: crypto.randomUUID(), createdAt: new Date().toISOString(),
183
+ };
184
+ }
185
+
186
+ // Durable receipt path for facts that must be proven on disk before
187
+ // anything is built on them. Success is returned only after write AND
188
+ // fsync; a failure reports its exact phase, rolls the file back to the
189
+ // last complete row, keeps prior rows and the plain-append pending queue
190
+ // intact, and never advances the high-water mark. The counter is strictly
191
+ // forward: sequences at or below the mark are rejected, not silently
192
+ // dropped or re-minted.
193
+ appendDurable(events) {
194
+ if (!Array.isArray(events) || events.length === 0) {
195
+ return { ok: false, phase: 'input', error: 'appendDurable requires a non-empty events array', highWater: this.highWater };
196
+ }
197
+ if (this.durableBarrier) {
198
+ // A failed head owns the next journal position: newer durable facts
199
+ // are refused instead of silently raising the mark over the hole.
200
+ // The retained batch is retried only through retryFailedBatch().
201
+ return { ok: false, phase: 'frozen', error: `journal is frozen behind failed sequence ${this.durableBarrier.sequence}`, highWater: this.highWater, pendingCount: this.pending.length };
202
+ }
203
+ const staged = [];
204
+ for (const event of events) {
205
+ const sequence = Number(event && event.sequence);
206
+ if (!event || typeof event !== 'object' || !Number.isSafeInteger(sequence) || sequence <= this.highWater) {
207
+ return { ok: false, phase: 'sequence', error: `sequence ${event && event.sequence} is not ahead of high-water ${this.highWater}`, highWater: this.highWater, pendingCount: this.pending.length };
208
+ }
209
+ // The staged row is an OWNED deep copy taken before any write attempt:
210
+ // a retained failed batch must replay the fact as first submitted, not
211
+ // whatever the caller (or an onError hook re-entering during the
212
+ // failure) later mutates in nested payload objects. A JSON round-trip
213
+ // is the file's own serialization semantics; anything it cannot
214
+ // express is refused with a fixed code that leaks no field content.
215
+ let own;
216
+ try {
217
+ own = JSON.parse(JSON.stringify(event));
218
+ } catch {
219
+ return { ok: false, phase: 'input', error: 'HISTORY_FACT_NOT_SERIALIZABLE', highWater: this.highWater, pendingCount: this.pending.length };
220
+ }
221
+ staged.push(Object.assign(own, { at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence }));
222
+ }
223
+ for (let i = 1; i < staged.length; i++) {
224
+ if (staged[i].sequence <= staged[i - 1].sequence) {
225
+ return { ok: false, phase: 'sequence', error: 'appendDurable batch sequences must strictly increase', highWater: this.highWater, pendingCount: this.pending.length };
226
+ }
227
+ }
228
+ const receipt = this._commitDurableRows(staged);
229
+ if (receipt.ok) this.highWater = staged[staged.length - 1].sequence;
230
+ else this._retainFailedBatch(staged, receipt.phase);
231
+ return receipt.ok
232
+ ? { ...receipt, sequence: this.highWater, highWater: this.highWater }
233
+ : { ...receipt, highWater: this.highWater };
234
+ }
235
+
236
+ // Retain the exact staged batch that failed so the same facts keep their
237
+ // original sequences for an in-place retry; the barrier head is the
238
+ // batch's first sequence. Nothing is cleared here - only a successful
239
+ // retry (or an explicitly recorded gap at close) may move past it.
240
+ _retainFailedBatch(staged, phase) {
241
+ if (!this.failedBatch) this.failedBatch = staged;
242
+ if (!this.durableBarrier) this.durableBarrier = { sequence: staged[0].sequence, phase: phase || 'write' };
243
+ }
244
+
245
+ // Same-batch, same-sequence retry of the retained failure. Success lands
246
+ // exactly the original rows once (the forward-only counter and the
247
+ // rollback guarantee no duplicate), clears the barrier and lets the
248
+ // frozen plain rows flush on the next flush(); failure keeps everything
249
+ // retryable and the file untouched past the last complete row.
250
+ retryFailedBatch() {
251
+ if (!this.failedBatch) return { ok: true, phase: 'none', highWater: this.highWater };
252
+ const staged = this.failedBatch;
253
+ const receipt = this._commitDurableRows(staged);
254
+ if (receipt.ok) {
255
+ this.highWater = Math.max(this.highWater, staged[staged.length - 1].sequence);
256
+ this.failedBatch = null;
257
+ this.durableBarrier = null;
258
+ return { ...receipt, sequence: this.highWater, highWater: this.highWater };
259
+ }
260
+ this.durableBarrier = this.durableBarrier || { sequence: staged[0].sequence, phase: receipt.phase };
261
+ return { ...receipt, highWater: this.highWater };
262
+ }
263
+
264
+ // Shared durable journal core: land any queued plain rows that sit BELOW
265
+ // the staged head first so the file stays arrival-ordered (a ceiling keeps
266
+ // rows above a barrier frozen), then write+fsync the staged rows in one
267
+ // 'r+' positioned write with half-line rollback. A stale error with
268
+ // nothing still queued below does not veto the attempt: the receipt
269
+ // itself is the retry, and a successful write+fsync clears it. A failed
270
+ // receipt leaves prior rows and the pending queue intact.
271
+ _commitDurableRows(staged) {
272
+ this.flush(staged[0].sequence);
273
+ // Out-of-order guard: a stale error only vetoes the attempt when rows
274
+ // that belong BELOW the staged head could not be landed - writing the
275
+ // staged batch past them would break arrival order. Frozen rows above
276
+ // the head are the barrier's business, not this write's.
277
+ const head = staged[0].sequence;
278
+ const blockedBelow = this.pending.some(row => {
279
+ const own = Number(row.streamSequence ?? row.sequence);
280
+ return !Number.isSafeInteger(own) || own < head;
281
+ });
282
+ if (this.error && blockedBelow) return { ok: false, phase: 'journal', error: this.error, pendingCount: this.pending.length };
283
+ let fd, start;
284
+ try {
285
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
286
+ if (!this.exists) {
287
+ const header = this._newHeader();
288
+ fs.writeFileSync(this.file, `${JSON.stringify(header)}\n`, { flag: 'wx', mode: 0o600 });
289
+ this.exists = true;
290
+ this.fileId = header.fileId;
291
+ this.createdAt = header.createdAt;
292
+ }
293
+ const payload = Buffer.from(staged.map(event => JSON.stringify(event)).join('\n') + '\n');
294
+ // 'r+' with explicit positions, not append mode: Windows refuses
295
+ // ftruncate on O_APPEND handles, which would turn the half-line
296
+ // rollback below into a silent no-op.
297
+ fd = fs.openSync(this.file, 'r+', 0o600);
298
+ start = fs.fstatSync(fd).size;
299
+ let written = 0;
300
+ while (written < payload.length) {
301
+ const count = fs.writeSync(fd, payload, written, payload.length - written, start + written);
302
+ if (!count) throw Object.assign(new Error('history write made no progress'), { code: 'EIO' });
303
+ written += count;
304
+ }
305
+ try { fs.fsyncSync(fd); } catch (fsyncError) { fsyncError.durablePhase = 'fsync'; throw fsyncError; }
306
+ this.error = null;
307
+ return { ok: true, phase: 'committed', count: staged.length, bytes: payload.length };
308
+ } catch (error) {
309
+ // No half line and no unproven durable row may survive a failed
310
+ // receipt; the rollback keeps every prior complete row so a retry
311
+ // rewrites exactly the failed batch.
312
+ if (fd !== undefined && start !== undefined) { try { fs.ftruncateSync(fd, start); } catch { /* rollback best effort; error state below still blocks reads */ } }
313
+ this.error = error.code || 'HISTORY_DURABLE_WRITE_FAILED';
314
+ this.onError(error);
315
+ return { ok: false, phase: error.durablePhase === 'fsync' ? 'fsync' : 'write', error: error.message, code: error.code || null, pendingCount: this.pending.length };
316
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
317
+ }
318
+
319
+ // Bounded backward scan of the durable archive: the newest rows inside
320
+ // (afterSequence, beforeSequence), at most maxLines of them, without the
321
+ // file ever being resident in memory. `complete` is true only when the
322
+ // page provably covers every durable row in that window (the scan crossed
323
+ // the lower boundary or hit the header) and no mixed numbering-era row
324
+ // was met; a page capped by maxLines reports truncated with
325
+ // nextBeforeSequence so callers page towards the past instead of
326
+ // re-reading the newest rows. Never returns an empty page as a stand-in
327
+ // for a complete archive.
328
+ durableTail({ afterSequence = 0, maxLines = 5000, beforeSequence } = {}) {
329
+ const after = Number(afterSequence);
330
+ const cap = Number(maxLines);
331
+ const before = beforeSequence === undefined || beforeSequence === null ? Number.POSITIVE_INFINITY : Number(beforeSequence);
332
+ if (!Number.isFinite(after) || after < 0) throw Object.assign(new Error('afterSequence must be a non-negative number'), { status: 400 });
333
+ if (Number.isNaN(before)) throw Object.assign(new Error('beforeSequence must be a number'), { status: 400 });
334
+ if (before <= after) throw Object.assign(new Error('beforeSequence must be greater than afterSequence'), { status: 400 });
335
+ if (!Number.isInteger(cap) || cap < 1 || cap > 20000) throw Object.assign(new Error('maxLines must be between 1 and 20000'), { status: 400 });
336
+ this.flush();
337
+ if (this.error) throw Object.assign(new Error('会话历史写入失败,请检查磁盘与本地日志。'), { status: 503, code: this.error });
338
+ if (!this.exists) return { events: [], complete: true, truncated: false, fromSequence: null, toSequence: null, nextBeforeSequence: null, highWater: this.highWater, incomplete: this.incomplete };
339
+ const fd = fs.openSync(this.file, 'r');
340
+ const collected = [];
341
+ let hitBoundary = false, mixedEra = false, smallest = null, largest = null;
342
+ try {
343
+ const size = fs.fstatSync(fd).size;
344
+ for (const line of reverseLines(fd, size)) {
345
+ const event = parseLine(line.text);
346
+ if (event.schema === HEADER) { hitBoundary = true; break; }
347
+ if (event.type === 'workbench/history-checkpoint') { mixedEra = true; continue; }
348
+ // A gap marker is known control metadata, not a mixed-numbering-era
349
+ // row: the hole it declares is reported separately via recoveryGap.
350
+ if (event.type === 'workbench/history-gap') continue;
351
+ const sequence = Number(event.sequence);
352
+ if (!Number.isFinite(sequence) || sequence <= 0) { mixedEra = true; continue; }
353
+ if (sequence <= after) { hitBoundary = true; break; }
354
+ if (sequence >= before) continue;
355
+ if (collected.length >= cap) break;
356
+ collected.push(event);
357
+ smallest = sequence;
358
+ if (largest === null) largest = sequence;
359
+ }
360
+ } finally { fs.closeSync(fd); }
361
+ const truncated = !hitBoundary;
362
+ return {
363
+ events: collected.reverse(),
364
+ complete: hitBoundary && !mixedEra,
365
+ truncated,
366
+ fromSequence: smallest,
367
+ toSequence: largest,
368
+ nextBeforeSequence: truncated && smallest !== null ? smallest : null,
369
+ highWater: this.highWater,
370
+ incomplete: this.incomplete,
371
+ };
372
+ }
373
+
374
+ // Exact lookup of one committed numbered row, scanned backwards with an
375
+ // early exit: rows arrive in ascending order, so the first row numbered
376
+ // below the target proves the target absent. Returns null when the row
377
+ // cannot be proven present.
378
+ durableRow(sequence) {
379
+ const target = Number(sequence);
380
+ if (!Number.isSafeInteger(target) || target <= 0 || !this.exists) return null;
381
+ const fd = fs.openSync(this.file, 'r');
382
+ try {
383
+ for (const line of reverseLines(fd, fs.fstatSync(fd).size)) {
384
+ const item = parseLine(line.text);
385
+ if (item.schema === HEADER) break;
386
+ if (item.type === 'workbench/history-checkpoint' || item.type === 'workbench/history-gap') continue;
387
+ const own = Number(item.sequence);
388
+ if (own === target) return item;
389
+ if (Number.isSafeInteger(own) && own < target) break;
390
+ }
391
+ } finally { fs.closeSync(fd); }
392
+ return null;
393
+ }
394
+
395
+ // The recovery gap is control metadata in the journal's own row space and
396
+ // earns the same receipt contract as durable facts: the marker counts as
397
+ // recorded only after a proven write+fsync, so a failed write keeps the
398
+ // original rows and a retryable state instead of silently skipping the
399
+ // marker on a later call. The interval is rewritten only when it changes.
400
+ recordRecoveryGap(from, to) {
401
+ const start = Number(from), end = Number(to);
402
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start <= 0 || end < start) {
403
+ return { ok: false, phase: 'input', highWater: this.highWater };
404
+ }
405
+ this.recoveryGap = { from: start, to: end, sessionId: this.sessionId };
406
+ if (this.gapRecorded && this.gapRecorded.from === start && this.gapRecorded.to === end) {
407
+ return { ok: true, phase: 'already-recorded', highWater: this.highWater };
408
+ }
409
+ // The marker is control metadata, not a native fact: its failed receipt
410
+ // is diagnosed through the return value and onError, but it must not
411
+ // raise the sticky read veto - readers keep serving with the in-memory
412
+ // gap instead of losing the whole journal to a metadata write.
413
+ const priorError = this.error;
414
+ const receipt = this._commitDurableRows([{ type: 'workbench/history-gap', from: start, to: end, sessionId: this.sessionId }]);
415
+ if (!receipt.ok) this.error = priorError;
416
+ if (receipt.ok) {
417
+ this.gapRecorded = { from: start, to: end };
418
+ // A landed marker declares the hole honestly; rows above the interval
419
+ // may flush again. The retained failed batch (if any) stays retained:
420
+ // its sequences sit inside the interval and belong to a retry.
421
+ if (this.durableBarrier && this.durableBarrier.phase === 'gap-marker') this.durableBarrier = null;
422
+ } else if (!this.durableBarrier) {
423
+ // While the marker itself cannot be proven on disk, no plain row may
424
+ // flush past the unrecorded hole either.
425
+ this.durableBarrier = { sequence: start, phase: 'gap-marker' };
426
+ }
427
+ return { ...receipt, highWater: this.highWater };
428
+ }
429
+
430
+ containsNativeFacts() {
431
+ if (!this.exists) return false;
432
+ const fd = fs.openSync(this.file, 'r');
433
+ try {
434
+ for (const line of reverseLines(fd, fs.fstatSync(fd).size)) {
435
+ if (!line.text.includes('"claude/native-')) continue;
436
+ if (isNativeFactType(parseLine(line.text).type)) return true;
437
+ }
438
+ } finally { fs.closeSync(fd); }
439
+ return false;
440
+ }
441
+
442
+ seedRecovered(events, streamSequence) {
443
+ // A valid-but-incomplete archive that already carries native facts is
444
+ // collected evidence: replacing it with a recovered chat transcript
445
+ // would destroy task rows no other source can rebuild. Keep the file
446
+ // and the incomplete flag; the refusal is an explicit receipt.
447
+ if (this.exists && this.containsNativeFacts()) {
448
+ this.incomplete = true;
449
+ const refusal = Object.assign(new Error('会话历史含原生任务事实,拒绝恢复覆盖;原文件已保留。'), { code: 'HISTORY_NATIVE_FACTS_REFUSED' });
450
+ this.onError(refusal);
451
+ return { ok: false, refused: true, reason: 'native-facts', highWater: this.highWater };
452
+ }
453
+ const recovered = [];
454
+ for (const event of events) compactInto(recovered, { ...event, at: event.at || this.startedAt, sessionId: this.sessionId, contextId: this.contextId, sequence: 0 });
455
+ recovered.push({ type: 'workbench/history-checkpoint', streamSequence });
456
+ const header = { schema: HEADER, sessionId: this.sessionId, contextId: this.contextId, incomplete: false, fileId: crypto.randomUUID(), createdAt: new Date().toISOString() };
457
+ const temporary = `${this.file}.${process.pid}.${crypto.randomUUID()}.recovery.tmp`;
458
+ try {
459
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
460
+ fs.writeFileSync(temporary, [header, ...recovered].map(item => JSON.stringify(item)).join('\n') + '\n', { mode: 0o600, flag: 'wx' });
461
+ fs.renameSync(temporary, this.file);
462
+ this.pending = []; this.highWater = streamSequence; this.incomplete = false; this.exists = true; this.error = null;
463
+ this.fileId = header.fileId; this.createdAt = header.createdAt;
464
+ return { ok: true, refused: false, highWater: this.highWater };
465
+ } catch (error) {
466
+ this.onError(error);
467
+ return { ok: false, refused: false, error: error.message, code: error.code || null, highWater: this.highWater };
468
+ } finally { try { fs.unlinkSync(temporary); } catch {} }
469
+ }
470
+
471
+ // Forward-only: a checkpoint may raise the mark (numbers the counter
472
+ // consumed without journaling rows) but may never lower it — old rows
473
+ // keep their numbering and nothing re-mints a sequence the archive
474
+ // already holds. A frozen journal refuses: the mark must not jump a
475
+ // failed head, and the checkpoint row would flush above the barrier.
476
+ resetStreamSequence(streamSequence) {
477
+ const target = Number(streamSequence);
478
+ if (!Number.isSafeInteger(target) || target <= this.highWater) return false;
479
+ if (this.durableBarrier) return false;
480
+ this.highWater = target;
481
+ this.pending.push({ type: 'workbench/history-checkpoint', streamSequence: target });
482
+ this.flush();
483
+ return true;
484
+ }
485
+
486
+ // Land queued plain rows. Without a ceiling the call is a free flush: it
487
+ // is refused while a durable barrier is armed (rows above a failed head
488
+ // must never reach the file first) and re-arms the timer so an unfrozen
489
+ // journal drains later. A ceiling (the staged head of a durable commit)
490
+ // lands only the rows that sit below it, keeping arrival order even
491
+ // during a retry of the retained failed batch. Rows queued while frozen
492
+ // never raised the in-memory mark, so a successful flush adopts the
493
+ // highest written sequence instead.
494
+ flush(ceiling) {
495
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
496
+ const limit = Number.isSafeInteger(ceiling) && ceiling > 0 ? ceiling : null;
497
+ if (this.durableBarrier && limit === null) {
498
+ if (this.pending.length && !this.timer) {
499
+ this.timer = setTimeout(() => { this.timer = null; this.flush(); }, 250);
500
+ this.timer.unref?.();
501
+ }
502
+ return;
503
+ }
504
+ if (!this.pending.length) return;
505
+ let rows = this.pending;
506
+ if (limit !== null) {
507
+ let split = rows.length;
508
+ for (let i = 0; i < rows.length; i++) {
509
+ const own = Number(rows[i].streamSequence ?? rows[i].sequence);
510
+ if (Number.isSafeInteger(own) && own >= limit) { split = i; break; }
511
+ }
512
+ if (split === 0) return;
513
+ rows = rows.slice(0, split);
514
+ }
515
+ let fd, start;
516
+ try {
517
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
518
+ if (!this.exists) {
519
+ const header = this._newHeader();
520
+ fs.writeFileSync(this.file, `${JSON.stringify(header)}\n`, { flag: 'wx', mode: 0o600 });
521
+ this.exists = true;
522
+ this.fileId = header.fileId;
523
+ this.createdAt = header.createdAt;
524
+ }
525
+ const payload = Buffer.from(rows.map(event => JSON.stringify(event)).join('\n') + '\n');
526
+ fd = fs.openSync(this.file, 'r+', 0o600);
527
+ start = fs.fstatSync(fd).size;
528
+ let written = 0;
529
+ while (written < payload.length) {
530
+ const count = fs.writeSync(fd, payload, written, payload.length - written, start + written);
531
+ if (!count) throw Object.assign(new Error('history write made no progress'), { code: 'EIO' });
532
+ written += count;
533
+ }
534
+ this.pending = this.pending.slice(rows.length);
535
+ let landed = 0;
536
+ for (const row of rows) {
537
+ const own = Number(row.streamSequence ?? row.sequence);
538
+ if (Number.isSafeInteger(own) && own > landed) landed = own;
539
+ }
540
+ if (landed > this.highWater) this.highWater = landed;
541
+ this.error = null;
542
+ } catch (error) {
543
+ // A failed append must not leave half a JSON line before the retry.
544
+ // This archive has one owning service/writer; prior complete rows stay intact.
545
+ if (fd !== undefined && start !== undefined) try { fs.ftruncateSync(fd, start); } catch {}
546
+ this.error = error.code || 'HISTORY_WRITE_FAILED';
547
+ this.onError(error);
548
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
549
+ }
550
+
551
+ read({ before, limit = 20 } = {}) {
552
+ const count = Number(limit);
553
+ if (!Number.isInteger(count) || count < 1 || count > 100) throw Object.assign(new Error('limit must be between 1 and 100'), { status: 400 });
554
+ if (before !== undefined && before !== null && !/^[1-9]\d*$/.test(String(before))) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
555
+ this.flush();
556
+ if (this.error) throw Object.assign(new Error('会话历史写入失败,请检查磁盘与本地日志。'), { status: 503, code: this.error });
557
+ if (!this.exists) return { events: [], nextCursor: null, incomplete: this.incomplete, highWater: this.highWater, ...(this.recoveryGap ? { recoveryGap: this.recoveryGap } : {}) };
558
+ const fd = fs.openSync(this.file, 'r');
559
+ const selected = [], prelude = [];
560
+ let cursor = null, prompts = 0;
561
+ try {
562
+ const size = fs.fstatSync(fd).size;
563
+ const end = before == null ? size : Number(before);
564
+ if (!Number.isSafeInteger(end) || end > size) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
565
+ if (before != null) {
566
+ const previous = Buffer.alloc(1); fs.readSync(fd, previous, 0, 1, end - 1);
567
+ if (previous[0] !== 10) throw Object.assign(new Error('invalid history cursor'), { status: 400 });
568
+ }
569
+ let boundary = null;
570
+ for (const line of reverseLines(fd, end)) {
571
+ const event = parseLine(line.text);
572
+ if (event.schema === HEADER || event.type === 'workbench/history-checkpoint' || event.type === 'workbench/history-gap') continue;
573
+ if (prompts >= count) {
574
+ if (event.type === 'claude/user-prompt') { cursor = boundary; break; }
575
+ prelude.push(event);
576
+ continue;
577
+ }
578
+ selected.push(event);
579
+ if (event.type === 'claude/user-prompt') { prompts++; boundary = line.offset; }
580
+ }
581
+ if (cursor === null) selected.push(...prelude);
582
+ } finally { fs.closeSync(fd); }
583
+ const events = [];
584
+ for (const event of selected.reverse()) compactInto(events, event);
585
+ return { events, nextCursor: cursor === null ? null : String(cursor), incomplete: this.incomplete, highWater: this.highWater, ...(this.recoveryGap ? { recoveryGap: this.recoveryGap } : {}) };
586
+ }
587
+
588
+ // Shutdown contract for a journaled history: a retained failed batch is
589
+ // retried in place first; only if that still fails is the hole persisted
590
+ // as a recovery-gap marker (never the other way around - the queue is
591
+ // never cleared to fake a drain). A landed marker lets the frozen plain
592
+ // rows flush as an honest partial refill; a marker that cannot land
593
+ // leaves everything queued and the failure diagnosable.
594
+ close() {
595
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
596
+ if (this.failedBatch) {
597
+ const retried = this.retryFailedBatch();
598
+ if (!retried.ok) {
599
+ const head = this.failedBatch[0].sequence;
600
+ let ceiling = this.failedBatch[this.failedBatch.length - 1].sequence;
601
+ for (const row of this.pending) {
602
+ const own = Number(row.streamSequence ?? row.sequence);
603
+ if (Number.isSafeInteger(own) && own > ceiling) ceiling = own;
604
+ }
605
+ this.recordRecoveryGap(head, ceiling);
606
+ }
607
+ }
608
+ this.flush();
609
+ }
610
+ }
611
+
612
+ module.exports = { SessionHistory, compactInto, reverseLines };