@larc-iu/plaid-client 0.2.0-alpha.7 → 0.2.0-alpha.9

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
@@ -5,28 +5,53 @@
5
5
  * Flat scalar keys — the query engine matches flat metadata well, nested
6
6
  * objects poorly.
7
7
  *
8
- * Three states (the PRESENCE of the `prov` key, not its value, is the
9
- * discriminator; absent keys mean "human"):
8
+ * Two axes. ORIGIN is the `prov` key: absent means a trusted person (a
9
+ * verifier) made it; 'inferred' means an algorithm or service did;
10
+ * 'contributed' means a person whose work the project reviews (a
11
+ * contributor) did. TRUST is `provConfirmed`: true once a verifier vouched
12
+ * for the value. Together they give four states:
10
13
  *
11
- * human — no prov keys; a person made it.
14
+ * human — no prov keys; a verifier made it.
12
15
  * machine — { prov: 'inferred', provSource: '<producer>' };
13
- * an algorithm/service made it, no human has vouched.
14
- * verified — same + { provConfirmed: true }; machine-made and a
15
- * human confirmed (or edited) it. provSource stays so
16
- * machine origin remains traceable.
16
+ * an algorithm/service made it, nobody has vouched.
17
+ * contributed — { prov: 'contributed', provSource: 'user:<id>' };
18
+ * a contributor made it, no verifier has vouched.
19
+ * verified — either origin + { provConfirmed: true }; a verifier
20
+ * confirmed (or edited) it. prov/provSource stay so
21
+ * the origin remains traceable.
17
22
  *
18
23
  * The write contract every machine writer must follow:
19
24
  * 1. Machine writers may freely replace MACHINE (unverified) material.
20
- * 2. Machine writers must never modify or delete human or verified
21
- * material unless explicitly told to overwrite (an explicit, per-run,
22
- * user-facing opt-in — for services, a declared boolean `overwrite`
23
- * parameter).
24
- * 3. Any human edit of a machine annotation verifies it: the edit also
25
- * stamps { provConfirmed: true } (see verifyOnEdit).
25
+ * 2. Machine writers must never modify or delete human, contributed or
26
+ * verified material unless explicitly told to overwrite (an explicit,
27
+ * per-run, user-facing opt-in — for services, a declared boolean
28
+ * `overwrite` parameter). A contributor's work is a person's work.
29
+ * 3. A verifier's edit of machine or contributed material verifies it:
30
+ * the edit also stamps { provConfirmed: true } (see verifyOnEdit).
31
+ * A contributor's edit of anything marks it contributed: the edit
32
+ * merges stampContributed(userId) and drops any earlier confirmation
33
+ * (see contributeOnEdit). Who is a contributor is the PROJECT's call,
34
+ * recorded once for every app (see "Review" below); a service running
35
+ * as a contributor should stamp likewise.
36
+ *
37
+ * REVIEW: which people are contributors. A project records it under the
38
+ * reserved `plaid` config namespace, so every app that writes on a person's
39
+ * behalf reads the same answer:
40
+ *
41
+ * config.plaid.review = { users: [userId, ...], roles: ['writer', ...] }
42
+ *
43
+ * `users` names people whose work is reviewed whatever their role; `roles`
44
+ * names whole project roles ('reader' | 'writer' | 'maintainer'; an admin
45
+ * without an explicit role counts as a maintainer). Either may be absent.
46
+ * Nothing here grants or denies access: the ACL lists stay the permission
47
+ * model, and this only says whose work needs a verifier's look. Apps expose
48
+ * it as a per-member mark on their access screens (readReview, isReviewed,
49
+ * withReviewedUser) and derive the writer's policy from it (writerPolicy).
26
50
  *
27
51
  * Producer naming: 'service:<serviceId>' for services (use serviceSource),
28
- * 'rule:<name>' for built-in rule algorithms, app-specific ids like
29
- * 'gloss:doc-frequency' or 'flex-import' otherwise.
52
+ * 'rule:<name>' for built-in rule algorithms, 'user:<userId>' for a
53
+ * contributor (use userSource), app-specific ids like 'gloss:doc-frequency'
54
+ * or 'flex-import' otherwise.
30
55
  *
31
56
  * PREDICTION EXTRAS. A producer may also record how confident it was and
32
57
  * what else it considered, in two reserved slots split along the
@@ -68,14 +93,17 @@ export const PROV = Object.freeze({
68
93
  probKey: 'provProb',
69
94
  /** Open map of producer extras (alternatives, model version, raw scores). */
70
95
  detailKey: 'provDetail',
71
- /** The (currently only) value of `prov`. Presence, not value, decides. */
96
+ /** `prov` for machine-made material. Any value but CONTRIBUTED reads as machine. */
72
97
  INFERRED: 'inferred',
98
+ /** `prov` for a contributor's material. */
99
+ CONTRIBUTED: 'contributed',
73
100
  });
74
101
 
75
- /** The three provenance states returned by provState. */
102
+ /** The four provenance states returned by provState. */
76
103
  export const PROV_STATES = Object.freeze({
77
104
  HUMAN: 'human',
78
105
  MACHINE: 'machine',
106
+ CONTRIBUTED: 'contributed',
79
107
  VERIFIED: 'verified',
80
108
  });
81
109
 
@@ -116,35 +144,228 @@ export const confirmedInferred = (source, extras = {}) => ({
116
144
  */
117
145
  export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
118
146
 
147
+ /**
148
+ * The metadata fragment a contributor's work carries:
149
+ * { prov: 'contributed', provSource: 'user:<userId>' }.
150
+ * @param {string} userId
151
+ */
152
+ export const stampContributed = (userId) => ({
153
+ [PROV.key]: PROV.CONTRIBUTED,
154
+ [PROV.sourceKey]: userSource(userId),
155
+ });
156
+
119
157
  /**
120
158
  * Classify an entity's metadata into one of PROV_STATES.
121
159
  * @param {Object|null|undefined} metadata
122
- * @returns {'human'|'machine'|'verified'}
160
+ * @returns {'human'|'machine'|'contributed'|'verified'}
123
161
  */
124
162
  export const provState = (metadata) => {
125
163
  if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
126
- return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
164
+ if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
165
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
127
166
  };
128
167
 
129
168
  /**
130
- * Whether this entity is machine-made and not yet human-verified — the
131
- * material that still needs review, that machine writers may replace, and
132
- * that a human confirmation gesture acts on. Complement of isProtected.
169
+ * Where an entity came from, confirmed or not: null for a verifier's own
170
+ * work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
171
+ * What a verified entity's tooltip needs, since provState folds both
172
+ * origins into 'verified'.
173
+ * @returns {null|'inferred'|'contributed'}
174
+ */
175
+ export const provOrigin = (metadata) => {
176
+ if (!metadata || metadata[PROV.key] == null) return null;
177
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
178
+ };
179
+
180
+ /**
181
+ * Whether this entity is machine-made and not yet verified — the material
182
+ * machine writers may replace. Complement of isProtected.
133
183
  */
134
184
  export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
135
185
 
136
186
  /**
137
187
  * Whether a machine writer must leave this entity alone (write-contract
138
- * rule 2): true for human-made and human-verified material.
188
+ * rule 2): true for human-made, contributed and verified material.
139
189
  */
140
190
  export const isProtected = (metadata) => !isMachine(metadata);
141
191
 
142
192
  /**
143
- * The metadata fragment a HUMAN edit of this entity should merge in
144
- * (write-contract rule 3): PROV_CONFIRMED when the entity is machine-made
145
- * and unverified, else null (nothing to do).
193
+ * Whether a verifier still has to look at this entity: machine-made or
194
+ * contributed, and not yet confirmed. What review UIs mark and sweep, and
195
+ * what a verifier's confirmation gesture acts on.
146
196
  */
147
- export const verifyOnEdit = (metadata) => (isMachine(metadata) ? PROV_CONFIRMED : null);
197
+ export const needsReview = (metadata) => {
198
+ const s = provState(metadata);
199
+ return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
200
+ };
201
+
202
+ /**
203
+ * The metadata fragment a VERIFIER's edit of this entity should merge in
204
+ * (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
205
+ * else null (nothing to do).
206
+ */
207
+ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
208
+
209
+ /**
210
+ * The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
211
+ * (write-contract rule 3): the contributed stamp, plus provConfirmed: null
212
+ * so an earlier confirmation is dropped (a patch deletes null-valued keys;
213
+ * see mergeMetadata for a full replace). Prediction extras a machine
214
+ * recorded stay, as history.
215
+ * @param {Object|null|undefined} metadata - unused today; the fragment is
216
+ * the same whatever the entity was, and the parameter keeps the shape of
217
+ * verifyOnEdit so callers can swap one for the other
218
+ * @param {string} userId
219
+ */
220
+ // eslint-disable-next-line no-unused-vars
221
+ export const contributeOnEdit = (metadata, userId) => ({
222
+ ...stampContributed(userId),
223
+ [PROV.confirmedKey]: null,
224
+ });
225
+
226
+ /**
227
+ * Merge a metadata fragment the way the server's PATCH does — a null value
228
+ * deletes the key — for callers that keep a local copy or send a full
229
+ * replacement (setMetadata). Returns a new object.
230
+ */
231
+ export const mergeMetadata = (metadata, fragment) => {
232
+ const out = { ...(metadata || {}) };
233
+ for (const [k, v] of Object.entries(fragment || {})) {
234
+ if (v === null) delete out[k];
235
+ else out[k] = v;
236
+ }
237
+ return out;
238
+ };
148
239
 
149
240
  /** Canonical provSource for a service: 'service:<serviceId>'. */
150
241
  export const serviceSource = (serviceId) => `service:${serviceId}`;
242
+
243
+ /** Canonical provSource for a contributor: 'user:<userId>'. */
244
+ export const userSource = (userId) => `user:${userId}`;
245
+
246
+ // ---- review: whose work is reviewed (a project-config norm) ----
247
+
248
+ /** The config key, under the `plaid` namespace, holding the review lists. */
249
+ export const REVIEW_KEY = 'review';
250
+
251
+ /** The project roles a review list may name. */
252
+ export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
253
+
254
+ /**
255
+ * The project's review lists, normalized: { users: string[], roles: string[] }.
256
+ * @param {Object|null|undefined} config - a project's `config`
257
+ */
258
+ export const readReview = (config) => {
259
+ const raw = config?.plaid?.[REVIEW_KEY];
260
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
261
+ return { users: list(raw?.users), roles: list(raw?.roles) };
262
+ };
263
+
264
+ /**
265
+ * A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
266
+ * 'reader' | null. An admin with no explicit entry is a maintainer, as the
267
+ * permission model treats them.
268
+ * @param {Object} project - with `maintainers`, `writers`, `readers`
269
+ * @param {string} userId
270
+ * @param {Object} [opts]
271
+ * @param {boolean} [opts.isAdmin]
272
+ */
273
+ export const projectRole = (project, userId, { isAdmin = false } = {}) => {
274
+ const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
275
+ if (inList(project?.maintainers)) return 'maintainer';
276
+ if (inList(project?.writers)) return 'writer';
277
+ if (inList(project?.readers)) return 'reader';
278
+ return isAdmin ? 'maintainer' : null;
279
+ };
280
+
281
+ /**
282
+ * Whether this person's work is reviewed in this project: named in
283
+ * review.users, or holding a role named in review.roles.
284
+ */
285
+ export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
286
+ if (!project || userId == null) return false;
287
+ const { users, roles } = readReview(project.config);
288
+ if (users.includes(userId)) return true;
289
+ const role = projectRole(project, userId, { isAdmin });
290
+ return role != null && roles.includes(role);
291
+ };
292
+
293
+ /**
294
+ * The review lists with one person added to or removed from `users`
295
+ * (`roles` untouched). Pure; write the result with
296
+ * projects.setConfig(id, 'plaid', REVIEW_KEY, next).
297
+ */
298
+ export const withReviewedUser = (review, userId, reviewed) => {
299
+ const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
300
+ const next = users.filter((u) => u !== userId);
301
+ if (reviewed) next.push(userId);
302
+ return { users: next, roles };
303
+ };
304
+
305
+ // ---- the writer's policy: what a person's writes carry ----
306
+
307
+ /**
308
+ * What one writer's writes carry and what their review gestures act on,
309
+ * given who they are: `contributorId` is their user id when their work is
310
+ * reviewed (isReviewed), else null for a verifier. Apps derive this once per
311
+ * document and route every human write through it, so the convention's
312
+ * rule 3 has one implementation.
313
+ *
314
+ * @param {string|null} contributorId
315
+ * @returns {{
316
+ * contributorId: string|null,
317
+ * isContributor: boolean,
318
+ * createStamp: Object|null,
319
+ * editStamp: (metadata: Object) => Object|null,
320
+ * confirmStamp: (metadata: Object) => Object|null,
321
+ * adoptStamp: (source: string, detail?: Object) => Object,
322
+ * reviewable: (metadata: Object) => boolean,
323
+ * reviewableState: (state: string) => boolean,
324
+ * }}
325
+ */
326
+ export const writerPolicy = (contributorId = null) => {
327
+ const id = contributorId || null;
328
+ const contributor = id != null;
329
+ // Material this writer's review gestures act on: a verifier reviews
330
+ // machine and contributed material; a contributor reviews machine
331
+ // proposals only, since their own vouching is itself a contribution.
332
+ const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
333
+ const reviewableState = (state) =>
334
+ contributor
335
+ ? state === PROV_STATES.MACHINE
336
+ : state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
337
+ return Object.freeze({
338
+ contributorId: id,
339
+ isContributor: contributor,
340
+ /** The metadata a NEW entity carries: null for a verifier. */
341
+ createStamp: contributor ? stampContributed(id) : null,
342
+ /**
343
+ * The fragment an EDIT merges over the entity's metadata, or null when
344
+ * there is nothing to merge: a verifier confirms what needs review; a
345
+ * contributor's edit marks the entity contributed, dropping any earlier
346
+ * confirmation.
347
+ */
348
+ editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
349
+ /**
350
+ * The fragment an explicit confirm gesture merges, or null when there is
351
+ * nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
352
+ * contributed stamp for a contributor accepting a machine proposal.
353
+ */
354
+ confirmStamp: (metadata) => {
355
+ if (!reviewable(metadata)) return null;
356
+ return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
357
+ },
358
+ /**
359
+ * What an adopted suggestion is written with (a guess or a picked value
360
+ * into an empty cell): born-verified with the suggestion's producer as
361
+ * its source for a verifier; contributed for a contributor, with the
362
+ * producer kept as provDetail.guess. `detail` is the prediction extras.
363
+ */
364
+ adoptStamp: (source, detail) =>
365
+ contributor
366
+ ? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
367
+ : confirmedInferred(source, { detail }),
368
+ reviewable,
369
+ reviewableState,
370
+ });
371
+ };
package/src/services.js CHANGED
@@ -118,24 +118,58 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
118
118
  const qs = params.toString();
119
119
  const channelPath = `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs ? `?${qs}` : ''}`;
120
120
 
121
- // The channel only carries `connected` (ignored) and `service_request` events.
121
+ // The requests this channel is serving, by id, each flagged once a
122
+ // `service_cancel` arrives. The handler reads it as `responseHelper.cancelled`;
123
+ // whether to stop is the handler's decision (a write under way should
124
+ // finish), and the request still ends with whatever it reports.
125
+ const cancelled = new Map();
126
+
127
+ // Beyond `connected` (ignored), the channel carries `service_request` and
128
+ // `service_cancel` events.
122
129
  const onChannelEvent = (eventType, payload) => {
123
130
  if (!isRunning) return true;
124
- if (eventType !== 'service_request' || !payload) return;
131
+ if (!payload) return;
132
+ if (eventType === 'service_cancel') {
133
+ if (cancelled.has(payload.requestId)) cancelled.set(payload.requestId, true);
134
+ return;
135
+ }
136
+ if (eventType !== 'service_request') return;
125
137
  const requestId = payload.requestId;
126
138
  if (!requestId) return;
139
+ cancelled.set(requestId, false);
140
+ const finished = () => cancelled.delete(requestId);
141
+
142
+ // Every service is told who asked (`requesterId`), beside the payload
143
+ // when that is a plain object, and on the helper regardless.
144
+ const requesterId = payload.requesterId || null;
145
+ const data =
146
+ requesterId && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
147
+ ? { ...payload.data, requesterId }
148
+ : payload.data;
127
149
 
128
150
  const responseHelper = {
129
- progress: (percent, msg) =>
130
- reportEvent(requestId, { status: 'progress', progress: { percent, message: msg } }),
131
- complete: (data) =>
132
- reportEvent(requestId, { status: 'completed', data }),
133
- error: (error) =>
134
- reportEvent(requestId, { status: 'error', data: { error: error?.message || error } }),
151
+ requestId,
152
+ requesterId,
153
+ // True once the requester asked for the request to stop.
154
+ get cancelled() {
155
+ return cancelled.get(requestId) === true;
156
+ },
157
+ // Extra fields ride in the progress payload (a chat service sends the
158
+ // reply text so far as `text`).
159
+ progress: (percent, msg, extra) =>
160
+ reportEvent(requestId, { status: 'progress', progress: { percent, message: msg, ...extra } }),
161
+ complete: (data) => {
162
+ finished();
163
+ return reportEvent(requestId, { status: 'completed', data });
164
+ },
165
+ error: (error) => {
166
+ finished();
167
+ return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
168
+ },
135
169
  };
136
170
 
137
171
  try {
138
- onServiceRequest(payload.data, responseHelper);
172
+ onServiceRequest(data, responseHelper);
139
173
  } catch (error) {
140
174
  responseHelper.error(error?.message || error);
141
175
  }
@@ -172,6 +206,13 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
172
206
  * response (no broadcast). Rejects if no service is connected (503), if the
173
207
  * service reports an error, or on timeout.
174
208
  *
209
+ * The request outlives this call: after a timeout, an abort, or a dropped
210
+ * connection the service goes on, and `attachServiceRequest` collects the
211
+ * result given the request id, which `opts.onAccepted` receives as soon as
212
+ * the server has taken the request. Pass `opts.requestId` (a UUID you mint)
213
+ * to know the id before submitting; submitting an id that names a request
214
+ * you already made rejoins it instead of starting another.
215
+ *
175
216
  * @param {Object} client - PlaidClient instance
176
217
  * @param {string} projectId - Project UUID
177
218
  * @param {string} serviceId - Service ID to request
@@ -179,9 +220,12 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
179
220
  * @param {number} [timeout=10000] - Timeout in ms
180
221
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
181
222
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
223
+ * @param {Object} [opts]
224
+ * @param {string} [opts.requestId] - A client-minted request id (UUID)
225
+ * @param {function} [opts.onAccepted] - Called with the request id once the server has it
182
226
  * @returns {Promise<any>} The service's result
183
227
  */
184
- export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal) {
228
+ export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
185
229
  // Propagate an open logical operation (client.beginOperation) to the
186
230
  // service: its writes then fold under the requester's audit-log entry
187
231
  // (the Python BaseService adopts the id around process_request). Only for a
@@ -192,6 +236,73 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
192
236
  group && data && typeof data === 'object' && !Array.isArray(data)
193
237
  ? { ...data, operationGroup: { id: group.id, message: group.message } }
194
238
  : data;
239
+ const qs = opts.requestId ? `?request-id=${encodeURIComponent(opts.requestId)}` : '';
240
+ return streamServiceRequest(
241
+ client,
242
+ {
243
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
244
+ method: 'POST',
245
+ body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
246
+ onStatus: (response) =>
247
+ response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
248
+ what: 'Service request',
249
+ },
250
+ timeout,
251
+ onProgress,
252
+ signal,
253
+ opts.onAccepted,
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Rejoin a service request made earlier (by this user) and await its result:
259
+ * the latest progress is replayed, then the result comes, or at once if the
260
+ * request already finished. Rejects with an error whose `status` is 404 when
261
+ * the request is unknown or expired (the server keeps a finished request's
262
+ * result for a while, not forever).
263
+ *
264
+ * @param {Object} client - PlaidClient instance
265
+ * @param {string} projectId - Project UUID
266
+ * @param {string} requestId - The request id (from `onAccepted` or your own)
267
+ * @param {number} [timeout=10000] - Timeout in ms
268
+ * @param {function} [onProgress] - Called with each progress payload {percent, message}
269
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
270
+ * @returns {Promise<any>} The service's result
271
+ */
272
+ export function attachServiceRequest(client, projectId, requestId, timeout = 10000, onProgress, signal) {
273
+ return streamServiceRequest(
274
+ client,
275
+ {
276
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
277
+ method: 'GET',
278
+ what: 'Attach to service request',
279
+ },
280
+ timeout,
281
+ onProgress,
282
+ signal,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Ask the service to stop a request made earlier (by this user). The request
288
+ * still ends with whatever the service then reports, on the stream of whoever
289
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
290
+ *
291
+ * @param {Object} client - PlaidClient instance
292
+ * @param {string} projectId - Project UUID
293
+ * @param {string} requestId - The request id
294
+ * @returns {Promise<void>}
295
+ */
296
+ export function cancelServiceRequest(client, projectId, requestId) {
297
+ return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
298
+ }
299
+
300
+ /**
301
+ * Open a request stream (submit or attach) and read it to its terminal
302
+ * event: `accepted` names the request, `progress` events go to `onProgress`,
303
+ * and `result` / `error` settle the promise.
304
+ */
305
+ function streamServiceRequest(client, { url, method, body, onStatus, what }, timeout, onProgress, signal, onAccepted) {
195
306
  return new Promise((resolve, reject) => {
196
307
  const abortController = new AbortController();
197
308
  let settled = false;
@@ -203,14 +314,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
203
314
  fn(arg);
204
315
  };
205
316
  const timer = setTimeout(
206
- () => finish(reject, new Error(`Service request timed out after ${timeout}ms`)),
317
+ () => finish(reject, new Error(`${what} timed out after ${timeout}ms`)),
207
318
  timeout,
208
319
  );
209
320
 
210
321
  // An external signal stops waiting on a long request (a UI's Stop button).
211
322
  // 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.
323
+ // failure. The service is not told here: it goes on, and its result can
324
+ // still be collected by request id (see cancelServiceRequest to stop it).
214
325
  const stop = () => {
215
326
  const err = new Error('The service request was stopped');
216
327
  err.name = 'AbortError';
@@ -227,30 +338,30 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
227
338
  (async () => {
228
339
  let response;
229
340
  try {
230
- response = await fetch(
231
- `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests`,
232
- {
233
- method: 'POST',
234
- headers: {
235
- 'Authorization': `Bearer ${client.token}`,
236
- 'Content-Type': 'application/json',
237
- 'Accept': 'text/event-stream',
238
- },
239
- body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
240
- signal: abortController.signal,
341
+ response = await fetch(url, {
342
+ method,
343
+ headers: {
344
+ 'Authorization': `Bearer ${client.token}`,
345
+ 'Content-Type': 'application/json',
346
+ 'Accept': 'text/event-stream',
241
347
  },
242
- );
348
+ ...(body === undefined ? {} : { body }),
349
+ signal: abortController.signal,
350
+ });
243
351
  } catch (error) {
244
- if (error.name !== 'AbortError') finish(reject, new Error(`Failed to submit service request: ${error.message}`));
352
+ if (error.name !== 'AbortError') finish(reject, new Error(`${what} could not be sent: ${error.message}`));
245
353
  return;
246
354
  }
247
355
 
248
- if (response.status === 503) {
249
- finish(reject, new Error(`No live service '${serviceId}' on this project`));
356
+ const special = onStatus ? onStatus(response) : null;
357
+ if (special) {
358
+ finish(reject, special);
250
359
  return;
251
360
  }
252
361
  if (!response.ok) {
253
- finish(reject, new Error(`Service request failed: HTTP ${response.status} ${response.statusText}`));
362
+ const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
363
+ err.status = response.status;
364
+ finish(reject, err);
254
365
  return;
255
366
  }
256
367
 
@@ -275,13 +386,15 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
275
386
  dataLine = line.slice(6);
276
387
  } else if (line === '' && eventType && dataLine) {
277
388
  const payload = transformResponse(JSON.parse(dataLine));
278
- if (eventType === 'progress') {
389
+ if (eventType === 'accepted') {
390
+ if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
391
+ } else if (eventType === 'progress') {
279
392
  if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
280
393
  } else if (eventType === 'result') {
281
394
  finish(resolve, payload.data);
282
395
  return;
283
396
  } else if (eventType === 'error') {
284
- finish(reject, new Error(payload?.error || 'Service request failed'));
397
+ finish(reject, new Error(payload?.error || `${what} failed`));
285
398
  return;
286
399
  }
287
400
  eventType = '';
@@ -291,7 +404,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
291
404
  }
292
405
  finish(reject, new Error('Service closed the connection without a result'));
293
406
  } catch (error) {
294
- if (error.name !== 'AbortError') finish(reject, new Error(`Service request stream error: ${error.message}`));
407
+ if (error.name !== 'AbortError') finish(reject, new Error(`${what} stream error: ${error.message}`));
295
408
  }
296
409
  })();
297
410
  });