@xema/omni-protocol 0.1.76 → 0.1.78

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
@@ -240,7 +240,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
240
240
  if (some(capabilities.preferences))
241
241
  requireMethod(on, "setPreference", "the login declares capabilities.preferences");
242
242
  };
243
- unsubscribeAuthentication = authentication.subscribe(state => {
243
+ const onAuthenticationState = (state) => {
244
244
  const own = validateAuthenticationState(state, "authentication", { levels });
245
245
  violations.push(...own);
246
246
  if (own.length > 0)
@@ -254,7 +254,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
254
254
  if (connection !== undefined)
255
255
  requireCapabilityMethods(connection, state.capabilities);
256
256
  }
257
- });
257
+ };
258
+ unsubscribeAuthentication = authentication.subscribe(onAuthenticationState);
258
259
  // The host's report is Omni's output, and a test that hands the adapter a malformed one is
259
260
  // testing a host that cannot exist. Its first report and every later one are validated, a
260
261
  // voice connection's host reports its audio and no other does, and the host the adapter
@@ -282,23 +283,40 @@ export async function exerciseAdapter(adapter, context, options = {}) {
282
283
  const storeShape = validateLoginStore(context.store, "context.store");
283
284
  violations.push(...storeShape);
284
285
  // The harness watches the store it hands over, so what an adapter leaves behind is seen rather than asked for.
285
- const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set() };
286
+ const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set(), everHeld: new Set() };
286
287
  unsubscribeHost = context.host.subscribe(report => {
287
288
  violations.push(...validateHostReport(report, "context.host"));
288
289
  });
289
- let consulted = false;
290
+ const consulted = { report: false, subscribe: false };
290
291
  // The wrapped host carries the mute kind the test's host stated, so the arms of ConnectContext still hold.
291
292
  const host = {
292
293
  guarantees: context.host.guarantees,
293
294
  ...(context.host.mute === undefined ? {} : { mute: context.host.mute }),
294
- report: () => { consulted = true; return context.host.report(); },
295
- subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
295
+ report: () => { consulted.report = true; return context.host.report(); },
296
+ subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
296
297
  };
297
298
  const connected = { ...context, host, store: watched.store };
298
299
  connection = await adapter.connect(connected);
299
- const live = connection;
300
- // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
301
- requireMethod(live, "refused", "every connection is told what the host refused");
300
+ // The standing client: the first connection, then the second after a hand-over. Everything the
301
+ // harness asks of a connection it asks of the one that stands.
302
+ let live = connection;
303
+ // What every connection owes on connect, the first and a reloaded second alike.
304
+ const connectObligations = (on) => {
305
+ // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
306
+ requireMethod(on, "refused", "every connection is told what the host refused");
307
+ // Dial is declared by presence: the capability object carries a destination policy rather
308
+ // than an `enabled` flag, so its presence is the declaration.
309
+ if (adapter.manifest.idleCapabilities?.dial !== undefined)
310
+ requireMethod(on, "dial", "the manifest declares dial");
311
+ // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
312
+ if (softphone)
313
+ requireMethod(on, "openMedia", "the login is on a softphone");
314
+ // The microphone is the host's, so on a softphone every call can be muted by it, and the
315
+ // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
316
+ if (softphone)
317
+ requireMethod(on, "recordStep", "the login is on a softphone, whose microphone the host mutes");
318
+ };
319
+ connectObligations(live);
302
320
  const tellRefused = (report) => {
303
321
  if (typeof live.refused !== "function")
304
322
  return;
@@ -310,17 +328,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
310
328
  violations.push({ rule: "connection.refused.rejected", path: "connection.refused", message: `told of a refusal, the adapter threw: ${String(error)}` });
311
329
  }
312
330
  };
313
- // Dial is declared by presence: the capability object carries a destination policy rather
314
- // than an `enabled` flag, so its presence is the declaration.
315
- if (adapter.manifest.idleCapabilities?.dial !== undefined)
316
- requireMethod(live, "dial", "the manifest declares dial");
317
- // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
318
- if (softphone)
319
- requireMethod(live, "openMedia", "the login is on a softphone");
320
- // The microphone is the host's, so on a softphone every call can be muted by it, and the
321
- // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
322
- if (softphone)
323
- requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
324
331
  const eventPayloads = new Map();
325
332
  let capacityStated;
326
333
  // The drive waits on events: each waiter is offered every envelope as it lands.
@@ -414,23 +421,61 @@ export async function exerciseAdapter(adapter, context, options = {}) {
414
421
  clientLive = false;
415
422
  return clean;
416
423
  },
417
- up: (session, second) => {
424
+ up: async (session, second) => {
418
425
  authentication = session;
419
426
  connection = second;
427
+ live = second;
428
+ // The second client is held to everything the first was on connect: its methods, its login's
429
+ // methods, the login it publishes from here, and a capacity stated to it, since the first's
430
+ // statement died with the first and nothing may be allocated against a count nobody stated to it.
431
+ connectObligations(second);
432
+ requireCapabilityMethods(second, current().capabilities);
433
+ try {
434
+ unsubscribeAuthentication?.();
435
+ }
436
+ catch { /* the closed first session's subscription */ }
437
+ unsubscribeAuthentication = session.subscribe(onAuthenticationState);
420
438
  unsubscribe = second.subscribe(onEnvelope);
421
439
  clientLive = true;
440
+ const restated = await second.setCapacity({ count: capacityStated ?? 1 });
441
+ violations.push(...validateResult(restated, "setCapacity", "drive.reload.setCapacity"));
422
442
  },
423
443
  };
424
- const snapshot = await connection.snapshot();
425
- observeSnapshot(snapshot, seen);
426
- {
427
- const broken = validateSnapshot(snapshot, adapter.manifest, "snapshot", reader());
444
+ // Every snapshot the run reads, the connect snapshot and a reloaded client's, is read the same
445
+ // way: observed, validated and refused back, its terms and locks noted, and every user it names
446
+ // looked up and the answer held to the shape.
447
+ const snapshotRead = async (source, read, path) => {
448
+ observeSnapshot(read, seen);
449
+ const broken = validateSnapshot(read, adapter.manifest, path, reader());
428
450
  violations.push(...broken);
429
451
  if (broken.length > 0)
430
452
  tellRefused({ artefact: "snapshot", violations: broken });
431
- }
432
- violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
433
- lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
453
+ const tasks = isRecord(read) && Array.isArray(read.tasks) ? read.tasks : [];
454
+ violations.push(...undeterminedTasks(tasks, `${path}.tasks`));
455
+ lockedPartySeen ||= tasks.some(locksParty);
456
+ if (publishesUserIds(read)) {
457
+ requireMethod(source, "describeUsers", "the snapshot publishes a UserId");
458
+ // Required by presence is not enough: the names the snapshot published are looked up, and what
459
+ // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
460
+ const named = userIdsIn(read);
461
+ if (typeof source.describeUsers === "function" && named.length > 0) {
462
+ let described;
463
+ try {
464
+ described = await source.describeUsers(named);
465
+ violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
466
+ if (Array.isArray(described) && described.length === 0) {
467
+ violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
468
+ message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
469
+ }
470
+ }
471
+ catch (error) {
472
+ violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
473
+ }
474
+ }
475
+ }
476
+ };
477
+ const snapshot = await connection.snapshot();
478
+ await snapshotRead(live, snapshot, "snapshot");
434
479
  stream.seed(snapshot);
435
480
  breaks.seed(snapshot);
436
481
  seeded = true;
@@ -438,7 +483,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
438
483
  // a state-replacing kind is dropped, since the snapshot carries that state and must account for
439
484
  // it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
440
485
  // transactions no snapshot carries, and are applied after it, in order.
441
- ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
442
486
  const carried = new Set();
443
487
  for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
444
488
  if (isRecord(task) && typeof task.allocationId === "string")
@@ -450,6 +494,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
450
494
  violations.push(...stream.apply(held), ...breaks.apply(held));
451
495
  continue;
452
496
  }
497
+ // Evaluated where a superseded event was held: with none, the question was never asked.
498
+ ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
453
499
  if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
454
500
  violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
455
501
  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` });
@@ -461,36 +507,20 @@ export async function exerciseAdapter(adapter, context, options = {}) {
461
507
  }
462
508
  duringRead.length = 0;
463
509
  requireCapabilityMethods(live, current().capabilities);
464
- if (publishesUserIds(snapshot)) {
465
- requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
466
- // Required by presence is not enough: the names the snapshot published are looked up, and what
467
- // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
468
- const named = userIdsIn(snapshot);
469
- if (typeof live.describeUsers === "function" && named.length > 0) {
470
- let described;
471
- try {
472
- described = await live.describeUsers(named);
473
- violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
474
- if (Array.isArray(described) && described.length === 0) {
475
- violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
476
- message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
477
- }
478
- }
479
- catch (error) {
480
- violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
481
- }
482
- }
483
- }
484
510
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
485
511
  // that will not accept one is a connection nothing can be given to.
486
512
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
487
513
  // ready, and on every change. An adapter that never asked cannot have.
488
- if (adapter.manifest.channel === "voice" && !consulted) {
489
- violations.push({
490
- rule: "connection.host.consulted",
491
- path: "connection.host",
492
- message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
493
- });
514
+ // The obligation has two halves: read the report before declaring the agent ready, and hear of
515
+ // every change after. An adapter that did one and not the other consulted the host once, or never.
516
+ ruleEvaluated("connection.host.consulted", "connection.host.subscribed");
517
+ if (adapter.manifest.channel === "voice" && !consulted.report) {
518
+ violations.push({ rule: "connection.host.consulted", path: "connection.host",
519
+ message: "a voice adapter reads the host's report before it declares the agent ready to its platform, and this one never asked" });
520
+ }
521
+ if (adapter.manifest.channel === "voice" && !consulted.subscribe) {
522
+ violations.push({ rule: "connection.host.subscribed", path: "connection.host",
523
+ message: "a voice adapter subscribes to the host's report so it hears of every change, and this one never did" });
494
524
  }
495
525
  capacityStated = 1;
496
526
  const capacity = await connection.setCapacity({ count: capacityStated });
@@ -508,19 +538,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
508
538
  message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
509
539
  });
510
540
  }
511
- // A refusal is read only from a result that has the shape of one.
512
- if (malformed.length === 0 && capacity.status === "failed") {
513
- violations.push({
514
- rule: "connection.setCapacity.failed",
515
- path: "connection.setCapacity",
516
- message: `the provider would not accept a capacity: ${capacity.failure.code}`,
517
- });
518
- }
519
541
  if (options.drive) {
520
542
  const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
521
543
  ? first.audio.input.localAudio : undefined;
522
544
  violations.push(...await driveOneCall({
523
545
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
546
+ everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
524
547
  timeoutMs: options.driveTimeoutMs ?? 5000,
525
548
  context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
526
549
  streams: { stream, breaks },
@@ -532,19 +555,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
532
555
  // five to three, so the exercise moves the axis both ways. Zero is host-stopped: the agent's
533
556
  // capacity is elsewhere, and the provider allocates nothing and refuses nothing for it. Any offer
534
557
  // after a lower count is caught against it (stream.taskOffered.overCapacity).
535
- ruleEvaluated("connection.setCapacity.lowered", "connection.setCapacity.zero");
558
+ // A capacity is taken, never refused: each restatement is answered applied and nothing else
559
+ // (validateResult), and any offer after a lower count is caught against it.
536
560
  for (const count of clientLive ? [2, 1, 0] : []) {
537
561
  capacityStated = count;
538
562
  const restated = await connection.setCapacity({ count });
539
- const shape = validateResult(restated, "setCapacity", "connection.setCapacity");
540
- violations.push(...shape);
541
- if (shape.length === 0 && restated.status === "failed") {
542
- violations.push({ rule: count === 0 ? "connection.setCapacity.zero" : "connection.setCapacity.lowered", path: "connection.setCapacity",
543
- message: count === 0
544
- ? `the provider would not take a capacity of zero: ${restated.failure.code}; zero is host-stopped, the agent's capacity being elsewhere, and a provider allocates nothing and refuses nothing for it`
545
- : `the provider would not take a capacity of ${count} after a higher one: ${restated.failure.code}; capacity supersedes, and a decrease is as ordinary as an increase` });
546
- break;
547
- }
563
+ violations.push(...validateResult(restated, "setCapacity", "connection.setCapacity"));
548
564
  }
549
565
  }
550
566
  finally {
@@ -737,6 +753,10 @@ export function assertAuthenticationRestoreAndExpiry(states) {
737
753
  }
738
754
  }
739
755
  /** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
756
+ /** Whether a store key names the task: the id as a delimited token, never as a run of characters inside another id. */
757
+ function namesTask(key, taskId) {
758
+ return key.split(/[^A-Za-z0-9_-]+/).includes(taskId);
759
+ }
740
760
  /** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
741
761
  function locksParty(task) {
742
762
  if (!isRecord(task) || !isRecord(task.party))
@@ -794,7 +814,9 @@ export function assertCapabilityWithdrawal(states, snapshot, manifest) {
794
814
  (before.team !== undefined && after.team === undefined) ||
795
815
  (before.team?.breakControl === true && after.team?.breakControl !== true) ||
796
816
  (before.team?.leadAssistControl === true && after.team?.leadAssistControl !== true) ||
797
- (before.team?.monitorControl !== undefined && after.team?.monitorControl === undefined);
817
+ (before.team?.monitorControl !== undefined && after.team?.monitorControl === undefined) ||
818
+ (before.team?.policyControl === true && after.team?.policyControl !== true) ||
819
+ (before.preferences ?? []).some(was => !(after.preferences ?? []).some(now => now.id === was.id));
798
820
  if (!withdrawn) {
799
821
  throw new Error("Capability withdrawal must end with at least one capability the first login declared withdrawn");
800
822
  }
@@ -1013,10 +1035,16 @@ export class TaskStream {
1013
1035
  return false;
1014
1036
  return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
1015
1037
  }
1016
- /** Whether the update shows the party being dialled again -- a connect-back, a platform's callback -- the one thing that brings a completing task back. */
1017
- static partyDialled(task) {
1018
- return isRecord(task) && Array.isArray(task.onCall)
1019
- && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
1038
+ /**
1039
+ * Whether the update shows the party being dialled again -- a connect-back, a platform's callback --
1040
+ * the one thing that brings a completing task back: the party ringing, or joined by a dial whose
1041
+ * answered outcome this stream saw. A stale copy carrying `joined` from a dial long over is not that.
1042
+ */
1043
+ partyDialled(task) {
1044
+ if (!isRecord(task) || !Array.isArray(task.onCall))
1045
+ return false;
1046
+ return task.onCall.some(entry => isRecord(entry) && entry.role === "party"
1047
+ && (entry.stage === "ringing" || (entry.stage === "joined" && typeof entry.dialId === "string" && this.dials.get(entry.dialId) === "answered")));
1020
1048
  }
1021
1049
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
1022
1050
  static record(task) {
@@ -1043,10 +1071,55 @@ export class TaskStream {
1043
1071
  stages.set(entry.dialId, entry.stage);
1044
1072
  }
1045
1073
  }
1046
- return { channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1074
+ const partyRingingByHost = isRecord(task) && Array.isArray(task.onCall)
1075
+ && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
1076
+ return { partyRingingByHost, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1047
1077
  wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
1048
1078
  phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
1049
1079
  }
1080
+ /**
1081
+ * Takes a resync snapshot -- a `snapshot` event, or the read a reloaded client makes -- as the
1082
+ * state now, and first holds it to what the stream knew. A snapshot replaces state; it does not
1083
+ * get to forget it. A task it carries that the stream already held may not go backwards, lose an
1084
+ * entry of its record, return to undetermined terms, or be at work without the audio the stream
1085
+ * held up: media ends on task-media-ended and the call moves on, so a task still in-progress or
1086
+ * paused without its media is a snapshot that lost state, not a call that ended.
1087
+ */
1088
+ resync(snapshot, at) {
1089
+ const found = [];
1090
+ const refuse = (rule, where, message) => found.push({ rule, path: where, message });
1091
+ ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory", "stream.snapshot.phase", "stream.snapshot.media");
1092
+ if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
1093
+ snapshot.tasks.forEach((task, index) => {
1094
+ if (!isRecord(task) || typeof task.id !== "string")
1095
+ return;
1096
+ const was = this.tasks.get(task.id);
1097
+ if (was === undefined || was.allocation !== String(task.allocationId))
1098
+ return;
1099
+ if ((was.source === "queue" || was.source === "ungoverned") && task.capabilitySource === "undetermined") {
1100
+ refuse("stream.snapshot.capabilitySource", `${at}.tasks[${index}].capabilitySource`, `${task.id} was published under ${was.source} terms and the snapshot says undetermined: terms once read stay read`);
1101
+ }
1102
+ // A record once read is not unread: a resync restates it whole, or with more, never with less.
1103
+ const lost = TaskStream.lost(was.record, TaskStream.record(task));
1104
+ if (lost.length > 0) {
1105
+ refuse("stream.snapshot.handlingHistory", `${at}.tasks[${index}].handlingHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
1106
+ }
1107
+ const to = String(task.phase);
1108
+ const reachable = REACHABLE_PHASES[was.phase];
1109
+ if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
1110
+ const connectingBack = was.phase === "completing" && this.partyDialled(task) && (to === "in-progress" || to === "paused");
1111
+ if (!connectingBack) {
1112
+ refuse("stream.snapshot.phase", `${at}.tasks[${index}].phase`, `${task.id} was ${was.phase} and the snapshot says ${to}: a task does not go backwards, on an update or on a resync`);
1113
+ }
1114
+ }
1115
+ if (was.media === "started" && (to === "in-progress" || to === "paused") && task.media !== "started") {
1116
+ refuse("stream.snapshot.media", `${at}.tasks[${index}].media`, `${task.id}'s audio was up and the snapshot carries it ${to} without it: media ends on task-media-ended and the call moves on, so a snapshot that forgets the audio lost state`);
1117
+ }
1118
+ });
1119
+ }
1120
+ this.seed(snapshot);
1121
+ return found;
1122
+ }
1050
1123
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
1051
1124
  seed(snapshot) {
1052
1125
  this.tasks.clear();
@@ -1073,24 +1146,7 @@ export class TaskStream {
1073
1146
  const known = id === undefined ? undefined : this.tasks.get(id);
1074
1147
  switch (event.type) {
1075
1148
  case "snapshot":
1076
- ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory");
1077
- // A snapshot replaces what is known, and still may not say a task lost terms it had read.
1078
- if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
1079
- event.snapshot.tasks.forEach((task, index) => {
1080
- if (!isRecord(task) || typeof task.id !== "string")
1081
- return;
1082
- const was = this.tasks.get(task.id);
1083
- if (was !== undefined && (was.source === "queue" || was.source === "ungoverned") && task.capabilitySource === "undetermined") {
1084
- refuse("stream.snapshot.capabilitySource", `${at}.snapshot.tasks[${index}].capabilitySource`, `${task.id} was published under ${was.source} terms and the snapshot says undetermined: terms once read stay read`);
1085
- }
1086
- // A record once read is not unread: a resync restates it whole, or with more, never with less.
1087
- const lost = TaskStream.lost(was?.record, TaskStream.record(task));
1088
- if (lost.length > 0) {
1089
- refuse("stream.snapshot.handlingHistory", `${at}.snapshot.tasks[${index}].handlingHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
1090
- }
1091
- });
1092
- }
1093
- this.seed(event.snapshot);
1149
+ found.push(...this.resync(event.snapshot, `${at}.snapshot`));
1094
1150
  break;
1095
1151
  case "task-offered": {
1096
1152
  ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
@@ -1112,11 +1168,13 @@ export class TaskStream {
1112
1168
  case "task-updated":
1113
1169
  if (id === undefined)
1114
1170
  break;
1115
- 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");
1171
+ ruleEvaluated("stream.taskUpdated.unknown");
1116
1172
  if (known === undefined) {
1117
1173
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
1118
1174
  break;
1119
1175
  }
1176
+ // The rules about a known task are evaluated only once there is one.
1177
+ ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
1120
1178
  // Terms once read stay read. A re-read that fails is not a new fact about the task, so the
1121
1179
  // last statement stands and the failure is a diagnostic; undetermined is a place a task
1122
1180
  // starts from, never one it returns to.
@@ -1138,7 +1196,7 @@ export class TaskStream {
1138
1196
  const to = isRecord(event.task) ? String(event.task.phase) : "";
1139
1197
  const reachable = REACHABLE_PHASES[from];
1140
1198
  if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
1141
- const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
1199
+ const connectingBack = from === "completing" && this.partyDialled(event.task) && (to === "in-progress" || to === "paused");
1142
1200
  if (!connectingBack) {
1143
1201
  refuse("stream.taskUpdated.phase", `${at}.task.phase`, `${id} was ${from} and the update says ${to}: a task does not go backwards, and ${from === "completing" ? "only the party being dialled again, a connect-back or a callback, with its stage on the call, brings a completing task back" : `${to} is not reachable from ${from}`}`);
1144
1202
  }
@@ -1160,9 +1218,10 @@ export class TaskStream {
1160
1218
  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`);
1161
1219
  }
1162
1220
  }
1221
+ // The connect-back that brings a completing task back is the one exception here too.
1163
1222
  if (known.media === "ended") {
1164
1223
  const phase = isRecord(event.task) ? String(event.task.phase) : "";
1165
- if (phase !== "completing") {
1224
+ if (phase !== "completing" && !(known.phase === "completing" && this.partyDialled(event.task))) {
1166
1225
  refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
1167
1226
  }
1168
1227
  }
@@ -1181,6 +1240,11 @@ export class TaskStream {
1181
1240
  if (stage === "joined" && known.stages.get(dialId) === "ringing" && this.dials.get(dialId) !== "answered") {
1182
1241
  refuse("stream.taskUpdated.stage", `${at}.task.onCall`, `a task-updated re-states a stage, it does not move it: ${dialId} was ringing and the update says joined before any answered dial-outcome for it`);
1183
1242
  }
1243
+ // joined is transient: it appears on the publication that follows the answered outcome and
1244
+ // on no later one, so a stale copy still carrying it cannot pass as a connect-back.
1245
+ if (stage === "joined" && known.stages.get(dialId) === "joined") {
1246
+ refuse("stream.taskUpdated.stage.lingering", `${at}.task.onCall`, `${dialId} was already published joined and the update says it again: joined is transient, stated once on the publication after the answered outcome, and a party with no stage is on the call`);
1247
+ }
1184
1248
  }
1185
1249
  this.tasks.set(id, next);
1186
1250
  }
@@ -1196,7 +1260,9 @@ export class TaskStream {
1196
1260
  }
1197
1261
  if (!this.namesTheOpenLife(event, known, at, refuse))
1198
1262
  break;
1199
- if (!AT_WORK.has(known.phase)) {
1263
+ // Ring-back is audio: a task whose party the host is dialling has media from dialling on,
1264
+ // whatever its phase; every other task not at work has none.
1265
+ if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
1200
1266
  refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1201
1267
  }
1202
1268
  if (known.media === "started") {
@@ -1233,6 +1299,13 @@ export class TaskStream {
1233
1299
  // A late ending for a life that is over must not end the life that is open under the same id.
1234
1300
  if (!this.namesTheOpenLife(event, known, at, refuse))
1235
1301
  break;
1302
+ // A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
1303
+ // and a lead leaving included. An ending with the audio still up leaves the host holding an
1304
+ // open media session and an open leg on a task that no longer exists.
1305
+ ruleEvaluated("stream.taskEnded.mediaOpen");
1306
+ if (known.media === "started") {
1307
+ refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
1308
+ }
1236
1309
  // Off voice there is no media event, so the update that moves a task to completing is the
1237
1310
  // provider's word that handling ended and the moment the wrap allowance starts. A provider
1238
1311
  // that completes the task itself with an allowance to run has to have started the clock:
@@ -1306,8 +1379,11 @@ async function driveOneCall(drive) {
1306
1379
  };
1307
1380
  drive.waiters.add(waiter);
1308
1381
  });
1382
+ // The call's media session, opened on the first client and opened again on a reloaded second.
1383
+ let session;
1309
1384
  const recordSurvivesReload = async (at) => {
1310
- ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history", "drive.reload.handover");
1385
+ // Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
1386
+ ruleEvaluated("drive.reload.rejected");
1311
1387
  let again;
1312
1388
  try {
1313
1389
  again = drive.rebuild();
@@ -1317,46 +1393,54 @@ async function driveOneCall(drive) {
1317
1393
  return;
1318
1394
  }
1319
1395
  // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
1396
+ ruleEvaluated("drive.reload.manifest");
1320
1397
  if (again.manifest.id !== drive.manifest.id) {
1321
1398
  refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1322
1399
  return;
1323
1400
  }
1324
1401
  // The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
1402
+ ruleEvaluated("drive.reload.handover");
1325
1403
  if (!await drive.handOver.down()) {
1326
1404
  refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
1327
1405
  }
1328
1406
  try {
1329
- const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1407
+ const restoredSession = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1330
1408
  // The reload is a restore before it is anything else: the second session stands authenticated
1331
1409
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1332
- const restored = await session.state();
1410
+ const restored = await restoredSession.state();
1333
1411
  found.push(...validateAuthenticationState(restored, "drive.reload.login"));
1412
+ ruleEvaluated("drive.reload.login");
1334
1413
  const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1335
1414
  if (!same) {
1336
1415
  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)}`);
1337
1416
  // A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
1338
1417
  try {
1339
- await session.close();
1418
+ await restoredSession.close();
1340
1419
  }
1341
1420
  catch { /* the session that would not restore is closed as far as it can be */ }
1342
1421
  return;
1343
1422
  }
1344
1423
  const second = await again.connect(drive.context);
1345
- // From here the second client is the run's connection: what it publishes is validated as before.
1346
- drive.handOver.up(session, second);
1424
+ // From here the second client is the run's connection, held to everything the first was on
1425
+ // connect: what it publishes is validated as before.
1426
+ await drive.handOver.up(restoredSession, second);
1347
1427
  drive.connection = second;
1348
1428
  const snapshot = await second.snapshot();
1349
- // The second adapter's snapshot is held to everything a first one is, and the streams take it as the state now.
1350
- found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
1351
- drive.streams.stream.seed(snapshot);
1429
+ // The second adapter's snapshot is read as the connect snapshot was, and the streams take it
1430
+ // as the state now the way they take any resync: held to what they knew, then replaced. A reload
1431
+ // is a place the stream's rules keep working, not one where they all stop.
1432
+ await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
1433
+ found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
1352
1434
  drive.streams.breaks.seed(snapshot);
1353
1435
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1354
1436
  ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
1437
+ ruleEvaluated("drive.reload.snapshot");
1355
1438
  if (carried === undefined) {
1356
1439
  refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
1357
1440
  return;
1358
1441
  }
1359
1442
  // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1443
+ ruleEvaluated("drive.reload.allocation", "drive.reload.history");
1360
1444
  if (carried.allocationId !== allocationOf()) {
1361
1445
  refuse("drive.reload.allocation", "drive.reload.allocation", `a second adapter built from the same login carries ${taskId} as allocation ${String(carried.allocationId)}; the first published ${allocationOf()}, and a life does not change its name on a reload`);
1362
1446
  }
@@ -1377,8 +1461,28 @@ async function driveOneCall(drive) {
1377
1461
  else if (leg.mutedBy !== "host") {
1378
1462
  refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
1379
1463
  }
1380
- // The drive goes on with the task as the reloaded client holds it.
1464
+ // The drive goes on with the task as the reloaded client holds it. The first client's media
1465
+ // session died with it: a host opens media on a task arriving with media started on a
1466
+ // snapshot, so the drive opens it again on the second and holds the answer as it held the first.
1381
1467
  task = carried;
1468
+ if (drive.softphone && carried.media === "started") {
1469
+ if (session !== undefined && typeof session.close === "function") {
1470
+ try {
1471
+ session.close();
1472
+ }
1473
+ catch {
1474
+ refuse("drive.openMedia.close", "drive.openMedia", "the first client's media session threw on close");
1475
+ }
1476
+ }
1477
+ session = undefined;
1478
+ ruleEvaluated("drive.reload.openMedia");
1479
+ const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1480
+ found.push(...validateResult(reopened, "openMedia", "drive.reload.openMedia"));
1481
+ if (isRecord(reopened) && reopened.status === "opened")
1482
+ session = reopened.session;
1483
+ else
1484
+ refuse("drive.reload.openMedia", "drive.reload.openMedia", `a second adapter built from the same login could not open the audio of ${taskId}, which its own snapshot carries with media started`);
1485
+ }
1382
1486
  }
1383
1487
  catch (error) {
1384
1488
  refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
@@ -1389,11 +1493,15 @@ async function driveOneCall(drive) {
1389
1493
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1390
1494
  let retainedAtEnd;
1391
1495
  const storeReleased = async () => {
1392
- ruleEvaluated("drive.store.retained");
1393
1496
  await Promise.resolve();
1394
1497
  await Promise.resolve();
1395
- const retained = [...drive.held].filter(key => key.includes(taskId));
1498
+ // A task's key names the task delimited, not as a run of characters inside another id, and the
1499
+ // rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
1500
+ // name nothing the harness can see leaves the rule unasked, which the result says.
1501
+ const retained = [...drive.held].filter(key => namesTask(key, taskId));
1396
1502
  retainedAtEnd = new Set(retained);
1503
+ if (drive.everHeldForTask(taskId))
1504
+ ruleEvaluated("drive.store.retained");
1397
1505
  if (retained.length > 0) {
1398
1506
  refuse("drive.store.retained", "drive.store", `${taskId} has ended and the login's store still holds ${retained.join(", ")}: a task's keys go with the task, or the next offer of the same id inherits them`);
1399
1507
  }
@@ -1404,7 +1512,7 @@ async function driveOneCall(drive) {
1404
1512
  if (retainedAtEnd === undefined)
1405
1513
  return;
1406
1514
  ruleEvaluated("drive.store.late");
1407
- const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1515
+ const late = [...drive.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
1408
1516
  if (late.length > 0) {
1409
1517
  refuse("drive.store.late", "drive.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
1410
1518
  }
@@ -1441,11 +1549,11 @@ async function driveOneCall(drive) {
1441
1549
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1442
1550
  ruleEvaluated("drive.timeout");
1443
1551
  const send = async (command, dialId) => {
1444
- ruleEvaluated("drive.command.rejected", "drive.command.failed");
1445
1552
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1446
1553
  found.push(...own);
1447
1554
  if (own.length > 0)
1448
1555
  return undefined;
1556
+ ruleEvaluated("drive.command.rejected");
1449
1557
  let result;
1450
1558
  try {
1451
1559
  result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
@@ -1454,6 +1562,7 @@ async function driveOneCall(drive) {
1454
1562
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
1455
1563
  return undefined;
1456
1564
  }
1565
+ ruleEvaluated("drive.command.failed");
1457
1566
  found.push(...validateResult(result, "execute", `drive.command.${String(command.type)}.result`, dialId));
1458
1567
  if (isRecord(result) && result.status === "failed") {
1459
1568
  refuse("drive.command.failed", `drive.command.${String(command.type)}`, `the provider refused ${String(command.type)} on a task that offered it: ${String(isRecord(result.failure) ? result.failure.code : result.failure)}`);
@@ -1510,7 +1619,6 @@ async function driveOneCall(drive) {
1510
1619
  return found;
1511
1620
  }
1512
1621
  // 3. On a softphone, the audio arrives and the host opens it.
1513
- let session;
1514
1622
  if (drive.softphone && latestTask().phase === "in-progress") {
1515
1623
  if (latestTask().media !== "started") {
1516
1624
  const started = await waitFor("task-media-started for the driven task", envelope => {
@@ -1532,6 +1640,13 @@ async function driveOneCall(drive) {
1532
1640
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1533
1641
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1534
1642
  const reportedLegs = [];
1643
+ /** The duration the provider's record states for the leg at `at`, as the task stands now. */
1644
+ const closedAs = (at) => {
1645
+ const history = latestTask().handlingHistory;
1646
+ const leg = isRecord(history) && Array.isArray(history.steps)
1647
+ ? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
1648
+ return typeof leg?.seconds === "number" ? leg.seconds : undefined;
1649
+ };
1535
1650
  const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
1536
1651
  const report = async (body) => {
1537
1652
  ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
@@ -1555,6 +1670,9 @@ async function driveOneCall(drive) {
1555
1670
  }
1556
1671
  };
1557
1672
  const setMuted = (muted) => {
1673
+ // No session, nothing to mute: a reload whose second client could not open the audio has been named already.
1674
+ if (session === undefined)
1675
+ return;
1558
1676
  try {
1559
1677
  session.setMuted(muted);
1560
1678
  }
@@ -1599,8 +1717,9 @@ async function driveOneCall(drive) {
1599
1717
  // after the media has ended.
1600
1718
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1601
1719
  let openLeg;
1602
- if (canRecordMute) {
1603
- openLeg = { at: new Date().toISOString(), began: Date.now() };
1720
+ if (canRecordMute && session !== undefined) {
1721
+ // The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
1722
+ openLeg = { at: new Date().toISOString(), began: Date.now(), task: latestTask() };
1604
1723
  setMuted(true);
1605
1724
  await report({ at: openLeg.at });
1606
1725
  }
@@ -1618,13 +1737,17 @@ async function driveOneCall(drive) {
1618
1737
  }, cursor);
1619
1738
  if (mediaEnded?.found === "ended")
1620
1739
  cursor = mediaEnded.at;
1740
+ const completing = await updated(t => t.phase === "completing", "the task completing after its media ended");
1621
1741
  if (openLeg !== undefined) {
1622
1742
  setMuted(false);
1623
- // Stated at the record's grain: a leg that rounds to nought is 1.
1743
+ // The provider closed this leg at media end, in the completing publication; the host's report,
1744
+ // stated at the record's grain (a leg that rounds to nought is 1), changes nothing, and the
1745
+ // record as it stands now is what every later restatement is held to.
1746
+ const before = closedAs(openLeg.at);
1624
1747
  await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
1625
- reportedLegs.push({ at: openLeg.at, task: latestTask() });
1748
+ reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
1626
1749
  }
1627
- if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1750
+ if (completing !== undefined && offers("hold")) {
1628
1751
  await holdRefusedOutsideHandling();
1629
1752
  }
1630
1753
  }
@@ -1676,11 +1799,21 @@ async function driveOneCall(drive) {
1676
1799
  if (await ended() !== undefined)
1677
1800
  await storeReleased();
1678
1801
  }
1679
- // The record kept by the provider has every leg the host reported, wherever the provider restated it.
1802
+ // The record kept by the provider has every leg the host reported, wherever the provider restated it:
1803
+ // the last publication of the task is read, whether or not the drive was waiting on it.
1804
+ const lastPublished = () => {
1805
+ for (let index = drive.events.length - 1; index >= 0; index -= 1) {
1806
+ const event = drive.events[index].event;
1807
+ if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.id === taskId)
1808
+ return event.task;
1809
+ }
1810
+ return latestTask();
1811
+ };
1680
1812
  for (const reported of reportedLegs) {
1681
- if (latestTask() === reported.task)
1813
+ const published = lastPublished();
1814
+ if (published === reported.task)
1682
1815
  continue;
1683
- const history = latestTask().handlingHistory;
1816
+ const history = published.handlingHistory;
1684
1817
  if (isRecord(history) && Array.isArray(history.steps)) {
1685
1818
  const { at } = reported;
1686
1819
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
@@ -1690,6 +1823,12 @@ async function driveOneCall(drive) {
1690
1823
  else if (leg.mutedBy !== "host") {
1691
1824
  refuse("drive.recordStep.history", "drive.recordStep", `the record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host, and the record keeps the host's word`);
1692
1825
  }
1826
+ else if (reported.closedAs !== undefined) {
1827
+ ruleEvaluated("drive.recordStep.overwritten");
1828
+ if (leg.seconds !== reported.closedAs) {
1829
+ refuse("drive.recordStep.overwritten", "drive.recordStep", `the provider closed the leg at ${at} with ${reported.closedAs}s at media end and the record now says ${String(leg.seconds)}s: the host's closing report changes nothing`);
1830
+ }
1831
+ }
1693
1832
  }
1694
1833
  }
1695
1834
  // A timer fires after the microtasks: one turn of the loop is what a late persist needs to show itself.
@@ -1777,11 +1916,13 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
1777
1916
  /** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
1778
1917
  function watchStore(store) {
1779
1918
  const held = new Set();
1919
+ const everHeld = new Set();
1780
1920
  return {
1781
1921
  held,
1922
+ everHeld,
1782
1923
  store: {
1783
1924
  get: key => store.get(key),
1784
- set: async (key, value) => { await store.set(key, value); held.add(key); },
1925
+ set: async (key, value) => { await store.set(key, value); held.add(key); everHeld.add(key); },
1785
1926
  delete: async (key) => { await store.delete(key); held.delete(key); },
1786
1927
  },
1787
1928
  };