sidekick-shared 0.28.3 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +188 -27
- package/dist/aggregation/EventAggregator.d.ts +19 -0
- package/dist/aggregation/EventAggregator.js +174 -35
- package/dist/aggregation/snapshot.d.ts +37 -8
- package/dist/aggregation/snapshot.js +31 -9
- package/dist/aggregation/types.d.ts +13 -0
- package/dist/authStatus.d.ts +54 -0
- package/dist/authStatus.js +91 -0
- package/dist/browser.d.ts +16 -6
- package/dist/browser.js +24 -2
- package/dist/codex.d.ts +23 -0
- package/dist/codex.js +44 -0
- package/dist/codexProfiles.d.ts +1 -0
- package/dist/codexProfiles.js +8 -11
- package/dist/context/sessionContext.js +4 -0
- package/dist/esm/aggregation/EventAggregator.js +174 -35
- package/dist/esm/aggregation/snapshot.js +31 -9
- package/dist/esm/authStatus.js +87 -0
- package/dist/esm/browser.js +5 -2
- package/dist/esm/codex.js +18 -0
- package/dist/esm/codexProfiles.js +8 -11
- package/dist/esm/context/sessionContext.js +4 -0
- package/dist/esm/formatters/toolSummary.js +4 -1
- package/dist/esm/formatting.js +10 -1
- package/dist/esm/index.js +11 -6
- package/dist/esm/modelCatalog.js +7 -5
- package/dist/esm/modelContext.js +4 -3
- package/dist/esm/modelInfo.js +425 -47
- package/dist/esm/node.js +9 -3
- package/dist/esm/observedContextWindows.js +2 -1
- package/dist/esm/ownKey.js +26 -0
- package/dist/esm/parsers/codexParser.js +351 -54
- package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
- package/dist/esm/parsers/openCodeParser.js +85 -22
- package/dist/esm/parsers/subagentScanner.js +33 -12
- package/dist/esm/parsers/subagentTraceParser.js +97 -37
- package/dist/esm/pricing.js +18 -0
- package/dist/esm/pricingCatalog.js +17 -9
- package/dist/esm/promptHistory.js +66 -4
- package/dist/esm/providerFailure.js +124 -3
- package/dist/esm/providers/claudeCode.js +10 -2
- package/dist/esm/providers/codex.js +84 -62
- package/dist/esm/providers/codexDatabase.js +3 -19
- package/dist/esm/providers/codexRollouts.js +24 -1
- package/dist/esm/providers/openCode.js +9 -17
- package/dist/esm/report/htmlReportGenerator.js +42 -5
- package/dist/esm/report/transcriptParser.js +13 -11
- package/dist/esm/schemas/providerFailure.js +3 -1
- package/dist/esm/schemas/sessionEvent.js +35 -14
- package/dist/esm/schemas/transcript.js +4 -0
- package/dist/esm/sessionPromptHistory.js +2 -2
- package/dist/esm/statusline/payload.js +9 -0
- package/dist/esm/subagentSpawn.js +163 -0
- package/dist/esm/testing/credentialGuard.js +87 -5
- package/dist/esm/toolCallOrigins.js +310 -0
- package/dist/esm/transcript.js +46 -3
- package/dist/esm/turns/assistantTurn.js +3 -2
- package/dist/esm/usage/claudeUsageDedupe.js +68 -0
- package/dist/esm/usage/usageEvents.js +146 -17
- package/dist/esm/usage/usageReports.js +39 -6
- package/dist/esm/watchers/eventBridge.js +15 -0
- package/dist/esm/watchers/factory.js +5 -3
- package/dist/esm/watchers/jsonlTail.js +442 -58
- package/dist/esm/watchers/jsonlWatcher.js +51 -4
- package/dist/esm/watchers/providerReaderWatcher.js +13 -0
- package/dist/esm/zaiQuota.js +2 -1
- package/dist/formatters/toolSummary.js +4 -1
- package/dist/formatting.d.ts +26 -1
- package/dist/formatting.js +11 -1
- package/dist/index.d.ts +22 -11
- package/dist/index.js +44 -12
- package/dist/modelCatalog.js +7 -5
- package/dist/modelContext.js +4 -3
- package/dist/modelInfo.d.ts +185 -16
- package/dist/modelInfo.js +434 -49
- package/dist/node.d.ts +16 -5
- package/dist/node.js +33 -2
- package/dist/observedContextWindows.js +2 -1
- package/dist/ownKey.d.ts +16 -0
- package/dist/ownKey.js +30 -0
- package/dist/parsers/codexParser.d.ts +118 -0
- package/dist/parsers/codexParser.js +352 -54
- package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
- package/dist/parsers/codexRolloutOrigin.js +111 -0
- package/dist/parsers/openCodeParser.d.ts +38 -0
- package/dist/parsers/openCodeParser.js +88 -22
- package/dist/parsers/subagentScanner.d.ts +19 -6
- package/dist/parsers/subagentScanner.js +33 -12
- package/dist/parsers/subagentTraceParser.d.ts +25 -5
- package/dist/parsers/subagentTraceParser.js +97 -37
- package/dist/pricing.d.ts +22 -0
- package/dist/pricing.js +50 -0
- package/dist/pricingCatalog.d.ts +10 -0
- package/dist/pricingCatalog.js +16 -8
- package/dist/promptHistory.d.ts +65 -4
- package/dist/promptHistory.js +68 -5
- package/dist/providerFailure.d.ts +44 -0
- package/dist/providerFailure.js +126 -4
- package/dist/providers/claudeCode.js +9 -1
- package/dist/providers/codex.d.ts +3 -22
- package/dist/providers/codex.js +85 -64
- package/dist/providers/codexDatabase.d.ts +3 -2
- package/dist/providers/codexDatabase.js +4 -22
- package/dist/providers/codexRollouts.d.ts +22 -0
- package/dist/providers/codexRollouts.js +24 -0
- package/dist/providers/openCode.js +8 -16
- package/dist/providers/types.d.ts +14 -0
- package/dist/report/htmlReportGenerator.js +42 -5
- package/dist/report/transcriptParser.d.ts +10 -1
- package/dist/report/transcriptParser.js +13 -11
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/providerFailure.d.ts +39 -0
- package/dist/schemas/providerFailure.js +2 -0
- package/dist/schemas/sessionEvent.d.ts +27 -10
- package/dist/schemas/sessionEvent.js +35 -14
- package/dist/schemas/transcript.d.ts +2 -0
- package/dist/schemas/transcript.js +4 -0
- package/dist/sessionPromptHistory.d.ts +7 -1
- package/dist/sessionPromptHistory.js +1 -1
- package/dist/statusline/payload.d.ts +10 -0
- package/dist/statusline/payload.js +14 -0
- package/dist/subagentSpawn.d.ts +80 -0
- package/dist/subagentSpawn.js +174 -0
- package/dist/testing/credentialGuard.d.ts +14 -0
- package/dist/testing/credentialGuard.js +87 -5
- package/dist/toolCallOrigins.d.ts +70 -0
- package/dist/toolCallOrigins.js +346 -0
- package/dist/transcript.d.ts +12 -0
- package/dist/transcript.js +46 -3
- package/dist/turns/assistantTurn.d.ts +1 -0
- package/dist/turns/assistantTurn.js +3 -2
- package/dist/types/opencode.d.ts +2 -0
- package/dist/usage/claudeUsageDedupe.d.ts +31 -0
- package/dist/usage/claudeUsageDedupe.js +68 -0
- package/dist/usage/usageEvents.d.ts +54 -6
- package/dist/usage/usageEvents.js +146 -17
- package/dist/usage/usageReports.d.ts +35 -4
- package/dist/usage/usageReports.js +39 -6
- package/dist/watchers/eventBridge.js +15 -0
- package/dist/watchers/factory.d.ts +6 -0
- package/dist/watchers/factory.js +5 -3
- package/dist/watchers/jsonlTail.d.ts +124 -4
- package/dist/watchers/jsonlTail.js +446 -59
- package/dist/watchers/jsonlWatcher.d.ts +18 -1
- package/dist/watchers/jsonlWatcher.js +50 -3
- package/dist/watchers/providerReaderWatcher.d.ts +7 -0
- package/dist/watchers/providerReaderWatcher.js +13 -0
- package/dist/watchers/types.d.ts +12 -0
- package/dist/zaiQuota.js +2 -1
- package/package.json +41 -1
|
@@ -11,29 +11,112 @@ const DEFAULT_CATCHUP_INTERVAL_MS = 30_000;
|
|
|
11
11
|
export const DEFAULT_JSONL_TAIL_READ_BYTES = 1024 * 1024;
|
|
12
12
|
/** Default longest line parsed; longer lines are skipped and reported. */
|
|
13
13
|
export const DEFAULT_JSONL_TAIL_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
/** The `version` written into, and accepted from, {@link JsonlTailCheckpoint}. */
|
|
15
|
+
export const JSONL_TAIL_CHECKPOINT_VERSION = 1;
|
|
16
|
+
const BACKWARD_SCAN_BYTES = 64 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Skip old complete events while retaining an in-progress final line: the
|
|
19
|
+
* offset just after the last newline in the first `size` bytes, or 0.
|
|
20
|
+
*/
|
|
21
|
+
function lastCompleteLineOffset(fd, size) {
|
|
22
|
+
let end = size;
|
|
23
|
+
const buffer = Buffer.alloc(BACKWARD_SCAN_BYTES);
|
|
24
|
+
while (end > 0) {
|
|
25
|
+
const start = Math.max(0, end - buffer.length);
|
|
26
|
+
const count = fs.readSync(fd, buffer, 0, end - start, start);
|
|
27
|
+
const newline = buffer.subarray(0, count).lastIndexOf(0x0a);
|
|
28
|
+
if (newline >= 0)
|
|
29
|
+
return start + newline + 1;
|
|
30
|
+
end = start;
|
|
29
31
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
async function lastCompleteLineOffsetAsync(handle, size) {
|
|
35
|
+
let end = size;
|
|
36
|
+
const buffer = Buffer.alloc(BACKWARD_SCAN_BYTES);
|
|
37
|
+
while (end > 0) {
|
|
38
|
+
const start = Math.max(0, end - buffer.length);
|
|
39
|
+
const { bytesRead } = await handle.read(buffer, 0, end - start, start);
|
|
40
|
+
const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
41
|
+
if (newline >= 0)
|
|
42
|
+
return start + newline + 1;
|
|
43
|
+
end = start;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reported to `onError` (and nothing is read) when `noFollow` is set and the
|
|
49
|
+
* tailed path is a symbolic link.
|
|
50
|
+
*/
|
|
51
|
+
export class JsonlTailSymlinkError extends Error {
|
|
52
|
+
path;
|
|
53
|
+
code = 'JSONL_TAIL_SYMLINK';
|
|
54
|
+
constructor(path) {
|
|
55
|
+
super(`Refusing to follow the symbolic link at ${path} (noFollow is set).`);
|
|
56
|
+
this.path = path;
|
|
57
|
+
this.name = 'JsonlTailSymlinkError';
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
export function createJsonlTail(options) {
|
|
35
61
|
return new JsonlTailReader(options);
|
|
36
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Records a {@link JsonlTailCheckpoint} for byte `offset` of `filePath`
|
|
65
|
+
* without a tail, for readers that keep their own offset (a snapshot that
|
|
66
|
+
* stores a reader position, for example). The checkpoint carries the file's
|
|
67
|
+
* current device and inode and a digest of its first bytes, so
|
|
68
|
+
* {@link verifyJsonlCheckpoint} (or {@link JsonlTail.resumeFrom}) can later
|
|
69
|
+
* tell whether `offset` still refers to the same, append-only content.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` when the file is missing or unreadable, or when `offset` is
|
|
72
|
+
* not a resume-safe position in it: negative, past the end, or not 0 and not
|
|
73
|
+
* just after a newline. Follows symbolic links.
|
|
74
|
+
*/
|
|
75
|
+
export function captureJsonlCheckpoint(filePath, offset) {
|
|
76
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
77
|
+
return null;
|
|
78
|
+
let fd = null;
|
|
79
|
+
try {
|
|
80
|
+
fd = fs.openSync(filePath, 'r');
|
|
81
|
+
const stat = fs.fstatSync(fd);
|
|
82
|
+
if (offset > stat.size)
|
|
83
|
+
return null;
|
|
84
|
+
if (offset > 0 && readAt(fd, offset - 1, 1)[0] !== 0x0a)
|
|
85
|
+
return null;
|
|
86
|
+
return checkpointOf(offset, stat, fileHeadOf(readPrefix(fd, stat)));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
closeQuietly(fd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether `checkpoint` (any value, typically parsed from JSON) is a valid
|
|
97
|
+
* {@link JsonlTailCheckpoint} that still describes `filePath`: same device
|
|
98
|
+
* and inode, the recorded first bytes unchanged, and its offset within the
|
|
99
|
+
* file and just after a newline (or 0). This is the check
|
|
100
|
+
* {@link JsonlTail.resumeFrom} applies. `false` on any error, including a
|
|
101
|
+
* missing file. Follows symbolic links.
|
|
102
|
+
*/
|
|
103
|
+
export function verifyJsonlCheckpoint(filePath, checkpoint) {
|
|
104
|
+
const parsed = parseCheckpoint(checkpoint);
|
|
105
|
+
if (parsed === null)
|
|
106
|
+
return false;
|
|
107
|
+
let fd = null;
|
|
108
|
+
try {
|
|
109
|
+
fd = fs.openSync(filePath, 'r');
|
|
110
|
+
const stat = fs.fstatSync(fd);
|
|
111
|
+
return checkpointMatches(parsed, fd, stat, readPrefix(fd, stat));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
closeQuietly(fd);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
37
120
|
class JsonlTailReader {
|
|
38
121
|
options;
|
|
39
122
|
active = false;
|
|
@@ -51,20 +134,34 @@ class JsonlTailReader {
|
|
|
51
134
|
lastMtimeMs = -1;
|
|
52
135
|
maxReadBytes;
|
|
53
136
|
maxLineBytes;
|
|
137
|
+
rewritePolicy;
|
|
54
138
|
fsWatcher = null;
|
|
55
139
|
debounceTimer = null;
|
|
56
140
|
catchupTimer = null;
|
|
57
141
|
eventsInCurrentBatch = 0;
|
|
58
142
|
parser;
|
|
143
|
+
/** Collects parsed events while a `read()` is running. */
|
|
144
|
+
collector = null;
|
|
145
|
+
/** Serializes `read()` calls. */
|
|
146
|
+
readChain = Promise.resolve();
|
|
147
|
+
/** `read()` calls queued or running; `readNow()` waits for them. */
|
|
148
|
+
queuedReads = 0;
|
|
149
|
+
readNowDeferred = false;
|
|
150
|
+
/** Bumped by repositioning calls so an in-flight `read()` stops early. */
|
|
151
|
+
generation = 0;
|
|
152
|
+
/** `resumeFrom()` chose the position; `start()` must not move it to the end. */
|
|
153
|
+
positioned = false;
|
|
59
154
|
constructor(options) {
|
|
60
155
|
this.options = options;
|
|
61
156
|
this.readOffset = options.startOffset ?? 0;
|
|
62
157
|
this.committedOffset = this.readOffset;
|
|
63
158
|
this.maxReadBytes = positiveInteger(options.maxReadBytes, DEFAULT_JSONL_TAIL_READ_BYTES);
|
|
64
159
|
this.maxLineBytes = positiveInteger(options.maxLineBytes, DEFAULT_JSONL_TAIL_MAX_LINE_BYTES);
|
|
160
|
+
this.rewritePolicy = options.onRewrite === 'end' ? 'end' : 'restart';
|
|
65
161
|
this.parser = new JsonlParser({
|
|
66
162
|
onEvent: (event) => {
|
|
67
163
|
this.eventsInCurrentBatch += 1;
|
|
164
|
+
this.collector?.push({ event, line: this.currentLine });
|
|
68
165
|
this.options.onEvent(event, this.currentLine);
|
|
69
166
|
},
|
|
70
167
|
onError: (error, line) => this.options.onError?.(error, line),
|
|
@@ -77,13 +174,18 @@ class JsonlTailReader {
|
|
|
77
174
|
if (this.active)
|
|
78
175
|
return;
|
|
79
176
|
this.active = true;
|
|
80
|
-
if (this.options.startAtEnd && this.options.startOffset === undefined) {
|
|
177
|
+
if (this.options.startAtEnd && this.options.startOffset === undefined && !this.positioned) {
|
|
178
|
+
let fd = null;
|
|
81
179
|
try {
|
|
82
|
-
|
|
180
|
+
fd = this.openSync();
|
|
181
|
+
this.readOffset = lastCompleteLineOffset(fd, fs.fstatSync(fd).size);
|
|
83
182
|
}
|
|
84
183
|
catch {
|
|
85
184
|
this.readOffset = 0;
|
|
86
185
|
}
|
|
186
|
+
finally {
|
|
187
|
+
closeQuietly(fd);
|
|
188
|
+
}
|
|
87
189
|
this.committedOffset = this.readOffset;
|
|
88
190
|
}
|
|
89
191
|
this.readNow();
|
|
@@ -115,11 +217,20 @@ class JsonlTailReader {
|
|
|
115
217
|
this.stop();
|
|
116
218
|
}
|
|
117
219
|
readNow() {
|
|
220
|
+
if (this.queuedReads > 0) {
|
|
221
|
+
this.readNowDeferred = true;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
118
224
|
let fd = null;
|
|
119
225
|
try {
|
|
120
|
-
fd =
|
|
226
|
+
fd = this.openSync();
|
|
121
227
|
const stat = fs.fstatSync(fd);
|
|
122
|
-
this.
|
|
228
|
+
const probe = this.probeNeeded(stat) ? this.probeSync(fd, stat) : null;
|
|
229
|
+
const reason = this.resetReason(stat, probe);
|
|
230
|
+
if (reason !== null) {
|
|
231
|
+
this.reset(reason, this.rewritePolicy === 'end' ? lastCompleteLineOffset(fd, stat.size) : 0);
|
|
232
|
+
}
|
|
233
|
+
this.adoptFile(stat, probe);
|
|
123
234
|
if (stat.size <= this.readOffset)
|
|
124
235
|
return;
|
|
125
236
|
this.eventsInCurrentBatch = 0;
|
|
@@ -143,61 +254,166 @@ class JsonlTailReader {
|
|
|
143
254
|
});
|
|
144
255
|
}
|
|
145
256
|
catch (error) {
|
|
146
|
-
this.options.onError?.(
|
|
257
|
+
this.options.onError?.(toError(error));
|
|
147
258
|
}
|
|
148
259
|
finally {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
260
|
+
closeQuietly(fd);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
read() {
|
|
264
|
+
this.queuedReads += 1;
|
|
265
|
+
const run = this.readChain.then(() => this.readAsync());
|
|
266
|
+
this.readChain = run.catch(() => undefined);
|
|
267
|
+
return run;
|
|
268
|
+
}
|
|
269
|
+
async readAsync() {
|
|
270
|
+
const generation = this.generation;
|
|
271
|
+
const events = [];
|
|
272
|
+
let reset;
|
|
273
|
+
let bytesRead = 0;
|
|
274
|
+
let eventsRead = 0;
|
|
275
|
+
let handle = null;
|
|
276
|
+
try {
|
|
277
|
+
handle = await this.openAsync();
|
|
278
|
+
const stat = await handle.stat();
|
|
279
|
+
const probe = this.probeNeeded(stat) ? await this.probeAsync(handle, stat) : null;
|
|
280
|
+
if (generation === this.generation) {
|
|
281
|
+
const reason = this.resetReason(stat, probe);
|
|
282
|
+
const offset = reason !== null && this.rewritePolicy === 'end'
|
|
283
|
+
? await lastCompleteLineOffsetAsync(handle, stat.size)
|
|
284
|
+
: 0;
|
|
285
|
+
if (generation === this.generation) {
|
|
286
|
+
if (reason !== null)
|
|
287
|
+
reset = this.reset(reason, offset);
|
|
288
|
+
this.adoptFile(stat, probe);
|
|
289
|
+
if (stat.size > this.readOffset) {
|
|
290
|
+
this.eventsInCurrentBatch = 0;
|
|
291
|
+
const chunk = Buffer.alloc(Math.min(this.maxReadBytes, stat.size - this.readOffset));
|
|
292
|
+
while (this.readOffset < stat.size) {
|
|
293
|
+
const want = Math.min(chunk.length, stat.size - this.readOffset);
|
|
294
|
+
const position = this.readOffset;
|
|
295
|
+
const { bytesRead: count } = await handle.read(chunk, 0, want, position);
|
|
296
|
+
if (count <= 0 || generation !== this.generation)
|
|
297
|
+
break;
|
|
298
|
+
this.collector = events;
|
|
299
|
+
try {
|
|
300
|
+
this.consume(chunk.subarray(0, count), position);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
this.collector = null;
|
|
304
|
+
}
|
|
305
|
+
this.readOffset += count;
|
|
306
|
+
bytesRead += count;
|
|
307
|
+
}
|
|
308
|
+
eventsRead = this.eventsInCurrentBatch;
|
|
309
|
+
if (bytesRead > 0) {
|
|
310
|
+
this.options.onBatchComplete?.({
|
|
311
|
+
bytesRead,
|
|
312
|
+
eventsRead,
|
|
313
|
+
offset: this.committedOffset,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
155
317
|
}
|
|
156
318
|
}
|
|
157
319
|
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
this.options.onError?.(toError(error));
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (handle)
|
|
325
|
+
await handle.close().catch(() => undefined);
|
|
326
|
+
this.queuedReads -= 1;
|
|
327
|
+
if (this.queuedReads === 0 && this.readNowDeferred) {
|
|
328
|
+
this.readNowDeferred = false;
|
|
329
|
+
this.readNow();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
bytesRead,
|
|
334
|
+
eventsRead,
|
|
335
|
+
offset: this.committedOffset,
|
|
336
|
+
events,
|
|
337
|
+
...(reset ? { reset } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Whether the continuity check needs bytes from the file. */
|
|
341
|
+
probeNeeded(stat) {
|
|
342
|
+
return (this.identity === null ||
|
|
343
|
+
stat.dev !== this.identity.dev ||
|
|
344
|
+
stat.ino !== this.identity.ino ||
|
|
345
|
+
stat.size < this.readOffset ||
|
|
346
|
+
stat.mtimeMs !== this.lastMtimeMs ||
|
|
347
|
+
this.head === null ||
|
|
348
|
+
this.head.bytes < Math.min(HEAD_BYTES, stat.size));
|
|
349
|
+
}
|
|
350
|
+
probeSync(fd, stat) {
|
|
351
|
+
return {
|
|
352
|
+
prefix: readPrefix(fd, stat),
|
|
353
|
+
before: this.committedOffset > 0 && this.committedOffset <= stat.size
|
|
354
|
+
? readAt(fd, this.committedOffset - 1, 1)
|
|
355
|
+
: null,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
async probeAsync(handle, stat) {
|
|
359
|
+
const committedOffset = this.committedOffset;
|
|
360
|
+
return {
|
|
361
|
+
prefix: await readAtAsync(handle, 0, Math.min(HEAD_BYTES, stat.size)),
|
|
362
|
+
before: committedOffset > 0 && committedOffset <= stat.size
|
|
363
|
+
? await readAtAsync(handle, committedOffset - 1, 1)
|
|
364
|
+
: null,
|
|
365
|
+
};
|
|
158
366
|
}
|
|
159
367
|
/**
|
|
160
|
-
*
|
|
161
|
-
* first bytes changed, or the resume point no longer follows a
|
|
368
|
+
* Why the tail must reposition: the path now names another file, the file
|
|
369
|
+
* shrank, its first bytes changed, or the resume point no longer follows a
|
|
370
|
+
* newline. `null` when it can continue.
|
|
162
371
|
*/
|
|
163
|
-
|
|
164
|
-
let reason = null;
|
|
372
|
+
resetReason(stat, probe) {
|
|
165
373
|
if (this.identity && (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)) {
|
|
166
|
-
|
|
374
|
+
return 'replaced';
|
|
167
375
|
}
|
|
168
|
-
|
|
169
|
-
|
|
376
|
+
if (stat.size < this.readOffset)
|
|
377
|
+
return 'truncated';
|
|
378
|
+
if (this.identity && stat.mtimeMs !== this.lastMtimeMs && probe) {
|
|
379
|
+
if (this.head && !headMatches(this.head, probe.prefix))
|
|
380
|
+
return 'rewritten';
|
|
381
|
+
if (probe.before && probe.before[0] !== 0x0a)
|
|
382
|
+
return 'rewritten';
|
|
170
383
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
reason = 'rewritten';
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (reason !== null)
|
|
179
|
-
this.reset(reason);
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
/** Records the identity and prefix of the file the offsets now refer to. */
|
|
387
|
+
adoptFile(stat, probe) {
|
|
180
388
|
this.identity = { dev: stat.dev, ino: stat.ino };
|
|
181
389
|
this.lastMtimeMs = stat.mtimeMs;
|
|
182
|
-
if (!this.head || this.head.bytes < Math.min(HEAD_BYTES, stat.size)) {
|
|
183
|
-
this.head = fileHeadOf(
|
|
390
|
+
if (probe && (!this.head || this.head.bytes < Math.min(HEAD_BYTES, stat.size))) {
|
|
391
|
+
this.head = fileHeadOf(probe.prefix);
|
|
184
392
|
}
|
|
185
393
|
}
|
|
186
|
-
reset(reason) {
|
|
394
|
+
reset(reason, offset) {
|
|
187
395
|
const previousOffset = this.committedOffset;
|
|
188
|
-
this.
|
|
189
|
-
this.committedOffset = 0;
|
|
190
|
-
this.pendingBytes = Buffer.alloc(0);
|
|
191
|
-
this.skippingLine = false;
|
|
396
|
+
this.position(offset);
|
|
192
397
|
this.head = null;
|
|
193
|
-
this.parser.reset();
|
|
194
398
|
if (reason === 'replaced' && this.active) {
|
|
195
399
|
// fs.watch follows the old inode; watch the file now at the path.
|
|
196
400
|
this.fsWatcher?.close();
|
|
197
401
|
this.fsWatcher = null;
|
|
198
402
|
this.watchFile();
|
|
199
403
|
}
|
|
200
|
-
this.
|
|
404
|
+
const reset = this.rewritePolicy === 'end'
|
|
405
|
+
? { reason, previousOffset, offset }
|
|
406
|
+
: { reason, previousOffset };
|
|
407
|
+
this.options.onReset?.(reset);
|
|
408
|
+
return reset;
|
|
409
|
+
}
|
|
410
|
+
/** Moves both offsets to `offset` and drops any partial-line state. */
|
|
411
|
+
position(offset) {
|
|
412
|
+
this.readOffset = offset;
|
|
413
|
+
this.committedOffset = offset;
|
|
414
|
+
this.pendingBytes = Buffer.alloc(0);
|
|
415
|
+
this.skippingLine = false;
|
|
416
|
+
this.parser.reset();
|
|
201
417
|
}
|
|
202
418
|
/** Parses the complete lines in `bytes`, which start at file offset `position`. */
|
|
203
419
|
consume(bytes, position) {
|
|
@@ -249,13 +465,93 @@ class JsonlTailReader {
|
|
|
249
465
|
return this.committedOffset;
|
|
250
466
|
}
|
|
251
467
|
seekTo(offset) {
|
|
252
|
-
this.
|
|
253
|
-
this.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.
|
|
468
|
+
this.generation += 1;
|
|
469
|
+
this.position(Math.max(0, offset));
|
|
470
|
+
}
|
|
471
|
+
checkpoint() {
|
|
472
|
+
return checkpointOf(this.committedOffset, this.identity, this.head);
|
|
473
|
+
}
|
|
474
|
+
resumeFrom(checkpoint) {
|
|
475
|
+
this.generation += 1;
|
|
476
|
+
this.positioned = true;
|
|
477
|
+
const parsed = parseCheckpoint(checkpoint);
|
|
478
|
+
let fd = null;
|
|
479
|
+
try {
|
|
480
|
+
fd = this.openSync();
|
|
481
|
+
const stat = fs.fstatSync(fd);
|
|
482
|
+
const prefix = readPrefix(fd, stat);
|
|
483
|
+
const matches = parsed !== null && checkpointMatches(parsed, fd, stat, prefix);
|
|
484
|
+
const offset = matches
|
|
485
|
+
? parsed.offset
|
|
486
|
+
: this.rewritePolicy === 'end'
|
|
487
|
+
? lastCompleteLineOffset(fd, stat.size)
|
|
488
|
+
: 0;
|
|
489
|
+
this.position(offset);
|
|
490
|
+
this.identity = { dev: stat.dev, ino: stat.ino };
|
|
491
|
+
this.lastMtimeMs = stat.mtimeMs;
|
|
492
|
+
this.head = fileHeadOf(prefix);
|
|
493
|
+
return matches;
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.position(0);
|
|
497
|
+
this.identity = null;
|
|
498
|
+
this.head = null;
|
|
499
|
+
this.lastMtimeMs = -1;
|
|
500
|
+
this.options.onError?.(toError(error));
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
closeQuietly(fd);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
openFlags() {
|
|
508
|
+
const noFollow = this.options.noFollow ? fs.constants.O_NOFOLLOW : undefined;
|
|
509
|
+
return typeof noFollow === 'number' ? fs.constants.O_RDONLY | noFollow : 'r';
|
|
510
|
+
}
|
|
511
|
+
openSync() {
|
|
512
|
+
try {
|
|
513
|
+
return fs.openSync(this.options.path, this.openFlags());
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
throw this.refusedSymlink(error) ?? error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async openAsync() {
|
|
520
|
+
try {
|
|
521
|
+
return await fs.promises.open(this.options.path, this.openFlags());
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
throw this.refusedSymlink(error) ?? error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** The typed error for an open that `O_NOFOLLOW` refused because the path is a symlink. */
|
|
528
|
+
refusedSymlink(error) {
|
|
529
|
+
if (!this.options.noFollow)
|
|
530
|
+
return null;
|
|
531
|
+
const code = error?.code;
|
|
532
|
+
// ELOOP on Linux and macOS; EMLINK on FreeBSD.
|
|
533
|
+
if (code !== 'ELOOP' && code !== 'EMLINK')
|
|
534
|
+
return null;
|
|
535
|
+
try {
|
|
536
|
+
return fs.lstatSync(this.options.path).isSymbolicLink()
|
|
537
|
+
? new JsonlTailSymlinkError(this.options.path)
|
|
538
|
+
: null;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
257
543
|
}
|
|
258
544
|
watchFile() {
|
|
545
|
+
if (this.options.noFollow) {
|
|
546
|
+
try {
|
|
547
|
+
// fs.watch follows links; a refused symlink is not watched either.
|
|
548
|
+
if (fs.lstatSync(this.options.path).isSymbolicLink())
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// Missing: fs.watch below fails the same way and polling covers it.
|
|
553
|
+
}
|
|
554
|
+
}
|
|
259
555
|
try {
|
|
260
556
|
this.fsWatcher = fs.watch(this.options.path, { persistent: false }, () => {
|
|
261
557
|
this.debouncedRead();
|
|
@@ -277,13 +573,101 @@ class JsonlTailReader {
|
|
|
277
573
|
this.debounceTimer = setTimeout(() => this.readNow(), debounceMs);
|
|
278
574
|
}
|
|
279
575
|
}
|
|
576
|
+
/** The checkpoint when `value` has the documented shape, else `null`. */
|
|
577
|
+
function parseCheckpoint(value) {
|
|
578
|
+
if (!value || typeof value !== 'object')
|
|
579
|
+
return null;
|
|
580
|
+
const candidate = value;
|
|
581
|
+
if (candidate.version !== JSONL_TAIL_CHECKPOINT_VERSION)
|
|
582
|
+
return null;
|
|
583
|
+
const { offset, identity, prefixDigest } = candidate;
|
|
584
|
+
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0)
|
|
585
|
+
return null;
|
|
586
|
+
let parsedIdentity = null;
|
|
587
|
+
if (identity !== null) {
|
|
588
|
+
if (!identity || typeof identity !== 'object')
|
|
589
|
+
return null;
|
|
590
|
+
const { dev, ino } = identity;
|
|
591
|
+
if (!isFiniteNumber(dev) || !isFiniteNumber(ino))
|
|
592
|
+
return null;
|
|
593
|
+
parsedIdentity = { dev, ino };
|
|
594
|
+
}
|
|
595
|
+
let parsedDigest = null;
|
|
596
|
+
if (prefixDigest !== null) {
|
|
597
|
+
if (!prefixDigest || typeof prefixDigest !== 'object')
|
|
598
|
+
return null;
|
|
599
|
+
const { bytes, hash } = prefixDigest;
|
|
600
|
+
if (typeof bytes !== 'number' || !Number.isInteger(bytes) || bytes < 0 || bytes > HEAD_BYTES) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (typeof hash !== 'string' || hash.length === 0)
|
|
604
|
+
return null;
|
|
605
|
+
parsedDigest = { bytes, hash };
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
version: JSONL_TAIL_CHECKPOINT_VERSION,
|
|
609
|
+
offset,
|
|
610
|
+
identity: parsedIdentity,
|
|
611
|
+
prefixDigest: parsedDigest,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** A fresh checkpoint for `offset` in the file with `identity` and recorded `head`. */
|
|
615
|
+
function checkpointOf(offset, identity, head) {
|
|
616
|
+
return {
|
|
617
|
+
version: JSONL_TAIL_CHECKPOINT_VERSION,
|
|
618
|
+
offset,
|
|
619
|
+
identity: identity ? { dev: identity.dev, ino: identity.ino } : null,
|
|
620
|
+
prefixDigest: head ? { bytes: head.bytes, hash: head.hash } : null,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/** Whether `checkpoint` still describes the open file. */
|
|
624
|
+
function checkpointMatches(checkpoint, fd, stat, prefix) {
|
|
625
|
+
const { offset, identity, prefixDigest } = checkpoint;
|
|
626
|
+
// Taken before the first read: nothing was consumed, so nothing can be stale.
|
|
627
|
+
if (identity === null && prefixDigest === null)
|
|
628
|
+
return offset === 0;
|
|
629
|
+
if (identity === null || prefixDigest === null)
|
|
630
|
+
return false;
|
|
631
|
+
if (identity.dev !== stat.dev || identity.ino !== stat.ino)
|
|
632
|
+
return false;
|
|
633
|
+
if (!headMatches(prefixDigest, prefix))
|
|
634
|
+
return false;
|
|
635
|
+
if (offset > stat.size)
|
|
636
|
+
return false;
|
|
637
|
+
return offset === 0 || readAt(fd, offset - 1, 1)[0] === 0x0a;
|
|
638
|
+
}
|
|
639
|
+
function isFiniteNumber(value) {
|
|
640
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
641
|
+
}
|
|
280
642
|
function positiveInteger(value, fallback) {
|
|
281
643
|
return typeof value === 'number' && Number.isFinite(value) && value >= 1
|
|
282
644
|
? Math.floor(value)
|
|
283
645
|
: fallback;
|
|
284
646
|
}
|
|
647
|
+
function toError(error) {
|
|
648
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
649
|
+
}
|
|
650
|
+
function closeQuietly(fd) {
|
|
651
|
+
if (fd === null)
|
|
652
|
+
return;
|
|
653
|
+
try {
|
|
654
|
+
fs.closeSync(fd);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
/* ignore close errors */
|
|
658
|
+
}
|
|
659
|
+
}
|
|
285
660
|
function readAt(fd, position, length) {
|
|
286
661
|
const buffer = Buffer.alloc(length);
|
|
287
662
|
const count = length > 0 ? fs.readSync(fd, buffer, 0, length, position) : 0;
|
|
288
663
|
return buffer.subarray(0, count);
|
|
289
664
|
}
|
|
665
|
+
/** The file's first `min(HEAD_BYTES, size)` bytes: what a checkpoint digests. */
|
|
666
|
+
function readPrefix(fd, stat) {
|
|
667
|
+
return readAt(fd, 0, Math.min(HEAD_BYTES, stat.size));
|
|
668
|
+
}
|
|
669
|
+
async function readAtAsync(handle, position, length) {
|
|
670
|
+
const buffer = Buffer.alloc(length);
|
|
671
|
+
const count = length > 0 ? (await handle.read(buffer, 0, length, position)).bytesRead : 0;
|
|
672
|
+
return buffer.subarray(0, count);
|
|
673
|
+
}
|