@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/index.d.ts +493 -105
- package/package.json +1 -1
- package/src/http.js +57 -3
- package/src/index.js +800 -341
- package/src/provenance.js +11 -0
- package/src/serviceSchema.js +49 -31
- package/src/services.js +60 -8
- package/src/sse.js +31 -0
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. */
|
package/src/serviceSchema.js
CHANGED
|
@@ -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:
|
|
47
|
-
PARSE:
|
|
48
|
-
TRANSCRIBE:
|
|
46
|
+
TOKENIZE: "tokenize",
|
|
47
|
+
PARSE: "parse",
|
|
48
|
+
TRANSCRIBE: "transcribe",
|
|
49
49
|
/** Create vocab links for unlinked tokens. */
|
|
50
|
-
LINK_VOCAB:
|
|
50
|
+
LINK_VOCAB: "link-vocab",
|
|
51
51
|
/** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
|
|
52
|
-
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]:
|
|
61
|
-
[TASKS.TRANSCRIBE]:
|
|
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
|
|
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 ===
|
|
123
|
-
if (param.default != null && opts.includes(param.default))
|
|
124
|
-
|
|
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 ===
|
|
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)
|
|
139
|
+
if (param.default !== undefined && param.default !== null)
|
|
140
|
+
return param.default;
|
|
131
141
|
switch (param.type) {
|
|
132
|
-
case
|
|
133
|
-
|
|
134
|
-
case
|
|
135
|
-
|
|
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
|
|
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 (
|
|
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 ===
|
|
196
|
+
n = typeof v === "number" ? v : Number(v);
|
|
180
197
|
if (Number.isNaN(n)) n = defaultForParam(param);
|
|
181
198
|
}
|
|
182
|
-
if (typeof param.min ===
|
|
183
|
-
if (typeof param.max ===
|
|
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
|
|
188
|
-
v = v === true || v ===
|
|
204
|
+
case "boolean":
|
|
205
|
+
v = v === true || v === "true";
|
|
189
206
|
break;
|
|
190
|
-
case
|
|
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
|
|
212
|
+
case "multiselect": {
|
|
196
213
|
const opts = optionValues(param);
|
|
197
|
-
const arr = Array.isArray(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
|
|
218
|
+
case "string":
|
|
202
219
|
default:
|
|
203
|
-
v = v == null ?
|
|
220
|
+
v = v == null ? "" : String(v);
|
|
204
221
|
break;
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
if (param.required) {
|
|
208
|
-
const empty =
|
|
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
|
-
* @
|
|
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
|
|
122
|
-
// re-registers the service server-side,
|
|
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
|
|
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
|