@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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 };
|