@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.4

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
@@ -43,6 +43,17 @@
43
43
  * human edits (history is valuable), so a consumer must not present
44
44
  * provProb as confidence in the CURRENT value once the entity is verified
45
45
  * (provConfirmed is exactly the flag to check).
46
+ *
47
+ * Recommended provDetail keys, so consumers (review dashboards, ranked
48
+ * alternatives) can read any producer's output:
49
+ * - value (spans) / form (morpheme tokens): the value the producer
50
+ * wrote, mirroring the entity's own field. The entity may be edited
51
+ * later; this copy is what makes "accepted as-is" vs "corrected"
52
+ * answerable once the entity is verified.
53
+ * - valueProbs / formProbs: a top-k {label: probability} map over the
54
+ * alternatives the producer considered, the chosen label included
55
+ * (provProb, when given, is that label's entry duplicated flat).
56
+ * - model, and anything else (language, adapter, raw scores).
46
57
  */
47
58
 
48
59
  /** The flat metadata keys of the provenance convention. */
@@ -43,13 +43,17 @@
43
43
  * without an explicit `overwrite` parameter. See the manual, "Provenance".
44
44
  */
45
45
  export const TASKS = Object.freeze({
46
- TOKENIZE: 'tokenize',
47
- PARSE: 'parse',
48
- TRANSCRIBE: 'transcribe',
46
+ TOKENIZE: "tokenize",
47
+ PARSE: "parse",
48
+ TRANSCRIBE: "transcribe",
49
49
  /** Create vocab links for unlinked tokens. */
50
- LINK_VOCAB: 'link-vocab',
50
+ LINK_VOCAB: "link-vocab",
51
51
  /** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
52
- ANALYZE: 'analyze',
52
+ ANALYZE: "analyze",
53
+ /** Propose a free translation for each sentence. */
54
+ TRANSLATE: "translate",
55
+ /** A conversational assistant over a project (chat turns; see plaid-igt-agent). */
56
+ ASSIST: "assist",
53
57
  });
54
58
 
55
59
  /**
@@ -57,8 +61,8 @@ export const TASKS = Object.freeze({
57
61
  * declared `tasks` array. Drop once all services advertise `tasks`.
58
62
  */
59
63
  const LEGACY_TASK_PREFIXES = Object.freeze({
60
- [TASKS.TOKENIZE]: 'tok:',
61
- [TASKS.TRANSCRIBE]: 'asr:',
64
+ [TASKS.TOKENIZE]: "tok:",
65
+ [TASKS.TRANSCRIBE]: "asr:",
62
66
  });
63
67
 
64
68
  /**
@@ -74,7 +78,11 @@ export function servesTask(service, task) {
74
78
  return declared.includes(task);
75
79
  }
76
80
  const prefix = LEGACY_TASK_PREFIXES[task];
77
- return !!prefix && typeof service?.serviceId === 'string' && service.serviceId.startsWith(prefix);
81
+ return (
82
+ !!prefix &&
83
+ typeof service?.serviceId === "string" &&
84
+ service.serviceId.startsWith(prefix)
85
+ );
78
86
  }
79
87
 
80
88
  /**
@@ -104,7 +112,7 @@ export function getParamSchema(service) {
104
112
  * @returns {string}
105
113
  */
106
114
  export function getServiceSummary(service) {
107
- return service?.extras?.summary || service?.description || '';
115
+ return service?.extras?.summary || service?.description || "";
108
116
  }
109
117
 
110
118
  /** Valid option values for an enum/multiselect param. */
@@ -119,20 +127,25 @@ function optionValues(param) {
119
127
  */
120
128
  function defaultForParam(param) {
121
129
  const opts = optionValues(param);
122
- if (param.type === 'enum') {
123
- if (param.default != null && opts.includes(param.default)) return param.default;
124
- return opts[0] ?? '';
130
+ if (param.type === "enum") {
131
+ if (param.default != null && opts.includes(param.default))
132
+ return param.default;
133
+ return opts[0] ?? "";
125
134
  }
126
- if (param.type === 'multiselect') {
135
+ if (param.type === "multiselect") {
127
136
  const arr = Array.isArray(param.default) ? param.default : [];
128
137
  return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
129
138
  }
130
- if (param.default !== undefined && param.default !== null) return param.default;
139
+ if (param.default !== undefined && param.default !== null)
140
+ return param.default;
131
141
  switch (param.type) {
132
- case 'number': return typeof param.min === 'number' ? param.min : 0;
133
- case 'boolean': return false;
134
- case 'string':
135
- default: return '';
142
+ case "number":
143
+ return typeof param.min === "number" ? param.min : 0;
144
+ case "boolean":
145
+ return false;
146
+ case "string":
147
+ default:
148
+ return "";
136
149
  }
137
150
  }
138
151
 
@@ -169,43 +182,48 @@ export function coerceParamValues(schema, raw) {
169
182
  if (v === undefined) v = defaultForParam(param);
170
183
 
171
184
  switch (param.type) {
172
- case 'number': {
185
+ case "number": {
173
186
  // Blank / nullish counts as "missing" → the param's default (matches the
174
187
  // Python client, where float('') raises and falls back to the default).
175
188
  let n;
176
- if (v === '' || v == null || (typeof v === 'string' && v.trim() === '')) {
189
+ if (
190
+ v === "" ||
191
+ v == null ||
192
+ (typeof v === "string" && v.trim() === "")
193
+ ) {
177
194
  n = defaultForParam(param);
178
195
  } else {
179
- n = typeof v === 'number' ? v : Number(v);
196
+ n = typeof v === "number" ? v : Number(v);
180
197
  if (Number.isNaN(n)) n = defaultForParam(param);
181
198
  }
182
- if (typeof param.min === 'number') n = Math.max(param.min, n);
183
- if (typeof param.max === 'number') n = Math.min(param.max, n);
199
+ if (typeof param.min === "number") n = Math.max(param.min, n);
200
+ if (typeof param.max === "number") n = Math.min(param.max, n);
184
201
  v = n;
185
202
  break;
186
203
  }
187
- case 'boolean':
188
- v = v === true || v === 'true';
204
+ case "boolean":
205
+ v = v === true || v === "true";
189
206
  break;
190
- case 'enum': {
207
+ case "enum": {
191
208
  const opts = optionValues(param);
192
209
  if (opts.length && !opts.includes(v)) v = defaultForParam(param);
193
210
  break;
194
211
  }
195
- case 'multiselect': {
212
+ case "multiselect": {
196
213
  const opts = optionValues(param);
197
- const arr = Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]);
214
+ const arr = Array.isArray(v) ? v : v == null || v === "" ? [] : [v];
198
215
  v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
199
216
  break;
200
217
  }
201
- case 'string':
218
+ case "string":
202
219
  default:
203
- v = v == null ? '' : String(v);
220
+ v = v == null ? "" : String(v);
204
221
  break;
205
222
  }
206
223
 
207
224
  if (param.required) {
208
- const empty = v === '' || v == null || (Array.isArray(v) && v.length === 0);
225
+ const empty =
226
+ v === "" || v == null || (Array.isArray(v) && v.length === 0);
209
227
  if (empty) errors[k] = `${param.label || k} is required`;
210
228
  }
211
229
  values[k] = v;
package/src/services.js CHANGED
@@ -52,13 +52,38 @@ export function discardService(client, projectId, serviceId) {
52
52
  * @param {Object} serviceInfo - {serviceId, serviceName, description}
53
53
  * @param {function} onServiceRequest - Handler callback (data, responseHelper)
54
54
  * @param {Object} extras - Optional additional metadata
55
- * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .serviceInfo
55
+ * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
56
+ * connection-state transitions: 'registered', 'reconnected', 'disconnected'.
57
+ * Called once per transition, not once per retry.
58
+ * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
59
+ * .serviceInfo
56
60
  */
57
- export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}) {
61
+ export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}, onStatus = null) {
58
62
  const { serviceId, serviceName, description = '' } = serviceInfo;
59
63
  let connection = null;
60
64
  let isRunning = true;
61
65
  let reconnectTimer = null;
66
+ // Last state REPORTED, so an outage produces one "lost" line and one "back"
67
+ // line however many retries it took.
68
+ let isConnected = false;
69
+ let everConnected = false;
70
+
71
+ const report = (event, detail) => {
72
+ if (!onStatus) return;
73
+ try { onStatus(event, projectId, detail); } catch (_) { /* never disturb the loop */ }
74
+ };
75
+ const noteConnected = () => {
76
+ if (isConnected) return;
77
+ const first = !everConnected;
78
+ isConnected = true;
79
+ everConnected = true;
80
+ report(first ? 'registered' : 'reconnected');
81
+ };
82
+ const noteDisconnected = () => {
83
+ if (!isConnected) return;
84
+ isConnected = false;
85
+ report('disconnected');
86
+ };
62
87
 
63
88
  const reportEvent = (requestId, body) =>
64
89
  client
@@ -76,6 +101,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
76
101
  if (connection) connection.close();
77
102
  },
78
103
  isRunning: () => isRunning,
104
+ // Whether the request channel is open RIGHT NOW, i.e. whether the server
105
+ // currently sees this service as online. A registration that is retrying
106
+ // through a server restart is still running but not connected.
107
+ isConnected: () => isRunning && !!connection && connection.readyState === 1,
79
108
  serviceInfo: { serviceId, serviceName, description, extras },
80
109
  };
81
110
 
@@ -118,12 +147,17 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
118
147
  throw new Error(`Failed to start service: ${error.message}`);
119
148
  }
120
149
 
121
- // Reopen the channel if it drops (e.g. the server restarted). Reopening
122
- // re-registers the service server-side, so presence and reachability come
123
- // back together.
150
+ // Reopen the channel whenever it drops (e.g. the server restarted), for as
151
+ // long as the service runs. Reopening re-registers the service server-side,
152
+ // so presence and reachability come back together, and a server restart never
153
+ // means a service restart. A reopened channel counts as connected only once
154
+ // the server has actually answered it (readyState OPEN), not merely because
155
+ // the attempt was made.
124
156
  reconnectTimer = setInterval(() => {
125
- if (!isRunning) return;
126
- if (connection && connection.readyState === 2) { // CLOSED (dropped)
157
+ if (!isRunning || !connection) return;
158
+ if (connection.readyState === 1) { noteConnected(); return; }
159
+ if (connection.readyState === 2) { // CLOSED (dropped or failed)
160
+ noteDisconnected();
127
161
  try { connection = openChannel(); } catch (_) { /* retry next tick */ }
128
162
  }
129
163
  }, 3000);
@@ -144,9 +178,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
144
178
  * @param {any} data - Request payload
145
179
  * @param {number} [timeout=10000] - Timeout in ms
146
180
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
181
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
147
182
  * @returns {Promise<any>} The service's result
148
183
  */
149
- export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
184
+ export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal) {
150
185
  // Propagate an open logical operation (client.beginOperation) to the
151
186
  // service: its writes then fold under the requester's audit-log entry
152
187
  // (the Python BaseService adopts the id around process_request). Only for a
@@ -172,6 +207,23 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
172
207
  timeout,
173
208
  );
174
209
 
210
+ // An external signal stops waiting on a long request (a UI's Stop button).
211
+ // Reject with an AbortError so a caller can tell a deliberate stop from a
212
+ // failure. The service is not told: it finishes its work and its reply
213
+ // goes nowhere, which is safe because it has already been asked for.
214
+ const stop = () => {
215
+ const err = new Error('The service request was stopped');
216
+ err.name = 'AbortError';
217
+ finish(reject, err);
218
+ };
219
+ if (signal) {
220
+ if (signal.aborted) {
221
+ stop();
222
+ return;
223
+ }
224
+ signal.addEventListener('abort', stop, { once: true });
225
+ }
226
+
175
227
  (async () => {
176
228
  let response;
177
229
  try {
package/src/sse.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { transformResponse } from './transforms.js';
2
2
 
3
+ // A live SSE stream is never silent for long: the server sends a keepalive
4
+ // comment on service channels (every 25s) and a heartbeat event on the /listen
5
+ // bus (every 30s). So if NOTHING arrives for longer than this, the connection is
6
+ // dead: the server was killed without closing the socket, the network dropped
7
+ // it, or a reconnect caught the server mid-restart (port accepting, app not yet
8
+ // streaming). We must surface that as a drop so the registration supervisor in
9
+ // serve() can reopen it. Without the watchdog such a silent drop
10
+ // leaves `reader.read()` pending forever: readyState never reaches CLOSED, so
11
+ // nothing reconnects and the service never heals. Comfortably above both server
12
+ // cadences, so a healthy idle stream never trips it.
13
+ export const SSE_IDLE_TIMEOUT_MS = 60000;
14
+
3
15
  /**
4
16
  * Project a parsed SSE `data:` object onto what a listener callback sees.
5
17
  *
@@ -59,6 +71,21 @@ export function createSSEConnection(client, projectId, onEvent, path) {
59
71
  }
60
72
  };
61
73
 
74
+ // Watchdog: abort the fetch if the stream goes silent for longer than the
75
+ // server's keepalive cadence, so a silently-dropped connection lands in the
76
+ // catch below (readyState CLOSED) instead of blocking on a read that will
77
+ // never resolve.
78
+ let idleTimer = null;
79
+ const clearIdleTimer = () => {
80
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
81
+ };
82
+ const armIdleTimer = () => {
83
+ clearIdleTimer();
84
+ idleTimer = setTimeout(() => {
85
+ if (!isClosed) abortController.abort(new Error('SSE stream went silent'));
86
+ }, SSE_IDLE_TIMEOUT_MS);
87
+ };
88
+
62
89
  const sseConnection = {
63
90
  readyState: 0, // CONNECTING
64
91
  close: () => {
@@ -66,6 +93,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
66
93
  isClosed = true;
67
94
  isConnected = false;
68
95
  sseConnection.readyState = 2; // CLOSED
96
+ clearIdleTimer();
69
97
  abortController.abort();
70
98
  }
71
99
  },
@@ -83,6 +111,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
83
111
  (async () => {
84
112
  try {
85
113
  const url = `${client.baseUrl}${streamPath}`;
114
+ armIdleTimer();
86
115
  const response = await fetch(url, {
87
116
  method: 'GET',
88
117
  headers: {
@@ -120,6 +149,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
120
149
  while (true) {
121
150
  const { done, value } = await reader.read();
122
151
  if (done || isClosed) break;
152
+ armIdleTimer();
123
153
 
124
154
  buffer += decoder.decode(value, { stream: true });
125
155
  const lines = buffer.split('\n');
@@ -163,6 +193,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
163
193
  console.warn('SSE connection error:', error);
164
194
  }
165
195
  } finally {
196
+ clearIdleTimer();
166
197
  isConnected = false;
167
198
  isClosed = true;
168
199
  sseConnection.readyState = 2; // CLOSED