@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/index.d.ts +49 -30
- package/dist/index.js +8 -3
- package/dist/testing.d.ts +15 -2
- package/dist/testing.js +266 -125
- package/dist/validation.js +41 -15
- package/guide.md +123 -73
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
//
|
|
301
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1346
|
-
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
1748
|
+
reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
|
|
1626
1749
|
}
|
|
1627
|
-
if (
|
|
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
|
-
|
|
1813
|
+
const published = lastPublished();
|
|
1814
|
+
if (published === reported.task)
|
|
1682
1815
|
continue;
|
|
1683
|
-
const history =
|
|
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
|
};
|