@verax-ai/proxy 0.1.0 → 0.1.2
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 +192 -0
- package/README.md +46 -0
- package/dist/approvals.d.ts +30 -1
- package/dist/approvals.js +64 -3
- package/dist/atomic-write.d.ts +1 -0
- package/dist/atomic-write.js +65 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/inputs.d.ts +7 -1
- package/dist/inputs.js +35 -18
- package/dist/jsonl-tail.d.ts +26 -0
- package/dist/jsonl-tail.js +218 -0
- package/dist/ledger-manifest.d.ts +88 -0
- package/dist/ledger-manifest.js +225 -0
- package/dist/ledger.d.ts +88 -9
- package/dist/ledger.js +541 -86
- package/dist/policy.js +9 -1
- package/dist/proxy.js +235 -166
- package/dist/serial-queue.d.ts +5 -0
- package/dist/serial-queue.js +9 -0
- package/dist/types.d.ts +3 -0
- package/package.json +11 -1
package/dist/ledger.js
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
4
|
/** I/O seam so append-cost tests can count rereads without mocking node:fs. */
|
|
5
5
|
export const ledgerFs = {
|
|
6
6
|
readFile,
|
|
7
7
|
readFileSync,
|
|
8
8
|
open,
|
|
9
9
|
};
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
11
12
|
import { canonical, decisionRecordHash } from "@cedulon/core";
|
|
12
13
|
import { coseToHex, signCoseSign1 } from "@cedulon/cose";
|
|
13
14
|
import { signEffectExtract, } from "@cedulon/effect-extract";
|
|
15
|
+
import { readJsonlTail, seekOffsetByTimestamp } from "./jsonl-tail.js";
|
|
16
|
+
import { copyRel, decisionIndexCount, DEFAULT_PIECE_MAX_BYTES, DEFAULT_PIECE_MAX_ROWS, indexPath, LEGACY_PIECE_ID, legacyPieceRow, newPieceRow, nextPieceId, pieceOverlaps, readLedgerIndex, readLedgerManifest, writeLedgerManifest, } from "./ledger-manifest.js";
|
|
14
17
|
import { tenantKey } from "./tenant.js";
|
|
15
18
|
import { sha256Canonical } from "./hash.js";
|
|
19
|
+
import { SerialQueue } from "./serial-queue.js";
|
|
16
20
|
const DEFAULT_WITNESS = "self";
|
|
17
|
-
/** One async tail so read-then-append cannot fork the chain. */
|
|
18
|
-
class SerialQueue {
|
|
19
|
-
tail = Promise.resolve();
|
|
20
|
-
enqueue(fn) {
|
|
21
|
-
const run = this.tail.then(fn, fn);
|
|
22
|
-
this.tail = run.then(() => undefined, () => undefined);
|
|
23
|
-
return run;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
21
|
function lineOf(value) {
|
|
27
22
|
return `${canonical(value)}\n`;
|
|
28
23
|
}
|
|
@@ -55,6 +50,47 @@ function readJsonlSync(path) {
|
|
|
55
50
|
throw err;
|
|
56
51
|
}
|
|
57
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A ledger file read from its end through the same seam the reads above use,
|
|
55
|
+
* so a test can count what a window costs.
|
|
56
|
+
*/
|
|
57
|
+
export function readLedgerTail(path, visit, chunkBytesOrOpts) {
|
|
58
|
+
const opts = chunkBytesOrOpts === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: typeof chunkBytesOrOpts === "number"
|
|
61
|
+
? { chunkBytes: chunkBytesOrOpts }
|
|
62
|
+
: chunkBytesOrOpts;
|
|
63
|
+
return readJsonlTail(path, visit, { open: ledgerFs.open, ...opts });
|
|
64
|
+
}
|
|
65
|
+
async function readPieceWindow(dir, piece, fileRel, toMs, tsOf, visit) {
|
|
66
|
+
const path = join(dir, fileRel);
|
|
67
|
+
const seekTarget = toMs + WINDOW_SLACK_MS;
|
|
68
|
+
if (piece.lastMs !== null && seekTarget < piece.lastMs) {
|
|
69
|
+
const endOffset = await seekOffsetByTimestamp(path, seekTarget, tsOf, { open: ledgerFs.open });
|
|
70
|
+
return readLedgerTail(path, visit, { endOffset });
|
|
71
|
+
}
|
|
72
|
+
return readLedgerTail(path, visit);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* How far past a window's lower edge the tail reader keeps looking before it
|
|
76
|
+
* stops. Rows are in file order; their stamps are the clock's, and a clock
|
|
77
|
+
* can be set back. A row stamped a minute before its neighbour is still seen.
|
|
78
|
+
*/
|
|
79
|
+
export const WINDOW_SLACK_MS = 60_000;
|
|
80
|
+
/**
|
|
81
|
+
* Write without fsync. For the ref index only: it is derived from the
|
|
82
|
+
* pieces, a tail lost to a crash is rebuilt on the next open, and the
|
|
83
|
+
* decision's own fsync stays the one per append.
|
|
84
|
+
*/
|
|
85
|
+
async function appendPlain(path, line) {
|
|
86
|
+
const fh = await ledgerFs.open(path, "a");
|
|
87
|
+
try {
|
|
88
|
+
await fh.write(line);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await fh.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
58
94
|
/** Write then fsync so a crash cannot drop a committed line. */
|
|
59
95
|
export async function appendDurable(path, line) {
|
|
60
96
|
const fh = await ledgerFs.open(path, "a");
|
|
@@ -278,8 +314,15 @@ export class FileLedger {
|
|
|
278
314
|
effectSigner;
|
|
279
315
|
/** Optional signer in another process. Null means stay `self`. */
|
|
280
316
|
remoteWitness;
|
|
317
|
+
/** Asked after a piece closes. A missing or failing hook does not block the close. */
|
|
318
|
+
onPieceClose;
|
|
319
|
+
pieceMaxRows;
|
|
320
|
+
pieceMaxBytes;
|
|
281
321
|
decisionsPath;
|
|
282
322
|
effectsPath;
|
|
323
|
+
inputsPath;
|
|
324
|
+
copyDecisionsPath;
|
|
325
|
+
copyEffectsPath;
|
|
283
326
|
lockPath;
|
|
284
327
|
q = new SerialQueue();
|
|
285
328
|
token = randomBytes(16).toString("hex");
|
|
@@ -292,20 +335,53 @@ export class FileLedger {
|
|
|
292
335
|
reauthByHash = new Map();
|
|
293
336
|
countedAt = [];
|
|
294
337
|
countsReadable = true;
|
|
295
|
-
copyDir;
|
|
296
338
|
heartbeatPath;
|
|
297
339
|
decisionCount = 0;
|
|
298
340
|
effectCount = 0;
|
|
299
|
-
|
|
341
|
+
lastDecisionMs = null;
|
|
342
|
+
pieces = [];
|
|
343
|
+
/** Inputs rows appended through this instance, until their decision lands. */
|
|
344
|
+
recentInputs = new Map();
|
|
345
|
+
activeId = LEGACY_PIECE_ID;
|
|
346
|
+
activeDecisionN = 0;
|
|
347
|
+
activeEffectN = 0;
|
|
348
|
+
constructor(dir, opts) {
|
|
300
349
|
this.dir = dir;
|
|
301
350
|
this.permissionCheck = ensureLedgerDir(dir);
|
|
351
|
+
this.pieceMaxRows = opts?.pieceMaxRows ?? DEFAULT_PIECE_MAX_ROWS;
|
|
352
|
+
this.pieceMaxBytes = opts?.pieceMaxBytes ?? DEFAULT_PIECE_MAX_BYTES;
|
|
353
|
+
this.onPieceClose = opts?.onPieceClose;
|
|
302
354
|
this.decisionsPath = join(dir, "decisions.jsonl");
|
|
303
355
|
this.effectsPath = join(dir, "effects.jsonl");
|
|
356
|
+
this.inputsPath = join(dir, "inputs.jsonl");
|
|
357
|
+
this.copyDecisionsPath = join(dir, "evidence-copy", "decisions.jsonl");
|
|
358
|
+
this.copyEffectsPath = join(dir, "evidence-copy", "effects.jsonl");
|
|
304
359
|
this.lockPath = join(dir, "ledger.lock");
|
|
305
|
-
this.copyDir = join(dir, "evidence-copy");
|
|
306
360
|
this.heartbeatPath = join(dir, "heartbeat.json");
|
|
307
361
|
this.acquireLock();
|
|
308
|
-
|
|
362
|
+
try {
|
|
363
|
+
this.loadCaches();
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
// A ledger that cannot be read is not held: the lock goes back so the
|
|
367
|
+
// operator's next attempt is not refused as "locked" by a dead open.
|
|
368
|
+
this.close();
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
noteInputs(ref, inputs) {
|
|
373
|
+
this.recentInputs.set(ref, inputs);
|
|
374
|
+
if (this.recentInputs.size > RECENT_INPUTS_MAX) {
|
|
375
|
+
const oldest = this.recentInputs.keys().next().value;
|
|
376
|
+
if (oldest !== undefined)
|
|
377
|
+
this.recentInputs.delete(oldest);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
inputsPaths() {
|
|
381
|
+
return {
|
|
382
|
+
active: this.inputsPath,
|
|
383
|
+
all: this.pieces.map((p) => join(this.dir, p.inputs)),
|
|
384
|
+
};
|
|
309
385
|
}
|
|
310
386
|
/** After lock handoff a new instance reloads; the lost owner cannot append. */
|
|
311
387
|
loadCaches() {
|
|
@@ -315,100 +391,308 @@ export class FileLedger {
|
|
|
315
391
|
this.reauthByHash.clear();
|
|
316
392
|
this.countedAt.length = 0;
|
|
317
393
|
this.countsReadable = true;
|
|
318
|
-
|
|
394
|
+
const stored = readLedgerManifest(this.dir);
|
|
395
|
+
this.pieces = stored ? stored.pieces.map((p) => ({ ...p })) : [legacyPieceRow()];
|
|
396
|
+
const open = [...this.pieces].reverse().find((p) => !p.closed) ?? this.pieces[this.pieces.length - 1];
|
|
397
|
+
this.activeId = open.id;
|
|
398
|
+
this.applyActivePaths();
|
|
399
|
+
let activeDecisions = [];
|
|
319
400
|
try {
|
|
320
|
-
|
|
401
|
+
activeDecisions = readJsonlSync(this.decisionsPath);
|
|
321
402
|
}
|
|
322
403
|
catch {
|
|
323
404
|
this.countsReadable = false;
|
|
324
405
|
}
|
|
325
|
-
this.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
406
|
+
const closedN = this.pieces.filter((p) => p.closed).reduce((s, p) => s + p.n, 0);
|
|
407
|
+
let indexLines = readLedgerIndex(this.dir);
|
|
408
|
+
if (decisionIndexCount(indexLines) < closedN + activeDecisions.length) {
|
|
409
|
+
indexLines = this.rebuildIndex(activeDecisions);
|
|
410
|
+
}
|
|
411
|
+
for (const line of indexLines)
|
|
412
|
+
this.applyIndexLine(line);
|
|
413
|
+
for (const line of indexLines) {
|
|
414
|
+
if (line.kind === "allow" || line.kind === "defer") {
|
|
415
|
+
noteCounted(this.countedAt, {
|
|
416
|
+
ref: line.ref,
|
|
417
|
+
requestHash: line.requestHash,
|
|
418
|
+
decision: line.kind,
|
|
419
|
+
reasonCode: line.reasonCode,
|
|
420
|
+
subject: line.subject,
|
|
421
|
+
policyHash: line.policyHash,
|
|
422
|
+
timestampMs: line.ts,
|
|
423
|
+
});
|
|
338
424
|
}
|
|
339
425
|
}
|
|
340
|
-
this.
|
|
426
|
+
this.pruneCounted(Date.now());
|
|
427
|
+
const last = activeDecisions[activeDecisions.length - 1];
|
|
428
|
+
if (last) {
|
|
429
|
+
this.tailHash = decisionRecordHash(last);
|
|
430
|
+
this.lastDecisionMs = last.claims.timestampMs;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
const prev = [...this.pieces].reverse().find((p) => p.closed);
|
|
434
|
+
this.tailHash = prev?.lastHash ?? null;
|
|
435
|
+
this.lastDecisionMs = prev?.lastMs ?? null;
|
|
436
|
+
}
|
|
437
|
+
this.activeDecisionN = activeDecisions.length;
|
|
438
|
+
this.decisionCount = closedN + activeDecisions.length;
|
|
439
|
+
if (open.firstMs === null && activeDecisions[0])
|
|
440
|
+
open.firstMs = activeDecisions[0].claims.timestampMs;
|
|
441
|
+
if (activeDecisions.length > 0)
|
|
442
|
+
open.lastMs = activeDecisions[activeDecisions.length - 1].claims.timestampMs;
|
|
443
|
+
open.n = activeDecisions.length;
|
|
341
444
|
this.effectCount = 0;
|
|
342
|
-
|
|
445
|
+
const activeEffects = readJsonlSync(this.effectsPath);
|
|
446
|
+
for (const effect of activeEffects) {
|
|
343
447
|
this.effectCount += 1;
|
|
344
448
|
if (effect.row.effectClass !== "duplicate-effect")
|
|
345
449
|
this.effectRefs.add(effect.row.ref);
|
|
346
450
|
}
|
|
347
|
-
this.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
451
|
+
this.activeEffectN = activeEffects.length;
|
|
452
|
+
const closedEffects = this.pieces.filter((p) => p.closed).reduce((s, p) => s + p.effectN, 0);
|
|
453
|
+
this.effectCount = closedEffects + activeEffects.length;
|
|
454
|
+
open.effectN = activeEffects.length;
|
|
455
|
+
}
|
|
456
|
+
applyActivePaths() {
|
|
457
|
+
const piece = this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
|
|
458
|
+
this.activeId = piece.id;
|
|
459
|
+
this.decisionsPath = join(this.dir, piece.decisions);
|
|
460
|
+
this.effectsPath = join(this.dir, piece.effects);
|
|
461
|
+
this.inputsPath = join(this.dir, piece.inputs);
|
|
462
|
+
this.copyDecisionsPath = join(this.dir, copyRel(piece.id, "decisions.jsonl"));
|
|
463
|
+
this.copyEffectsPath = join(this.dir, copyRel(piece.id, "effects.jsonl"));
|
|
464
|
+
}
|
|
465
|
+
activePiece() {
|
|
466
|
+
return this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
|
|
467
|
+
}
|
|
468
|
+
applyIndexLine(line) {
|
|
469
|
+
// An effect marker names its decision's ref and nothing else: the
|
|
470
|
+
// decision's own line already carries the row, and counting the marker
|
|
471
|
+
// as a decision doubled the rate-limit work after a restart.
|
|
472
|
+
if (line.kind === "effect") {
|
|
473
|
+
this.effectRefs.add(line.ref);
|
|
474
|
+
return;
|
|
354
475
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
476
|
+
const row = {
|
|
477
|
+
ref: line.ref,
|
|
478
|
+
requestHash: line.requestHash,
|
|
479
|
+
decision: line.kind,
|
|
480
|
+
reasonCode: line.reasonCode,
|
|
481
|
+
subject: line.subject,
|
|
482
|
+
policyHash: line.policyHash,
|
|
483
|
+
timestampMs: line.ts,
|
|
484
|
+
};
|
|
485
|
+
this.byRef.set(line.ref, row);
|
|
486
|
+
if (line.tenantRef !== "")
|
|
487
|
+
this.byRef.set(`${line.tenantRef}:${line.ref}`, row);
|
|
488
|
+
noteReauth(this.reauthByHash, row);
|
|
489
|
+
if (line.resolves) {
|
|
490
|
+
const kind = resolutionKindOf(row);
|
|
491
|
+
if (kind)
|
|
492
|
+
this.resolvedBy.set(line.resolves, { ref: line.ref, kind });
|
|
359
493
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
494
|
+
if (line.hasEffect)
|
|
495
|
+
this.effectRefs.add(line.ref);
|
|
496
|
+
}
|
|
497
|
+
pruneCounted(nowMs) {
|
|
498
|
+
const day = new Date(nowMs);
|
|
499
|
+
const keepFrom = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - 60_000;
|
|
500
|
+
let w = 0;
|
|
501
|
+
for (const t of this.countedAt) {
|
|
502
|
+
if (t >= keepFrom) {
|
|
503
|
+
this.countedAt[w] = t;
|
|
504
|
+
w += 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
this.countedAt.length = w;
|
|
508
|
+
}
|
|
509
|
+
rebuildIndex(activeDecisions) {
|
|
510
|
+
const lines = [];
|
|
511
|
+
for (const piece of this.pieces) {
|
|
512
|
+
const path = join(this.dir, piece.decisions);
|
|
513
|
+
const recs = piece.id === this.activeId ? activeDecisions : readJsonlSync(path);
|
|
514
|
+
const inputLists = inputsByRef(join(this.dir, piece.inputs));
|
|
515
|
+
const effectSet = primaryEffectRefs(join(this.dir, piece.effects));
|
|
516
|
+
const seen = new Map();
|
|
517
|
+
for (const rec of recs) {
|
|
518
|
+
const row = indexRowOf(rec);
|
|
519
|
+
if (!row)
|
|
520
|
+
continue;
|
|
521
|
+
const idx = seen.get(row.ref) ?? 0;
|
|
522
|
+
seen.set(row.ref, idx + 1);
|
|
523
|
+
const inputs = inputLists.get(row.ref)?.[idx];
|
|
524
|
+
const principal = inputs?.principal;
|
|
525
|
+
const tenantRef = principal && typeof principal.brain === "string"
|
|
526
|
+
? tenantKey({
|
|
527
|
+
brain: principal.brain,
|
|
528
|
+
iss: principal.iss,
|
|
529
|
+
tenant: principal.tenant,
|
|
530
|
+
org: principal.org,
|
|
531
|
+
})
|
|
532
|
+
: "";
|
|
533
|
+
lines.push({
|
|
534
|
+
ref: row.ref,
|
|
535
|
+
tenantRef,
|
|
536
|
+
piece: piece.id,
|
|
537
|
+
ts: row.timestampMs,
|
|
538
|
+
kind: row.decision,
|
|
539
|
+
requestHash: row.requestHash,
|
|
540
|
+
resolves: inputs?.approver?.resolves ?? null,
|
|
541
|
+
hasEffect: effectSet.has(row.ref),
|
|
542
|
+
reasonCode: row.reasonCode,
|
|
543
|
+
subject: row.subject,
|
|
544
|
+
policyHash: row.policyHash,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const text = lines.map((l) => lineOf(l)).join("");
|
|
549
|
+
writeFileAtomic(indexPath(this.dir), text);
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
552
|
+
async writeIndexForDecision(signed) {
|
|
553
|
+
const row = indexRowOf(signed);
|
|
554
|
+
if (!row)
|
|
555
|
+
return;
|
|
556
|
+
// The inputs row was handed over in memory when it was appended (the
|
|
557
|
+
// proxy writes it just before the decision). The tail read is for a row
|
|
558
|
+
// this instance did not write, and it is bounded: an append never reads
|
|
559
|
+
// a piece back.
|
|
560
|
+
let inputs = this.recentInputs.get(row.ref) ?? null;
|
|
561
|
+
if (inputs)
|
|
562
|
+
this.recentInputs.delete(row.ref);
|
|
563
|
+
else
|
|
564
|
+
inputs = await peekInputsTail(this.inputsPath, row.ref);
|
|
565
|
+
const principal = inputs?.principal;
|
|
566
|
+
const tenantRef = principal && typeof principal.brain === "string"
|
|
567
|
+
? tenantKey({
|
|
376
568
|
brain: principal.brain,
|
|
377
569
|
iss: principal.iss,
|
|
378
570
|
tenant: principal.tenant,
|
|
379
571
|
org: principal.org,
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
|
|
572
|
+
})
|
|
573
|
+
: "";
|
|
574
|
+
const line = {
|
|
575
|
+
ref: row.ref,
|
|
576
|
+
tenantRef,
|
|
577
|
+
piece: this.activeId,
|
|
578
|
+
ts: row.timestampMs,
|
|
579
|
+
kind: row.decision,
|
|
580
|
+
requestHash: row.requestHash,
|
|
581
|
+
resolves: inputs?.approver?.resolves ?? null,
|
|
582
|
+
hasEffect: this.effectRefs.has(row.ref),
|
|
583
|
+
reasonCode: row.reasonCode,
|
|
584
|
+
subject: row.subject,
|
|
585
|
+
policyHash: row.policyHash,
|
|
586
|
+
};
|
|
587
|
+
await appendPlain(indexPath(this.dir), lineOf(line));
|
|
588
|
+
this.applyIndexLine(line);
|
|
383
589
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
590
|
+
async writeIndexHasEffect(ref) {
|
|
591
|
+
const existing = this.byRef.get(ref);
|
|
592
|
+
if (!existing)
|
|
593
|
+
return;
|
|
594
|
+
const line = {
|
|
595
|
+
ref,
|
|
596
|
+
tenantRef: "",
|
|
597
|
+
piece: this.activeId,
|
|
598
|
+
ts: existing.timestampMs,
|
|
599
|
+
kind: "effect",
|
|
600
|
+
requestHash: "",
|
|
601
|
+
resolves: null,
|
|
602
|
+
hasEffect: true,
|
|
603
|
+
reasonCode: "",
|
|
604
|
+
subject: "",
|
|
605
|
+
policyHash: "",
|
|
606
|
+
};
|
|
607
|
+
await appendPlain(indexPath(this.dir), lineOf(line));
|
|
608
|
+
this.effectRefs.add(ref);
|
|
609
|
+
}
|
|
610
|
+
noteActiveStamp(ts) {
|
|
611
|
+
const piece = this.activePiece();
|
|
612
|
+
if (piece.firstMs === null)
|
|
613
|
+
piece.firstMs = ts;
|
|
614
|
+
piece.lastMs = ts;
|
|
615
|
+
}
|
|
616
|
+
persistManifest() {
|
|
617
|
+
const manifest = {
|
|
618
|
+
version: 1,
|
|
619
|
+
pieces: this.pieces,
|
|
620
|
+
countedAtMs: [...this.countedAt],
|
|
621
|
+
};
|
|
622
|
+
writeLedgerManifest(this.dir, manifest);
|
|
623
|
+
}
|
|
624
|
+
async fsyncPath(path) {
|
|
387
625
|
try {
|
|
388
|
-
|
|
626
|
+
const fh = await ledgerFs.open(path, "r+");
|
|
627
|
+
try {
|
|
628
|
+
await fh.sync();
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
await fh.close();
|
|
632
|
+
}
|
|
389
633
|
}
|
|
390
634
|
catch (err) {
|
|
391
635
|
if (err.code === "ENOENT")
|
|
392
636
|
return;
|
|
393
637
|
throw err;
|
|
394
638
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
639
|
+
}
|
|
640
|
+
ensurePieceFiles(piece) {
|
|
641
|
+
mkdirSync(join(this.dir, dirname(piece.decisions)), { recursive: true, mode: 0o700 });
|
|
642
|
+
mkdirSync(join(this.dir, dirname(copyRel(piece.id, "decisions.jsonl"))), { recursive: true, mode: 0o700 });
|
|
643
|
+
for (const rel of [piece.decisions, piece.effects, piece.inputs, copyRel(piece.id, "decisions.jsonl"), copyRel(piece.id, "effects.jsonl")]) {
|
|
644
|
+
const path = join(this.dir, rel);
|
|
645
|
+
if (!existsSync(path))
|
|
646
|
+
writeFileSync(path, "", { encoding: "utf8", mode: 0o600 });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async closeActivePiece() {
|
|
650
|
+
this.assertOwned();
|
|
651
|
+
const closing = this.activePiece();
|
|
652
|
+
await this.fsyncPath(this.decisionsPath);
|
|
653
|
+
await this.fsyncPath(this.effectsPath);
|
|
654
|
+
await this.fsyncPath(this.inputsPath);
|
|
655
|
+
closing.closed = true;
|
|
656
|
+
closing.closedAtMs = Date.now();
|
|
657
|
+
closing.n = this.activeDecisionN;
|
|
658
|
+
closing.effectN = this.activeEffectN;
|
|
659
|
+
closing.lastHash = this.tailHash;
|
|
660
|
+
const nextId = nextPieceId(this.pieces.map((p) => p.id), closing.lastMs ?? Date.now());
|
|
661
|
+
const next = newPieceRow(nextId);
|
|
662
|
+
this.pieces.push(next);
|
|
663
|
+
this.pruneCounted(Date.now());
|
|
664
|
+
this.persistManifest();
|
|
665
|
+
this.activeId = nextId;
|
|
666
|
+
this.applyActivePaths();
|
|
667
|
+
this.ensurePieceFiles(next);
|
|
668
|
+
this.activeDecisionN = 0;
|
|
669
|
+
this.activeEffectN = 0;
|
|
670
|
+
try {
|
|
671
|
+
await this.onPieceClose?.({
|
|
672
|
+
startMs: closing.firstMs ?? 0,
|
|
673
|
+
endMs: (closing.lastMs ?? 0) + 1,
|
|
674
|
+
pieceId: closing.id,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// witness down: the piece stays closed
|
|
410
679
|
}
|
|
411
680
|
}
|
|
681
|
+
async maybeRotate() {
|
|
682
|
+
if (this.activeDecisionN >= this.pieceMaxRows) {
|
|
683
|
+
await this.closeActivePiece();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let bytes = 0;
|
|
687
|
+
try {
|
|
688
|
+
bytes = statSync(this.decisionsPath).size;
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
bytes = 0;
|
|
692
|
+
}
|
|
693
|
+
if (bytes >= this.pieceMaxBytes)
|
|
694
|
+
await this.closeActivePiece();
|
|
695
|
+
}
|
|
412
696
|
lockBody() {
|
|
413
697
|
return `${JSON.stringify({ pid: process.pid, startedAt: Date.now(), token: this.token })}\n`;
|
|
414
698
|
}
|
|
@@ -472,12 +756,17 @@ export class FileLedger {
|
|
|
472
756
|
await appendDurable(this.decisionsPath, line);
|
|
473
757
|
await this.pulse("decisions.jsonl", line, "decision");
|
|
474
758
|
this.tailHash = decisionRecordHash(signed);
|
|
759
|
+
this.lastDecisionMs = signed.claims.timestampMs;
|
|
760
|
+
this.noteActiveStamp(signed.claims.timestampMs);
|
|
761
|
+
this.activeDecisionN += 1;
|
|
475
762
|
const row = indexRowOf(signed);
|
|
476
763
|
if (row) {
|
|
477
764
|
this.byRef.set(row.ref, row);
|
|
478
765
|
noteReauth(this.reauthByHash, row);
|
|
479
766
|
noteCounted(this.countedAt, row);
|
|
480
767
|
}
|
|
768
|
+
await this.writeIndexForDecision(signed);
|
|
769
|
+
await this.maybeRotate();
|
|
481
770
|
});
|
|
482
771
|
}
|
|
483
772
|
async appendDecisionChained(build) {
|
|
@@ -490,14 +779,110 @@ export class FileLedger {
|
|
|
490
779
|
await appendDurable(this.decisionsPath, line);
|
|
491
780
|
await this.pulse("decisions.jsonl", line, "decision");
|
|
492
781
|
this.tailHash = decisionRecordHash(signed);
|
|
782
|
+
this.lastDecisionMs = signed.claims.timestampMs;
|
|
783
|
+
this.noteActiveStamp(signed.claims.timestampMs);
|
|
784
|
+
this.activeDecisionN += 1;
|
|
493
785
|
const row = indexRowOf(signed);
|
|
494
786
|
if (row) {
|
|
495
787
|
this.byRef.set(row.ref, row);
|
|
496
788
|
noteReauth(this.reauthByHash, row);
|
|
497
789
|
noteCounted(this.countedAt, row);
|
|
498
790
|
}
|
|
791
|
+
await this.writeIndexForDecision(signed);
|
|
792
|
+
await this.maybeRotate();
|
|
499
793
|
});
|
|
500
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* What /healthz reports, from memory: the counts are kept on load and on
|
|
797
|
+
* every append. Rereading both files to count lines cost a second per call
|
|
798
|
+
* on a 100k-decision ledger (measured 17 Sep 2026), and the panel asks
|
|
799
|
+
* every five seconds. Null when the decision file could not be parsed on
|
|
800
|
+
* load; then the caller reads for itself and fails the way it always did.
|
|
801
|
+
*/
|
|
802
|
+
counts() {
|
|
803
|
+
if (!this.countsReadable)
|
|
804
|
+
return null;
|
|
805
|
+
return {
|
|
806
|
+
decisions: this.decisionCount,
|
|
807
|
+
effects: this.effectCount,
|
|
808
|
+
lastDecisionMs: this.lastDecisionMs,
|
|
809
|
+
activeDecisions: this.activeDecisionN,
|
|
810
|
+
pieces: this.pieces.length,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Decisions stamped in [fromMs, toMs), read from the end of each overlapping
|
|
815
|
+
* piece so the cost is the window's, not the ledger's. With a limit, the
|
|
816
|
+
* newest rows of the window come back and `more` says the window went on,
|
|
817
|
+
* including in a closed piece.
|
|
818
|
+
*/
|
|
819
|
+
async decisionsWindow(fromMs, toMs, limit) {
|
|
820
|
+
const max = limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(limit));
|
|
821
|
+
const overlapping = this.pieces.filter((p) => pieceOverlaps(p, fromMs, toMs));
|
|
822
|
+
const piecesTouched = overlapping.map((p) => p.id);
|
|
823
|
+
let taken = 0;
|
|
824
|
+
let more = false;
|
|
825
|
+
let stopOld = false;
|
|
826
|
+
const newestFirst = [...overlapping].reverse();
|
|
827
|
+
const collected = [];
|
|
828
|
+
for (let i = 0; i < newestFirst.length; i += 1) {
|
|
829
|
+
const piece = newestFirst[i];
|
|
830
|
+
if (stopOld)
|
|
831
|
+
break;
|
|
832
|
+
const part = await readPieceWindow(this.dir, piece, piece.decisions, toMs, (row) => row.claims.timestampMs, (row) => {
|
|
833
|
+
const ts = row.claims.timestampMs;
|
|
834
|
+
if (ts >= toMs)
|
|
835
|
+
return "skip";
|
|
836
|
+
if (ts < fromMs) {
|
|
837
|
+
if (ts < fromMs - WINDOW_SLACK_MS) {
|
|
838
|
+
stopOld = true;
|
|
839
|
+
return "stop";
|
|
840
|
+
}
|
|
841
|
+
return "skip";
|
|
842
|
+
}
|
|
843
|
+
if (taken >= max) {
|
|
844
|
+
more = true;
|
|
845
|
+
return "stop";
|
|
846
|
+
}
|
|
847
|
+
taken += 1;
|
|
848
|
+
return "take";
|
|
849
|
+
});
|
|
850
|
+
collected.unshift(...part);
|
|
851
|
+
if (more || stopOld)
|
|
852
|
+
break;
|
|
853
|
+
if (taken >= max && i + 1 < newestFirst.length) {
|
|
854
|
+
more = true;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return { rows: collected, more, piecesTouched };
|
|
859
|
+
}
|
|
860
|
+
/** Effects stamped in [fromMs, toMs), read from the end of each overlapping piece. */
|
|
861
|
+
async effectsWindow(fromMs, toMs) {
|
|
862
|
+
const overlapping = this.pieces.filter((p) => pieceOverlaps(p, fromMs, toMs));
|
|
863
|
+
const newestFirst = [...overlapping].reverse();
|
|
864
|
+
const collected = [];
|
|
865
|
+
let stopOld = false;
|
|
866
|
+
for (const piece of newestFirst) {
|
|
867
|
+
if (stopOld)
|
|
868
|
+
break;
|
|
869
|
+
const part = await readPieceWindow(this.dir, piece, piece.effects, toMs, (effect) => effect.row.timestampMs, (effect) => {
|
|
870
|
+
const ts = effect.row.timestampMs;
|
|
871
|
+
if (ts >= toMs)
|
|
872
|
+
return "skip";
|
|
873
|
+
if (ts < fromMs) {
|
|
874
|
+
if (ts < fromMs - WINDOW_SLACK_MS) {
|
|
875
|
+
stopOld = true;
|
|
876
|
+
return "stop";
|
|
877
|
+
}
|
|
878
|
+
return "skip";
|
|
879
|
+
}
|
|
880
|
+
return "take";
|
|
881
|
+
});
|
|
882
|
+
collected.unshift(...part);
|
|
883
|
+
}
|
|
884
|
+
return collected;
|
|
885
|
+
}
|
|
501
886
|
async appendEffect(row, witnessClass = DEFAULT_WITNESS, resultHash) {
|
|
502
887
|
return this.q.enqueue(async () => {
|
|
503
888
|
this.assertOwned();
|
|
@@ -515,19 +900,31 @@ export class FileLedger {
|
|
|
515
900
|
}, DEFAULT_WITNESS, resultHash, undefined));
|
|
516
901
|
await appendDurable(this.effectsPath, dup);
|
|
517
902
|
await this.pulse("effects.jsonl", dup, "effect");
|
|
903
|
+
this.activeEffectN += 1;
|
|
518
904
|
throw new Error(`duplicate-effect:${row.ref}`);
|
|
519
905
|
}
|
|
520
906
|
const line = lineOf(await this.storeEffect(row, witnessClass, resultHash));
|
|
521
907
|
await appendDurable(this.effectsPath, line);
|
|
522
908
|
await this.pulse("effects.jsonl", line, "effect");
|
|
523
|
-
|
|
909
|
+
this.activeEffectN += 1;
|
|
910
|
+
if (row.effectClass !== "duplicate-effect") {
|
|
524
911
|
this.effectRefs.add(row.ref);
|
|
912
|
+
await this.writeIndexHasEffect(row.ref);
|
|
913
|
+
}
|
|
525
914
|
}
|
|
526
915
|
async decisions() {
|
|
527
|
-
|
|
916
|
+
const out = [];
|
|
917
|
+
for (const piece of this.pieces) {
|
|
918
|
+
out.push(...(await readJsonl(join(this.dir, piece.decisions))));
|
|
919
|
+
}
|
|
920
|
+
return out;
|
|
528
921
|
}
|
|
529
922
|
async effects() {
|
|
530
|
-
|
|
923
|
+
const out = [];
|
|
924
|
+
for (const piece of this.pieces) {
|
|
925
|
+
out.push(...(await readJsonl(join(this.dir, piece.effects))));
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
531
928
|
}
|
|
532
929
|
async lastDecisionHash() {
|
|
533
930
|
return this.lastDecisionHashUnlocked();
|
|
@@ -562,8 +959,9 @@ export class FileLedger {
|
|
|
562
959
|
return this.effectRefs.has(ref);
|
|
563
960
|
}
|
|
564
961
|
async pulse(name, line, kind) {
|
|
565
|
-
|
|
566
|
-
|
|
962
|
+
const dest = name === "decisions.jsonl" ? this.copyDecisionsPath : this.copyEffectsPath;
|
|
963
|
+
mkdirSync(dirname(dest), { recursive: true, mode: 0o700 });
|
|
964
|
+
appendFileSync(dest, line, { encoding: "utf8" });
|
|
567
965
|
if (kind === "decision")
|
|
568
966
|
this.decisionCount += 1;
|
|
569
967
|
else
|
|
@@ -594,6 +992,63 @@ export class FileLedger {
|
|
|
594
992
|
return asStoredEffect(row, witnessClass, resultHash, this.effectSigner);
|
|
595
993
|
}
|
|
596
994
|
}
|
|
995
|
+
const RECENT_INPUTS_MAX = 4096;
|
|
996
|
+
const PEEK_INPUTS_ROWS = 256;
|
|
997
|
+
/** The newest rows of inputs.jsonl, from the end, for a ref this instance did not append. */
|
|
998
|
+
async function peekInputsTail(path, ref) {
|
|
999
|
+
let visited = 0;
|
|
1000
|
+
let found = false;
|
|
1001
|
+
let rows;
|
|
1002
|
+
try {
|
|
1003
|
+
rows = await readLedgerTail(path, (row) => {
|
|
1004
|
+
if (found)
|
|
1005
|
+
return "stop";
|
|
1006
|
+
visited += 1;
|
|
1007
|
+
if (row.ref === ref) {
|
|
1008
|
+
found = true;
|
|
1009
|
+
return "take";
|
|
1010
|
+
}
|
|
1011
|
+
return visited >= PEEK_INPUTS_ROWS ? "stop" : "skip";
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
if (err.code === "ENOENT")
|
|
1016
|
+
return null;
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
return rows[0]?.inputs ?? null;
|
|
1020
|
+
}
|
|
1021
|
+
function inputsByRef(path) {
|
|
1022
|
+
const out = new Map();
|
|
1023
|
+
let text;
|
|
1024
|
+
try {
|
|
1025
|
+
text = readFileSync(path, "utf8");
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
if (err.code === "ENOENT")
|
|
1029
|
+
return out;
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
1032
|
+
for (const line of text.split("\n")) {
|
|
1033
|
+
if (line === "")
|
|
1034
|
+
continue;
|
|
1035
|
+
const row = JSON.parse(line);
|
|
1036
|
+
if (typeof row.ref !== "string" || !row.inputs)
|
|
1037
|
+
continue;
|
|
1038
|
+
const list = out.get(row.ref) ?? [];
|
|
1039
|
+
list.push(row.inputs);
|
|
1040
|
+
out.set(row.ref, list);
|
|
1041
|
+
}
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
1044
|
+
function primaryEffectRefs(path) {
|
|
1045
|
+
const refs = new Set();
|
|
1046
|
+
for (const effect of readJsonlSync(path)) {
|
|
1047
|
+
if (effect.row.effectClass !== "duplicate-effect")
|
|
1048
|
+
refs.add(effect.row.ref);
|
|
1049
|
+
}
|
|
1050
|
+
return refs;
|
|
1051
|
+
}
|
|
597
1052
|
function signWindow(effects, window, signer) {
|
|
598
1053
|
const rows = effects
|
|
599
1054
|
.filter((e) => e.row.timestampMs >= window.startMs && e.row.timestampMs < window.endMs)
|