@larc-iu/plaid-client 0.2.0-alpha.11 → 0.2.0-alpha.13
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/index.d.ts +227 -10
- package/package.json +1 -1
- package/src/index.js +495 -28
- package/src/provenance.js +19 -0
- package/src/roles.js +9 -0
- package/src/serviceSchema.js +12 -2
- package/src/services.js +129 -17
package/src/provenance.js
CHANGED
|
@@ -99,6 +99,25 @@ export const PROV = Object.freeze({
|
|
|
99
99
|
CONTRIBUTED: 'contributed',
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Every provenance key, for a caller that has to name them as a set rather
|
|
104
|
+
* than read one: what a token layer declares under
|
|
105
|
+
* `config.plaid.preserveOnSplit` so a token born of a split inherits the
|
|
106
|
+
* origin of the token it came from, and what a reshape carries by hand.
|
|
107
|
+
*
|
|
108
|
+
* Provenance is the one metadata family Plaid itself owns. The user cannot
|
|
109
|
+
* maintain it, and in a shared project usually cannot even see it, since it
|
|
110
|
+
* may belong to an app they are not using. Everything else on an entity is
|
|
111
|
+
* the user's own content and no structural operation should touch it.
|
|
112
|
+
*/
|
|
113
|
+
export const PROVENANCE_KEYS = Object.freeze([
|
|
114
|
+
PROV.key,
|
|
115
|
+
PROV.sourceKey,
|
|
116
|
+
PROV.confirmedKey,
|
|
117
|
+
PROV.probKey,
|
|
118
|
+
PROV.detailKey,
|
|
119
|
+
]);
|
|
120
|
+
|
|
102
121
|
/** The four provenance states returned by provState. */
|
|
103
122
|
export const PROV_STATES = Object.freeze({
|
|
104
123
|
HUMAN: 'human',
|
package/src/roles.js
CHANGED
|
@@ -25,6 +25,15 @@ export const PLAID_NAMESPACE = 'plaid';
|
|
|
25
25
|
export const ROLE_KEY = 'role';
|
|
26
26
|
|
|
27
27
|
/** The fixed role inventory. */
|
|
28
|
+
/**
|
|
29
|
+
* Layer config key naming the metadata keys a token born of a SPLIT inherits
|
|
30
|
+
* from the token it came from. Plaid honors it without knowing what the keys
|
|
31
|
+
* mean; see the manual's `Metadata Preserved Across a Split`. An app declares
|
|
32
|
+
* it so that a split in ANY app, including one that has never heard of these
|
|
33
|
+
* keys, does not silently drop them.
|
|
34
|
+
*/
|
|
35
|
+
export const PRESERVE_ON_SPLIT_KEY = 'preserveOnSplit';
|
|
36
|
+
|
|
28
37
|
export const ROLES = Object.freeze({
|
|
29
38
|
BASELINE: 'baseline',
|
|
30
39
|
SENTENCE: 'sentence',
|
package/src/serviceSchema.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* parameters: [ // ordered; rendered into a form
|
|
22
22
|
* { key, label, type, description?, default?, required?,
|
|
23
23
|
* options?: [{value, label}], // enum / multiselect
|
|
24
|
-
* min?, max?, step?,
|
|
24
|
+
* min?, max?, step?, slider?, // number (slider: drag, don't type)
|
|
25
25
|
* placeholder?, multiline? } // string
|
|
26
26
|
* ]
|
|
27
27
|
* }
|
|
@@ -52,7 +52,17 @@ export const TASKS = Object.freeze({
|
|
|
52
52
|
ANALYZE: "analyze",
|
|
53
53
|
/** Propose a free translation for each sentence. */
|
|
54
54
|
TRANSLATE: "translate",
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Propose time-aligned speech regions for a recording.
|
|
57
|
+
*
|
|
58
|
+
* The odd one out: a detect-speech service RETURNS its regions and writes
|
|
59
|
+
* nothing, because a time-aligned segment is a stretch of the baseline and
|
|
60
|
+
* cannot exist without text. The response carries
|
|
61
|
+
* `{segments: [{timeBegin, timeEnd, speaker?}]}` (seconds), and the app
|
|
62
|
+
* holds them as proposals until a person types into one.
|
|
63
|
+
*/
|
|
64
|
+
DETECT_SPEECH: "detect-speech",
|
|
65
|
+
/** A conversational assistant over a project (chat turns; see plaid-agent). */
|
|
56
66
|
ASSIST: "assist",
|
|
57
67
|
});
|
|
58
68
|
|
package/src/services.js
CHANGED
|
@@ -147,31 +147,63 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
147
147
|
? { ...payload.data, requesterId }
|
|
148
148
|
: payload.data;
|
|
149
149
|
|
|
150
|
+
const scope = createCancelScope(() => cancelled.get(requestId) === true);
|
|
150
151
|
const responseHelper = {
|
|
151
152
|
requestId,
|
|
152
153
|
requesterId,
|
|
153
154
|
// True once the requester asked for the request to stop.
|
|
154
155
|
get cancelled() {
|
|
155
|
-
return cancelled
|
|
156
|
+
return scope.cancelled;
|
|
156
157
|
},
|
|
158
|
+
raiseIfCancelled: () => scope.raiseIfCancelled(),
|
|
159
|
+
critical: (fn) => scope.critical(fn),
|
|
157
160
|
// Extra fields ride in the progress payload (a chat service sends the
|
|
158
161
|
// reply text so far as `text`).
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
//
|
|
163
|
+
// This is a CANCELLATION CHECKPOINT: it throws ServiceCancelled if the
|
|
164
|
+
// requester has stopped the request, so a service that reports progress
|
|
165
|
+
// through its work is cancellable without doing anything else. Use
|
|
166
|
+
// `critical()` around writes to hold it off.
|
|
167
|
+
progress: (percent, msg, extra) => {
|
|
168
|
+
scope.raiseIfCancelled();
|
|
169
|
+
return reportEvent(requestId, {
|
|
170
|
+
status: 'progress',
|
|
171
|
+
progress: { percent, message: msg, ...extra },
|
|
172
|
+
});
|
|
173
|
+
},
|
|
161
174
|
complete: (data) => {
|
|
162
175
|
finished();
|
|
163
176
|
return reportEvent(requestId, { status: 'completed', data });
|
|
164
177
|
},
|
|
178
|
+
// Asked to stop, and it did. Reported as a result carrying
|
|
179
|
+
// `stopped: true`, so a client can tell "you stopped it" from "it broke".
|
|
180
|
+
stopped: (data) => {
|
|
181
|
+
finished();
|
|
182
|
+
return reportEvent(requestId, {
|
|
183
|
+
status: 'completed',
|
|
184
|
+
data: { ...(data || {}), stopped: true },
|
|
185
|
+
});
|
|
186
|
+
},
|
|
165
187
|
error: (error) => {
|
|
166
188
|
finished();
|
|
167
189
|
return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
|
|
168
190
|
},
|
|
169
191
|
};
|
|
170
192
|
|
|
193
|
+
// A handler may be async, so a rejected promise has to be caught too, or a
|
|
194
|
+
// cancelled async service would report a failure instead of a stop.
|
|
195
|
+
const settle = (error) => {
|
|
196
|
+
if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
|
|
197
|
+
responseHelper.stopped();
|
|
198
|
+
} else {
|
|
199
|
+
responseHelper.error(error?.message || error);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
171
202
|
try {
|
|
172
|
-
onServiceRequest(data, responseHelper);
|
|
203
|
+
const maybe = onServiceRequest(data, responseHelper);
|
|
204
|
+
if (maybe && typeof maybe.catch === 'function') maybe.catch(settle);
|
|
173
205
|
} catch (error) {
|
|
174
|
-
|
|
206
|
+
settle(error);
|
|
175
207
|
}
|
|
176
208
|
};
|
|
177
209
|
const openChannel = () => client.messages.listen(projectId, onChannelEvent, channelPath);
|
|
@@ -204,7 +236,7 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
204
236
|
*
|
|
205
237
|
* Streams the service's progress + result back over a single server-mediated
|
|
206
238
|
* response (no broadcast). Rejects if no service is connected (503), if the
|
|
207
|
-
* service reports an error, or
|
|
239
|
+
* service reports an error, or after `timeout` of SILENCE.
|
|
208
240
|
*
|
|
209
241
|
* The request outlives this call: after a timeout, an abort, or a dropped
|
|
210
242
|
* connection the service goes on, and `attachServiceRequest` collects the
|
|
@@ -213,11 +245,15 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
213
245
|
* to know the id before submitting; submitting an id that names a request
|
|
214
246
|
* you already made rejoins it instead of starting another.
|
|
215
247
|
*
|
|
248
|
+
* Errors that leave the request alive carry `pending: true`: a timeout, a
|
|
249
|
+
* stop, a dropped connection. An error without it is the end of the request.
|
|
250
|
+
*
|
|
216
251
|
* @param {Object} client - PlaidClient instance
|
|
217
252
|
* @param {string} projectId - Project UUID
|
|
218
253
|
* @param {string} serviceId - Service ID to request
|
|
219
254
|
* @param {any} data - Request payload
|
|
220
|
-
* @param {number} [timeout=10000] -
|
|
255
|
+
* @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
|
|
256
|
+
* Every event it sends starts the clock again, so this does not cap a long run
|
|
221
257
|
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
222
258
|
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
223
259
|
* @param {Object} [opts]
|
|
@@ -259,12 +295,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
259
295
|
* the latest progress is replayed, then the result comes, or at once if the
|
|
260
296
|
* request already finished. Rejects with an error whose `status` is 404 when
|
|
261
297
|
* the request is unknown or expired (the server keeps a finished request's
|
|
262
|
-
* result for a while, not forever).
|
|
298
|
+
* result for a while, not forever). As with `requestService`, `timeout` is
|
|
299
|
+
* how long the service may be silent, and an error carrying `pending: true`
|
|
300
|
+
* means the request is still there to rejoin again.
|
|
263
301
|
*
|
|
264
302
|
* @param {Object} client - PlaidClient instance
|
|
265
303
|
* @param {string} projectId - Project UUID
|
|
266
304
|
* @param {string} requestId - The request id (from `onAccepted` or your own)
|
|
267
|
-
* @param {number} [timeout=10000] -
|
|
305
|
+
* @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
|
|
268
306
|
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
269
307
|
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
270
308
|
* @returns {Promise<any>} The service's result
|
|
@@ -293,10 +331,68 @@ export function attachServiceRequest(client, projectId, requestId, timeout = 100
|
|
|
293
331
|
* @param {string} requestId - The request id
|
|
294
332
|
* @returns {Promise<void>}
|
|
295
333
|
*/
|
|
334
|
+
/**
|
|
335
|
+
* Thrown inside a handler when the requester has asked it to stop.
|
|
336
|
+
*
|
|
337
|
+
* Cooperative cancellation: nothing interrupts a handler, so the request ends
|
|
338
|
+
* at the next point the handler looks. `responseHelper.progress` is that
|
|
339
|
+
* point, which is why a long service that already reports progress needs no
|
|
340
|
+
* changes at all. `serve` catches this and ends the request as STOPPED rather
|
|
341
|
+
* than failed.
|
|
342
|
+
*/
|
|
343
|
+
export class ServiceCancelled extends Error {
|
|
344
|
+
constructor(message = 'The requester stopped this request') {
|
|
345
|
+
super(message);
|
|
346
|
+
this.name = 'ServiceCancelled';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The cancellation half of a responseHelper: a flag, a depth counter, and one
|
|
352
|
+
* rule about when a checkpoint may raise. Split from the transport so it can
|
|
353
|
+
* be reasoned about on its own. Mirrors `CancelScope` in the Python client.
|
|
354
|
+
*/
|
|
355
|
+
export function createCancelScope(isCancelled) {
|
|
356
|
+
// Depth of nested `critical()` blocks; while non-zero, a checkpoint will
|
|
357
|
+
// not throw.
|
|
358
|
+
let criticalDepth = 0;
|
|
359
|
+
return {
|
|
360
|
+
get cancelled() {
|
|
361
|
+
return !!isCancelled();
|
|
362
|
+
},
|
|
363
|
+
/** Stop here if the requester has asked the request to stop. */
|
|
364
|
+
raiseIfCancelled() {
|
|
365
|
+
if (isCancelled() && criticalDepth === 0) throw new ServiceCancelled();
|
|
366
|
+
},
|
|
367
|
+
/**
|
|
368
|
+
* Run `fn` as a stretch that must finish once begun, usually the writes.
|
|
369
|
+
* Checkpoints inside do not throw, so a half-written document is never
|
|
370
|
+
* left behind; cancellation takes effect at the first checkpoint after.
|
|
371
|
+
*/
|
|
372
|
+
async critical(fn) {
|
|
373
|
+
criticalDepth++;
|
|
374
|
+
try {
|
|
375
|
+
return await fn();
|
|
376
|
+
} finally {
|
|
377
|
+
criticalDepth--;
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
296
383
|
export function cancelServiceRequest(client, projectId, requestId) {
|
|
297
384
|
return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
|
|
298
385
|
}
|
|
299
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Mark an error as one that leaves the request ALIVE on the server: a timeout,
|
|
389
|
+
* a stop, a dropped connection. The service goes on working and its result
|
|
390
|
+
* still waits under the request id, so a caller holding that id can rejoin it
|
|
391
|
+
* with `attachServiceRequest`, and must not forget the id the way it forgets
|
|
392
|
+
* a request that really ended. An error without `pending` is terminal.
|
|
393
|
+
*/
|
|
394
|
+
const stillRunning = (err) => Object.assign(err, { pending: true });
|
|
395
|
+
|
|
300
396
|
/**
|
|
301
397
|
* Open a request stream (submit or attach) and read it to its terminal
|
|
302
398
|
* event: `accepted` names the request, `progress` events go to `onProgress`,
|
|
@@ -313,10 +409,23 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
313
409
|
abortController.abort();
|
|
314
410
|
fn(arg);
|
|
315
411
|
};
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
412
|
+
|
|
413
|
+
// `timeout` is IDLE time, meaning how long the service may say nothing. It
|
|
414
|
+
// is not a deadline on the run: every event from the server restarts it,
|
|
415
|
+
// because a service that is reporting its progress is not hung. As a
|
|
416
|
+
// deadline on the whole run it killed working transcriptions at the
|
|
417
|
+
// five-minute default and left their writes to land on a document the page
|
|
418
|
+
// had already handed back to the user as editable.
|
|
419
|
+
let timer;
|
|
420
|
+
const waitAgain = () => {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
timer = setTimeout(
|
|
423
|
+
() =>
|
|
424
|
+
finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
|
|
425
|
+
timeout,
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
waitAgain();
|
|
320
429
|
|
|
321
430
|
// An external signal stops waiting on a long request (a UI's Stop button).
|
|
322
431
|
// Reject with an AbortError so a caller can tell a deliberate stop from a
|
|
@@ -325,7 +434,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
325
434
|
const stop = () => {
|
|
326
435
|
const err = new Error('The service request was stopped');
|
|
327
436
|
err.name = 'AbortError';
|
|
328
|
-
finish(reject, err);
|
|
437
|
+
finish(reject, stillRunning(err));
|
|
329
438
|
};
|
|
330
439
|
if (signal) {
|
|
331
440
|
if (signal.aborted) {
|
|
@@ -349,7 +458,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
349
458
|
signal: abortController.signal,
|
|
350
459
|
});
|
|
351
460
|
} catch (error) {
|
|
352
|
-
|
|
461
|
+
// The POST may or may not have reached the server. Treat it as alive:
|
|
462
|
+
// rejoining a request that was never made simply 404s.
|
|
463
|
+
if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} could not be sent: ${error.message}`)));
|
|
353
464
|
return;
|
|
354
465
|
}
|
|
355
466
|
|
|
@@ -386,6 +497,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
386
497
|
dataLine = line.slice(6);
|
|
387
498
|
} else if (line === '' && eventType && dataLine) {
|
|
388
499
|
const payload = transformResponse(JSON.parse(dataLine));
|
|
500
|
+
waitAgain(); // it spoke, so it is not hung
|
|
389
501
|
if (eventType === 'accepted') {
|
|
390
502
|
if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
|
|
391
503
|
} else if (eventType === 'progress') {
|
|
@@ -402,9 +514,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
|
|
|
402
514
|
}
|
|
403
515
|
}
|
|
404
516
|
}
|
|
405
|
-
finish(reject, new Error('Service closed the connection without a result'));
|
|
517
|
+
finish(reject, stillRunning(new Error('Service closed the connection without a result')));
|
|
406
518
|
} catch (error) {
|
|
407
|
-
if (error.name !== 'AbortError') finish(reject, new Error(`${what} stream error: ${error.message}`));
|
|
519
|
+
if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} stream error: ${error.message}`)));
|
|
408
520
|
}
|
|
409
521
|
})();
|
|
410
522
|
});
|