@xema/omni-protocol 0.1.14 → 0.1.16
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 +2 -1
- package/dist/testing.js +38 -7
- package/dist/validation.d.ts +13 -0
- package/dist/validation.js +189 -14
- package/guide.md +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ An adapter is loaded from a separate package and may be compiled against a diffe
|
|
|
30
30
|
version, so its output is untrusted input. Every validator takes `unknown` and returns every
|
|
31
31
|
violation it found rather than throwing on the first, so a caller reports all of them at once.
|
|
32
32
|
Validating a snapshot before it replaces provider state is what stops a malformed task reaching
|
|
33
|
-
the agent's workspace
|
|
33
|
+
the agent's workspace; validating a result with `validateResult(result, method)` before acting on
|
|
34
|
+
it is what stops a status the host does not know being shown as an outcome.
|
|
34
35
|
|
|
35
36
|
```ts
|
|
36
37
|
const violations = validateSnapshot(snapshot, manifest);
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateSnapshot, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -59,8 +59,11 @@ function observeTask(value, seen) {
|
|
|
59
59
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
60
60
|
if (isRecord(capabilities.dispositions))
|
|
61
61
|
seen.add("task.dispositions");
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
for (const directory of ["blindTransfer", "consultTransfer", "conference"]) {
|
|
63
|
+
const declared = capabilities[directory];
|
|
64
|
+
if (isRecord(declared) && some(declared.destinations))
|
|
65
|
+
seen.add("task.destinations");
|
|
66
|
+
}
|
|
64
67
|
if (some(capabilities.custom))
|
|
65
68
|
seen.add("task.custom");
|
|
66
69
|
}
|
|
@@ -173,7 +176,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
173
176
|
throw new Error("unreachable: the exercise has an authenticated login");
|
|
174
177
|
return login;
|
|
175
178
|
};
|
|
176
|
-
const reader = () => ({
|
|
179
|
+
const reader = () => ({
|
|
180
|
+
self: current().identity.id,
|
|
181
|
+
capabilities: current().capabilities,
|
|
182
|
+
sessionId: context.sessionId,
|
|
183
|
+
autoAcceptTasks: context.autoAcceptTasks ?? true,
|
|
184
|
+
});
|
|
177
185
|
// The optional methods are optional only until something declares a need for them. Each
|
|
178
186
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
179
187
|
// table does; a missing one is a control the agent would be shown and could never use.
|
|
@@ -229,6 +237,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
229
237
|
unsubscribe = connection.subscribe(envelope => {
|
|
230
238
|
observeEvent(envelope, seen);
|
|
231
239
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
240
|
+
if (eventNamesUsers(envelope))
|
|
241
|
+
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
232
242
|
if (typeof envelope?.id === "string") {
|
|
233
243
|
if (eventIds.has(envelope.id))
|
|
234
244
|
return;
|
|
@@ -245,7 +255,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
245
255
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
246
256
|
// that will not accept one is a connection nothing can be given to.
|
|
247
257
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
248
|
-
|
|
258
|
+
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
259
|
+
violations.push(...malformed);
|
|
260
|
+
// A refusal is read only from a result that has the shape of one.
|
|
261
|
+
if (malformed.length === 0 && capacity.status === "failed") {
|
|
249
262
|
violations.push({
|
|
250
263
|
rule: "connection.setCapacity.failed",
|
|
251
264
|
path: "connection.setCapacity",
|
|
@@ -307,11 +320,29 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
307
320
|
function publishesUserIds(snapshot) {
|
|
308
321
|
if (snapshot?.break?.imposed?.by !== undefined)
|
|
309
322
|
return true;
|
|
310
|
-
if (
|
|
323
|
+
if (teamNamesUsers(snapshot?.team))
|
|
311
324
|
return true;
|
|
312
325
|
if (!Array.isArray(snapshot?.tasks))
|
|
313
326
|
return false;
|
|
314
|
-
return snapshot.tasks.some(
|
|
327
|
+
return snapshot.tasks.some(taskNamesUsers);
|
|
328
|
+
}
|
|
329
|
+
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
330
|
+
const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
331
|
+
(isRecord(task.lead) && task.lead.leadId !== undefined) ||
|
|
332
|
+
isRecord(task.assisting));
|
|
333
|
+
/** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
|
|
334
|
+
function eventNamesUsers(envelope) {
|
|
335
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
336
|
+
if (!isRecord(event))
|
|
337
|
+
return false;
|
|
338
|
+
switch (event.type) {
|
|
339
|
+
case "snapshot": return publishesUserIds(event.snapshot);
|
|
340
|
+
case "break-state": return isRecord(event.break) && isRecord(event.break.imposed) && event.break.imposed.by !== undefined;
|
|
341
|
+
case "task-offered":
|
|
342
|
+
case "task-updated": return taskNamesUsers(event.task);
|
|
343
|
+
case "team-updated": return teamNamesUsers(event.team);
|
|
344
|
+
default: return false;
|
|
345
|
+
}
|
|
315
346
|
}
|
|
316
347
|
/**
|
|
317
348
|
* `refreshing` carries the identity and capabilities of the login it refreshes. A change to
|
package/dist/validation.d.ts
CHANGED
|
@@ -30,8 +30,21 @@ export interface ReaderContext {
|
|
|
30
30
|
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
31
31
|
*/
|
|
32
32
|
capabilities?: SessionCapabilities;
|
|
33
|
+
/** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
|
|
36
|
+
autoAcceptTasks?: boolean;
|
|
33
37
|
}
|
|
34
38
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
35
39
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
36
40
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
41
|
+
/** The connection methods whose results `validateResult` knows. */
|
|
42
|
+
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
|
|
43
|
+
/**
|
|
44
|
+
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
45
|
+
* snapshot is: it comes from an adapter that may be compiled against another version, and Omni
|
|
46
|
+
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
47
|
+
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
48
|
+
*/
|
|
49
|
+
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
37
50
|
export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -70,6 +70,12 @@ const SNAPSHOT_REASONS = membersOf({
|
|
|
70
70
|
reconnected: true, "provider-requested": true,
|
|
71
71
|
});
|
|
72
72
|
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
|
|
73
|
+
const MEMBER_BREAKS = membersOf({
|
|
74
|
+
"awaiting-decision": true, granted: true, "starting-after-task": true,
|
|
75
|
+
});
|
|
76
|
+
const OFFERABLE_PHASES = membersOf({
|
|
77
|
+
pending: true, confirmed: true, preparing: true,
|
|
78
|
+
});
|
|
73
79
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
|
|
74
80
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
75
81
|
const EXPIRABLE_PHASES = membersOf({
|
|
@@ -283,7 +289,9 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
283
289
|
}
|
|
284
290
|
else {
|
|
285
291
|
methods.forEach((method, index) => {
|
|
286
|
-
into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`)
|
|
292
|
+
if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
|
|
293
|
+
into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${String(method)}`);
|
|
294
|
+
}
|
|
287
295
|
});
|
|
288
296
|
}
|
|
289
297
|
if (channelValid)
|
|
@@ -331,19 +339,28 @@ function validateDestinationDirectory(value, path, into) {
|
|
|
331
339
|
return;
|
|
332
340
|
}
|
|
333
341
|
into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
|
|
342
|
+
// A directory with nothing in it and no typing is a control with nothing to offer.
|
|
343
|
+
if (value.allowManualEntry === false && !(Array.isArray(value.destinations) && value.destinations.length > 0)) {
|
|
344
|
+
into.add("task.destinations.offer", path, "a directory with no destinations must allow manual entry, or the control has nothing to offer");
|
|
345
|
+
}
|
|
334
346
|
if (value.destinations === undefined)
|
|
335
347
|
return;
|
|
336
348
|
if (!Array.isArray(value.destinations)) {
|
|
337
349
|
into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array when present");
|
|
338
350
|
return;
|
|
339
351
|
}
|
|
352
|
+
const seen = new Set();
|
|
340
353
|
value.destinations.forEach((destination, index) => {
|
|
341
354
|
const at = `${path}.destinations[${index}]`;
|
|
342
355
|
if (!isPlainObject(destination)) {
|
|
343
356
|
into.add("task.destination.shape", at, "each destination must be an object");
|
|
344
357
|
return;
|
|
345
358
|
}
|
|
346
|
-
into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id")
|
|
359
|
+
if (into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id")) {
|
|
360
|
+
if (seen.has(destination.id))
|
|
361
|
+
into.add("task.destination.unique", `${at}.id`, `duplicate destination id: ${destination.id}`);
|
|
362
|
+
seen.add(destination.id);
|
|
363
|
+
}
|
|
347
364
|
into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
|
|
348
365
|
into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
|
|
349
366
|
into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
|
|
@@ -359,6 +376,10 @@ function validateDispositions(value, path, into) {
|
|
|
359
376
|
if (value.required !== undefined) {
|
|
360
377
|
into.require(typeof value.required === "boolean", "task.dispositions.required", `${path}.required`, "required must be a boolean when present");
|
|
361
378
|
}
|
|
379
|
+
// A code must be collected before completion, so there must be one to collect.
|
|
380
|
+
if (value.required === true && !(Array.isArray(value.codes) && value.codes.length > 0)) {
|
|
381
|
+
into.add("task.dispositions.required.codes", `${path}.codes`, "a required disposition policy must publish at least one code");
|
|
382
|
+
}
|
|
362
383
|
if (value.notes !== undefined)
|
|
363
384
|
into.oneOf(value.notes, NOTES_POLICIES, "task.dispositions.notes", `${path}.notes`);
|
|
364
385
|
if (value.codes === undefined)
|
|
@@ -414,6 +435,7 @@ function validateBrowsers(value, path, into) {
|
|
|
414
435
|
return;
|
|
415
436
|
}
|
|
416
437
|
const seen = new Set();
|
|
438
|
+
const names = new Set();
|
|
417
439
|
value.forEach((browser, index) => {
|
|
418
440
|
const at = `${path}[${index}]`;
|
|
419
441
|
if (!isPlainObject(browser)) {
|
|
@@ -425,7 +447,13 @@ function validateBrowsers(value, path, into) {
|
|
|
425
447
|
into.add("task.browser.unique", `${at}.id`, `duplicate browser id: ${browser.id}`);
|
|
426
448
|
seen.add(browser.id);
|
|
427
449
|
}
|
|
428
|
-
|
|
450
|
+
// The name is an input to a `TAB_NAME` isolation scheme: two tabs with one name would
|
|
451
|
+
// silently share a session.
|
|
452
|
+
if (into.filled(browser.name, "task.browser.name", `${at}.name`, "a browser needs a name")) {
|
|
453
|
+
if (names.has(browser.name))
|
|
454
|
+
into.add("task.browser.name.unique", `${at}.name`, `duplicate browser name: ${browser.name}`);
|
|
455
|
+
names.add(browser.name);
|
|
456
|
+
}
|
|
429
457
|
into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
|
|
430
458
|
if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
|
|
431
459
|
let scheme;
|
|
@@ -463,13 +491,18 @@ function validateTaskAttributes(value, path, into) {
|
|
|
463
491
|
into.add("task.attributes.shape", path, "attributes must be an array when present");
|
|
464
492
|
return;
|
|
465
493
|
}
|
|
494
|
+
const keys = new Set();
|
|
466
495
|
value.forEach((attribute, index) => {
|
|
467
496
|
const at = `${path}[${index}]`;
|
|
468
497
|
if (!isPlainObject(attribute)) {
|
|
469
498
|
into.add("task.attribute.shape", at, "each task attribute must be an object");
|
|
470
499
|
return;
|
|
471
500
|
}
|
|
472
|
-
into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key")
|
|
501
|
+
if (into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key")) {
|
|
502
|
+
if (keys.has(attribute.key))
|
|
503
|
+
into.add("task.attribute.unique", `${at}.key`, `duplicate attribute key: ${attribute.key}`);
|
|
504
|
+
keys.add(attribute.key);
|
|
505
|
+
}
|
|
473
506
|
if (attribute.label !== undefined) {
|
|
474
507
|
into.filled(attribute.label, "task.attribute.label", `${at}.label`, "a label must not be empty when present");
|
|
475
508
|
}
|
|
@@ -509,6 +542,8 @@ function validateHandlingHistory(value, path, into) {
|
|
|
509
542
|
}
|
|
510
543
|
if (entry.by !== undefined) {
|
|
511
544
|
into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
|
|
545
|
+
// On `queued` nobody takes part, so there is nothing to name.
|
|
546
|
+
into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
512
547
|
}
|
|
513
548
|
});
|
|
514
549
|
}
|
|
@@ -611,6 +646,15 @@ function validateTaskInto(task, context, path, into) {
|
|
|
611
646
|
return;
|
|
612
647
|
}
|
|
613
648
|
const allowed = isChannel(context.channel) ? TASK_CAPABILITIES[context.channel] : TASK_CAPABILITIES.voice;
|
|
649
|
+
// A task that supplies browser definitions declares the capability that shows them, and one
|
|
650
|
+
// that declares it supplies at least one: the capability puts a panel in the workspace, and a
|
|
651
|
+
// panel with nothing in it is a control with nothing to offer.
|
|
652
|
+
if (Array.isArray(task.browsers) && task.browsers.length > 0 && capabilities.browsers !== true) {
|
|
653
|
+
into.add("task.browsers.capability", `${path}.browsers`, "a task that supplies browsers declares capabilities.browsers");
|
|
654
|
+
}
|
|
655
|
+
if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
|
|
656
|
+
into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
|
|
657
|
+
}
|
|
614
658
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
615
659
|
if (declared === undefined)
|
|
616
660
|
continue;
|
|
@@ -669,6 +713,10 @@ function validateBreakState(value, path, into) {
|
|
|
669
713
|
into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
670
714
|
}
|
|
671
715
|
}
|
|
716
|
+
// The refusal is the reason the control is withdrawn; beside `accepting: true` it explains nothing.
|
|
717
|
+
if (value.refusedReason !== undefined) {
|
|
718
|
+
into.require(value.accepting !== true, "break.refusedReason.accepting", `${path}.refusedReason`, "refusedReason is shown when accepting is false; omit it while the agent may ask");
|
|
719
|
+
}
|
|
672
720
|
if (value.retryAfterMs !== undefined) {
|
|
673
721
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "break.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
674
722
|
}
|
|
@@ -678,14 +726,21 @@ function validateBreakState(value, path, into) {
|
|
|
678
726
|
// break that is not happening.
|
|
679
727
|
into.require(value.approval !== "not-requested", "break.activeReasonId.approval", `${path}.activeReasonId`, "activeReasonId must be omitted when no break is requested or in effect");
|
|
680
728
|
}
|
|
681
|
-
if (value.imposed !== undefined)
|
|
729
|
+
if (value.imposed !== undefined) {
|
|
682
730
|
validateImposedBreak(value.imposed, `${path}.imposed`, into);
|
|
731
|
+
// An imposed break is a break somebody placed; beside `not-requested` there is no break.
|
|
732
|
+
into.require(value.approval !== "not-requested", "break.imposed.approval", `${path}.imposed`, "an imposed break is a break in progress; not-requested says there is none");
|
|
733
|
+
}
|
|
683
734
|
if (value.reasons === undefined)
|
|
684
735
|
return;
|
|
685
736
|
if (!Array.isArray(value.reasons)) {
|
|
686
737
|
into.add("break.reasons.shape", `${path}.reasons`, "break reasons must be an array when present");
|
|
687
738
|
return;
|
|
688
739
|
}
|
|
740
|
+
// A provider that defines no codes omits the field: an empty list is a second spelling of that.
|
|
741
|
+
if (value.reasons.length === 0) {
|
|
742
|
+
into.add("break.reasons.empty", `${path}.reasons`, "a provider that defines no reasons omits the field rather than publishing an empty list");
|
|
743
|
+
}
|
|
689
744
|
const seen = new Set();
|
|
690
745
|
value.reasons.forEach((reason, index) => {
|
|
691
746
|
const at = `${path}.reasons[${index}]`;
|
|
@@ -705,6 +760,10 @@ function validateBreakState(value, path, into) {
|
|
|
705
760
|
into.require(reason.alwaysAvailable === true, "break.reason.alwaysAvailable", `${at}.alwaysAvailable`, "alwaysAvailable is declared by presence: send true or omit it");
|
|
706
761
|
}
|
|
707
762
|
});
|
|
763
|
+
// The active reason is one of the published ones, or it is a reason Omni cannot name.
|
|
764
|
+
if (typeof value.activeReasonId === "string" && value.activeReasonId.length > 0) {
|
|
765
|
+
into.require(seen.has(value.activeReasonId), "break.activeReasonId.known", `${path}.activeReasonId`, `activeReasonId names a reason the provider did not publish: ${value.activeReasonId}`);
|
|
766
|
+
}
|
|
708
767
|
}
|
|
709
768
|
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
710
769
|
const into = new Collector();
|
|
@@ -780,8 +839,13 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
780
839
|
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
781
840
|
if (member.since !== undefined)
|
|
782
841
|
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
783
|
-
if (member.break !== undefined)
|
|
784
|
-
|
|
842
|
+
if (member.break !== undefined) {
|
|
843
|
+
// Only an outstanding request appears here: `not-requested` is absence, `in-effect` is
|
|
844
|
+
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
845
|
+
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
846
|
+
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
847
|
+
}
|
|
848
|
+
}
|
|
785
849
|
});
|
|
786
850
|
}
|
|
787
851
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
@@ -792,15 +856,25 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
792
856
|
}
|
|
793
857
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
794
858
|
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
795
|
-
into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
|
|
859
|
+
if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
|
|
860
|
+
&& context.sessionId !== undefined) {
|
|
861
|
+
into.require(snapshot.sessionId === context.sessionId, "snapshot.sessionId.mismatch", `${path}.sessionId`, `a snapshot for session ${String(snapshot.sessionId)} on a login whose session is ${context.sessionId}`);
|
|
862
|
+
}
|
|
796
863
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
797
864
|
if (!Array.isArray(snapshot.tasks)) {
|
|
798
865
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
799
866
|
}
|
|
800
867
|
else {
|
|
801
868
|
const seen = new Set();
|
|
869
|
+
let assisting;
|
|
802
870
|
snapshot.tasks.forEach((task, index) => {
|
|
803
871
|
validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
|
|
872
|
+
// A lead assists one call at a time.
|
|
873
|
+
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
874
|
+
if (assisting !== undefined)
|
|
875
|
+
into.add("snapshot.assisting.single", `${path}.tasks[${index}].assisting`, "a lead assists one call at a time");
|
|
876
|
+
assisting = index;
|
|
877
|
+
}
|
|
804
878
|
if (isPlainObject(task) && isTaskId(task.id)) {
|
|
805
879
|
if (seen.has(task.id))
|
|
806
880
|
into.add("task.id.unique", `${path}.tasks[${index}].id`, `duplicate task id: ${task.id}`);
|
|
@@ -895,9 +969,7 @@ function validateTaskOutcome(value, path, into) {
|
|
|
895
969
|
into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
|
|
896
970
|
}
|
|
897
971
|
else {
|
|
898
|
-
|
|
899
|
-
into.filled(value.failure.message, "failure.message", `${path}.failure.message`, "a failure needs a message");
|
|
900
|
-
into.require(typeof value.failure.retryable === "boolean", "failure.retryable", `${path}.failure.retryable`, "a failure must say whether it is retryable");
|
|
972
|
+
validateFailureInto(value.failure, `${path}.failure`, into);
|
|
901
973
|
}
|
|
902
974
|
break;
|
|
903
975
|
default:
|
|
@@ -921,7 +993,13 @@ function validateProviderSummary(value, path, into) {
|
|
|
921
993
|
into.add("event.summary.metrics.shape", `${path}.metrics`, "metrics must be an array when present");
|
|
922
994
|
return;
|
|
923
995
|
}
|
|
996
|
+
const ids = new Set();
|
|
924
997
|
value.metrics.forEach((metric, index) => {
|
|
998
|
+
if (isPlainObject(metric) && isFilled(metric.id)) {
|
|
999
|
+
if (ids.has(metric.id))
|
|
1000
|
+
into.add("event.summary.metric.unique", `${path}.metrics[${index}].id`, `duplicate metric id: ${metric.id}`);
|
|
1001
|
+
ids.add(metric.id);
|
|
1002
|
+
}
|
|
925
1003
|
const at = `${path}.metrics[${index}]`;
|
|
926
1004
|
if (!isPlainObject(metric)) {
|
|
927
1005
|
into.add("event.summary.metric.shape", at, "each metric must be an object");
|
|
@@ -940,7 +1018,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
940
1018
|
}
|
|
941
1019
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
942
1020
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
943
|
-
into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
|
|
1021
|
+
if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
|
|
1022
|
+
&& context.sessionId !== undefined) {
|
|
1023
|
+
into.require(envelope.sessionId === context.sessionId, "event.sessionId.mismatch", `${path}.sessionId`, `an event for session ${String(envelope.sessionId)} on a login whose session is ${context.sessionId}`);
|
|
1024
|
+
}
|
|
1025
|
+
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
944
1026
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
945
1027
|
const event = envelope.event;
|
|
946
1028
|
if (!isPlainObject(event)) {
|
|
@@ -964,9 +1046,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
964
1046
|
break;
|
|
965
1047
|
case "task-offered":
|
|
966
1048
|
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
1049
|
+
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1050
|
+
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1051
|
+
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1052
|
+
}
|
|
967
1053
|
if (event.acceptanceMode !== undefined) {
|
|
968
1054
|
into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
|
|
969
1055
|
}
|
|
1056
|
+
// The mode travels exactly when Omni said tasks may be auto-accepted.
|
|
1057
|
+
if (context.autoAcceptTasks === true) {
|
|
1058
|
+
into.require(event.acceptanceMode !== undefined, "event.taskOffered.acceptanceMode.required", `${at}.acceptanceMode`, "autoAcceptTasks is on, so task-offered carries an acceptanceMode");
|
|
1059
|
+
}
|
|
1060
|
+
else if (context.autoAcceptTasks === false) {
|
|
1061
|
+
into.require(event.acceptanceMode === undefined, "event.taskOffered.acceptanceMode.unexpected", `${at}.acceptanceMode`, "autoAcceptTasks is off, so every task requires agent acceptance and task-offered carries no acceptanceMode");
|
|
1062
|
+
}
|
|
970
1063
|
for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
|
|
971
1064
|
if (event[field] !== undefined)
|
|
972
1065
|
into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
|
|
@@ -998,6 +1091,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
998
1091
|
validateTeamRosterInto(event.team, `${at}.team`, context, into);
|
|
999
1092
|
break;
|
|
1000
1093
|
case "contacts-updated":
|
|
1094
|
+
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
1001
1095
|
if (!Array.isArray(event.contacts)) {
|
|
1002
1096
|
into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
|
|
1003
1097
|
}
|
|
@@ -1006,11 +1100,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1006
1100
|
}
|
|
1007
1101
|
break;
|
|
1008
1102
|
case "calendar-updated":
|
|
1103
|
+
into.require(idle.calendar === true, "event.calendar.capability", `${at}.scheduledActivities`, "calendar-updated requires the calendar idle capability");
|
|
1009
1104
|
if (!Array.isArray(event.scheduledActivities)) {
|
|
1010
1105
|
into.add("event.calendar.shape", `${at}.scheduledActivities`, "scheduledActivities must be an array");
|
|
1011
1106
|
}
|
|
1012
1107
|
else {
|
|
1013
|
-
|
|
1108
|
+
const ids = new Set();
|
|
1109
|
+
event.scheduledActivities.forEach((activity, index) => {
|
|
1110
|
+
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
|
|
1111
|
+
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1112
|
+
if (ids.has(activity.id))
|
|
1113
|
+
into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1114
|
+
ids.add(activity.id);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1014
1117
|
}
|
|
1015
1118
|
break;
|
|
1016
1119
|
default:
|
|
@@ -1019,6 +1122,75 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1019
1122
|
return into.violations;
|
|
1020
1123
|
}
|
|
1021
1124
|
// ---------------------------------------------------------------------------
|
|
1125
|
+
// Results. A result crosses the same boundary a snapshot does, from an adapter that may be
|
|
1126
|
+
// compiled against another version, and Omni shows the agent what it says.
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
/** A `ProtocolFailure`, wherever one appears: on a result, or on a task's failed outcome. */
|
|
1129
|
+
function validateFailureInto(value, path, into) {
|
|
1130
|
+
if (!isPlainObject(value)) {
|
|
1131
|
+
into.add("failure.shape", path, "a failure must be an object");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (into.filled(value.code, "failure.code", `${path}.code`, "a failure needs a code")) {
|
|
1135
|
+
// A provider names its own codes freely; the `omni.` namespace is the contract's, and a code
|
|
1136
|
+
// in it that the contract lacks is one Omni would show without knowing what it means.
|
|
1137
|
+
if (value.code.startsWith("omni.")) {
|
|
1138
|
+
into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `not a contract failure code: ${value.code}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
into.filled(value.message, "failure.message", `${path}.message`, "a failure needs a message");
|
|
1142
|
+
into.require(typeof value.retryable === "boolean", "failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
|
|
1143
|
+
if (value.retryAfterMs !== undefined) {
|
|
1144
|
+
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// Pinned to the result unions: each method's one success status, and the status that carries a
|
|
1148
|
+
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1149
|
+
const RESULT_STATUSES = {
|
|
1150
|
+
execute: { success: "applied", failure: "failed" },
|
|
1151
|
+
dial: { success: "dialled", failure: "failed" },
|
|
1152
|
+
setCapacity: { success: "accepted", failure: "failed" },
|
|
1153
|
+
requestBreak: { success: "requested", failure: "failed" },
|
|
1154
|
+
commitBreak: { success: "committed", failure: "failed" },
|
|
1155
|
+
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
1156
|
+
endBreak: { success: "ended", failure: "failed" },
|
|
1157
|
+
executeTeamBreak: { success: "applied", failure: "failed" },
|
|
1158
|
+
executeTeamConsult: { success: "applied", failure: "failed" },
|
|
1159
|
+
openMedia: { success: "opened", failure: "unavailable" },
|
|
1160
|
+
};
|
|
1161
|
+
/**
|
|
1162
|
+
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
1163
|
+
* snapshot is: it comes from an adapter that may be compiled against another version, and Omni
|
|
1164
|
+
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
1165
|
+
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
1166
|
+
*/
|
|
1167
|
+
export function validateResult(result, method, path = "result") {
|
|
1168
|
+
const into = new Collector();
|
|
1169
|
+
const statuses = RESULT_STATUSES[method];
|
|
1170
|
+
if (!isPlainObject(result)) {
|
|
1171
|
+
into.add("result.shape", path, `${method} must answer an object`);
|
|
1172
|
+
return into.violations;
|
|
1173
|
+
}
|
|
1174
|
+
if (result.status === statuses.success) {
|
|
1175
|
+
into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
|
|
1176
|
+
if (method === "openMedia") {
|
|
1177
|
+
into.require(isPlainObject(result.session), "result.session", `${path}.session`, "opened carries the media session");
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
else if (result.status === statuses.failure) {
|
|
1181
|
+
if (result.failure === undefined) {
|
|
1182
|
+
into.add("result.failure.required", `${path}.failure`, `${statuses.failure} carries the failure that says why`);
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
validateFailureInto(result.failure, `${path}.failure`, into);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
into.add("result.status", `${path}.status`, `${method} answers ${statuses.success} or ${statuses.failure}, not ${String(result.status)}`);
|
|
1190
|
+
}
|
|
1191
|
+
return into.violations;
|
|
1192
|
+
}
|
|
1193
|
+
// ---------------------------------------------------------------------------
|
|
1022
1194
|
// Authentication.
|
|
1023
1195
|
// ---------------------------------------------------------------------------
|
|
1024
1196
|
function validateUser(value, rule, path, into) {
|
|
@@ -1088,6 +1260,9 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1088
1260
|
else {
|
|
1089
1261
|
into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
|
|
1090
1262
|
}
|
|
1263
|
+
if (state.failure !== undefined) {
|
|
1264
|
+
into.require(state.status === "expired", "authentication.failure.unexpected", `${path}.failure`, "only an expired state may carry a failure");
|
|
1265
|
+
}
|
|
1091
1266
|
if (state.expiresAt !== undefined) {
|
|
1092
1267
|
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
1093
1268
|
into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
|
package/guide.md
CHANGED
|
@@ -1731,7 +1731,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
1731
1731
|
| `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
|
|
1732
1732
|
| `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
|
|
1733
1733
|
| `capabilities` | Controls and workspace features available for this specific task. |
|
|
1734
|
-
| `browsers` | Named browser definitions for the task workspace
|
|
1734
|
+
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
1735
1735
|
| `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
|
|
1736
1736
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
1737
1737
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
@@ -2042,14 +2042,13 @@ browser that used it.
|
|
|
2042
2042
|
|
|
2043
2043
|
### Task capabilities
|
|
2044
2044
|
|
|
2045
|
-
Task capabilities belong to each `Task`. If `hold` is
|
|
2045
|
+
Task capabilities belong to each `Task`. If `hold` is omitted on one task, Omni
|
|
2046
2046
|
must not show or issue hold for that task even if another task from the same provider supports it.
|
|
2047
2047
|
|
|
2048
2048
|
```ts
|
|
2049
2049
|
const taskCapabilities = {
|
|
2050
2050
|
channel: "voice",
|
|
2051
2051
|
capabilities: {
|
|
2052
|
-
browsers: true,
|
|
2053
2052
|
hold: true,
|
|
2054
2053
|
dispositions: true,
|
|
2055
2054
|
},
|
|
@@ -2225,7 +2224,8 @@ Starts one outbound call from the idle dialpad. It is present only when the voic
|
|
|
2225
2224
|
declares `dial`.
|
|
2226
2225
|
|
|
2227
2226
|
- `destination` is the original number selected or entered by the agent.
|
|
2228
|
-
-
|
|
2227
|
+
- The provider holds `destination` to its declared `destinationPolicy`: under `contacts-only`, a
|
|
2228
|
+
number that is not one of its contacts answers `failed` with `omni.destination-not-permitted`.
|
|
2229
2229
|
- `dialled` confirms that outbound call creation completed.
|
|
2230
2230
|
- `failed` contains a `ProtocolFailure` and confirms no call was placed.
|
|
2231
2231
|
|
|
@@ -2246,7 +2246,7 @@ nothing while none are being accepted — so they are not published separately.
|
|
|
2246
2246
|
| `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
|
|
2247
2247
|
| `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
|
|
2248
2248
|
| `retryAfterMs` | How long until the agent may retry, when the provider can say. |
|
|
2249
|
-
| `reasons` | Not-ready codes this provider offers. Omitted when it defines none. |
|
|
2249
|
+
| `reasons` | Not-ready codes this provider offers. Omitted when it defines none; an empty list is refused, being a second spelling of the same fact. |
|
|
2250
2250
|
| `activeReasonId` | The `BreakReason.id` the current break is on. Omitted when there is no break, or when the provider cannot say. |
|
|
2251
2251
|
| `imposed` | Set when the break was placed on the agent rather than requested. |
|
|
2252
2252
|
|
|
@@ -2934,6 +2934,11 @@ Applies a `TaskCommandRequest` to one provider-local task.
|
|
|
2934
2934
|
deciding a member's break that another lead has already decided is `applied` when the decisions
|
|
2935
2935
|
agree and `failed`, saying so in `message`, when they differ. `commitBreak()` on a break already
|
|
2936
2936
|
in effect is `committed` for the same reason.
|
|
2937
|
+
- **A result is untrusted for the same reason a snapshot is.** It comes from an adapter that may be
|
|
2938
|
+
compiled against another version, and Omni shows the agent what it says. Omni validates it at
|
|
2939
|
+
the boundary with `validateResult(result, "execute")` — a status the method does not answer, a
|
|
2940
|
+
`failed` without its failure, a success carrying one, or an `omni.` code this contract lacks is
|
|
2941
|
+
refused, and the command is treated as unsettled.
|
|
2937
2942
|
- **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
|
|
2938
2943
|
promise with no result at all, and that means *unknown*, not *failed*, and a snapshot follows —
|
|
2939
2944
|
see **An unsettled result is unknown**. `failed` must never be returned for something the
|
|
@@ -3176,6 +3181,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3176
3181
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3177
3182
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3178
3183
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3184
|
+
| `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
|
|
3179
3185
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3180
3186
|
|
|
3181
3187
|
Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
|