@xema/omni-protocol 0.1.77 → 0.1.79
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/README.md +8 -1
- package/dist/index.d.ts +49 -30
- package/dist/index.js +8 -3
- package/dist/testing.d.ts +6 -2
- package/dist/testing.js +235 -112
- package/dist/validation.js +192 -77
- package/guide.md +128 -86
- package/package.json +6 -2
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,7 +1071,9 @@ 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
|
}
|
|
@@ -1077,7 +1107,7 @@ export class TaskStream {
|
|
|
1077
1107
|
const to = String(task.phase);
|
|
1078
1108
|
const reachable = REACHABLE_PHASES[was.phase];
|
|
1079
1109
|
if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
|
|
1080
|
-
const connectingBack = was.phase === "completing" &&
|
|
1110
|
+
const connectingBack = was.phase === "completing" && this.partyDialled(task) && (to === "in-progress" || to === "paused");
|
|
1081
1111
|
if (!connectingBack) {
|
|
1082
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`);
|
|
1083
1113
|
}
|
|
@@ -1138,11 +1168,13 @@ export class TaskStream {
|
|
|
1138
1168
|
case "task-updated":
|
|
1139
1169
|
if (id === undefined)
|
|
1140
1170
|
break;
|
|
1141
|
-
ruleEvaluated("stream.taskUpdated.unknown"
|
|
1171
|
+
ruleEvaluated("stream.taskUpdated.unknown");
|
|
1142
1172
|
if (known === undefined) {
|
|
1143
1173
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
1144
1174
|
break;
|
|
1145
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");
|
|
1146
1178
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1147
1179
|
// last statement stands and the failure is a diagnostic; undetermined is a place a task
|
|
1148
1180
|
// starts from, never one it returns to.
|
|
@@ -1164,7 +1196,7 @@ export class TaskStream {
|
|
|
1164
1196
|
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1165
1197
|
const reachable = REACHABLE_PHASES[from];
|
|
1166
1198
|
if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
|
|
1167
|
-
const connectingBack = from === "completing" &&
|
|
1199
|
+
const connectingBack = from === "completing" && this.partyDialled(event.task) && (to === "in-progress" || to === "paused");
|
|
1168
1200
|
if (!connectingBack) {
|
|
1169
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}`}`);
|
|
1170
1202
|
}
|
|
@@ -1186,9 +1218,10 @@ export class TaskStream {
|
|
|
1186
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`);
|
|
1187
1219
|
}
|
|
1188
1220
|
}
|
|
1221
|
+
// The connect-back that brings a completing task back is the one exception here too.
|
|
1189
1222
|
if (known.media === "ended") {
|
|
1190
1223
|
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1191
|
-
if (phase !== "completing") {
|
|
1224
|
+
if (phase !== "completing" && !(known.phase === "completing" && this.partyDialled(event.task))) {
|
|
1192
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`);
|
|
1193
1226
|
}
|
|
1194
1227
|
}
|
|
@@ -1207,13 +1240,18 @@ export class TaskStream {
|
|
|
1207
1240
|
if (stage === "joined" && known.stages.get(dialId) === "ringing" && this.dials.get(dialId) !== "answered") {
|
|
1208
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`);
|
|
1209
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
|
+
}
|
|
1210
1248
|
}
|
|
1211
1249
|
this.tasks.set(id, next);
|
|
1212
1250
|
}
|
|
1213
1251
|
this.noteDials(event.task);
|
|
1214
1252
|
break;
|
|
1215
1253
|
case "task-media-started":
|
|
1216
|
-
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1254
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1217
1255
|
if (id === undefined)
|
|
1218
1256
|
break;
|
|
1219
1257
|
if (known === undefined) {
|
|
@@ -1222,7 +1260,13 @@ export class TaskStream {
|
|
|
1222
1260
|
}
|
|
1223
1261
|
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1224
1262
|
break;
|
|
1225
|
-
|
|
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 (known.channel !== "voice") {
|
|
1266
|
+
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
|
|
1226
1270
|
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`);
|
|
1227
1271
|
}
|
|
1228
1272
|
if (known.media === "started") {
|
|
@@ -1231,7 +1275,7 @@ export class TaskStream {
|
|
|
1231
1275
|
known.media = "started";
|
|
1232
1276
|
break;
|
|
1233
1277
|
case "task-media-ended":
|
|
1234
|
-
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1278
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1235
1279
|
if (id === undefined)
|
|
1236
1280
|
break;
|
|
1237
1281
|
if (known === undefined) {
|
|
@@ -1240,6 +1284,10 @@ export class TaskStream {
|
|
|
1240
1284
|
}
|
|
1241
1285
|
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1242
1286
|
break;
|
|
1287
|
+
if (known.channel !== "voice") {
|
|
1288
|
+
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1243
1291
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
1244
1292
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1245
1293
|
}
|
|
@@ -1259,6 +1307,13 @@ export class TaskStream {
|
|
|
1259
1307
|
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1260
1308
|
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1261
1309
|
break;
|
|
1310
|
+
// A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
|
|
1311
|
+
// and a lead leaving included. An ending with the audio still up leaves the host holding an
|
|
1312
|
+
// open media session and an open leg on a task that no longer exists.
|
|
1313
|
+
ruleEvaluated("stream.taskEnded.mediaOpen");
|
|
1314
|
+
if (known.media === "started") {
|
|
1315
|
+
refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
|
|
1316
|
+
}
|
|
1262
1317
|
// Off voice there is no media event, so the update that moves a task to completing is the
|
|
1263
1318
|
// provider's word that handling ended and the moment the wrap allowance starts. A provider
|
|
1264
1319
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
@@ -1332,8 +1387,11 @@ async function driveOneCall(drive) {
|
|
|
1332
1387
|
};
|
|
1333
1388
|
drive.waiters.add(waiter);
|
|
1334
1389
|
});
|
|
1390
|
+
// The call's media session, opened on the first client and opened again on a reloaded second.
|
|
1391
|
+
let session;
|
|
1335
1392
|
const recordSurvivesReload = async (at) => {
|
|
1336
|
-
|
|
1393
|
+
// Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
|
|
1394
|
+
ruleEvaluated("drive.reload.rejected");
|
|
1337
1395
|
let again;
|
|
1338
1396
|
try {
|
|
1339
1397
|
again = drive.rebuild();
|
|
@@ -1343,48 +1401,54 @@ async function driveOneCall(drive) {
|
|
|
1343
1401
|
return;
|
|
1344
1402
|
}
|
|
1345
1403
|
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
1404
|
+
ruleEvaluated("drive.reload.manifest");
|
|
1346
1405
|
if (again.manifest.id !== drive.manifest.id) {
|
|
1347
1406
|
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
1348
1407
|
return;
|
|
1349
1408
|
}
|
|
1350
1409
|
// The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
|
|
1410
|
+
ruleEvaluated("drive.reload.handover");
|
|
1351
1411
|
if (!await drive.handOver.down()) {
|
|
1352
1412
|
refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
|
|
1353
1413
|
}
|
|
1354
1414
|
try {
|
|
1355
|
-
const
|
|
1415
|
+
const restoredSession = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1356
1416
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1357
1417
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1358
|
-
const restored = await
|
|
1418
|
+
const restored = await restoredSession.state();
|
|
1359
1419
|
found.push(...validateAuthenticationState(restored, "drive.reload.login"));
|
|
1420
|
+
ruleEvaluated("drive.reload.login");
|
|
1360
1421
|
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
|
|
1361
1422
|
if (!same) {
|
|
1362
1423
|
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)}`);
|
|
1363
1424
|
// A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
|
|
1364
1425
|
try {
|
|
1365
|
-
await
|
|
1426
|
+
await restoredSession.close();
|
|
1366
1427
|
}
|
|
1367
1428
|
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1368
1429
|
return;
|
|
1369
1430
|
}
|
|
1370
1431
|
const second = await again.connect(drive.context);
|
|
1371
|
-
// From here the second client is the run's connection
|
|
1372
|
-
|
|
1432
|
+
// From here the second client is the run's connection, held to everything the first was on
|
|
1433
|
+
// connect: what it publishes is validated as before.
|
|
1434
|
+
await drive.handOver.up(restoredSession, second);
|
|
1373
1435
|
drive.connection = second;
|
|
1374
1436
|
const snapshot = await second.snapshot();
|
|
1375
|
-
// The second adapter's snapshot is
|
|
1437
|
+
// The second adapter's snapshot is read as the connect snapshot was, and the streams take it
|
|
1376
1438
|
// as the state now the way they take any resync: held to what they knew, then replaced. A reload
|
|
1377
1439
|
// is a place the stream's rules keep working, not one where they all stop.
|
|
1378
|
-
|
|
1440
|
+
await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
|
|
1379
1441
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1380
1442
|
drive.streams.breaks.seed(snapshot);
|
|
1381
1443
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1382
1444
|
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
1445
|
+
ruleEvaluated("drive.reload.snapshot");
|
|
1383
1446
|
if (carried === undefined) {
|
|
1384
1447
|
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`);
|
|
1385
1448
|
return;
|
|
1386
1449
|
}
|
|
1387
1450
|
// The allocation is part of the task, not memory beside it: a reload brings the same life back.
|
|
1451
|
+
ruleEvaluated("drive.reload.allocation", "drive.reload.history");
|
|
1388
1452
|
if (carried.allocationId !== allocationOf()) {
|
|
1389
1453
|
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`);
|
|
1390
1454
|
}
|
|
@@ -1405,8 +1469,29 @@ async function driveOneCall(drive) {
|
|
|
1405
1469
|
else if (leg.mutedBy !== "host") {
|
|
1406
1470
|
refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
|
|
1407
1471
|
}
|
|
1408
|
-
// The drive goes on with the task as the reloaded client holds it.
|
|
1472
|
+
// The drive goes on with the task as the reloaded client holds it. The first client's media
|
|
1473
|
+
// session died with it: a host opens media on a task arriving with media started on a
|
|
1474
|
+
// snapshot, so the drive opens it again on the second and holds the answer as it held the first.
|
|
1409
1475
|
task = carried;
|
|
1476
|
+
if (drive.softphone && carried.media === "started") {
|
|
1477
|
+
if (session !== undefined && typeof session.close === "function") {
|
|
1478
|
+
try {
|
|
1479
|
+
session.close();
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
refuse("drive.openMedia.close", "drive.openMedia", "the first client's media session threw on close");
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
session = undefined;
|
|
1486
|
+
ruleEvaluated("drive.reload.openMedia");
|
|
1487
|
+
const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1488
|
+
const malformed = validateResult(reopened, "openMedia", "drive.reload.openMedia");
|
|
1489
|
+
found.push(...malformed);
|
|
1490
|
+
if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
|
|
1491
|
+
session = reopened.session;
|
|
1492
|
+
else if (malformed.length === 0)
|
|
1493
|
+
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`);
|
|
1494
|
+
}
|
|
1410
1495
|
}
|
|
1411
1496
|
catch (error) {
|
|
1412
1497
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
@@ -1417,11 +1502,15 @@ async function driveOneCall(drive) {
|
|
|
1417
1502
|
// inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
|
|
1418
1503
|
let retainedAtEnd;
|
|
1419
1504
|
const storeReleased = async () => {
|
|
1420
|
-
ruleEvaluated("drive.store.retained");
|
|
1421
1505
|
await Promise.resolve();
|
|
1422
1506
|
await Promise.resolve();
|
|
1423
|
-
|
|
1507
|
+
// A task's key names the task delimited, not as a run of characters inside another id, and the
|
|
1508
|
+
// rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
|
|
1509
|
+
// name nothing the harness can see leaves the rule unasked, which the result says.
|
|
1510
|
+
const retained = [...drive.held].filter(key => namesTask(key, taskId));
|
|
1424
1511
|
retainedAtEnd = new Set(retained);
|
|
1512
|
+
if (drive.everHeldForTask(taskId))
|
|
1513
|
+
ruleEvaluated("drive.store.retained");
|
|
1425
1514
|
if (retained.length > 0) {
|
|
1426
1515
|
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`);
|
|
1427
1516
|
}
|
|
@@ -1432,7 +1521,7 @@ async function driveOneCall(drive) {
|
|
|
1432
1521
|
if (retainedAtEnd === undefined)
|
|
1433
1522
|
return;
|
|
1434
1523
|
ruleEvaluated("drive.store.late");
|
|
1435
|
-
const late = [...drive.held].filter(key => key
|
|
1524
|
+
const late = [...drive.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
|
|
1436
1525
|
if (late.length > 0) {
|
|
1437
1526
|
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`);
|
|
1438
1527
|
}
|
|
@@ -1469,11 +1558,11 @@ async function driveOneCall(drive) {
|
|
|
1469
1558
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1470
1559
|
ruleEvaluated("drive.timeout");
|
|
1471
1560
|
const send = async (command, dialId) => {
|
|
1472
|
-
ruleEvaluated("drive.command.rejected", "drive.command.failed");
|
|
1473
1561
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
|
|
1474
1562
|
found.push(...own);
|
|
1475
1563
|
if (own.length > 0)
|
|
1476
1564
|
return undefined;
|
|
1565
|
+
ruleEvaluated("drive.command.rejected");
|
|
1477
1566
|
let result;
|
|
1478
1567
|
try {
|
|
1479
1568
|
result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
|
|
@@ -1482,6 +1571,7 @@ async function driveOneCall(drive) {
|
|
|
1482
1571
|
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
1483
1572
|
return undefined;
|
|
1484
1573
|
}
|
|
1574
|
+
ruleEvaluated("drive.command.failed");
|
|
1485
1575
|
found.push(...validateResult(result, "execute", `drive.command.${String(command.type)}.result`, dialId));
|
|
1486
1576
|
if (isRecord(result) && result.status === "failed") {
|
|
1487
1577
|
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)}`);
|
|
@@ -1538,7 +1628,6 @@ async function driveOneCall(drive) {
|
|
|
1538
1628
|
return found;
|
|
1539
1629
|
}
|
|
1540
1630
|
// 3. On a softphone, the audio arrives and the host opens it.
|
|
1541
|
-
let session;
|
|
1542
1631
|
if (drive.softphone && latestTask().phase === "in-progress") {
|
|
1543
1632
|
if (latestTask().media !== "started") {
|
|
1544
1633
|
const started = await waitFor("task-media-started for the driven task", envelope => {
|
|
@@ -1550,16 +1639,24 @@ async function driveOneCall(drive) {
|
|
|
1550
1639
|
cursor = Math.max(cursor, started.at);
|
|
1551
1640
|
}
|
|
1552
1641
|
const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1553
|
-
|
|
1554
|
-
|
|
1642
|
+
const malformed = validateResult(opened, "openMedia", "drive.openMedia");
|
|
1643
|
+
found.push(...malformed);
|
|
1644
|
+
if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
|
|
1555
1645
|
session = opened.session;
|
|
1556
|
-
else if (isRecord(opened))
|
|
1646
|
+
else if (malformed.length === 0 && isRecord(opened))
|
|
1557
1647
|
refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
|
|
1558
1648
|
}
|
|
1559
1649
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1560
1650
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1561
1651
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1562
1652
|
const reportedLegs = [];
|
|
1653
|
+
/** The duration the provider's record states for the leg at `at`, as the task stands now. */
|
|
1654
|
+
const closedAs = (at) => {
|
|
1655
|
+
const history = latestTask().handlingHistory;
|
|
1656
|
+
const leg = isRecord(history) && Array.isArray(history.steps)
|
|
1657
|
+
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
|
|
1658
|
+
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
1659
|
+
};
|
|
1563
1660
|
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
|
|
1564
1661
|
const report = async (body) => {
|
|
1565
1662
|
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
@@ -1583,6 +1680,9 @@ async function driveOneCall(drive) {
|
|
|
1583
1680
|
}
|
|
1584
1681
|
};
|
|
1585
1682
|
const setMuted = (muted) => {
|
|
1683
|
+
// No session, nothing to mute: a reload whose second client could not open the audio has been named already.
|
|
1684
|
+
if (session === undefined)
|
|
1685
|
+
return;
|
|
1586
1686
|
try {
|
|
1587
1687
|
session.setMuted(muted);
|
|
1588
1688
|
}
|
|
@@ -1627,8 +1727,9 @@ async function driveOneCall(drive) {
|
|
|
1627
1727
|
// after the media has ended.
|
|
1628
1728
|
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1629
1729
|
let openLeg;
|
|
1630
|
-
if (canRecordMute) {
|
|
1631
|
-
|
|
1730
|
+
if (canRecordMute && session !== undefined) {
|
|
1731
|
+
// The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
|
|
1732
|
+
openLeg = { at: new Date().toISOString(), began: Date.now(), task: latestTask() };
|
|
1632
1733
|
setMuted(true);
|
|
1633
1734
|
await report({ at: openLeg.at });
|
|
1634
1735
|
}
|
|
@@ -1646,13 +1747,17 @@ async function driveOneCall(drive) {
|
|
|
1646
1747
|
}, cursor);
|
|
1647
1748
|
if (mediaEnded?.found === "ended")
|
|
1648
1749
|
cursor = mediaEnded.at;
|
|
1750
|
+
const completing = await updated(t => t.phase === "completing", "the task completing after its media ended");
|
|
1649
1751
|
if (openLeg !== undefined) {
|
|
1650
1752
|
setMuted(false);
|
|
1651
|
-
//
|
|
1753
|
+
// The provider closed this leg at media end, in the completing publication; the host's report,
|
|
1754
|
+
// stated at the record's grain (a leg that rounds to nought is 1), changes nothing, and the
|
|
1755
|
+
// record as it stands now is what every later restatement is held to.
|
|
1756
|
+
const before = closedAs(openLeg.at);
|
|
1652
1757
|
await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
|
|
1653
|
-
reportedLegs.push({ at: openLeg.at, task:
|
|
1758
|
+
reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
|
|
1654
1759
|
}
|
|
1655
|
-
if (
|
|
1760
|
+
if (completing !== undefined && offers("hold")) {
|
|
1656
1761
|
await holdRefusedOutsideHandling();
|
|
1657
1762
|
}
|
|
1658
1763
|
}
|
|
@@ -1704,11 +1809,21 @@ async function driveOneCall(drive) {
|
|
|
1704
1809
|
if (await ended() !== undefined)
|
|
1705
1810
|
await storeReleased();
|
|
1706
1811
|
}
|
|
1707
|
-
// The record kept by the provider has every leg the host reported, wherever the provider restated it
|
|
1812
|
+
// The record kept by the provider has every leg the host reported, wherever the provider restated it:
|
|
1813
|
+
// the last publication of the task is read, whether or not the drive was waiting on it.
|
|
1814
|
+
const lastPublished = () => {
|
|
1815
|
+
for (let index = drive.events.length - 1; index >= 0; index -= 1) {
|
|
1816
|
+
const event = drive.events[index].event;
|
|
1817
|
+
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.id === taskId)
|
|
1818
|
+
return event.task;
|
|
1819
|
+
}
|
|
1820
|
+
return latestTask();
|
|
1821
|
+
};
|
|
1708
1822
|
for (const reported of reportedLegs) {
|
|
1709
|
-
|
|
1823
|
+
const published = lastPublished();
|
|
1824
|
+
if (published === reported.task)
|
|
1710
1825
|
continue;
|
|
1711
|
-
const history =
|
|
1826
|
+
const history = published.handlingHistory;
|
|
1712
1827
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1713
1828
|
const { at } = reported;
|
|
1714
1829
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
@@ -1718,6 +1833,12 @@ async function driveOneCall(drive) {
|
|
|
1718
1833
|
else if (leg.mutedBy !== "host") {
|
|
1719
1834
|
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`);
|
|
1720
1835
|
}
|
|
1836
|
+
else if (reported.closedAs !== undefined) {
|
|
1837
|
+
ruleEvaluated("drive.recordStep.overwritten");
|
|
1838
|
+
if (leg.seconds !== reported.closedAs) {
|
|
1839
|
+
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`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1721
1842
|
}
|
|
1722
1843
|
}
|
|
1723
1844
|
// A timer fires after the microtasks: one turn of the loop is what a late persist needs to show itself.
|
|
@@ -1805,11 +1926,13 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
1805
1926
|
/** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
|
|
1806
1927
|
function watchStore(store) {
|
|
1807
1928
|
const held = new Set();
|
|
1929
|
+
const everHeld = new Set();
|
|
1808
1930
|
return {
|
|
1809
1931
|
held,
|
|
1932
|
+
everHeld,
|
|
1810
1933
|
store: {
|
|
1811
1934
|
get: key => store.get(key),
|
|
1812
|
-
set: async (key, value) => { await store.set(key, value); held.add(key); },
|
|
1935
|
+
set: async (key, value) => { await store.set(key, value); held.add(key); everHeld.add(key); },
|
|
1813
1936
|
delete: async (key) => { await store.delete(key); held.delete(key); },
|
|
1814
1937
|
},
|
|
1815
1938
|
};
|