@xema/omni-protocol 0.1.96 → 0.1.98
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 +51 -64
- package/dist/index.js +13 -13
- package/dist/testing.d.ts +6 -7
- package/dist/testing.js +97 -122
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +76 -88
- package/guide.md +201 -197
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore,
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateHistoryReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -11,7 +11,7 @@ const STATE_SUBJECTS = [
|
|
|
11
11
|
"tasks",
|
|
12
12
|
"task.browsers",
|
|
13
13
|
"task.attributes",
|
|
14
|
-
"task.
|
|
14
|
+
"task.history",
|
|
15
15
|
"task.onCall",
|
|
16
16
|
"task.leadAssist",
|
|
17
17
|
"task.assisting",
|
|
@@ -53,8 +53,8 @@ function observeTask(value, seen) {
|
|
|
53
53
|
seen.add("task.browsers");
|
|
54
54
|
if (some(value.attributes))
|
|
55
55
|
seen.add("task.attributes");
|
|
56
|
-
if (isRecord(value.
|
|
57
|
-
seen.add("task.
|
|
56
|
+
if (isRecord(value.history) && some(value.history.steps))
|
|
57
|
+
seen.add("task.history");
|
|
58
58
|
if (some(value.onCall))
|
|
59
59
|
seen.add("task.onCall");
|
|
60
60
|
if (value.leadAssist !== undefined)
|
|
@@ -357,13 +357,13 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
357
357
|
lockedPartySeen ||= eventTasks(envelope).some(locksParty);
|
|
358
358
|
if (eventNamesUsers(envelope))
|
|
359
359
|
requireMethod(live, "getUserDetails", "an event publishes a UserId");
|
|
360
|
-
// Work is pulled, never pushed: an offer before the host stated capacity is an
|
|
360
|
+
// Work is pulled, never pushed: an offer before the host stated capacity is an assignment
|
|
361
361
|
// against nothing, and an offer beyond the count is one too many -- unless the task is the
|
|
362
362
|
// host's own dial arriving, which counts against nothing.
|
|
363
363
|
if (isRecord(envelope?.event) && envelope.event.type === "task-offered") {
|
|
364
364
|
if (capacityStated === undefined) {
|
|
365
365
|
violations.push({ rule: "stream.taskOffered.beforeCapacity", path: "event.task",
|
|
366
|
-
message: "a task was offered before the host stated any capacity: work is pulled, and nothing is
|
|
366
|
+
message: "a task was offered before the host stated any capacity: work is pulled, and nothing is assigned against a capacity nobody stated" });
|
|
367
367
|
}
|
|
368
368
|
else if (seeded && stream.openCount() >= capacityStated && !TaskStream.carriesDial(envelope.event.task)) {
|
|
369
369
|
violations.push({ rule: "stream.taskOffered.overCapacity", path: "event.task",
|
|
@@ -431,7 +431,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
431
431
|
live = second;
|
|
432
432
|
// The second client is held to everything the first was on connect: its methods, its login's
|
|
433
433
|
// methods, the login it publishes from here, and a capacity stated to it, since the first's
|
|
434
|
-
// statement died with the first and nothing may be
|
|
434
|
+
// statement died with the first and nothing may be assigned against a count nobody stated to it.
|
|
435
435
|
connectObligations(second);
|
|
436
436
|
requireCapabilityMethods(second, current().capabilities);
|
|
437
437
|
try {
|
|
@@ -489,8 +489,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
489
489
|
// transactions no snapshot carries, and are applied after it, in order.
|
|
490
490
|
const carried = new Set();
|
|
491
491
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
492
|
-
if (isRecord(task) && typeof task.
|
|
493
|
-
carried.add(task.
|
|
492
|
+
if (isRecord(task) && typeof task.assignmentId === "string")
|
|
493
|
+
carried.add(task.assignmentId);
|
|
494
494
|
}
|
|
495
495
|
for (const held of duringRead) {
|
|
496
496
|
const event = held.event;
|
|
@@ -500,18 +500,18 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
500
500
|
}
|
|
501
501
|
// Evaluated where a superseded event was held: with none, the question was never asked.
|
|
502
502
|
ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
|
|
503
|
-
if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.
|
|
503
|
+
if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.assignmentId === "string" && !carried.has(event.task.assignmentId)) {
|
|
504
504
|
violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
|
|
505
|
-
message: `${String(event.task.
|
|
505
|
+
message: `${String(event.task.assignmentId)} (${event.task.assignmentId}) 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` });
|
|
506
506
|
}
|
|
507
|
-
if (event.type === "task-ended" && typeof event.
|
|
507
|
+
if (event.type === "task-ended" && typeof event.assignmentId === "string" && carried.has(event.assignmentId)) {
|
|
508
508
|
violations.push({ rule: "snapshot.accounts.ended", path: "snapshot.tasks",
|
|
509
|
-
message: `${String(event.
|
|
509
|
+
message: `${String(event.assignmentId)} (${event.assignmentId}) ended while the snapshot was read and the snapshot still carries it: a snapshot accounts for everything the adapter emitted before it resolved` });
|
|
510
510
|
}
|
|
511
511
|
}
|
|
512
512
|
duringRead.length = 0;
|
|
513
513
|
requireCapabilityMethods(live, current().capabilities);
|
|
514
|
-
// Capacity is stated, not requested: nothing may be
|
|
514
|
+
// Capacity is stated, not requested: nothing may be assigned until it is, so a connection
|
|
515
515
|
// that will not accept one is a connection nothing can be given to.
|
|
516
516
|
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
517
517
|
// ready, and on every change. An adapter that never asked cannot have.
|
|
@@ -557,7 +557,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
557
557
|
// host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
|
|
558
558
|
// A provider whose ceiling can only rise passes a zero it special-cases and still cannot go from
|
|
559
559
|
// five to three, so the exercise moves the axis both ways. Zero is host-stopped: the agent's
|
|
560
|
-
// capacity is elsewhere, and the provider
|
|
560
|
+
// capacity is elsewhere, and the provider assigns nothing and refuses nothing for it. Any offer
|
|
561
561
|
// after a lower count is caught against it (stream.taskOffered.overCapacity).
|
|
562
562
|
// A capacity is taken, never refused: each restatement is answered applied and nothing else
|
|
563
563
|
// (validateResult), and any offer after a lower count is caught against it.
|
|
@@ -661,8 +661,8 @@ function userIdsIn(snapshot) {
|
|
|
661
661
|
}
|
|
662
662
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
663
663
|
const t = task;
|
|
664
|
-
if (isRecord(t.
|
|
665
|
-
for (const step of t.
|
|
664
|
+
if (isRecord(t.history) && Array.isArray(t.history.steps))
|
|
665
|
+
for (const step of t.history.steps)
|
|
666
666
|
if (isRecord(step))
|
|
667
667
|
add(step.by);
|
|
668
668
|
if (Array.isArray(t.onCall))
|
|
@@ -681,7 +681,7 @@ function userIdsIn(snapshot) {
|
|
|
681
681
|
return [...ids];
|
|
682
682
|
}
|
|
683
683
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
684
|
-
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.
|
|
684
|
+
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.history) && Array.isArray(task.history.steps) && task.history.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
685
685
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
686
686
|
isRecord(task.assisting) ||
|
|
687
687
|
isRecord(task.listening));
|
|
@@ -786,7 +786,7 @@ function eventTasks(envelope) {
|
|
|
786
786
|
function undeterminedTasks(tasks, path) {
|
|
787
787
|
return tasks.flatMap((task, index) => isRecord(task) && task.capabilitySource === "undetermined"
|
|
788
788
|
? [{ rule: "capabilitySource.undetermined", path: `${path}[${index}].capabilitySource`,
|
|
789
|
-
message: `task ${String(task.
|
|
789
|
+
message: `task ${String(task.assignmentId)} was published under terms the provider could not determine` }]
|
|
790
790
|
: []);
|
|
791
791
|
}
|
|
792
792
|
/**
|
|
@@ -866,8 +866,8 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
|
|
|
866
866
|
};
|
|
867
867
|
tasks.forEach((task, index) => {
|
|
868
868
|
assertNoViolations(validateTask(task, context, `tasks[${index}]`), `Task capability withdrawal: tasks[${index}] must be a task the wire could carry`);
|
|
869
|
-
if (task.
|
|
870
|
-
throw new Error(`Task capability withdrawal must keep the task: tasks[${index}] is ${task.
|
|
869
|
+
if (task.assignmentId !== first.assignmentId) {
|
|
870
|
+
throw new Error(`Task capability withdrawal must keep the task: tasks[${index}] is ${task.assignmentId}, and the offer was ${first.assignmentId}`);
|
|
871
871
|
}
|
|
872
872
|
});
|
|
873
873
|
const withdrawn = Object.keys(first.capabilities).filter(name => last.capabilities[name] === undefined);
|
|
@@ -922,8 +922,8 @@ export function assertReconnectWithMissedAssignments(before, reconnect, missedTa
|
|
|
922
922
|
if (reconnect.event.type !== "snapshot" || reconnect.event.reason !== "reconnected") {
|
|
923
923
|
throw new Error("Reconnect scenario requires a reconnected snapshot event");
|
|
924
924
|
}
|
|
925
|
-
const beforeIds = new Set(before.tasks.map(task => task.
|
|
926
|
-
const refreshedIds = new Set(reconnect.event.snapshot.tasks.map(task => task.
|
|
925
|
+
const beforeIds = new Set(before.tasks.map(task => task.assignmentId));
|
|
926
|
+
const refreshedIds = new Set(reconnect.event.snapshot.tasks.map(task => task.assignmentId));
|
|
927
927
|
for (const taskId of missedTaskIds) {
|
|
928
928
|
if (beforeIds.has(taskId))
|
|
929
929
|
throw new Error(`Task '${taskId}' was not missed before reconnect`);
|
|
@@ -988,11 +988,11 @@ export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
|
988
988
|
]);
|
|
989
989
|
export class TaskStream {
|
|
990
990
|
tasks = new Map();
|
|
991
|
-
// Every
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
|
|
995
|
-
|
|
991
|
+
// Every assignment this stream has seen, and the ones whose task has ended: an assignment is
|
|
992
|
+
// introduced once, and an event naming one that ended is the late event it is, never applied to
|
|
993
|
+
// a task that is open.
|
|
994
|
+
assignments = new Set();
|
|
995
|
+
endedAssignments = new Set();
|
|
996
996
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
997
997
|
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
998
998
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
@@ -1007,7 +1007,7 @@ export class TaskStream {
|
|
|
1007
1007
|
return;
|
|
1008
1008
|
const entries = [
|
|
1009
1009
|
...(Array.isArray(task.onCall) ? task.onCall : []),
|
|
1010
|
-
...(isRecord(task.
|
|
1010
|
+
...(isRecord(task.history) && Array.isArray(task.history.steps) ? task.history.steps : []),
|
|
1011
1011
|
];
|
|
1012
1012
|
for (const entry of entries) {
|
|
1013
1013
|
if (isRecord(entry) && typeof entry.dialId === "string")
|
|
@@ -1015,21 +1015,16 @@ export class TaskStream {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
}
|
|
1017
1017
|
/**
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* a different fault. Either way it is not applied to the open task.
|
|
1018
|
+
* Why an event names no open task: the assignment ended, so this is the late event that must not
|
|
1019
|
+
* land on anything, or nobody has seen it. Either way it is not applied.
|
|
1021
1020
|
*/
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
return true;
|
|
1026
|
-
if (this.endedAllocations.has(named)) {
|
|
1027
|
-
refuse("stream.allocation.ended", `${at}.allocationId`, `${String(event.type)} names allocation ${named}, a life of ${String(event.taskId)} that has ended; the life open under that id is ${known.allocation}, and a late event lands on the life it names, never the next`);
|
|
1021
|
+
notOpen(event, id, at, refuse) {
|
|
1022
|
+
if (this.endedAssignments.has(id)) {
|
|
1023
|
+
refuse("stream.assignment.ended", `${at}.assignmentId`, `${String(event.type)} names ${id}, an assignment that has ended: a late event lands on the assignment it names, never on another`);
|
|
1028
1024
|
}
|
|
1029
1025
|
else {
|
|
1030
|
-
refuse("stream.
|
|
1026
|
+
refuse("stream.assignment.unknown", `${at}.assignmentId`, `${id} is not an assignment this stream has seen`);
|
|
1031
1027
|
}
|
|
1032
|
-
return false;
|
|
1033
1028
|
}
|
|
1034
1029
|
/** How many tasks the stream currently holds open. */
|
|
1035
1030
|
openCount() { return this.tasks.size; }
|
|
@@ -1052,9 +1047,9 @@ export class TaskStream {
|
|
|
1052
1047
|
}
|
|
1053
1048
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
1054
1049
|
static record(task) {
|
|
1055
|
-
if (!isRecord(task) || !isRecord(task.
|
|
1050
|
+
if (!isRecord(task) || !isRecord(task.history) || !Array.isArray(task.history.steps))
|
|
1056
1051
|
return undefined;
|
|
1057
|
-
return new Set(task.
|
|
1052
|
+
return new Set(task.history.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
|
|
1058
1053
|
}
|
|
1059
1054
|
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
|
1060
1055
|
static lost(was, now) {
|
|
@@ -1079,7 +1074,7 @@ export class TaskStream {
|
|
|
1079
1074
|
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && (typeof entry.dialId === "string" || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
|
|
1080
1075
|
return { partyRingingBeforeWork, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
|
|
1081
1076
|
wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
|
|
1082
|
-
phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task),
|
|
1077
|
+
phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), assignment: String(isRecord(task) ? task.assignmentId : undefined) };
|
|
1083
1078
|
}
|
|
1084
1079
|
/**
|
|
1085
1080
|
* Takes a resync snapshot -- a `snapshot` event, or the read a reloaded client makes -- as the
|
|
@@ -1092,32 +1087,32 @@ export class TaskStream {
|
|
|
1092
1087
|
resync(snapshot, at) {
|
|
1093
1088
|
const found = [];
|
|
1094
1089
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1095
|
-
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.
|
|
1090
|
+
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.history", "stream.snapshot.phase", "stream.snapshot.media");
|
|
1096
1091
|
if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
|
|
1097
1092
|
snapshot.tasks.forEach((task, index) => {
|
|
1098
|
-
if (!isRecord(task) || typeof task.
|
|
1093
|
+
if (!isRecord(task) || typeof task.assignmentId !== "string")
|
|
1099
1094
|
return;
|
|
1100
|
-
const was = this.tasks.get(task.
|
|
1101
|
-
if (was === undefined
|
|
1095
|
+
const was = this.tasks.get(task.assignmentId);
|
|
1096
|
+
if (was === undefined)
|
|
1102
1097
|
return;
|
|
1103
1098
|
if ((was.source === "queue" || was.source === "ungoverned") && task.capabilitySource === "undetermined") {
|
|
1104
|
-
refuse("stream.snapshot.capabilitySource", `${at}.tasks[${index}].capabilitySource`, `${task.
|
|
1099
|
+
refuse("stream.snapshot.capabilitySource", `${at}.tasks[${index}].capabilitySource`, `${task.assignmentId} was published under ${was.source} terms and the snapshot says undetermined: terms once read stay read`);
|
|
1105
1100
|
}
|
|
1106
1101
|
// A record once read is not unread: a resync restates it whole, or with more, never with less.
|
|
1107
1102
|
const lost = TaskStream.lost(was.record, TaskStream.record(task));
|
|
1108
1103
|
if (lost.length > 0) {
|
|
1109
|
-
refuse("stream.snapshot.
|
|
1104
|
+
refuse("stream.snapshot.history", `${at}.tasks[${index}].history`, `${task.assignmentId}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
|
|
1110
1105
|
}
|
|
1111
1106
|
const to = String(task.phase);
|
|
1112
1107
|
const reachable = REACHABLE_PHASES[was.phase];
|
|
1113
1108
|
if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
|
|
1114
1109
|
const connectingBack = was.phase === "completing" && this.partyDialled(task) && (to === "in-progress" || to === "paused");
|
|
1115
1110
|
if (!connectingBack) {
|
|
1116
|
-
refuse("stream.snapshot.phase", `${at}.tasks[${index}].phase`, `${task.
|
|
1111
|
+
refuse("stream.snapshot.phase", `${at}.tasks[${index}].phase`, `${task.assignmentId} was ${was.phase} and the snapshot says ${to}: a task does not go backwards, on an update or on a resync`);
|
|
1117
1112
|
}
|
|
1118
1113
|
}
|
|
1119
1114
|
if (was.media === "started" && (to === "in-progress" || to === "paused") && task.media !== "started") {
|
|
1120
|
-
refuse("stream.snapshot.media", `${at}.tasks[${index}].media`, `${task.
|
|
1115
|
+
refuse("stream.snapshot.media", `${at}.tasks[${index}].media`, `${task.assignmentId}'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`);
|
|
1121
1116
|
}
|
|
1122
1117
|
});
|
|
1123
1118
|
}
|
|
@@ -1130,10 +1125,10 @@ export class TaskStream {
|
|
|
1130
1125
|
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
1131
1126
|
return;
|
|
1132
1127
|
for (const task of snapshot.tasks) {
|
|
1133
|
-
if (isRecord(task) && typeof task.
|
|
1134
|
-
this.tasks.set(task.
|
|
1135
|
-
if (typeof task.
|
|
1136
|
-
this.
|
|
1128
|
+
if (isRecord(task) && typeof task.assignmentId === "string") {
|
|
1129
|
+
this.tasks.set(task.assignmentId, TaskStream.stated(task));
|
|
1130
|
+
if (typeof task.assignmentId === "string")
|
|
1131
|
+
this.assignments.add(task.assignmentId);
|
|
1137
1132
|
}
|
|
1138
1133
|
this.noteDials(task);
|
|
1139
1134
|
}
|
|
@@ -1146,25 +1141,22 @@ export class TaskStream {
|
|
|
1146
1141
|
return found;
|
|
1147
1142
|
const at = `${path}.event`;
|
|
1148
1143
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1149
|
-
const id = typeof event.
|
|
1144
|
+
const id = typeof event.assignmentId === "string" ? event.assignmentId : isRecord(event.task) && typeof event.task.assignmentId === "string" ? event.task.assignmentId : undefined;
|
|
1150
1145
|
const known = id === undefined ? undefined : this.tasks.get(id);
|
|
1151
1146
|
switch (event.type) {
|
|
1152
1147
|
case "snapshot":
|
|
1153
1148
|
found.push(...this.resync(event.snapshot, `${at}.snapshot`));
|
|
1154
1149
|
break;
|
|
1155
1150
|
case "task-offered": {
|
|
1156
|
-
ruleEvaluated("stream.taskOffered.duplicate"
|
|
1151
|
+
ruleEvaluated("stream.taskOffered.duplicate");
|
|
1157
1152
|
if (id === undefined)
|
|
1158
1153
|
break;
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
if (this.allocations.has(allocation)) {
|
|
1164
|
-
refuse("stream.taskOffered.allocation", `${at}.task.allocationId`, `${allocation} was already an allocation on this login: an allocation is minted once per offer and never reused, whatever the task id does`);
|
|
1165
|
-
}
|
|
1166
|
-
this.allocations.add(allocation);
|
|
1154
|
+
// An assignment is introduced once: not while it is open, and never again once it has ended,
|
|
1155
|
+
// whatever the platform did with its own handle.
|
|
1156
|
+
if (this.assignments.has(id)) {
|
|
1157
|
+
refuse("stream.taskOffered.duplicate", `${at}.task.assignmentId`, known !== undefined ? `${id} is already on the stream; an offer introduces an assignment once` : `${id} was already an assignment on this stream: an assignment is named once per offer and never reused, whatever the platform does with its own handle`);
|
|
1167
1158
|
}
|
|
1159
|
+
this.assignments.add(id);
|
|
1168
1160
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
1169
1161
|
this.noteDials(event.task);
|
|
1170
1162
|
break;
|
|
@@ -1174,22 +1166,17 @@ export class TaskStream {
|
|
|
1174
1166
|
break;
|
|
1175
1167
|
ruleEvaluated("stream.taskUpdated.unknown");
|
|
1176
1168
|
if (known === undefined) {
|
|
1177
|
-
refuse("stream.taskUpdated.unknown", `${at}.task.
|
|
1169
|
+
refuse("stream.taskUpdated.unknown", `${at}.task.assignmentId`, `${id} was never offered or carried on a snapshot`);
|
|
1178
1170
|
break;
|
|
1179
1171
|
}
|
|
1180
1172
|
// The rules about a known task are evaluated only once there is one.
|
|
1181
|
-
ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.
|
|
1173
|
+
ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.history", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
|
|
1182
1174
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1183
1175
|
// last statement stands and the failure is a diagnostic; undetermined is a place a task
|
|
1184
1176
|
// starts from, never one it returns to.
|
|
1185
1177
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
1186
1178
|
refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
|
|
1187
1179
|
}
|
|
1188
|
-
// An update is of the life that is open. One carrying another allocation is a copy of a
|
|
1189
|
-
// different life of this id -- a stale republish of the last customer's call, or the next one's.
|
|
1190
|
-
if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
|
|
1191
|
-
refuse("stream.taskUpdated.allocation", `${at}.task.allocationId`, `${id} is open as allocation ${known.allocation} and the update says ${String(event.task.allocationId)}: an update restates the life that is open, never another`);
|
|
1192
|
-
}
|
|
1193
1180
|
// A task does not go backwards. The stream sees publications, not transitions, and a task may
|
|
1194
1181
|
// pass through a phase between two, so what is refused is a phase unreachable from the last
|
|
1195
1182
|
// one read by the guide's table: back to pending, back to confirmed once work began, out of
|
|
@@ -1210,7 +1197,7 @@ export class TaskStream {
|
|
|
1210
1197
|
{
|
|
1211
1198
|
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
1212
1199
|
if (lost.length > 0) {
|
|
1213
|
-
refuse("stream.taskUpdated.
|
|
1200
|
+
refuse("stream.taskUpdated.history", `${at}.task.history`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
|
|
1214
1201
|
}
|
|
1215
1202
|
}
|
|
1216
1203
|
// A task completes after its media ends, never around it: an update that moves a task to
|
|
@@ -1255,15 +1242,13 @@ export class TaskStream {
|
|
|
1255
1242
|
this.noteDials(event.task);
|
|
1256
1243
|
break;
|
|
1257
1244
|
case "task-media-started":
|
|
1258
|
-
ruleEvaluated("stream.taskMediaStarted.
|
|
1245
|
+
ruleEvaluated("stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.taskMedia.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1259
1246
|
if (id === undefined)
|
|
1260
1247
|
break;
|
|
1261
1248
|
if (known === undefined) {
|
|
1262
|
-
|
|
1249
|
+
this.notOpen(event, id, at, refuse);
|
|
1263
1250
|
break;
|
|
1264
1251
|
}
|
|
1265
|
-
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1266
|
-
break;
|
|
1267
1252
|
// Ring-back is audio: a task whose party the host is dialling has media from dialling on,
|
|
1268
1253
|
// whatever its phase; every other task not at work has none.
|
|
1269
1254
|
if (known.channel !== "voice") {
|
|
@@ -1271,52 +1256,47 @@ export class TaskStream {
|
|
|
1271
1256
|
break;
|
|
1272
1257
|
}
|
|
1273
1258
|
if (!AT_WORK.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1274
|
-
refuse("stream.taskMediaStarted.beforeWork", `${at}.
|
|
1259
|
+
refuse("stream.taskMediaStarted.beforeWork", `${at}.assignmentId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1275
1260
|
}
|
|
1276
1261
|
if (known.media === "started") {
|
|
1277
|
-
refuse("stream.taskMediaStarted.duplicate", `${at}.
|
|
1262
|
+
refuse("stream.taskMediaStarted.duplicate", `${at}.assignmentId`, `media already started on ${id}; started and ended alternate`);
|
|
1278
1263
|
}
|
|
1279
1264
|
known.media = "started";
|
|
1280
1265
|
break;
|
|
1281
1266
|
case "task-media-ended":
|
|
1282
|
-
ruleEvaluated("stream.taskMediaEnded.
|
|
1267
|
+
ruleEvaluated("stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.taskMedia.channel", "stream.assignment.ended", "stream.assignment.unknown");
|
|
1283
1268
|
if (id === undefined)
|
|
1284
1269
|
break;
|
|
1285
1270
|
if (known === undefined) {
|
|
1286
|
-
|
|
1271
|
+
this.notOpen(event, id, at, refuse);
|
|
1287
1272
|
break;
|
|
1288
1273
|
}
|
|
1289
|
-
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1290
|
-
break;
|
|
1291
1274
|
if (known.channel !== "voice") {
|
|
1292
1275
|
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1293
1276
|
break;
|
|
1294
1277
|
}
|
|
1295
1278
|
if (!WORK_BEGUN.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1296
|
-
refuse("stream.taskMediaEnded.beforeWork", `${at}.
|
|
1279
|
+
refuse("stream.taskMediaEnded.beforeWork", `${at}.assignmentId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1297
1280
|
}
|
|
1298
1281
|
if (known.media !== "started") {
|
|
1299
|
-
refuse("stream.taskMediaEnded.silent", `${at}.
|
|
1282
|
+
refuse("stream.taskMediaEnded.silent", `${at}.assignmentId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-started, or on a task carried with media started`);
|
|
1300
1283
|
}
|
|
1301
1284
|
known.media = "ended";
|
|
1302
1285
|
break;
|
|
1303
1286
|
case "task-ended":
|
|
1304
|
-
ruleEvaluated("stream.
|
|
1287
|
+
ruleEvaluated("stream.assignment.ended", "stream.assignment.unknown");
|
|
1305
1288
|
if (id === undefined)
|
|
1306
1289
|
break;
|
|
1307
1290
|
if (known === undefined) {
|
|
1308
|
-
|
|
1291
|
+
this.notOpen(event, id, at, refuse);
|
|
1309
1292
|
break;
|
|
1310
1293
|
}
|
|
1311
|
-
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1312
|
-
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1313
|
-
break;
|
|
1314
1294
|
// A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
|
|
1315
1295
|
// and a lead leaving included. An ending with the audio still up leaves the host holding an
|
|
1316
1296
|
// open media session and an open leg on a task that no longer exists.
|
|
1317
1297
|
ruleEvaluated("stream.taskEnded.mediaOpen");
|
|
1318
1298
|
if (known.media === "started") {
|
|
1319
|
-
refuse("stream.taskEnded.mediaOpen", `${at}.
|
|
1299
|
+
refuse("stream.taskEnded.mediaOpen", `${at}.assignmentId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
|
|
1320
1300
|
}
|
|
1321
1301
|
// Off voice there is no media event, so the update that moves a task to completing is the
|
|
1322
1302
|
// provider's word that interaction ended and the moment the wrap allowance starts. A provider
|
|
@@ -1327,16 +1307,16 @@ export class TaskStream {
|
|
|
1327
1307
|
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1328
1308
|
refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that interaction ended and the allowance's start, and it was never published`);
|
|
1329
1309
|
}
|
|
1330
|
-
this.
|
|
1310
|
+
this.endedAssignments.add(id);
|
|
1331
1311
|
this.tasks.delete(id);
|
|
1332
1312
|
break;
|
|
1333
1313
|
case "dial-outcome": {
|
|
1334
1314
|
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
1335
1315
|
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1336
|
-
// and why the outcome names the
|
|
1337
|
-
ruleEvaluated("stream.
|
|
1338
|
-
if (typeof event.
|
|
1339
|
-
refuse("stream.
|
|
1316
|
+
// and why the outcome names the assignment: the host routes it to that life, ended or not.
|
|
1317
|
+
ruleEvaluated("stream.assignment.unknown");
|
|
1318
|
+
if (typeof event.assignmentId === "string" && !this.assignments.has(event.assignmentId)) {
|
|
1319
|
+
refuse("stream.assignment.unknown", `${at}.assignmentId`, `${event.assignmentId} is not an assignment this stream has seen`);
|
|
1340
1320
|
}
|
|
1341
1321
|
if (typeof event.dialId !== "string")
|
|
1342
1322
|
break;
|
|
@@ -1366,7 +1346,7 @@ export class TaskStream {
|
|
|
1366
1346
|
async function driveOneCall(drive) {
|
|
1367
1347
|
const found = [];
|
|
1368
1348
|
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
1369
|
-
const isTask = (value) => isRecord(value) && typeof value.
|
|
1349
|
+
const isTask = (value) => isRecord(value) && typeof value.assignmentId === "string";
|
|
1370
1350
|
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
1371
1351
|
const waitFor = (what, matches, from, within = { ms: drive.timeoutMs, onExpiry: () => refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`) }) => new Promise(resolve => {
|
|
1372
1352
|
for (let index = from; index < drive.events.length; index += 1) {
|
|
@@ -1445,21 +1425,17 @@ async function driveOneCall(drive) {
|
|
|
1445
1425
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1446
1426
|
found.push(...drive.streams.breaks.seed(snapshot));
|
|
1447
1427
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1448
|
-
? snapshot.tasks.find(task => isRecord(task) && task.
|
|
1428
|
+
? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
|
|
1449
1429
|
ruleEvaluated("drive.reload.snapshot");
|
|
1450
1430
|
if (carried === undefined) {
|
|
1451
1431
|
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`);
|
|
1452
1432
|
return;
|
|
1453
1433
|
}
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (carried.allocationId !== allocationOf()) {
|
|
1457
|
-
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`);
|
|
1458
|
-
}
|
|
1459
|
-
const history = carried.interactionHistory;
|
|
1434
|
+
ruleEvaluated("drive.reload.history");
|
|
1435
|
+
const history = carried.history;
|
|
1460
1436
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1461
1437
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
1462
|
-
const before = latestTask().
|
|
1438
|
+
const before = latestTask().history;
|
|
1463
1439
|
const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
|
|
1464
1440
|
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
1465
1441
|
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
@@ -1488,7 +1464,7 @@ async function driveOneCall(drive) {
|
|
|
1488
1464
|
}
|
|
1489
1465
|
session = undefined;
|
|
1490
1466
|
ruleEvaluated("drive.reload.openMedia");
|
|
1491
|
-
const reopened = await second.openMedia?.({
|
|
1467
|
+
const reopened = await second.openMedia?.({ assignmentId: taskId, localAudio: drive.localAudio });
|
|
1492
1468
|
const malformed = validateResult(reopened, "openMedia", "drive.reload.openMedia");
|
|
1493
1469
|
found.push(...malformed);
|
|
1494
1470
|
if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
|
|
@@ -1543,12 +1519,11 @@ async function driveOneCall(drive) {
|
|
|
1543
1519
|
task = offered.found;
|
|
1544
1520
|
cursor = offered.at;
|
|
1545
1521
|
}
|
|
1546
|
-
const taskId = task.
|
|
1547
|
-
const allocationOf = () => String(latestTask().allocationId);
|
|
1522
|
+
const taskId = task.assignmentId;
|
|
1548
1523
|
const latestTask = () => task;
|
|
1549
1524
|
const updated = (until, what) => waitFor(what, envelope => {
|
|
1550
1525
|
const event = envelope.event;
|
|
1551
|
-
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.
|
|
1526
|
+
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId) {
|
|
1552
1527
|
task = event.task;
|
|
1553
1528
|
return until(event.task) ? event.task : undefined;
|
|
1554
1529
|
}
|
|
@@ -1557,7 +1532,7 @@ async function driveOneCall(drive) {
|
|
|
1557
1532
|
cursor = hit.at; return hit; });
|
|
1558
1533
|
const ended = (within) => waitFor("a task-ended for the driven task", envelope => {
|
|
1559
1534
|
const event = envelope.event;
|
|
1560
|
-
return event.type === "task-ended" && event.
|
|
1535
|
+
return event.type === "task-ended" && event.assignmentId === taskId ? event : undefined;
|
|
1561
1536
|
}, cursor, within);
|
|
1562
1537
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1563
1538
|
ruleEvaluated("drive.timeout");
|
|
@@ -1573,7 +1548,7 @@ async function driveOneCall(drive) {
|
|
|
1573
1548
|
ruleEvaluated("drive.command.rejected");
|
|
1574
1549
|
let result;
|
|
1575
1550
|
try {
|
|
1576
|
-
result = await drive.connection.execute({
|
|
1551
|
+
result = await drive.connection.execute({ assignmentId: taskId, command });
|
|
1577
1552
|
}
|
|
1578
1553
|
catch (error) {
|
|
1579
1554
|
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1620,7 +1595,7 @@ async function driveOneCall(drive) {
|
|
|
1620
1595
|
const phase = String(latestTask().phase);
|
|
1621
1596
|
let answer;
|
|
1622
1597
|
try {
|
|
1623
|
-
answer = await drive.connection.execute({
|
|
1598
|
+
answer = await drive.connection.execute({ assignmentId: taskId, command: { type: "hold" } });
|
|
1624
1599
|
}
|
|
1625
1600
|
catch (error) {
|
|
1626
1601
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1640,13 +1615,13 @@ async function driveOneCall(drive) {
|
|
|
1640
1615
|
if (latestTask().media !== "started") {
|
|
1641
1616
|
const started = await waitFor("task-media-started for the driven task", envelope => {
|
|
1642
1617
|
const event = envelope.event;
|
|
1643
|
-
return event.type === "task-media-started" && event.
|
|
1618
|
+
return event.type === "task-media-started" && event.assignmentId === taskId ? event : undefined;
|
|
1644
1619
|
}, acceptedAt);
|
|
1645
1620
|
if (started === undefined)
|
|
1646
1621
|
return found;
|
|
1647
1622
|
cursor = Math.max(cursor, started.at);
|
|
1648
1623
|
}
|
|
1649
|
-
const opened = await drive.connection.openMedia?.({
|
|
1624
|
+
const opened = await drive.connection.openMedia?.({ assignmentId: taskId, localAudio: drive.localAudio });
|
|
1650
1625
|
const malformed = validateResult(opened, "openMedia", "drive.openMedia");
|
|
1651
1626
|
found.push(...malformed);
|
|
1652
1627
|
if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
|
|
@@ -1661,7 +1636,7 @@ async function driveOneCall(drive) {
|
|
|
1661
1636
|
const reportedLegs = [];
|
|
1662
1637
|
/** The duration the provider's record states for the leg at `at`, as the task stands now. */
|
|
1663
1638
|
const closedAs = (at) => {
|
|
1664
|
-
const history = latestTask().
|
|
1639
|
+
const history = latestTask().history;
|
|
1665
1640
|
const leg = isRecord(history) && Array.isArray(history.steps)
|
|
1666
1641
|
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1667
1642
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
@@ -1670,8 +1645,8 @@ async function driveOneCall(drive) {
|
|
|
1670
1645
|
const report = async (body) => {
|
|
1671
1646
|
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1672
1647
|
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1673
|
-
const leg = {
|
|
1674
|
-
const own =
|
|
1648
|
+
const leg = { assignmentId: taskId, step: "muted", mutedBy: "host", ...body };
|
|
1649
|
+
const own = validateHistoryReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1675
1650
|
found.push(...own);
|
|
1676
1651
|
if (own.length > 0)
|
|
1677
1652
|
return;
|
|
@@ -1756,9 +1731,9 @@ async function driveOneCall(drive) {
|
|
|
1756
1731
|
// that is not coming, so the run reads by the rule and not by its timeout.
|
|
1757
1732
|
const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
|
|
1758
1733
|
const event = envelope.event;
|
|
1759
|
-
if (event.type === "task-media-ended" && event.
|
|
1734
|
+
if (event.type === "task-media-ended" && event.assignmentId === taskId)
|
|
1760
1735
|
return "ended";
|
|
1761
|
-
if (event.type === "task-updated" && isTask(event.task) && event.task.
|
|
1736
|
+
if (event.type === "task-updated" && isTask(event.task) && event.task.assignmentId === taskId && event.task.phase === "completing")
|
|
1762
1737
|
return "completing";
|
|
1763
1738
|
return undefined;
|
|
1764
1739
|
}, cursor);
|
|
@@ -1815,7 +1790,7 @@ async function driveOneCall(drive) {
|
|
|
1815
1790
|
catch (error) {
|
|
1816
1791
|
refuse("drive.command.rejected", "drive.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
|
|
1817
1792
|
}
|
|
1818
|
-
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.
|
|
1793
|
+
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.assignmentId === taskId);
|
|
1819
1794
|
refuse("drive.completion.unsettled", "drive.completion", still
|
|
1820
1795
|
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider completed the task, and it has not`
|
|
1821
1796
|
: `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
|
|
@@ -1831,7 +1806,7 @@ async function driveOneCall(drive) {
|
|
|
1831
1806
|
const lastPublished = () => {
|
|
1832
1807
|
for (let index = drive.events.length - 1; index >= 0; index -= 1) {
|
|
1833
1808
|
const event = drive.events[index].event;
|
|
1834
|
-
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.
|
|
1809
|
+
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId)
|
|
1835
1810
|
return event.task;
|
|
1836
1811
|
}
|
|
1837
1812
|
return latestTask();
|
|
@@ -1842,7 +1817,7 @@ async function driveOneCall(drive) {
|
|
|
1842
1817
|
const published = lastPublished();
|
|
1843
1818
|
if (published === reported.task)
|
|
1844
1819
|
continue;
|
|
1845
|
-
const history = published.
|
|
1820
|
+
const history = published.history;
|
|
1846
1821
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1847
1822
|
const { at } = reported;
|
|
1848
1823
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|