@xema/omni-protocol 0.1.70 → 0.1.72

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.js CHANGED
@@ -168,8 +168,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
168
168
  set: async (key, value) => { storedSecrets.set(key, value); },
169
169
  delete: async (key) => { storedSecrets.delete(key); },
170
170
  };
171
- const authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
171
+ let authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
172
172
  let connection;
173
+ /** Whether the run holds a client at all: false between a reload's first client going down and its second standing, and after a reload that failed. */
174
+ let clientLive = true;
173
175
  let unsubscribe;
174
176
  let unsubscribeAuthentication;
175
177
  let unsubscribeHost;
@@ -318,10 +320,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
318
320
  requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
319
321
  const eventPayloads = new Map();
320
322
  let capacityStated;
321
- const reloading = { now: false };
322
323
  // The drive waits on events: each waiter is offered every envelope as it lands.
323
324
  const waiters = new Set();
324
- unsubscribe = connection.subscribe(envelope => {
325
+ const onEnvelope = (envelope) => {
325
326
  observeEvent(envelope, seen);
326
327
  const broken = validateEventEnvelope(envelope, adapter.manifest, "event", reader());
327
328
  violations.push(...broken);
@@ -334,7 +335,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
334
335
  // client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
335
336
  // defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
336
337
  // second adapter is up; every other moment it is.
337
- if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
338
+ if (isRecord(envelope?.event) && envelope.event.type === "diagnostic") {
338
339
  violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
339
340
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
340
341
  }
@@ -374,7 +375,45 @@ export async function exerciseAdapter(adapter, context, options = {}) {
374
375
  events.push(envelope);
375
376
  for (const waiter of waiters)
376
377
  waiter(envelope);
377
- });
378
+ };
379
+ unsubscribe = connection.subscribe(onEnvelope);
380
+ // A host reload is the first client dying and a second coming up in its place. The drive hands
381
+ // the run over: the first connection is unsubscribed, disconnected and its session closed before
382
+ // the second connects, and the run continues on the second -- so there is no first client for a
383
+ // platform to push the open task back to, and nothing to exempt.
384
+ const handOver = {
385
+ isLive: () => clientLive,
386
+ down: async () => {
387
+ let clean = true;
388
+ try {
389
+ unsubscribe?.();
390
+ }
391
+ catch {
392
+ clean = false;
393
+ }
394
+ unsubscribe = undefined;
395
+ try {
396
+ await connection?.disconnect();
397
+ }
398
+ catch {
399
+ clean = false;
400
+ }
401
+ try {
402
+ await authentication.close();
403
+ }
404
+ catch {
405
+ clean = false;
406
+ }
407
+ clientLive = false;
408
+ return clean;
409
+ },
410
+ up: (session, second) => {
411
+ authentication = session;
412
+ connection = second;
413
+ unsubscribe = second.subscribe(onEnvelope);
414
+ clientLive = true;
415
+ },
416
+ };
378
417
  const snapshot = await connection.snapshot();
379
418
  observeSnapshot(snapshot, seen);
380
419
  {
@@ -449,7 +488,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
449
488
  violations.push(...await driveOneCall({
450
489
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
451
490
  timeoutMs: options.driveTimeoutMs ?? 5000,
452
- context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
491
+ context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
492
+ streams: { stream, breaks },
453
493
  }));
454
494
  }
455
495
  // Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
@@ -459,9 +499,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
459
499
  // capacity is elsewhere, and the provider allocates nothing and refuses nothing for it. Any offer
460
500
  // after a lower count is caught against it (stream.taskOffered.overCapacity).
461
501
  ruleEvaluated("connection.setCapacity.lowered", "connection.setCapacity.zero");
462
- for (const count of [2, 1, 0]) {
502
+ for (const count of clientLive ? [2, 1, 0] : []) {
463
503
  capacityStated = count;
464
- const restated = await live.setCapacity({ count });
504
+ const restated = await connection.setCapacity({ count });
465
505
  const shape = validateResult(restated, "setCapacity", "connection.setCapacity");
466
506
  violations.push(...shape);
467
507
  if (shape.length === 0 && restated.status === "failed") {
@@ -493,17 +533,20 @@ export async function exerciseAdapter(adapter, context, options = {}) {
493
533
  catch {
494
534
  clean = false;
495
535
  }
496
- try {
497
- await connection?.disconnect();
498
- }
499
- catch {
500
- clean = false;
501
- }
502
- try {
503
- await authentication.close();
504
- }
505
- catch {
506
- clean = false;
536
+ // A run whose reload took the first client down and stood no second holds nothing to close.
537
+ if (clientLive) {
538
+ try {
539
+ await connection?.disconnect();
540
+ }
541
+ catch {
542
+ clean = false;
543
+ }
544
+ try {
545
+ await authentication.close();
546
+ }
547
+ catch {
548
+ clean = false;
549
+ }
507
550
  }
508
551
  disconnectWasClean = clean;
509
552
  }
@@ -1012,7 +1055,7 @@ export class TaskStream {
1012
1055
  case "task-updated":
1013
1056
  if (id === undefined)
1014
1057
  break;
1015
- ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
1058
+ ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
1016
1059
  if (known === undefined) {
1017
1060
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
1018
1061
  break;
@@ -1051,6 +1094,15 @@ export class TaskStream {
1051
1094
  refuse("stream.taskUpdated.handlingHistory", `${at}.task.handlingHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
1052
1095
  }
1053
1096
  }
1097
+ // A task completes after its media ends, never around it: an update that moves a task to
1098
+ // completing while the stream holds its audio as started is a call whose audio never ended,
1099
+ // whoever caused the ending -- the drive's end-call, a transfer, the provider's own hand.
1100
+ {
1101
+ const to = isRecord(event.task) ? String(event.task.phase) : "";
1102
+ if (to === "completing" && known.media === "started" && known.phase !== "completing") {
1103
+ refuse("stream.taskUpdated.mediaOpen", `${at}.task.phase`, `${id} moves to completing with its media still started: the audio ends first, on task-media-ended, and a wrap-up with the call still up is audio that never ended`);
1104
+ }
1105
+ }
1054
1106
  if (known.media === "ended") {
1055
1107
  const phase = isRecord(event.task) ? String(event.task.phase) : "";
1056
1108
  if (phase !== "completing") {
@@ -1189,7 +1241,7 @@ async function driveOneCall(drive) {
1189
1241
  drive.waiters.add(waiter);
1190
1242
  });
1191
1243
  const recordSurvivesReload = async (at) => {
1192
- ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
1244
+ ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history", "drive.reload.handover");
1193
1245
  let again;
1194
1246
  try {
1195
1247
  again = drive.rebuild();
@@ -1198,16 +1250,17 @@ async function driveOneCall(drive) {
1198
1250
  refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
1199
1251
  return;
1200
1252
  }
1201
- let session;
1202
- let second;
1253
+ // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
1254
+ if (again.manifest.id !== drive.manifest.id) {
1255
+ refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1256
+ return;
1257
+ }
1258
+ // The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
1259
+ if (!await drive.handOver.down()) {
1260
+ refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
1261
+ }
1203
1262
  try {
1204
- // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
1205
- if (again.manifest.id !== drive.manifest.id) {
1206
- refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1207
- return;
1208
- }
1209
- drive.reloading.now = true;
1210
- session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1263
+ const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1211
1264
  // The reload is a restore before it is anything else: the second session stands authenticated
1212
1265
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1213
1266
  const restored = await session.state();
@@ -1215,12 +1268,22 @@ async function driveOneCall(drive) {
1215
1268
  const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1216
1269
  if (!same) {
1217
1270
  refuse("drive.reload.login", "drive.reload.login", `a second adapter built from the same login and secrets did not restore it: the session says ${String(isRecord(restored) ? restored.status : restored)}${isRecord(restored) && isRecord(restored.identity) ? ` as ${String(restored.identity.id)}` : ""}, and the login is ${String(drive.reader().self)}`);
1271
+ // A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
1272
+ try {
1273
+ await session.close();
1274
+ }
1275
+ catch { /* the session that would not restore is closed as far as it can be */ }
1218
1276
  return;
1219
1277
  }
1220
- second = await again.connect(drive.context);
1278
+ const second = await again.connect(drive.context);
1279
+ // From here the second client is the run's connection: what it publishes is validated as before.
1280
+ drive.handOver.up(session, second);
1281
+ drive.connection = second;
1221
1282
  const snapshot = await second.snapshot();
1222
- // The second adapter's snapshot is held to everything a first one is.
1283
+ // The second adapter's snapshot is held to everything a first one is, and the streams take it as the state now.
1223
1284
  found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
1285
+ drive.streams.stream.seed(snapshot);
1286
+ drive.streams.breaks.seed(snapshot);
1224
1287
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1225
1288
  ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
1226
1289
  if (carried === undefined) {
@@ -1248,25 +1311,12 @@ async function driveOneCall(drive) {
1248
1311
  else if (leg.mutedBy !== "host") {
1249
1312
  refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
1250
1313
  }
1314
+ // The drive goes on with the task as the reloaded client holds it.
1315
+ task = carried;
1251
1316
  }
1252
1317
  catch (error) {
1253
1318
  refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
1254
1319
  }
1255
- finally {
1256
- try {
1257
- await second?.disconnect();
1258
- }
1259
- catch (error) {
1260
- refuse("drive.reload.rejected", "drive.reload", `the second adapter's disconnect threw: ${String(error)}`);
1261
- }
1262
- try {
1263
- await session?.close();
1264
- }
1265
- catch (error) {
1266
- refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
1267
- }
1268
- drive.reloading.now = false;
1269
- }
1270
1320
  };
1271
1321
  // A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
1272
1322
  // store says which keys were the task's; one still held after task-ended is a key about to be
@@ -1458,8 +1508,12 @@ async function driveOneCall(drive) {
1458
1508
  // 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
1459
1509
  // the same login, a second adapter carries the task and the leg -- from its platform, or from
1460
1510
  // the store -- or it composed the record in memory and the record died with it.
1461
- if (drive.rebuild !== undefined)
1511
+ if (drive.rebuild !== undefined) {
1462
1512
  await recordSurvivesReload(at);
1513
+ // A reload that stood no client ends the run: there is nothing left to drive.
1514
+ if (!drive.handOver.isLive())
1515
+ return found;
1516
+ }
1463
1517
  }
1464
1518
  // 4. Hold and resume, where offered.
1465
1519
  if (latestTask().phase === "in-progress" && offers("hold")) {
@@ -1474,11 +1528,18 @@ async function driveOneCall(drive) {
1474
1528
  // 5. End the call, where the agent may.
1475
1529
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1476
1530
  if (await send({ type: "end-call" }) !== undefined) {
1531
+ // A task that completes with its audio still up is refused by the stream as it passes
1532
+ // (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
1533
+ // that is not coming, so the run reads by the rule and not by its timeout.
1477
1534
  const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
1478
1535
  const event = envelope.event;
1479
- return event.type === "task-media-ended" && event.taskId === taskId ? event : undefined;
1536
+ if (event.type === "task-media-ended" && event.taskId === taskId)
1537
+ return "ended";
1538
+ if (event.type === "task-updated" && isTask(event.task) && event.task.id === taskId && event.task.phase === "completing")
1539
+ return "completing";
1540
+ return undefined;
1480
1541
  }, cursor);
1481
- if (mediaEnded !== undefined)
1542
+ if (mediaEnded?.found === "ended")
1482
1543
  cursor = mediaEnded.at;
1483
1544
  if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1484
1545
  await holdRefusedOutsideHandling();
@@ -1021,6 +1021,12 @@ function validateTaskInto(task, context, path, into) {
1021
1021
  // The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
1022
1022
  // task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
1023
1023
  // wire said about the call stays true for ever. Empty and absent both say nobody.
1024
+ // A completing task's call is over: its media has ended, or it never started. Audio still stated
1025
+ // as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
1026
+ // the agent's notes -- and a task stating it contradicts itself.
1027
+ if (task.phase === "completing" && task.media === "started") {
1028
+ into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started: the call is over, and media still started is audio that never ended");
1029
+ }
1024
1030
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
1025
1031
  into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
1026
1032
  }
package/guide.md CHANGED
@@ -2854,7 +2854,12 @@ which the update itself shows: the party on the call carrying a `stage`. A compl
2854
2854
  republished as `in-progress` from a stale copy carries no such stage, and that is the ending the
2855
2855
  agent never saw. Audio arrives only on a task at work: `task-media-started` on a `completing` task
2856
2856
  is refused as it is on a pending one (`stream.taskMediaStarted.beforeWork`), since a connect-back
2857
- returns the task to `in-progress` before any media.
2857
+ returns the task to `in-progress` before any media. And a task completes after its audio ends,
2858
+ never around it: an update moving a task to `completing` while the stream holds its media as
2859
+ started is refused (`stream.taskUpdated.mediaOpen`), whoever caused the ending, and a task stating
2860
+ `completing` with `media: "started"` contradicts itself on any snapshot or update
2861
+ (`task.media.completing`). The consequence the rule exists for is concrete: the customer's audio
2862
+ keeps playing through the agent's wrap-up.
2858
2863
 
2859
2864
  What the agent is told differs by source, and only one source tells them anything. Under `queue`
2860
2865
  and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
@@ -4641,7 +4646,9 @@ A refusal has an aftermath on the desk and a report to the provider, and both ar
4641
4646
  vanish from the desk while the call is up.
4642
4647
  - **A refused event is dropped and counted.** The state the host holds does not move for it. The
4643
4648
  provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
4644
- nothing.
4649
+ nothing. The desk shows nothing for it: the view kept moving for every other event, so "last
4650
+ update refused" would be false of it, and a chip that said nothing would be right about the
4651
+ transport. Counted, and the provider told, is all a host can truthfully do for one event.
4645
4652
  - **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
4646
4653
  an event, and every violation with its rule and path. An adapter logs it at error and treats it as
4647
4654
  its own defect until shown otherwise: what the host refused never reached the agent, and a
@@ -4710,15 +4717,19 @@ the drive mutes it for one second and reports the leg through `recordStep`, begu
4710
4717
  expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4711
4718
  restates the task's record afterwards, the leg is in it or the hole is named
4712
4719
  (`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
4713
- does, the drive then connects a second adapter for the same login with the same context and the
4714
- same `store`, and expects its snapshot to carry the task with that leg and the host's word: a
4715
- platform that holds the record hands it back, and an adapter that composed the record in memory
4716
- has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`, `.rejected`). The second
4717
- adapter is held to what the first was: the same provider (`drive.reload.manifest`), a snapshot that
4718
- stands as any snapshot must, and a record that lost none of the entries the first had published --
4719
- a record once read is not unread across a reload either. A reconnect on the same adapter object
4720
- would prove nothing, since an in-process adapter's memory survives it; only a second object
4721
- separates kept-in-the-store from never-lost. The second adapter opens its session from the same
4720
+ does, the drive reloads the host as a reload happens: the first client is unsubscribed,
4721
+ disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
4722
+ built and connected for the same login with the same context and the same `store`; its snapshot
4723
+ must carry the task with that leg and the host's word, and the run goes on with the second as its
4724
+ connection to the end. A platform that holds the record hands it back, and an adapter that composed
4725
+ the record in memory has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`,
4726
+ `.rejected`). The second adapter is held to what the first was: the same provider
4727
+ (`drive.reload.manifest`), a snapshot that stands as any snapshot must, and a record that lost none
4728
+ of the entries the first had published -- a record once read is not unread across a reload either.
4729
+ A reconnect on the same adapter object would prove nothing, since an in-process adapter's memory
4730
+ survives it; only a second object separates kept-in-the-store from never-lost. And because the
4731
+ first client is gone before the second connects, there is no client for the platform to push the
4732
+ open task back to, and nothing about the reload is exempt from any rule. The second adapter opens its session from the same
4722
4733
  `secrets`, so the reload is a restore before it is anything else: an adapter that holds a session
4723
4734
  has already put what would rebuild it into the secrets store, from the moment it was handed one,
4724
4735
  not only when a flow completes -- the session is the adapter's, the store is the host's, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",