@pingroom/sdk 0.7.1 → 0.8.0
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/README.md +144 -12
- package/dist/client.d.ts +67 -18
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +275 -111
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +8 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -3
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts +12 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +40 -17
- package/dist/http.js.map +1 -1
- package/dist/internal/async.d.ts +2 -0
- package/dist/internal/async.d.ts.map +1 -1
- package/dist/internal/async.js +6 -0
- package/dist/internal/async.js.map +1 -1
- package/dist/internal/guards.d.ts +9 -0
- package/dist/internal/guards.d.ts.map +1 -1
- package/dist/internal/guards.js +21 -0
- package/dist/internal/guards.js.map +1 -1
- package/dist/internal/retry.d.ts +58 -0
- package/dist/internal/retry.d.ts.map +1 -0
- package/dist/internal/retry.js +108 -0
- package/dist/internal/retry.js.map +1 -0
- package/dist/internal/url.d.ts +9 -0
- package/dist/internal/url.d.ts.map +1 -1
- package/dist/internal/url.js +25 -0
- package/dist/internal/url.js.map +1 -1
- package/dist/linkPing.d.ts.map +1 -1
- package/dist/linkPing.js +6 -2
- package/dist/linkPing.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +10 -2
- package/dist/mcp.js.map +1 -1
- package/dist/types.d.ts +182 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +20 -3
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +14 -9
- package/dist/webhooks.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { PingRoomActivationIncompleteError, PingRoomError } from './errors.js';
|
|
2
2
|
import { HttpClient } from './http.js';
|
|
3
|
-
import { combineSignals, sleep } from './internal/async.js';
|
|
4
|
-
import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
|
|
3
|
+
import { combineSignals, sleep, throwIfAborted } from './internal/async.js';
|
|
4
|
+
import { assertActionNumber, assertMaxLength, assertStructuredData, MAX_PING_TITLE_LENGTH, MAX_PUBLIC_PING_MESSAGE_LENGTH, normalizeIdempotencyKey, normalizeRedeemCode, requireNonEmpty, requirePresentString, } from './internal/guards.js';
|
|
5
|
+
import { isTransientError, pollUntilSettled, transientDelayMs } from './internal/retry.js';
|
|
5
6
|
import { linkPing } from './linkPing.js';
|
|
6
7
|
import { locationPing } from './locationPing.js';
|
|
7
8
|
import { McpClient } from './mcp.js';
|
|
8
9
|
const DEFAULT_BASE_URL = 'https://api.pingroom.io';
|
|
10
|
+
/** Node's util.inspect hook, named through the global registry so no import is needed. */
|
|
11
|
+
const INSPECT = Symbol.for('nodejs.util.inspect.custom');
|
|
9
12
|
/** Server cap per uploaded file; a larger body is a 413 the server answers before reading it. */
|
|
10
13
|
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
|
|
11
14
|
const MAX_TRIGGER_ATTACHMENTS = 4;
|
|
@@ -15,14 +18,70 @@ const MAX_QUICK_ACTION_PAGES = 4;
|
|
|
15
18
|
const LONG_POLL_BUFFER_MS = 10_000;
|
|
16
19
|
const DEFAULT_WAIT_SECONDS = 20;
|
|
17
20
|
const DEFAULT_INBOX_ACTIVATION_TIMEOUT_MS = 120_000;
|
|
18
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Minimum spacing between the starts of two handoff-wait polls. The route is
|
|
23
|
+
* throttled at 30 requests a minute per human; a short or zero `timeout`
|
|
24
|
+
* would otherwise let one loop exhaust it.
|
|
25
|
+
*/
|
|
26
|
+
const MIN_HANDOFF_POLL_INTERVAL_MS = 2100;
|
|
27
|
+
/**
|
|
28
|
+
* Minimum spacing between the starts of two approval or question wait polls.
|
|
29
|
+
* The server normally holds each one, but a proxy or a zero hold that answers
|
|
30
|
+
* `pending` at once would otherwise spin the loop and keep the human's shared
|
|
31
|
+
* 30-per-minute wait bucket exhausted. Same floor as the CLI.
|
|
32
|
+
*/
|
|
33
|
+
const MIN_WAIT_POLL_INTERVAL_MS = 1000;
|
|
19
34
|
function envBaseUrl() {
|
|
20
35
|
const proc = globalThis.process;
|
|
21
36
|
return proc?.env?.['PINGROOM_API_URL'] || proc?.env?.['PINGROOM_BASE_URL'] || undefined;
|
|
22
37
|
}
|
|
23
38
|
function enc(segment) {
|
|
39
|
+
// encodeURIComponent leaves `.` and `..` alone, and URL parsing then
|
|
40
|
+
// resolves them as dot segments: `live.get('AB12', '..')` would request the
|
|
41
|
+
// room itself. No id is empty or a dot segment, so refuse them.
|
|
42
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
43
|
+
throw new PingRoomError('A path parameter may not be empty, `.` or `..`.', { code: 'invalid_request' });
|
|
44
|
+
}
|
|
24
45
|
return encodeURIComponent(segment);
|
|
25
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Lift the SDK-only `idempotencyKey` out of an input and into the
|
|
49
|
+
* `Idempotency-Key` header the server reads. The server trims the key and
|
|
50
|
+
* accepts 1–255 characters; as a header it must also be printable ASCII.
|
|
51
|
+
*/
|
|
52
|
+
function splitIdempotencyKey(input) {
|
|
53
|
+
const { idempotencyKey, ...body } = input;
|
|
54
|
+
if (idempotencyKey === undefined) {
|
|
55
|
+
return { body };
|
|
56
|
+
}
|
|
57
|
+
return { body, headers: { 'Idempotency-Key': normalizeIdempotencyKey(idempotencyKey) } };
|
|
58
|
+
}
|
|
59
|
+
/** `onRetry`, when given, must be callable: a TypeError mid-outage would hide the real failure. */
|
|
60
|
+
function assertOnRetry(onRetry) {
|
|
61
|
+
if (onRetry !== undefined && typeof onRetry !== 'function') {
|
|
62
|
+
throw new PingRoomError('`onRetry` must be a function.', { code: 'invalid_request' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The per-slot checks shared by `updateMany()` and `updateLayout()`: a valid,
|
|
67
|
+
* present, unique action_number and an icon whenever the slot has a label.
|
|
68
|
+
* Two entries for one slot would let the last silently win, and the caller
|
|
69
|
+
* would never learn which of its definitions was stored.
|
|
70
|
+
*/
|
|
71
|
+
function assertActionSlots(actions) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const action of actions) {
|
|
74
|
+
assertActionNumber(action.action_number);
|
|
75
|
+
if (action.action_number === undefined || action.action_number === null) {
|
|
76
|
+
throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(action.action_number)) {
|
|
79
|
+
throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
|
|
80
|
+
}
|
|
81
|
+
seen.add(action.action_number);
|
|
82
|
+
assertQuickActionIcon(action);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
26
85
|
function dropUndefined(obj) {
|
|
27
86
|
const out = {};
|
|
28
87
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -219,10 +278,56 @@ function validateInboxWait(value, expectedId) {
|
|
|
219
278
|
}
|
|
220
279
|
return value;
|
|
221
280
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
281
|
+
/** UUIDs are case-insensitive; a caller may hold an id in either case. */
|
|
282
|
+
function sameId(received, expected) {
|
|
283
|
+
return typeof received === 'string' && received.toLowerCase() === expected.toLowerCase();
|
|
284
|
+
}
|
|
285
|
+
function invalidWaitResponse(subject, expectedId, value, stateField) {
|
|
286
|
+
throw new PingRoomError(`PingRoom returned an invalid ${subject} wait response.`, {
|
|
287
|
+
code: 'invalid_response',
|
|
288
|
+
body: {
|
|
289
|
+
expected_id: expectedId,
|
|
290
|
+
received_id: isRecord(value) && typeof value['id'] === 'string' ? value['id'] : null,
|
|
291
|
+
[stateField]: isRecord(value) && typeof value[stateField] === 'string' ? value[stateField] : null,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const APPROVAL_STATUSES = new Set(['pending', 'decided', 'expired', 'cancelled']);
|
|
296
|
+
const QUESTION_STATES = new Set(['pending', 'answered', 'expired', 'cancelled']);
|
|
297
|
+
const HANDOFF_STATES = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
|
|
298
|
+
/**
|
|
299
|
+
* A wait response is trusted as an outcome only when it is the approval that
|
|
300
|
+
* was asked about, in a known status. A captive portal's HTML, `{}`, or another
|
|
301
|
+
* approval must not read as "decided".
|
|
302
|
+
*/
|
|
303
|
+
function validateApprovalWait(value, approvalId) {
|
|
304
|
+
const status = isRecord(value) ? value['status'] : undefined;
|
|
305
|
+
if (!isRecord(value)
|
|
306
|
+
|| !sameId(value['id'], approvalId)
|
|
307
|
+
|| typeof status !== 'string'
|
|
308
|
+
|| !APPROVAL_STATUSES.has(status)
|
|
309
|
+
|| !isNullableString(value['decided_at'])
|
|
310
|
+
// A gate acts on `decision`; a decided approval without one, or a pending
|
|
311
|
+
// one already stamped decided, is not an outcome it can act on.
|
|
312
|
+
|| (status === 'decided' && !isNonEmptyString(value['decision']))
|
|
313
|
+
|| (status === 'pending' && value['decided_at'] !== null)) {
|
|
314
|
+
return invalidWaitResponse('approval', approvalId, value, 'status');
|
|
225
315
|
}
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
function validateQuestionWait(value, questionId) {
|
|
319
|
+
const state = isRecord(value) ? value['state'] : undefined;
|
|
320
|
+
if (!isRecord(value) || !sameId(value['id'], questionId) || typeof state !== 'string' || !QUESTION_STATES.has(state)) {
|
|
321
|
+
return invalidWaitResponse('question', questionId, value, 'state');
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
function validateHandoffWait(value, handoffId) {
|
|
326
|
+
const state = isRecord(value) ? value['state'] : undefined;
|
|
327
|
+
if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
|
|
328
|
+
return invalidWaitResponse('handoff', handoffId, value, 'state');
|
|
329
|
+
}
|
|
330
|
+
return value;
|
|
226
331
|
}
|
|
227
332
|
// --- namespaces -----------------------------------------------------------
|
|
228
333
|
/** Agent credential lifecycle (auth.md flow). */
|
|
@@ -290,10 +395,10 @@ class AuthApi {
|
|
|
290
395
|
const deadline = startedAt + lifetimeMs;
|
|
291
396
|
let transientFailures = 0;
|
|
292
397
|
while (Date.now() < deadline) {
|
|
293
|
-
|
|
398
|
+
throwIfAborted(options.signal);
|
|
294
399
|
try {
|
|
295
400
|
const status = await this.pairingStatus({ signal: options.signal });
|
|
296
|
-
|
|
401
|
+
throwIfAborted(options.signal);
|
|
297
402
|
transientFailures = 0;
|
|
298
403
|
if (status.status === 'active') {
|
|
299
404
|
if (typeof status.credential !== 'string'
|
|
@@ -356,11 +461,6 @@ class AuthApi {
|
|
|
356
461
|
code: 'pairing_expired',
|
|
357
462
|
});
|
|
358
463
|
}
|
|
359
|
-
throwIfPairingAborted(signal) {
|
|
360
|
-
if (signal?.aborted) {
|
|
361
|
-
throw signal.reason ?? new Error('aborted');
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
464
|
/** Rotate to a fresh credential (same scopes/handle). */
|
|
365
465
|
refresh() {
|
|
366
466
|
return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
|
|
@@ -369,6 +469,23 @@ class AuthApi {
|
|
|
369
469
|
revoke() {
|
|
370
470
|
return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
|
|
371
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* What this credential is connected to: the robot, the owner's public id and
|
|
474
|
+
* name, the home room and room memberships, and the room grant. Check
|
|
475
|
+
* `owner.id` and `handle` against the account and robot you approved before
|
|
476
|
+
* sending anything.
|
|
477
|
+
*/
|
|
478
|
+
connection() {
|
|
479
|
+
return this.http.request('GET', '/api/agent/connection');
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Disconnect this agent: revokes the calling credential and its refresh
|
|
483
|
+
* credentials. The client keeps the now-revoked token; clear it with
|
|
484
|
+
* `setToken(null)` or pair again.
|
|
485
|
+
*/
|
|
486
|
+
disconnect() {
|
|
487
|
+
return this.http.request('POST', '/api/agent/disconnect', { body: {} });
|
|
488
|
+
}
|
|
372
489
|
}
|
|
373
490
|
class RoomsApi {
|
|
374
491
|
http;
|
|
@@ -446,20 +563,7 @@ class ActionsApi {
|
|
|
446
563
|
if (actions.length > 16) {
|
|
447
564
|
throw new PingRoomError('A batch may contain at most 16 action slots.', { code: 'invalid_request' });
|
|
448
565
|
}
|
|
449
|
-
|
|
450
|
-
for (const action of actions) {
|
|
451
|
-
assertActionNumber(action.action_number);
|
|
452
|
-
if (action.action_number === undefined || action.action_number === null) {
|
|
453
|
-
throw new PingRoomError('`action_number` is required for each action.', { code: 'invalid_request' });
|
|
454
|
-
}
|
|
455
|
-
// Two entries for one slot would let the last silently win, and the
|
|
456
|
-
// caller would never learn which of its definitions was stored.
|
|
457
|
-
if (seen.has(action.action_number)) {
|
|
458
|
-
throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
|
|
459
|
-
}
|
|
460
|
-
seen.add(action.action_number);
|
|
461
|
-
assertQuickActionIcon(action);
|
|
462
|
-
}
|
|
566
|
+
assertActionSlots(actions);
|
|
463
567
|
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions`, {
|
|
464
568
|
body: { actions: actions.map((action) => dropUndefined({ ...action })) },
|
|
465
569
|
});
|
|
@@ -475,8 +579,10 @@ class ActionsApi {
|
|
|
475
579
|
trigger(inviteCode, actionNumber, input = {}) {
|
|
476
580
|
assertActionNumber(actionNumber);
|
|
477
581
|
assertTriggerInput(input);
|
|
582
|
+
const { body, headers } = splitIdempotencyKey(input);
|
|
478
583
|
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/actions/${actionNumber}/trigger`, {
|
|
479
|
-
body: dropUndefined({ ...
|
|
584
|
+
body: dropUndefined({ ...body }),
|
|
585
|
+
...(headers ? { headers } : {}),
|
|
480
586
|
});
|
|
481
587
|
}
|
|
482
588
|
/**
|
|
@@ -507,15 +613,7 @@ class ActionsApi {
|
|
|
507
613
|
code: 'invalid_request',
|
|
508
614
|
});
|
|
509
615
|
}
|
|
510
|
-
|
|
511
|
-
for (const action of input.actions) {
|
|
512
|
-
assertActionNumber(action.action_number);
|
|
513
|
-
if (seen.has(action.action_number)) {
|
|
514
|
-
throw new PingRoomError(`Duplicate action_number ${action.action_number}.`, { code: 'invalid_request' });
|
|
515
|
-
}
|
|
516
|
-
seen.add(action.action_number);
|
|
517
|
-
assertQuickActionIcon(action);
|
|
518
|
-
}
|
|
616
|
+
assertActionSlots(input.actions);
|
|
519
617
|
return this.http.request('PUT', `/api/agent/rooms/${enc(inviteCode)}/actions/layout`, {
|
|
520
618
|
body: {
|
|
521
619
|
base_action_ids: input.base_action_ids,
|
|
@@ -649,35 +747,39 @@ class NotificationsApi {
|
|
|
649
747
|
*/
|
|
650
748
|
async *listen(input = {}) {
|
|
651
749
|
let cursor = input.after;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
for (const n of head.notifications) {
|
|
656
|
-
yield n;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
const backoff = input.backoffMs ?? 1000;
|
|
750
|
+
let headRead = cursor !== undefined;
|
|
751
|
+
const backoffMs = input.backoffMs ?? 1000;
|
|
752
|
+
let failures = 0;
|
|
660
753
|
while (!input.signal?.aborted) {
|
|
661
754
|
let result;
|
|
662
755
|
try {
|
|
663
|
-
result =
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
756
|
+
result = headRead
|
|
757
|
+
? await this.wait({
|
|
758
|
+
after: cursor,
|
|
759
|
+
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
|
|
760
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
761
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
762
|
+
})
|
|
763
|
+
// No cursor yet: read the current head (an immediate, empty answer)
|
|
764
|
+
// so only pings that land after this call are delivered.
|
|
765
|
+
: await this.wait({ timeout: 0, ...(input.signal ? { signal: input.signal } : {}) });
|
|
669
766
|
}
|
|
670
767
|
catch (err) {
|
|
671
768
|
if (input.signal?.aborted) {
|
|
672
769
|
return;
|
|
673
770
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
771
|
+
// A revoked credential or a bad request never heals; only network
|
|
772
|
+
// failures, deadlines, 429 and 5xx are retried.
|
|
773
|
+
if (!isTransientError(err)) {
|
|
774
|
+
throw err;
|
|
678
775
|
}
|
|
679
|
-
|
|
776
|
+
failures += 1;
|
|
777
|
+
input.onError?.(err);
|
|
778
|
+
await sleep(transientDelayMs(err, failures, backoffMs), input.signal);
|
|
779
|
+
continue;
|
|
680
780
|
}
|
|
781
|
+
failures = 0;
|
|
782
|
+
headRead = true;
|
|
681
783
|
cursor = result.cursor ?? cursor;
|
|
682
784
|
for (const n of result.notifications) {
|
|
683
785
|
yield n;
|
|
@@ -700,7 +802,11 @@ class AgentsApi {
|
|
|
700
802
|
ping(handle, ping) {
|
|
701
803
|
requireNonEmpty(handle, 'handle');
|
|
702
804
|
assertPing(ping);
|
|
703
|
-
|
|
805
|
+
const { body, headers } = splitIdempotencyKey(ping);
|
|
806
|
+
return this.http.request('POST', `/api/agent/agents/${enc(handle)}/ping`, {
|
|
807
|
+
body: dropUndefined({ ...body }),
|
|
808
|
+
...(headers ? { headers } : {}),
|
|
809
|
+
});
|
|
704
810
|
}
|
|
705
811
|
}
|
|
706
812
|
class ApprovalsApi {
|
|
@@ -712,8 +818,10 @@ class ApprovalsApi {
|
|
|
712
818
|
request(inviteCode, input) {
|
|
713
819
|
requireNonEmpty(input.question, 'question');
|
|
714
820
|
assertStructuredData(input.data);
|
|
821
|
+
const { body, headers } = splitIdempotencyKey(input);
|
|
715
822
|
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/approvals`, {
|
|
716
|
-
body: dropUndefined({ ...
|
|
823
|
+
body: dropUndefined({ ...body }),
|
|
824
|
+
...(headers ? { headers } : {}),
|
|
717
825
|
});
|
|
718
826
|
}
|
|
719
827
|
get(approvalId) {
|
|
@@ -727,20 +835,29 @@ class ApprovalsApi {
|
|
|
727
835
|
...(input.signal ? { signal: input.signal } : {}),
|
|
728
836
|
});
|
|
729
837
|
}
|
|
730
|
-
/**
|
|
838
|
+
/**
|
|
839
|
+
* Block until the human decides (or the request expires or is cancelled),
|
|
840
|
+
* looping wait() under the hood, with polls started at least 1 s apart. A
|
|
841
|
+
* response that is not this approval in a known status rejects with
|
|
842
|
+
* `invalid_response`. Network failures, 429 and 5xx are retried with backoff
|
|
843
|
+
* (honouring `retryAfter`), and reported to `onRetry`, until `signal` aborts
|
|
844
|
+
* or the outage outlasts the approval's `expires_at` by 5 minutes (15 minutes
|
|
845
|
+
* of failures while no `expires_at` has been read); that rejects with
|
|
846
|
+
* `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
847
|
+
*/
|
|
731
848
|
async waitForDecision(approvalId, input = {}) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
849
|
+
requireNonEmpty(approvalId, 'approvalId');
|
|
850
|
+
assertOnRetry(input.onRetry);
|
|
851
|
+
return pollUntilSettled({
|
|
852
|
+
poll: () => this.wait(approvalId, input.signal ? { signal: input.signal } : {}),
|
|
853
|
+
validate: (value) => validateApprovalWait(value, approvalId),
|
|
854
|
+
settled: (approval) => approval.status !== 'pending',
|
|
855
|
+
expiresAt: (approval) => approval.expires_at,
|
|
856
|
+
what: `approval ${approvalId}`,
|
|
857
|
+
minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
|
|
858
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
859
|
+
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
860
|
+
});
|
|
744
861
|
}
|
|
745
862
|
}
|
|
746
863
|
/**
|
|
@@ -754,18 +871,19 @@ class QuestionsApi {
|
|
|
754
871
|
constructor(http) {
|
|
755
872
|
this.http = http;
|
|
756
873
|
}
|
|
757
|
-
/**
|
|
874
|
+
/**
|
|
875
|
+
* Ask a Question in a room; returns the pending Question (id + computed
|
|
876
|
+
* expiry). Pass `resolution_policy: 'all'` to poll the room: everyone asked
|
|
877
|
+
* votes once, and `results` carries the tally on every read.
|
|
878
|
+
*/
|
|
758
879
|
ask(inviteCode, input) {
|
|
759
880
|
requireNonEmpty(input.prompt, 'prompt');
|
|
760
881
|
assertStructuredData(input.data);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
requireNonEmpty(idempotencyKey, 'idempotencyKey');
|
|
764
|
-
assertMaxLength(idempotencyKey, 255, 'idempotencyKey');
|
|
765
|
-
}
|
|
882
|
+
assertPollShape(input);
|
|
883
|
+
const { body, headers } = splitIdempotencyKey(input);
|
|
766
884
|
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/questions`, {
|
|
767
885
|
body: dropUndefined(body),
|
|
768
|
-
...(
|
|
886
|
+
...(headers ? { headers } : {}),
|
|
769
887
|
});
|
|
770
888
|
}
|
|
771
889
|
/** Fetch a Question's current state. */
|
|
@@ -783,20 +901,29 @@ class QuestionsApi {
|
|
|
783
901
|
/**
|
|
784
902
|
* Block until the human answers (or the question expires/cancels), looping
|
|
785
903
|
* wait() under the hood. Returns the resolved Question — inspect `state` and
|
|
786
|
-
* `answer.value` to branch.
|
|
904
|
+
* `answer.value` to branch. A poll returns once it closes; its `answer` is the
|
|
905
|
+
* leading option, or `null` on a tie, and `results` holds the tally.
|
|
906
|
+
*
|
|
907
|
+
* Polls start at least 1 s apart. A response that is not this Question in a
|
|
908
|
+
* known state rejects with `invalid_response`. Network failures, 429 and 5xx
|
|
909
|
+
* are retried with backoff (honouring `retryAfter`), and reported to
|
|
910
|
+
* `onRetry`, until `signal` aborts or the outage outlasts the Question's
|
|
911
|
+
* `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
|
|
912
|
+
* been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
787
913
|
*/
|
|
788
914
|
async waitForAnswer(questionId, input = {}) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
915
|
+
requireNonEmpty(questionId, 'questionId');
|
|
916
|
+
assertOnRetry(input.onRetry);
|
|
917
|
+
return pollUntilSettled({
|
|
918
|
+
poll: () => this.wait(questionId, input.signal ? { signal: input.signal } : {}),
|
|
919
|
+
validate: (value) => validateQuestionWait(value, questionId),
|
|
920
|
+
settled: (question) => question.state !== 'pending',
|
|
921
|
+
expiresAt: (question) => question.expires_at,
|
|
922
|
+
what: `question ${questionId}`,
|
|
923
|
+
minIntervalMs: MIN_WAIT_POLL_INTERVAL_MS,
|
|
924
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
925
|
+
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
926
|
+
});
|
|
800
927
|
}
|
|
801
928
|
/** List the asker's Questions, optionally filtered by state. */
|
|
802
929
|
async list(input = {}) {
|
|
@@ -810,6 +937,21 @@ class QuestionsApi {
|
|
|
810
937
|
return this.http.request('POST', `/api/agent/questions/${enc(questionId)}/cancel`, { body: {} });
|
|
811
938
|
}
|
|
812
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* A poll asks the whole room and counts option votes, so the server refuses it
|
|
942
|
+
* (422 on `resolution_policy`) alongside a direct scope, a named target, or a
|
|
943
|
+
* typed answer. Fail before the request instead.
|
|
944
|
+
*/
|
|
945
|
+
function assertPollShape(input) {
|
|
946
|
+
if (input.resolution_policy !== 'all')
|
|
947
|
+
return;
|
|
948
|
+
const target = input.target_user_id;
|
|
949
|
+
if (input.responder_scope === 'direct'
|
|
950
|
+
|| (target !== undefined && target !== null && target !== '')
|
|
951
|
+
|| (input.text_input !== undefined && input.text_input !== null)) {
|
|
952
|
+
throw new PingRoomError("A poll (`resolution_policy: 'all'`) asks the whole room with options only; it cannot use `responder_scope: 'direct'`, `target_user_id`, or `text_input`.", { code: 'invalid_request' });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
813
955
|
/** A bare string option is shorthand for `{ value, label: value }`. */
|
|
814
956
|
function normalizeHandoffOptions(options) {
|
|
815
957
|
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
@@ -865,20 +1007,28 @@ class HandoffsApi {
|
|
|
865
1007
|
* Block until the handoff resolves (or expires/cancels), looping the bounded
|
|
866
1008
|
* long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
|
|
867
1009
|
* `hold`) all surface as normal terminal results — nothing is thrown for them.
|
|
1010
|
+
*
|
|
1011
|
+
* Polls start at least 2.1 s apart, so a short `timeout` stays under the
|
|
1012
|
+
* route's 30-per-minute limit. A response that is not this handoff in a
|
|
1013
|
+
* known state rejects with `invalid_response`. Network failures, 429 and 5xx
|
|
1014
|
+
* are retried with backoff (honouring `retryAfter`), and reported to
|
|
1015
|
+
* `onRetry`, until `signal` aborts or the outage outlasts the handoff's
|
|
1016
|
+
* `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
|
|
1017
|
+
* been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
868
1018
|
*/
|
|
869
1019
|
async waitForResult(id, input = {}) {
|
|
870
1020
|
requireNonEmpty(id, 'id');
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
1021
|
+
assertOnRetry(input.onRetry);
|
|
1022
|
+
return pollUntilSettled({
|
|
1023
|
+
poll: () => this.wait(id, input),
|
|
1024
|
+
validate: (value) => validateHandoffWait(value, id),
|
|
1025
|
+
settled: isHandoffTerminal,
|
|
1026
|
+
expiresAt: (handoff) => handoff.expires_at,
|
|
1027
|
+
what: `handoff ${id}`,
|
|
1028
|
+
minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
|
|
1029
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
1030
|
+
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
1031
|
+
});
|
|
882
1032
|
}
|
|
883
1033
|
/** List the agent's open handoffs or bounded recent history. */
|
|
884
1034
|
async list(input = {}) {
|
|
@@ -909,7 +1059,7 @@ class HandoffsApi {
|
|
|
909
1059
|
});
|
|
910
1060
|
return this.http.request('POST', '/api/agent/handoffs', {
|
|
911
1061
|
body,
|
|
912
|
-
...(input.idempotencyKey ? { headers: { 'Idempotency-Key': input.idempotencyKey } } : {}),
|
|
1062
|
+
...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
|
|
913
1063
|
});
|
|
914
1064
|
}
|
|
915
1065
|
}
|
|
@@ -963,7 +1113,7 @@ class InboxApi {
|
|
|
963
1113
|
}), questionId);
|
|
964
1114
|
throwIfAborted(signal);
|
|
965
1115
|
if (question.state === 'pending') {
|
|
966
|
-
await sleep(Math.max(0,
|
|
1116
|
+
await sleep(Math.max(0, MIN_HANDOFF_POLL_INTERVAL_MS - (Date.now() - pollStartedAt)), signal);
|
|
967
1117
|
continue;
|
|
968
1118
|
}
|
|
969
1119
|
if (question.state === 'answered' && question.activation_completed === true) {
|
|
@@ -1044,11 +1194,11 @@ class LiveApi {
|
|
|
1044
1194
|
* Read back a stream this credential started, so a restarted producer can
|
|
1045
1195
|
* reconcile instead of opening a duplicate.
|
|
1046
1196
|
*
|
|
1047
|
-
* Resolves null for a 404
|
|
1048
|
-
* last 24 hours. Every other failure throws:
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
* reported as "no stream".
|
|
1197
|
+
* Resolves null ONLY for a 404 `stream_not_found` — no stream with that
|
|
1198
|
+
* correlation id in the last 24 hours. Every other failure throws: a 404 for
|
|
1199
|
+
* an unknown or deleted room code, and a 403 `room_not_granted`
|
|
1200
|
+
* ({@link RoomScopedErrorCode}) when the room is outside the grant the human
|
|
1201
|
+
* gave this agent, both propagate rather than being reported as "no stream".
|
|
1052
1202
|
*/
|
|
1053
1203
|
async get(inviteCode, correlationId) {
|
|
1054
1204
|
requireNonEmpty(inviteCode, 'inviteCode');
|
|
@@ -1057,7 +1207,7 @@ class LiveApi {
|
|
|
1057
1207
|
return await this.http.request('GET', `/api/agent/rooms/${enc(inviteCode)}/live/${enc(correlationId)}`);
|
|
1058
1208
|
}
|
|
1059
1209
|
catch (error) {
|
|
1060
|
-
if (error instanceof PingRoomError && error.status === 404)
|
|
1210
|
+
if (error instanceof PingRoomError && error.status === 404 && error.code === 'stream_not_found')
|
|
1061
1211
|
return null;
|
|
1062
1212
|
throw error;
|
|
1063
1213
|
}
|
|
@@ -1212,12 +1362,26 @@ export class PingRoom {
|
|
|
1212
1362
|
body: { code: normalizeRedeemCode(code) },
|
|
1213
1363
|
});
|
|
1214
1364
|
}
|
|
1215
|
-
/**
|
|
1365
|
+
/**
|
|
1366
|
+
* Broadcast a ping to every member of a room you own. Pass `idempotencyKey`
|
|
1367
|
+
* and reuse it when retrying after a timeout or network error, so a send
|
|
1368
|
+
* the server already committed is replayed rather than delivered twice.
|
|
1369
|
+
*/
|
|
1216
1370
|
broadcast(inviteCode, ping) {
|
|
1217
1371
|
assertPing(ping);
|
|
1372
|
+
const { body, headers } = splitIdempotencyKey(ping);
|
|
1218
1373
|
return this.http.request('POST', `/api/agent/rooms/${enc(inviteCode)}/notifications`, {
|
|
1219
|
-
body: dropUndefined({ ...
|
|
1374
|
+
body: dropUndefined({ ...body }),
|
|
1375
|
+
...(headers ? { headers } : {}),
|
|
1220
1376
|
});
|
|
1221
1377
|
}
|
|
1378
|
+
/** What a logger or JSON.stringify() sees: the API origin, never the credential. */
|
|
1379
|
+
toJSON() {
|
|
1380
|
+
return this.http.toJSON();
|
|
1381
|
+
}
|
|
1382
|
+
[INSPECT]() {
|
|
1383
|
+
const { baseUrl, token } = this.http.toJSON();
|
|
1384
|
+
return `PingRoom { baseUrl: ${JSON.stringify(baseUrl)}, token: ${token === null ? 'null' : token} }`;
|
|
1385
|
+
}
|
|
1222
1386
|
}
|
|
1223
1387
|
//# sourceMappingURL=client.js.map
|