@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/validation.d.ts
CHANGED
|
@@ -122,9 +122,9 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
|
|
|
122
122
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
123
123
|
* carries the final duration.
|
|
124
124
|
*/
|
|
125
|
-
export declare function
|
|
125
|
+
export declare function validateHistoryReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
126
126
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
127
|
-
/** Validate the exact interaction/
|
|
127
|
+
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
128
128
|
export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
129
129
|
/**
|
|
130
130
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, LISTENING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES,
|
|
12
|
+
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, LISTENING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -57,7 +57,7 @@ const BREAK_APPROVALS = membersOf({
|
|
|
57
57
|
const TEAM_AVAILABILITIES = membersOf({
|
|
58
58
|
ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
|
|
59
59
|
});
|
|
60
|
-
const
|
|
60
|
+
const HISTORY_STEPS = membersOf({
|
|
61
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
62
62
|
});
|
|
63
63
|
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
@@ -155,8 +155,7 @@ const isIsoTimestamp = (value) => {
|
|
|
155
155
|
const isDurationSeconds = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
156
156
|
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
157
157
|
const isUserId = isFilled;
|
|
158
|
-
const
|
|
159
|
-
const isAllocationId = isFilled;
|
|
158
|
+
const isAssignmentId = isFilled;
|
|
160
159
|
const ruleListeners = new Set();
|
|
161
160
|
/**
|
|
162
161
|
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
@@ -880,82 +879,82 @@ function validateTaskAttributes(value, path, into, levels) {
|
|
|
880
879
|
}
|
|
881
880
|
});
|
|
882
881
|
}
|
|
883
|
-
function
|
|
882
|
+
function validateHistory(value, path, into, task = {}) {
|
|
884
883
|
if (value === undefined)
|
|
885
884
|
return;
|
|
886
885
|
if (!isPlainObject(value)) {
|
|
887
|
-
into.add("task.
|
|
886
|
+
into.add("task.history.shape", path, "history must be an object with its steps when present");
|
|
888
887
|
return;
|
|
889
888
|
}
|
|
890
889
|
// What the record adds up to before this agent: each total present when the provider knows it
|
|
891
890
|
// and absent when it does not, never a plausible nought.
|
|
892
|
-
into.require(!Object.hasOwn(value, "handleSeconds"), "task.
|
|
891
|
+
into.require(!Object.hasOwn(value, "handleSeconds"), "task.history.interactionSeconds.renamed", `${path}.handleSeconds`, "use interactionSeconds; the former field is not accepted");
|
|
893
892
|
for (const field of ["interactionSeconds", "holdSeconds", "queueSeconds"]) {
|
|
894
893
|
if (value[field] !== undefined) {
|
|
895
|
-
into.require(isDurationSeconds(value[field]), `task.
|
|
894
|
+
into.require(isDurationSeconds(value[field]), `task.history.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
896
895
|
}
|
|
897
896
|
}
|
|
898
897
|
if (value.transfers !== undefined) {
|
|
899
|
-
into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.
|
|
898
|
+
into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.history.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
|
|
900
899
|
}
|
|
901
900
|
if (!Array.isArray(value.steps)) {
|
|
902
|
-
into.add("task.
|
|
901
|
+
into.add("task.history.steps.shape", `${path}.steps`, "history carries its steps as an array, empty when the task has had none");
|
|
903
902
|
return;
|
|
904
903
|
}
|
|
905
904
|
let previous;
|
|
906
905
|
value.steps.forEach((entry, index) => {
|
|
907
906
|
const at = `${path}.steps[${index}]`;
|
|
908
907
|
if (!isPlainObject(entry)) {
|
|
909
|
-
into.add("task.
|
|
908
|
+
into.add("task.history.entry", at, "each history step must be an object");
|
|
910
909
|
return;
|
|
911
910
|
}
|
|
912
|
-
into.oneOf(entry.step,
|
|
913
|
-
if (into.timestamp(entry.at, "task.
|
|
911
|
+
into.oneOf(entry.step, HISTORY_STEPS, "task.history.step", `${at}.step`);
|
|
912
|
+
if (into.timestamp(entry.at, "task.history.at", `${at}.at`)) {
|
|
914
913
|
// The record is one entry per occurrence, oldest first: a second hold is a second entry
|
|
915
914
|
// after the first, never a revision of it or an entry filed out of its turn.
|
|
916
915
|
const instant = Date.parse(entry.at);
|
|
917
916
|
if (previous !== undefined && instant < previous) {
|
|
918
|
-
into.add("task.
|
|
917
|
+
into.add("task.history.order", `${at}.at`, "history steps are oldest first; this entry is earlier than the one before it");
|
|
919
918
|
}
|
|
920
919
|
previous = instant;
|
|
921
920
|
}
|
|
922
921
|
if (entry.seconds !== undefined) {
|
|
923
922
|
// Omitted while a leg is still running. Nought is a claim that it took no time.
|
|
924
|
-
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.
|
|
923
|
+
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.history.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
|
|
925
924
|
}
|
|
926
925
|
else {
|
|
927
926
|
// An open leg is one still running, and the task says whether it can be: a hold runs only while
|
|
928
927
|
// the task is paused, and a mute only while its media is up. An open entry after that is a leg
|
|
929
928
|
// nobody closed, which reads exactly like a leg running now.
|
|
930
929
|
if (entry.step === "held") {
|
|
931
|
-
into.require(task.phase === "paused", "task.
|
|
930
|
+
into.require(task.phase === "paused", "task.history.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${describeValue(task.phase)}: the hold has ended, and its duration is stated`);
|
|
932
931
|
}
|
|
933
932
|
if (entry.step === "muted") {
|
|
934
|
-
into.require(task.media !== "ended" && task.phase !== "completing", "task.
|
|
933
|
+
into.require(task.media !== "ended" && task.phase !== "completing", "task.history.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's media has ended: the provider closes its open leg and states the duration; other channels may continue");
|
|
935
934
|
}
|
|
936
935
|
}
|
|
937
936
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
938
937
|
// And it names the agent: the host has exactly one, the provider knows who, so an unattributed
|
|
939
938
|
// muted leg is a record that dropped a fact it held rather than one it could not establish.
|
|
940
939
|
if (entry.step === "muted") {
|
|
941
|
-
into.oneOf(entry.mutedBy, MUTED_BY, "task.
|
|
942
|
-
into.require(entry.by !== undefined, "task.
|
|
940
|
+
into.oneOf(entry.mutedBy, MUTED_BY, "task.history.mutedBy", `${at}.mutedBy`);
|
|
941
|
+
into.require(entry.by !== undefined, "task.history.muted.by", `${at}.by`, "a muted leg is attributed to the login's agent: the host reported it, and the provider knows who the host's agent is");
|
|
943
942
|
}
|
|
944
|
-
else if (
|
|
945
|
-
into.require(entry.mutedBy === undefined, "task.
|
|
943
|
+
else if (HISTORY_STEPS.includes(entry.step)) {
|
|
944
|
+
into.require(entry.mutedBy === undefined, "task.history.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
|
|
946
945
|
}
|
|
947
946
|
if (entry.by !== undefined) {
|
|
948
|
-
into.require(isUserId(entry.by), "task.
|
|
947
|
+
into.require(isUserId(entry.by), "task.history.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
|
|
949
948
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
950
|
-
into.require(entry.step !== "queued", "task.
|
|
949
|
+
into.require(entry.step !== "queued", "task.history.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
951
950
|
}
|
|
952
951
|
// A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
|
|
953
|
-
const dialled = typeof entry.step === "string" &&
|
|
952
|
+
const dialled = typeof entry.step === "string" && HISTORY_STEPS_THAT_DIAL.includes(entry.step);
|
|
954
953
|
for (const field of ["dialId", "destinationId"]) {
|
|
955
954
|
if (entry[field] === undefined)
|
|
956
955
|
continue;
|
|
957
|
-
if (into.filled(entry[field], `task.
|
|
958
|
-
into.require(dialled, `task.
|
|
956
|
+
if (into.filled(entry[field], `task.history.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
|
|
957
|
+
into.require(dialled, `task.history.${field}.unexpected`, `${at}.${field}`, `${describeValue(entry.step)} dialled nothing; ${field} belongs on transferred, conferenced, or unanswered`);
|
|
959
958
|
}
|
|
960
959
|
}
|
|
961
960
|
});
|
|
@@ -1085,6 +1084,7 @@ function validateAssisting(value, channel, path, into) {
|
|
|
1085
1084
|
return;
|
|
1086
1085
|
}
|
|
1087
1086
|
into.require(isUserId(value.memberId), "task.assisting.memberId", `${path}.memberId`, "a joined call names the member who asked");
|
|
1087
|
+
into.require(isAssignmentId(value.assignmentId), "task.assisting.assignmentId", `${path}.assignmentId`, "a joined call names the member's assignment");
|
|
1088
1088
|
if (value.note !== undefined)
|
|
1089
1089
|
into.filled(value.note, "task.assisting.note", `${path}.note`, "a note must not be empty when present");
|
|
1090
1090
|
into.timestamp(value.since, "task.assisting.since", `${path}.since`);
|
|
@@ -1100,8 +1100,7 @@ function validateListening(value, channel, path, into) {
|
|
|
1100
1100
|
return;
|
|
1101
1101
|
}
|
|
1102
1102
|
into.require(isUserId(value.memberId), "task.listening.memberId", `${path}.memberId`, "a call being listened to names the member on it");
|
|
1103
|
-
into.require(
|
|
1104
|
-
into.require(isAllocationId(value.allocationId), "task.listening.allocationId", `${path}.allocationId`, "a call being listened to names the member's task by its allocation too");
|
|
1103
|
+
into.require(isAssignmentId(value.assignmentId), "task.listening.assignmentId", `${path}.assignmentId`, "a call being listened to names the member's assignment");
|
|
1105
1104
|
into.oneOf(value.mode, LISTENING_MODES, "task.listening.mode", `${path}.mode`);
|
|
1106
1105
|
into.timestamp(value.since, "task.listening.since", `${path}.since`);
|
|
1107
1106
|
}
|
|
@@ -1122,9 +1121,13 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1122
1121
|
return;
|
|
1123
1122
|
}
|
|
1124
1123
|
into.require(!Object.hasOwn(task, "monitoring"), "task.listening.renamed", `${path}.monitoring`, "use listening; the former field is not accepted");
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1124
|
+
for (const former of ["handlingHistory", "interactionHistory"]) {
|
|
1125
|
+
into.require(!Object.hasOwn(task, former), "task.history.renamed", `${path}.${former}`, "use history; the former field is not accepted");
|
|
1126
|
+
}
|
|
1127
|
+
for (const former of ["id", "allocationId"]) {
|
|
1128
|
+
into.require(!Object.hasOwn(task, former), "task.assignmentId.renamed", `${path}.${former}`, "use assignmentId; a task is named by its assignment alone, and the former field is not accepted");
|
|
1129
|
+
}
|
|
1130
|
+
into.require(isAssignmentId(task.assignmentId), "task.assignmentId", `${path}.assignmentId`, "a task names the assignment it is the record of: the provider's name for it, unique within the provider and never reused");
|
|
1128
1131
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
1129
1132
|
into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
|
|
1130
1133
|
if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
|
|
@@ -1209,7 +1212,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1209
1212
|
}
|
|
1210
1213
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1211
1214
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1212
|
-
|
|
1215
|
+
validateHistory(task.history, `${path}.history`, into, { phase: task.phase, media: task.media });
|
|
1213
1216
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
1214
1217
|
// Media and onCall describe this agent's interaction, not the continuing caller journey.
|
|
1215
1218
|
// Once the interaction's media ends or it enters wrap, its live room is cleared. Other
|
|
@@ -1622,8 +1625,7 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1622
1625
|
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1623
1626
|
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1624
1627
|
}
|
|
1625
|
-
into.require(
|
|
1626
|
-
into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
|
|
1628
|
+
into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join");
|
|
1627
1629
|
if (request.note !== undefined)
|
|
1628
1630
|
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1629
1631
|
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
@@ -1698,10 +1700,10 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1698
1700
|
into.add("snapshot.listening.single", `${path}.tasks[${index}].listening`, "a lead listens to one call at a time");
|
|
1699
1701
|
listening = index;
|
|
1700
1702
|
}
|
|
1701
|
-
if (isPlainObject(task) &&
|
|
1702
|
-
if (seen.has(task.
|
|
1703
|
-
into.add("task.
|
|
1704
|
-
seen.add(task.
|
|
1703
|
+
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1704
|
+
if (seen.has(task.assignmentId))
|
|
1705
|
+
into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
|
|
1706
|
+
seen.add(task.assignmentId);
|
|
1705
1707
|
}
|
|
1706
1708
|
});
|
|
1707
1709
|
}
|
|
@@ -1864,6 +1866,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1864
1866
|
return into.violations;
|
|
1865
1867
|
}
|
|
1866
1868
|
const at = `${path}.event`;
|
|
1869
|
+
into.require(!Object.hasOwn(event, "taskId"), "event.assignmentId.renamed", `${at}.taskId`, "an event names a task by its assignment alone; the former field is not accepted");
|
|
1867
1870
|
switch (event.type) {
|
|
1868
1871
|
case "snapshot":
|
|
1869
1872
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
@@ -1893,25 +1896,22 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1893
1896
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1894
1897
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1895
1898
|
}
|
|
1896
|
-
if (event.
|
|
1897
|
-
into.timestamp(event.
|
|
1899
|
+
if (event.assignmentExpiresAt !== undefined)
|
|
1900
|
+
into.timestamp(event.assignmentExpiresAt, "event.taskOffered.assignmentExpiresAt", `${at}.assignmentExpiresAt`);
|
|
1898
1901
|
break;
|
|
1899
1902
|
case "task-updated":
|
|
1900
1903
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1901
1904
|
break;
|
|
1902
1905
|
case "task-media-started":
|
|
1903
1906
|
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1904
|
-
into.require(
|
|
1905
|
-
into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1907
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskMediaStarted.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1906
1908
|
break;
|
|
1907
1909
|
case "task-media-ended":
|
|
1908
1910
|
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1909
|
-
into.require(
|
|
1910
|
-
into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1911
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskMediaEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1911
1912
|
break;
|
|
1912
1913
|
case "task-ended":
|
|
1913
|
-
into.require(
|
|
1914
|
-
into.require(isAllocationId(event.allocationId), "event.taskEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1914
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1915
1915
|
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
1916
1916
|
break;
|
|
1917
1917
|
case "dial-outcome": {
|
|
@@ -1921,9 +1921,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1921
1921
|
if (into.oneOf(event.outcome, DIAL_OUTCOMES, "event.dialOutcome.outcome", `${at}.outcome`)) {
|
|
1922
1922
|
into.require(declared !== undefined && declared.includes(event.outcome), "event.dialOutcome.undeclared", `${at}.outcome`, `the manifest does not declare ${describeValue(event.outcome)} among its dialOutcomes`);
|
|
1923
1923
|
}
|
|
1924
|
-
if (event.
|
|
1925
|
-
into.require(
|
|
1926
|
-
allocationWithTask(event, "event.dialOutcome", at, into);
|
|
1924
|
+
if (event.assignmentId !== undefined)
|
|
1925
|
+
into.require(isAssignmentId(event.assignmentId), "event.dialOutcome.assignmentId", `${at}.assignmentId`, "assignmentId must name an assignment when present");
|
|
1927
1926
|
if (event.destinationId !== undefined)
|
|
1928
1927
|
into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
|
|
1929
1928
|
if (event.reason !== undefined)
|
|
@@ -1942,9 +1941,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1942
1941
|
case "diagnostic":
|
|
1943
1942
|
into.filled(event.expected, "event.diagnostic.expected", `${at}.expected`, "a diagnostic states the rule that was broken, as a sentence");
|
|
1944
1943
|
into.filled(event.observed, "event.diagnostic.observed", `${at}.observed`, "a diagnostic states what was observed instead");
|
|
1945
|
-
if (event.
|
|
1946
|
-
into.require(
|
|
1947
|
-
allocationWithTask(event, "event.diagnostic", at, into);
|
|
1944
|
+
if (event.assignmentId !== undefined)
|
|
1945
|
+
into.require(isAssignmentId(event.assignmentId), "event.diagnostic.assignmentId", `${at}.assignmentId`, "assignmentId must name an assignment when present");
|
|
1948
1946
|
break;
|
|
1949
1947
|
case "queue-summary":
|
|
1950
1948
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
@@ -2084,7 +2082,7 @@ export function validateCapacity(capacity, path = "capacity") {
|
|
|
2084
2082
|
into.add("capacity.shape", path, "a capacity is an object carrying count");
|
|
2085
2083
|
return into.violations;
|
|
2086
2084
|
}
|
|
2087
|
-
into.require(typeof capacity.count === "number" && Number.isInteger(capacity.count) && capacity.count >= 0, "capacity.count", `${path}.count`, "count is a whole number, zero or more: how many tasks this provider may
|
|
2085
|
+
into.require(typeof capacity.count === "number" && Number.isInteger(capacity.count) && capacity.count >= 0, "capacity.count", `${path}.count`, "count is a whole number, zero or more: how many tasks this provider may assign at once, zero while the agent's capacity is elsewhere");
|
|
2088
2086
|
return into.violations;
|
|
2089
2087
|
}
|
|
2090
2088
|
/** The login's store, which the host provides on every connection: three functions, and nothing else is a store. */
|
|
@@ -2133,34 +2131,34 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
|
2133
2131
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
2134
2132
|
* carries the final duration.
|
|
2135
2133
|
*/
|
|
2136
|
-
export function
|
|
2134
|
+
export function validateHistoryReport(report, path = "historyReport", manifest) {
|
|
2137
2135
|
const into = new Collector();
|
|
2138
2136
|
if (!isPlainObject(report)) {
|
|
2139
|
-
into.add("
|
|
2137
|
+
into.add("historyReport.shape", path, "a history report must be an object");
|
|
2140
2138
|
return into.violations;
|
|
2141
2139
|
}
|
|
2142
|
-
into.require(
|
|
2143
|
-
into.require(
|
|
2144
|
-
into.oneOf(report.step,
|
|
2145
|
-
into.timestamp(report.at, "
|
|
2140
|
+
into.require(!Object.hasOwn(report, "taskId"), "historyReport.assignmentId.renamed", `${path}.taskId`, "a report names the assignment alone; the former field is not accepted");
|
|
2141
|
+
into.require(isAssignmentId(report.assignmentId), "historyReport.assignmentId", `${path}.assignmentId`, "a report names the task's assignment, so a late one never lands on the next life of the id");
|
|
2142
|
+
into.oneOf(report.step, HISTORY_STEPS, "historyReport.step", `${path}.step`);
|
|
2143
|
+
into.timestamp(report.at, "historyReport.at", `${path}.at`);
|
|
2146
2144
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
2147
2145
|
if (report.step === "muted")
|
|
2148
|
-
into.oneOf(report.mutedBy, MUTED_BY, "
|
|
2149
|
-
else if (
|
|
2150
|
-
into.require(report.mutedBy === undefined, "
|
|
2146
|
+
into.oneOf(report.mutedBy, MUTED_BY, "historyReport.mutedBy", `${path}.mutedBy`);
|
|
2147
|
+
else if (HISTORY_STEPS.includes(report.step)) {
|
|
2148
|
+
into.require(report.mutedBy === undefined, "historyReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
|
|
2151
2149
|
}
|
|
2152
2150
|
if (report.seconds !== undefined) {
|
|
2153
|
-
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "
|
|
2151
|
+
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "historyReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
|
|
2154
2152
|
}
|
|
2155
2153
|
if (report.ended !== undefined) {
|
|
2156
|
-
if (into.require(report.ended === true, "
|
|
2157
|
-
into.require(report.seconds !== undefined, "
|
|
2154
|
+
if (into.require(report.ended === true, "historyReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
|
|
2155
|
+
into.require(report.seconds !== undefined, "historyReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
|
|
2158
2156
|
}
|
|
2159
2157
|
}
|
|
2160
2158
|
else if (report.seconds !== undefined && manifest !== undefined) {
|
|
2161
2159
|
// A running report crosses only to a provider that asked for one: begin and end are the whole
|
|
2162
2160
|
// of what the rest receive.
|
|
2163
|
-
into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "
|
|
2161
|
+
into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "historyReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
|
|
2164
2162
|
}
|
|
2165
2163
|
return into.violations;
|
|
2166
2164
|
}
|
|
@@ -2173,15 +2171,6 @@ function validateMutedBy(device, rule, path, into) {
|
|
|
2173
2171
|
into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
|
|
2174
2172
|
}
|
|
2175
2173
|
}
|
|
2176
|
-
/** An event that may name a task names its allocation with it, and never an allocation alone. */
|
|
2177
|
-
function allocationWithTask(event, rule, at, into) {
|
|
2178
|
-
if (event.taskId !== undefined) {
|
|
2179
|
-
into.require(isAllocationId(event.allocationId), `${rule}.allocationId`, `${at}.allocationId`, "an event that names a task names its allocation too");
|
|
2180
|
-
}
|
|
2181
|
-
else {
|
|
2182
|
-
into.require(event.allocationId === undefined, `${rule}.allocationId.unexpected`, `${at}.allocationId`, "an allocation is named with its task, never alone");
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
2174
|
export function validateHostReport(report, path = "host") {
|
|
2186
2175
|
const into = new Collector();
|
|
2187
2176
|
if (!isPlainObject(report)) {
|
|
@@ -2249,19 +2238,18 @@ export function validateHostReport(report, path = "host") {
|
|
|
2249
2238
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2250
2239
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over-call", "leave"];
|
|
2251
2240
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2252
|
-
/** Validate the exact interaction/
|
|
2241
|
+
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
2253
2242
|
export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
|
|
2254
2243
|
const into = new Collector();
|
|
2255
2244
|
if (!isPlainObject(request) || !isPlainObject(task)) {
|
|
2256
2245
|
into.add("command.request.shape", path, "a request and the current published task are required");
|
|
2257
2246
|
return into.violations;
|
|
2258
2247
|
}
|
|
2259
|
-
into.
|
|
2260
|
-
into.filled(request.
|
|
2261
|
-
into.require(request.
|
|
2262
|
-
into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
|
|
2248
|
+
into.require(!Object.hasOwn(request, "taskId"), "command.request.assignmentId.renamed", `${path}.taskId`, "a command names the assignment alone; the former field is not accepted");
|
|
2249
|
+
into.filled(request.assignmentId, "command.request.assignmentId", `${path}.assignmentId`, "name the assignment");
|
|
2250
|
+
into.require(request.assignmentId === task.assignmentId, "command.request.assignmentId.mismatch", `${path}.assignmentId`, "the command must target this exact assignment");
|
|
2263
2251
|
for (const key of Object.keys(request))
|
|
2264
|
-
into.require(["
|
|
2252
|
+
into.require(["assignmentId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
|
|
2265
2253
|
into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
|
|
2266
2254
|
return into.violations;
|
|
2267
2255
|
}
|
|
@@ -2987,10 +2975,10 @@ export function validateHostRecordings(value, path = "host.recordings") {
|
|
|
2987
2975
|
check(false, "report.shape", at, "expected scoped report");
|
|
2988
2976
|
return;
|
|
2989
2977
|
}
|
|
2990
|
-
keys(report, ["
|
|
2991
|
-
check(filled(report.
|
|
2992
|
-
const key = JSON.stringify([report.
|
|
2993
|
-
check(!seen.has(key), "report.duplicate", at, "one host state per
|
|
2978
|
+
keys(report, ["assignmentId", "state"], at);
|
|
2979
|
+
check(filled(report.assignmentId), "report.scope", at, "report identifies the assignment");
|
|
2980
|
+
const key = JSON.stringify([report.assignmentId]);
|
|
2981
|
+
check(!seen.has(key), "report.duplicate", at, "one host state per assignment");
|
|
2994
2982
|
seen.add(key);
|
|
2995
2983
|
violations.push(...validateRecordingState(report.state, `${at}.state`));
|
|
2996
2984
|
});
|
|
@@ -3053,8 +3041,8 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3053
3041
|
return violations;
|
|
3054
3042
|
}
|
|
3055
3043
|
violations.push(...validateTask(task, { ...context.taskContext, channel: "voice" }, `${path}.task`));
|
|
3056
|
-
keys(request, ["
|
|
3057
|
-
check(filled(request.
|
|
3044
|
+
keys(request, ["assignmentId", "command"], path);
|
|
3045
|
+
check(filled(request.assignmentId) && request.assignmentId === task.assignmentId, "request.scope", path, "task assignment changed or scope missing");
|
|
3058
3046
|
check(task.channel === "voice", "request.channel", path, "recording is voice-only");
|
|
3059
3047
|
violations.push(...validateRecordingCommandShape(request.command, context.source, `${path}.command`));
|
|
3060
3048
|
if (!object(request.command))
|
|
@@ -3069,7 +3057,7 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3069
3057
|
check(object(context.host), "host.required", path, "host did not declare recording support");
|
|
3070
3058
|
const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
|
|
3071
3059
|
violations.push(...validateHostRecordings(reports));
|
|
3072
|
-
state = Array.isArray(reports) ? reports.find(r => object(r) && r.
|
|
3060
|
+
state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
|
|
3073
3061
|
if (object(context.host)) {
|
|
3074
3062
|
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
3075
3063
|
check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
|