@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 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
- let mutedLeg;
1469
- if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
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
- mutedLeg = { at, task: latestTask() };
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 the leg the host reported, wherever the provider restated it.
1597
- if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
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 } = mutedLeg;
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`);
@@ -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 host ends its legs when the media ends, and the duration is stated");
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 and discards anything delivered
2068
- while the snapshot is read, because the snapshot accounts for it. Events after it are applied in
2069
- order.
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. There is no resumed step; a resume is the end of a
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 discards events buffered during the read on that
4351
- promise; an adapter that serves a stale snapshot and then lets an earlier event through will have
4352
- Omni apply state the snapshot already superseded.
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`); and where the provider
4729
- restates the task's record afterwards, the leg is in it or the hole is named
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.73",
3
+ "version": "0.1.74",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",