@startsimpli/integrations 0.2.32 → 0.2.34

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,178 @@
1
+ /**
2
+ * A CARD THAT COUNTS TWENTY FAILURES AND NAMES NONE (bd startsim-ei8gm.2).
3
+ *
4
+ * THE SIXTEEN DAYS. The Tandem Library's Granola card read *"Failing — 20 syncs
5
+ * in a row failed, synced 16d ago"* from 2026-08-12 to 2026-08-27 and nothing a
6
+ * human could open said why. It was our own 256 KiB egress cap: the per-note
7
+ * detail response peaked at 351 KB. Diagnosable in minutes by anyone who could
8
+ * read the error text; sixteen days for someone who could not.
9
+ *
10
+ * `#375` fixed the SERVER half. `SubtreeConnectionsView` now derives the newest
11
+ * failed run's reason and sends it as `last_error`, and `surface.recent_runs`
12
+ * answers a real `error_message` for source runs instead of the `""` that a
13
+ * `getattr` default had returned for every source run ever recorded. Both
14
+ * facts are on prod. Nothing renders either of them, which is this bead.
15
+ *
16
+ * WHY `check_error` COULD NEVER HAVE ANSWERED IT, which is what makes this a
17
+ * missing field rather than a missing render. `listSources` composes its
18
+ * `detail` from `check_error` alone, and `check_error` is empty BY
19
+ * CONSTRUCTION for this class of failure: `check()` and `poll()` are different
20
+ * requests. Granola's check asks for `page_size=1` and passes; its poll asks
21
+ * for thirty notes with transcripts and aborts. The credential was fine the
22
+ * whole time, which is exactly why a reader re-entered a key that was never the
23
+ * problem.
24
+ *
25
+ * BOTH SPELLINGS, and not as a courtesy. `@startsimpli/api` camelCases response
26
+ * keys in the browser while a plain server-side fetch does not — the trap the
27
+ * three timestamps in this adapter already carry a comment about, and one that
28
+ * is invisible until somebody's Library quietly lies.
29
+ */
30
+ import { describe, expect, it } from 'vitest';
31
+ import { render, screen } from '@testing-library/react';
32
+
33
+ import { LibraryPage } from '../components/pages/LibraryPage';
34
+ import { createCorpusApi } from '../corpus/client';
35
+ import type { CorpusHttp } from '../corpus/client';
36
+ import { FIXTURE_ORG } from '../corpus/fixtures';
37
+
38
+ const SCOPE = { orgId: FIXTURE_ORG };
39
+ const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
40
+
41
+ const NOW = Date.now();
42
+ const ago = (ms: number) => new Date(NOW - ms).toISOString();
43
+ const SIXTEEN_DAYS_AGO = ago(16 * 24 * 60 * 60_000);
44
+ const TEN_MINUTES_AGO = ago(10 * 60_000);
45
+
46
+ /** What production held, verbatim: the connector taxonomy's own code and text. */
47
+ const THE_REAL_REASON =
48
+ 'connector_granola poll failed on page 1: [config_error] the response '
49
+ + 'exceeded the 262144-byte cap and was aborted [granola]';
50
+
51
+ /**
52
+ * The WIRE, not the contract — rows go in exactly as central serialises them,
53
+ * the same convention `corpus-source-strip.test.tsx` keeps and for the same
54
+ * reason: a fixture must never be more generous than the server that feeds it.
55
+ */
56
+ function httpOver(rows: Array<Record<string, unknown>>): CorpusHttp {
57
+ return {
58
+ async get<T>(path: string): Promise<T> {
59
+ if (path.includes('connections')) {
60
+ return { count: rows.length, next: null, previous: null, results: rows } as T;
61
+ }
62
+ return EMPTY_PAGE as T;
63
+ },
64
+ };
65
+ }
66
+
67
+ const sourcesFrom = (rows: Array<Record<string, unknown>>) =>
68
+ createCorpusApi({ http: httpOver(rows), scope: SCOPE }).listSources();
69
+
70
+ /**
71
+ * THE LIVE ROW, as `/api/v1/subtree/connections/` served it on 2026-08-27.
72
+ * `check_error` empty, `status` connected, twenty failures counted, and — since
73
+ * #375 — a reason.
74
+ */
75
+ const TANDEM_GRANOLA = {
76
+ id: 'shared-granola',
77
+ connector_key: 'granola',
78
+ connector_name: 'Granola',
79
+ status: 'connected',
80
+ check_error: '',
81
+ last_synced_at: SIXTEEN_DAYS_AGO,
82
+ last_attempt_at: TEN_MINUTES_AGO,
83
+ last_checked_at: TEN_MINUTES_AGO,
84
+ consecutive_failures: 20,
85
+ last_error: THE_REAL_REASON,
86
+ };
87
+
88
+ describe('listSources — the strip carries the reason central sends', () => {
89
+ it('reads last_error onto the card', async () => {
90
+ const [granola] = await sourcesFrom([TANDEM_GRANOLA]);
91
+
92
+ expect(granola.lastError).toBe(THE_REAL_REASON);
93
+ });
94
+
95
+ it('reads it in the camelCase spelling the browser sees', async () => {
96
+ const [granola] = await sourcesFrom([
97
+ { ...TANDEM_GRANOLA, last_error: undefined, lastError: THE_REAL_REASON },
98
+ ]);
99
+
100
+ expect(granola.lastError).toBe(THE_REAL_REASON);
101
+ });
102
+
103
+ it('says WHY rather than only how many times', async () => {
104
+ const [granola] = await sourcesFrom([TANDEM_GRANOLA]);
105
+
106
+ expect(granola.status).toBe('error');
107
+ // The count survives — "one blip" and "sixteen days" are different
108
+ // problems — but it is no longer the whole sentence.
109
+ expect(granola.detail).toMatch(/20 syncs in a row failed/);
110
+ expect(granola.detail).toContain('262144-byte cap');
111
+ });
112
+
113
+ it('still counts alone when the server sent no reason', async () => {
114
+ const [granola] = await sourcesFrom([{ ...TANDEM_GRANOLA, last_error: '' }]);
115
+
116
+ expect(granola.status).toBe('error');
117
+ expect(granola.detail).toMatch(/20 syncs in a row failed/);
118
+ expect(granola.lastError).toBeUndefined();
119
+ });
120
+
121
+ it('calls a source failing when its newest run failed, even at a zero streak', async () => {
122
+ // `consecutive_failures` and `last_error` have different writers: the
123
+ // counter lives on the connection and the reason is derived from the newest
124
+ // RUN. Central sends `last_error` only while that newest run is the failed
125
+ // one, so a reason with no streak behind it is still a failing source —
126
+ // and the old branch, which turned only on the counter, called it `stale`.
127
+ const [granola] = await sourcesFrom([
128
+ { ...TANDEM_GRANOLA, consecutive_failures: 0 },
129
+ ]);
130
+
131
+ expect(granola.status).toBe('error');
132
+ expect(granola.detail).toContain('262144-byte cap');
133
+ });
134
+
135
+ it('leaves the credential’s own verdict in charge when there is one', async () => {
136
+ // Two different facts and never substitutes (bd startsim-1cow). A rejected
137
+ // key is what the reader must act on; the run reason is the sentence for
138
+ // when the key is FINE and the data still is not arriving.
139
+ const [granola] = await sourcesFrom([
140
+ {
141
+ ...TANDEM_GRANOLA,
142
+ status: 'needs_reauth',
143
+ check_error: 'The vendor rejected this key (401). Reconnect Granola.',
144
+ },
145
+ ]);
146
+
147
+ expect(granola.detail).toBe('The vendor rejected this key (401). Reconnect Granola.');
148
+ });
149
+
150
+ it('never reports a reason on a source that is fine', async () => {
151
+ const [granola] = await sourcesFrom([
152
+ {
153
+ ...TANDEM_GRANOLA,
154
+ last_synced_at: ago(60_000),
155
+ consecutive_failures: 0,
156
+ last_error: '',
157
+ },
158
+ ]);
159
+
160
+ expect(granola.status).toBe('ok');
161
+ expect(granola.detail).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe('the Library strip, rendered', () => {
166
+ it('prints the reason next to the corpus it was supposed to fill', async () => {
167
+ // The bug was VISIBLE, so the assertion is made where the reader would have
168
+ // seen it — not on the adapter's return value alone.
169
+ render(
170
+ <LibraryPage
171
+ api={createCorpusApi({ http: httpOver([TANDEM_GRANOLA]), scope: SCOPE })}
172
+ scope={SCOPE}
173
+ />,
174
+ );
175
+
176
+ expect(await screen.findByText(/262144-byte cap/)).toBeInTheDocument();
177
+ });
178
+ });
@@ -47,6 +47,7 @@ import type {
47
47
  ConnectionChoicePage,
48
48
  ConnectionInvokeResult,
49
49
  ConnectionPermissions,
50
+ ConnectionRun,
50
51
  ConnectionTarget,
51
52
  ConnectorAction,
52
53
  ConnectorCatalogEntry,
@@ -135,6 +136,16 @@ interface BoundApi {
135
136
  invokeConnectionAction?(id: string, action: string, input: {
136
137
  destination_id: string; record_type?: string; records?: unknown[];
137
138
  }): Promise<ConnectionInvokeResult>;
139
+ /**
140
+ * RECENT ACTIVITY on both legs (bd startsim-ei8gm.2). Required on both
141
+ * injected interfaces and, until this bead, called by nothing — so the reason
142
+ * a run failed reached the payload and never a pixel.
143
+ *
144
+ * Optional HERE for the reason every other verb on this type is: an app
145
+ * pinned to an older `@startsimpli/api` keeps type-checking, and the card
146
+ * renders no control rather than a button whose handler is `undefined`.
147
+ */
148
+ connectionRuns?(id: string): Promise<{ results: ConnectionRun[] }>;
138
149
  /** Per-member governance (bd startsim-768w.22.25.8). Foundry mode only, and
139
150
  absent on an api that predates the endpoints — see `ConnectionsApi`. */
140
151
  /** The OAuth hand-off (bd startsim-768w.22.25.14/.23). Optional: an app on an
@@ -179,7 +190,16 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
179
190
  ? { disconnectMemberConnection: (id: string) =>
180
191
  api.disconnectMemberConnection!(slug, id) }
181
192
  : {};
193
+ // Bound only when the injected client HAS it, even though `ConnectionsApi`
194
+ // declares it required: apps consume `@startsimpli/*` as PUBLISHED packages,
195
+ // so a runtime object that predates the verb is a real state the TYPE cannot
196
+ // see (bd startsim-ei8gm.2). Same rule as the four above — a wrapper that
197
+ // always exists and throws inside would render the control and fail on click.
198
+ const runs = api.connectionRuns
199
+ ? { connectionRuns: (id: string) => api.connectionRuns(slug, id) }
200
+ : {};
182
201
  return {
202
+ ...runs,
183
203
  ...memberPolicy,
184
204
  ...oauth,
185
205
  ...roster,
@@ -216,6 +236,21 @@ const DEFAULTS = {
216
236
  error: 'text-sm text-red-600',
217
237
  };
218
238
 
239
+ /**
240
+ * WHICH LEG A RUN BELONGS TO, in a reader's words (bd startsim-ei8gm.2).
241
+ *
242
+ * The card merges three tables — `SourceRun`, `DestinationRun` and
243
+ * `DestinationWebhookEvent` — into one list, and "failed" means a different
244
+ * thing and needs a different action on each. Data with a fall-through to the
245
+ * raw key, the same rule `CAPABILITY_COPY` below states: a `kind` this bundle
246
+ * has never heard of renders as itself rather than vanishing from the list.
247
+ */
248
+ const RUN_KIND_COPY: Record<string, string> = {
249
+ source_run: 'Fetched in',
250
+ destination_run: 'Sent out',
251
+ webhook_event: 'Received',
252
+ };
253
+
219
254
  /**
220
255
  * WHAT AN ADMIN IS AGREEING TO, in their words rather than the wire's.
221
256
  *
@@ -1088,6 +1123,10 @@ function ConnectionCard({
1088
1123
  const [replacing, setReplacing] = React.useState(false);
1089
1124
  const [settingsOpen, setSettingsOpen] = React.useState(false);
1090
1125
  const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
1126
+ const [activityOpen, setActivityOpen] = React.useState(false);
1127
+ /** `null` is NOT LOADED YET, which reads differently from a connection that
1128
+ has never run — the distinction the empty state below turns on. */
1129
+ const [runs, setRuns] = React.useState<ConnectionRun[] | null>(null);
1091
1130
 
1092
1131
  const declared = entry?.capabilities;
1093
1132
  const enabled = React.useMemo(
@@ -1138,102 +1177,169 @@ function ConnectionCard({
1138
1177
  <p className={cn.error}>{connection.check_error}</p>
1139
1178
  ) : null}
1140
1179
  </div>
1141
- {canManage ? (
1142
- <div className="flex flex-wrap justify-end gap-2">
1143
- <button
1144
- type="button"
1145
- className={cn.button}
1146
- disabled={Boolean(busy)}
1147
- onClick={() => void guard('check', async () => {
1148
- const res = await api.checkConnection(connection.id);
1149
- setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
1150
- onChanged();
1151
- })}
1152
- >
1153
- Test
1154
- </button>
1155
- {/* THE CONTROL THIS PAGE WAS MISSING. A vendor key gets rotated —
1156
- that is normal operations, not an exception — and the only place
1157
- to put the new one was a form that creates a second account. */}
1180
+ <div className="flex flex-wrap justify-end gap-2">
1181
+ {/* ACTIVITY IS OUTSIDE THE MANAGE GATE, and deliberately. Reading the
1182
+ run ledger needs `view_connections` server-side, which is what put
1183
+ this card on the screen at all — and the person who most needs to
1184
+ know WHY a connection is failing is often the one who cannot fix
1185
+ it themselves. Gating the answer behind the permission to act
1186
+ would leave them with the word "Failing" and nothing else, which
1187
+ is the sixteen days this bead is about. */}
1188
+ {api.connectionRuns ? (
1158
1189
  <button
1159
1190
  type="button"
1160
1191
  className={cn.button}
1161
1192
  disabled={Boolean(busy)}
1162
1193
  onClick={() => {
1163
- setConfirmingDisconnect(false);
1164
- setError('');
1165
- setReplacing((v) => !v);
1194
+ const opening = !activityOpen;
1195
+ setActivityOpen(opening);
1196
+ if (!opening) return;
1197
+ void guard('activity', async () => {
1198
+ const page = await api.connectionRuns!(connection.id);
1199
+ setRuns(page.results ?? []);
1200
+ });
1166
1201
  }}
1167
1202
  >
1168
- {replacing ? 'Cancel' : 'Replace key'}
1203
+ {activityOpen ? 'Hide activity' : 'Activity'}
1169
1204
  </button>
1170
- {/* DISCOVER -> WIRE -> FIRE is foundry-level and stays there. The
1171
- member router serves no choices, targets or actions at all, so
1172
- the control is gated on the verb EXISTING rather than on a mode
1173
- flag — there is no way to render a button here whose handler is
1174
- undefined. */}
1175
- {api.connectionChoices && api.connectionActions ? (
1205
+ ) : null}
1206
+ {canManage ? (
1207
+ <>
1176
1208
  <button
1177
1209
  type="button"
1178
1210
  className={cn.button}
1179
1211
  disabled={Boolean(busy)}
1180
- onClick={() => void guard('discover', async () => {
1181
- const page = await api.connectionChoices!(connection.id);
1182
- setChoices(page.results ?? []);
1183
- const available = await api.connectionActions!(connection.id);
1184
- setActions(available.results ?? []);
1212
+ onClick={() => void guard('check', async () => {
1213
+ const res = await api.checkConnection(connection.id);
1214
+ setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
1215
+ onChanged();
1185
1216
  })}
1186
1217
  >
1187
- {busy === 'discover' ? 'Loading…' : 'Workflows'}
1218
+ Test
1188
1219
  </button>
1189
- ) : null}
1190
- {/* SETTINGS, which this card has never had. A connector's
1191
- `config_schema` ships on every catalog row and was rendered
1192
- exactly once — on the connect form, before the account existed.
1193
- After that a setting could not be changed from the product at
1194
- all, which for Granola means the switch deciding whether verbatim
1195
- recordings of people's conversations are stored was reachable
1196
- only by editing the database. `ReplaceKeyForm` already pointed at
1197
- "the connection's own settings" as if this existed.
1220
+ {/* THE CONTROL THIS PAGE WAS MISSING. A vendor key gets rotated —
1221
+ that is normal operations, not an exception — and the only place
1222
+ to put the new one was a form that creates a second account. */}
1223
+ <button
1224
+ type="button"
1225
+ className={cn.button}
1226
+ disabled={Boolean(busy)}
1227
+ onClick={() => {
1228
+ setConfirmingDisconnect(false);
1229
+ setError('');
1230
+ setReplacing((v) => !v);
1231
+ }}
1232
+ >
1233
+ {replacing ? 'Cancel' : 'Replace key'}
1234
+ </button>
1235
+ {/* DISCOVER -> WIRE -> FIRE is foundry-level and stays there. The
1236
+ member router serves no choices, targets or actions at all, so
1237
+ the control is gated on the verb EXISTING rather than on a mode
1238
+ flag — there is no way to render a button here whose handler is
1239
+ undefined. */}
1240
+ {api.connectionChoices && api.connectionActions ? (
1241
+ <button
1242
+ type="button"
1243
+ className={cn.button}
1244
+ disabled={Boolean(busy)}
1245
+ onClick={() => void guard('discover', async () => {
1246
+ const page = await api.connectionChoices!(connection.id);
1247
+ setChoices(page.results ?? []);
1248
+ const available = await api.connectionActions!(connection.id);
1249
+ setActions(available.results ?? []);
1250
+ })}
1251
+ >
1252
+ {busy === 'discover' ? 'Loading…' : 'Workflows'}
1253
+ </button>
1254
+ ) : null}
1255
+ {/* SETTINGS, which this card has never had. A connector's
1256
+ `config_schema` ships on every catalog row and was rendered
1257
+ exactly once — on the connect form, before the account existed.
1258
+ After that a setting could not be changed from the product at
1259
+ all, which for Granola means the switch deciding whether verbatim
1260
+ recordings of people's conversations are stored was reachable
1261
+ only by editing the database. `ReplaceKeyForm` already pointed at
1262
+ "the connection's own settings" as if this existed.
1198
1263
 
1199
- GATED ON THE SETTINGS VERB, not on the general one (bd
1200
- startsim-sfms2). The member surface has `updateConnectionConfig`
1201
- and deliberately not `updateConnection`, because a personal
1202
- connection's config is the MEMBER's privacy boundary while its
1203
- name and what it may do stay the foundry's. One gate for both
1204
- would have made opening this control open the capability Save
1205
- beside it, which is a decision this change did not make. */}
1206
- {configFields.length && api.updateConnectionConfig ? (
1264
+ GATED ON THE SETTINGS VERB, not on the general one (bd
1265
+ startsim-sfms2). The member surface has `updateConnectionConfig`
1266
+ and deliberately not `updateConnection`, because a personal
1267
+ connection's config is the MEMBER's privacy boundary while its
1268
+ name and what it may do stay the foundry's. One gate for both
1269
+ would have made opening this control open the capability Save
1270
+ beside it, which is a decision this change did not make. */}
1271
+ {configFields.length && api.updateConnectionConfig ? (
1272
+ <button
1273
+ type="button"
1274
+ className={cn.button}
1275
+ disabled={Boolean(busy) || confirmingDisconnect}
1276
+ onClick={() => {
1277
+ setReplacing(false);
1278
+ setError('');
1279
+ setSettingsOpen((v) => !v);
1280
+ }}
1281
+ >
1282
+ {settingsOpen ? 'Close settings' : 'Settings'}
1283
+ </button>
1284
+ ) : null}
1207
1285
  <button
1208
1286
  type="button"
1209
1287
  className={cn.button}
1210
1288
  disabled={Boolean(busy) || confirmingDisconnect}
1211
1289
  onClick={() => {
1212
1290
  setReplacing(false);
1291
+ setSettingsOpen(false);
1213
1292
  setError('');
1214
- setSettingsOpen((v) => !v);
1293
+ setConfirmingDisconnect(true);
1215
1294
  }}
1216
1295
  >
1217
- {settingsOpen ? 'Close settings' : 'Settings'}
1296
+ Disconnect
1218
1297
  </button>
1219
- ) : null}
1220
- <button
1221
- type="button"
1222
- className={cn.button}
1223
- disabled={Boolean(busy) || confirmingDisconnect}
1224
- onClick={() => {
1225
- setReplacing(false);
1226
- setSettingsOpen(false);
1227
- setError('');
1228
- setConfirmingDisconnect(true);
1229
- }}
1230
- >
1231
- Disconnect
1232
- </button>
1233
- </div>
1234
- ) : null}
1298
+ </>
1299
+ ) : null}
1300
+ </div>
1235
1301
  </div>
1236
1302
 
1303
+ {activityOpen ? (
1304
+ <div className="mt-3 space-y-2 border-t pt-3" role="group"
1305
+ aria-label="Recent activity">
1306
+ {/* THE FIELD NOTHING HAS EVER READ. `ConnectionRun.error_message` has
1307
+ been declared in this package's types since the endpoint existed;
1308
+ until #375 the server answered `""` for every source run ever
1309
+ recorded, and even after it did not, no component asked. A failed
1310
+ run rendered as a status word, which is a fact the reader already
1311
+ had from the card above it.
1312
+
1313
+ RENDERED AS THE SERVER WROTE IT. The text is scrubbed against this
1314
+ connection's stored credential and the platform app_secret before
1315
+ it leaves Django (`surface.py::_reason_reader`), and a reason that
1316
+ could not be scrubbed arrives as a sentence saying so. There is
1317
+ one redaction path in this estate and it is not in the browser —
1318
+ which does not hold the secrets to run it. */}
1319
+ {busy === 'activity' && runs === null ? (
1320
+ <p className={cn.muted}>Loading recent activity…</p>
1321
+ ) : null}
1322
+ {runs !== null && runs.length === 0 ? (
1323
+ <p className={cn.muted}>No runs yet for this connection.</p>
1324
+ ) : null}
1325
+ {(runs ?? []).map((run) => (
1326
+ <div key={run.id} data-run={run.id}>
1327
+ <p className={cn.muted}>
1328
+ <span className="font-medium text-gray-900">
1329
+ {RUN_KIND_COPY[run.kind] ?? run.kind}
1330
+ </span>
1331
+ {run.name ? ` · ${run.name}` : ''}
1332
+ {` · ${run.status}`}
1333
+ {run.occurred_at ? ` · ${new Date(run.occurred_at).toLocaleString()}` : ''}
1334
+ </p>
1335
+ {run.error_message ? (
1336
+ <p className={cn.error}>{run.error_message}</p>
1337
+ ) : null}
1338
+ </div>
1339
+ ))}
1340
+ </div>
1341
+ ) : null}
1342
+
1237
1343
  {settingsOpen ? (
1238
1344
  <ConnectionSettingsForm
1239
1345
  api={api}