@startsimpli/integrations 0.1.6 → 0.2.1

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,437 @@
1
+ /**
2
+ * THE TRACKING SURFACE — what came out of your meetings and is still owed
3
+ * (bd startsim-768w.38.25).
4
+ *
5
+ * `<TrackerPage api={trackerApi} scope={scope} />`, the same injected shape as
6
+ * `ConnectionsPage`, `LibraryPage` and `AskPage`. The app owns className slots
7
+ * and a wrapper; extraction happens behind `api.extract`, server-side, where the
8
+ * provider key is. Nothing in this file imports a provider.
9
+ *
10
+ * THE SCREEN ANSWERS FOUR QUESTIONS and nothing else, because those are the four
11
+ * a person actually has: what is outstanding, who owns it, what is overdue, and
12
+ * what changed since last week. Everything on it is either one of those or the
13
+ * evidence for one of those.
14
+ *
15
+ * EVERY ROW IS TRACEABLE, for exactly the reason `AskPage` enforces citations:
16
+ * an item nobody can trace back to something a person said is an item nobody
17
+ * will act on. So each row carries the meetings it came from, the verbatim quote
18
+ * from each, and a click-through to the record. A commitment with no mention
19
+ * cannot be rendered, because it cannot exist.
20
+ *
21
+ * FILTERS ARE SERVER PARAMETERS (CLAUDE.md rule 8) and the screen SAYS SO when
22
+ * the deployment's data API does not honour one — same `appliedFilters`
23
+ * mechanism the Library uses. A status filter that silently does nothing is
24
+ * worse on this screen than on any other: the whole product claim is "this is
25
+ * what is still open".
26
+ */
27
+ import * as React from 'react';
28
+
29
+ import { stripMarkdown } from '../Markdown';
30
+ import type {
31
+ Commitment,
32
+ CommitmentQuery,
33
+ CommitmentStatus,
34
+ CommitmentTrackerApi,
35
+ CorpusScope,
36
+ ExtractionResult,
37
+ } from '../../corpus/types';
38
+
39
+ export interface TrackerPageProps {
40
+ api: CommitmentTrackerApi;
41
+ scope: CorpusScope;
42
+ /** Click-through from a mention to the record, normally the Library route. */
43
+ onOpenDocument?: (documentId: string) => void;
44
+ /** Injectable clock, so "overdue" and "this week" are testable. */
45
+ now?: number;
46
+ classNames?: Partial<Record<TrackerSlot, string>>;
47
+ }
48
+
49
+ type TrackerSlot =
50
+ | 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
51
+ | 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due';
52
+
53
+ const DEFAULTS: Record<TrackerSlot, string> = {
54
+ root: 'space-y-6',
55
+ section: 'space-y-3',
56
+ heading: 'text-lg font-semibold text-gray-900',
57
+ card: 'rounded-lg border border-gray-200 p-4',
58
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
59
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
60
+ secondaryButton: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-800 disabled:opacity-50',
61
+ input: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm',
62
+ muted: 'text-sm text-gray-500',
63
+ error: 'text-sm text-red-600',
64
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
65
+ row: 'rounded-lg border border-gray-200 p-4',
66
+ due: 'text-sm font-medium text-red-700',
67
+ };
68
+
69
+ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: string }> = [
70
+ { key: 'open', label: 'Outstanding' },
71
+ { key: 'overdue', label: 'Overdue' },
72
+ { key: 'recent', label: 'Changed this week' },
73
+ { key: 'done', label: 'Closed' },
74
+ ];
75
+
76
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
77
+
78
+ export function TrackerPage({
79
+ api,
80
+ scope,
81
+ onOpenDocument,
82
+ now = Date.now(),
83
+ classNames = {},
84
+ }: TrackerPageProps) {
85
+ const cn = { ...DEFAULTS, ...classNames };
86
+
87
+ const [tab, setTab] = React.useState<'open' | 'overdue' | 'recent' | 'done'>('open');
88
+ const [owner, setOwner] = React.useState('');
89
+ const [rows, setRows] = React.useState<Commitment[]>([]);
90
+ const [applied, setApplied] = React.useState<string[]>([]);
91
+ const [loading, setLoading] = React.useState(true);
92
+ const [error, setError] = React.useState('');
93
+ const [run, setRun] = React.useState<ExtractionResult | null>(null);
94
+ const [running, setRunning] = React.useState(false);
95
+
96
+ const query = React.useMemo<CommitmentQuery>(
97
+ () => ({
98
+ // The SERVER is asked for what the tab means. What comes back is then
99
+ // narrowed again below — see `visible` — because a deployment whose API
100
+ // ignores `status` would otherwise render closed items under "Outstanding"
101
+ // and the banner alone would be doing all the work.
102
+ status: tab === 'done' ? 'done' : (['open', 'blocked'] as CommitmentStatus[]),
103
+ ...(owner.trim() ? { owner: owner.trim() } : {}),
104
+ ...(tab === 'recent' ? { changedAfter: new Date(now - WEEK_MS).toISOString() } : {}),
105
+ ...(tab === 'overdue' ? { dueBefore: new Date(now).toISOString() } : {}),
106
+ pageSize: 200,
107
+ }),
108
+ [tab, owner, now],
109
+ );
110
+
111
+ const load = React.useCallback(async () => {
112
+ setLoading(true);
113
+ setError('');
114
+ try {
115
+ const page = await api.listCommitments(query);
116
+ setRows(page.results);
117
+ setApplied(page.appliedFilters ?? []);
118
+ } catch (err) {
119
+ setRows([]);
120
+ setError(failureMessage(err));
121
+ } finally {
122
+ setLoading(false);
123
+ }
124
+ }, [api, query]);
125
+
126
+ React.useEffect(() => { void load(); }, [load]);
127
+
128
+ /**
129
+ * The tab, applied in the browser as well as sent to the server.
130
+ *
131
+ * NOT a violation of rule 8 — the query still goes out as parameters and the
132
+ * server still pages. This is the honest belt to the server's braces: until bd
133
+ * startsim-768w.38.12 teaches the tenant API `?status=`, the API returns
134
+ * everything, and a screen that showed closed items under "Outstanding" would
135
+ * be lying about the one thing it exists to say. The banner below states that
136
+ * the list is not server-filtered, so the user knows the count is over what
137
+ * this page could fetch rather than over the whole corpus.
138
+ */
139
+ const visible = React.useMemo(() => {
140
+ const byTab = rows.filter((item) => {
141
+ if (tab === 'done') return item.status === 'done' || item.status === 'dropped';
142
+ if (item.status !== 'open' && item.status !== 'blocked') return false;
143
+ if (tab === 'overdue') return isOverdue(item, now);
144
+ if (tab === 'recent') return changedSince(item, now - WEEK_MS);
145
+ return true;
146
+ });
147
+ const needle = owner.trim().toLowerCase();
148
+ const byOwner = needle
149
+ ? byTab.filter((item) => (item.ownerName ?? '').toLowerCase().includes(needle))
150
+ : byTab;
151
+ return [...byOwner].sort(compareForScreen(now));
152
+ }, [rows, tab, owner, now]);
153
+
154
+ const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
155
+ (key) => Boolean(query[key]) && !applied.includes(key),
156
+ );
157
+
158
+ return (
159
+ <div className={cn.root}>
160
+ <section className={cn.section}>
161
+ <div className="flex flex-wrap items-center gap-2">
162
+ <div className="flex gap-2" role="tablist" aria-label="What to show">
163
+ {STATUS_TABS.map((entry) => (
164
+ <button
165
+ key={entry.key}
166
+ type="button"
167
+ role="tab"
168
+ aria-selected={tab === entry.key}
169
+ className={tab === entry.key ? cn.button : cn.secondaryButton}
170
+ onClick={() => setTab(entry.key)}
171
+ >
172
+ {entry.label}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ <label className="ml-auto flex items-center gap-2">
177
+ <span className="sr-only">Filter by owner</span>
178
+ <input
179
+ className={cn.input}
180
+ value={owner}
181
+ placeholder="Owner"
182
+ onChange={(event) => setOwner(event.target.value)}
183
+ />
184
+ </label>
185
+ {api.extract ? (
186
+ <button
187
+ type="button"
188
+ className={cn.secondaryButton}
189
+ disabled={running}
190
+ onClick={async () => {
191
+ setRunning(true);
192
+ setError('');
193
+ try {
194
+ setRun(await api.extract!({ scope }));
195
+ await load();
196
+ } catch (err) {
197
+ setError(failureMessage(err));
198
+ } finally {
199
+ setRunning(false);
200
+ }
201
+ }}
202
+ >
203
+ {running ? 'Reading your meetings…' : 'Pull from meetings'}
204
+ </button>
205
+ ) : null}
206
+ </div>
207
+
208
+ {unfiltered.length ? (
209
+ // The same honesty the Library owes about search, owed here about
210
+ // status — and it matters more here, because "outstanding" is the
211
+ // claim the whole screen makes.
212
+ <p className={cn.warning} role="status">
213
+ This workspace’s data API does not filter by {unfiltered.join(', ')} yet, so this list was
214
+ narrowed in the browser over the records it could fetch. Counts are over that set, not
215
+ over the whole corpus.
216
+ </p>
217
+ ) : null}
218
+
219
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
220
+ {run ? <RunSummary result={run} classNames={cn} onOpenDocument={onOpenDocument} /> : null}
221
+ </section>
222
+
223
+ <section className={cn.section} aria-label="Commitments">
224
+ <h2 className={cn.heading}>
225
+ {loading ? 'Loading…' : `${visible.length} item${visible.length === 1 ? '' : 's'}`}
226
+ </h2>
227
+
228
+ {!loading && visible.length === 0 ? (
229
+ <p className={cn.muted}>
230
+ {rows.length === 0
231
+ ? 'Nothing has been pulled out of your meetings yet. Connect a source, then run “Pull from meetings”.'
232
+ : 'Nothing matches this view. Everything here is either closed or filtered out.'}
233
+ </p>
234
+ ) : null}
235
+
236
+ {visible.map((item) => (
237
+ <CommitmentRow
238
+ key={item.externalId}
239
+ item={item}
240
+ now={now}
241
+ classNames={cn}
242
+ onOpenDocument={onOpenDocument}
243
+ />
244
+ ))}
245
+ </section>
246
+ </div>
247
+ );
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function CommitmentRow({
253
+ item, now, classNames: cn, onOpenDocument,
254
+ }: {
255
+ item: Commitment;
256
+ now: number;
257
+ classNames: Record<TrackerSlot, string>;
258
+ onOpenDocument?: (documentId: string) => void;
259
+ }) {
260
+ const overdue = isOverdue(item, now);
261
+ const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / 86_400_000) : null;
262
+
263
+ return (
264
+ <article className={cn.row}>
265
+ <div className="flex flex-wrap items-center gap-2">
266
+ <span className={cn.badge}>{item.subtype}</span>
267
+ {item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
268
+ <h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
269
+ </div>
270
+
271
+ <div className="mt-1 flex flex-wrap items-center gap-3">
272
+ {item.ownerName ? (
273
+ <span className="text-sm text-gray-700">{item.ownerName}</span>
274
+ ) : (
275
+ // Stated, not blank. "Nobody said who" is a fact about the meeting and
276
+ // it is the single most actionable thing on this screen.
277
+ <span className={cn.muted}>No owner named</span>
278
+ )}
279
+ {item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
280
+ {item.dueAt ? (
281
+ <span className={overdue ? cn.due : cn.muted}>
282
+ {overdue ? 'Overdue ' : 'Due '}
283
+ {new Date(item.dueAt).toLocaleDateString()}
284
+ </span>
285
+ ) : null}
286
+ {age !== null && age > 0 ? (
287
+ <span className={cn.muted}>
288
+ {/* The sentence the product exists to say. */}
289
+ open for {age} day{age === 1 ? '' : 's'}
290
+ </span>
291
+ ) : null}
292
+ {item.mentions.length > 1 ? (
293
+ <span className={cn.badge}>raised in {item.mentions.length} meetings</span>
294
+ ) : null}
295
+ </div>
296
+
297
+ {item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
298
+
299
+ {item.statusReason ? (
300
+ <p className="mt-2 text-sm text-gray-600">
301
+ {/* Why does it say this is closed? Answerable without opening the
302
+ meeting, or nobody will trust a single closed row. */}
303
+ {item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
304
+ </p>
305
+ ) : null}
306
+
307
+ <details className="mt-2">
308
+ <summary className="cursor-pointer text-sm text-gray-600">
309
+ From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
310
+ </summary>
311
+ <div className="mt-2 space-y-2">
312
+ {item.mentions.map((mention) => (
313
+ <div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
314
+ <div className="flex flex-wrap items-center gap-2">
315
+ <button
316
+ type="button"
317
+ className="text-sm text-blue-700 underline"
318
+ onClick={() => onOpenDocument?.(mention.documentId)}
319
+ >
320
+ {mention.occurredAt ? new Date(mention.occurredAt).toLocaleDateString() : 'Undated meeting'}
321
+ </button>
322
+ {/* Where the row came from. A model-derived item and one the
323
+ summariser itself wrote down are different levels of
324
+ evidence, and the difference belongs on screen. */}
325
+ <span className={cn.badge}>
326
+ {mention.extractor === 'rules' ? 'from the meeting’s own next steps' : 'read from the notes'}
327
+ </span>
328
+ </div>
329
+ {mention.quote ? (
330
+ <p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(mention.quote)}”</p>
331
+ ) : null}
332
+ </div>
333
+ ))}
334
+ </div>
335
+ </details>
336
+ </article>
337
+ );
338
+ }
339
+
340
+ /**
341
+ * What the last run did, in the terms that make it arguable.
342
+ *
343
+ * `fromRules` vs `fromModel` is here on purpose: it is the measurement of what
344
+ * the model is actually contributing over the structure the summariser already
345
+ * wrote, and if that number is ever zero the model call should be switched off
346
+ * rather than paid for.
347
+ */
348
+ function RunSummary({
349
+ result, classNames: cn, onOpenDocument,
350
+ }: {
351
+ result: ExtractionResult;
352
+ classNames: Record<TrackerSlot, string>;
353
+ onOpenDocument?: (documentId: string) => void;
354
+ }) {
355
+ return (
356
+ <div className={cn.card}>
357
+ <p className="text-sm text-gray-800">
358
+ Read {result.documentsProcessed} meeting{result.documentsProcessed === 1 ? '' : 's'}:{' '}
359
+ {result.created} new, {result.merged} matched to something already tracked,{' '}
360
+ {result.resolved} closed by a later meeting.
361
+ </p>
362
+ <p className={cn.muted}>
363
+ {result.fromRules} came from the meetings’ own next-step lists, {result.fromModel} were read
364
+ out of the notes by {result.provider.live ? `${result.provider.provider} · ${result.provider.model}` : 'no live model'}.
365
+ </p>
366
+
367
+ {result.needsReview.length ? (
368
+ // Weak resolution matches are shown, never applied. A to-do list that
369
+ // quietly empties itself is the one failure with no symptom.
370
+ <div className={`mt-2 ${cn.warning}`} role="status">
371
+ <p>
372
+ {result.needsReview.length} meeting statement
373
+ {result.needsReview.length === 1 ? '' : 's'} looked like they closed something, but not
374
+ closely enough to act on. Nothing was closed automatically.
375
+ </p>
376
+ <ul className="mt-1 space-y-1">
377
+ {result.needsReview.map((entry) => (
378
+ <li key={`${entry.commitmentId}:${entry.documentId}`}>
379
+ <button
380
+ type="button"
381
+ className="text-sm underline"
382
+ onClick={() => onOpenDocument?.(entry.documentId)}
383
+ >
384
+ “{stripMarkdown(entry.quote).slice(0, 120)}”
385
+ </button>
386
+ </li>
387
+ ))}
388
+ </ul>
389
+ </div>
390
+ ) : null}
391
+
392
+ {result.droppedOutOfScope > 0 ? (
393
+ <p className={cn.error} role="status">
394
+ {result.droppedOutOfScope} record{result.droppedOutOfScope === 1 ? '' : 's'} from another
395
+ workspace were returned by the data API and excluded before extracting.
396
+ </p>
397
+ ) : null}
398
+ </div>
399
+ );
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+
404
+ function isOverdue(item: Commitment, now: number): boolean {
405
+ if (!item.dueAt) return false;
406
+ if (item.status !== 'open' && item.status !== 'blocked') return false;
407
+ const at = Date.parse(item.dueAt);
408
+ return !Number.isNaN(at) && at < now;
409
+ }
410
+
411
+ function changedSince(item: Commitment, since: number): boolean {
412
+ const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
413
+ return stamps.some((stamp) => Date.parse(stamp) >= since);
414
+ }
415
+
416
+ /**
417
+ * Overdue first, then the oldest thing still open.
418
+ *
419
+ * Sorting by "raised longest ago and still not done" rather than by recency is
420
+ * the whole difference between a list of meetings and a list of debts.
421
+ */
422
+ function compareForScreen(now: number) {
423
+ return (a: Commitment, b: Commitment): number => {
424
+ const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
425
+ if (overdue !== 0) return overdue;
426
+ if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
427
+ if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
428
+ return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
429
+ };
430
+ }
431
+
432
+ function failureMessage(err: unknown): string {
433
+ const status = (err as { status?: number })?.status;
434
+ if (status === 403) return 'You do not have access to this workspace’s records.';
435
+ if (status === 401) return 'Your session has expired. Sign in again.';
436
+ return (err as { message?: string })?.message || 'That could not be loaded right now.';
437
+ }
@@ -28,13 +28,18 @@
28
28
  import {
29
29
  DEFAULT_PARTICIPANT_EDGES,
30
30
  DEFAULT_TYPE_KEYS,
31
+ commitmentToEntity,
31
32
  indexParticipants,
33
+ toCommitment,
32
34
  toMoneyEvent,
33
35
  toParty,
34
36
  toSourceDocument,
35
37
  } from './entities';
36
38
  import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
37
39
  import type {
40
+ Commitment,
41
+ CommitmentApi,
42
+ CommitmentQuery,
38
43
  CorpusApi,
39
44
  CorpusPage,
40
45
  CorpusScope,
@@ -45,9 +50,17 @@ import type {
45
50
  SourceFreshness,
46
51
  } from './types';
47
52
 
48
- /** The `@startsimpli/api` client surface this adapter needs, and no more. */
53
+ /**
54
+ * The `@startsimpli/api` client surface this adapter needs, and no more.
55
+ *
56
+ * `post` is OPTIONAL: reading the corpus is the common case and a deployment
57
+ * that only reads should not have to supply a write method it never calls. The
58
+ * commitment write path checks for it and reports honestly when it is absent,
59
+ * rather than failing at the moment somebody presses a button.
60
+ */
49
61
  export interface CorpusHttp {
50
62
  get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
63
+ post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
51
64
  }
52
65
 
53
66
  export interface CreateCorpusApiOptions {
@@ -62,7 +75,7 @@ export interface CreateCorpusApiOptions {
62
75
  * Which `DocumentQuery` keys this deployment's API really applies. Anything
63
76
  * not listed is reported to the user as unfiltered rather than assumed.
64
77
  */
65
- supportedFilters?: Array<keyof DocumentQuery>;
78
+ supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
66
79
  /** Endpoint bases, without leading/trailing slashes (the client convention). */
67
80
  paths?: { entities?: string; connections?: string; relationships?: string };
68
81
  /** How old a source's last sync may be before the Library calls it stale. */
@@ -103,6 +116,9 @@ interface DrfPage<T> {
103
116
 
104
117
  const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
105
118
 
119
+ /** Rows written in parallel. Small enough not to look like a burst to the tenant. */
120
+ const WRITE_CONCURRENCY = 6;
121
+
106
122
  function params(query: DocumentQuery, type: string) {
107
123
  const out: Record<string, unknown> = { type, page: query.page ?? 1 };
108
124
  if (query.pageSize) out.page_size = query.pageSize;
@@ -117,7 +133,7 @@ function params(query: DocumentQuery, type: string) {
117
133
  /** Which of the filters the caller asked for are actually honoured server-side. */
118
134
  function appliedFilters(
119
135
  query: DocumentQuery,
120
- supported: Array<keyof DocumentQuery>,
136
+ supported: Array<string>,
121
137
  ): string[] {
122
138
  const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
123
139
  (key) => Boolean(query[key]),
@@ -125,7 +141,7 @@ function appliedFilters(
125
141
  return requested.filter((key) => supported.includes(key));
126
142
  }
127
143
 
128
- export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
144
+ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & CommitmentApi {
129
145
  const {
130
146
  http,
131
147
  scope,
@@ -251,6 +267,71 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
251
267
  * Dropbox has contributed zero documents — and a count derived from the
252
268
  * corpus alone can never say it.
253
269
  */
270
+ /**
271
+ * The tracking surface's read (bd startsim-768w.38.25).
272
+ *
273
+ * SERVER-SIDE FILTERING, ALWAYS (CLAUDE.md rule 8) — status, owner and due
274
+ * window all go out as query parameters. The tenant does not honour them
275
+ * yet (bd startsim-768w.38.12 is the bead for that, and the filters that
276
+ * matter most for THIS screen are status/owner/dueBefore, not full text), so
277
+ * `appliedFilters` reports the gap and the composer says so on screen rather
278
+ * than rendering an unfiltered list under a filled-in control.
279
+ */
280
+ async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
281
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
282
+ const response = await http.get<DrfPage<TenantEntity>>(entities, {
283
+ params: {
284
+ type: types.commitment,
285
+ page: query.page ?? 1,
286
+ ...(query.pageSize ? { page_size: query.pageSize } : {}),
287
+ ...(statuses.length ? { status: statuses.join(',') } : {}),
288
+ ...(query.subtype ? { subtype: query.subtype } : {}),
289
+ ...(query.owner ? { owner: query.owner } : {}),
290
+ ...(query.dueBefore ? { due_before: query.dueBefore } : {}),
291
+ ...(query.changedAfter ? { changed_after: query.changedAfter } : {}),
292
+ ...(query.search ? { search: query.search } : {}),
293
+ },
294
+ });
295
+ return {
296
+ count: response.count ?? response.results?.length ?? 0,
297
+ next: response.next ?? null,
298
+ previous: response.previous ?? null,
299
+ results: (response.results ?? []).map((entity) => toCommitment(entity, scope, sourceLabels)),
300
+ appliedFilters: (['status', 'subtype', 'owner', 'dueBefore', 'changedAfter', 'search'] as const)
301
+ .filter((key) => Boolean(query[key]))
302
+ .filter((key) => (supportedFilters as string[]).includes(key)),
303
+ };
304
+ },
305
+
306
+ /**
307
+ * Write a run's commitments back.
308
+ *
309
+ * ONE REQUEST PER ROW, because that is what the tenant offers: its
310
+ * `/api/v1/entities/upsert/` takes a single record (verified on the wire —
311
+ * a list body is rejected with `external_id: required for upsert`). The
312
+ * writes are therefore fanned out with a bounded concurrency rather than
313
+ * looped serially, and the missing bulk endpoint is filed rather than
314
+ * pretended away.
315
+ *
316
+ * Idempotency is the SERVER's, not this function's: the upsert is keyed on
317
+ * (entity_type, external_id), so a second run rewrites rows in place and two
318
+ * concurrent runs converge instead of duplicating.
319
+ */
320
+ async saveCommitments(commitments: Commitment[]): Promise<void> {
321
+ if (!http.post) {
322
+ throw new Error('This corpus client is read-only — no write method was supplied.');
323
+ }
324
+ const post = http.post.bind(http);
325
+ const payloads = commitments.map(commitmentToEntity);
326
+ for (let index = 0; index < payloads.length; index += WRITE_CONCURRENCY) {
327
+ await Promise.all(
328
+ payloads
329
+ .slice(index, index + WRITE_CONCURRENCY)
330
+ .map((payload) => post(`${entities}/upsert`, payload)),
331
+ );
332
+ }
333
+ },
334
+
254
335
  async listSources(): Promise<SourceFreshness[]> {
255
336
  const [connectionPage, documentPage] = await Promise.all([
256
337
  http