@xema/omni-protocol 0.1.102 → 0.1.104
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 +81 -40
- package/dist/index.js +4 -2
- package/dist/testing.d.ts +8 -1
- package/dist/testing.js +81 -8
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +134 -41
- package/guide.md +199 -94
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -91,7 +91,7 @@ const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true
|
|
|
91
91
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
92
92
|
const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
|
|
93
93
|
const EXPIRABLE_PHASES = membersOf({
|
|
94
|
-
pending: true, confirmed: true,
|
|
94
|
+
pending: true, confirmed: true,
|
|
95
95
|
});
|
|
96
96
|
const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
97
97
|
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
@@ -100,8 +100,8 @@ const TASK_CAPABILITIES = {
|
|
|
100
100
|
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
101
101
|
endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
|
|
102
102
|
}),
|
|
103
|
-
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
104
|
-
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
103
|
+
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true, schedule: true }),
|
|
104
|
+
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, schedule: true }),
|
|
105
105
|
};
|
|
106
106
|
// The published list and the type's keys are the same set, or one of them is wrong.
|
|
107
107
|
true;
|
|
@@ -393,6 +393,10 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
393
393
|
function manifestDials(manifest) {
|
|
394
394
|
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
395
395
|
}
|
|
396
|
+
/** Whether a manifest declares a calendar, and so may publish tasks that schedule onto it. `undefined` where there is no manifest to ask. */
|
|
397
|
+
function manifestCalendar(manifest) {
|
|
398
|
+
return isPlainObject(manifest) ? isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.calendar === true : undefined;
|
|
399
|
+
}
|
|
396
400
|
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
397
401
|
export function validateProviderTimeEstimate(value, scope, path = "estimate") {
|
|
398
402
|
const into = new Collector();
|
|
@@ -520,8 +524,10 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
520
524
|
if (manifest.runningStepReports !== undefined) {
|
|
521
525
|
into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
|
|
522
526
|
}
|
|
523
|
-
|
|
524
|
-
|
|
527
|
+
for (const former of ["disposalSettleMs", "completionSettleMs"]) {
|
|
528
|
+
into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
|
|
529
|
+
}
|
|
530
|
+
into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
|
|
525
531
|
if (manifest.orgLevels !== undefined) {
|
|
526
532
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
527
533
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -1120,6 +1126,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1120
1126
|
into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
|
|
1121
1127
|
into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
|
|
1122
1128
|
}
|
|
1129
|
+
// How long the offer has left travels on the pending task, so a reconnect snapshot restates it.
|
|
1130
|
+
if (task.expiresInSeconds !== undefined) {
|
|
1131
|
+
into.require(task.phase === "pending", "task.pending.expiresInSeconds.unexpected", `${path}.expiresInSeconds`, "expiresInSeconds is how long an offer has left, and belongs to a pending task; accepted, there is no offer to run out");
|
|
1132
|
+
into.require(isDurationSeconds(task.expiresInSeconds), "task.pending.expiresInSeconds", `${path}.expiresInSeconds`, "expiresInSeconds is how long the offer has left: a whole number of seconds, zero or more, counted from this publication");
|
|
1133
|
+
}
|
|
1123
1134
|
// The deadline and what happens at it travel together, and only while the record is being previewed.
|
|
1124
1135
|
if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
|
|
1125
1136
|
into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
|
|
@@ -1239,8 +1250,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1239
1250
|
if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
|
|
1240
1251
|
into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
|
|
1241
1252
|
}
|
|
1253
|
+
// Under agent-command the allowance is the expected wrap, shown and never acted on: the agent
|
|
1254
|
+
// completes whenever they are done, so there is always a wrap to collect the code in.
|
|
1242
1255
|
if (capabilities.outcomes !== undefined) {
|
|
1243
|
-
into.require(task.wrapAllowance
|
|
1256
|
+
into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and provider-automatic with wrapAllowance 0 gives none: publish a wrap or no outcomes");
|
|
1244
1257
|
}
|
|
1245
1258
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
1246
1259
|
if (declared === undefined)
|
|
@@ -1256,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1256
1269
|
if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
|
|
1257
1270
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
1258
1271
|
}
|
|
1272
|
+
// A follow-up lands on the calendar, so the manifest has to have one for it to land on.
|
|
1273
|
+
if (name === "schedule" && context.calendarDeclared === false) {
|
|
1274
|
+
into.add("task.capability.calendar.required", `${path}.capabilities.schedule`, "schedule puts a follow-up on the calendar, and the manifest declares no calendar for it to land on");
|
|
1275
|
+
}
|
|
1259
1276
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
1260
1277
|
// queue provides -- browsers, outcomes, custom controls -- is content, not a control.
|
|
1261
1278
|
if (isLocked(declared)) {
|
|
@@ -1296,8 +1313,9 @@ function validateForcedBreak(value, path, into) {
|
|
|
1296
1313
|
for (const field of ["endsAutomatically", "endsAt"]) {
|
|
1297
1314
|
into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
|
|
1298
1315
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1316
|
+
into.require(!Object.hasOwn(value, "expectedDurationMs"), "break.forced.expectedDurationMs.renamed", `${path}.expectedDurationMs`, "the state says how long is left, as expectedEndsInSeconds; the duration asked for travels on the lead's command alone");
|
|
1317
|
+
if (value.expectedEndsInSeconds !== undefined)
|
|
1318
|
+
into.require(isDurationSeconds(value.expectedEndsInSeconds), "break.forced.expectedEndsInSeconds", `${path}.expectedEndsInSeconds`, "expectedEndsInSeconds is how long the break is expected to run yet: a whole number of seconds, zero or more, counted from this publication");
|
|
1301
1319
|
}
|
|
1302
1320
|
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
1303
1321
|
export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
@@ -1399,8 +1417,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1399
1417
|
else if (command.type === "force-break" || command.type === "end-forced-break") {
|
|
1400
1418
|
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1401
1419
|
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1402
|
-
if (command.type === "end-forced-break")
|
|
1420
|
+
if (command.type === "end-forced-break") {
|
|
1403
1421
|
into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
|
|
1422
|
+
// The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
|
|
1423
|
+
into.require(!(isPlainObject(state.forced) && state.forced.by === "provider"), "team.command.endForcedBreak.provider", path, "the platform put this member on the break, and it lifts it: a lead ends a break a lead forced");
|
|
1424
|
+
}
|
|
1404
1425
|
if (command.type === "force-break") {
|
|
1405
1426
|
if (command.expectedDurationMs !== undefined)
|
|
1406
1427
|
into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
|
|
@@ -1415,7 +1436,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1415
1436
|
into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
|
|
1416
1437
|
}
|
|
1417
1438
|
else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
|
|
1418
|
-
|
|
1439
|
+
// Every lead's entry is on the member; the one this command moves is the signed-in lead's.
|
|
1440
|
+
into.require(isPlainObject(target) && Array.isArray(target.listening) && target.listening.some((entry) => isPlainObject(entry) && entry.leadId === self), "team.command.listening", path, `${command.type} needs this lead on this member's call: the team member list shows listening on them naming this lead`);
|
|
1419
1441
|
}
|
|
1420
1442
|
return into.violations;
|
|
1421
1443
|
}
|
|
@@ -1611,10 +1633,19 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1611
1633
|
}
|
|
1612
1634
|
validateTeamMemberInto(member, at, context, into);
|
|
1613
1635
|
});
|
|
1614
|
-
// One member's call at a time.
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1636
|
+
// One member's call at a time, per lead.
|
|
1637
|
+
const onCalls = new Map();
|
|
1638
|
+
for (const member of teamMembers.members) {
|
|
1639
|
+
if (!isPlainObject(member) || !Array.isArray(member.listening))
|
|
1640
|
+
continue;
|
|
1641
|
+
const leads = new Set(member.listening.filter(isPlainObject).map(entry => entry.leadId).filter((id) => typeof id === "string"));
|
|
1642
|
+
for (const leadId of leads)
|
|
1643
|
+
onCalls.set(leadId, (onCalls.get(leadId) ?? 0) + 1);
|
|
1644
|
+
}
|
|
1645
|
+
for (const [leadId, count] of onCalls) {
|
|
1646
|
+
if (count > 1)
|
|
1647
|
+
into.add("team.listening.single", `${path}.members`, `${leadId} is on ${count} members' calls: a lead is on one member's call at a time`);
|
|
1648
|
+
}
|
|
1618
1649
|
}
|
|
1619
1650
|
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
1620
1651
|
export function validateTeamMember(member, path = "member", context = {}) {
|
|
@@ -1659,23 +1690,41 @@ function validateTeamMemberInto(member, at, context, into) {
|
|
|
1659
1690
|
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1660
1691
|
}
|
|
1661
1692
|
}
|
|
1662
|
-
//
|
|
1693
|
+
// Every lead on this member's call, in the mode they are heard, published to every lead alike.
|
|
1663
1694
|
if (member.listening !== undefined) {
|
|
1664
|
-
if (!
|
|
1665
|
-
into.add("team.member.listening.shape", `${at}.listening`, "listening
|
|
1695
|
+
if (!Array.isArray(member.listening)) {
|
|
1696
|
+
into.add("team.member.listening.shape", `${at}.listening`, "listening is an array of the leads on the member's call: absent while none is");
|
|
1666
1697
|
}
|
|
1667
1698
|
else {
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1699
|
+
into.require(member.listening.length > 0, "team.member.listening.empty", `${at}.listening`, "nobody on the call is absence, not an empty list");
|
|
1700
|
+
const leads = new Set();
|
|
1701
|
+
member.listening.forEach((entry, index) => {
|
|
1702
|
+
const here = `${at}.listening[${index}]`;
|
|
1703
|
+
if (!isPlainObject(entry)) {
|
|
1704
|
+
into.add("team.member.listening.entry", here, "each listening entry must be an object");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (into.require(isUserId(entry.leadId), "team.member.listening.leadId", `${here}.leadId`, "a listening entry names the lead on the call, by user id")) {
|
|
1708
|
+
if (leads.has(entry.leadId))
|
|
1709
|
+
into.add("team.member.listening.unique", `${here}.leadId`, `${entry.leadId} is listed twice on one member: a lead is on a member's call once`);
|
|
1710
|
+
leads.add(entry.leadId);
|
|
1711
|
+
}
|
|
1712
|
+
const task = Array.isArray(member.tasks) ? member.tasks.find((task) => isPlainObject(task) && task.assignmentId === entry.assignmentId) : undefined;
|
|
1713
|
+
if (into.require(isAssignmentId(entry.assignmentId), "team.member.listening.assignmentId", `${here}.assignmentId`, "a listening entry names which of the member's calls the lead is on: the application opens its own audio on it") && Array.isArray(member.tasks)) {
|
|
1714
|
+
into.require(task !== undefined, "team.member.listening.assignment", `${here}.assignmentId`, "the call the lead is on is one the member's tasks carry");
|
|
1715
|
+
}
|
|
1716
|
+
if (into.oneOf(entry.mode, LISTENING_MODES, "team.member.listening.mode", `${here}.mode`) && entry.mode === "join-call"
|
|
1717
|
+
&& isPlainObject(task) && Array.isArray(task.onCall) && isUserId(entry.leadId)) {
|
|
1718
|
+
// A lead who is heard is in the room the member sees; a silent one is not.
|
|
1719
|
+
into.require(task.onCall.some((who) => isPlainObject(who) && who.role === "agent" && who.userId === entry.leadId), "team.member.listening.heard", `${here}.mode`, `${entry.leadId} has joined the call and is heard, so the task's onCall carries them as an agent`);
|
|
1720
|
+
}
|
|
1721
|
+
into.timestamp(entry.since, "team.member.listening.since", `${here}.since`);
|
|
1722
|
+
});
|
|
1674
1723
|
}
|
|
1675
1724
|
}
|
|
1676
1725
|
// The member's own history for the day: about the person, not any one call.
|
|
1677
1726
|
if (member.shift !== undefined)
|
|
1678
|
-
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1727
|
+
validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
|
|
1679
1728
|
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1680
1729
|
if (member.request !== undefined) {
|
|
1681
1730
|
if (!isPlainObject(member.request)) {
|
|
@@ -1701,6 +1750,18 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1701
1750
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1702
1751
|
const levels = context.levels ?? manifestLevels(manifest);
|
|
1703
1752
|
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
|
|
1753
|
+
// The provider's own instant of the read: required where the provider has a clock to state, and
|
|
1754
|
+
// a clock nobody declared is not one the desk may count from.
|
|
1755
|
+
if (isPlainObject(manifest) && manifest.timeCheck === true) {
|
|
1756
|
+
into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
|
|
1757
|
+
}
|
|
1758
|
+
else if (snapshot.providerTime !== undefined) {
|
|
1759
|
+
into.add("snapshot.providerTime.unexpected", `${path}.providerTime`, "providerTime is the clock of a provider that declares timeCheck; declare it, or send none");
|
|
1760
|
+
}
|
|
1761
|
+
requireClockInto(snapshot, manifest, path, into);
|
|
1762
|
+
// The agent's own day, as the provider counts it: the same numbers their lead sees.
|
|
1763
|
+
if (snapshot.shift !== undefined)
|
|
1764
|
+
validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
|
|
1704
1765
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1705
1766
|
&& context.loginId !== undefined) {
|
|
1706
1767
|
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
@@ -1717,7 +1778,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1717
1778
|
else {
|
|
1718
1779
|
const seen = new Set();
|
|
1719
1780
|
snapshot.tasks.forEach((task, index) => {
|
|
1720
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1781
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1721
1782
|
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1722
1783
|
if (seen.has(task.assignmentId))
|
|
1723
1784
|
into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
|
|
@@ -1874,6 +1935,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1874
1935
|
return into.violations;
|
|
1875
1936
|
}
|
|
1876
1937
|
const at = `${path}.event`;
|
|
1938
|
+
// A snapshot event is read by validateSnapshot, which asks the clock question of it there.
|
|
1939
|
+
if (event.type !== "snapshot")
|
|
1940
|
+
requireClockInto(event, manifest, at, into);
|
|
1877
1941
|
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");
|
|
1878
1942
|
switch (event.type) {
|
|
1879
1943
|
case "snapshot":
|
|
@@ -1899,16 +1963,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1899
1963
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1900
1964
|
break;
|
|
1901
1965
|
case "task-offered":
|
|
1902
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1966
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1903
1967
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1904
1968
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1905
1969
|
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}`);
|
|
1906
1970
|
}
|
|
1907
|
-
|
|
1908
|
-
into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
|
|
1971
|
+
into.require(event.expiresInSeconds === undefined, "event.taskOffered.expiresInSeconds.unexpected", `${at}.expiresInSeconds`, "expiresInSeconds travels on the pending task, where a reconnect snapshot restates it, not on the event");
|
|
1909
1972
|
break;
|
|
1910
1973
|
case "task-updated":
|
|
1911
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1974
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1912
1975
|
break;
|
|
1913
1976
|
case "task-audio-started":
|
|
1914
1977
|
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
@@ -1985,6 +2048,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1985
2048
|
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
1986
2049
|
}
|
|
1987
2050
|
break;
|
|
2051
|
+
case "shift-updated":
|
|
2052
|
+
validateShiftInto(event.shift, `${at}.shift`, into, "shift");
|
|
2053
|
+
break;
|
|
1988
2054
|
case "calendar-updated":
|
|
1989
2055
|
into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
|
|
1990
2056
|
if (!Array.isArray(event.calendar)) {
|
|
@@ -2036,38 +2102,65 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
2036
2102
|
});
|
|
2037
2103
|
}
|
|
2038
2104
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
2039
|
-
|
|
2105
|
+
/** Whether a publication carries an instant the desk renders as a running duration, which the provider's clock reads. */
|
|
2106
|
+
function carriesRunningInstant(value) {
|
|
2107
|
+
if (!isPlainObject(value))
|
|
2108
|
+
return false;
|
|
2109
|
+
if (isPlainObject(value.shift) && value.shift.signedInAt !== undefined)
|
|
2110
|
+
return true;
|
|
2111
|
+
const tasks = [
|
|
2112
|
+
...(Array.isArray(value.tasks) ? value.tasks : []),
|
|
2113
|
+
...(isPlainObject(value.task) ? [value.task] : []),
|
|
2114
|
+
];
|
|
2115
|
+
if (tasks.some(task => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))
|
|
2116
|
+
return true;
|
|
2117
|
+
const members = [
|
|
2118
|
+
...(isPlainObject(value.team) && Array.isArray(value.team.members) ? value.team.members : []),
|
|
2119
|
+
...(isPlainObject(value.member) ? [value.member] : []),
|
|
2120
|
+
];
|
|
2121
|
+
return members.some(member => isPlainObject(member) && (member.since !== undefined
|
|
2122
|
+
|| (Array.isArray(member.listening) && member.listening.some((entry) => isPlainObject(entry) && entry.since !== undefined))
|
|
2123
|
+
|| (isPlainObject(member.shift) && member.shift.signedInAt !== undefined)
|
|
2124
|
+
|| (Array.isArray(member.tasks) && member.tasks.some((task) => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))));
|
|
2125
|
+
}
|
|
2126
|
+
/** A running duration is counted from the provider's clock: a provider that publishes one declares timeCheck. */
|
|
2127
|
+
function requireClockInto(value, manifest, path, into) {
|
|
2128
|
+
if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
|
|
2129
|
+
return;
|
|
2130
|
+
into.add("manifest.timeCheck.required", path, "this publishes an instant the desk renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
|
|
2131
|
+
}
|
|
2132
|
+
function validateShiftInto(value, path, into, rule) {
|
|
2040
2133
|
if (!isPlainObject(value)) {
|
|
2041
|
-
into.add(
|
|
2134
|
+
into.add(`${rule}.shape`, path, "shift must be an object when present");
|
|
2042
2135
|
return;
|
|
2043
2136
|
}
|
|
2044
|
-
const signedIn = into.timestamp(value.signedInAt,
|
|
2045
|
-
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt,
|
|
2046
|
-
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt),
|
|
2137
|
+
const signedIn = into.timestamp(value.signedInAt, `${rule}.signedInAt`, `${path}.signedInAt`);
|
|
2138
|
+
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, `${rule}.signedOutAt`, `${path}.signedOutAt`) && signedIn) {
|
|
2139
|
+
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), `${rule}.signedOutAt.order`, `${path}.signedOutAt`, "a member signs out after signing in");
|
|
2047
2140
|
}
|
|
2048
2141
|
for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
|
|
2049
2142
|
if (value[field] !== undefined)
|
|
2050
2143
|
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`);
|
|
2051
2144
|
}
|
|
2052
2145
|
if (value.tasksHandled !== undefined)
|
|
2053
|
-
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0,
|
|
2146
|
+
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, `${rule}.tasksHandled`, `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
|
|
2054
2147
|
if (value.events !== undefined) {
|
|
2055
2148
|
if (!Array.isArray(value.events)) {
|
|
2056
|
-
into.add(
|
|
2149
|
+
into.add(`${rule}.events.shape`, `${path}.events`, "events must be an array when present");
|
|
2057
2150
|
return;
|
|
2058
2151
|
}
|
|
2059
2152
|
let previous;
|
|
2060
2153
|
value.events.forEach((event, index) => {
|
|
2061
2154
|
const at = `${path}.events[${index}]`;
|
|
2062
2155
|
if (!isPlainObject(event)) {
|
|
2063
|
-
into.add(
|
|
2156
|
+
into.add(`${rule}.event.shape`, at, "each shift event must be an object");
|
|
2064
2157
|
return;
|
|
2065
2158
|
}
|
|
2066
|
-
into.oneOf(event.kind, SHIFT_EVENT_KINDS,
|
|
2067
|
-
if (into.timestamp(event.at,
|
|
2159
|
+
into.oneOf(event.kind, SHIFT_EVENT_KINDS, `${rule}.event.kind`, `${at}.kind`);
|
|
2160
|
+
if (into.timestamp(event.at, `${rule}.event.at`, `${at}.at`)) {
|
|
2068
2161
|
const instant = Date.parse(event.at);
|
|
2069
2162
|
if (previous !== undefined && instant < previous)
|
|
2070
|
-
into.add(
|
|
2163
|
+
into.add(`${rule}.events.order`, `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
|
|
2071
2164
|
previous = instant;
|
|
2072
2165
|
}
|
|
2073
2166
|
});
|
|
@@ -2543,7 +2636,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2543
2636
|
case "complete": {
|
|
2544
2637
|
// The agent may finish early under either mode; the provider is free to end at once. With
|
|
2545
2638
|
// no wrap at all there is no window to cut short, and nothing for the command to do.
|
|
2546
|
-
into.require(task.wrapAllowance
|
|
2639
|
+
into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "command.complete.wrapAllowance", path, "the task has no wrap: under provider-automatic with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
|
|
2547
2640
|
// What travels with complete is what the outcomes capability published: a code from its
|
|
2548
2641
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2549
2642
|
const outcomes = capabilities.outcomes;
|