@xema/omni-protocol 0.1.99 → 0.1.101
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 +60 -47
- package/dist/index.js +4 -3
- package/dist/testing.d.ts +12 -12
- package/dist/testing.js +110 -107
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +108 -72
- package/guide.md +250 -210
- package/package.json +1 -1
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, 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";
|
|
12
|
+
import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, 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") {
|
|
@@ -41,7 +41,7 @@ const TASK_PHASES = membersOf({
|
|
|
41
41
|
pending: true, confirmed: true, preview: true, "in-progress": true, paused: true, completing: true,
|
|
42
42
|
});
|
|
43
43
|
const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic": true });
|
|
44
|
-
const CAPABILITY_SOURCES = membersOf({ queue: true,
|
|
44
|
+
const CAPABILITY_SOURCES = membersOf({ queue: true, nobody: true, "not-yet-read": true });
|
|
45
45
|
const ACCEPTANCE_MODES = membersOf({
|
|
46
46
|
"no-preference": true, "consent": true, "automatic": true,
|
|
47
47
|
});
|
|
@@ -55,7 +55,7 @@ const BREAK_APPROVALS = membersOf({
|
|
|
55
55
|
"not-requested": true, "awaiting-approval": true, granted: true, "starting-after-task": true, "on-break": true,
|
|
56
56
|
});
|
|
57
57
|
const TEAM_AVAILABILITIES = membersOf({
|
|
58
|
-
ready: true, "on-task": true, "on-break": true,
|
|
58
|
+
ready: true, "on-task": true, "on-break": true, elsewhere: true, "signed-out": true,
|
|
59
59
|
});
|
|
60
60
|
const HISTORY_STEPS = membersOf({
|
|
61
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
@@ -85,7 +85,7 @@ const MEMBER_BREAKS = membersOf({
|
|
|
85
85
|
const OFFERABLE_PHASES = membersOf({
|
|
86
86
|
pending: true,
|
|
87
87
|
});
|
|
88
|
-
const PREVIEW_DEADLINES = membersOf({
|
|
88
|
+
const PREVIEW_DEADLINES = membersOf({ "provider-dials": true, "host-dials": true, waits: true });
|
|
89
89
|
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
90
90
|
const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
|
|
91
91
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
@@ -923,13 +923,13 @@ function validateHistory(value, path, into, task = {}) {
|
|
|
923
923
|
}
|
|
924
924
|
else {
|
|
925
925
|
// An open leg is one still running, and the task says whether it can be: a hold runs only while
|
|
926
|
-
// the task is paused, and a mute only while its
|
|
926
|
+
// the task is paused, and a mute only while its audio is up. An open entry after that is a leg
|
|
927
927
|
// nobody closed, which reads exactly like a leg running now.
|
|
928
928
|
if (entry.step === "held") {
|
|
929
929
|
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`);
|
|
930
930
|
}
|
|
931
931
|
if (entry.step === "muted") {
|
|
932
|
-
into.require(task.
|
|
932
|
+
into.require(task.audio !== "ended" && task.phase !== "completing", "task.history.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this interaction's audio has ended: the provider closes its open leg and states the duration; other channels may continue");
|
|
933
933
|
}
|
|
934
934
|
}
|
|
935
935
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
@@ -960,22 +960,22 @@ function validateHistory(value, path, into, task = {}) {
|
|
|
960
960
|
}
|
|
961
961
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
962
962
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
963
|
-
/** Real-time
|
|
963
|
+
/** Real-time audio is a voice affair, and its state is one of two words. */
|
|
964
964
|
/** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
|
|
965
965
|
function partyRingingBeforeWork(task) {
|
|
966
|
-
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "
|
|
966
|
+
return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "provider-dials" && entry.dialId === undefined)));
|
|
967
967
|
}
|
|
968
|
-
function
|
|
968
|
+
function validateTaskAudio(task, value, channel, phase, path, into) {
|
|
969
969
|
if (value === undefined)
|
|
970
970
|
return;
|
|
971
|
-
if (!into.require(channel === "voice", "task.
|
|
971
|
+
if (!into.require(channel === "voice", "task.audio.channel", path, `a ${channel} task carries no real-time audio state`))
|
|
972
972
|
return;
|
|
973
|
-
if (into.oneOf(value, TASK_MEDIA_STATES, "task.
|
|
974
|
-
// Nothing is acquired while pending, and a preview has placed no call:
|
|
973
|
+
if (into.oneOf(value, TASK_MEDIA_STATES, "task.audio", path)) {
|
|
974
|
+
// Nothing is acquired while pending, and a preview has placed no call: audio names a task whose
|
|
975
975
|
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
976
976
|
// Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
|
|
977
|
-
// A deadline alone is never evidence that
|
|
978
|
-
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.
|
|
977
|
+
// A deadline alone is never evidence that audio started.
|
|
978
|
+
into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.audio.beforeWork", path, `a ${describeValue(phase)} task has no audio: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
|
|
979
979
|
}
|
|
980
980
|
}
|
|
981
981
|
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
@@ -1131,7 +1131,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1131
1131
|
into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
|
|
1132
1132
|
}
|
|
1133
1133
|
if (task.atDeadline === undefined) {
|
|
1134
|
-
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider
|
|
1134
|
+
into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider dials, host dials, or waits for the agent -- an agent counting down has to know which");
|
|
1135
1135
|
}
|
|
1136
1136
|
else {
|
|
1137
1137
|
into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
|
|
@@ -1200,18 +1200,18 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1200
1200
|
if (context.member !== true || task.browsers !== undefined)
|
|
1201
1201
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1202
1202
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1203
|
-
validateHistory(task.history, `${path}.history`, into, { phase: task.phase,
|
|
1203
|
+
validateHistory(task.history, `${path}.history`, into, { phase: task.phase, audio: task.audio });
|
|
1204
1204
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
1205
|
-
//
|
|
1206
|
-
// Once the interaction's
|
|
1205
|
+
// Audio and onCall describe this agent's interaction, not the continuing caller journey.
|
|
1206
|
+
// Once the interaction's audio ends or it enters wrap, its live room is cleared. Other
|
|
1207
1207
|
// channels may remain connected under another interaction or in IVR/queue stages.
|
|
1208
|
-
if (task.phase === "completing" && task.
|
|
1209
|
-
into.add("task.
|
|
1208
|
+
if (task.phase === "completing" && task.audio === "started") {
|
|
1209
|
+
into.add("task.audio.completing", `${path}.audio`, "a completing task's interaction audio has ended or never started; it cannot still be started during wrap");
|
|
1210
1210
|
}
|
|
1211
|
-
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.
|
|
1212
|
-
into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its
|
|
1211
|
+
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.audio === "ended")) {
|
|
1212
|
+
into.add("task.onCall.ended", `${path}.onCall`, `this interaction is no longer connected (${task.phase === "completing" ? "the task is completing" : "its audio ended"}); clear its onCall view without implying that other channels ended`);
|
|
1213
1213
|
}
|
|
1214
|
-
|
|
1214
|
+
validateTaskAudio(task, task.audio, context.channel, task.phase, `${path}.audio`, into);
|
|
1215
1215
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
1216
1216
|
validateTakenOver(task.takenOver, context.channel, `${path}.takenOver`, into);
|
|
1217
1217
|
// Where the terms came from is stated with them: a host cannot tell "the platform permits
|
|
@@ -1671,10 +1671,17 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1671
1671
|
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1672
1672
|
}
|
|
1673
1673
|
else {
|
|
1674
|
+
const listened = member.listening;
|
|
1675
|
+
if (into.require(isAssignmentId(member.listening.assignmentId), "team.member.listening.assignmentId", `${at}.listening.assignmentId`, "listening names which of the member's calls the lead is on: the application opens the lead's audio on it") && Array.isArray(member.tasks)) {
|
|
1676
|
+
into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === listened.assignmentId), "team.member.listening.assignment", `${at}.listening.assignmentId`, "the call the lead is on is one the member's tasks carry");
|
|
1677
|
+
}
|
|
1674
1678
|
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1675
1679
|
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1676
1680
|
}
|
|
1677
1681
|
}
|
|
1682
|
+
// The member's own history for the day: about the person, not any one call.
|
|
1683
|
+
if (member.shift !== undefined)
|
|
1684
|
+
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1678
1685
|
});
|
|
1679
1686
|
// One member's call at a time.
|
|
1680
1687
|
const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
|
|
@@ -1724,8 +1731,8 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1724
1731
|
if (snapshot.contacts === undefined && idle.contacts === true) {
|
|
1725
1732
|
into.add("snapshot.contacts.required", `${path}.contacts`, "the manifest declares contacts, so every snapshot carries the contribution: [] when there are none");
|
|
1726
1733
|
}
|
|
1727
|
-
if (snapshot.
|
|
1728
|
-
into.add("snapshot.calendar.required", `${path}.
|
|
1734
|
+
if (snapshot.calendar === undefined && idle.calendar === true) {
|
|
1735
|
+
into.add("snapshot.calendar.required", `${path}.calendar`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
|
|
1729
1736
|
}
|
|
1730
1737
|
if (snapshot.contacts !== undefined) {
|
|
1731
1738
|
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
@@ -1736,22 +1743,22 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1736
1743
|
into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
|
|
1737
1744
|
}
|
|
1738
1745
|
}
|
|
1739
|
-
if (snapshot.
|
|
1740
|
-
into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.
|
|
1741
|
-
if (Array.isArray(snapshot.
|
|
1746
|
+
if (snapshot.calendar !== undefined) {
|
|
1747
|
+
into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.calendar`, "scheduled activities require the calendar idle capability");
|
|
1748
|
+
if (Array.isArray(snapshot.calendar)) {
|
|
1742
1749
|
const seen = new Set();
|
|
1743
|
-
snapshot.
|
|
1744
|
-
validateScheduledActivityInto(activity, `${path}.
|
|
1750
|
+
snapshot.calendar.forEach((activity, index) => {
|
|
1751
|
+
validateScheduledActivityInto(activity, `${path}.calendar[${index}]`, into, levels);
|
|
1745
1752
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1746
1753
|
if (seen.has(activity.id)) {
|
|
1747
|
-
into.add("activity.id.unique", `${path}.
|
|
1754
|
+
into.add("activity.id.unique", `${path}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1748
1755
|
}
|
|
1749
1756
|
seen.add(activity.id);
|
|
1750
1757
|
}
|
|
1751
1758
|
});
|
|
1752
1759
|
}
|
|
1753
1760
|
else {
|
|
1754
|
-
into.add("snapshot.calendar.shape", `${path}.
|
|
1761
|
+
into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
|
|
1755
1762
|
}
|
|
1756
1763
|
}
|
|
1757
1764
|
// The login is the permission: a lead's snapshot carries a team member list, `members: []` included,
|
|
@@ -1903,13 +1910,13 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1903
1910
|
case "task-updated":
|
|
1904
1911
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1905
1912
|
break;
|
|
1906
|
-
case "task-
|
|
1907
|
-
into.require(channel === "voice", "event.
|
|
1908
|
-
into.require(isAssignmentId(event.assignmentId), "event.
|
|
1913
|
+
case "task-audio-started":
|
|
1914
|
+
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
1915
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskAudioStarted.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1909
1916
|
break;
|
|
1910
|
-
case "task-
|
|
1911
|
-
into.require(channel === "voice", "event.
|
|
1912
|
-
into.require(isAssignmentId(event.assignmentId), "event.
|
|
1917
|
+
case "task-audio-ended":
|
|
1918
|
+
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
1919
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskAudioEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1913
1920
|
break;
|
|
1914
1921
|
case "task-ended":
|
|
1915
1922
|
into.require(isAssignmentId(event.assignmentId), "event.taskEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
@@ -1953,14 +1960,6 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1953
1960
|
into.require(context.leadFeatures !== false, "event.team.features", `${at}.team`, "the lead turned the team feature off, so nothing of the team reaches them");
|
|
1954
1961
|
validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
1955
1962
|
break;
|
|
1956
|
-
case "team-media-started":
|
|
1957
|
-
case "team-media-ended":
|
|
1958
|
-
// The lead's channel in a member's call: audio the application opens on the member's assignment.
|
|
1959
|
-
into.require(channel === "voice", "event.media.channel", `${at}.type`, "only a voice provider publishes media transitions");
|
|
1960
|
-
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.teamMedia.capability", `${at}.type`, "team media reaches a login that declares lead, and nobody else");
|
|
1961
|
-
into.require(isUserId(event.memberId), "event.teamMedia.memberId", `${at}.memberId`, "team media names the member whose call the lead is on");
|
|
1962
|
-
into.require(isAssignmentId(event.assignmentId), "event.teamMedia.assignmentId", `${at}.assignmentId`, "team media names the member's assignment");
|
|
1963
|
-
break;
|
|
1964
1963
|
case "contacts-updated":
|
|
1965
1964
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
1966
1965
|
if (!Array.isArray(event.contacts)) {
|
|
@@ -1971,17 +1970,17 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1971
1970
|
}
|
|
1972
1971
|
break;
|
|
1973
1972
|
case "calendar-updated":
|
|
1974
|
-
into.require(idle.calendar === true, "event.calendar.capability", `${at}.
|
|
1975
|
-
if (!Array.isArray(event.
|
|
1976
|
-
into.add("event.calendar.shape", `${at}.
|
|
1973
|
+
into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
|
|
1974
|
+
if (!Array.isArray(event.calendar)) {
|
|
1975
|
+
into.add("event.calendar.shape", `${at}.calendar`, "calendar must be an array");
|
|
1977
1976
|
}
|
|
1978
1977
|
else {
|
|
1979
1978
|
const ids = new Set();
|
|
1980
|
-
event.
|
|
1981
|
-
validateScheduledActivityInto(activity, `${at}.
|
|
1979
|
+
event.calendar.forEach((activity, index) => {
|
|
1980
|
+
validateScheduledActivityInto(activity, `${at}.calendar[${index}]`, into, levels);
|
|
1982
1981
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1983
1982
|
if (ids.has(activity.id))
|
|
1984
|
-
into.add("activity.id.unique", `${at}.
|
|
1983
|
+
into.add("activity.id.unique", `${at}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1985
1984
|
ids.add(activity.id);
|
|
1986
1985
|
}
|
|
1987
1986
|
});
|
|
@@ -2021,6 +2020,43 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
2021
2020
|
});
|
|
2022
2021
|
}
|
|
2023
2022
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
2023
|
+
function validateShiftInto(value, path, into) {
|
|
2024
|
+
if (!isPlainObject(value)) {
|
|
2025
|
+
into.add("team.member.shift.shape", path, "shift must be an object when present");
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const signedIn = into.timestamp(value.signedInAt, "team.member.shift.signedInAt", `${path}.signedInAt`);
|
|
2029
|
+
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, "team.member.shift.signedOutAt", `${path}.signedOutAt`) && signedIn) {
|
|
2030
|
+
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), "team.member.shift.signedOutAt.order", `${path}.signedOutAt`, "a member signs out after signing in");
|
|
2031
|
+
}
|
|
2032
|
+
for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
|
|
2033
|
+
if (value[field] !== undefined)
|
|
2034
|
+
into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
2035
|
+
}
|
|
2036
|
+
if (value.tasksHandled !== undefined)
|
|
2037
|
+
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, "team.member.shift.tasksHandled", `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
|
|
2038
|
+
if (value.events !== undefined) {
|
|
2039
|
+
if (!Array.isArray(value.events)) {
|
|
2040
|
+
into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
let previous;
|
|
2044
|
+
value.events.forEach((event, index) => {
|
|
2045
|
+
const at = `${path}.events[${index}]`;
|
|
2046
|
+
if (!isPlainObject(event)) {
|
|
2047
|
+
into.add("team.member.shift.event.shape", at, "each shift event must be an object");
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
into.oneOf(event.kind, SHIFT_EVENT_KINDS, "team.member.shift.event.kind", `${at}.kind`);
|
|
2051
|
+
if (into.timestamp(event.at, "team.member.shift.event.at", `${at}.at`)) {
|
|
2052
|
+
const instant = Date.parse(event.at);
|
|
2053
|
+
if (previous !== undefined && instant < previous)
|
|
2054
|
+
into.add("team.member.shift.events.order", `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
|
|
2055
|
+
previous = instant;
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2024
2060
|
function validateTeamPoliciesInto(value, path, into, levels) {
|
|
2025
2061
|
if (!isPlainObject(value)) {
|
|
2026
2062
|
into.add("team.policies.shape", path, "policies must be an object keyed by capability");
|
|
@@ -2293,7 +2329,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2293
2329
|
if (type !== "custom" && type !== "recording") {
|
|
2294
2330
|
let fields = ["type"];
|
|
2295
2331
|
switch (type) {
|
|
2296
|
-
case "
|
|
2332
|
+
case "dial":
|
|
2297
2333
|
case "connect-back":
|
|
2298
2334
|
fields.push("dialId");
|
|
2299
2335
|
break;
|
|
@@ -2320,8 +2356,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2320
2356
|
}
|
|
2321
2357
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
2322
2358
|
switch (type) {
|
|
2323
|
-
case "
|
|
2324
|
-
dial("command.
|
|
2359
|
+
case "dial":
|
|
2360
|
+
dial("command.dial.dialId");
|
|
2325
2361
|
break;
|
|
2326
2362
|
case "connect-back":
|
|
2327
2363
|
dial("command.connectBack.dialId");
|
|
@@ -2433,7 +2469,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2433
2469
|
if (type === "decline")
|
|
2434
2470
|
offered("decline");
|
|
2435
2471
|
break;
|
|
2436
|
-
case "
|
|
2472
|
+
case "dial":
|
|
2437
2473
|
inPhase("command.phase.preview", "preview");
|
|
2438
2474
|
break;
|
|
2439
2475
|
case "hold":
|
|
@@ -2458,7 +2494,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2458
2494
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2459
2495
|
if (command.action === "start" || command.action === "resume") {
|
|
2460
2496
|
interaction();
|
|
2461
|
-
into.require(task.
|
|
2497
|
+
into.require(task.audio === "started", "recording.request.phase", path, "capture needs live audio");
|
|
2462
2498
|
}
|
|
2463
2499
|
else
|
|
2464
2500
|
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
@@ -2551,7 +2587,7 @@ const RESULT_STATUSES = {
|
|
|
2551
2587
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
2552
2588
|
endBreak: { success: "ended", failure: "failed" },
|
|
2553
2589
|
executeTeam: { success: "applied", failure: "failed" },
|
|
2554
|
-
|
|
2590
|
+
openAudio: { success: "opened", failure: "unavailable" },
|
|
2555
2591
|
setPreference: { success: "applied", failure: "failed" },
|
|
2556
2592
|
recordStep: { success: "recorded", failure: "failed" },
|
|
2557
2593
|
};
|
|
@@ -2571,22 +2607,22 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2571
2607
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
2572
2608
|
return into.violations;
|
|
2573
2609
|
}
|
|
2574
|
-
if (method === "
|
|
2575
|
-
into.require(result.
|
|
2610
|
+
if (method === "openAudio" && result.status !== "opened") {
|
|
2611
|
+
into.require(result.audio === undefined, "result.audio.unexpected", `${path}.audio`, "only opened carries an audio session");
|
|
2576
2612
|
}
|
|
2577
2613
|
if (result.status === statuses.success) {
|
|
2578
2614
|
into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
|
|
2579
|
-
if (method === "
|
|
2580
|
-
if (isPlainObject(result.
|
|
2615
|
+
if (method === "openAudio") {
|
|
2616
|
+
if (isPlainObject(result.audio)) {
|
|
2581
2617
|
// Streams may come from another realm, and Node conformance runs have no MediaStream
|
|
2582
2618
|
// constructor. Check the boundary shape without claiming to verify live audio.
|
|
2583
|
-
into.require(isPlainObject(result.
|
|
2619
|
+
into.require(isPlainObject(result.audio.remoteAudio), "result.audio.remoteAudio", `${path}.audio.remoteAudio`, "an opened session carries its remote audio stream");
|
|
2584
2620
|
for (const method of ["setMuted", "close"]) {
|
|
2585
|
-
into.require(typeof result.
|
|
2621
|
+
into.require(typeof result.audio[method] === "function", `result.audio.${method}`, `${path}.audio.${method}`, `an audio session implements ${method}`);
|
|
2586
2622
|
}
|
|
2587
2623
|
}
|
|
2588
2624
|
else {
|
|
2589
|
-
into.add("result.
|
|
2625
|
+
into.add("result.audio", `${path}.audio`, "opened carries the audio session");
|
|
2590
2626
|
}
|
|
2591
2627
|
}
|
|
2592
2628
|
if (dials) {
|
|
@@ -2893,7 +2929,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
|
|
|
2893
2929
|
check(false, "policy.shape", at, "expected permitted actions");
|
|
2894
2930
|
continue;
|
|
2895
2931
|
}
|
|
2896
|
-
keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["
|
|
2932
|
+
keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["storageId"] : [])], at);
|
|
2897
2933
|
check(RECORDING_ACTIONS.some(action => policy[action] !== undefined), "policy.empty", at, "omit paths offering no actions");
|
|
2898
2934
|
for (const action of RECORDING_ACTIONS) {
|
|
2899
2935
|
if (policy[action] === undefined)
|
|
@@ -2901,7 +2937,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
|
|
|
2901
2937
|
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2902
2938
|
}
|
|
2903
2939
|
if (source === "host")
|
|
2904
|
-
check(filled(policy.
|
|
2940
|
+
check(filled(policy.storageId), "storage", `${at}.storageId`, "host storage must be named per task");
|
|
2905
2941
|
}
|
|
2906
2942
|
return violations;
|
|
2907
2943
|
}
|
|
@@ -2909,15 +2945,15 @@ export function validateHostRecording(value, softphone, path = "host.recording")
|
|
|
2909
2945
|
if (value === undefined)
|
|
2910
2946
|
return [];
|
|
2911
2947
|
const { violations, check, keys } = collector();
|
|
2912
|
-
check(softphone, "host.channel", path, "initial host recording requires voice softphone
|
|
2948
|
+
check(softphone, "host.channel", path, "initial host recording requires voice softphone audio");
|
|
2913
2949
|
if (!object(value)) {
|
|
2914
2950
|
check(false, "host.shape", path, "expected host recording declaration and executor");
|
|
2915
2951
|
return violations;
|
|
2916
2952
|
}
|
|
2917
|
-
keys(value, ["actions", "
|
|
2953
|
+
keys(value, ["actions", "storageIds", "execute", "announcesToCaller"], path);
|
|
2918
2954
|
if (value.announcesToCaller !== undefined)
|
|
2919
2955
|
check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
|
|
2920
|
-
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["
|
|
2956
|
+
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["storageIds", undefined]]) {
|
|
2921
2957
|
const list = value[key];
|
|
2922
2958
|
if (!Array.isArray(list)) {
|
|
2923
2959
|
check(false, "host.list", `${path}.${key}`, "expected explicit array");
|
|
@@ -3033,14 +3069,14 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3033
3069
|
state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
|
|
3034
3070
|
if (object(context.host)) {
|
|
3035
3071
|
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
3036
|
-
check(object(policy) && Array.isArray(context.host.
|
|
3072
|
+
check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
|
|
3037
3073
|
}
|
|
3038
3074
|
}
|
|
3039
3075
|
const fresh = effectiveRecordingState(state, context.now);
|
|
3040
3076
|
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
3041
3077
|
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
3042
3078
|
const opening = command.action === "start" || command.action === "resume";
|
|
3043
|
-
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.
|
|
3079
|
+
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
|
|
3044
3080
|
return violations;
|
|
3045
3081
|
}
|
|
3046
3082
|
/** Check recording-specific result semantics against the owner's confirming observations. */
|