@allocator-one/rcl 4.5.8 → 4.5.12

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.
Files changed (82) hide show
  1. package/README.md +5 -4
  2. package/dist/consensus/semantic-deduper.d.ts +2 -1
  3. package/dist/consensus/semantic-deduper.js +21 -10
  4. package/dist/consensus/semantic-deduper.js.map +1 -1
  5. package/dist/converge/attempt-budget.d.ts +11 -0
  6. package/dist/converge/attempt-budget.js +119 -61
  7. package/dist/converge/attempt-budget.js.map +1 -1
  8. package/dist/converge/cycle-remote.d.ts +6 -1
  9. package/dist/converge/cycle-remote.js +13 -3
  10. package/dist/converge/cycle-remote.js.map +1 -1
  11. package/dist/converge/delivery-reconciliation.d.ts +2 -1
  12. package/dist/converge/delivery-reconciliation.js +105 -11
  13. package/dist/converge/delivery-reconciliation.js.map +1 -1
  14. package/dist/converge/launch-guard.d.ts +3 -0
  15. package/dist/converge/launch-guard.js +53 -17
  16. package/dist/converge/launch-guard.js.map +1 -1
  17. package/dist/converge/ordinary-pending-export.d.ts +5 -0
  18. package/dist/converge/ordinary-pending-export.js +9 -1
  19. package/dist/converge/ordinary-pending-export.js.map +1 -1
  20. package/dist/converge/recovered-production.d.ts +14 -0
  21. package/dist/converge/recovered-production.js +61 -0
  22. package/dist/converge/recovered-production.js.map +1 -0
  23. package/dist/converge/recovery-launch.js +82 -35
  24. package/dist/converge/recovery-launch.js.map +1 -1
  25. package/dist/converge/run-state.d.ts +2 -0
  26. package/dist/converge/run-state.js +26 -12
  27. package/dist/converge/run-state.js.map +1 -1
  28. package/dist/converge/semantic-state.js +18 -4
  29. package/dist/converge/semantic-state.js.map +1 -1
  30. package/dist/coordination/registry-lock.d.ts +2 -0
  31. package/dist/coordination/registry-lock.js +42 -13
  32. package/dist/coordination/registry-lock.js.map +1 -1
  33. package/dist/evidence/claim-recovery/delivery.d.ts +25 -0
  34. package/dist/evidence/claim-recovery/delivery.js +100 -0
  35. package/dist/evidence/claim-recovery/delivery.js.map +1 -0
  36. package/dist/evidence/claim-recovery/public-adoption.d.ts +31 -0
  37. package/dist/evidence/claim-recovery/public-adoption.js +177 -0
  38. package/dist/evidence/claim-recovery/public-adoption.js.map +1 -0
  39. package/dist/evidence/claim-recovery/public-manifest.d.ts +114 -0
  40. package/dist/evidence/claim-recovery/public-manifest.js +11 -0
  41. package/dist/evidence/claim-recovery/public-manifest.js.map +1 -0
  42. package/dist/evidence/claim-recovery/public-model.d.ts +85 -0
  43. package/dist/evidence/claim-recovery/public-model.js +177 -0
  44. package/dist/evidence/claim-recovery/public-model.js.map +1 -0
  45. package/dist/evidence/original-run/journal.d.ts +10 -0
  46. package/dist/evidence/original-run/journal.js +55 -3
  47. package/dist/evidence/original-run/journal.js.map +1 -1
  48. package/dist/evidence/original-run/lock.js +22 -9
  49. package/dist/evidence/original-run/lock.js.map +1 -1
  50. package/dist/evidence/recover-claim.d.ts +21 -0
  51. package/dist/evidence/recover-claim.js +504 -0
  52. package/dist/evidence/recover-claim.js.map +1 -0
  53. package/dist/index.js +239 -137
  54. package/dist/index.js.map +1 -1
  55. package/dist/report/assembly.d.ts +2 -0
  56. package/dist/report/assembly.js +1 -0
  57. package/dist/report/assembly.js.map +1 -1
  58. package/dist/report/consensus-assembly.d.ts +2 -0
  59. package/dist/report/consensus-assembly.js +6 -3
  60. package/dist/report/consensus-assembly.js.map +1 -1
  61. package/dist/report/reviewer-artifact.js +3 -1
  62. package/dist/report/reviewer-artifact.js.map +1 -1
  63. package/dist/report/run-header.js +2 -0
  64. package/dist/report/run-header.js.map +1 -1
  65. package/dist/telemetry/deliver.d.ts +8 -0
  66. package/dist/telemetry/deliver.js +55 -8
  67. package/dist/telemetry/deliver.js.map +1 -1
  68. package/dist/telemetry/events.js +2 -0
  69. package/dist/telemetry/events.js.map +1 -1
  70. package/dist/telemetry/outbox.d.ts +20 -0
  71. package/dist/telemetry/outbox.js +324 -103
  72. package/dist/telemetry/outbox.js.map +1 -1
  73. package/dist/telemetry/reviewer-delivery.d.ts +95 -3
  74. package/dist/telemetry/reviewer-delivery.js +600 -12
  75. package/dist/telemetry/reviewer-delivery.js.map +1 -1
  76. package/dist/telemetry/sink.d.ts +14 -0
  77. package/dist/telemetry/sink.js +44 -2
  78. package/dist/telemetry/sink.js.map +1 -1
  79. package/dist/telemetry/terminal-reviewer-delivery.d.ts +154 -5
  80. package/dist/telemetry/terminal-reviewer-delivery.js +196 -21
  81. package/dist/telemetry/terminal-reviewer-delivery.js.map +1 -1
  82. package/package.json +1 -1
@@ -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
- /** Read no more than the declared artifact bytes, including if a file grows after stat. */
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
- const raw = Buffer.concat(chunks, size).toString('utf8');
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 (err) {
120
- if (err.code === 'ENOENT')
121
+ catch (error) {
122
+ if (isEnoent(error))
121
123
  return { kind: 'missing' };
122
- return { kind: 'error', reason: err instanceof Error ? err.message : String(err) };
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, raw, delivered) {
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
- const result = await this.flushEntry(sink, entry, pastDeadline, request).catch((err) => {
441
- if (err instanceof EntryGone)
442
- return { kind: 'delivered' };
443
- throw err;
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 loss = await this.reportLoss(sink, pastDeadline, request);
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
- if (entry.meta.kind !== 'run' && entry.meta.kind !== 'events') {
483
- return this.markFailed(dir, `meta.json: unknown entry kind ${JSON.stringify(entry.meta.kind)}`);
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
- try {
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 = await readJson(join(dir, ENVELOPE_FILE));
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 report = await readBoundedJson(join(dir, ARTIFACTS_DIR, ARTIFACT_FILES.report_json), Math.min(declared.bytes, MAX_ARTIFACT_BYTES));
516
- if (report.kind === 'error') {
517
- await remember(`report_json unreadable: ${report.reason}`);
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 (report.kind === 'oversized')
521
- return this.markFailed(dir, 'report_json exceeds declared or maximum artifact bytes');
522
- if (report.kind === 'malformed')
523
- return this.markFailed(dir, 'report_json malformed; verified-consensus source cannot be checked');
524
- if (report.kind === 'ok') {
525
- if (declared.sha256 !== sha256Hex(report.raw) || declared.bytes !== Buffer.byteLength(report.raw, 'utf8')) {
526
- return this.markFailed(dir, 'report_json digest or size differs from the queued envelope');
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
- const problem = verifiedConsensusReportProblem(report.value, envelope, report.raw);
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 entry.artifacts) {
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
- let bytes;
567
- try {
568
- bytes = await readFile(path, 'utf8');
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
- catch (err) {
571
- if (err.code === 'ENOENT')
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
- const current = await readFile(path, 'utf8').catch(() => undefined);
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 rm(path, { force: true });
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 = await readJson(join(dir, EVENTS_FILE));
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
- if (pastDeadline())
618
- return { kind: 'deadline' };
619
- const outcome = await sink.postEvents(events.value, request());
620
- switch (outcome.kind) {
621
- case 'ok':
622
- await dropDeliveredEvents(join(dir, EVENTS_FILE), events.raw, events.value);
623
- break;
624
- case 'unavailable':
625
- await remember(outcome.reason);
626
- return { kind: 'unavailable' };
627
- case 'disabled':
628
- await this.drop(dir);
629
- return { kind: 'dropped', reason: outcome.message || outcome.reason };
630
- case 'conflict':
631
- case 'rejected':
632
- return this.markFailed(dir, `events: ${outcome.kind === 'conflict' ? outcome.message : `HTTP ${outcome.httpStatus} ${outcome.error} ${outcome.message}`.trim()}`);
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), events.raw, []);
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), { ...meta, envelope_delivered: true }).catch(() => undefined);
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
- try {
685
- await writeJsonAtomic(join(dir, FAILED_MARKER), { at: new Date().toISOString(), reason });
686
- }
687
- catch (err) {
688
- if (isEnoent(err))
689
- throw new EntryGone();
690
- throw err;
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
- for (let i = 0; i < files.length; i += LOSS_BATCH) {
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
- const batch = files.slice(i, i + LOSS_BATCH);
710
- const outcome = await sink.postEvents(batch.map((f) => f.event), request());
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