@startsimpli/integrations 0.2.16 → 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.
- package/package.json +1 -1
- package/src/__tests__/corpus-llm-timeout.test.ts +91 -0
- package/src/__tests__/corpus-runs.test.ts +631 -0
- package/src/__tests__/corpus-tracker.test.tsx +254 -1
- package/src/components/pages/TrackerPage.tsx +326 -2
- package/src/corpus/client.ts +57 -1
- package/src/corpus/entities.ts +79 -0
- package/src/corpus/extract.ts +85 -8
- package/src/corpus/fixtures.ts +34 -0
- package/src/corpus/index.ts +29 -0
- package/src/corpus/llm.ts +32 -3
- package/src/corpus/runs.ts +563 -0
- package/src/corpus/types.ts +176 -2
|
@@ -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 };
|