@startsimpli/integrations 0.2.14 → 0.2.17

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.
@@ -0,0 +1,563 @@
1
+ /**
2
+ * EXTRACTION AS A JOB (bd startsim-768w.38.28).
3
+ *
4
+ * THE MEASUREMENT THAT FORCED THIS. On the live tandem tenant `POST
5
+ * /corpus/extract` returned 504 at every slice size, `limit=1` included. One
6
+ * document takes about twenty seconds against claude-sonnet-5, so seventeen
7
+ * documents is roughly six minutes — and there are two independent sixty-second
8
+ * walls in front of it: the tenant gateway's, and the provider client's own.
9
+ * There is no slice size that fits a browser request. A synchronous button
10
+ * cannot do this job, and no amount of tuning makes it able to.
11
+ *
12
+ * ────────────────────────────────────────────────────────────────────────────
13
+ * WHY THE QUEUE IS HERE AND NOT IN THE CENTRAL BACKEND
14
+ * ────────────────────────────────────────────────────────────────────────────
15
+ *
16
+ * The obvious answer is "run it in `start-simpli-api`, where Celery, a broker
17
+ * and the connector framework already live", by analogy with the corpus bridge:
18
+ * projection happens at DELIVERY time in central, which is what lets a mapping
19
+ * fix re-derive without re-fetching from the vendor. That analogy is right about
20
+ * the SHAPE — this work is asynchronous, resumable and schedulable — and wrong
21
+ * about the LOCATION, for three reasons that are facts rather than preferences.
22
+ *
23
+ * 1. DELIVERY PROJECTION AND EXTRACTION ARE NOT THE SAME KIND OF STEP. Central
24
+ * projection is a pure, cheap, deterministic function of a vendor payload
25
+ * central already holds. Extraction is a twenty-second-per-document
26
+ * frontier-model call whose INPUT is the tenant's corpus — including
27
+ * documents that never came through a central connector, such as the drafts
28
+ * Ask writes back — and whose OUTPUT is tenant records.
29
+ * 2. IT NEEDS A WRITE CREDENTIAL INSIDE THE TENANT. The run reads the tenant's
30
+ * existing commitments to build ALREADY_FOUND (the structural half of
31
+ * idempotency), reads the tenant schema registry, and upserts entities.
32
+ * Running it centrally means giving central a credential that can write
33
+ * into every foundry's workspace. That is a security expansion across the
34
+ * whole platform to solve a scheduling problem.
35
+ * 3. THE EXTRACTOR IS THIS PACKAGE. Rules pass, prompts, grounding,
36
+ * verification, identity and reconciliation are TypeScript here, with the
37
+ * measured numbers in their comments and a test suite around them. Central
38
+ * is Python. "Run it centrally" is either a rewrite of all of that in
39
+ * another language or a new Node service — both far larger than the problem.
40
+ *
41
+ * The rejected alternatives, and why:
42
+ *
43
+ * • A WORKER AND BROKER IN `tenant-starter`. Generic, but it puts an AI
44
+ * dependency in the boot path of every foundry, including the ones with no
45
+ * AI surface at all — the same objection that kept pgvector out of it.
46
+ * • CLIENT-DRIVEN CHUNKING. Cheapest, and it makes the browser the scheduler.
47
+ * A tab that closes stops the sweep; a tab that reopens cannot see it. The
48
+ * browser is a bad system of record.
49
+ *
50
+ * ────────────────────────────────────────────────────────────────────────────
51
+ * WHAT THIS IS INSTEAD
52
+ * ────────────────────────────────────────────────────────────────────────────
53
+ *
54
+ * A DURABLE, LEASED, RESUMABLE RUN RECORD kept in the tenant's own generic
55
+ * entity store, with an idempotent STEP as the unit of work. Nothing is added to
56
+ * `tenant-starter`: a run is one more `Entity` row, which is exactly what that
57
+ * store is for.
58
+ *
59
+ * The important consequence is that the DRIVER is swappable and the durability
60
+ * is not. Today the tenant app server drives it in-process, past the gateway
61
+ * that killed the old design. Tomorrow a Celery beat in central can drive the
62
+ * same step over HTTP for unattended sweeps
63
+ * (feedback_scheduled_work_celery), and nothing here changes. That is the part
64
+ * of the central argument worth keeping, kept without the rewrite.
65
+ *
66
+ * The four properties it owes, and the mechanism for each:
67
+ *
68
+ * RESUMPTION `cursor` is durable. A sweep interrupted at nine of
69
+ * seventeen restarts at ten. Partial progress is progress
70
+ * because the commitments from those nine are already written.
71
+ * IDEMPOTENCE Every step is a no-op when repeated: identity resolves on
72
+ * `(subject, action)` stems carried as `external_id`, mentions
73
+ * key on `(documentId, originKey)`, and the tenant upsert is
74
+ * keyed on `(entity_type, external_id)`. A retry converges.
75
+ * LEGIBILITY A model that did not answer is `lastModelFailure` and
76
+ * `totals.modelFailures`, never `fromModel: 0` pretending to
77
+ * be a finding.
78
+ * ONE DRIVER A lease. Not an optimisation: two drivers over one cursor
79
+ * means two model calls over one document, and two model calls
80
+ * over one document produce two differently-worded items that
81
+ * identity resolution cannot see are the same. That is exactly
82
+ * how a live re-run once turned 82 commitments into 98.
83
+ */
84
+ import { extractCommitments } from './extract';
85
+ import type { ExtractCommitmentsOptions, ExtractDeps } from './extract';
86
+ import type {
87
+ CorpusScope,
88
+ ExtractionModelFailure,
89
+ ExtractionResult,
90
+ ExtractionRun,
91
+ ExtractionRunStore,
92
+ ExtractionRunSummary,
93
+ ExtractionRunTotals,
94
+ } from './types';
95
+
96
+ export interface ExtractionRunDeps extends ExtractDeps {
97
+ runs: ExtractionRunStore;
98
+ }
99
+
100
+ export interface StartExtractionRunRequest {
101
+ scope: CorpusScope;
102
+ /** Ignore a resumable sweep and begin again from the first document. */
103
+ restart?: boolean;
104
+ /**
105
+ * Who is claiming the lease. Defaults to a value unique to this process, which
106
+ * is what makes "somebody else took it" detectable at all.
107
+ */
108
+ driverId?: string;
109
+ }
110
+
111
+ export interface ExtractionRunOptions extends ExtractCommitmentsOptions {
112
+ /**
113
+ * Documents one step may ask for. The TEXT budget cuts it again — see
114
+ * `ExtractCommitmentsOptions.maxCharsPerRun` — so this is a ceiling, not a
115
+ * promise, and `nextOffset` is what is authoritative about what was done.
116
+ */
117
+ documentsPerSlice?: number;
118
+ /** How long a claim is good for. Renewed after every step. */
119
+ leaseMs?: number;
120
+ /** Wall-clock ceiling on one driver's stint. It stops; the run stays resumable. */
121
+ maxRunMs?: number;
122
+ /** Attempts on a step that THREW before the run is called failed. */
123
+ maxSliceAttempts?: number;
124
+ retryDelayMs?: number;
125
+ /** Injectable, so the lease and the timestamps are testable. */
126
+ now?: () => number;
127
+ driverId?: string;
128
+ /** Sweeps kept inline on the record. */
129
+ historyLimit?: number;
130
+ /** Ids kept on the record, so it never grows without bound. */
131
+ idLimit?: number;
132
+ }
133
+
134
+ const RUN_DEFAULTS = {
135
+ documentsPerSlice: 2,
136
+ // Comfortably longer than one step. A step is one model call of ~20s plus a
137
+ // verification call, so a minute is not enough and an hour would make a dead
138
+ // driver block the run until somebody noticed.
139
+ leaseMs: 5 * 60_000,
140
+ maxRunMs: 30 * 60_000,
141
+ maxSliceAttempts: 3,
142
+ retryDelayMs: 2_000,
143
+ historyLimit: 5,
144
+ idLimit: 1_000,
145
+ };
146
+
147
+ export const EMPTY_TOTALS: ExtractionRunTotals = {
148
+ documentsProcessed: 0,
149
+ fromRules: 0,
150
+ fromModel: 0,
151
+ ungrounded: 0,
152
+ unconfirmed: 0,
153
+ created: 0,
154
+ merged: 0,
155
+ resolved: 0,
156
+ droppedOutOfScope: 0,
157
+ modelCalls: 0,
158
+ modelFailures: 0,
159
+ };
160
+
161
+ /** States a driver stops at. */
162
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
163
+
164
+ /**
165
+ * States a NEW sweep is needed for.
166
+ *
167
+ * `failed` is deliberately NOT one of them, and that is the difference between
168
+ * "resumable" as a word and as a property. A sweep that stopped at meeting nine
169
+ * because the workspace 502'd, or because the session that started it expired
170
+ * — thirty minutes is the token's life and a full sweep is about ten — has nine
171
+ * meetings' worth of commitments already written and a cursor pointing at the
172
+ * tenth. Starting that over is the exact waste this design exists to avoid.
173
+ * Only a sweep that finished, or one somebody cancelled, means "begin again".
174
+ */
175
+ const COMPLETE = new Set(['succeeded', 'cancelled']);
176
+
177
+ /**
178
+ * A failure of AUTHORITY, not of the work.
179
+ *
180
+ * Retried it stays a 401, and three attempts two seconds apart just spends six
181
+ * seconds proving that. It is also not a reason to call the sweep dead: the
182
+ * cursor is good, the rows are written, and the very next request carries a
183
+ * fresh bearer. So it stops the stint and leaves the run adoptable.
184
+ */
185
+ function isAuthFailure(err: unknown): boolean {
186
+ const status = (err as { status?: number })?.status;
187
+ return status === 401 || status === 403;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // reading a run
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export function isLeaseLive(run: ExtractionRun, now: number): boolean {
195
+ if (!run.leaseUntil) return false;
196
+ const until = Date.parse(run.leaseUntil);
197
+ return !Number.isNaN(until) && until > now;
198
+ }
199
+
200
+ /**
201
+ * A run that says it is running but whose driver has gone away.
202
+ *
203
+ * The honest name for what happens when a container is replaced mid-sweep. It is
204
+ * reported rather than hidden, because "this stopped and can be resumed" is
205
+ * actionable and a spinner that never ends is not.
206
+ */
207
+ export function isStalled(run: ExtractionRun, now: number): boolean {
208
+ // A run that FAILED is stopped, not stalled — it reported why, and the screen
209
+ // says that rather than offering a mystery. `succeeded` and `cancelled` are
210
+ // simply over.
211
+ if (TERMINAL.has(run.status)) return false;
212
+ return !isLeaseLive(run, now);
213
+ }
214
+
215
+ export function runProgress(run: ExtractionRun): { done: number; total: number; percent: number } {
216
+ const total = Math.max(run.totalDocuments, run.cursor);
217
+ const done = Math.min(run.cursor, total || run.cursor);
218
+ return {
219
+ done,
220
+ total,
221
+ percent: total > 0 ? Math.round((done / total) * 100) : 0,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Did this sweep read everything the corpus holds?
227
+ *
228
+ * The cursor is an offset into a list ordered newest-first, so a document that
229
+ * arrives mid-sweep shifts it and one document can be stepped over. Recording
230
+ * ids makes that DETECTABLE — a complete-looking sweep that read fewer documents
231
+ * than the corpus holds says so, and the next sweep, which starts from the top
232
+ * and is idempotent, picks the straggler up.
233
+ */
234
+ export function runCoverage(run: ExtractionRun): { read: number; inCorpus: number; missed: number } {
235
+ const read = run.processedIds.length;
236
+ const inCorpus = run.totalDocuments;
237
+ return { read, inCorpus, missed: Math.max(0, inCorpus - read) };
238
+ }
239
+
240
+ export function newExtractionRun(input: {
241
+ scope?: CorpusScope;
242
+ totalDocuments?: number;
243
+ now?: number;
244
+ runId?: string;
245
+ }): ExtractionRun {
246
+ const at = new Date(input.now ?? Date.now()).toISOString();
247
+ return {
248
+ runId: input.runId ?? `sweep-${at.replace(/[^0-9]/g, '').slice(0, 14)}-${randomSuffix()}`,
249
+ scope: input.scope ?? { orgId: '' },
250
+ status: 'queued',
251
+ cursor: 0,
252
+ totalDocuments: input.totalDocuments ?? 0,
253
+ totals: { ...EMPTY_TOTALS },
254
+ processedIds: [],
255
+ modelIncompleteIds: [],
256
+ needsReview: [],
257
+ provider: { provider: 'none', model: '', live: false },
258
+ startedAt: at,
259
+ updatedAt: at,
260
+ finishedAt: null,
261
+ leaseOwner: null,
262
+ leaseUntil: null,
263
+ error: null,
264
+ lastModelFailure: null,
265
+ history: [],
266
+ };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // starting one
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Start a sweep, or take over one that stopped, and RETURN WITHOUT DOING THE
275
+ * WORK.
276
+ *
277
+ * That last part is the whole contract. This is what a browser request calls, so
278
+ * it must not contain a model call — the moment it does, it is the design that
279
+ * 504s.
280
+ *
281
+ * The three cases it decides between:
282
+ *
283
+ * • a sweep is running and its lease is LIVE → hand back that sweep. Pressing
284
+ * the button twice must not produce two drivers.
285
+ * • a sweep is unfinished and its lease is DEAD → adopt it, from its cursor.
286
+ * This is the container-was-replaced case and it must not start over.
287
+ * • anything else → a new sweep, from the first document, with the previous
288
+ * one filed in `history`.
289
+ */
290
+ export async function startExtractionRun(
291
+ request: StartExtractionRunRequest,
292
+ deps: ExtractionRunDeps,
293
+ options: ExtractionRunOptions = {},
294
+ ): Promise<ExtractionRun> {
295
+ // The same refusal `extractCommitments` makes, made before anything is written
296
+ // rather than after. An empty scope grants nothing.
297
+ if (!request.scope?.orgId) {
298
+ throw new Error('This session has no workspace, so there is nothing to extract.');
299
+ }
300
+
301
+ const config = { ...RUN_DEFAULTS, ...options };
302
+ const now = options.now ?? Date.now;
303
+ const at = now();
304
+ const driver = request.driverId ?? options.driverId ?? defaultDriverId();
305
+
306
+ const existing = await deps.runs.readRun().catch(() => null);
307
+
308
+ if (existing && !COMPLETE.has(existing.status) && !request.restart) {
309
+ if (isLeaseLive(existing, at) && existing.leaseOwner !== driver) {
310
+ // Somebody is already driving. Reporting their run is the honest answer to
311
+ // "start a run" and it is also the one that cannot duplicate.
312
+ return { ...existing, adopted: false };
313
+ }
314
+ const adopted: ExtractionRun = {
315
+ ...existing,
316
+ // The adopter proved this from its own token a moment ago; the record's
317
+ // copy is only ever a cache of it.
318
+ scope: request.scope,
319
+ status: 'running',
320
+ leaseOwner: driver,
321
+ leaseUntil: new Date(at + config.leaseMs).toISOString(),
322
+ updatedAt: new Date(at).toISOString(),
323
+ error: null,
324
+ adopted: !isLeaseLive(existing, at),
325
+ };
326
+ return { ...(await deps.runs.writeRun(adopted)), adopted: adopted.adopted };
327
+ }
328
+
329
+ // ONE CHEAP READ, so the very first progress line says "0 of 17" instead of
330
+ // "0 of 0". A count is a page of one row; it is not a model call and it does
331
+ // not put this function anywhere near the gateway's sixty seconds.
332
+ const totalDocuments = await deps.corpus
333
+ .listDocuments({ page: 1, pageSize: 1 })
334
+ .then((page) => page.count)
335
+ .catch(() => existing?.totalDocuments ?? 0);
336
+
337
+ const fresh: ExtractionRun = {
338
+ ...newExtractionRun({ scope: request.scope, totalDocuments, now: at }),
339
+ status: 'queued',
340
+ leaseOwner: driver,
341
+ leaseUntil: new Date(at + config.leaseMs).toISOString(),
342
+ history: existing
343
+ ? [summarise(existing), ...(existing.history ?? [])].slice(0, config.historyLimit)
344
+ : [],
345
+ };
346
+ return { ...(await deps.runs.writeRun(fresh)), adopted: false };
347
+ }
348
+
349
+ function summarise(run: ExtractionRun): ExtractionRunSummary {
350
+ return {
351
+ runId: run.runId,
352
+ status: run.status,
353
+ startedAt: run.startedAt,
354
+ finishedAt: run.finishedAt,
355
+ totals: run.totals,
356
+ error: run.error,
357
+ };
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // one step
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /**
365
+ * ONE SLICE: read from the cursor, extract, write what changed, move the cursor,
366
+ * persist.
367
+ *
368
+ * IT ALWAYS ADVANCES, even when the model half failed. That is deliberate. The
369
+ * rules pass DID land for those documents, re-running the same slice against the
370
+ * same prompt of the same size will hit the same deadline, and a run that
371
+ * retries a timeout forever never reaches document ten. The failure is recorded
372
+ * against the document ids instead, and the fix — a bigger deadline, or less
373
+ * text per call — is an operator's, not a loop's. A whole re-run is idempotent,
374
+ * so those documents get another chance for free.
375
+ *
376
+ * A slice that THROWS is a different thing entirely and does not advance: see
377
+ * `driveExtractionRun`.
378
+ */
379
+ export async function advanceExtractionRun(
380
+ run: ExtractionRun,
381
+ deps: ExtractionRunDeps,
382
+ options: ExtractionRunOptions = {},
383
+ ): Promise<ExtractionRun> {
384
+ const config = { ...RUN_DEFAULTS, ...options };
385
+ const now = options.now ?? Date.now;
386
+ if (!run.scope?.orgId) {
387
+ throw new Error('This run has no workspace, so there is nothing to extract.');
388
+ }
389
+
390
+ const result = await extractCommitments(
391
+ { scope: run.scope, offset: run.cursor, limit: config.documentsPerSlice },
392
+ deps,
393
+ options,
394
+ );
395
+
396
+ const at = now();
397
+ const advanced = result.nextOffset ?? run.cursor + result.documentsProcessed;
398
+ const complete = result.nextOffset === null;
399
+
400
+ const next: ExtractionRun = {
401
+ ...run,
402
+ status: complete ? 'succeeded' : 'running',
403
+ cursor: advanced,
404
+ totalDocuments: result.totalDocuments || run.totalDocuments,
405
+ totals: addSlice(run.totals, result),
406
+ processedIds: capped([...run.processedIds, ...result.documentIds], config.idLimit),
407
+ modelIncompleteIds: result.modelFailure
408
+ ? capped([...run.modelIncompleteIds, ...result.documentIds], config.idLimit)
409
+ : run.modelIncompleteIds,
410
+ needsReview: capped([...run.needsReview, ...result.needsReview], config.idLimit),
411
+ // WHAT ANSWERED beats what was configured, and a slice where nothing
412
+ // answered must not overwrite the name of the model that did.
413
+ provider: result.provider.live ? result.provider : run.provider,
414
+ updatedAt: new Date(at).toISOString(),
415
+ finishedAt: complete ? new Date(at).toISOString() : null,
416
+ // A finished run holds no lease. Leaving one behind makes the next start
417
+ // look like a race with a driver that is not there.
418
+ leaseUntil: complete ? null : new Date(at + config.leaseMs).toISOString(),
419
+ lastModelFailure: result.modelFailure ?? run.lastModelFailure,
420
+ error: null,
421
+ };
422
+
423
+ return deps.runs.writeRun(next);
424
+ }
425
+
426
+ function addSlice(totals: ExtractionRunTotals, result: ExtractionResult): ExtractionRunTotals {
427
+ return {
428
+ documentsProcessed: totals.documentsProcessed + result.documentsProcessed,
429
+ fromRules: totals.fromRules + result.fromRules,
430
+ fromModel: totals.fromModel + result.fromModel,
431
+ ungrounded: totals.ungrounded + result.ungrounded,
432
+ unconfirmed: totals.unconfirmed + result.unconfirmed,
433
+ created: totals.created + result.created,
434
+ merged: totals.merged + result.merged,
435
+ resolved: totals.resolved + result.resolved,
436
+ droppedOutOfScope: Math.max(totals.droppedOutOfScope, result.droppedOutOfScope),
437
+ // Counted per SLICE, not per document: one call covers the slice, so one
438
+ // failure costs the slice. `modelIncompleteIds` carries the documents.
439
+ modelCalls: totals.modelCalls + (result.documentsProcessed > 0 && !result.modelFailure ? 1 : 0),
440
+ modelFailures: totals.modelFailures + (result.modelFailure ? 1 : 0),
441
+ };
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // driving one to the end
446
+ // ---------------------------------------------------------------------------
447
+
448
+ /**
449
+ * Step until the corpus is done, the lease is lost, the budget runs out, or a
450
+ * step fails too many times.
451
+ *
452
+ * WHAT IT NEVER DOES IS THROW. A sweep that ends badly ends as a RECORD saying
453
+ * so — `status: 'failed'`, `error`, and a cursor still pointing at the work that
454
+ * is left. The caller of this is a fire-and-forget background task; an exception
455
+ * there goes nowhere a person will ever read it.
456
+ *
457
+ * THE LEASE IS RE-READ BETWEEN STEPS, and a driver that has lost it stops
458
+ * immediately. The tenant store has no compare-and-set, so this is a check
459
+ * rather than a guarantee — the guarantee is the layer underneath: identity is
460
+ * `(subject, action)`, mentions key on `(documentId, originKey)`, and the upsert
461
+ * is keyed on `external_id`, so even a genuine overlap converges rather than
462
+ * duplicating. The lease is what stops two drivers PAYING for the same
463
+ * documents, and it closes the one window — two model calls over one document
464
+ * wording the same item two ways — where convergence is not automatic.
465
+ */
466
+ export async function driveExtractionRun(
467
+ run: ExtractionRun,
468
+ deps: ExtractionRunDeps,
469
+ options: ExtractionRunOptions = {},
470
+ ): Promise<ExtractionRun> {
471
+ const config = { ...RUN_DEFAULTS, ...options };
472
+ const now = options.now ?? Date.now;
473
+ const driver = run.leaseOwner ?? options.driverId ?? defaultDriverId();
474
+ const deadline = now() + config.maxRunMs;
475
+
476
+ let current = run;
477
+ let attempts = 0;
478
+
479
+ while (!TERMINAL.has(current.status)) {
480
+ if (now() > deadline) {
481
+ // Not a failure: everything done is written and the cursor is honest. A
482
+ // later start adopts it.
483
+ return current;
484
+ }
485
+
486
+ const latest = await deps.runs.readRun().catch(() => null);
487
+ if (latest && latest.runId === current.runId) {
488
+ if (TERMINAL.has(latest.status)) return latest;
489
+ if (latest.leaseOwner && latest.leaseOwner !== driver && isLeaseLive(latest, now())) {
490
+ // Somebody else is driving this sweep. Two drivers over one cursor is
491
+ // the duplication mode this whole record exists to prevent.
492
+ return latest;
493
+ }
494
+ current = { ...latest, leaseOwner: driver };
495
+ } else if (latest && latest.runId !== current.runId) {
496
+ // The workspace moved on to a different sweep. This driver's is over.
497
+ return current;
498
+ }
499
+
500
+ try {
501
+ current = await advanceExtractionRun(current, deps, { ...options, driverId: driver });
502
+ attempts = 0;
503
+ } catch (err) {
504
+ if (isAuthFailure(err)) {
505
+ // Hand the run back, still running, with no lease and a stated reason.
506
+ // Whoever presents a live credential next picks it up at the cursor.
507
+ const paused: ExtractionRun = {
508
+ ...current,
509
+ status: 'running',
510
+ leaseUntil: null,
511
+ updatedAt: new Date(now()).toISOString(),
512
+ error: 'The session that started this run expired before it finished. Resuming continues from where it stopped.',
513
+ };
514
+ return deps.runs.writeRun(paused).catch(() => paused);
515
+ }
516
+ attempts += 1;
517
+ if (attempts >= config.maxSliceAttempts) {
518
+ const failed: ExtractionRun = {
519
+ ...current,
520
+ status: 'failed',
521
+ // THE CURSOR IS NOT REWOUND. Everything already extracted stays
522
+ // extracted, and a resume starts at the document that failed.
523
+ error: (err as { message?: string })?.message || 'The sweep could not continue.',
524
+ updatedAt: new Date(now()).toISOString(),
525
+ finishedAt: new Date(now()).toISOString(),
526
+ leaseUntil: null,
527
+ };
528
+ return deps.runs.writeRun(failed).catch(() => failed);
529
+ }
530
+ if (config.retryDelayMs > 0) await sleep(config.retryDelayMs);
531
+ }
532
+ }
533
+
534
+ return current;
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+
539
+ function capped<T>(rows: T[], limit: number): T[] {
540
+ return rows.length > limit ? rows.slice(rows.length - limit) : rows;
541
+ }
542
+
543
+ function sleep(ms: number): Promise<void> {
544
+ return new Promise((resolve) => setTimeout(resolve, ms));
545
+ }
546
+
547
+ function randomSuffix(): string {
548
+ return Math.random().toString(36).slice(2, 8);
549
+ }
550
+
551
+ /**
552
+ * Identifies THIS process for the life of it.
553
+ *
554
+ * One value per module instance, so "did somebody else take the lease" is a
555
+ * meaningful question between replicas and between restarts of the same one.
556
+ */
557
+ let processDriverId = '';
558
+ export function defaultDriverId(): string {
559
+ if (!processDriverId) processDriverId = `driver-${randomSuffix()}${randomSuffix()}`;
560
+ return processDriverId;
561
+ }
562
+
563
+ export type { ExtractionModelFailure };