@startsimpli/integrations 0.2.33 → 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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONNECTION CARD SAYS WHY A RUN FAILED (bd startsim-ei8gm.2, part 2).
|
|
3
|
+
*
|
|
4
|
+
* `ConnectionRun.error_message` has been DECLARED in this package's own types
|
|
5
|
+
* for as long as the endpoint has existed, and no component in
|
|
6
|
+
* `packages/integrations` or `apps/foundry` has ever read it. `connectionRuns`
|
|
7
|
+
* is likewise required on both api interfaces and called by nothing. So the
|
|
8
|
+
* activity endpoint that `#375` just taught to answer a real reason for source
|
|
9
|
+
* runs has no reader at all: a failed run does not render as a status word — it
|
|
10
|
+
* does not render.
|
|
11
|
+
*
|
|
12
|
+
* ONE COMPOSER, TWO SURFACES. `ConnectionsPage` is mounted by the control plane
|
|
13
|
+
* (`apps/foundry`'s `<ConnectionsPage api={api.foundry} foundrySlug={slug} />`)
|
|
14
|
+
* and by a tenant member's own screen in `personal` mode. The disclosure is
|
|
15
|
+
* gated on the VERB EXISTING — the rule the file already applies to Workflows
|
|
16
|
+
* and Settings — so an app pinned to an older `@startsimpli/api` degrades to no
|
|
17
|
+
* control rather than to a button whose handler is `undefined`.
|
|
18
|
+
*
|
|
19
|
+
* NOTHING IS SCRUBBED HERE. The text arrives already redacted against that
|
|
20
|
+
* connection's stored credential plus the platform app_secret
|
|
21
|
+
* (`apps/connectors/services/surface.py::_reason_reader` ->
|
|
22
|
+
* `dispatch.redaction_list`), and a run whose reason could not be proven clean
|
|
23
|
+
* arrives as a sentence saying so. A second redaction path in the browser would
|
|
24
|
+
* be a second thing to get wrong, and the browser does not hold the secrets to
|
|
25
|
+
* do it with.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
28
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
29
|
+
import userEvent from '@testing-library/user-event';
|
|
30
|
+
|
|
31
|
+
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
32
|
+
import type { ConnectionsApi, ConnectionRun } from '../components/pages/types';
|
|
33
|
+
|
|
34
|
+
const CATALOG = {
|
|
35
|
+
results: [
|
|
36
|
+
{
|
|
37
|
+
key: 'granola', name: 'Granola', description: 'Meeting notes',
|
|
38
|
+
logo_url: '', docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
|
|
39
|
+
credential_schema: { version: 1, fields: [] },
|
|
40
|
+
config_schema: { version: 1, fields: [] },
|
|
41
|
+
record_types: [], supports_webhooks: false,
|
|
42
|
+
availability: { available: true, reason: '' },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const CONNECTED = {
|
|
48
|
+
results: [
|
|
49
|
+
{
|
|
50
|
+
id: 'c1', public_id: 'pub1', name: 'Tandem Granola',
|
|
51
|
+
connector_key: 'granola', connector_name: 'Granola',
|
|
52
|
+
external_account_id: '', account_label: 'tandem',
|
|
53
|
+
capabilities_enabled: ['poll'], config: {}, status: 'connected',
|
|
54
|
+
health: 'ok', auth_mode: 'api_key',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
permissions: { view: true, manage: true, issue_tokens: true },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** What production held, verbatim, for each of the twenty failed polls. */
|
|
61
|
+
const THE_REAL_REASON =
|
|
62
|
+
'connector_granola poll failed on page 1: [config_error] the response '
|
|
63
|
+
+ 'exceeded the 262144-byte cap and was aborted [granola]';
|
|
64
|
+
|
|
65
|
+
const RUNS: ConnectionRun[] = [
|
|
66
|
+
{
|
|
67
|
+
id: 'r-new', kind: 'destination_run', object_id: 'd1', name: 'Corpus delivery',
|
|
68
|
+
status: 'completed', run_type: 'scheduled',
|
|
69
|
+
occurred_at: '2026-08-27T04:24:08Z', error_message: '',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'r-old', kind: 'source_run', object_id: 's1', name: 'granola-poll',
|
|
73
|
+
status: 'failed', run_type: 'incremental',
|
|
74
|
+
occurred_at: '2026-08-12T20:21:46Z', error_message: THE_REAL_REASON,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
79
|
+
return {
|
|
80
|
+
connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
|
|
81
|
+
connections: vi.fn().mockResolvedValue(CONNECTED),
|
|
82
|
+
connect: vi.fn(),
|
|
83
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: '' }),
|
|
84
|
+
disconnect: vi.fn(),
|
|
85
|
+
connectionChoices: vi.fn(),
|
|
86
|
+
wireTarget: vi.fn(),
|
|
87
|
+
confirmTarget: vi.fn(),
|
|
88
|
+
connectionActions: vi.fn(),
|
|
89
|
+
invokeConnectionAction: vi.fn(),
|
|
90
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: RUNS }),
|
|
91
|
+
updateConnection: vi.fn(),
|
|
92
|
+
rotateCredential: vi.fn(),
|
|
93
|
+
...overrides,
|
|
94
|
+
} as unknown as ConnectionsApi;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function openActivity(api: ConnectionsApi = stubApi()) {
|
|
98
|
+
render(<ConnectionsPage api={api} foundrySlug="tandem" />);
|
|
99
|
+
const button = await screen.findByRole('button', { name: /activity/i });
|
|
100
|
+
await userEvent.click(button);
|
|
101
|
+
return api;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('ConnectionCard — recent activity', () => {
|
|
105
|
+
it('has a control at all, which is the whole of the gap', async () => {
|
|
106
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="tandem" />);
|
|
107
|
+
|
|
108
|
+
expect(await screen.findByRole('button', { name: /activity/i })).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reads the runs endpoint that nothing has ever called', async () => {
|
|
112
|
+
const api = await openActivity();
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(api.connectionRuns).toHaveBeenCalledWith('tandem', 'c1'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders the reason a failed run gives, not only its status word', async () => {
|
|
118
|
+
await openActivity();
|
|
119
|
+
|
|
120
|
+
expect(await screen.findByText(/262144-byte cap/)).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('names which leg the run belongs to, since the card merges three tables', async () => {
|
|
124
|
+
await openActivity();
|
|
125
|
+
|
|
126
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
127
|
+
expect(activity).toHaveTextContent(/granola-poll/);
|
|
128
|
+
expect(activity).toHaveTextContent(/Corpus delivery/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('invents no reason for a run that succeeded', async () => {
|
|
132
|
+
await openActivity(stubApi({
|
|
133
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [RUNS[0]] }),
|
|
134
|
+
}) as ConnectionsApi);
|
|
135
|
+
|
|
136
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
137
|
+
expect(activity).not.toHaveTextContent(/262144/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('says so when there is no activity, rather than rendering an empty box', async () => {
|
|
141
|
+
await openActivity(stubApi({
|
|
142
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
143
|
+
}) as ConnectionsApi);
|
|
144
|
+
|
|
145
|
+
const activity = await screen.findByRole('group', { name: /recent activity/i });
|
|
146
|
+
expect(activity).toHaveTextContent(/no runs yet/i);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('offers no control when the client is too old to have the verb', async () => {
|
|
150
|
+
// Apps consume `@startsimpli/*` as PUBLISHED packages, so a client pinned
|
|
151
|
+
// to an older `@startsimpli/api` is a normal state of the world. The gate
|
|
152
|
+
// is the verb existing — the same rule Workflows and Settings use.
|
|
153
|
+
const api = stubApi();
|
|
154
|
+
delete (api as Partial<ConnectionsApi>).connectionRuns;
|
|
155
|
+
render(<ConnectionsPage api={api} foundrySlug="tandem" />);
|
|
156
|
+
|
|
157
|
+
await screen.findByText('Tandem Granola');
|
|
158
|
+
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
-
{
|
|
1203
|
+
{activityOpen ? 'Hide activity' : 'Activity'}
|
|
1169
1204
|
</button>
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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('
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1218
|
+
Test
|
|
1188
1219
|
</button>
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1293
|
+
setConfirmingDisconnect(true);
|
|
1215
1294
|
}}
|
|
1216
1295
|
>
|
|
1217
|
-
|
|
1296
|
+
Disconnect
|
|
1218
1297
|
</button>
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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}
|
package/src/corpus/client.ts
CHANGED
|
@@ -940,6 +940,11 @@ export function createCorpusApi(
|
|
|
940
940
|
Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
|
|
941
941
|
const documentCount = counts.get(key) ?? null;
|
|
942
942
|
const error = String(connection.check_error ?? connection.checkError ?? '');
|
|
943
|
+
// WHY THE LAST POLL FAILED — not why the credential did (bd
|
|
944
|
+
// startsim-ei8gm.2). Both spellings for the same reason the three
|
|
945
|
+
// timestamps above read both: `@startsimpli/api` camelCases response
|
|
946
|
+
// keys in the browser and a plain server-side fetch does not.
|
|
947
|
+
const lastError = String(connection.last_error ?? connection.lastError ?? '');
|
|
943
948
|
const status = connection.status ?? '';
|
|
944
949
|
|
|
945
950
|
let health: SourceFreshness['status'] = 'ok';
|
|
@@ -947,7 +952,7 @@ export function createCorpusApi(
|
|
|
947
952
|
if (error || status === 'error' || status === 'needs_reauth') {
|
|
948
953
|
health = 'error';
|
|
949
954
|
detail = error || `Connection status is ${String(status)}.`;
|
|
950
|
-
} else if (failures > 0 || (lastAttemptAt && !lastSyncedAt)) {
|
|
955
|
+
} else if (failures > 0 || lastError || (lastAttemptAt && !lastSyncedAt)) {
|
|
951
956
|
// IT TRIED AND IT FAILED. Central's view exists partly so that "a card
|
|
952
957
|
// cannot read 'nothing yet' while it is in fact failing on a
|
|
953
958
|
// schedule" — a run that FINISHED while nothing has ever finished well
|
|
@@ -955,12 +960,33 @@ export function createCorpusApi(
|
|
|
955
960
|
// when an older sync did land. Note what does NOT appear in this
|
|
956
961
|
// condition: `lastCheckedAt`. A verified key is not a delivered
|
|
957
962
|
// record, in either direction.
|
|
963
|
+
//
|
|
964
|
+
// `lastError` JOINS THAT CONDITION, because the two facts have
|
|
965
|
+
// different writers: `consecutive_failures` is a counter on the
|
|
966
|
+
// CONNECTION and the reason is derived from the newest RUN. Central
|
|
967
|
+
// sends a reason only while that newest run is the failed one, so a
|
|
968
|
+
// reason with a zero streak behind it is still a source whose last
|
|
969
|
+
// sync failed — and turning only on the counter reported that as
|
|
970
|
+
// `stale`, which reads as "nothing to do yet".
|
|
958
971
|
health = 'error';
|
|
959
|
-
|
|
972
|
+
// THE COUNT SURVIVES; IT IS NO LONGER THE WHOLE SENTENCE. "One blip"
|
|
973
|
+
// and "sixteen days" are different problems, so the streak stays —
|
|
974
|
+
// what was missing was the half a reader can act on. Composed here
|
|
975
|
+
// rather than server-side because the streak is a connection field
|
|
976
|
+
// and the reason is a run field, and central sends them separately
|
|
977
|
+
// on purpose (bd startsim-1cow: conflating two facts is how a
|
|
978
|
+
// freshness strip lies).
|
|
979
|
+
const streak =
|
|
960
980
|
failures > 0
|
|
961
|
-
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed`
|
|
962
|
-
|
|
963
|
-
|
|
981
|
+
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed`
|
|
982
|
+
: lastError
|
|
983
|
+
? 'The last sync failed'
|
|
984
|
+
: 'A sync ran but nothing arrived';
|
|
985
|
+
const nothingYet =
|
|
986
|
+
failures > 0 && !lastSyncedAt ? ' and nothing has arrived yet' : '';
|
|
987
|
+
// An em dash, not a colon: the server's reason usually contains its
|
|
988
|
+
// own colons ("[config_error] ...") and a second one reads as a typo.
|
|
989
|
+
detail = lastError ? `${streak}${nothingYet} — ${lastError}` : `${streak}${nothingYet}.`;
|
|
964
990
|
} else if (!lastSyncedAt) {
|
|
965
991
|
// `never` regardless of `lastCheckedAt`, which is the whole bead.
|
|
966
992
|
health = 'never';
|
|
@@ -982,6 +1008,10 @@ export function createCorpusApi(
|
|
|
982
1008
|
consecutiveFailures: failures,
|
|
983
1009
|
status: health,
|
|
984
1010
|
...(detail ? { detail } : {}),
|
|
1011
|
+
// Carried separately as well as folded into `detail`, so a surface
|
|
1012
|
+
// that wants the reason on its own — a tooltip, a copy button, the
|
|
1013
|
+
// control plane's card — does not have to unpick a composed sentence.
|
|
1014
|
+
...(lastError ? { lastError } : {}),
|
|
985
1015
|
};
|
|
986
1016
|
});
|
|
987
1017
|
},
|
package/src/corpus/types.ts
CHANGED
|
@@ -222,6 +222,34 @@ export interface SourceFreshness {
|
|
|
222
222
|
status: 'ok' | 'stale' | 'error' | 'never';
|
|
223
223
|
/** Why it is not `ok`, in the words the server used. */
|
|
224
224
|
detail?: string;
|
|
225
|
+
/**
|
|
226
|
+
* WHY THE LAST SYNC FAILED, in the vendor's own words, and a DIFFERENT FACT
|
|
227
|
+
* FROM THE CREDENTIAL'S VERDICT (bd startsim-ei8gm.2).
|
|
228
|
+
*
|
|
229
|
+
* The Tandem Granola card read "Failing — 20 syncs in a row failed, synced
|
|
230
|
+
* 16d ago" for sixteen days and nothing said why. It was our own 256 KiB
|
|
231
|
+
* egress cap — the per-note detail response peaked at 351 KB — which is a
|
|
232
|
+
* minutes-long diagnosis for anyone who could see the text and sixteen days
|
|
233
|
+
* for anyone who could not.
|
|
234
|
+
*
|
|
235
|
+
* `check_error` COULD NOT HAVE ANSWERED IT, which is why this is its own
|
|
236
|
+
* field rather than a wider read of that one. `check()` and `poll()` are
|
|
237
|
+
* different requests: Granola's check asks for `page_size=1` and passes,
|
|
238
|
+
* while its poll asks for thirty notes with transcripts and aborts. The
|
|
239
|
+
* credential verified cleanly throughout, so a strip built on `check_error`
|
|
240
|
+
* alone can count twenty failures and name none of them — and it sends the
|
|
241
|
+
* reader to re-enter a key that was never the problem.
|
|
242
|
+
*
|
|
243
|
+
* Central sends it only while the connection's NEWEST run is the failed one
|
|
244
|
+
* (`SubtreeConnectionsView._last_failure_reasons`), so it is never last
|
|
245
|
+
* Tuesday's error quoted over a fix that has already landed. It is SCRUBBED
|
|
246
|
+
* server-side against that connection's own stored credential plus the
|
|
247
|
+
* platform app_secret — a vendor can quote our key back inside its own error
|
|
248
|
+
* body — and a reason that could not be scrubbed arrives as a sentence saying
|
|
249
|
+
* so rather than as the raw text. Nothing here re-checks it; there is exactly
|
|
250
|
+
* one redaction path and it is not in the browser.
|
|
251
|
+
*/
|
|
252
|
+
lastError?: string;
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
/** DRF PageNumberPagination, which is what the tenant data API speaks. */
|