@xema/omni-protocol 0.1.72 → 0.1.74
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/index.d.ts +7 -0
- package/dist/testing.d.ts +5 -0
- package/dist/testing.js +125 -46
- package/dist/validation.js +2 -1
- package/guide.md +41 -11
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -174,6 +174,13 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
174
174
|
* when it ended, and a running report is refused: what a provider never asked for never crosses.
|
|
175
175
|
*/
|
|
176
176
|
runningStepReports?: true;
|
|
177
|
+
/**
|
|
178
|
+
* How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist`
|
|
179
|
+
* `take-over` -- the provider's `task-ended` is owed, in milliseconds. `applied` says the provider
|
|
180
|
+
* has disposed of the task; the ending follows within this, or the host resyncs and shows the
|
|
181
|
+
* task as unsettled. Stated per provider, since platforms settle at different speeds.
|
|
182
|
+
*/
|
|
183
|
+
disposalSettleMs: number;
|
|
177
184
|
}
|
|
178
185
|
export interface SecretStore {
|
|
179
186
|
get(key: string): Promise<string | undefined>;
|
package/dist/testing.d.ts
CHANGED
|
@@ -133,6 +133,11 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
133
133
|
* both.
|
|
134
134
|
*/
|
|
135
135
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
136
|
+
/**
|
|
137
|
+
* The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
|
|
138
|
+
* other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
|
|
139
|
+
*/
|
|
140
|
+
export declare const SUPERSEDED_BY_A_SNAPSHOT: ReadonlySet<string>;
|
|
136
141
|
export declare class TaskStream {
|
|
137
142
|
private readonly tasks;
|
|
138
143
|
private readonly allocations;
|
package/dist/testing.js
CHANGED
|
@@ -162,6 +162,7 @@ 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 = [];
|
|
165
166
|
const storedSecrets = new Map();
|
|
166
167
|
const authenticationSecrets = {
|
|
167
168
|
get: async (key) => storedSecrets.get(key),
|
|
@@ -369,9 +370,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
369
370
|
}
|
|
370
371
|
eventPayloads.set(envelope.id, payload);
|
|
371
372
|
}
|
|
372
|
-
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
373
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
|
|
374
|
+
// delivered while that snapshot is read is held until it lands, and read then against it.
|
|
373
375
|
if (seeded)
|
|
374
376
|
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
377
|
+
else
|
|
378
|
+
duringRead.push(envelope);
|
|
375
379
|
events.push(envelope);
|
|
376
380
|
for (const waiter of waiters)
|
|
377
381
|
waiter(envelope);
|
|
@@ -426,6 +430,32 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
426
430
|
stream.seed(snapshot);
|
|
427
431
|
breaks.seed(snapshot);
|
|
428
432
|
seeded = true;
|
|
433
|
+
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
434
|
+
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
435
|
+
// it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
|
|
436
|
+
// transactions no snapshot carries, and are applied after it, in order.
|
|
437
|
+
ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
|
|
438
|
+
const carried = new Set();
|
|
439
|
+
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
440
|
+
if (isRecord(task) && typeof task.allocationId === "string")
|
|
441
|
+
carried.add(task.allocationId);
|
|
442
|
+
}
|
|
443
|
+
for (const held of duringRead) {
|
|
444
|
+
const event = held.event;
|
|
445
|
+
if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
|
|
446
|
+
violations.push(...stream.apply(held), ...breaks.apply(held));
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
|
|
450
|
+
violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
|
|
451
|
+
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` });
|
|
452
|
+
}
|
|
453
|
+
if (event.type === "task-ended" && typeof event.allocationId === "string" && carried.has(event.allocationId)) {
|
|
454
|
+
violations.push({ rule: "snapshot.accounts.ended", path: "snapshot.tasks",
|
|
455
|
+
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` });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
duringRead.length = 0;
|
|
429
459
|
requireCapabilityMethods(live, current().capabilities);
|
|
430
460
|
if (publishesUserIds(snapshot)) {
|
|
431
461
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
@@ -905,6 +935,14 @@ const REACHABLE_PHASES = {
|
|
|
905
935
|
paused: new Set(["paused", "in-progress", "completing"]),
|
|
906
936
|
completing: new Set(["completing"]),
|
|
907
937
|
};
|
|
938
|
+
/**
|
|
939
|
+
* The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
|
|
940
|
+
* other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
|
|
941
|
+
*/
|
|
942
|
+
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
943
|
+
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
944
|
+
"task-media-started", "task-media-ended", "team-updated", "contacts-updated", "calendar-updated",
|
|
945
|
+
]);
|
|
908
946
|
export class TaskStream {
|
|
909
947
|
tasks = new Map();
|
|
910
948
|
// Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
|
|
@@ -1217,7 +1255,7 @@ async function driveOneCall(drive) {
|
|
|
1217
1255
|
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
1218
1256
|
const isTask = (value) => isRecord(value) && typeof value.id === "string";
|
|
1219
1257
|
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
1220
|
-
const waitFor = (what, matches, from) => new Promise(resolve => {
|
|
1258
|
+
const waitFor = (what, matches, from, within = { ms: drive.timeoutMs, onExpiry: () => refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`) }) => new Promise(resolve => {
|
|
1221
1259
|
for (let index = from; index < drive.events.length; index += 1) {
|
|
1222
1260
|
const hit = matches(drive.events[index]);
|
|
1223
1261
|
if (hit !== undefined) {
|
|
@@ -1227,9 +1265,9 @@ async function driveOneCall(drive) {
|
|
|
1227
1265
|
}
|
|
1228
1266
|
const timer = setTimeout(() => {
|
|
1229
1267
|
drive.waiters.delete(waiter);
|
|
1230
|
-
|
|
1268
|
+
within.onExpiry();
|
|
1231
1269
|
resolve(undefined);
|
|
1232
|
-
},
|
|
1270
|
+
}, within.ms);
|
|
1233
1271
|
const waiter = (envelope) => {
|
|
1234
1272
|
const hit = matches(envelope);
|
|
1235
1273
|
if (hit === undefined)
|
|
@@ -1368,10 +1406,10 @@ async function driveOneCall(drive) {
|
|
|
1368
1406
|
return undefined;
|
|
1369
1407
|
}, cursor).then(hit => { if (hit)
|
|
1370
1408
|
cursor = hit.at; return hit; });
|
|
1371
|
-
const ended = () => waitFor("a task-ended for the driven task", envelope => {
|
|
1409
|
+
const ended = (within) => waitFor("a task-ended for the driven task", envelope => {
|
|
1372
1410
|
const event = envelope.event;
|
|
1373
1411
|
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
1374
|
-
}, cursor);
|
|
1412
|
+
}, cursor, within);
|
|
1375
1413
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1376
1414
|
ruleEvaluated("drive.timeout");
|
|
1377
1415
|
const send = async (command, dialId) => {
|
|
@@ -1465,46 +1503,47 @@ async function driveOneCall(drive) {
|
|
|
1465
1503
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1466
1504
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1467
1505
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1468
|
-
|
|
1469
|
-
|
|
1506
|
+
const reportedLegs = [];
|
|
1507
|
+
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
|
|
1508
|
+
const report = async (body) => {
|
|
1509
|
+
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1510
|
+
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1511
|
+
const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
|
|
1512
|
+
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1513
|
+
found.push(...own);
|
|
1514
|
+
if (own.length > 0)
|
|
1515
|
+
return;
|
|
1516
|
+
let answer;
|
|
1517
|
+
try {
|
|
1518
|
+
answer = await drive.connection.recordStep(leg);
|
|
1519
|
+
}
|
|
1520
|
+
catch (error) {
|
|
1521
|
+
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
|
|
1525
|
+
if (isRecord(answer) && answer.status === "failed") {
|
|
1526
|
+
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)}`);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
const setMuted = (muted) => {
|
|
1530
|
+
try {
|
|
1531
|
+
session.setMuted(muted);
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
if (canRecordMute) {
|
|
1470
1538
|
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
1539
|
const began = Date.now();
|
|
1501
1540
|
setMuted(true);
|
|
1502
|
-
await report({});
|
|
1541
|
+
await report({ at });
|
|
1503
1542
|
// A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
|
|
1504
1543
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1505
1544
|
setMuted(false);
|
|
1506
|
-
await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1507
|
-
|
|
1545
|
+
await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1546
|
+
reportedLegs.push({ at, task: latestTask() });
|
|
1508
1547
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1509
1548
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1510
1549
|
// the store -- or it composed the record in memory and the record died with it.
|
|
@@ -1525,8 +1564,18 @@ async function driveOneCall(drive) {
|
|
|
1525
1564
|
}
|
|
1526
1565
|
}
|
|
1527
1566
|
}
|
|
1528
|
-
// 5. End the call, where the agent may.
|
|
1567
|
+
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
1568
|
+
// open at media end is the provider's to close, since the provider knows the instant the media
|
|
1569
|
+
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
1570
|
+
// changes nothing. So the drive mutes, does not report the end before end-call, and reports it
|
|
1571
|
+
// after the media has ended.
|
|
1529
1572
|
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1573
|
+
let openLeg;
|
|
1574
|
+
if (canRecordMute) {
|
|
1575
|
+
openLeg = { at: new Date().toISOString(), began: Date.now() };
|
|
1576
|
+
setMuted(true);
|
|
1577
|
+
await report({ at: openLeg.at });
|
|
1578
|
+
}
|
|
1530
1579
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
1531
1580
|
// A task that completes with its audio still up is refused by the stream as it passes
|
|
1532
1581
|
// (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
|
|
@@ -1541,6 +1590,12 @@ async function driveOneCall(drive) {
|
|
|
1541
1590
|
}, cursor);
|
|
1542
1591
|
if (mediaEnded?.found === "ended")
|
|
1543
1592
|
cursor = mediaEnded.at;
|
|
1593
|
+
if (openLeg !== undefined) {
|
|
1594
|
+
setMuted(false);
|
|
1595
|
+
// Stated at the record's grain: a leg that rounds to nought is 1.
|
|
1596
|
+
await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
|
|
1597
|
+
reportedLegs.push({ at: openLeg.at, task: latestTask() });
|
|
1598
|
+
}
|
|
1544
1599
|
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1545
1600
|
await holdRefusedOutsideHandling();
|
|
1546
1601
|
}
|
|
@@ -1564,18 +1619,42 @@ async function driveOneCall(drive) {
|
|
|
1564
1619
|
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
|
1565
1620
|
command.disposition = dispositions.codes[0].id;
|
|
1566
1621
|
}
|
|
1567
|
-
if (await send(command) !== undefined
|
|
1568
|
-
|
|
1622
|
+
if (await send(command) !== undefined) {
|
|
1623
|
+
// applied says the provider has disposed of the task, and its ending follows within the bound
|
|
1624
|
+
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1625
|
+
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1626
|
+
ruleEvaluated("drive.disposal.unsettled");
|
|
1627
|
+
const settle = Number(drive.manifest.disposalSettleMs);
|
|
1628
|
+
let unsettled = false;
|
|
1629
|
+
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1630
|
+
if (end !== undefined)
|
|
1631
|
+
await storeReleased();
|
|
1632
|
+
else if (unsettled) {
|
|
1633
|
+
let resync;
|
|
1634
|
+
try {
|
|
1635
|
+
resync = await drive.connection.snapshot();
|
|
1636
|
+
}
|
|
1637
|
+
catch (error) {
|
|
1638
|
+
refuse("drive.command.rejected", "drive.disposal", `snapshot() after an unsettled disposal rejected: ${String(error)}`);
|
|
1639
|
+
}
|
|
1640
|
+
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
|
|
1641
|
+
refuse("drive.disposal.unsettled", "drive.disposal", still
|
|
1642
|
+
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider disposed of the task, and it has not`
|
|
1643
|
+
: `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1569
1646
|
}
|
|
1570
1647
|
else if (latestTask().phase === "completing") {
|
|
1571
1648
|
if (await ended() !== undefined)
|
|
1572
1649
|
await storeReleased();
|
|
1573
1650
|
}
|
|
1574
|
-
// The record kept by the provider has
|
|
1575
|
-
|
|
1651
|
+
// The record kept by the provider has every leg the host reported, wherever the provider restated it.
|
|
1652
|
+
for (const reported of reportedLegs) {
|
|
1653
|
+
if (latestTask() === reported.task)
|
|
1654
|
+
continue;
|
|
1576
1655
|
const history = latestTask().handlingHistory;
|
|
1577
1656
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1578
|
-
const { at } =
|
|
1657
|
+
const { at } = reported;
|
|
1579
1658
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1580
1659
|
if (leg === undefined) {
|
|
1581
1660
|
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.js
CHANGED
|
@@ -422,6 +422,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
422
422
|
if (manifest.runningStepReports !== undefined) {
|
|
423
423
|
into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
|
|
424
424
|
}
|
|
425
|
+
into.require(typeof manifest.disposalSettleMs === "number" && Number.isInteger(manifest.disposalSettleMs) && manifest.disposalSettleMs > 0, "manifest.disposalSettleMs", `${path}.disposalSettleMs`, "disposalSettleMs is how long after an applied disposal the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
|
|
425
426
|
if (manifest.orgLevels !== undefined) {
|
|
426
427
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
427
428
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -778,7 +779,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
778
779
|
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`);
|
|
779
780
|
}
|
|
780
781
|
if (entry.step === "muted") {
|
|
781
|
-
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
|
|
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 provider closes every open leg at the instant the media ends, and the duration is stated");
|
|
782
783
|
}
|
|
783
784
|
}
|
|
784
785
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
package/guide.md
CHANGED
|
@@ -193,6 +193,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
193
193
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
194
194
|
phones?: C extends "voice" ? Phone[] : never;
|
|
195
195
|
runningStepReports?: true;
|
|
196
|
+
disposalSettleMs: number;
|
|
196
197
|
};
|
|
197
198
|
```
|
|
198
199
|
|
|
@@ -1471,6 +1472,7 @@ export default defineAdapter({
|
|
|
1471
1472
|
channel: "voice",
|
|
1472
1473
|
supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
|
|
1473
1474
|
authenticationMethods: ["browser-sso"],
|
|
1475
|
+
disposalSettleMs: 5000,
|
|
1474
1476
|
idleCapabilities: {
|
|
1475
1477
|
dial: { destinations: "any-number" },
|
|
1476
1478
|
},
|
|
@@ -1506,6 +1508,7 @@ compile time.
|
|
|
1506
1508
|
| `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the host) and/or `deskPhone` (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See **How the agent hears the call**. |
|
|
1507
1509
|
| `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
|
|
1508
1510
|
| `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
|
|
1511
|
+
| `disposalSettleMs` | Required. How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist` `take-over` -- the provider's `task-ended` is owed, a positive whole number of milliseconds (`manifest.disposalSettleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
|
|
1509
1512
|
|
|
1510
1513
|
### Authentication methods
|
|
1511
1514
|
|
|
@@ -2061,9 +2064,15 @@ report that: `granted` is a promise to honour a later commit.
|
|
|
2061
2064
|
|
|
2062
2065
|
Returns the provider's complete authoritative state at one point in time.
|
|
2063
2066
|
|
|
2064
|
-
- Omni registers `subscribe()` before awaiting the initial snapshot
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
+
- Omni registers `subscribe()` before awaiting the initial snapshot. An event delivered while the
|
|
2068
|
+
snapshot is read is held until it lands, and what happens to it then depends on what it is. A
|
|
2069
|
+
snapshot restates state, so an event of a state-replacing kind -- `task-offered`, `task-updated`,
|
|
2070
|
+
`task-ended`, `task-media-started`, `task-media-ended`, `break-state`, `team-updated`,
|
|
2071
|
+
`contacts-updated`, `calendar-updated`, `transport-status`, `snapshot` -- is dropped, because the
|
|
2072
|
+
snapshot accounts for it. An event that reports a transaction no snapshot carries --
|
|
2073
|
+
`dial-outcome`, `diagnostic`, `announcement`, `queue-summary` -- is applied after the snapshot,
|
|
2074
|
+
in order: a dial's outcome delivered during a resync still ends the dial, and **Every dial has an
|
|
2075
|
+
outcome** holds through a snapshot. Events after it are applied in order.
|
|
2067
2076
|
- `tasks` must contain every task currently owned by this agent for this provider.
|
|
2068
2077
|
- A snapshot replaces Omni's state for this provider; it is not a partial patch.
|
|
2069
2078
|
- The adapter may return synchronously when it already holds current live values, or
|
|
@@ -2537,7 +2546,13 @@ only while its media is up, so a `held` entry without `seconds` on a task that i
|
|
|
2537
2546
|
(`task.handlingHistory.held.open`, `.muted.open`). Whoever performs the leg closes it — the
|
|
2538
2547
|
provider whose platform parks the caller, the host whose microphone it is — by restating the entry
|
|
2539
2548
|
with its duration, and **an entry that cannot be closed is not written**: open for ever is a
|
|
2540
|
-
plausible nought one level down from a total.
|
|
2549
|
+
plausible nought one level down from a total. **A leg still open when the media ends is closed by
|
|
2550
|
+
the provider**, at that instant, in the same publication that says the media ended: the provider
|
|
2551
|
+
is the one that knows the instant, and the host can only learn of it afterwards. Agents end calls
|
|
2552
|
+
muted, so the host's leg is routinely open at media end; the provider closes it with the duration
|
|
2553
|
+
from the leg's `at` to the media's end, and the host's own closing report for that leg, which
|
|
2554
|
+
follows what it hears, is answered `recorded` and changes nothing, the entry standing as the
|
|
2555
|
+
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
|
|
2541
2556
|
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
|
|
2542
2557
|
`muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
|
|
2543
2558
|
entries, one per join. Order is enforced: an entry earlier than the one before it is refused
|
|
@@ -4344,9 +4359,13 @@ a backend that does not know the session yet — yields no snapshot, and the ada
|
|
|
4344
4359
|
`connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
|
|
4345
4360
|
|
|
4346
4361
|
A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
|
|
4347
|
-
everything emitted when it was requested. Omni
|
|
4348
|
-
|
|
4349
|
-
Omni apply state the
|
|
4362
|
+
everything emitted when it was requested. Omni drops the state-replacing events held during the
|
|
4363
|
+
read on that promise, and applies the rest after it (see **`Connection.snapshot()`**); an adapter
|
|
4364
|
+
that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
|
|
4365
|
+
snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
|
|
4366
|
+
snapshot to accounting for every task event it superseded: a task published during the read and
|
|
4367
|
+
missing from the snapshot, or ended during the read and still carried, is named
|
|
4368
|
+
(`snapshot.accounts.task`, `snapshot.accounts.ended`).
|
|
4350
4369
|
|
|
4351
4370
|
#### Liveness
|
|
4352
4371
|
|
|
@@ -4492,8 +4511,16 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
|
|
|
4492
4511
|
A `left` outcome ends the task for this agent alone: the call continues without them, as it does
|
|
4493
4512
|
when a lead who joined it leaves -- see **Lead assist**.
|
|
4494
4513
|
|
|
4495
|
-
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended
|
|
4496
|
-
|
|
4514
|
+
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`,
|
|
4515
|
+
and not for ever: `applied` to a disposal -- `complete`, `transfer` `complete`, `lead-assist`
|
|
4516
|
+
`take-over` -- says the provider has disposed of the task, and its `task-ended` follows within the
|
|
4517
|
+
manifest's `disposalSettleMs`. A provider never answers `applied` for a disposal it has not yet
|
|
4518
|
+
performed. Past the bound the host calls `snapshot()`: a snapshot still carrying the task is a task
|
|
4519
|
+
held open by a provider that said it was done, and the desk shows it as unsettled -- "Completing...
|
|
4520
|
+
the provider has not confirmed" -- naming the command; a snapshot no longer carrying it clears the
|
|
4521
|
+
task, since the ending was owed and lost. The drive holds a provider to the same bound
|
|
4522
|
+
(`drive.disposal.unsettled`). The `task-media-ended` event and the `completing` phase are likewise
|
|
4523
|
+
non-terminal. A replacement
|
|
4497
4524
|
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|
|
4498
4525
|
same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
|
|
4499
4526
|
recognised as the late event it is, never applied to the life now open under the same id.
|
|
@@ -4714,8 +4741,11 @@ as each case was considered, so a test that needs a rule to have run asserts it
|
|
|
4714
4741
|
inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
|
|
4715
4742
|
gap and not a pass. With the audio open on a softphone,
|
|
4716
4743
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4717
|
-
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`);
|
|
4718
|
-
|
|
4744
|
+
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); then it mutes again and
|
|
4745
|
+
ends the call muted, as agents do, so the leg is open when the media ends, the provider closes it
|
|
4746
|
+
in the completing publication or the open entry is refused (`task.handlingHistory.muted.open`),
|
|
4747
|
+
and the drive's closing report after the media ended is expected `recorded` and to change nothing.
|
|
4748
|
+
Where the provider restates the task's record afterwards, each leg is in it or the hole is named
|
|
4719
4749
|
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4720
4750
|
does, the drive reloads the host as a reload happens: the first client is unsubscribed,
|
|
4721
4751
|
disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
|