@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/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',
@@ -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?, // number
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
- /** A conversational assistant over a project (chat turns; see plaid-igt-agent). */
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.get(requestId) === true;
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
- progress: (percent, msg, extra) =>
160
- reportEvent(requestId, { status: 'progress', progress: { percent, message: msg, ...extra } }),
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
- responseHelper.error(error?.message || error);
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 on timeout.
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] - Timeout in ms
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] - Timeout in ms
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
- const timer = setTimeout(
317
- () => finish(reject, new Error(`${what} timed out after ${timeout}ms`)),
318
- timeout,
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
- if (error.name !== 'AbortError') finish(reject, new Error(`${what} could not be sent: ${error.message}`));
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
  });