@xema/omni-protocol 0.1.73 → 0.1.75
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/dist/testing.d.ts +12 -0
- package/dist/testing.js +113 -39
- package/dist/validation.d.ts +12 -0
- package/dist/validation.js +54 -4
- package/guide.md +37 -11
- package/package.json +1 -1
package/dist/testing.d.ts
CHANGED
|
@@ -57,6 +57,13 @@ export interface ExerciseAdapterOptions {
|
|
|
57
57
|
* memory has nothing, and is named. Without it the store is required and never read.
|
|
58
58
|
*/
|
|
59
59
|
rebuild?: () => Adapter<Channel>;
|
|
60
|
+
/**
|
|
61
|
+
* The values the queue locks on this login's tasks -- a party's number or email -- as the run's
|
|
62
|
+
* author knows them. A task whose party stands locked may carry none of them anywhere else
|
|
63
|
+
* (`task.locked.leak`). Required once any task locks a party field: a run that cannot ask the
|
|
64
|
+
* question is not a pass, and says so by throwing rather than passing.
|
|
65
|
+
*/
|
|
66
|
+
lockedValues?: readonly string[];
|
|
60
67
|
}
|
|
61
68
|
/**
|
|
62
69
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
@@ -133,6 +140,11 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
133
140
|
* both.
|
|
134
141
|
*/
|
|
135
142
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
143
|
+
/**
|
|
144
|
+
* The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
|
|
145
|
+
* other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
|
|
146
|
+
*/
|
|
147
|
+
export declare const SUPERSEDED_BY_A_SNAPSHOT: ReadonlySet<string>;
|
|
136
148
|
export declare class TaskStream {
|
|
137
149
|
private readonly tasks;
|
|
138
150
|
private readonly allocations;
|
package/dist/testing.js
CHANGED
|
@@ -162,6 +162,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
162
162
|
const stream = new TaskStream();
|
|
163
163
|
const breaks = new BreakStream();
|
|
164
164
|
let seeded = false;
|
|
165
|
+
const duringRead = [];
|
|
166
|
+
let lockedPartySeen = false;
|
|
165
167
|
const storedSecrets = new Map();
|
|
166
168
|
const authenticationSecrets = {
|
|
167
169
|
get: async (key) => storedSecrets.get(key),
|
|
@@ -203,6 +205,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
203
205
|
capabilities: current().capabilities,
|
|
204
206
|
loginId: context.loginId,
|
|
205
207
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
208
|
+
locked: options.lockedValues,
|
|
206
209
|
});
|
|
207
210
|
// The optional methods are optional only until something declares a need for them. Each
|
|
208
211
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
@@ -340,6 +343,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
340
343
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
341
344
|
}
|
|
342
345
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
346
|
+
lockedPartySeen ||= eventTasks(envelope).some(locksParty);
|
|
343
347
|
if (eventNamesUsers(envelope))
|
|
344
348
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
345
349
|
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
@@ -369,9 +373,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
369
373
|
}
|
|
370
374
|
eventPayloads.set(envelope.id, payload);
|
|
371
375
|
}
|
|
372
|
-
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
376
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
|
|
377
|
+
// delivered while that snapshot is read is held until it lands, and read then against it.
|
|
373
378
|
if (seeded)
|
|
374
379
|
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
380
|
+
else
|
|
381
|
+
duringRead.push(envelope);
|
|
375
382
|
events.push(envelope);
|
|
376
383
|
for (const waiter of waiters)
|
|
377
384
|
waiter(envelope);
|
|
@@ -423,9 +430,36 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
423
430
|
tellRefused({ artefact: "snapshot", violations: broken });
|
|
424
431
|
}
|
|
425
432
|
violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
|
|
433
|
+
lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
|
|
426
434
|
stream.seed(snapshot);
|
|
427
435
|
breaks.seed(snapshot);
|
|
428
436
|
seeded = true;
|
|
437
|
+
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
438
|
+
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
439
|
+
// it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
|
|
440
|
+
// transactions no snapshot carries, and are applied after it, in order.
|
|
441
|
+
ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
|
|
442
|
+
const carried = new Set();
|
|
443
|
+
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
444
|
+
if (isRecord(task) && typeof task.allocationId === "string")
|
|
445
|
+
carried.add(task.allocationId);
|
|
446
|
+
}
|
|
447
|
+
for (const held of duringRead) {
|
|
448
|
+
const event = held.event;
|
|
449
|
+
if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
|
|
450
|
+
violations.push(...stream.apply(held), ...breaks.apply(held));
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
|
|
454
|
+
violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
|
|
455
|
+
message: `${String(event.task.id)} (${event.task.allocationId}) was published while the snapshot was read and the snapshot does not carry it: a snapshot accounts for everything the adapter emitted before it resolved` });
|
|
456
|
+
}
|
|
457
|
+
if (event.type === "task-ended" && typeof event.allocationId === "string" && carried.has(event.allocationId)) {
|
|
458
|
+
violations.push({ rule: "snapshot.accounts.ended", path: "snapshot.tasks",
|
|
459
|
+
message: `${String(event.taskId)} (${event.allocationId}) ended while the snapshot was read and the snapshot still carries it: a snapshot accounts for everything the adapter emitted before it resolved` });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
duringRead.length = 0;
|
|
429
463
|
requireCapabilityMethods(live, current().capabilities);
|
|
430
464
|
if (publishesUserIds(snapshot)) {
|
|
431
465
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
@@ -557,6 +591,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
557
591
|
message: "an unsubscribe, disconnect(), or close() threw during shutdown",
|
|
558
592
|
});
|
|
559
593
|
}
|
|
594
|
+
// A task locked a party field and the run was not told the value: task.locked.leak was never
|
|
595
|
+
// asked, and a result that cannot say is not returned as one that passed.
|
|
596
|
+
if (lockedPartySeen && options.lockedValues === undefined) {
|
|
597
|
+
stopObserving();
|
|
598
|
+
throw new TypeError("a task locks its party's number or email and the run states no lockedValues: task.locked.leak cannot be evaluated; pass the values the queue locked in options.lockedValues");
|
|
599
|
+
}
|
|
560
600
|
if (!options.collectOnly)
|
|
561
601
|
assertNoViolations(violations);
|
|
562
602
|
stopObserving();
|
|
@@ -697,6 +737,13 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
697
737
|
}
|
|
698
738
|
}
|
|
699
739
|
/** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
|
|
740
|
+
/** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
|
|
741
|
+
function locksParty(task) {
|
|
742
|
+
if (!isRecord(task) || !isRecord(task.party))
|
|
743
|
+
return false;
|
|
744
|
+
const locked = (value) => isRecord(value) && value.lockedBy !== undefined;
|
|
745
|
+
return locked(task.party.number) || locked(task.party.email);
|
|
746
|
+
}
|
|
700
747
|
function eventTasks(envelope) {
|
|
701
748
|
if (!isRecord(envelope) || !isRecord(envelope.event))
|
|
702
749
|
return [];
|
|
@@ -905,6 +952,14 @@ const REACHABLE_PHASES = {
|
|
|
905
952
|
paused: new Set(["paused", "in-progress", "completing"]),
|
|
906
953
|
completing: new Set(["completing"]),
|
|
907
954
|
};
|
|
955
|
+
/**
|
|
956
|
+
* The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
|
|
957
|
+
* other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
|
|
958
|
+
*/
|
|
959
|
+
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
960
|
+
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
961
|
+
"task-media-started", "task-media-ended", "team-updated", "contacts-updated", "calendar-updated",
|
|
962
|
+
]);
|
|
908
963
|
export class TaskStream {
|
|
909
964
|
tasks = new Map();
|
|
910
965
|
// Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
|
|
@@ -1465,46 +1520,47 @@ async function driveOneCall(drive) {
|
|
|
1465
1520
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1466
1521
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1467
1522
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1468
|
-
|
|
1469
|
-
|
|
1523
|
+
const reportedLegs = [];
|
|
1524
|
+
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
|
|
1525
|
+
const report = async (body) => {
|
|
1526
|
+
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1527
|
+
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1528
|
+
const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
|
|
1529
|
+
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1530
|
+
found.push(...own);
|
|
1531
|
+
if (own.length > 0)
|
|
1532
|
+
return;
|
|
1533
|
+
let answer;
|
|
1534
|
+
try {
|
|
1535
|
+
answer = await drive.connection.recordStep(leg);
|
|
1536
|
+
}
|
|
1537
|
+
catch (error) {
|
|
1538
|
+
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
|
|
1542
|
+
if (isRecord(answer) && answer.status === "failed") {
|
|
1543
|
+
refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
const setMuted = (muted) => {
|
|
1547
|
+
try {
|
|
1548
|
+
session.setMuted(muted);
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
if (canRecordMute) {
|
|
1470
1555
|
const at = new Date().toISOString();
|
|
1471
|
-
const report = async (body) => {
|
|
1472
|
-
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1473
|
-
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1474
|
-
const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
|
|
1475
|
-
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1476
|
-
found.push(...own);
|
|
1477
|
-
if (own.length > 0)
|
|
1478
|
-
return;
|
|
1479
|
-
let answer;
|
|
1480
|
-
try {
|
|
1481
|
-
answer = await drive.connection.recordStep(leg);
|
|
1482
|
-
}
|
|
1483
|
-
catch (error) {
|
|
1484
|
-
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
|
|
1488
|
-
if (isRecord(answer) && answer.status === "failed") {
|
|
1489
|
-
refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1490
|
-
}
|
|
1491
|
-
};
|
|
1492
|
-
const setMuted = (muted) => {
|
|
1493
|
-
try {
|
|
1494
|
-
session.setMuted(muted);
|
|
1495
|
-
}
|
|
1496
|
-
catch {
|
|
1497
|
-
refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
|
|
1498
|
-
}
|
|
1499
|
-
};
|
|
1500
1556
|
const began = Date.now();
|
|
1501
1557
|
setMuted(true);
|
|
1502
|
-
await report({});
|
|
1558
|
+
await report({ at });
|
|
1503
1559
|
// A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
|
|
1504
1560
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1505
1561
|
setMuted(false);
|
|
1506
|
-
await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1507
|
-
|
|
1562
|
+
await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1563
|
+
reportedLegs.push({ at, task: latestTask() });
|
|
1508
1564
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1509
1565
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1510
1566
|
// the store -- or it composed the record in memory and the record died with it.
|
|
@@ -1525,8 +1581,18 @@ async function driveOneCall(drive) {
|
|
|
1525
1581
|
}
|
|
1526
1582
|
}
|
|
1527
1583
|
}
|
|
1528
|
-
// 5. End the call, where the agent may.
|
|
1584
|
+
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
1585
|
+
// open at media end is the provider's to close, since the provider knows the instant the media
|
|
1586
|
+
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
1587
|
+
// changes nothing. So the drive mutes, does not report the end before end-call, and reports it
|
|
1588
|
+
// after the media has ended.
|
|
1529
1589
|
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1590
|
+
let openLeg;
|
|
1591
|
+
if (canRecordMute) {
|
|
1592
|
+
openLeg = { at: new Date().toISOString(), began: Date.now() };
|
|
1593
|
+
setMuted(true);
|
|
1594
|
+
await report({ at: openLeg.at });
|
|
1595
|
+
}
|
|
1530
1596
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
1531
1597
|
// A task that completes with its audio still up is refused by the stream as it passes
|
|
1532
1598
|
// (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
|
|
@@ -1541,6 +1607,12 @@ async function driveOneCall(drive) {
|
|
|
1541
1607
|
}, cursor);
|
|
1542
1608
|
if (mediaEnded?.found === "ended")
|
|
1543
1609
|
cursor = mediaEnded.at;
|
|
1610
|
+
if (openLeg !== undefined) {
|
|
1611
|
+
setMuted(false);
|
|
1612
|
+
// Stated at the record's grain: a leg that rounds to nought is 1.
|
|
1613
|
+
await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
|
|
1614
|
+
reportedLegs.push({ at: openLeg.at, task: latestTask() });
|
|
1615
|
+
}
|
|
1544
1616
|
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1545
1617
|
await holdRefusedOutsideHandling();
|
|
1546
1618
|
}
|
|
@@ -1593,11 +1665,13 @@ async function driveOneCall(drive) {
|
|
|
1593
1665
|
if (await ended() !== undefined)
|
|
1594
1666
|
await storeReleased();
|
|
1595
1667
|
}
|
|
1596
|
-
// The record kept by the provider has
|
|
1597
|
-
|
|
1668
|
+
// The record kept by the provider has every leg the host reported, wherever the provider restated it.
|
|
1669
|
+
for (const reported of reportedLegs) {
|
|
1670
|
+
if (latestTask() === reported.task)
|
|
1671
|
+
continue;
|
|
1598
1672
|
const history = latestTask().handlingHistory;
|
|
1599
1673
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1600
|
-
const { at } =
|
|
1674
|
+
const { at } = reported;
|
|
1601
1675
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1602
1676
|
if (leg === undefined) {
|
|
1603
1677
|
refuse("drive.recordStep.history", "drive.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
|
package/dist/validation.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface TaskValidationContext {
|
|
|
32
32
|
autoAcceptTasks?: boolean;
|
|
33
33
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
34
34
|
dialOutcomesDeclared?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The values the queue locked on this login's tasks -- a party's number or email -- as whoever
|
|
37
|
+
* runs the validator knows them. Where a task's party stands locked, no other field of it may
|
|
38
|
+
* carry one of these. Unknown to a host, which never sees the value, and then unchecked.
|
|
39
|
+
*/
|
|
40
|
+
locked?: readonly string[];
|
|
35
41
|
}
|
|
36
42
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
37
43
|
/**
|
|
@@ -53,6 +59,12 @@ export interface ReaderContext {
|
|
|
53
59
|
levels?: readonly string[];
|
|
54
60
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
55
61
|
loginId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The values the queue locked on this login's tasks, as whoever runs the validator knows them:
|
|
64
|
+
* a conformance run states them, a host never has them. Where a task's party stands locked, no
|
|
65
|
+
* other field of it may carry one (`task.locked.leak`). Absent, unchecked.
|
|
66
|
+
*/
|
|
67
|
+
locked?: readonly string[];
|
|
56
68
|
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
57
69
|
autoAcceptTasks?: boolean;
|
|
58
70
|
}
|
package/dist/validation.js
CHANGED
|
@@ -598,6 +598,53 @@ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
|
598
598
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
599
599
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
600
600
|
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
601
|
+
/**
|
|
602
|
+
* What the queue locks is locked on the whole task. Given the locked values, every other field an
|
|
603
|
+
* agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
|
|
604
|
+
* desk shows -- is held to carrying none of them. A number is compared by its digits, so no
|
|
605
|
+
* formatting hides it; anything else by its text, case aside.
|
|
606
|
+
*/
|
|
607
|
+
function validateNothingLeaksInto(task, locked, path, into) {
|
|
608
|
+
ruleEvaluated("task.locked.leak");
|
|
609
|
+
const carries = (text, value) => {
|
|
610
|
+
if (typeof text !== "string")
|
|
611
|
+
return false;
|
|
612
|
+
const digits = value.replace(/[\s+().-]/g, "");
|
|
613
|
+
if (/^\d+$/.test(digits))
|
|
614
|
+
return text.replace(/\D/g, "").includes(digits);
|
|
615
|
+
return text.toLowerCase().includes(value.trim().toLowerCase());
|
|
616
|
+
};
|
|
617
|
+
const hold = (text, at) => {
|
|
618
|
+
for (const value of locked) {
|
|
619
|
+
if (carries(text, value))
|
|
620
|
+
into.add("task.locked.leak", at, "carries a value the queue locked: what the queue locks is locked on the whole task, and appears nowhere the agent reads");
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
hold(task.title, `${path}.title`);
|
|
624
|
+
hold(task.reference, `${path}.reference`);
|
|
625
|
+
if (Array.isArray(task.attributes)) {
|
|
626
|
+
task.attributes.forEach((attribute, index) => {
|
|
627
|
+
if (!isPlainObject(attribute))
|
|
628
|
+
return;
|
|
629
|
+
hold(attribute.label, `${path}.attributes[${index}].label`);
|
|
630
|
+
hold(attribute.value, `${path}.attributes[${index}].value`);
|
|
631
|
+
if (isPlainObject(attribute.party)) {
|
|
632
|
+
hold(attribute.party.number, `${path}.attributes[${index}].party.number`);
|
|
633
|
+
hold(attribute.party.email, `${path}.attributes[${index}].party.email`);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
const custom = isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
638
|
+
if (Array.isArray(custom))
|
|
639
|
+
custom.forEach((control, index) => { if (isPlainObject(control))
|
|
640
|
+
hold(control.label, `${path}.capabilities.custom[${index}].label`); });
|
|
641
|
+
if (Array.isArray(task.browsers)) {
|
|
642
|
+
task.browsers.forEach((browser, index) => {
|
|
643
|
+
if (isPlainObject(browser) && browser.urlVisibility !== "hidden")
|
|
644
|
+
hold(browser.url, `${path}.browsers[${index}].url`);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
601
648
|
/** `lockedBy`: a declared level other than `person`, who never locks their own value. */
|
|
602
649
|
function validateLockedByInto(value, rule, path, levels, into) {
|
|
603
650
|
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
@@ -779,7 +826,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
779
826
|
into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${String(task.phase)}: the hold has ended, and its duration is stated`);
|
|
780
827
|
}
|
|
781
828
|
if (entry.step === "muted") {
|
|
782
|
-
into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the
|
|
829
|
+
into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the provider closes every open leg at the instant the media ends, and the duration is stated");
|
|
783
830
|
}
|
|
784
831
|
}
|
|
785
832
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
@@ -1015,6 +1062,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1015
1062
|
}
|
|
1016
1063
|
if (task.party !== undefined)
|
|
1017
1064
|
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
1065
|
+
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1066
|
+
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1067
|
+
}
|
|
1018
1068
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1019
1069
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1020
1070
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
@@ -1308,7 +1358,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1308
1358
|
let assisting;
|
|
1309
1359
|
let monitoring;
|
|
1310
1360
|
snapshot.tasks.forEach((task, index) => {
|
|
1311
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
|
|
1361
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1312
1362
|
// A lead assists one call at a time, and listens to one at a time.
|
|
1313
1363
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1314
1364
|
if (assisting !== undefined)
|
|
@@ -1520,7 +1570,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1520
1570
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1521
1571
|
break;
|
|
1522
1572
|
case "task-offered":
|
|
1523
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1573
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1524
1574
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1525
1575
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1526
1576
|
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}`);
|
|
@@ -1529,7 +1579,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1529
1579
|
into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
|
|
1530
1580
|
break;
|
|
1531
1581
|
case "task-updated":
|
|
1532
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1582
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1533
1583
|
break;
|
|
1534
1584
|
case "task-media-started":
|
|
1535
1585
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
package/guide.md
CHANGED
|
@@ -831,7 +831,14 @@ lock, so nothing that can be locked — a directory, a number — may carry that
|
|
|
831
831
|
contact's number and email are the same, since each identifies a person: where the queue says
|
|
832
832
|
the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
|
|
833
833
|
nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
|
|
834
|
-
|
|
834
|
+
**What the queue locks is locked on the whole task.** A number the agent may not see appears
|
|
835
|
+
nowhere else they read — not in the title, the reference, an attribute, a custom control's label,
|
|
836
|
+
or a browser URL the desk shows; a browser whose URL must carry it says `urlVisibility: "hidden"`.
|
|
837
|
+
The adapter is the one that knows the value, so it is the one held to it: given the values the
|
|
838
|
+
queue locked, the validator refuses any other field carrying one, digits compared as digits so no
|
|
839
|
+
formatting hides them (`task.locked.leak`), and a conformance run whose tasks lock a party's number
|
|
840
|
+
or email states those values in `lockedValues`, since a run that cannot ask the question is not a
|
|
841
|
+
pass. A host never has the value and never asks. A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
|
|
835
842
|
controls — is content, and is never locked.
|
|
836
843
|
|
|
837
844
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
@@ -2064,9 +2071,15 @@ report that: `granted` is a promise to honour a later commit.
|
|
|
2064
2071
|
|
|
2065
2072
|
Returns the provider's complete authoritative state at one point in time.
|
|
2066
2073
|
|
|
2067
|
-
- Omni registers `subscribe()` before awaiting the initial snapshot
|
|
2068
|
-
|
|
2069
|
-
|
|
2074
|
+
- Omni registers `subscribe()` before awaiting the initial snapshot. An event delivered while the
|
|
2075
|
+
snapshot is read is held until it lands, and what happens to it then depends on what it is. A
|
|
2076
|
+
snapshot restates state, so an event of a state-replacing kind -- `task-offered`, `task-updated`,
|
|
2077
|
+
`task-ended`, `task-media-started`, `task-media-ended`, `break-state`, `team-updated`,
|
|
2078
|
+
`contacts-updated`, `calendar-updated`, `transport-status`, `snapshot` -- is dropped, because the
|
|
2079
|
+
snapshot accounts for it. An event that reports a transaction no snapshot carries --
|
|
2080
|
+
`dial-outcome`, `diagnostic`, `announcement`, `queue-summary` -- is applied after the snapshot,
|
|
2081
|
+
in order: a dial's outcome delivered during a resync still ends the dial, and **Every dial has an
|
|
2082
|
+
outcome** holds through a snapshot. Events after it are applied in order.
|
|
2070
2083
|
- `tasks` must contain every task currently owned by this agent for this provider.
|
|
2071
2084
|
- A snapshot replaces Omni's state for this provider; it is not a partial patch.
|
|
2072
2085
|
- The adapter may return synchronously when it already holds current live values, or
|
|
@@ -2540,7 +2553,13 @@ only while its media is up, so a `held` entry without `seconds` on a task that i
|
|
|
2540
2553
|
(`task.handlingHistory.held.open`, `.muted.open`). Whoever performs the leg closes it — the
|
|
2541
2554
|
provider whose platform parks the caller, the host whose microphone it is — by restating the entry
|
|
2542
2555
|
with its duration, and **an entry that cannot be closed is not written**: open for ever is a
|
|
2543
|
-
plausible nought one level down from a total.
|
|
2556
|
+
plausible nought one level down from a total. **A leg still open when the media ends is closed by
|
|
2557
|
+
the provider**, at that instant, in the same publication that says the media ended: the provider
|
|
2558
|
+
is the one that knows the instant, and the host can only learn of it afterwards. Agents end calls
|
|
2559
|
+
muted, so the host's leg is routinely open at media end; the provider closes it with the duration
|
|
2560
|
+
from the leg's `at` to the media's end, and the host's own closing report for that leg, which
|
|
2561
|
+
follows what it hears, is answered `recorded` and changes nothing, the entry standing as the
|
|
2562
|
+
provider closed it. One publisher of the closed leg, and no order between them to get right. There is no resumed step; a resume is the end of a
|
|
2544
2563
|
hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
|
|
2545
2564
|
`muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
|
|
2546
2565
|
entries, one per join. Order is enforced: an entry earlier than the one before it is refused
|
|
@@ -4347,9 +4366,13 @@ a backend that does not know the session yet — yields no snapshot, and the ada
|
|
|
4347
4366
|
`connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
|
|
4348
4367
|
|
|
4349
4368
|
A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
|
|
4350
|
-
everything emitted when it was requested. Omni
|
|
4351
|
-
|
|
4352
|
-
Omni apply state the
|
|
4369
|
+
everything emitted when it was requested. Omni drops the state-replacing events held during the
|
|
4370
|
+
read on that promise, and applies the rest after it (see **`Connection.snapshot()`**); an adapter
|
|
4371
|
+
that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
|
|
4372
|
+
snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
|
|
4373
|
+
snapshot to accounting for every task event it superseded: a task published during the read and
|
|
4374
|
+
missing from the snapshot, or ended during the read and still carried, is named
|
|
4375
|
+
(`snapshot.accounts.task`, `snapshot.accounts.ended`).
|
|
4353
4376
|
|
|
4354
4377
|
#### Liveness
|
|
4355
4378
|
|
|
@@ -4608,7 +4631,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4608
4631
|
| Function | Validates |
|
|
4609
4632
|
| --- | --- |
|
|
4610
4633
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
4611
|
-
| `validateTask(task, { channel })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. |
|
|
4634
|
+
| `validateTask(task, { channel, locked? })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. Given `locked`, the values the queue locked, a task whose party stands locked carries none of them anywhere else (`task.locked.leak`). |
|
|
4612
4635
|
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
|
|
4613
4636
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
4614
4637
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
@@ -4725,8 +4748,11 @@ as each case was considered, so a test that needs a rule to have run asserts it
|
|
|
4725
4748
|
inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
|
|
4726
4749
|
gap and not a pass. With the audio open on a softphone,
|
|
4727
4750
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4728
|
-
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`);
|
|
4729
|
-
|
|
4751
|
+
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); then it mutes again and
|
|
4752
|
+
ends the call muted, as agents do, so the leg is open when the media ends, the provider closes it
|
|
4753
|
+
in the completing publication or the open entry is refused (`task.handlingHistory.muted.open`),
|
|
4754
|
+
and the drive's closing report after the media ended is expected `recorded` and to change nothing.
|
|
4755
|
+
Where the provider restates the task's record afterwards, each leg is in it or the hole is named
|
|
4730
4756
|
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4731
4757
|
does, the drive reloads the host as a reload happens: the first client is unsubscribed,
|
|
4732
4758
|
disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
|