pi-durable-subagents 1.0.22 → 1.0.24
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 +30 -0
- package/README.md +120 -4
- package/dist/agent/child.js +4 -4
- package/dist/agent/main/schema.js +2 -1
- package/dist/agent/main/tool.js +5 -1
- package/dist/agent/main.js +1 -1
- package/dist/cli/main.js +6 -1
- package/dist/cli/requests.js +59 -47
- package/dist/events/cli.js +117 -0
- package/dist/events/derive.js +128 -0
- package/dist/events/fence.js +61 -0
- package/dist/events/labels.js +53 -0
- package/dist/events/log.js +491 -0
- package/dist/events/pump.js +351 -0
- package/dist/events/r7.js +412 -0
- package/dist/events/types.js +19 -0
- package/dist/kernel/journal.js +23 -2
- package/dist/orchestrator/config.js +3 -2
- package/dist/orchestrator/engine.js +77 -9
- package/dist/orchestrator/executor/hibernate.js +8 -13
- package/dist/orchestrator/executor/session.js +28 -2
- package/dist/orchestrator/snapshot.js +11 -3
- package/dist/orchestrator/store.js +3 -0
- package/dist/paths.js +2 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// R2: the cross-workflow event log, `<home>/events.jsonl`. Single writer: the orchestrator (under its OS lock). Each line
|
|
2
|
+
// is CRC-framed like the journals (`<crc32 hex 8> <json>\n`, kernel/journal.ts); a reader ignores a torn final line, a
|
|
3
|
+
// bad line before the last one is corruption. Records (`k`):
|
|
4
|
+
// log {v, epoch, dropped} first line, written when the file is created or compacted. `epoch` (16 hex chars, random)
|
|
5
|
+
// names this log: cursors of another epoch are expired. `dropped` = highest seq ever dropped
|
|
6
|
+
// by retention (monotone; carried over by compaction).
|
|
7
|
+
// ev {seq, at, e} one event: `e` is the draft (everything but the cursor, which is `<epoch>:<seq>`); `at` =
|
|
8
|
+
// append time (ms), which retention measures (not the milestone `e.ts`).
|
|
9
|
+
// head {seq} an orchestrator start: the head jumps EVENT_SEQ_SKIP ahead (fsynced before any event of
|
|
10
|
+
// that start), so a seq that a reader saw in a write a power loss undid is never reused.
|
|
11
|
+
// Also written alone after failed writes: seqs a failed write spent (a reader may have
|
|
12
|
+
// seen them) become durable before a write would go past durable + EVENT_SEQ_SKIP.
|
|
13
|
+
// mark {head, src} deriver watermarks, source -> highest source seq derived ("orch" = orchestrator ledger,
|
|
14
|
+
// else a wid); later records override earlier ones per source. Compaction writes them all.
|
|
15
|
+
// Every record's position (log 0, ev/head seq, mark head) is non-decreasing in file order: the last record gives the
|
|
16
|
+
// head, and readers binary-search byte offsets for `--since`. Seqs grow strictly but may have gaps.
|
|
17
|
+
// Compaction writes a new file (header, kept events, full mark), fsyncs it, renames it over the log and fsyncs the
|
|
18
|
+
// directory; a failure after the rename leaves the writer broken (its handle may name the replaced file), and the
|
|
19
|
+
// pump reopens the log. A log that is unreadable beyond a torn tail is renamed aside (`events.jsonl.corrupt-<ms>`) and
|
|
20
|
+
// a new epoch begins; it is never truncated in the middle. Open removes temp files a crashed compaction left.
|
|
21
|
+
// Seq invariant: the highest seq any reader can have seen <= the last durable position + EVENT_SEQ_SKIP, so the start
|
|
22
|
+
// skip of the next open is past it.
|
|
23
|
+
import { closeSync, fstatSync, fsyncSync, openSync, readSync, writeSync } from "node:fs";
|
|
24
|
+
import { open, readdir, rename, rm } from "node:fs/promises";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import { frameLine, syncDirectory, unframeLine } from "../kernel/journal.js";
|
|
28
|
+
import { EVENT_SEQ_SKIP } from "./types.js";
|
|
29
|
+
export const EPOCH = /^[0-9a-f]{16}$/;
|
|
30
|
+
const CHUNK = 1 << 16, NEWLINE = Buffer.from("\n");
|
|
31
|
+
/** A framed line's record, or undefined when its frame, checksum or shape is wrong. */
|
|
32
|
+
export function parseRecord(line) {
|
|
33
|
+
const json = unframeLine(line);
|
|
34
|
+
if (json === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
let r;
|
|
37
|
+
try {
|
|
38
|
+
r = JSON.parse(json);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const n = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
44
|
+
if (r?.k === "log" && typeof r.epoch === "string" && EPOCH.test(r.epoch) && n(r.dropped))
|
|
45
|
+
return r;
|
|
46
|
+
if (r?.k === "ev" && n(r.seq) && typeof r.at === "number" && r.e && typeof r.e === "object")
|
|
47
|
+
return r;
|
|
48
|
+
if (r?.k === "head" && n(r.seq))
|
|
49
|
+
return r;
|
|
50
|
+
if (r?.k === "mark" && n(r.head) && r.src && typeof r.src === "object")
|
|
51
|
+
return r;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
export const position = (r) => r.k === "ev" || r.k === "head" ? r.seq : r.k === "mark" ? r.head : 0;
|
|
55
|
+
/** The public form of a logged event: id and cursor first. */
|
|
56
|
+
export function publicEvent(epoch, r) {
|
|
57
|
+
const { id, ...rest } = r.e;
|
|
58
|
+
delete rest.cursor;
|
|
59
|
+
return { id, cursor: `${epoch}:${r.seq}`, ...rest };
|
|
60
|
+
}
|
|
61
|
+
export class LogCorrupt extends Error {
|
|
62
|
+
name = "LogCorrupt";
|
|
63
|
+
}
|
|
64
|
+
/** Visit the complete lines of `fd` in [from, to) in order (`visit` returns false to stop). Returns the offset after
|
|
65
|
+
* the last complete line visited (the end of the readable prefix). */
|
|
66
|
+
export function scanLines(fd, from, to, visit) {
|
|
67
|
+
let carry = Buffer.alloc(0), start = from, offset = from;
|
|
68
|
+
while (offset < to) {
|
|
69
|
+
const buffer = Buffer.allocUnsafe(Math.min(CHUNK, to - offset)), read = readSync(fd, buffer, 0, buffer.length, offset);
|
|
70
|
+
if (!read)
|
|
71
|
+
break;
|
|
72
|
+
offset += read;
|
|
73
|
+
const data = carry.length ? Buffer.concat([carry, buffer.subarray(0, read)]) : buffer.subarray(0, read);
|
|
74
|
+
let pos = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const nl = data.indexOf(10, pos);
|
|
77
|
+
if (nl < 0)
|
|
78
|
+
break;
|
|
79
|
+
if (visit(data.subarray(pos, nl), start + pos) === false)
|
|
80
|
+
return start + nl + 1;
|
|
81
|
+
pos = nl + 1;
|
|
82
|
+
}
|
|
83
|
+
carry = Buffer.from(data.subarray(pos));
|
|
84
|
+
start += pos;
|
|
85
|
+
}
|
|
86
|
+
return start;
|
|
87
|
+
}
|
|
88
|
+
function scanFile(fd, size) {
|
|
89
|
+
let header, head = 0, bad, records = 0, events = 0;
|
|
90
|
+
const marks = new Map();
|
|
91
|
+
const end = scanLines(fd, 0, size, (line, start) => {
|
|
92
|
+
if (bad !== undefined)
|
|
93
|
+
throw new LogCorrupt(`event log corrupt at byte ${bad}`);
|
|
94
|
+
const r = parseRecord(line);
|
|
95
|
+
if (!r) {
|
|
96
|
+
bad = start;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!header) {
|
|
100
|
+
if (r.k !== "log")
|
|
101
|
+
throw new LogCorrupt("event log has no header");
|
|
102
|
+
header = r;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (r.k === "log")
|
|
106
|
+
throw new LogCorrupt(`event log has a second header at byte ${start}`);
|
|
107
|
+
const p = position(r);
|
|
108
|
+
if (p < head || (r.k === "ev" && p === head && events))
|
|
109
|
+
throw new LogCorrupt(`event log out of order at byte ${start}`);
|
|
110
|
+
head = p;
|
|
111
|
+
records++;
|
|
112
|
+
if (r.k === "ev")
|
|
113
|
+
events++;
|
|
114
|
+
if (r.k === "mark")
|
|
115
|
+
for (const [source, seq] of Object.entries(r.src))
|
|
116
|
+
if (typeof seq === "number")
|
|
117
|
+
marks.set(source, seq);
|
|
118
|
+
});
|
|
119
|
+
if (!header)
|
|
120
|
+
throw new LogCorrupt("event log has no header");
|
|
121
|
+
return { epoch: header.epoch, dropped: header.dropped, head: Math.max(head, header.dropped), marks, end: bad ?? end, records, events };
|
|
122
|
+
}
|
|
123
|
+
/** R2: The writer. Appends, compaction and close are serialized; an append resolves only after fsync. */
|
|
124
|
+
export class EventLog {
|
|
125
|
+
path;
|
|
126
|
+
epoch;
|
|
127
|
+
/** The last seq handed out (spent: also by a write that failed), or the start skip's head. */
|
|
128
|
+
head;
|
|
129
|
+
/** The position of the last record durably in the file (head >= durable; head <= durable + EVENT_SEQ_SKIP). */
|
|
130
|
+
durable;
|
|
131
|
+
/** Highest seq dropped by retention. */
|
|
132
|
+
dropped;
|
|
133
|
+
file;
|
|
134
|
+
size;
|
|
135
|
+
queue = Promise.resolve();
|
|
136
|
+
/** Set when the file is in an unknown state (a failed cut-back, a failure after a compaction's rename): every later
|
|
137
|
+
* operation throws it; the owner reopens the log (EventLog.open). */
|
|
138
|
+
failed;
|
|
139
|
+
redundant;
|
|
140
|
+
constructor(path, file, scan) {
|
|
141
|
+
this.path = path;
|
|
142
|
+
this.file = file;
|
|
143
|
+
this.epoch = scan.epoch;
|
|
144
|
+
this.head = this.durable = scan.head;
|
|
145
|
+
this.dropped = scan.dropped;
|
|
146
|
+
this.size = scan.end;
|
|
147
|
+
this.redundant = scan.records - scan.events;
|
|
148
|
+
}
|
|
149
|
+
/** The writer cannot be used any more: reopen the log. */
|
|
150
|
+
get broken() { return this.failed !== undefined; }
|
|
151
|
+
/** Open the log at orchestrator start: repair a torn tail, skip the head ahead (durably), or create a new log (new
|
|
152
|
+
* epoch) when it is missing or corrupt; `created` tells the caller to derive everything still on disk (backfill). */
|
|
153
|
+
static async open(path) {
|
|
154
|
+
// A compaction that crashed before its rename left its temp file (single writer: none is in progress now).
|
|
155
|
+
const base = basename(path), stale = (name) => name.startsWith(base) && /^\.[0-9a-f]{8}\.tmp$/.test(name.slice(base.length));
|
|
156
|
+
for (const name of await readdir(dirname(path)).catch(() => []))
|
|
157
|
+
if (stale(name))
|
|
158
|
+
await rm(join(dirname(path), name), { force: true });
|
|
159
|
+
let scan, corrupt;
|
|
160
|
+
let fd;
|
|
161
|
+
try {
|
|
162
|
+
fd = openSync(path, "r");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.code !== "ENOENT")
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (fd !== undefined) {
|
|
169
|
+
try {
|
|
170
|
+
scan = scanFile(fd, fstatSync(fd).size);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (!(error instanceof LogCorrupt))
|
|
174
|
+
throw error;
|
|
175
|
+
corrupt = error.message;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (corrupt) {
|
|
182
|
+
const aside = `${path}.corrupt-${Date.now()}`;
|
|
183
|
+
await rename(path, aside);
|
|
184
|
+
await syncDirectory(dirname(path));
|
|
185
|
+
corrupt = `${corrupt}; kept as ${aside}`;
|
|
186
|
+
}
|
|
187
|
+
if (!scan) {
|
|
188
|
+
const epoch = randomBytes(8).toString("hex"), text = frameLine(JSON.stringify({ k: "log", v: 1, epoch, dropped: 0 }));
|
|
189
|
+
await replace(path, async (file) => { await file.writeFile(text); });
|
|
190
|
+
const file = await open(path, "a");
|
|
191
|
+
return { log: new EventLog(path, file, { epoch, dropped: 0, head: 0, end: Buffer.byteLength(text), records: 0, events: 0 }), created: true, marks: new Map(), ...(corrupt ? { corrupt } : {}) };
|
|
192
|
+
}
|
|
193
|
+
const file = await open(path, "r+");
|
|
194
|
+
try {
|
|
195
|
+
if (scan.end !== (await file.stat()).size) {
|
|
196
|
+
await file.truncate(scan.end);
|
|
197
|
+
await file.sync();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await file.close();
|
|
202
|
+
}
|
|
203
|
+
const log = new EventLog(path, await open(path, "a"), scan);
|
|
204
|
+
// The pump retries a broken log's reopen every second: a failed start skip must not leak the handle.
|
|
205
|
+
try {
|
|
206
|
+
await log.write(frameLine(JSON.stringify({ k: "head", seq: log.head + EVENT_SEQ_SKIP })), log.head + EVENT_SEQ_SKIP);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await log.file.close().catch(() => { });
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
log.head += EVENT_SEQ_SKIP;
|
|
213
|
+
log.redundant++;
|
|
214
|
+
return { log, created: false, marks: scan.marks };
|
|
215
|
+
}
|
|
216
|
+
serial(fn) {
|
|
217
|
+
const run = this.queue.then(() => { if (this.failed)
|
|
218
|
+
throw this.failed; return fn(); });
|
|
219
|
+
this.queue = run.catch(() => { });
|
|
220
|
+
return run;
|
|
221
|
+
}
|
|
222
|
+
/** Write and fsync records whose last position is `position`; on failure cut the file back to its last good length
|
|
223
|
+
* (or break the log when that fails too). */
|
|
224
|
+
async write(text, position) {
|
|
225
|
+
try {
|
|
226
|
+
await this.file.write(text);
|
|
227
|
+
await this.file.sync();
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
try {
|
|
231
|
+
await this.file.truncate(this.size);
|
|
232
|
+
await this.file.sync();
|
|
233
|
+
}
|
|
234
|
+
catch (cause) {
|
|
235
|
+
this.failed = cause;
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
this.size += Buffer.byteLength(text);
|
|
240
|
+
this.durable = Math.max(this.durable, position);
|
|
241
|
+
}
|
|
242
|
+
/** Append events (in writes of at most EVENT_SEQ_SKIP, each fsynced) and, with the last write, watermarks. Returns
|
|
243
|
+
* the events as logged (callers pass at most EVENT_SEQ_SKIP drafts at a time, so this stays small). */
|
|
244
|
+
append(drafts, marks) {
|
|
245
|
+
return this.serial(async () => {
|
|
246
|
+
const out = [];
|
|
247
|
+
for (let i = 0; i < drafts.length || (i === 0 && marks?.size); i += EVENT_SEQ_SKIP) {
|
|
248
|
+
const chunk = drafts.slice(i, i + EVENT_SEQ_SKIP), at = Date.now(), last = i + EVENT_SEQ_SKIP >= drafts.length;
|
|
249
|
+
// Failed writes spent seqs past the durable head: make them durable (a lone head record) before this write could
|
|
250
|
+
// spend beyond durable + EVENT_SEQ_SKIP, which the next open's skip would not clear. If that fails, no events.
|
|
251
|
+
if (this.head + chunk.length > this.durable + EVENT_SEQ_SKIP) {
|
|
252
|
+
await this.write(frameLine(JSON.stringify({ k: "head", seq: this.head })), this.head);
|
|
253
|
+
this.redundant++;
|
|
254
|
+
}
|
|
255
|
+
let text = "", seq = this.head;
|
|
256
|
+
for (const e of chunk) {
|
|
257
|
+
const r = { k: "ev", seq: ++seq, at, e };
|
|
258
|
+
text += frameLine(JSON.stringify(r));
|
|
259
|
+
out.push(publicEvent(this.epoch, r));
|
|
260
|
+
}
|
|
261
|
+
if (last && marks?.size)
|
|
262
|
+
text += frameLine(JSON.stringify({ k: "mark", head: seq, src: Object.fromEntries(marks) }));
|
|
263
|
+
// The seqs are spent even when the write fails: a reader may have seen part of it.
|
|
264
|
+
const spent = seq;
|
|
265
|
+
try {
|
|
266
|
+
await this.write(text, spent);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
this.head = spent;
|
|
270
|
+
}
|
|
271
|
+
if (last && marks?.size)
|
|
272
|
+
this.redundant++;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/** Retention: rewrite the log without the events `drop` selects (when there are any, or when superseded head/mark
|
|
278
|
+
* records pile up), keeping the header's epoch, a monotone `dropped`, every other event and the watermarks. */
|
|
279
|
+
compact(drop, marks) {
|
|
280
|
+
return this.serial(async () => {
|
|
281
|
+
const fd = openSync(this.path, "r");
|
|
282
|
+
let count = 0, highest = this.dropped;
|
|
283
|
+
try {
|
|
284
|
+
scanLines(fd, 0, this.size, line => {
|
|
285
|
+
const r = parseRecord(line);
|
|
286
|
+
if (r?.k === "ev" && drop(r)) {
|
|
287
|
+
count++;
|
|
288
|
+
highest = Math.max(highest, r.seq);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
if (!count && this.redundant < 1000)
|
|
292
|
+
return 0;
|
|
293
|
+
// Synchronous copy (bounded buffer): the kept lines are copied verbatim, never all held in memory.
|
|
294
|
+
let size = 0;
|
|
295
|
+
const temp = `${this.path}.${randomBytes(4).toString("hex")}.tmp`, out = openSync(temp, "wx", 0o600);
|
|
296
|
+
try {
|
|
297
|
+
let pending = [], bytes = 0;
|
|
298
|
+
const put = (chunk, force = false) => {
|
|
299
|
+
if (chunk.length) {
|
|
300
|
+
pending.push(Buffer.from(chunk));
|
|
301
|
+
bytes += chunk.length;
|
|
302
|
+
}
|
|
303
|
+
if (bytes >= CHUNK * 16 || (force && bytes)) {
|
|
304
|
+
const all = Buffer.concat(pending);
|
|
305
|
+
for (let at = 0; at < all.length;)
|
|
306
|
+
at += writeSync(out, all, at, all.length - at);
|
|
307
|
+
size += all.length;
|
|
308
|
+
pending = [];
|
|
309
|
+
bytes = 0;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
put(Buffer.from(frameLine(JSON.stringify({ k: "log", v: 1, epoch: this.epoch, dropped: highest }))));
|
|
313
|
+
scanLines(fd, 0, this.size, line => {
|
|
314
|
+
const r = parseRecord(line);
|
|
315
|
+
if (r?.k === "ev" && !drop(r)) {
|
|
316
|
+
put(line);
|
|
317
|
+
put(NEWLINE);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
put(Buffer.from(frameLine(JSON.stringify({ k: "mark", head: this.head, src: Object.fromEntries(marks) }))), true);
|
|
321
|
+
fsyncSync(out);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
closeSync(out);
|
|
325
|
+
await rm(temp, { force: true });
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
closeSync(out);
|
|
329
|
+
try {
|
|
330
|
+
await rename(temp, this.path);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
await rm(temp, { force: true });
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
// Past the rename this handle names the replaced file: any failure breaks the writer (the owner reopens).
|
|
337
|
+
try {
|
|
338
|
+
await syncDirectory(dirname(this.path));
|
|
339
|
+
await this.file.close();
|
|
340
|
+
this.file = await open(this.path, "a");
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
this.failed = error;
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
this.size = size;
|
|
347
|
+
this.dropped = highest;
|
|
348
|
+
this.redundant = 1;
|
|
349
|
+
this.durable = this.head;
|
|
350
|
+
return count;
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
closeSync(fd);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
close() { return this.queue.then(() => this.file.close()).catch(() => { }); }
|
|
358
|
+
}
|
|
359
|
+
/** Write a file by temp + fsync + rename + directory fsync (atomic replacement). */
|
|
360
|
+
async function replace(path, fill) {
|
|
361
|
+
const temp = `${path}.${randomBytes(4).toString("hex")}.tmp`, file = await open(temp, "wx", 0o600);
|
|
362
|
+
try {
|
|
363
|
+
await fill(file);
|
|
364
|
+
await file.sync();
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
await file.close().catch(() => { });
|
|
368
|
+
await rm(temp, { force: true });
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
await file.close();
|
|
372
|
+
try {
|
|
373
|
+
await rename(temp, path);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
await rm(temp, { force: true });
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
await syncDirectory(dirname(path));
|
|
380
|
+
}
|
|
381
|
+
/** The first complete line starting at or after `offset` (offset 0: the first line), with its start. */
|
|
382
|
+
function lineAt(fd, offset, size) {
|
|
383
|
+
let found, skipped = offset === 0;
|
|
384
|
+
scanLines(fd, offset, size, (line, start) => {
|
|
385
|
+
if (!skipped) {
|
|
386
|
+
skipped = true;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
found = { start, line: Buffer.from(line) };
|
|
390
|
+
return false;
|
|
391
|
+
});
|
|
392
|
+
return found;
|
|
393
|
+
}
|
|
394
|
+
/** The last valid record within the first `size` bytes (a torn or partial final line is ignored). */
|
|
395
|
+
function lastRecord(fd, size) {
|
|
396
|
+
for (let window = CHUNK;; window *= 4) {
|
|
397
|
+
const from = Math.max(0, size - window);
|
|
398
|
+
let last, lines = 0;
|
|
399
|
+
scanLines(fd, from, size, (line, start) => {
|
|
400
|
+
if (start === from && from > 0)
|
|
401
|
+
return; // possibly the middle of a line
|
|
402
|
+
lines++;
|
|
403
|
+
const r = parseRecord(line);
|
|
404
|
+
if (r)
|
|
405
|
+
last = r;
|
|
406
|
+
});
|
|
407
|
+
if (last || from === 0)
|
|
408
|
+
return last;
|
|
409
|
+
if (lines > 1)
|
|
410
|
+
return last;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function header(fd, size) {
|
|
414
|
+
const first = lineAt(fd, 0, size), r = first && parseRecord(first.line);
|
|
415
|
+
if (r?.k !== "log")
|
|
416
|
+
throw new LogCorrupt("event log has no valid header");
|
|
417
|
+
return r;
|
|
418
|
+
}
|
|
419
|
+
/** The log's epoch, dropped and head; undefined when there is no log yet. */
|
|
420
|
+
export function readHead(path) {
|
|
421
|
+
let fd;
|
|
422
|
+
try {
|
|
423
|
+
fd = openSync(path, "r");
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error.code === "ENOENT")
|
|
427
|
+
return undefined;
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const size = fstatSync(fd).size, h = header(fd, size), last = lastRecord(fd, size);
|
|
432
|
+
return { epoch: h.epoch, dropped: h.dropped, head: Math.max(h.dropped, last ? position(last) : 0) };
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
closeSync(fd);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Events with seq > `since`, in order, at most `limit`; `more` when the log has further events. The caller checks the
|
|
439
|
+
* cursor against the head first (`readHead` values are returned again, from the same read). */
|
|
440
|
+
export function readPage(path, since, limit, check) {
|
|
441
|
+
let fd;
|
|
442
|
+
try {
|
|
443
|
+
fd = openSync(path, "r");
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (error.code === "ENOENT")
|
|
447
|
+
return undefined;
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const size = fstatSync(fd).size, h = header(fd, size), last = lastRecord(fd, size);
|
|
452
|
+
const view = { epoch: h.epoch, dropped: h.dropped, head: Math.max(h.dropped, last ? position(last) : 0) };
|
|
453
|
+
check?.(view);
|
|
454
|
+
// Binary search for a line start before which every record's position is <= since (positions never decrease).
|
|
455
|
+
let lo = 0, hi = size;
|
|
456
|
+
while (hi - lo > CHUNK * 4) {
|
|
457
|
+
const mid = lo + Math.floor((hi - lo) / 2), at = lineAt(fd, mid, size);
|
|
458
|
+
if (!at || at.start >= hi) {
|
|
459
|
+
hi = mid;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const r = parseRecord(at.line);
|
|
463
|
+
if (r && position(r) <= since)
|
|
464
|
+
lo = at.start;
|
|
465
|
+
else
|
|
466
|
+
hi = mid;
|
|
467
|
+
}
|
|
468
|
+
const events = [];
|
|
469
|
+
let more = false, bad;
|
|
470
|
+
scanLines(fd, lo, size, (line, start) => {
|
|
471
|
+
if (bad !== undefined)
|
|
472
|
+
throw new LogCorrupt(`event log corrupt at byte ${bad}`);
|
|
473
|
+
const r = parseRecord(line);
|
|
474
|
+
if (!r) {
|
|
475
|
+
bad = start;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (r.k !== "ev" || r.seq <= since)
|
|
479
|
+
return;
|
|
480
|
+
if (events.length >= limit) {
|
|
481
|
+
more = true;
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
events.push(publicEvent(h.epoch, r));
|
|
485
|
+
});
|
|
486
|
+
return { ...view, events, more };
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
closeSync(fd);
|
|
490
|
+
}
|
|
491
|
+
}
|