@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.
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import * as React from 'react';
28
28
 
29
+ import { isStalled, runCoverage, runProgress } from '../../corpus/runs';
29
30
  import { stripMarkdown } from '../Markdown';
30
31
  import type {
31
32
  Commitment,
@@ -34,6 +35,7 @@ import type {
34
35
  CommitmentTrackerApi,
35
36
  CorpusScope,
36
37
  ExtractionResult,
38
+ ExtractionRun,
37
39
  } from '../../corpus/types';
38
40
 
39
41
  export interface TrackerPageProps {
@@ -51,12 +53,29 @@ export interface TrackerPageProps {
51
53
  * supply a clock, one is frozen ONCE for the life of the component.
52
54
  */
53
55
  now?: number;
56
+ /**
57
+ * How often the queued sweep is polled while it is going.
58
+ *
59
+ * A six-minute job does not need a second-by-second poll, and the tenant data
60
+ * API pays for every one of them. Three seconds is fast enough that the bar
61
+ * moves and slow enough that watching costs nothing.
62
+ */
63
+ pollMs?: number;
64
+ /**
65
+ * How many times a stalled sweep may be picked back up without anybody
66
+ * pressing anything, per mount.
67
+ *
68
+ * Bounded so a workspace that cannot be reached ends up stopped and legible
69
+ * rather than retrying forever behind an open tab. Twenty-four stints is well
70
+ * over a day of ten-minute sweeps.
71
+ */
72
+ maxAutoResumes?: number;
54
73
  classNames?: Partial<Record<TrackerSlot, string>>;
55
74
  }
56
75
 
57
76
  type TrackerSlot =
58
77
  | 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
59
- | 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due';
78
+ | 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due' | 'progressTrack' | 'progressBar';
60
79
 
61
80
  const DEFAULTS: Record<TrackerSlot, string> = {
62
81
  root: 'space-y-6',
@@ -72,6 +91,8 @@ const DEFAULTS: Record<TrackerSlot, string> = {
72
91
  warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
73
92
  row: 'rounded-lg border border-gray-200 p-4',
74
93
  due: 'text-sm font-medium text-red-700',
94
+ progressTrack: 'h-2 w-full overflow-hidden rounded-full bg-gray-200',
95
+ progressBar: 'h-2 rounded-full bg-gray-900 transition-all',
75
96
  };
76
97
 
77
98
  const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: string }> = [
@@ -83,14 +104,20 @@ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: s
83
104
 
84
105
  const WEEK_MS = 7 * 24 * 3600 * 1000;
85
106
 
107
+ /** States the poll stops at. A finished run is a record, not a subscription. */
108
+ const TERMINAL_RUN = new Set(['succeeded', 'failed', 'cancelled']);
109
+
86
110
  export function TrackerPage({
87
111
  api,
88
112
  scope,
89
113
  onOpenDocument,
90
114
  now,
115
+ pollMs = 3_000,
116
+ maxAutoResumes = 24,
91
117
  classNames = {},
92
118
  }: TrackerPageProps) {
93
119
  const cn = { ...DEFAULTS, ...classNames };
120
+ const [sweepEpoch, setSweepEpoch] = React.useState(0);
94
121
 
95
122
  // Frozen once. See the note on the prop.
96
123
  const [mountedAt] = React.useState(() => Date.now());
@@ -113,6 +140,18 @@ export function TrackerPage({
113
140
  const [error, setError] = React.useState('');
114
141
  const [run, setRun] = React.useState<ExtractionResult | null>(null);
115
142
  const [running, setRunning] = React.useState(false);
143
+ /**
144
+ * The SWEEP, when this deployment runs extraction as a job
145
+ * (bd startsim-768w.38.28).
146
+ *
147
+ * `run` above is one synchronous slice's result and is what a deployment small
148
+ * enough to finish inside a request still gets. This is the durable record of a
149
+ * long one, polled from the server. They are different objects because they
150
+ * describe different things — a result, and a job — and collapsing them would
151
+ * put a cursor on something that has no cursor.
152
+ */
153
+ const [sweep, setSweep] = React.useState<ExtractionRun | null>(null);
154
+ const [starting, setStarting] = React.useState(false);
116
155
  /**
117
156
  * Does this workspace hold ANY commitments at all?
118
157
  *
@@ -168,6 +207,98 @@ export function TrackerPage({
168
207
  // Real input only: the query the user asked for, and the workspace they are in.
169
208
  React.useEffect(() => { void load(); }, [load, scope.orgId]);
170
209
 
210
+ /**
211
+ * WATCH THE SWEEP, and pick one up that was already going.
212
+ *
213
+ * The poll starts on mount rather than on the button, because the run outlives
214
+ * the tab that started it: somebody who closes this page and comes back has to
215
+ * see the job that is still going, not a fresh idle screen with a button that
216
+ * would start a second one. Polling STOPS the moment the run reaches a
217
+ * terminal state — a finished job is a record, not a subscription.
218
+ *
219
+ * `now` is read from the frozen clock, never from `Date.now()` in a dependency
220
+ * array: that is what turned this component into 917 requests in ten minutes
221
+ * once already.
222
+ */
223
+ const canQueue = Boolean(api.startExtraction && api.getExtractionRun);
224
+ /**
225
+ * The reader, and the last state we saw, held in REFS.
226
+ *
227
+ * Neither is an input to the poll. `load` changes whenever the tab or the
228
+ * owner filter changes, and a poll that restarts on a tab click is a poll that
229
+ * fires an extra request per click; the last-seen status is compared inside
230
+ * the tick rather than through a state updater, because a side effect inside a
231
+ * `setState` updater runs twice under StrictMode. Same discipline as the
232
+ * frozen clock above, and for the same reason: this component has already
233
+ * shipped one runaway.
234
+ */
235
+ const loadRef = React.useRef(load);
236
+ loadRef.current = load;
237
+ const clockRef = React.useRef(clock);
238
+ clockRef.current = clock;
239
+ const scopeRef = React.useRef(scope);
240
+ scopeRef.current = scope;
241
+ const lastSeen = React.useRef<{ runId: string; status: string } | null>(null);
242
+ const resumes = React.useRef(0);
243
+
244
+ React.useEffect(() => {
245
+ if (!canQueue) return undefined;
246
+ let live = true;
247
+ let timer: ReturnType<typeof setTimeout> | undefined;
248
+
249
+ const tick = async () => {
250
+ if (!live) return;
251
+ const latest = await apiRef.current.getExtractionRun!().catch(() => null);
252
+ if (!live) return;
253
+ if (latest) {
254
+ const previous = lastSeen.current;
255
+ lastSeen.current = { runId: latest.runId, status: latest.status };
256
+ setSweep(latest);
257
+ // A sweep that has just reached the end is a reason to re-read the list:
258
+ // the rows it wrote are the entire point of having watched it.
259
+ const settled = latest.status === 'succeeded' || latest.status === 'failed';
260
+ if (settled && (!previous || previous.runId !== latest.runId || previous.status !== latest.status)) {
261
+ void loadRef.current();
262
+ }
263
+ }
264
+ /**
265
+ * A STALLED RUN IS PICKED BACK UP, and this is the one place the browser
266
+ * is allowed near the job.
267
+ *
268
+ * It is NOT the scheduler: the server owns the cursor, the ordering, the
269
+ * lease and the work, and closing this tab does not lose any of it. What
270
+ * the browser uniquely has is a LIVE CREDENTIAL. A sweep is about ten
271
+ * minutes and the token that starts it lives thirty, so a run that began
272
+ * late in a session stops mid-way with its cursor intact; re-presenting a
273
+ * fresh bearer is all it needs to carry on, and asking somebody to click
274
+ * for that would be asking them to do the machine's bookkeeping.
275
+ *
276
+ * Bounded by a ref, not by state, so it can never drive a render. A run
277
+ * that FAILED is not auto-resumed — it said why it stopped and a person
278
+ * should read that before it is tried again.
279
+ */
280
+ if (
281
+ latest &&
282
+ !TERMINAL_RUN.has(latest.status) &&
283
+ isStalled(latest, clockRef.current) &&
284
+ resumes.current < maxAutoResumes
285
+ ) {
286
+ resumes.current += 1;
287
+ await apiRef.current.startExtraction!({ scope: scopeRef.current }).catch(() => undefined);
288
+ }
289
+
290
+ const busy = latest ? latest.status === 'queued' || latest.status === 'running' : false;
291
+ if (busy) timer = setTimeout(() => { void tick(); }, pollMs);
292
+ };
293
+
294
+ void tick();
295
+ return () => {
296
+ live = false;
297
+ if (timer) clearTimeout(timer);
298
+ };
299
+ // `sweepEpoch` re-arms the poll after the button starts a new one.
300
+ }, [canQueue, pollMs, maxAutoResumes, scope.orgId, sweepEpoch]);
301
+
171
302
  /**
172
303
  * The tab, applied in the browser as well as sent to the server.
173
304
  *
@@ -194,6 +325,11 @@ export function TrackerPage({
194
325
  return [...byOwner].sort(compareForScreen(clock));
195
326
  }, [rows, tab, owner, clock]);
196
327
 
328
+ const sweepBusy = Boolean(sweep && (sweep.status === 'queued' || sweep.status === 'running'));
329
+ // A run whose lease has lapsed is not running, whatever its status field says.
330
+ // Rendering it as "in progress" is a spinner that never ends.
331
+ const sweepStalled = Boolean(sweep && isStalled(sweep, clock));
332
+
197
333
  const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
198
334
  (key) => Boolean(query[key]) && !applied.includes(key),
199
335
  );
@@ -225,7 +361,41 @@ export function TrackerPage({
225
361
  onChange={(event) => setOwner(event.target.value)}
226
362
  />
227
363
  </label>
228
- {api.extract ? (
364
+ {canQueue ? (
365
+ /**
366
+ * THE BUTTON NO LONGER WAITS, because it cannot: seventeen meetings
367
+ * is about six minutes of model time and the tenant gateway closes a
368
+ * request at sixty seconds. It starts the job and the screen watches
369
+ * it (bd startsim-768w.38.28).
370
+ */
371
+ <button
372
+ type="button"
373
+ className={cn.secondaryButton}
374
+ disabled={starting || sweepBusy}
375
+ onClick={async () => {
376
+ setStarting(true);
377
+ setError('');
378
+ try {
379
+ const started = await apiRef.current.startExtraction!({ scope });
380
+ setSweep(started);
381
+ setSweepEpoch((epoch) => epoch + 1);
382
+ } catch (err) {
383
+ setError(failureMessage(err));
384
+ } finally {
385
+ setStarting(false);
386
+ }
387
+ }}
388
+ >
389
+ {sweepStalled
390
+ ? 'Resume reading your meetings'
391
+ : sweepBusy
392
+ ? 'Reading your meetings…'
393
+ : 'Pull from meetings'}
394
+ </button>
395
+ ) : api.extract ? (
396
+ // A deployment with no run store still gets the old synchronous
397
+ // call. It is the same primitive; it just has nowhere to keep a
398
+ // cursor, so it is only honest on a corpus that fits in a request.
229
399
  <button
230
400
  type="button"
231
401
  className={cn.secondaryButton}
@@ -260,6 +430,9 @@ export function TrackerPage({
260
430
  ) : null}
261
431
 
262
432
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
433
+ {sweep ? (
434
+ <SweepPanel sweep={sweep} now={clock} classNames={cn} onOpenDocument={onOpenDocument} />
435
+ ) : null}
263
436
  {run ? <RunSummary result={run} classNames={cn} onOpenDocument={onOpenDocument} /> : null}
264
437
  </section>
265
438
 
@@ -380,6 +553,157 @@ function CommitmentRow({
380
553
  );
381
554
  }
382
555
 
556
+ /**
557
+ * A SWEEP IN FLIGHT, and afterwards what it did (bd startsim-768w.38.28).
558
+ *
559
+ * The old button blocked on a call that could not finish, so the only states it
560
+ * ever had were "spinning" and "504". A six-minute job has four states a person
561
+ * actually needs, and every one of them is a sentence rather than a spinner:
562
+ *
563
+ * GOING how far, out of what, and that it survives leaving the page. A
564
+ * progress bar with no denominator is a spinner with extra steps.
565
+ * STALLED the driver went away — a container replacement, usually. Said out
566
+ * loud with a Resume, because a sweep that silently stops looking
567
+ * busy is the worst of the four.
568
+ * DONE what it wrote, AND whether the model actually answered.
569
+ * FAILED why, and what it kept. Partial progress is progress and nobody
570
+ * should start over by hand because the screen did not say so.
571
+ *
572
+ * THE MODEL LINE IS THE POINT OF THE BEAD. `fromModel: 0` used to be the entire
573
+ * report of a blown provider timeout, and it reads exactly like "the model found
574
+ * nothing to add". It is not allowed to render that way any more: if the model
575
+ * did not answer, this says so, says it timed out when it did, and names how many
576
+ * meetings got only the deterministic pass.
577
+ */
578
+ function SweepPanel({
579
+ sweep, now, classNames: cn, onOpenDocument,
580
+ }: {
581
+ sweep: ExtractionRun;
582
+ now: number;
583
+ classNames: Record<TrackerSlot, string>;
584
+ onOpenDocument?: (documentId: string) => void;
585
+ }) {
586
+ const progress = runProgress(sweep);
587
+ const coverage = runCoverage(sweep);
588
+ const stalled = isStalled(sweep, now);
589
+ const busy = (sweep.status === 'queued' || sweep.status === 'running') && !stalled;
590
+ const totals = sweep.totals;
591
+ const failure = sweep.lastModelFailure;
592
+
593
+ return (
594
+ <div className={cn.card} role="status" aria-live="polite">
595
+ {busy || stalled ? (
596
+ <>
597
+ <p className="text-sm text-gray-800">
598
+ Reading your meetings — {progress.done} of {progress.total} meetings
599
+ {totals.created > 0 ? `, ${totals.created} items so far` : ''}.
600
+ </p>
601
+ <div className={`mt-2 ${cn.progressTrack}`}>
602
+ <div className={cn.progressBar} style={{ width: `${Math.max(progress.percent, 2)}%` }} />
603
+ </div>
604
+ </>
605
+ ) : null}
606
+
607
+ {busy ? (
608
+ <p className={`mt-2 ${cn.muted}`}>
609
+ {/* The single most useful sentence on the screen: it removes the
610
+ reason somebody sits and watches, or presses the button again. */}
611
+ This keeps going if you close this page. Come back and it will still be here.
612
+ </p>
613
+ ) : null}
614
+
615
+ {stalled ? (
616
+ <p className={`mt-2 ${cn.warning}`}>
617
+ This run stopped before it finished — the process that was doing the work went
618
+ away. Everything it had already read is saved. Resume picks up at meeting{' '}
619
+ {progress.done + 1}, not at the beginning.
620
+ </p>
621
+ ) : null}
622
+
623
+ {sweep.status === 'succeeded' ? (
624
+ <p className="text-sm text-gray-800">
625
+ Read {totals.documentsProcessed} meeting{totals.documentsProcessed === 1 ? '' : 's'}:{' '}
626
+ {totals.created} new, {totals.merged} matched to something already tracked,{' '}
627
+ {totals.resolved} closed by a later meeting.
628
+ </p>
629
+ ) : null}
630
+
631
+ {sweep.status === 'failed' ? (
632
+ <p className={cn.error}>
633
+ This run stopped: {sweep.error || 'the workspace could not be reached'}. It kept
634
+ everything it had already read — {progress.done} of {progress.total} meetings,{' '}
635
+ {totals.created} items. Running it again carries on from there.
636
+ </p>
637
+ ) : null}
638
+
639
+ {/* WHAT THE MODEL DID, AND WHETHER IT ANSWERED AT ALL. */}
640
+ {sweep.status !== 'queued' && totals.documentsProcessed > 0 ? (
641
+ <p className={cn.muted}>
642
+ {totals.fromRules} came from the meetings’ own next-step lists, {totals.fromModel} were
643
+ read out of the notes by{' '}
644
+ {sweep.provider.live
645
+ ? `${sweep.provider.provider} · ${sweep.provider.model}`
646
+ : 'no live model'}
647
+ .
648
+ </p>
649
+ ) : null}
650
+
651
+ {totals.modelFailures > 0 ? (
652
+ <p className={`mt-2 ${cn.warning}`} role="status">
653
+ {/* NOT `fromModel: 0` on its own. That reading — "nothing to add" —
654
+ is what hid a week of degraded runs. */}
655
+ On {totals.modelFailures} of {totals.modelFailures + totals.modelCalls} passes the model
656
+ did not answer
657
+ {failure?.timedOut ? ' — it timed out' : failure ? ` — ${failure.message}` : ''}. Those{' '}
658
+ {sweep.modelIncompleteIds.length} meeting
659
+ {sweep.modelIncompleteIds.length === 1 ? '' : 's'} were read by the deterministic pass
660
+ only, so anything stated in the notes but not written into a next-step list is still
661
+ missing from them. Running it again gives the model another go; nothing is duplicated.
662
+ </p>
663
+ ) : null}
664
+
665
+ {sweep.status === 'succeeded' && coverage.missed > 0 ? (
666
+ <p className={`mt-2 ${cn.warning}`}>
667
+ {/* A document that arrived mid-sweep shifts the offsets under it. Said
668
+ out loud rather than reported as a complete pass. */}
669
+ {coverage.missed} meeting{coverage.missed === 1 ? '' : 's'} arrived while this was
670
+ running and were not read. Run it again to pick them up.
671
+ </p>
672
+ ) : null}
673
+
674
+ {sweep.needsReview.length ? (
675
+ <div className={`mt-2 ${cn.warning}`} role="status">
676
+ <p>
677
+ {sweep.needsReview.length} meeting statement
678
+ {sweep.needsReview.length === 1 ? '' : 's'} looked like they closed something, but not
679
+ closely enough to act on. Nothing was closed automatically.
680
+ </p>
681
+ <ul className="mt-1 space-y-1">
682
+ {sweep.needsReview.map((entry) => (
683
+ <li key={`${entry.commitmentId}:${entry.documentId}`}>
684
+ <button
685
+ type="button"
686
+ className="text-sm underline"
687
+ onClick={() => onOpenDocument?.(entry.documentId)}
688
+ >
689
+ “{stripMarkdown(entry.quote).slice(0, 120)}”
690
+ </button>
691
+ </li>
692
+ ))}
693
+ </ul>
694
+ </div>
695
+ ) : null}
696
+
697
+ {totals.droppedOutOfScope > 0 ? (
698
+ <p className={cn.error} role="status">
699
+ {totals.droppedOutOfScope} record{totals.droppedOutOfScope === 1 ? '' : 's'} from another
700
+ workspace were returned by the data API and excluded before extracting.
701
+ </p>
702
+ ) : null}
703
+ </div>
704
+ );
705
+ }
706
+
383
707
  /**
384
708
  * What the last run did, in the terms that make it arguable.
385
709
  *
@@ -27,10 +27,13 @@
27
27
  */
28
28
  import {
29
29
  DEFAULT_PARTICIPANT_EDGES,
30
+ DEFAULT_RUN_KEY,
30
31
  DEFAULT_TYPE_KEYS,
31
32
  commitmentToEntity,
33
+ extractionRunToEntity,
32
34
  indexParticipants,
33
35
  toCommitment,
36
+ toExtractionRun,
34
37
  toMoneyEvent,
35
38
  toParty,
36
39
  toSourceDocument,
@@ -44,6 +47,8 @@ import type {
44
47
  CorpusPage,
45
48
  CorpusScope,
46
49
  DocumentQuery,
50
+ ExtractionRun,
51
+ ExtractionRunStore,
47
52
  MoneyEvent,
48
53
  Party,
49
54
  PartyRef,
@@ -105,6 +110,11 @@ export interface CreateCorpusApiOptions {
105
110
  maxGraphPages?: number;
106
111
  /** How long a loaded graph is reused before it is read again. */
107
112
  graphTtlMs?: number;
113
+ /**
114
+ * The `external_id` the singleton extraction-run row lives at. One per
115
+ * workspace; see `ExtractionRunStore` for why it is a singleton.
116
+ */
117
+ runKey?: string;
108
118
  }
109
119
 
110
120
  interface DrfPage<T> {
@@ -236,7 +246,9 @@ function appliedFilters(
236
246
  return requested.filter((key) => supported.includes(key));
237
247
  }
238
248
 
239
- export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & CommitmentApi {
249
+ export function createCorpusApi(
250
+ options: CreateCorpusApiOptions,
251
+ ): CorpusApi & CommitmentApi & ExtractionRunStore {
240
252
  const {
241
253
  http,
242
254
  scope,
@@ -249,6 +261,7 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
249
261
  graphTtlMs = 30_000,
250
262
  } = options;
251
263
  const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
264
+ const runKey = options.runKey ?? DEFAULT_RUN_KEY;
252
265
  const entities = options.paths?.entities ?? 'api/v1/entities';
253
266
  const connections = options.paths?.connections ?? 'api/v1/connections';
254
267
  const relationships = options.paths?.relationships ?? 'api/v1/relationships';
@@ -427,6 +440,49 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
427
440
  }
428
441
  },
429
442
 
443
+ /**
444
+ * The sweep record, read back (bd startsim-768w.38.28).
445
+ *
446
+ * ONE ROW PER WORKSPACE, at a fixed `external_id`, because the tenant entity
447
+ * API filters on `?type=` and nothing else — there is no way to ask for "the
448
+ * newest run", so a row per sweep would mean scanning a list that grows
449
+ * forever on every poll. A singleton is one deterministic read that never
450
+ * grows, and the last few sweeps ride along inside it.
451
+ *
452
+ * FAIL-SOFT ON ABSENCE, NOT ON FAILURE. No run yet is `null`, which is a
453
+ * fact. A 5xx is thrown, because "we could not reach the workspace" and
454
+ * "nothing has ever run" are different answers and the screen renders them
455
+ * differently.
456
+ */
457
+ async readRun() {
458
+ const response = await http
459
+ .get<DrfPage<TenantEntity>>(entities, {
460
+ params: { type: types.extractionRun, page: 1, page_size: 5 },
461
+ })
462
+ .catch((err: { status?: number }) => {
463
+ // An undeclared type is a 403 from the read gate, and it means exactly
464
+ // "no sweep has ever run here" — the type is declared by the first one.
465
+ if (err?.status === 403 || err?.status === 404) return EMPTY_PAGE as DrfPage<TenantEntity>;
466
+ throw err;
467
+ });
468
+ const row = (response.results ?? []).find(
469
+ (entity) => String(entity.external_id ?? '') === runKey,
470
+ );
471
+ return row ? toExtractionRun(row) : null;
472
+ },
473
+
474
+ async writeRun(run: ExtractionRun) {
475
+ if (!http.post) {
476
+ throw new Error('This corpus client is read-only — no write method was supplied.');
477
+ }
478
+ await http.post(`${entities}/upsert`, extractionRunToEntity(run, types.extractionRun, runKey));
479
+ // The record just written is the record, not whatever the server chose to
480
+ // echo — the upsert response shape is the tenant's, and re-parsing it here
481
+ // would make every field depend on a transform this package does not own.
482
+ const { adopted: _adopted, ...durable } = run;
483
+ return durable as ExtractionRun;
484
+ },
485
+
430
486
  async listSources(): Promise<SourceFreshness[]> {
431
487
  const [connectionPage, documentPage] = await Promise.all([
432
488
  http
@@ -106,6 +106,15 @@ export interface CorpusTypeKeys {
106
106
  moneyEvent: string;
107
107
  /** The trackable-commitment type (bd startsim-768w.38.25). */
108
108
  commitment: string;
109
+ /**
110
+ * The sweep record (bd startsim-768w.38.28).
111
+ *
112
+ * A long pass over the corpus is a JOB, and a job needs somewhere durable to
113
+ * keep its cursor. `tenant-starter` has no worker and no broker and is not
114
+ * getting one for this, so the job's state lives where all tenant state lives:
115
+ * a generic entity row. Nothing about the row is meeting-specific.
116
+ */
117
+ extractionRun: string;
109
118
  }
110
119
 
111
120
  export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
@@ -113,8 +122,12 @@ export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
113
122
  party: 'party',
114
123
  moneyEvent: 'money_event',
115
124
  commitment: 'commitment',
125
+ extractionRun: 'extraction_run',
116
126
  };
117
127
 
128
+ /** One sweep record per workspace, at a fixed id. See `ExtractionRunStore`. */
129
+ export const DEFAULT_RUN_KEY = 'corpus-extraction-sweep';
130
+
118
131
  // ---------------------------------------------------------------------------
119
132
  // tolerant accessors
120
133
  // ---------------------------------------------------------------------------
@@ -681,3 +694,69 @@ export function commitmentToEntity(commitment: Commitment): {
681
694
  },
682
695
  };
683
696
  }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // the sweep record (bd startsim-768w.38.28)
700
+ // ---------------------------------------------------------------------------
701
+
702
+ /**
703
+ * A run, as an entity row.
704
+ *
705
+ * THE WHOLE RECORD GOES IN ONE JSON FIELD, and that is deliberate rather than
706
+ * lazy. Every other mapper in this file spreads its fields across `data` so the
707
+ * tenant's generic record UI and its `attr.*` filters can see them, because
708
+ * those rows are the PRODUCT — somebody searches commitments by owner. Nobody
709
+ * filters sweeps by cursor. What a sweep record has to do is round-trip
710
+ * losslessly through a store that recurses snake_case↔camelCase into `data`
711
+ * (`@startsimpli/api`'s `transformKeys`), and a nested `totals.modelFailures`
712
+ * that becomes `totals.model_failures` on the way out and back is a silently
713
+ * zeroed counter. A string survives that transform exactly.
714
+ *
715
+ * The handful of fields worth seeing in the tenant's own admin — status, cursor,
716
+ * timestamps — are ALSO written flat, as a projection. They are never read back.
717
+ */
718
+ export function extractionRunToEntity(
719
+ run: import('./types').ExtractionRun,
720
+ entityType: string,
721
+ externalId: string,
722
+ ): { entity_type: string; external_id: string; name: string; data: Record<string, unknown> } {
723
+ const { adopted: _adopted, ...durable } = run;
724
+ return {
725
+ entity_type: entityType,
726
+ external_id: externalId,
727
+ name: `Extraction sweep ${run.runId}`.slice(0, 200),
728
+ data: {
729
+ // The authoritative copy.
730
+ run: JSON.stringify(durable),
731
+ // A projection, for eyes only.
732
+ run_id: run.runId,
733
+ status: run.status,
734
+ cursor: run.cursor,
735
+ total_documents: run.totalDocuments,
736
+ started_at: run.startedAt,
737
+ updated_at: run.updatedAt,
738
+ finished_at: run.finishedAt,
739
+ _origin: 'tenant-extraction',
740
+ },
741
+ };
742
+ }
743
+
744
+ /**
745
+ * Back again, tolerantly.
746
+ *
747
+ * A row whose JSON will not parse is treated as NO RUN rather than as a broken
748
+ * one. The alternative — throwing — turns a single malformed record into a
749
+ * workspace that can never start a sweep again, and the only thing lost by
750
+ * starting over is a cursor.
751
+ */
752
+ export function toExtractionRun(entity: TenantEntity): import('./types').ExtractionRun | null {
753
+ const data = bag(entity);
754
+ const raw = asText(pick(data, 'run'));
755
+ if (!raw) return null;
756
+ try {
757
+ const parsed = JSON.parse(raw) as import('./types').ExtractionRun;
758
+ return parsed && typeof parsed === 'object' && typeof parsed.runId === 'string' ? parsed : null;
759
+ } catch {
760
+ return null;
761
+ }
762
+ }