@xema/omni-protocol 0.1.101 → 0.1.103
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 +92 -81
- package/dist/index.js +8 -6
- package/dist/testing.d.ts +26 -1
- package/dist/testing.js +224 -35
- package/dist/validation.d.ts +8 -3
- package/dist/validation.js +178 -159
- package/guide.md +316 -210
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -71,10 +71,10 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
|
|
|
71
71
|
const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true, unreachable: true, rejected: true, cancelled: true, unexplained: true });
|
|
72
72
|
/** The outcomes that name a cause. `unexplained` is declarable only beside one of these. */
|
|
73
73
|
const EXPLAINED_FAILURES = ["busy", "no-answer", "unreachable", "rejected", "cancelled"];
|
|
74
|
-
const ON_CALL_ROLES = membersOf({ party: true, agent: true,
|
|
74
|
+
const ON_CALL_ROLES = membersOf({ party: true, agent: true, conferenced: true });
|
|
75
75
|
const ON_CALL_STAGES = membersOf({ ringing: true, joined: true });
|
|
76
76
|
/** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
|
|
77
|
-
const DIALLING_CAPABILITIES = ["connectBack", "
|
|
77
|
+
const DIALLING_CAPABILITIES = ["connectBack", "conference"];
|
|
78
78
|
const SNAPSHOT_REASONS = membersOf({
|
|
79
79
|
reconnected: true, "provider-requested": true,
|
|
80
80
|
});
|
|
@@ -91,17 +91,17 @@ 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. */
|
|
98
98
|
const TASK_CAPABILITIES = {
|
|
99
99
|
voice: membersOf({
|
|
100
100
|
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
101
|
-
endCall: true, terminateCall: true, connectBack: true,
|
|
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");
|
|
@@ -579,7 +585,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
579
585
|
// ---------------------------------------------------------------------------
|
|
580
586
|
function validateDestinationDirectory(value, path, into) {
|
|
581
587
|
if (!isPlainObject(value)) {
|
|
582
|
-
into.add("task.destinations.shape", path, "a
|
|
588
|
+
into.add("task.destinations.shape", path, "a conference control carries the directory it offers: { destinations: [...] }");
|
|
583
589
|
return;
|
|
584
590
|
}
|
|
585
591
|
if (!Array.isArray(value.destinations)) {
|
|
@@ -982,7 +988,7 @@ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
|
982
988
|
/**
|
|
983
989
|
* Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
|
|
984
990
|
* role needs and nothing another role would: a party or an agent dialled nowhere, so neither
|
|
985
|
-
* carries a destination; somebody
|
|
991
|
+
* carries a destination; somebody conferenced came from one, so they do.
|
|
986
992
|
*/
|
|
987
993
|
function validateOnCall(value, channel, path, into) {
|
|
988
994
|
if (value === undefined)
|
|
@@ -993,7 +999,7 @@ function validateOnCall(value, channel, path, into) {
|
|
|
993
999
|
into.add("task.onCall.shape", path, "onCall is an array of who is on the call, when the provider knows");
|
|
994
1000
|
return;
|
|
995
1001
|
}
|
|
996
|
-
|
|
1002
|
+
let parties = 0;
|
|
997
1003
|
value.forEach((entry, index) => {
|
|
998
1004
|
const at = `${path}[${index}]`;
|
|
999
1005
|
if (!isPlainObject(entry)) {
|
|
@@ -1007,15 +1013,15 @@ function validateOnCall(value, channel, path, into) {
|
|
|
1007
1013
|
if (entry.held !== undefined) {
|
|
1008
1014
|
into.require(entry.held === true, "task.onCall.held", `${at}.held`, "held is stated by presence: send true or omit it");
|
|
1009
1015
|
}
|
|
1010
|
-
if (role === "party"
|
|
1011
|
-
|
|
1016
|
+
if (role === "party")
|
|
1017
|
+
parties += 1;
|
|
1012
1018
|
if (role === "agent") {
|
|
1013
1019
|
into.require(isUserId(entry.userId), "task.onCall.userId", `${at}.userId`, "an agent on the call is named by their user id");
|
|
1014
1020
|
}
|
|
1015
1021
|
else {
|
|
1016
1022
|
into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
|
|
1017
1023
|
}
|
|
1018
|
-
if (role === "
|
|
1024
|
+
if (role === "conferenced") {
|
|
1019
1025
|
into.filled(entry.destinationId, "task.onCall.destinationId", `${at}.destinationId`, `a ${role} entry names the directory item that was dialled`);
|
|
1020
1026
|
// Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
|
|
1021
1027
|
if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
|
|
@@ -1037,17 +1043,16 @@ function validateOnCall(value, channel, path, into) {
|
|
|
1037
1043
|
into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
|
|
1038
1044
|
}
|
|
1039
1045
|
for (const field of ["destinationId", "label"]) {
|
|
1040
|
-
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on
|
|
1046
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on conferenced`);
|
|
1041
1047
|
}
|
|
1042
1048
|
}
|
|
1043
1049
|
else {
|
|
1044
1050
|
for (const field of ["destinationId", "dialId", "label", "stage"]) {
|
|
1045
|
-
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on
|
|
1051
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on conferenced`);
|
|
1046
1052
|
}
|
|
1047
1053
|
}
|
|
1048
1054
|
});
|
|
1049
|
-
into.require(
|
|
1050
|
-
into.require(counted.consulted <= 1, "task.onCall.consulted.single", path, "one consultation at a time: transfer complete and cancel name no destination because there is exactly one");
|
|
1055
|
+
into.require(parties <= 1, "task.onCall.party.single", path, "a call has one party; a second is somebody else's role");
|
|
1051
1056
|
}
|
|
1052
1057
|
const LEAD_STAGES = membersOf({ requested: true, joined: true });
|
|
1053
1058
|
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
@@ -1121,14 +1126,19 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1121
1126
|
into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
|
|
1122
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");
|
|
1123
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
|
+
}
|
|
1124
1134
|
// The deadline and what happens at it travel together, and only while the record is being previewed.
|
|
1125
|
-
if (task.
|
|
1126
|
-
into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.
|
|
1127
|
-
if (task.
|
|
1128
|
-
into.add("task.preview.
|
|
1135
|
+
if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
|
|
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");
|
|
1137
|
+
if (task.previewEndsInSeconds === undefined) {
|
|
1138
|
+
into.add("task.preview.previewEndsInSeconds.required", `${path}.previewEndsInSeconds`, "atDeadline says what happens at a deadline, so there has to be one");
|
|
1129
1139
|
}
|
|
1130
1140
|
else {
|
|
1131
|
-
into.
|
|
1141
|
+
into.require(isDurationSeconds(task.previewEndsInSeconds), "task.preview.previewEndsInSeconds", `${path}.previewEndsInSeconds`, "previewEndsInSeconds is how long the preview has left: a whole number of seconds, zero or more, counted from this publication");
|
|
1132
1142
|
}
|
|
1133
1143
|
if (task.atDeadline === undefined) {
|
|
1134
1144
|
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");
|
|
@@ -1235,6 +1245,16 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1235
1245
|
if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
|
|
1236
1246
|
into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
|
|
1237
1247
|
}
|
|
1248
|
+
// An outcome is given in wrap, by the agent: a provider that completes the task itself cannot
|
|
1249
|
+
// wait for one, and a task with no wrap has nowhere to take one.
|
|
1250
|
+
if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
|
|
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");
|
|
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.
|
|
1255
|
+
if (capabilities.outcomes !== undefined) {
|
|
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");
|
|
1257
|
+
}
|
|
1238
1258
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
1239
1259
|
if (declared === undefined)
|
|
1240
1260
|
continue;
|
|
@@ -1249,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1249
1269
|
if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
|
|
1250
1270
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
1251
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
|
+
}
|
|
1252
1276
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
1253
1277
|
// queue provides -- browsers, outcomes, custom controls -- is content, not a control.
|
|
1254
1278
|
if (isLocked(declared)) {
|
|
@@ -1267,8 +1291,6 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1267
1291
|
case "custom":
|
|
1268
1292
|
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
1269
1293
|
break;
|
|
1270
|
-
case "coldTransfer":
|
|
1271
|
-
case "warmTransfer":
|
|
1272
1294
|
case "conference":
|
|
1273
1295
|
validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
|
|
1274
1296
|
break;
|
|
@@ -1287,7 +1309,7 @@ function validateForcedBreak(value, path, into) {
|
|
|
1287
1309
|
into.add("break.forced.shape", path, "a forced break must be an object");
|
|
1288
1310
|
return;
|
|
1289
1311
|
}
|
|
1290
|
-
into.require(isUserId(value.by), "break.forced.by", `${path}.by`, "a forced break
|
|
1312
|
+
into.require(isUserId(value.by) || value.by === "provider", "break.forced.by", `${path}.by`, "a forced break says who forced it: the lead by user id, or provider where the platform did");
|
|
1291
1313
|
for (const field of ["endsAutomatically", "endsAt"]) {
|
|
1292
1314
|
into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
|
|
1293
1315
|
}
|
|
@@ -1394,8 +1416,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1394
1416
|
else if (command.type === "force-break" || command.type === "end-forced-break") {
|
|
1395
1417
|
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1396
1418
|
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1397
|
-
if (command.type === "end-forced-break")
|
|
1419
|
+
if (command.type === "end-forced-break") {
|
|
1398
1420
|
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");
|
|
1421
|
+
// The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
|
|
1422
|
+
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");
|
|
1423
|
+
}
|
|
1399
1424
|
if (command.type === "force-break") {
|
|
1400
1425
|
if (command.expectedDurationMs !== undefined)
|
|
1401
1426
|
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");
|
|
@@ -1406,9 +1431,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1406
1431
|
}
|
|
1407
1432
|
}
|
|
1408
1433
|
else if (command.type === "join" || command.type === "decline") {
|
|
1409
|
-
const
|
|
1410
|
-
into.require(
|
|
1411
|
-
&& (command.assignmentId === undefined || r.assignmentId === command.assignmentId)), "team.command.request", path, `${command.type} answers a request the team member list carries, and this member has none`);
|
|
1434
|
+
const asked = isPlainObject(target) && isPlainObject(target.request) ? target.request : undefined;
|
|
1435
|
+
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`);
|
|
1412
1436
|
}
|
|
1413
1437
|
else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
|
|
1414
1438
|
into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
|
|
@@ -1593,32 +1617,6 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1593
1617
|
// Policies are present where the provider offers policy control, absent where it does not.
|
|
1594
1618
|
if (teamMembers.policies !== undefined)
|
|
1595
1619
|
validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
|
|
1596
|
-
// Requests are what a lead acts on: always carried, `[]` when nobody is asking.
|
|
1597
|
-
if (!Array.isArray(teamMembers.requests)) {
|
|
1598
|
-
into.add("team.requests.shape", `${path}.requests`, "a team member list carries its requests: [] when nobody is asking");
|
|
1599
|
-
}
|
|
1600
|
-
else {
|
|
1601
|
-
const seenRequests = new Set();
|
|
1602
|
-
teamMembers.requests.forEach((request, index) => {
|
|
1603
|
-
const at = `${path}.requests[${index}]`;
|
|
1604
|
-
if (!isPlainObject(request)) {
|
|
1605
|
-
into.add("team.request.shape", at, "each request must be an object");
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1609
|
-
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");
|
|
1610
|
-
}
|
|
1611
|
-
if (into.require(isAssignmentId(request.assignmentId), "team.request.assignmentId", `${at}.assignmentId`, "a request names the member's assignment the lead would join")) {
|
|
1612
|
-
const key = `${String(request.memberId)}@${String(request.assignmentId)}`;
|
|
1613
|
-
if (seenRequests.has(key))
|
|
1614
|
-
into.add("team.request.unique", at, `duplicate request: ${key}`);
|
|
1615
|
-
seenRequests.add(key);
|
|
1616
|
-
}
|
|
1617
|
-
if (request.note !== undefined)
|
|
1618
|
-
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1619
|
-
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
1620
|
-
});
|
|
1621
|
-
}
|
|
1622
1620
|
if (!Array.isArray(teamMembers.members)) {
|
|
1623
1621
|
into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
|
|
1624
1622
|
return;
|
|
@@ -1626,68 +1624,94 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1626
1624
|
const seen = new Set();
|
|
1627
1625
|
teamMembers.members.forEach((member, index) => {
|
|
1628
1626
|
const at = `${path}.members[${index}]`;
|
|
1629
|
-
if (
|
|
1630
|
-
into.add("team.member.shape", at, "each team member must be an object");
|
|
1631
|
-
return;
|
|
1632
|
-
}
|
|
1633
|
-
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
|
|
1627
|
+
if (isPlainObject(member) && isUserId(member.id)) {
|
|
1634
1628
|
if (seen.has(member.id))
|
|
1635
1629
|
into.add("team.member.unique", `${at}.id`, `duplicate team member: ${member.id}`);
|
|
1636
1630
|
seen.add(member.id);
|
|
1637
|
-
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
|
|
1638
|
-
}
|
|
1639
|
-
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
1640
|
-
if (member.since !== undefined)
|
|
1641
|
-
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
1642
|
-
if (member.break !== undefined) {
|
|
1643
|
-
// Only an outstanding request appears here: `not-requested` is absence, `on-break` is
|
|
1644
|
-
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
1645
|
-
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
1646
|
-
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
1647
|
-
}
|
|
1648
1631
|
}
|
|
1649
|
-
|
|
1650
|
-
if (member.tasks !== undefined) {
|
|
1651
|
-
if (!Array.isArray(member.tasks)) {
|
|
1652
|
-
into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
|
|
1653
|
-
}
|
|
1654
|
-
else {
|
|
1655
|
-
const held = new Set();
|
|
1656
|
-
member.tasks.forEach((task, index) => {
|
|
1657
|
-
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
|
|
1658
|
-
validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
|
|
1659
|
-
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1660
|
-
if (held.has(task.assignmentId))
|
|
1661
|
-
into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
|
|
1662
|
-
held.add(task.assignmentId);
|
|
1663
|
-
}
|
|
1664
|
-
});
|
|
1665
|
-
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
// The lead on this member's call, in the mode they are heard.
|
|
1669
|
-
if (member.listening !== undefined) {
|
|
1670
|
-
if (!isPlainObject(member.listening)) {
|
|
1671
|
-
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1672
|
-
}
|
|
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
|
-
}
|
|
1678
|
-
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1679
|
-
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1680
|
-
}
|
|
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);
|
|
1632
|
+
validateTeamMemberInto(member, at, context, into);
|
|
1685
1633
|
});
|
|
1686
1634
|
// One member's call at a time.
|
|
1687
1635
|
const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
|
|
1688
1636
|
if (listened.length > 1)
|
|
1689
1637
|
into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
|
|
1690
1638
|
}
|
|
1639
|
+
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
1640
|
+
export function validateTeamMember(member, path = "member", context = {}) {
|
|
1641
|
+
const into = new Collector();
|
|
1642
|
+
validateTeamMemberInto(member, path, context, into);
|
|
1643
|
+
return into.violations;
|
|
1644
|
+
}
|
|
1645
|
+
function validateTeamMemberInto(member, at, context, into) {
|
|
1646
|
+
if (!isPlainObject(member)) {
|
|
1647
|
+
into.add("team.member.shape", at, "each team member must be an object");
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
|
|
1651
|
+
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
|
|
1652
|
+
}
|
|
1653
|
+
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
1654
|
+
if (member.since !== undefined)
|
|
1655
|
+
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
1656
|
+
if (member.break !== undefined) {
|
|
1657
|
+
// Only an outstanding request appears here: `not-requested` is absence, `on-break` is
|
|
1658
|
+
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
1659
|
+
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
1660
|
+
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
// The member's open tasks, as the lead sees them: trimmed by the provider, held to the task rules otherwise.
|
|
1664
|
+
if (member.tasks !== undefined) {
|
|
1665
|
+
if (!Array.isArray(member.tasks)) {
|
|
1666
|
+
into.add("team.member.tasks.shape", `${at}.tasks`, "a member's tasks are an array: [] when they hold none, omitted only where the provider cannot see them");
|
|
1667
|
+
}
|
|
1668
|
+
else {
|
|
1669
|
+
const held = new Set();
|
|
1670
|
+
member.tasks.forEach((task, index) => {
|
|
1671
|
+
const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
|
|
1672
|
+
validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
|
|
1673
|
+
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1674
|
+
if (held.has(task.assignmentId))
|
|
1675
|
+
into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
|
|
1676
|
+
held.add(task.assignmentId);
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
// The lead on this member's call, in the mode they are heard.
|
|
1683
|
+
if (member.listening !== undefined) {
|
|
1684
|
+
if (!isPlainObject(member.listening)) {
|
|
1685
|
+
into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
const listened = member.listening;
|
|
1689
|
+
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)) {
|
|
1690
|
+
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");
|
|
1691
|
+
}
|
|
1692
|
+
into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
|
|
1693
|
+
into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
// The member's own history for the day: about the person, not any one call.
|
|
1697
|
+
if (member.shift !== undefined)
|
|
1698
|
+
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1699
|
+
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1700
|
+
if (member.request !== undefined) {
|
|
1701
|
+
if (!isPlainObject(member.request)) {
|
|
1702
|
+
into.add("team.member.request.shape", `${at}.request`, "request must be an object when present");
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
const asked = member.request;
|
|
1706
|
+
if (into.require(isAssignmentId(asked.assignmentId), "team.member.request.assignmentId", `${at}.request.assignmentId`, "a request names the member's assignment the lead would join") && Array.isArray(member.tasks)) {
|
|
1707
|
+
into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === asked.assignmentId), "team.member.request.assignment", `${at}.request.assignmentId`, "the call the member asks a lead onto is one their tasks carry");
|
|
1708
|
+
}
|
|
1709
|
+
if (asked.note !== undefined)
|
|
1710
|
+
into.filled(asked.note, "team.member.request.note", `${at}.request.note`, "a note must not be empty when present");
|
|
1711
|
+
into.timestamp(asked.since, "team.member.request.since", `${at}.request.since`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1691
1715
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
1692
1716
|
const into = new Collector();
|
|
1693
1717
|
if (!isPlainObject(snapshot)) {
|
|
@@ -1713,7 +1737,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1713
1737
|
else {
|
|
1714
1738
|
const seen = new Set();
|
|
1715
1739
|
snapshot.tasks.forEach((task, index) => {
|
|
1716
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1740
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1717
1741
|
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1718
1742
|
if (seen.has(task.assignmentId))
|
|
1719
1743
|
into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
|
|
@@ -1761,14 +1785,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1761
1785
|
into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
|
|
1762
1786
|
}
|
|
1763
1787
|
}
|
|
1764
|
-
// The
|
|
1765
|
-
//
|
|
1766
|
-
//
|
|
1767
|
-
|
|
1768
|
-
|
|
1788
|
+
// The switch is the permission: once the application has said the team feature is on, a
|
|
1789
|
+
// snapshot carries the team, `members: []` included. Before it has said, the provider assumes
|
|
1790
|
+
// nothing and owes nothing. The other direction -- a team member list to a login that does not
|
|
1791
|
+
// lead, or to a lead who turned the feature off -- is the team member list's own rule.
|
|
1792
|
+
if (context.leadFeatures === true && snapshot.team === undefined) {
|
|
1793
|
+
into.add("team.required", `${path}.team`, "the team feature is on, so every snapshot carries a team member list: members: [] when nobody is in it");
|
|
1769
1794
|
}
|
|
1770
1795
|
if (context.leadFeatures === false && snapshot.team !== undefined) {
|
|
1771
|
-
into.add("team.unexpected", `${path}.team`, "the
|
|
1796
|
+
into.add("team.unexpected", `${path}.team`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
|
|
1772
1797
|
}
|
|
1773
1798
|
if (snapshot.team !== undefined)
|
|
1774
1799
|
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
@@ -1786,11 +1811,6 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1786
1811
|
case "completed":
|
|
1787
1812
|
into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
|
|
1788
1813
|
break;
|
|
1789
|
-
case "transferred":
|
|
1790
|
-
if (value.destinationId !== undefined) {
|
|
1791
|
-
into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
|
|
1792
|
-
}
|
|
1793
|
-
break;
|
|
1794
1814
|
case "taken-over":
|
|
1795
1815
|
// A lead is not a directory item: the ending names the lead, and the host resolves the name.
|
|
1796
1816
|
into.require(isUserId(value.leadId), "event.taskEnded.outcome.takenOver", `${path}.leadId`, "a take-over names the lead who took the call, by user id");
|
|
@@ -1899,16 +1919,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1899
1919
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1900
1920
|
break;
|
|
1901
1921
|
case "task-offered":
|
|
1902
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1922
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1903
1923
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1904
1924
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1905
1925
|
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
1926
|
}
|
|
1907
|
-
|
|
1908
|
-
into.timestamp(event.assignmentExpiresAt, "event.taskOffered.assignmentExpiresAt", `${at}.assignmentExpiresAt`);
|
|
1927
|
+
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
1928
|
break;
|
|
1910
1929
|
case "task-updated":
|
|
1911
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1930
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1912
1931
|
break;
|
|
1913
1932
|
case "task-audio-started":
|
|
1914
1933
|
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
@@ -1956,10 +1975,26 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1956
1975
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1957
1976
|
break;
|
|
1958
1977
|
case "team-updated":
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1978
|
+
case "team-member-updated":
|
|
1979
|
+
case "team-member-removed":
|
|
1980
|
+
case "team-policies-updated": {
|
|
1981
|
+
const field = event.type === "team-updated" ? "team" : event.type === "team-member-updated" ? "member" : event.type === "team-member-removed" ? "memberId" : "policies";
|
|
1982
|
+
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.team.capability", `${at}.${field}`, `${event.type} reaches a login that declares lead, and nobody else`);
|
|
1983
|
+
into.require(context.leadFeatures !== false, "event.team.features", `${at}.${field}`, "the team feature is not on for this lead: nothing of the team reaches them until the application switches it on, and nothing after it switches it off");
|
|
1984
|
+
const teamContext = { ...context, levels, channel: isChannel(channel) ? channel : undefined };
|
|
1985
|
+
if (event.type === "team-updated")
|
|
1986
|
+
validateTeamMembersInto(event.team, `${at}.team`, teamContext, into);
|
|
1987
|
+
else if (event.type === "team-member-updated")
|
|
1988
|
+
validateTeamMemberInto(event.member, `${at}.member`, teamContext, into);
|
|
1989
|
+
else if (event.type === "team-member-removed") {
|
|
1990
|
+
if (into.require(isUserId(event.memberId), "event.teamMemberRemoved.memberId", `${at}.memberId`, "team-member-removed names the member taken off, by user id")) {
|
|
1991
|
+
into.require(event.memberId !== context.self, "team.member.self", `${at}.memberId`, "a lead does not report to themself, so is never removed from their own team");
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
else
|
|
1995
|
+
validateTeamPoliciesInto(event.policies, `${at}.policies`, into, levels);
|
|
1962
1996
|
break;
|
|
1997
|
+
}
|
|
1963
1998
|
case "contacts-updated":
|
|
1964
1999
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
1965
2000
|
if (!Array.isArray(event.contacts)) {
|
|
@@ -2282,7 +2317,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
2282
2317
|
// ---------------------------------------------------------------------------
|
|
2283
2318
|
// Commands.
|
|
2284
2319
|
// ---------------------------------------------------------------------------
|
|
2285
|
-
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2286
2320
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
|
|
2287
2321
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2288
2322
|
/** Validate the exact interaction/assignment target before provider command dispatch. */
|
|
@@ -2303,7 +2337,7 @@ export function validateTaskCommandRequest(request, task, path = "request", task
|
|
|
2303
2337
|
/**
|
|
2304
2338
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2305
2339
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
2306
|
-
*
|
|
2340
|
+
* lead requested, somebody else on the call. Without a task only the command's
|
|
2307
2341
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
2308
2342
|
* for a control the task never offered is the host's error, and this names it.
|
|
2309
2343
|
*/
|
|
@@ -2336,10 +2370,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2336
2370
|
case "complete":
|
|
2337
2371
|
fields.push("outcome", "notes");
|
|
2338
2372
|
break;
|
|
2339
|
-
case "
|
|
2340
|
-
fields.push("
|
|
2341
|
-
if (command.action === "cold" || command.action === "warm")
|
|
2342
|
-
fields.push("dialId", "destinationId");
|
|
2373
|
+
case "schedule":
|
|
2374
|
+
fields.push("at", "note");
|
|
2343
2375
|
break;
|
|
2344
2376
|
case "lead-assist":
|
|
2345
2377
|
fields.push("action");
|
|
@@ -2362,16 +2394,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2362
2394
|
case "connect-back":
|
|
2363
2395
|
dial("command.connectBack.dialId");
|
|
2364
2396
|
break;
|
|
2365
|
-
case "
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
|
|
2370
|
-
}
|
|
2371
|
-
else {
|
|
2372
|
-
into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${describeValue(command.action)} names no destination: there is exactly one it could mean`);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2397
|
+
case "schedule":
|
|
2398
|
+
into.timestamp(command.at, "command.schedule.at", `${path}.at`);
|
|
2399
|
+
if (command.note !== undefined)
|
|
2400
|
+
into.filled(command.note, "command.schedule.note", `${path}.note`, "a note must not be empty when present");
|
|
2375
2401
|
break;
|
|
2376
2402
|
case "lead-assist":
|
|
2377
2403
|
into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
|
|
@@ -2504,19 +2530,10 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2504
2530
|
offered("connectBack");
|
|
2505
2531
|
inPhase("command.phase.completing", "completing");
|
|
2506
2532
|
break;
|
|
2507
|
-
case "
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
listed("coldTransfer");
|
|
2512
|
-
}
|
|
2513
|
-
else if (command.action === "warm") {
|
|
2514
|
-
if (offered("warmTransfer"))
|
|
2515
|
-
listed("warmTransfer");
|
|
2516
|
-
}
|
|
2517
|
-
else if (command.action === "complete" || command.action === "cancel") {
|
|
2518
|
-
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${describeValue(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
2519
|
-
}
|
|
2533
|
+
case "schedule":
|
|
2534
|
+
// A follow-up is promised on the call and written up in wrap alike.
|
|
2535
|
+
offered("schedule");
|
|
2536
|
+
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
2520
2537
|
break;
|
|
2521
2538
|
case "lead-assist":
|
|
2522
2539
|
interaction();
|
|
@@ -2543,7 +2560,9 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2543
2560
|
break;
|
|
2544
2561
|
}
|
|
2545
2562
|
case "complete": {
|
|
2546
|
-
|
|
2563
|
+
// The agent may finish early under either mode; the provider is free to end at once. With
|
|
2564
|
+
// no wrap at all there is no window to cut short, and nothing for the command to do.
|
|
2565
|
+
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
2566
|
// What travels with complete is what the outcomes capability published: a code from its
|
|
2548
2567
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2549
2568
|
const outcomes = capabilities.outcomes;
|
|
@@ -2999,10 +3018,10 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
2999
3018
|
check(false, "command.shape", path, "expected recording command");
|
|
3000
3019
|
return violations;
|
|
3001
3020
|
}
|
|
3002
|
-
keys(value, ["type", "source", "
|
|
3021
|
+
keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
|
|
3003
3022
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
3004
3023
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
3005
|
-
for (const key of ["
|
|
3024
|
+
for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
3006
3025
|
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
3007
3026
|
return violations;
|
|
3008
3027
|
}
|