@allocator-one/rcl 4.5.7 → 4.5.9
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/README.md +5 -4
- package/dist/consensus/semantic-deduper.d.ts +2 -1
- package/dist/consensus/semantic-deduper.js +21 -10
- package/dist/consensus/semantic-deduper.js.map +1 -1
- package/dist/converge/attempt-budget.d.ts +11 -0
- package/dist/converge/attempt-budget.js +119 -61
- package/dist/converge/attempt-budget.js.map +1 -1
- package/dist/converge/delivery-reconciliation.d.ts +2 -1
- package/dist/converge/delivery-reconciliation.js +17 -9
- package/dist/converge/delivery-reconciliation.js.map +1 -1
- package/dist/converge/guarded-input-retention.d.ts +23 -0
- package/dist/converge/guarded-input-retention.js +179 -0
- package/dist/converge/guarded-input-retention.js.map +1 -0
- package/dist/converge/launch-guard.d.ts +3 -0
- package/dist/converge/launch-guard.js +53 -17
- package/dist/converge/launch-guard.js.map +1 -1
- package/dist/converge/ordinary-pending-export.js +74 -40
- package/dist/converge/ordinary-pending-export.js.map +1 -1
- package/dist/converge/ordinary-pending-package.d.ts +16 -2
- package/dist/converge/ordinary-pending-package.js +77 -4
- package/dist/converge/ordinary-pending-package.js.map +1 -1
- package/dist/converge/recovered-production.d.ts +14 -0
- package/dist/converge/recovered-production.js +61 -0
- package/dist/converge/recovered-production.js.map +1 -0
- package/dist/converge/recovery-launch.js +82 -35
- package/dist/converge/recovery-launch.js.map +1 -1
- package/dist/converge/run-state.d.ts +2 -0
- package/dist/converge/run-state.js +26 -12
- package/dist/converge/run-state.js.map +1 -1
- package/dist/converge/semantic-state.js +18 -4
- package/dist/converge/semantic-state.js.map +1 -1
- package/dist/coordination/registry-lock.d.ts +2 -0
- package/dist/coordination/registry-lock.js +42 -13
- package/dist/coordination/registry-lock.js.map +1 -1
- package/dist/evidence/claim-recovery/delivery.d.ts +25 -0
- package/dist/evidence/claim-recovery/delivery.js +100 -0
- package/dist/evidence/claim-recovery/delivery.js.map +1 -0
- package/dist/evidence/claim-recovery/public-adoption.d.ts +31 -0
- package/dist/evidence/claim-recovery/public-adoption.js +177 -0
- package/dist/evidence/claim-recovery/public-adoption.js.map +1 -0
- package/dist/evidence/claim-recovery/public-manifest.d.ts +114 -0
- package/dist/evidence/claim-recovery/public-manifest.js +11 -0
- package/dist/evidence/claim-recovery/public-manifest.js.map +1 -0
- package/dist/evidence/claim-recovery/public-model.d.ts +85 -0
- package/dist/evidence/claim-recovery/public-model.js +177 -0
- package/dist/evidence/claim-recovery/public-model.js.map +1 -0
- package/dist/evidence/original-run/journal.d.ts +10 -0
- package/dist/evidence/original-run/journal.js +55 -3
- package/dist/evidence/original-run/journal.js.map +1 -1
- package/dist/evidence/original-run/lock.js +22 -9
- package/dist/evidence/original-run/lock.js.map +1 -1
- package/dist/evidence/recover-claim.d.ts +21 -0
- package/dist/evidence/recover-claim.js +504 -0
- package/dist/evidence/recover-claim.js.map +1 -0
- package/dist/index.js +234 -139
- package/dist/index.js.map +1 -1
- package/dist/report/assembly.d.ts +2 -0
- package/dist/report/assembly.js +1 -0
- package/dist/report/assembly.js.map +1 -1
- package/dist/report/consensus-assembly.d.ts +2 -0
- package/dist/report/consensus-assembly.js +6 -3
- package/dist/report/consensus-assembly.js.map +1 -1
- package/dist/report/reviewer-artifact.js +3 -1
- package/dist/report/reviewer-artifact.js.map +1 -1
- package/dist/report/run-header.js +2 -0
- package/dist/report/run-header.js.map +1 -1
- package/dist/telemetry/deliver.d.ts +8 -0
- package/dist/telemetry/deliver.js +55 -8
- package/dist/telemetry/deliver.js.map +1 -1
- package/dist/telemetry/events.js +2 -0
- package/dist/telemetry/events.js.map +1 -1
- package/dist/telemetry/outbox.d.ts +20 -0
- package/dist/telemetry/outbox.js +324 -103
- package/dist/telemetry/outbox.js.map +1 -1
- package/dist/telemetry/reviewer-delivery.d.ts +95 -3
- package/dist/telemetry/reviewer-delivery.js +600 -12
- package/dist/telemetry/reviewer-delivery.js.map +1 -1
- package/dist/telemetry/sink.d.ts +14 -0
- package/dist/telemetry/sink.js +44 -2
- package/dist/telemetry/sink.js.map +1 -1
- package/dist/telemetry/terminal-reviewer-delivery.d.ts +154 -5
- package/dist/telemetry/terminal-reviewer-delivery.js +196 -21
- package/dist/telemetry/terminal-reviewer-delivery.js.map +1 -1
- package/package.json +1 -1
package/dist/telemetry/outbox.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, open, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'fs/promises';
|
|
2
|
-
import { join, resolve, sep } from 'path';
|
|
2
|
+
import { basename, join, resolve, sep } from 'path';
|
|
3
3
|
import { resolveDataDir } from '../config/data-dir.js';
|
|
4
|
+
import { withRecoveryLock } from '../evidence/original-run/lock.js';
|
|
4
5
|
import { sha256Hex } from './envelope.js';
|
|
5
6
|
import { MAX_ARTIFACT_BYTES, validateRunEnvelope } from './envelope-validation.js';
|
|
6
7
|
import { buildEvent } from './events.js';
|
|
@@ -37,6 +38,7 @@ const EVENTS_FILE = 'events.json';
|
|
|
37
38
|
const ARTIFACTS_DIR = 'artifacts';
|
|
38
39
|
const LOSS_DIR = 'loss';
|
|
39
40
|
const FAILED_MARKER = 'failed.json';
|
|
41
|
+
const FAILED_EVENTS_FILE = 'failed-events.json';
|
|
40
42
|
/** A loss report the server refused stays on disk under this suffix; it is never retried or counted as reported. */
|
|
41
43
|
const LOSS_REFUSED_SUFFIX = '.refused';
|
|
42
44
|
/** Loss reports go out in batches this size, well under the server's event cap. */
|
|
@@ -72,6 +74,13 @@ class EntryGone extends Error {
|
|
|
72
74
|
this.name = 'EntryGone';
|
|
73
75
|
}
|
|
74
76
|
}
|
|
77
|
+
/** A bounded local outbox transaction could not acquire its per-entry lock. */
|
|
78
|
+
class EntryMutationLocked extends Error {
|
|
79
|
+
constructor() {
|
|
80
|
+
super('outbox_entry_locked');
|
|
81
|
+
this.name = 'EntryMutationLocked';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
75
84
|
async function readJson(path) {
|
|
76
85
|
let raw;
|
|
77
86
|
try {
|
|
@@ -89,8 +98,7 @@ async function readJson(path) {
|
|
|
89
98
|
return { kind: 'malformed' };
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
|
-
|
|
93
|
-
async function readBoundedJson(path, limit) {
|
|
101
|
+
async function readText(path, limit = MAX_ARTIFACT_BYTES) {
|
|
94
102
|
let file;
|
|
95
103
|
try {
|
|
96
104
|
file = await open(path, 'r');
|
|
@@ -108,18 +116,12 @@ async function readBoundedJson(path, limit) {
|
|
|
108
116
|
return { kind: 'oversized' };
|
|
109
117
|
chunks.push(chunk.subarray(0, bytesRead));
|
|
110
118
|
}
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
return { kind: 'ok', value: JSON.parse(raw), raw };
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return { kind: 'malformed' };
|
|
117
|
-
}
|
|
119
|
+
return { kind: 'ok', value: Buffer.concat(chunks, size).toString('utf8') };
|
|
118
120
|
}
|
|
119
|
-
catch (
|
|
120
|
-
if (
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (isEnoent(error))
|
|
121
123
|
return { kind: 'missing' };
|
|
122
|
-
return { kind: 'error', reason:
|
|
124
|
+
return { kind: 'error', reason: error instanceof Error ? error.message : String(error) };
|
|
123
125
|
}
|
|
124
126
|
finally {
|
|
125
127
|
await file?.close().catch(() => undefined);
|
|
@@ -130,7 +132,7 @@ async function readBoundedJson(path, limit) {
|
|
|
130
132
|
* what was sent; otherwise (another process queued more meanwhile) only the
|
|
131
133
|
* delivered ids leave it and the rest waits for the next flush.
|
|
132
134
|
*/
|
|
133
|
-
async function dropDeliveredEvents(path,
|
|
135
|
+
async function dropDeliveredEvents(path, delivered) {
|
|
134
136
|
let current;
|
|
135
137
|
try {
|
|
136
138
|
current = await readFile(path, 'utf8');
|
|
@@ -138,10 +140,6 @@ async function dropDeliveredEvents(path, raw, delivered) {
|
|
|
138
140
|
catch {
|
|
139
141
|
return;
|
|
140
142
|
}
|
|
141
|
-
if (current === raw) {
|
|
142
|
-
await rm(path, { force: true });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
143
|
let parsed;
|
|
146
144
|
try {
|
|
147
145
|
parsed = JSON.parse(current);
|
|
@@ -205,14 +203,31 @@ function isRecord(value) {
|
|
|
205
203
|
function validEvents(value) {
|
|
206
204
|
return Array.isArray(value) && value.every((e) => isRecord(e) && typeof e['id'] === 'string' && typeof e['kind'] === 'string');
|
|
207
205
|
}
|
|
206
|
+
function validEventFailures(value) {
|
|
207
|
+
return isRecord(value) && Object.entries(value).every(([id, reason]) => UUID.test(id) && typeof reason === 'string');
|
|
208
|
+
}
|
|
208
209
|
export class Outbox {
|
|
209
210
|
dir;
|
|
210
211
|
capBytes;
|
|
211
212
|
now;
|
|
213
|
+
entryLocks;
|
|
212
214
|
constructor(dir = join(resolveDataDir(), OUTBOX_DIR), options = {}) {
|
|
213
215
|
this.dir = resolve(dir);
|
|
214
216
|
this.capBytes = options.capBytes ?? DEFAULT_OUTBOX_CAP_BYTES;
|
|
215
217
|
this.now = options.now ?? Date.now;
|
|
218
|
+
this.entryLocks = `${this.dir}-entry-locks`;
|
|
219
|
+
}
|
|
220
|
+
async withEntryMutation(id, work) {
|
|
221
|
+
if (!ENTRY_ID.test(id))
|
|
222
|
+
throw new OutboxError(`Not an outbox entry id: ${JSON.stringify(id)}`);
|
|
223
|
+
try {
|
|
224
|
+
return await withRecoveryLock(this.entryLocks, id.toLowerCase(), work);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error instanceof Error && error.message === 'recovery_run_locked')
|
|
228
|
+
throw new EntryMutationLocked();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
216
231
|
}
|
|
217
232
|
async totalBytes() {
|
|
218
233
|
return directorySize(this.dir);
|
|
@@ -229,6 +244,64 @@ export class Outbox {
|
|
|
229
244
|
throw new OutboxError(`Entry escapes the outbox: ${id}`);
|
|
230
245
|
return dir;
|
|
231
246
|
}
|
|
247
|
+
async updateMeta(id, update) {
|
|
248
|
+
return this.withEntryMutation(id, async () => {
|
|
249
|
+
const path = join(this.entryDir(id), META_FILE);
|
|
250
|
+
const current = await readJson(path);
|
|
251
|
+
if (current.kind === 'missing')
|
|
252
|
+
throw new EntryGone();
|
|
253
|
+
if (current.kind !== 'ok' || !isRecord(current.value))
|
|
254
|
+
throw new OutboxError('meta.json malformed');
|
|
255
|
+
const next = update(current.value);
|
|
256
|
+
await writeJsonAtomic(path, next);
|
|
257
|
+
return next;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
mergeFlushMeta(current, desired) {
|
|
261
|
+
return {
|
|
262
|
+
...current,
|
|
263
|
+
attempts: Math.max(current.attempts, desired.attempts),
|
|
264
|
+
...(current.envelope_delivered || desired.envelope_delivered ? { envelope_delivered: true } : {}),
|
|
265
|
+
...(desired.run_url !== undefined ? { run_url: desired.run_url } : current.run_url !== undefined ? { run_url: current.run_url } : {}),
|
|
266
|
+
...(desired.last_error !== undefined ? { last_error: desired.last_error } : current.last_error !== undefined ? { last_error: current.last_error } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async beginFlush(id) {
|
|
270
|
+
return this.withEntryMutation(id, async () => {
|
|
271
|
+
const dir = this.entryDir(id);
|
|
272
|
+
const metaRead = await readJson(join(dir, META_FILE));
|
|
273
|
+
if (metaRead.kind === 'missing')
|
|
274
|
+
throw new EntryGone();
|
|
275
|
+
if (metaRead.kind !== 'ok' || !isRecord(metaRead.value))
|
|
276
|
+
throw new OutboxError('meta.json malformed');
|
|
277
|
+
const meta = { ...metaRead.value, attempts: metaRead.value.attempts + 1 };
|
|
278
|
+
await writeJsonAtomic(join(dir, META_FILE), meta);
|
|
279
|
+
const names = await readdir(join(dir, ARTIFACTS_DIR)).catch(() => []);
|
|
280
|
+
const artifacts = {};
|
|
281
|
+
for (const [kind, file] of Object.entries(ARTIFACT_FILES)) {
|
|
282
|
+
if (names.includes(file))
|
|
283
|
+
artifacts[kind] = await readText(join(dir, ARTIFACTS_DIR, file));
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
meta,
|
|
287
|
+
envelope: await readJson(join(dir, ENVELOPE_FILE)),
|
|
288
|
+
artifacts,
|
|
289
|
+
events: await readJson(join(dir, EVENTS_FILE)),
|
|
290
|
+
failures: await readJson(join(dir, FAILED_EVENTS_FILE)),
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async removeArtifactIfExact(id, path, bytes) {
|
|
295
|
+
await this.withEntryMutation(id, async () => {
|
|
296
|
+
const current = await readFile(path, 'utf8').catch(error => {
|
|
297
|
+
if (isEnoent(error))
|
|
298
|
+
return undefined;
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
if (current === bytes)
|
|
302
|
+
await rm(path, { force: true });
|
|
303
|
+
});
|
|
304
|
+
}
|
|
232
305
|
/** Spool a run whose delivery failed (or whose artifacts could not be uploaded). */
|
|
233
306
|
async spoolRun(input) {
|
|
234
307
|
if (!UUID.test(input.runId))
|
|
@@ -236,6 +309,9 @@ export class Outbox {
|
|
|
236
309
|
if (input.envelope.run?.id?.toLowerCase() !== input.runId.toLowerCase()) {
|
|
237
310
|
throw new OutboxError(`Envelope run id ${JSON.stringify(input.envelope.run?.id)} does not match the entry id ${input.runId}.`);
|
|
238
311
|
}
|
|
312
|
+
return this.withEntryMutation(input.runId, () => this.spoolRunLocked(input));
|
|
313
|
+
}
|
|
314
|
+
async spoolRunLocked(input) {
|
|
239
315
|
const dir = this.entryDir(input.runId);
|
|
240
316
|
await mkdir(join(dir, ARTIFACTS_DIR), { recursive: true, mode: 0o700 });
|
|
241
317
|
const existing = await readOptionalJson(join(dir, META_FILE));
|
|
@@ -291,6 +367,9 @@ export class Outbox {
|
|
|
291
367
|
if (!first || !UUID.test(first.id))
|
|
292
368
|
throw new OutboxError('Events to spool must carry UUID ids.');
|
|
293
369
|
const id = `events-${first.id.toLowerCase()}`;
|
|
370
|
+
return this.withEntryMutation(id, () => this.spoolEventsLocked(id, events));
|
|
371
|
+
}
|
|
372
|
+
async spoolEventsLocked(id, events) {
|
|
294
373
|
const dir = this.entryDir(id);
|
|
295
374
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
296
375
|
// A batch spooled again lands on the same entry: merge by event id so
|
|
@@ -437,11 +516,25 @@ export class Outbox {
|
|
|
437
516
|
summary.remaining.push(entry.id);
|
|
438
517
|
continue;
|
|
439
518
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
519
|
+
let result;
|
|
520
|
+
try {
|
|
521
|
+
const work = () => this.flushEntry(sink, entry, pastDeadline, request, options);
|
|
522
|
+
result = options.entryBoundary ? await options.entryBoundary(entry, work) : await work();
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (error instanceof EntryGone)
|
|
526
|
+
result = { kind: 'delivered' };
|
|
527
|
+
else if (error instanceof EntryMutationLocked)
|
|
528
|
+
result = { kind: 'retry' };
|
|
529
|
+
else {
|
|
530
|
+
const reason = options.entryRefusalReason?.(error);
|
|
531
|
+
if (reason === undefined)
|
|
532
|
+
throw error;
|
|
533
|
+
summary.remaining.push(entry.id);
|
|
534
|
+
summary.failed.push({ id: entry.id, reason });
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
445
538
|
switch (result.kind) {
|
|
446
539
|
case 'delivered':
|
|
447
540
|
summary.delivered.push(entry.id);
|
|
@@ -449,6 +542,10 @@ export class Outbox {
|
|
|
449
542
|
case 'failed':
|
|
450
543
|
summary.failed.push({ id: entry.id, reason: result.reason });
|
|
451
544
|
break;
|
|
545
|
+
case 'retained-failed':
|
|
546
|
+
summary.remaining.push(entry.id);
|
|
547
|
+
summary.failed.push({ id: entry.id, reason: result.reason });
|
|
548
|
+
break;
|
|
452
549
|
case 'dropped':
|
|
453
550
|
summary.dropped.push({ id: entry.id, reason: result.reason });
|
|
454
551
|
break;
|
|
@@ -465,8 +562,9 @@ export class Outbox {
|
|
|
465
562
|
break;
|
|
466
563
|
}
|
|
467
564
|
}
|
|
468
|
-
if (summary.stopped !== 'unavailable' && options.runId === undefined) {
|
|
469
|
-
const
|
|
565
|
+
if (summary.stopped !== 'unavailable' && options.runId === undefined && (await this.pendingLossFiles()).length > 0) {
|
|
566
|
+
const report = () => this.reportLoss(sink, pastDeadline, request, options);
|
|
567
|
+
const loss = options.lossBoundary ? await options.lossBoundary(report) : await report();
|
|
470
568
|
if (loss.kind === 'done' && loss.reported > 0)
|
|
471
569
|
summary.lossReported = loss.reported;
|
|
472
570
|
if (loss.kind !== 'done' && !summary.stopped)
|
|
@@ -477,60 +575,63 @@ export class Outbox {
|
|
|
477
575
|
}
|
|
478
576
|
return summary;
|
|
479
577
|
}
|
|
480
|
-
async flushEntry(sink, entry, pastDeadline, request) {
|
|
578
|
+
async flushEntry(sink, entry, pastDeadline, request, options) {
|
|
481
579
|
const dir = this.entryDir(entry.id);
|
|
482
|
-
|
|
483
|
-
|
|
580
|
+
const snapshot = await this.beginFlush(entry.id);
|
|
581
|
+
let meta = snapshot.meta;
|
|
582
|
+
if (meta.kind !== 'run' && meta.kind !== 'events') {
|
|
583
|
+
return this.markFailed(entry.id, dir, `meta.json: unknown entry kind ${JSON.stringify(meta.kind)}`);
|
|
484
584
|
}
|
|
485
|
-
const meta = { ...entry.meta, attempts: entry.meta.attempts + 1 };
|
|
486
585
|
const remember = async (error) => {
|
|
487
586
|
if (error !== undefined)
|
|
488
587
|
meta.last_error = error;
|
|
489
|
-
|
|
490
|
-
await writeJsonAtomic(join(dir, META_FILE), meta);
|
|
491
|
-
}
|
|
492
|
-
catch (err) {
|
|
493
|
-
if (isEnoent(err))
|
|
494
|
-
throw new EntryGone();
|
|
495
|
-
throw err;
|
|
496
|
-
}
|
|
588
|
+
meta = await this.updateMeta(entry.id, current => this.mergeFlushMeta(current, meta));
|
|
497
589
|
};
|
|
498
|
-
await remember();
|
|
499
590
|
if (meta.kind === 'run' && !meta.envelope_delivered) {
|
|
500
|
-
const read =
|
|
591
|
+
const read = snapshot.envelope;
|
|
501
592
|
if (read.kind === 'error') {
|
|
502
593
|
await remember(`envelope unreadable: ${read.reason}`);
|
|
503
594
|
return { kind: 'retry' };
|
|
504
595
|
}
|
|
505
596
|
if (read.kind !== 'ok' || !isRecord(read.value) || !isRecord(read.value['run'])) {
|
|
506
|
-
return this.markFailed(dir, 'envelope.json missing or malformed');
|
|
597
|
+
return this.markFailed(entry.id, dir, 'envelope.json missing or malformed');
|
|
507
598
|
}
|
|
508
599
|
const envelope = read.value;
|
|
509
600
|
envelope.delivery = { mode: 'retried', spooled_at: meta.spooled_at };
|
|
510
601
|
if (envelope.run.gating?.mode === 'verified-consensus') {
|
|
511
602
|
const invalid = validateRunEnvelope(envelope);
|
|
512
603
|
if (invalid.length > 0)
|
|
513
|
-
return this.markFailed(dir, `queued verified-consensus envelope invalid: ${invalid[0].path}`);
|
|
604
|
+
return this.markFailed(entry.id, dir, `queued verified-consensus envelope invalid: ${invalid[0].path}`);
|
|
514
605
|
const declared = envelope.artifacts_declared.find(artifact => artifact.kind === 'report_json');
|
|
515
|
-
const
|
|
516
|
-
if (
|
|
517
|
-
await remember(`report_json unreadable: ${
|
|
606
|
+
const reportText = snapshot.artifacts.report_json;
|
|
607
|
+
if (reportText?.kind === 'error') {
|
|
608
|
+
await remember(`report_json unreadable: ${reportText.reason}`);
|
|
518
609
|
return { kind: 'retry' };
|
|
519
610
|
}
|
|
520
|
-
if (
|
|
521
|
-
return this.markFailed(dir, 'report_json exceeds declared or maximum artifact bytes');
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (
|
|
526
|
-
return this.markFailed(dir, 'report_json
|
|
611
|
+
if (reportText?.kind === 'oversized') {
|
|
612
|
+
return this.markFailed(entry.id, dir, 'report_json exceeds declared or maximum artifact bytes');
|
|
613
|
+
}
|
|
614
|
+
if (reportText?.kind === 'ok') {
|
|
615
|
+
const size = Buffer.byteLength(reportText.value, 'utf8');
|
|
616
|
+
if (size > Math.min(declared.bytes, MAX_ARTIFACT_BYTES)) {
|
|
617
|
+
return this.markFailed(entry.id, dir, 'report_json exceeds declared or maximum artifact bytes');
|
|
527
618
|
}
|
|
528
|
-
|
|
619
|
+
let report;
|
|
620
|
+
try {
|
|
621
|
+
report = JSON.parse(reportText.value);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return this.markFailed(entry.id, dir, 'report_json malformed; verified-consensus source cannot be checked');
|
|
625
|
+
}
|
|
626
|
+
if (declared.sha256 !== sha256Hex(reportText.value) || declared.bytes !== size) {
|
|
627
|
+
return this.markFailed(entry.id, dir, 'report_json digest or size differs from the queued envelope');
|
|
628
|
+
}
|
|
629
|
+
const problem = verifiedConsensusReportProblem(report, envelope, reportText.value);
|
|
529
630
|
if (problem)
|
|
530
|
-
return this.markFailed(dir, problem);
|
|
631
|
+
return this.markFailed(entry.id, dir, problem);
|
|
531
632
|
}
|
|
532
633
|
else if (!hasGuardedProducer(envelope.run.rcl_version)) {
|
|
533
|
-
return this.markFailed(dir, 'report_json unavailable; queued verified-consensus producer or envelope cannot prove original gating labels');
|
|
634
|
+
return this.markFailed(entry.id, dir, 'report_json unavailable; queued verified-consensus producer or envelope cannot prove original gating labels');
|
|
534
635
|
}
|
|
535
636
|
}
|
|
536
637
|
if (pastDeadline())
|
|
@@ -553,33 +654,32 @@ export class Outbox {
|
|
|
553
654
|
// The server holds this run id with a different report. The events
|
|
554
655
|
// queued here name that run id too, so they must not be attached to
|
|
555
656
|
// whatever the server has; the whole entry stays for inspection.
|
|
556
|
-
return this.markFailed(dir, `conflict: ${outcome.message}`);
|
|
657
|
+
return this.markFailed(entry.id, dir, `conflict: ${outcome.message}`);
|
|
557
658
|
case 'rejected':
|
|
558
|
-
return this.markFailed(dir, `HTTP ${outcome.httpStatus} ${outcome.error} ${outcome.message}`.trim());
|
|
659
|
+
return this.markFailed(entry.id, dir, `HTTP ${outcome.httpStatus} ${outcome.error} ${outcome.message}`.trim());
|
|
559
660
|
}
|
|
560
661
|
}
|
|
561
662
|
if (meta.kind === 'run') {
|
|
562
|
-
for (const kind of
|
|
663
|
+
for (const kind of Object.keys(snapshot.artifacts)) {
|
|
563
664
|
if (pastDeadline())
|
|
564
665
|
return { kind: 'deadline' };
|
|
565
666
|
const path = join(dir, ARTIFACTS_DIR, ARTIFACT_FILES[kind]);
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
667
|
+
const artifact = snapshot.artifacts[kind];
|
|
668
|
+
if (artifact.kind === 'missing')
|
|
669
|
+
continue;
|
|
670
|
+
if (artifact.kind === 'oversized') {
|
|
671
|
+
return this.markFailed(entry.id, dir, `artifact ${kind} exceeds maximum artifact bytes`);
|
|
569
672
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
continue; // delivered by a concurrent flush
|
|
573
|
-
await remember(`artifact ${kind} unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
673
|
+
if (artifact.kind === 'error') {
|
|
674
|
+
await remember(`artifact ${kind} unreadable: ${artifact.reason}`);
|
|
574
675
|
return { kind: 'retry' };
|
|
575
676
|
}
|
|
677
|
+
const bytes = artifact.value;
|
|
576
678
|
const outcome = await sink.putArtifact(entry.id, kind, bytes, request());
|
|
577
679
|
switch (outcome.kind) {
|
|
578
680
|
case 'ok': {
|
|
579
681
|
// Remove exactly the bytes that were uploaded; a re-spool meanwhile stays.
|
|
580
|
-
|
|
581
|
-
if (current === bytes)
|
|
582
|
-
await rm(path, { force: true });
|
|
682
|
+
await this.removeArtifactIfExact(entry.id, path, bytes);
|
|
583
683
|
break;
|
|
584
684
|
}
|
|
585
685
|
case 'disabled':
|
|
@@ -588,24 +688,24 @@ export class Outbox {
|
|
|
588
688
|
return { kind: 'dropped', reason: outcome.message || outcome.reason };
|
|
589
689
|
}
|
|
590
690
|
// Artifacts are capped for the org; the envelope stands.
|
|
591
|
-
await
|
|
691
|
+
await this.removeArtifactIfExact(entry.id, path, bytes);
|
|
592
692
|
break;
|
|
593
693
|
case 'unavailable':
|
|
594
694
|
await remember(outcome.reason);
|
|
595
695
|
return { kind: 'unavailable' };
|
|
596
696
|
case 'conflict':
|
|
597
697
|
case 'rejected':
|
|
598
|
-
return this.markFailed(dir, `artifact ${kind}: ${outcome.kind === 'conflict' ? outcome.message : `HTTP ${outcome.httpStatus} ${outcome.error}`}`);
|
|
698
|
+
return this.markFailed(entry.id, dir, `artifact ${kind}: ${outcome.kind === 'conflict' ? outcome.message : `HTTP ${outcome.httpStatus} ${outcome.error}`}`);
|
|
599
699
|
}
|
|
600
700
|
}
|
|
601
701
|
}
|
|
602
|
-
const events =
|
|
702
|
+
const events = snapshot.events;
|
|
603
703
|
if (events.kind === 'error') {
|
|
604
704
|
await remember(`events unreadable: ${events.reason}`);
|
|
605
705
|
return { kind: 'retry' };
|
|
606
706
|
}
|
|
607
707
|
if (events.kind === 'malformed' || (events.kind === 'ok' && !validEvents(events.value))) {
|
|
608
|
-
return this.markFailed(dir, 'events.json malformed');
|
|
708
|
+
return this.markFailed(entry.id, dir, 'events.json malformed');
|
|
609
709
|
}
|
|
610
710
|
if (events.kind === 'missing' && meta.kind === 'events') {
|
|
611
711
|
// events.json is written before meta.json, so a missing file means an
|
|
@@ -614,26 +714,77 @@ export class Outbox {
|
|
|
614
714
|
return (await this.finish(dir, meta)) ? { kind: 'delivered' } : { kind: 'retry' };
|
|
615
715
|
}
|
|
616
716
|
if (events.kind === 'ok' && validEvents(events.value) && events.value.length > 0) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
717
|
+
const failuresRead = snapshot.failures;
|
|
718
|
+
if (failuresRead.kind === 'error') {
|
|
719
|
+
await remember(`failed events unreadable: ${failuresRead.reason}`);
|
|
720
|
+
return { kind: 'retry' };
|
|
721
|
+
}
|
|
722
|
+
if (failuresRead.kind === 'malformed' || (failuresRead.kind === 'ok' && !validEventFailures(failuresRead.value))) {
|
|
723
|
+
return this.markFailed(entry.id, dir, 'failed-events.json malformed');
|
|
724
|
+
}
|
|
725
|
+
const eventFailures = failuresRead.kind === 'ok' && validEventFailures(failuresRead.value)
|
|
726
|
+
? failuresRead.value : {};
|
|
727
|
+
const batches = options.eventBoundary ? events.value.map(event => [event]) : [events.value];
|
|
728
|
+
let retainedBoundaryError;
|
|
729
|
+
let retainedEventFailure = Object.values(eventFailures)[0];
|
|
730
|
+
let disabledReason;
|
|
731
|
+
for (const batch of batches) {
|
|
732
|
+
if (options.eventBoundary && eventFailures[batch[0].id] !== undefined)
|
|
733
|
+
continue;
|
|
734
|
+
if (pastDeadline())
|
|
735
|
+
return { kind: 'deadline' };
|
|
736
|
+
let outcome;
|
|
737
|
+
try {
|
|
738
|
+
const work = () => sink.postEvents(batch, request());
|
|
739
|
+
outcome = options.eventBoundary
|
|
740
|
+
? await options.eventBoundary(batch[0], entry, work)
|
|
741
|
+
: await work();
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
if (options.entryRefusalReason?.(error) === undefined)
|
|
745
|
+
throw error;
|
|
746
|
+
retainedBoundaryError ??= error;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
switch (outcome.kind) {
|
|
750
|
+
case 'ok':
|
|
751
|
+
await this.dropDeliveredEvents(entry.id, join(dir, EVENTS_FILE), batch);
|
|
752
|
+
break;
|
|
753
|
+
case 'unavailable':
|
|
754
|
+
await remember(outcome.reason);
|
|
755
|
+
return { kind: 'unavailable' };
|
|
756
|
+
case 'disabled':
|
|
757
|
+
if (!options.eventBoundary) {
|
|
758
|
+
await this.drop(dir);
|
|
759
|
+
return { kind: 'dropped', reason: outcome.message || outcome.reason };
|
|
760
|
+
}
|
|
761
|
+
// A mixed entry may also hold an armed run's event. Remove only
|
|
762
|
+
// the independently classified row; never drop protected peers.
|
|
763
|
+
await this.dropDeliveredEvents(entry.id, join(dir, EVENTS_FILE), batch);
|
|
764
|
+
disabledReason ??= outcome.message || outcome.reason;
|
|
765
|
+
break;
|
|
766
|
+
case 'conflict':
|
|
767
|
+
case 'rejected': {
|
|
768
|
+
const reason = `events: ${outcome.kind === 'conflict' ? outcome.message : `HTTP ${outcome.httpStatus} ${outcome.error} ${outcome.message}`.trim()}`;
|
|
769
|
+
if (!options.eventBoundary)
|
|
770
|
+
return this.markFailed(entry.id, dir, reason);
|
|
771
|
+
eventFailures[batch[0].id] = reason;
|
|
772
|
+
await this.recordFailedEvent(entry.id, dir, batch[0].id, reason);
|
|
773
|
+
retainedEventFailure ??= reason;
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (retainedBoundaryError !== undefined)
|
|
779
|
+
throw retainedBoundaryError;
|
|
780
|
+
if (retainedEventFailure !== undefined)
|
|
781
|
+
return { kind: 'retained-failed', reason: retainedEventFailure };
|
|
782
|
+
if (disabledReason !== undefined) {
|
|
783
|
+
return (await this.finish(dir, meta)) ? { kind: 'dropped', reason: disabledReason } : { kind: 'retry' };
|
|
633
784
|
}
|
|
634
785
|
}
|
|
635
786
|
if (events.kind === 'ok' && Array.isArray(events.value) && events.value.length === 0) {
|
|
636
|
-
await dropDeliveredEvents(join(dir, EVENTS_FILE),
|
|
787
|
+
await this.dropDeliveredEvents(entry.id, join(dir, EVENTS_FILE), []);
|
|
637
788
|
}
|
|
638
789
|
// Whatever arrived while flushing stays for the next flush; the entry
|
|
639
790
|
// is only "delivered" once nothing of it remains.
|
|
@@ -644,9 +795,61 @@ export class Outbox {
|
|
|
644
795
|
* files, then the directory — which stays, listed, if another process
|
|
645
796
|
* put something new into it meanwhile. Returns whether the directory went.
|
|
646
797
|
*/
|
|
798
|
+
async dropDeliveredEvents(id, path, delivered) {
|
|
799
|
+
await this.withEntryMutation(id, async () => {
|
|
800
|
+
await dropDeliveredEvents(path, delivered);
|
|
801
|
+
const failedPath = join(this.entryDir(id), FAILED_EVENTS_FILE);
|
|
802
|
+
const current = await readJson(failedPath);
|
|
803
|
+
if (current.kind !== 'ok' || !validEventFailures(current.value))
|
|
804
|
+
return;
|
|
805
|
+
const deliveredIds = new Set(delivered.map(event => event.id));
|
|
806
|
+
const remaining = Object.fromEntries(Object.entries(current.value).filter(([eventId]) => !deliveredIds.has(eventId)));
|
|
807
|
+
if (Object.keys(remaining).length === 0)
|
|
808
|
+
await rm(failedPath, { force: true });
|
|
809
|
+
else
|
|
810
|
+
await writeJsonAtomic(failedPath, remaining);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
async recordFailedEvent(id, dir, eventId, reason) {
|
|
814
|
+
await this.withEntryMutation(id, async () => {
|
|
815
|
+
const path = join(dir, FAILED_EVENTS_FILE);
|
|
816
|
+
const current = await readJson(path);
|
|
817
|
+
if (current.kind === 'error' || current.kind === 'malformed' ||
|
|
818
|
+
(current.kind === 'ok' && !validEventFailures(current.value))) {
|
|
819
|
+
throw new OutboxError('failed-events.json malformed');
|
|
820
|
+
}
|
|
821
|
+
const failures = current.kind === 'ok' && validEventFailures(current.value)
|
|
822
|
+
? current.value : {};
|
|
823
|
+
await writeJsonAtomic(path, { ...failures, [eventId]: reason });
|
|
824
|
+
});
|
|
825
|
+
}
|
|
647
826
|
async finish(dir, meta) {
|
|
827
|
+
return this.withEntryMutation(basename(dir), () => this.finishLocked(dir, meta));
|
|
828
|
+
}
|
|
829
|
+
async finishLocked(dir, meta) {
|
|
830
|
+
const events = await readJson(join(dir, EVENTS_FILE));
|
|
831
|
+
const failedEvents = await readJson(join(dir, FAILED_EVENTS_FILE));
|
|
832
|
+
const metaRead = await readJson(join(dir, META_FILE));
|
|
833
|
+
if (metaRead.kind === 'missing')
|
|
834
|
+
throw new EntryGone();
|
|
835
|
+
if (metaRead.kind !== 'ok' || !isRecord(metaRead.value))
|
|
836
|
+
throw new OutboxError('meta.json malformed');
|
|
837
|
+
const retainedMeta = this.mergeFlushMeta(metaRead.value, { ...meta, envelope_delivered: true });
|
|
838
|
+
const pendingEvents = events.kind === 'ok' ? !validEvents(events.value) || events.value.length > 0 : events.kind !== 'missing';
|
|
839
|
+
const pendingFailures = failedEvents.kind === 'ok'
|
|
840
|
+
? !validEventFailures(failedEvents.value) || Object.keys(failedEvents.value).length > 0
|
|
841
|
+
: failedEvents.kind !== 'missing';
|
|
842
|
+
if (pendingEvents || pendingFailures) {
|
|
843
|
+
await writeJsonAtomic(join(dir, META_FILE), retainedMeta);
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
if (events.kind === 'ok')
|
|
847
|
+
await rm(join(dir, EVENTS_FILE), { force: true });
|
|
848
|
+
if (failedEvents.kind === 'ok')
|
|
849
|
+
await rm(join(dir, FAILED_EVENTS_FILE), { force: true });
|
|
648
850
|
await rm(join(dir, ENVELOPE_FILE), { force: true });
|
|
649
851
|
await rm(join(dir, FAILED_MARKER), { force: true });
|
|
852
|
+
await rm(join(dir, FAILED_EVENTS_FILE), { force: true });
|
|
650
853
|
await this.sweepTempFiles(dir);
|
|
651
854
|
await rmdir(join(dir, ARTIFACTS_DIR)).catch(() => undefined);
|
|
652
855
|
await rm(join(dir, META_FILE), { force: true });
|
|
@@ -660,7 +863,7 @@ export class Outbox {
|
|
|
660
863
|
if (err.code !== 'ENOTEMPTY')
|
|
661
864
|
throw err;
|
|
662
865
|
// Something arrived while flushing: keep the entry visible for the next flush.
|
|
663
|
-
await writeJsonAtomic(join(dir, META_FILE),
|
|
866
|
+
await writeJsonAtomic(join(dir, META_FILE), retainedMeta).catch(() => undefined);
|
|
664
867
|
return false;
|
|
665
868
|
}
|
|
666
869
|
}
|
|
@@ -678,17 +881,22 @@ export class Outbox {
|
|
|
678
881
|
}
|
|
679
882
|
/** The organization switched evidence off: nothing in this entry will ever be wanted. */
|
|
680
883
|
async drop(dir) {
|
|
884
|
+
await this.withEntryMutation(basename(dir), () => this.dropLocked(dir));
|
|
885
|
+
}
|
|
886
|
+
async dropLocked(dir) {
|
|
681
887
|
await rm(dir, { recursive: true, force: true });
|
|
682
888
|
}
|
|
683
|
-
async markFailed(dir, reason) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
889
|
+
async markFailed(id, dir, reason) {
|
|
890
|
+
await this.withEntryMutation(id, async () => {
|
|
891
|
+
try {
|
|
892
|
+
await writeJsonAtomic(join(dir, FAILED_MARKER), { at: new Date().toISOString(), reason });
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
if (isEnoent(err))
|
|
896
|
+
throw new EntryGone();
|
|
897
|
+
throw err;
|
|
898
|
+
}
|
|
899
|
+
});
|
|
692
900
|
return { kind: 'failed', reason };
|
|
693
901
|
}
|
|
694
902
|
/**
|
|
@@ -700,14 +908,27 @@ export class Outbox {
|
|
|
700
908
|
* never retried, never counted. An unreachable server or an expired
|
|
701
909
|
* deadline ends the pass with the rest still pending.
|
|
702
910
|
*/
|
|
703
|
-
async reportLoss(sink, pastDeadline, request) {
|
|
911
|
+
async reportLoss(sink, pastDeadline, request, options) {
|
|
704
912
|
const files = await this.pendingLossFiles();
|
|
705
913
|
let reported = 0;
|
|
706
|
-
|
|
914
|
+
const batches = options.eventBoundary ? files.map(file => [file]) :
|
|
915
|
+
Array.from({ length: Math.ceil(files.length / LOSS_BATCH) }, (_, index) => files.slice(index * LOSS_BATCH, (index + 1) * LOSS_BATCH));
|
|
916
|
+
for (const batch of batches) {
|
|
707
917
|
if (pastDeadline())
|
|
708
918
|
return { kind: 'deadline' };
|
|
709
|
-
|
|
710
|
-
|
|
919
|
+
let outcome;
|
|
920
|
+
try {
|
|
921
|
+
const work = () => sink.postEvents(batch.map(f => f.event), request());
|
|
922
|
+
outcome = options.eventBoundary
|
|
923
|
+
? await options.eventBoundary(batch[0].event, undefined, work)
|
|
924
|
+
: await work();
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
if (options.entryRefusalReason?.(error) === undefined)
|
|
928
|
+
throw error;
|
|
929
|
+
// This loss row remains in place, while independent rows continue.
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
711
932
|
switch (outcome.kind) {
|
|
712
933
|
case 'ok':
|
|
713
934
|
// The sink only reads `ok` when the receipt accounts for every event
|
|
@@ -735,7 +956,7 @@ export class Outbox {
|
|
|
735
956
|
}
|
|
736
957
|
/** Remove one entry by id (validated like every other id). */
|
|
737
958
|
async remove(id) {
|
|
738
|
-
await rm(this.entryDir(id), { recursive: true, force: true });
|
|
959
|
+
await this.withEntryMutation(id, () => rm(this.entryDir(id), { recursive: true, force: true }));
|
|
739
960
|
}
|
|
740
961
|
}
|
|
741
962
|
//# sourceMappingURL=outbox.js.map
|