@untangle-code/jobs 0.0.0-stage → 0.1.0

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 (50) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +138 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/abort.d.ts +14 -0
  6. package/dist/abort.d.ts.map +1 -0
  7. package/dist/abort.js +35 -0
  8. package/dist/abort.js.map +1 -0
  9. package/dist/bounded-queue.d.ts +200 -0
  10. package/dist/bounded-queue.d.ts.map +1 -0
  11. package/dist/bounded-queue.js +338 -0
  12. package/dist/bounded-queue.js.map +1 -0
  13. package/dist/checkpoint-store.d.ts +30 -0
  14. package/dist/checkpoint-store.d.ts.map +1 -0
  15. package/dist/checkpoint-store.js +131 -0
  16. package/dist/checkpoint-store.js.map +1 -0
  17. package/dist/events.d.ts +29 -0
  18. package/dist/events.d.ts.map +1 -0
  19. package/dist/events.js +66 -0
  20. package/dist/events.js.map +1 -0
  21. package/dist/index.d.ts +22 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +30 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/resume.d.ts +45 -0
  26. package/dist/resume.d.ts.map +1 -0
  27. package/dist/resume.js +60 -0
  28. package/dist/resume.js.map +1 -0
  29. package/dist/runner.d.ts +148 -0
  30. package/dist/runner.d.ts.map +1 -0
  31. package/dist/runner.js +686 -0
  32. package/dist/runner.js.map +1 -0
  33. package/dist/stages.d.ts +146 -0
  34. package/dist/stages.d.ts.map +1 -0
  35. package/dist/stages.js +2 -0
  36. package/dist/stages.js.map +1 -0
  37. package/dist/state-machine.d.ts +52 -0
  38. package/dist/state-machine.d.ts.map +1 -0
  39. package/dist/state-machine.js +114 -0
  40. package/dist/state-machine.js.map +1 -0
  41. package/package.json +61 -3
  42. package/src/abort.ts +37 -0
  43. package/src/bounded-queue.ts +462 -0
  44. package/src/checkpoint-store.ts +171 -0
  45. package/src/events.ts +71 -0
  46. package/src/index.ts +37 -0
  47. package/src/resume.ts +106 -0
  48. package/src/runner.ts +916 -0
  49. package/src/stages.ts +167 -0
  50. package/src/state-machine.ts +132 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Bounded FIFO queue with backpressure (architecture.md §13.1–§13.2).
3
+ *
4
+ * A full queue makes `push()` WAIT — that is the mechanism by which a slow
5
+ * downstream stage pauses the previous stage instead of letting it
6
+ * accumulate unbounded ASTs/vectors in memory. Bounds are enforced on both
7
+ * item count (`capacity`) and, optionally, estimated bytes (`maxBytes` with
8
+ * a `sizeOf` cost function), so the pipeline's memory budget is a hard
9
+ * limit, not a hope. All observed values are exposed via `stats()` for the
10
+ * job status output (spec-driven visibility, architecture.md §13.2).
11
+ *
12
+ * Die EINZIGE Ausnahme vom Byte-Budget ist ein Element, das das ganze Budget
13
+ * allein uebersteigt: fuer so ein Element kann Warten nie Platz schaffen, es
14
+ * wuerde die Queue also nicht bremsen, sondern haengen lassen. Diese Ausnahme
15
+ * ist eng gefasst (leere Queue, kontingentiert, gezaehlt, gemeldet) — sie war
16
+ * frueher an den Fuellstand geknuepft und damit bei `capacity: 1` ein
17
+ * Dauerzustand, der die Byte-Grenze mit gueltigen Werten abschaltete (WP-116).
18
+ */
19
+ import type { PortCallOptions } from "@untangle-code/domain";
20
+ import { indexIncompleteError } from "@untangle-code/observability";
21
+
22
+ import { abortedPromise, throwIfAborted } from "./abort.js";
23
+
24
+ /**
25
+ * Vorgabe fuer `maxOverBudgetAdmissions`: genau EIN Element, das das Budget
26
+ * allein sprengt, darf passieren (sonst haengt die Queue an ihm fest). Jedes
27
+ * weitere ist ein Konfigurationsfehler und wird als solcher gemeldet.
28
+ */
29
+ const DEFAULT_OVER_BUDGET_ALLOWANCE = 1;
30
+
31
+ /**
32
+ * Eine Aufnahme, die das Byte-Budget verletzt hat, weil das Element ALLEIN
33
+ * groesser als das gesamte Budget ist. Wird gezaehlt und gemeldet — eine
34
+ * Budgetverletzung darf nie stillschweigend passieren (E4).
35
+ */
36
+ export interface OverBudgetAdmissionV1 {
37
+ /** Groesse des aufgenommenen Elements in Bytes (> `maxBytes`). */
38
+ itemBytes: number;
39
+ /** Das Budget, das dieses Element allein ueberschreitet. */
40
+ maxBytes: number;
41
+ /** Die wievielte Ausnahme dieser Queue (1-basiert). */
42
+ ordinal: number;
43
+ /** Wie viele solcher Ausnahmen diese Queue insgesamt zulaesst. */
44
+ allowance: number;
45
+ }
46
+
47
+ export interface BoundedQueueOptions<T> {
48
+ /** Maximum number of buffered items; must be a safe integer >= 1. */
49
+ capacity: number;
50
+ /** Optional byte budget across all buffered items. Requires `sizeOf`. */
51
+ maxBytes?: number;
52
+ /** Estimated size of one item in bytes; required when `maxBytes` is set. */
53
+ sizeOf?: (item: T) => number;
54
+ /**
55
+ * Wie viele Elemente, die das Byte-Budget ALLEIN uebersteigen, darf diese
56
+ * Queue insgesamt aufnehmen? Vorgabe: 1.
57
+ *
58
+ * Die Ausnahme existiert nur, damit EIN einzelnes uebergrosses Element die
59
+ * Queue nicht dauerhaft blockiert (warten kann fuer so ein Element nie
60
+ * Platz schaffen). Sie ist ausdruecklich kein Dauerzustand: ist das
61
+ * Kontingent erschoepft, scheitert `push()` mit einem benannten Fehler,
62
+ * statt das Budget weiter stillschweigend zu ueberschreiten. `0` lehnt
63
+ * schon das erste uebergrosse Element ab.
64
+ */
65
+ maxOverBudgetAdmissions?: number;
66
+ /**
67
+ * Wird bei JEDER Ausnahme gerufen — der vorgesehene Weg, die
68
+ * Budgetverletzung in Log, Diagnostics oder Skip-Ledger sichtbar zu
69
+ * machen. Wirft der Rueckruf, scheitert der `push()`.
70
+ */
71
+ onOverBudgetAdmission?: (admission: OverBudgetAdmissionV1) => void;
72
+ }
73
+
74
+ /** Snapshot of one queue for status output and tests. */
75
+ export interface QueueStatsV1 {
76
+ depth: number;
77
+ capacity: number;
78
+ bytes: number;
79
+ maxBytes: number | null;
80
+ /** High-water marks since construction — prove boundedness in tests. */
81
+ maxDepthObserved: number;
82
+ maxBytesObserved: number;
83
+ /**
84
+ * Anzahl der Elemente, die trotz Budgetueberschreitung aufgenommen wurden,
85
+ * weil sie allein groesser als das gesamte Budget sind. `> 0` ist die
86
+ * Erklaerung dafuer, dass `maxBytesObserved > maxBytes` sein kann — ohne
87
+ * diesen Zaehler waere die Budgetverletzung unsichtbar (E4).
88
+ */
89
+ overBudgetAdmissions: number;
90
+ /** Wie viele solcher Ausnahmen diese Queue insgesamt zulaesst. */
91
+ overBudgetAllowance: number;
92
+ /** Groesstes je aufgenommenes Einzelelement in Bytes. */
93
+ maxItemBytesObserved: number;
94
+ }
95
+
96
+ export type QueuePullResult<T> = { done: true } | { done: false; value: T };
97
+
98
+ interface Slot<T> {
99
+ item: T;
100
+ size: number;
101
+ }
102
+
103
+ export class BoundedQueue<T> {
104
+ private readonly slots: Slot<T>[] = [];
105
+ private readonly capacity: number;
106
+ private readonly maxBytes: number | null;
107
+ private readonly sizeOf: ((item: T) => number) | null;
108
+ private readonly overBudgetAllowance: number;
109
+ private readonly onOverBudgetAdmission: ((admission: OverBudgetAdmissionV1) => void) | null;
110
+ private overBudgetAdmissions = 0;
111
+ private maxItemBytesObserved = 0;
112
+ private bytes = 0;
113
+ private closed = false;
114
+ private failure: { error: unknown } | null = null;
115
+ private wakers: Array<() => void> = [];
116
+ private maxDepthObserved = 0;
117
+ private maxBytesObserved = 0;
118
+
119
+ public constructor(options: BoundedQueueOptions<T>) {
120
+ // WP-116: `capacity` ist ueber `IndexJobRunnerOptions.queueCapacities`
121
+ // frei konfigurierbar und entscheidet mit darueber, ob die Byte-Grenze
122
+ // ueberhaupt greifen kann — deshalb wird sie hier so hart geprueft wie
123
+ // `maxBytes`. `Number.isInteger(1e21)` ist true, `isSafeInteger` nicht:
124
+ // eine Kapazitaet jenseits der sicheren Ganzzahlen ist als Grenze
125
+ // wertlos, weil Vergleiche und Zaehler darauf nicht mehr exakt sind.
126
+ if (!Number.isSafeInteger(options.capacity) || options.capacity < 1) {
127
+ throw new Error(
128
+ `BoundedQueue capacity must be a safe integer >= 1, got ${String(options.capacity)}`,
129
+ );
130
+ }
131
+ if (options.maxBytes !== undefined) {
132
+ if (options.sizeOf === undefined) {
133
+ throw new Error("BoundedQueue maxBytes requires a sizeOf cost function");
134
+ }
135
+ // Same defect class as an invalid per-item size (see `validateSize`
136
+ // below): a non-finite/negative budget disables the byte check just
137
+ // as surely as a poisoned item does, only permanently instead of
138
+ // transiently — e.g. `Math.floor(memoryBudgetBytes / 2)` silently
139
+ // produces `NaN`/`Infinity` when a caller's `memoryBudgetBytes` is
140
+ // itself malformed (runner.ts). Reject at construction, before any
141
+ // item is ever admitted.
142
+ BoundedQueue.validateSize(options.maxBytes, "maxBytes");
143
+ }
144
+ const allowance = options.maxOverBudgetAdmissions ?? DEFAULT_OVER_BUDGET_ALLOWANCE;
145
+ if (!Number.isSafeInteger(allowance) || allowance < 0) {
146
+ throw new Error(
147
+ `BoundedQueue maxOverBudgetAdmissions must be a safe integer >= 0, got ${String(
148
+ options.maxOverBudgetAdmissions,
149
+ )}`,
150
+ );
151
+ }
152
+ this.capacity = options.capacity;
153
+ this.maxBytes = options.maxBytes ?? null;
154
+ this.sizeOf = options.sizeOf ?? null;
155
+ this.overBudgetAllowance = allowance;
156
+ this.onOverBudgetAdmission = options.onOverBudgetAdmission ?? null;
157
+ }
158
+
159
+ /**
160
+ * Rejects a caller-supplied byte size (a per-item `sizeOf(item)` result or
161
+ * the constructor's `maxBytes`) that is not finite and >= 0.
162
+ *
163
+ * This is deliberately a hard rejection, never a clamp to some
164
+ * conservative substitute: the whole point of `maxBytes`/`sizeOf` is that
165
+ * the QUEUE trusts the caller's cost estimate to keep the pipeline's
166
+ * memory bound truthful (architecture.md §13.2, "a hard limit, not a
167
+ * hope"). A `NaN`/`Infinity`/negative size means that trust is already
168
+ * broken — the caller's cost model produced a value with no relationship
169
+ * to real memory use — so any number the queue might invent instead
170
+ * (0, the budget itself, the last good size, …) would just re-establish
171
+ * that same false trust one layer down and let corrupted accounting
172
+ * accumulate exactly as the adversarial repro showed (`Infinity` on an
173
+ * empty queue turns `bytes` into `NaN` on the very next `pull()`, which
174
+ * then compares `false` against every future budget check forever). A
175
+ * caller that cannot produce a valid size has a bug that must surface as
176
+ * a failed job (spec.md §25.2: write-side operations fail closed when
177
+ * consistency cannot be guaranteed), not as a silently-disabled budget.
178
+ */
179
+ private static validateSize(size: number, field: "maxBytes" | "sizeOf(item)"): void {
180
+ if (Number.isFinite(size) && size >= 0) {
181
+ return;
182
+ }
183
+ throw indexIncompleteError(
184
+ `BoundedQueue received an invalid byte size from ${field} (${String(size)}); ` +
185
+ "sizes must be finite numbers >= 0 — a NaN/Infinity/negative size would " +
186
+ "silently disable the byte budget (architecture.md §13.2)",
187
+ { retryable: false, operation: "bounded_queue_size", context: { field, size } },
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Defense-in-depth invariant on the running `bytes` counter itself, on
193
+ * top of `validateSize()` at every entry point: `bytes` is a sum of
194
+ * values that already passed `validateSize()`, so it should never go
195
+ * non-finite or negative — but "should never" is not a guarantee, and a
196
+ * counter that silently drifts into `NaN`/negative is exactly as
197
+ * dangerous as an unchecked input (it disables the same budget check the
198
+ * same way). Called after every mutation of `bytes`; poisons the queue
199
+ * and throws rather than letting `push()`/`pull()` keep computing on a
200
+ * counter that can no longer prove the budget (spec.md §25.2, fail
201
+ * closed).
202
+ */
203
+ private assertByteCounterIntact(operation: string): void {
204
+ if (Number.isFinite(this.bytes) && this.bytes >= 0) {
205
+ return;
206
+ }
207
+ const corrupted = indexIncompleteError(
208
+ `BoundedQueue's internal byte counter became invalid (${String(this.bytes)}) during ` +
209
+ `${operation}() despite validated inputs — refusing to continue accounting on a ` +
210
+ "counter that can no longer prove the byte budget (architecture.md §13.2)",
211
+ { retryable: false, operation: `bounded_queue_${operation}`, context: { bytes: this.bytes } },
212
+ );
213
+ // Poison rather than merely throw: a corrupted counter is a queue-wide
214
+ // invariant violation, not a one-off bad call — every other pending and
215
+ // future push()/pull() must see the same failure.
216
+ this.fail(corrupted);
217
+ throw corrupted;
218
+ }
219
+
220
+ private notify(): void {
221
+ const wakers = this.wakers;
222
+ this.wakers = [];
223
+ for (const wake of wakers) {
224
+ wake();
225
+ }
226
+ }
227
+
228
+ private async waitForChange(signal: AbortSignal | undefined): Promise<void> {
229
+ const wake = new Promise<void>((resolve) => this.wakers.push(resolve));
230
+ if (signal === undefined) {
231
+ await wake;
232
+ return;
233
+ }
234
+ const cleanup = new AbortController();
235
+ try {
236
+ await Promise.race([wake, abortedPromise(signal, cleanup.signal)]);
237
+ } finally {
238
+ cleanup.abort();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Entscheidet ueber eine Aufnahme, ohne etwas zu veraendern.
244
+ *
245
+ * - `"admit"` — Element passt in beide Grenzen.
246
+ * - `"wait"` — kein Platz; Warten kann Platz schaffen (Rueckstau).
247
+ * - `"over-budget"` — Element ist ALLEIN groesser als das ganze Budget:
248
+ * Warten kann fuer dieses Element nie Platz schaffen, die Ausnahme greift.
249
+ *
250
+ * WP-116: Frueher haengte der Byte-Zweig an `slots.length > 0`. Auf einer
251
+ * leeren Queue ist `bytes === 0`, also fiel „Element passt nicht ins
252
+ * Budget" mit „Element ist allein zu gross" nur zufaellig zusammen — und
253
+ * bei `capacity: 1` ist die Queue vor JEDEM Push leer, womit die Ausnahme
254
+ * zum Dauerzustand wurde und die Byte-Grenze mit voellig gueltigen Werten
255
+ * abgeschaltet war (belegt: 20 x 50 MB durch ein 100-Byte-Budget). Die
256
+ * Bedingung steht jetzt dort, wo sie hingehoert: am ELEMENT
257
+ * (`size > maxBytes`), nicht am Fuellstand.
258
+ */
259
+ private admissionVerdict(size: number): "admit" | "wait" | "over-budget" {
260
+ if (this.slots.length >= this.capacity) {
261
+ return "wait";
262
+ }
263
+ if (this.maxBytes === null) {
264
+ return "admit";
265
+ }
266
+ this.assertEmptyStateCounter();
267
+ if (this.bytes + size <= this.maxBytes) {
268
+ return "admit";
269
+ }
270
+ if (size > this.maxBytes) {
271
+ // Das Element sprengt das Budget aus eigener Kraft. Es wird nur in die
272
+ // LEERE Queue aufgenommen, damit die Verletzung nie mit anderen
273
+ // Elementen kumuliert; ist noch etwas gepuffert, schafft Warten sehr
274
+ // wohl Platz (naemlich die leere Queue).
275
+ return this.slots.length === 0 ? "over-budget" : "wait";
276
+ }
277
+ // Passt grundsaetzlich ins Budget, gerade nur nicht: klassischer
278
+ // Rueckstau — genau der Mechanismus, den §13.2 will.
279
+ return "wait";
280
+ }
281
+
282
+ /**
283
+ * Invariante der leeren Queue: ohne gepufferte Elemente MUSS `bytes` 0
284
+ * sein. Frueher hat der Byte-Zweig diese Annahme stillschweigend benutzt
285
+ * (`slots.length > 0` als Ersatz fuer `bytes === 0`); jetzt wird sie
286
+ * geprueft, damit eine kuenftige Abweichung als Fehler auffaellt und nicht
287
+ * als lautlos abgeschaltete Grenze.
288
+ */
289
+ private assertEmptyStateCounter(): void {
290
+ if (this.slots.length !== 0 || this.bytes === 0) {
291
+ return;
292
+ }
293
+ const corrupted = indexIncompleteError(
294
+ `BoundedQueue holds no items but its byte counter reads ${String(this.bytes)} — the byte ` +
295
+ "budget can no longer be proven, refusing to admit further items (architecture.md §13.2)",
296
+ {
297
+ retryable: false,
298
+ operation: "bounded_queue_empty_state",
299
+ context: { bytes: this.bytes, depth: this.slots.length },
300
+ },
301
+ );
302
+ this.fail(corrupted);
303
+ throw corrupted;
304
+ }
305
+
306
+ /**
307
+ * Bucht eine Ausnahme. Ist das Kontingent erschoepft, scheitert der Push
308
+ * mit benanntem, nicht wiederholbarem Fehler — fail closed (spec.md
309
+ * §25.2) statt einer Budgetverletzung, die niemand sieht. Absichtlich
310
+ * KEIN Warten: fuer ein Element groesser als das Budget kann Warten nie
311
+ * Platz schaffen, ein Warten waere ein Haenger statt eines Rueckstaus.
312
+ */
313
+ private admitOverBudget(size: number): void {
314
+ const maxBytes = this.maxBytes ?? 0;
315
+ if (this.overBudgetAdmissions >= this.overBudgetAllowance) {
316
+ throw indexIncompleteError(
317
+ `BoundedQueue refuses a ${String(size)}-byte item: it exceeds the queue's entire ` +
318
+ `${String(maxBytes)}-byte budget on its own, and this queue already admitted ` +
319
+ `${String(this.overBudgetAdmissions)} such item(s) (allowance ` +
320
+ `${String(this.overBudgetAllowance)}). The exception exists for a SINGLE oversized ` +
321
+ "item that would otherwise block the queue forever, not as a permanent state that " +
322
+ "turns the byte budget off (architecture.md §13.2). Raise the byte budget, reduce the " +
323
+ "item size, or skip this item and record the skip with a reason.",
324
+ {
325
+ retryable: false,
326
+ operation: "bounded_queue_push",
327
+ context: {
328
+ itemBytes: size,
329
+ maxBytes,
330
+ overBudgetAdmissions: this.overBudgetAdmissions,
331
+ allowance: this.overBudgetAllowance,
332
+ },
333
+ },
334
+ );
335
+ }
336
+ this.overBudgetAdmissions += 1;
337
+ this.onOverBudgetAdmission?.({
338
+ itemBytes: size,
339
+ maxBytes,
340
+ ordinal: this.overBudgetAdmissions,
341
+ allowance: this.overBudgetAllowance,
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Enqueues `item`, waiting while the queue is full (count or byte bound) —
347
+ * this wait IS the backpressure that pauses the previous stage. Rejects
348
+ * with `JOB_CANCELLED` when `options.signal` aborts, and with the failure
349
+ * error after `fail()`.
350
+ */
351
+ public async push(item: T, options?: PortCallOptions): Promise<void> {
352
+ const size = this.sizeOf === null ? 0 : this.sizeOf(item);
353
+ // Validate BEFORE the item ever reaches `hasRoomFor()`/`bytes` — an
354
+ // invalid size must never be accepted "for now" and dealt with later;
355
+ // by the time it is in `bytes` it has already corrupted the budget.
356
+ BoundedQueue.validateSize(size, "sizeOf(item)");
357
+ while (true) {
358
+ if (this.failure !== null) {
359
+ throw this.failure.error;
360
+ }
361
+ if (this.closed) {
362
+ throw new Error("BoundedQueue.push() called after close()");
363
+ }
364
+ throwIfAborted(options?.signal, "bounded_queue_push");
365
+ const verdict = this.admissionVerdict(size);
366
+ if (verdict === "admit") {
367
+ break;
368
+ }
369
+ if (verdict === "over-budget") {
370
+ // Bucht die Ausnahme oder wirft, wenn das Kontingent aufgebraucht
371
+ // ist. Erst NACH dieser Buchung wird das Element aufgenommen.
372
+ this.admitOverBudget(size);
373
+ break;
374
+ }
375
+ await this.waitForChange(options?.signal);
376
+ }
377
+ this.slots.push({ item, size });
378
+ this.bytes += size;
379
+ this.assertByteCounterIntact("push");
380
+ this.maxDepthObserved = Math.max(this.maxDepthObserved, this.slots.length);
381
+ this.maxBytesObserved = Math.max(this.maxBytesObserved, this.bytes);
382
+ this.maxItemBytesObserved = Math.max(this.maxItemBytesObserved, size);
383
+ this.notify();
384
+ }
385
+
386
+ /**
387
+ * Dequeues the oldest item, waiting while the queue is empty. Returns
388
+ * `{ done: true }` once the queue is closed AND drained. Rejects with
389
+ * `JOB_CANCELLED` when `options.signal` aborts, and with the failure
390
+ * error after `fail()` (buffered items are NOT delivered after a failure —
391
+ * the pipeline is broken and must not continue on stale work).
392
+ */
393
+ public async pull(options?: PortCallOptions): Promise<QueuePullResult<T>> {
394
+ while (true) {
395
+ if (this.failure !== null) {
396
+ throw this.failure.error;
397
+ }
398
+ throwIfAborted(options?.signal, "bounded_queue_pull");
399
+ const slot = this.slots.shift();
400
+ if (slot !== undefined) {
401
+ this.bytes -= slot.size;
402
+ this.assertByteCounterIntact("pull");
403
+ this.notify();
404
+ return { done: false, value: slot.item };
405
+ }
406
+ if (this.closed) {
407
+ return { done: true };
408
+ }
409
+ await this.waitForChange(options?.signal);
410
+ }
411
+ }
412
+
413
+ /** Non-blocking pull: `null` when nothing is buffered right now. */
414
+ public tryPull(): QueuePullResult<T> | null {
415
+ if (this.failure !== null) {
416
+ throw this.failure.error;
417
+ }
418
+ const slot = this.slots.shift();
419
+ if (slot !== undefined) {
420
+ this.bytes -= slot.size;
421
+ this.assertByteCounterIntact("tryPull");
422
+ this.notify();
423
+ return { done: false, value: slot.item };
424
+ }
425
+ if (this.closed) {
426
+ return { done: true };
427
+ }
428
+ return null;
429
+ }
430
+
431
+ /** No more pushes; pulls drain the remaining items, then report done. */
432
+ public close(): void {
433
+ this.closed = true;
434
+ this.notify();
435
+ }
436
+
437
+ /**
438
+ * Poisons the queue: every pending and future `push`/`pull` rejects with
439
+ * `error`. Used to unblock sibling pipeline workers when one stage fails.
440
+ */
441
+ public fail(error: unknown): void {
442
+ if (this.failure === null) {
443
+ this.failure = { error };
444
+ }
445
+ this.closed = true;
446
+ this.notify();
447
+ }
448
+
449
+ public stats(): QueueStatsV1 {
450
+ return {
451
+ depth: this.slots.length,
452
+ capacity: this.capacity,
453
+ bytes: this.bytes,
454
+ maxBytes: this.maxBytes,
455
+ maxDepthObserved: this.maxDepthObserved,
456
+ maxBytesObserved: this.maxBytesObserved,
457
+ overBudgetAdmissions: this.overBudgetAdmissions,
458
+ overBudgetAllowance: this.overBudgetAllowance,
459
+ maxItemBytesObserved: this.maxItemBytesObserved,
460
+ };
461
+ }
462
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * File-backed `CheckpointStorePort` (architecture.md §6.14, §13.3) on top of
3
+ * the injected `FileSystemPort` — atomic via the temp+rename pattern the
4
+ * port guarantees for `rename`.
5
+ *
6
+ * Error semantics (spec.md §25.2): a MISSING checkpoint is `null` — never an
7
+ * error; a CORRUPT checkpoint file is an `UntangleError` with code
8
+ * `INDEX_INCOMPLETE` — never silently treated as "not found", because that
9
+ * would silently discard confirmed work and restart from scratch.
10
+ */
11
+ import type {
12
+ FileSystemPort,
13
+ IndexCheckpointV1,
14
+ PortCallOptions,
15
+ CheckpointStorePort,
16
+ } from "@untangle-code/domain";
17
+ import { sha256Hex } from "@untangle-code/domain";
18
+ import { indexIncompleteError } from "@untangle-code/observability";
19
+
20
+ import { throwIfAborted } from "./abort.js";
21
+
22
+ function isStringArray(value: unknown): value is string[] {
23
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
24
+ }
25
+
26
+ function isStringRecord(value: unknown): value is Record<string, string> {
27
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
28
+ return false;
29
+ }
30
+ return Object.values(value).every((entry) => typeof entry === "string");
31
+ }
32
+
33
+ function isStringArrayRecord(value: unknown): value is Record<string, string[]> {
34
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
35
+ return false;
36
+ }
37
+ return Object.values(value).every((entry) => isStringArray(entry));
38
+ }
39
+
40
+ /** Structural guard for the exact `IndexCheckpointV1` shape (§13.3). */
41
+ export function isIndexCheckpointV1(value: unknown): value is IndexCheckpointV1 {
42
+ if (typeof value !== "object" || value === null) {
43
+ return false;
44
+ }
45
+ const record = value as Record<string, unknown>;
46
+ return (
47
+ typeof record["jobId"] === "string" &&
48
+ typeof record["generationId"] === "string" &&
49
+ typeof record["workspaceId"] === "string" &&
50
+ typeof record["repoId"] === "string" &&
51
+ typeof record["refId"] === "string" &&
52
+ typeof record["effectiveProfileId"] === "string" &&
53
+ isStringArray(record["completedFiles"]) &&
54
+ isStringArrayRecord(record["storedPointIdsByFile"]) &&
55
+ isStringRecord(record["manifestHashes"]) &&
56
+ typeof record["stage"] === "string" &&
57
+ typeof record["savedAt"] === "string"
58
+ );
59
+ }
60
+
61
+ export interface FileCheckpointStoreOptions {
62
+ /** Directory the checkpoint JSON files live in; created on first save. */
63
+ directory: string;
64
+ }
65
+
66
+ export class FileCheckpointStore implements CheckpointStorePort {
67
+ private readonly fs: FileSystemPort;
68
+ private readonly directory: string;
69
+ private tmpCounter = 0;
70
+
71
+ public constructor(fs: FileSystemPort, options: FileCheckpointStoreOptions) {
72
+ this.fs = fs;
73
+ this.directory = options.directory.replace(/\/+$/u, "");
74
+ }
75
+
76
+ private pathFor(jobId: string): string {
77
+ // jobIds are usually UUIDs, but the file name must stay collision-free
78
+ // and path-safe for ANY id: sanitize, then disambiguate with a hash.
79
+ const safe = jobId.replace(/[^A-Za-z0-9._-]/gu, "_");
80
+ return `${this.directory}/${safe}-${sha256Hex(jobId).slice(0, 8)}.json`;
81
+ }
82
+
83
+ public async save(checkpoint: IndexCheckpointV1, options?: PortCallOptions): Promise<void> {
84
+ throwIfAborted(options?.signal, "checkpoint_save");
85
+ if (!isIndexCheckpointV1(checkpoint)) {
86
+ throw indexIncompleteError("refusing to persist a malformed checkpoint", {
87
+ retryable: false,
88
+ operation: "checkpoint_save",
89
+ });
90
+ }
91
+ await this.fs.mkdir(this.directory, { recursive: true, ...(options ?? {}) });
92
+ const path = this.pathFor(checkpoint.jobId);
93
+ this.tmpCounter += 1;
94
+ const tmpPath = `${path}.tmp-${this.tmpCounter}`;
95
+ const bytes = new TextEncoder().encode(`${JSON.stringify(checkpoint, null, 2)}\n`);
96
+ await this.fs.writeFile(tmpPath, bytes, options);
97
+ await this.fs.rename(tmpPath, path, options);
98
+ }
99
+
100
+ public async load(jobId: string, options?: PortCallOptions): Promise<IndexCheckpointV1 | null> {
101
+ throwIfAborted(options?.signal, "checkpoint_load");
102
+ const path = this.pathFor(jobId);
103
+ const stat = await this.fs.stat(path, options);
104
+ if (stat === null) {
105
+ return null;
106
+ }
107
+ const text = await this.fs.readTextFile(path, options);
108
+ return this.parseCheckpoint(text, path);
109
+ }
110
+
111
+ public async loadLatest(
112
+ repoId: string,
113
+ refId: string,
114
+ options?: PortCallOptions,
115
+ ): Promise<IndexCheckpointV1 | null> {
116
+ throwIfAborted(options?.signal, "checkpoint_load_latest");
117
+ const dirStat = await this.fs.stat(this.directory, options);
118
+ if (dirStat === null) {
119
+ return null;
120
+ }
121
+ const entries = await this.fs.readDir(this.directory, options);
122
+ let latest: IndexCheckpointV1 | null = null;
123
+ for (const entry of entries) {
124
+ if (entry.kind !== "file" || !entry.name.endsWith(".json")) {
125
+ continue;
126
+ }
127
+ const text = await this.fs.readTextFile(entry.path, options);
128
+ const checkpoint = this.parseCheckpoint(text, entry.path);
129
+ if (checkpoint.repoId !== repoId || checkpoint.refId !== refId) {
130
+ continue;
131
+ }
132
+ if (
133
+ latest === null ||
134
+ checkpoint.savedAt > latest.savedAt ||
135
+ (checkpoint.savedAt === latest.savedAt && checkpoint.jobId > latest.jobId)
136
+ ) {
137
+ latest = checkpoint;
138
+ }
139
+ }
140
+ return latest;
141
+ }
142
+
143
+ public async delete(jobId: string, options?: PortCallOptions): Promise<void> {
144
+ throwIfAborted(options?.signal, "checkpoint_delete");
145
+ // `FileSystemPort.remove` is force-style: a missing file is not an error,
146
+ // which makes delete idempotent by design.
147
+ await this.fs.remove(this.pathFor(jobId), options);
148
+ }
149
+
150
+ private parseCheckpoint(text: string, path: string): IndexCheckpointV1 {
151
+ let parsed: unknown;
152
+ try {
153
+ parsed = JSON.parse(text);
154
+ } catch (cause) {
155
+ throw indexIncompleteError(`checkpoint file is not valid JSON: ${path}`, {
156
+ retryable: false,
157
+ operation: "checkpoint_load",
158
+ context: { path },
159
+ cause,
160
+ });
161
+ }
162
+ if (!isIndexCheckpointV1(parsed)) {
163
+ throw indexIncompleteError(`checkpoint file has an invalid shape: ${path}`, {
164
+ retryable: false,
165
+ operation: "checkpoint_load",
166
+ context: { path },
167
+ });
168
+ }
169
+ return parsed;
170
+ }
171
+ }