@xema/omni-protocol 0.1.73 → 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/testing.d.ts +5 -0
- package/dist/testing.js +96 -39
- package/dist/validation.js +1 -1
- package/guide.md +28 -9
- package/package.json +1 -1
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
|
|
@@ -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
|
}
|
|
@@ -1593,11 +1648,13 @@ async function driveOneCall(drive) {
|
|
|
1593
1648
|
if (await ended() !== undefined)
|
|
1594
1649
|
await storeReleased();
|
|
1595
1650
|
}
|
|
1596
|
-
// The record kept by the provider has
|
|
1597
|
-
|
|
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;
|
|
1598
1655
|
const history = latestTask().handlingHistory;
|
|
1599
1656
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1600
|
-
const { at } =
|
|
1657
|
+
const { at } = reported;
|
|
1601
1658
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1602
1659
|
if (leg === undefined) {
|
|
1603
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
|
@@ -779,7 +779,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
779
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`);
|
|
780
780
|
}
|
|
781
781
|
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
|
|
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");
|
|
783
783
|
}
|
|
784
784
|
}
|
|
785
785
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
package/guide.md
CHANGED
|
@@ -2064,9 +2064,15 @@ report that: `granted` is a promise to honour a later commit.
|
|
|
2064
2064
|
|
|
2065
2065
|
Returns the provider's complete authoritative state at one point in time.
|
|
2066
2066
|
|
|
2067
|
-
- Omni registers `subscribe()` before awaiting the initial snapshot
|
|
2068
|
-
|
|
2069
|
-
|
|
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.
|
|
2070
2076
|
- `tasks` must contain every task currently owned by this agent for this provider.
|
|
2071
2077
|
- A snapshot replaces Omni's state for this provider; it is not a partial patch.
|
|
2072
2078
|
- The adapter may return synchronously when it already holds current live values, or
|
|
@@ -2540,7 +2546,13 @@ only while its media is up, so a `held` entry without `seconds` on a task that i
|
|
|
2540
2546
|
(`task.handlingHistory.held.open`, `.muted.open`). Whoever performs the leg closes it — the
|
|
2541
2547
|
provider whose platform parks the caller, the host whose microphone it is — by restating the entry
|
|
2542
2548
|
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.
|
|
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
|
|
2544
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
|
|
2545
2557
|
`muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
|
|
2546
2558
|
entries, one per join. Order is enforced: an entry earlier than the one before it is refused
|
|
@@ -4347,9 +4359,13 @@ a backend that does not know the session yet — yields no snapshot, and the ada
|
|
|
4347
4359
|
`connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
|
|
4348
4360
|
|
|
4349
4361
|
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
|
|
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`).
|
|
4353
4369
|
|
|
4354
4370
|
#### Liveness
|
|
4355
4371
|
|
|
@@ -4725,8 +4741,11 @@ as each case was considered, so a test that needs a rule to have run asserts it
|
|
|
4725
4741
|
inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
|
|
4726
4742
|
gap and not a pass. With the audio open on a softphone,
|
|
4727
4743
|
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
|
-
|
|
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
|
|
4730
4749
|
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4731
4750
|
does, the drive reloads the host as a reload happens: the first client is unsubscribed,
|
|
4732
4751
|
disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
|