@xema/omni-protocol 0.1.99 → 0.1.100
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 +33 -33
- package/dist/index.js +3 -3
- package/dist/testing.d.ts +12 -12
- package/dist/testing.js +107 -107
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +69 -69
- package/guide.md +186 -180
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -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
|
|
@@ -1724,8 +1724,8 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1724
1724
|
if (snapshot.contacts === undefined && idle.contacts === true) {
|
|
1725
1725
|
into.add("snapshot.contacts.required", `${path}.contacts`, "the manifest declares contacts, so every snapshot carries the contribution: [] when there are none");
|
|
1726
1726
|
}
|
|
1727
|
-
if (snapshot.
|
|
1728
|
-
into.add("snapshot.calendar.required", `${path}.
|
|
1727
|
+
if (snapshot.calendar === undefined && idle.calendar === true) {
|
|
1728
|
+
into.add("snapshot.calendar.required", `${path}.calendar`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
|
|
1729
1729
|
}
|
|
1730
1730
|
if (snapshot.contacts !== undefined) {
|
|
1731
1731
|
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
@@ -1736,22 +1736,22 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1736
1736
|
into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
|
|
1737
1737
|
}
|
|
1738
1738
|
}
|
|
1739
|
-
if (snapshot.
|
|
1740
|
-
into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.
|
|
1741
|
-
if (Array.isArray(snapshot.
|
|
1739
|
+
if (snapshot.calendar !== undefined) {
|
|
1740
|
+
into.require(idle.calendar === true, "snapshot.calendar.capability", `${path}.calendar`, "scheduled activities require the calendar idle capability");
|
|
1741
|
+
if (Array.isArray(snapshot.calendar)) {
|
|
1742
1742
|
const seen = new Set();
|
|
1743
|
-
snapshot.
|
|
1744
|
-
validateScheduledActivityInto(activity, `${path}.
|
|
1743
|
+
snapshot.calendar.forEach((activity, index) => {
|
|
1744
|
+
validateScheduledActivityInto(activity, `${path}.calendar[${index}]`, into, levels);
|
|
1745
1745
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1746
1746
|
if (seen.has(activity.id)) {
|
|
1747
|
-
into.add("activity.id.unique", `${path}.
|
|
1747
|
+
into.add("activity.id.unique", `${path}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1748
1748
|
}
|
|
1749
1749
|
seen.add(activity.id);
|
|
1750
1750
|
}
|
|
1751
1751
|
});
|
|
1752
1752
|
}
|
|
1753
1753
|
else {
|
|
1754
|
-
into.add("snapshot.calendar.shape", `${path}.
|
|
1754
|
+
into.add("snapshot.calendar.shape", `${path}.calendar`, "calendar must be an array when present");
|
|
1755
1755
|
}
|
|
1756
1756
|
}
|
|
1757
1757
|
// The login is the permission: a lead's snapshot carries a team member list, `members: []` included,
|
|
@@ -1903,13 +1903,13 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1903
1903
|
case "task-updated":
|
|
1904
1904
|
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1905
1905
|
break;
|
|
1906
|
-
case "task-
|
|
1907
|
-
into.require(channel === "voice", "event.
|
|
1908
|
-
into.require(isAssignmentId(event.assignmentId), "event.
|
|
1906
|
+
case "task-audio-started":
|
|
1907
|
+
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
1908
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskAudioStarted.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1909
1909
|
break;
|
|
1910
|
-
case "task-
|
|
1911
|
-
into.require(channel === "voice", "event.
|
|
1912
|
-
into.require(isAssignmentId(event.assignmentId), "event.
|
|
1910
|
+
case "task-audio-ended":
|
|
1911
|
+
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
1912
|
+
into.require(isAssignmentId(event.assignmentId), "event.taskAudioEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
1913
1913
|
break;
|
|
1914
1914
|
case "task-ended":
|
|
1915
1915
|
into.require(isAssignmentId(event.assignmentId), "event.taskEnded.assignmentId", `${at}.assignmentId`, "an event about a task names its assignment");
|
|
@@ -1953,13 +1953,13 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1953
1953
|
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
1954
|
validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels, channel: isChannel(channel) ? channel : undefined }, into);
|
|
1955
1955
|
break;
|
|
1956
|
-
case "team-
|
|
1957
|
-
case "team-
|
|
1956
|
+
case "team-audio-started":
|
|
1957
|
+
case "team-audio-ended":
|
|
1958
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.
|
|
1960
|
-
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.
|
|
1961
|
-
into.require(isUserId(event.memberId), "event.
|
|
1962
|
-
into.require(isAssignmentId(event.assignmentId), "event.
|
|
1959
|
+
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
1960
|
+
into.require(context.capabilities === undefined || context.capabilities.lead === true, "event.teamAudio.capability", `${at}.type`, "team audio reaches a login that declares lead, and nobody else");
|
|
1961
|
+
into.require(isUserId(event.memberId), "event.teamAudio.memberId", `${at}.memberId`, "team audio names the member whose call the lead is on");
|
|
1962
|
+
into.require(isAssignmentId(event.assignmentId), "event.teamAudio.assignmentId", `${at}.assignmentId`, "team audio names the member's assignment");
|
|
1963
1963
|
break;
|
|
1964
1964
|
case "contacts-updated":
|
|
1965
1965
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -1971,17 +1971,17 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1971
1971
|
}
|
|
1972
1972
|
break;
|
|
1973
1973
|
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}.
|
|
1974
|
+
into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
|
|
1975
|
+
if (!Array.isArray(event.calendar)) {
|
|
1976
|
+
into.add("event.calendar.shape", `${at}.calendar`, "calendar must be an array");
|
|
1977
1977
|
}
|
|
1978
1978
|
else {
|
|
1979
1979
|
const ids = new Set();
|
|
1980
|
-
event.
|
|
1981
|
-
validateScheduledActivityInto(activity, `${at}.
|
|
1980
|
+
event.calendar.forEach((activity, index) => {
|
|
1981
|
+
validateScheduledActivityInto(activity, `${at}.calendar[${index}]`, into, levels);
|
|
1982
1982
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1983
1983
|
if (ids.has(activity.id))
|
|
1984
|
-
into.add("activity.id.unique", `${at}.
|
|
1984
|
+
into.add("activity.id.unique", `${at}.calendar[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1985
1985
|
ids.add(activity.id);
|
|
1986
1986
|
}
|
|
1987
1987
|
});
|
|
@@ -2293,7 +2293,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2293
2293
|
if (type !== "custom" && type !== "recording") {
|
|
2294
2294
|
let fields = ["type"];
|
|
2295
2295
|
switch (type) {
|
|
2296
|
-
case "
|
|
2296
|
+
case "dial":
|
|
2297
2297
|
case "connect-back":
|
|
2298
2298
|
fields.push("dialId");
|
|
2299
2299
|
break;
|
|
@@ -2320,8 +2320,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2320
2320
|
}
|
|
2321
2321
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
2322
2322
|
switch (type) {
|
|
2323
|
-
case "
|
|
2324
|
-
dial("command.
|
|
2323
|
+
case "dial":
|
|
2324
|
+
dial("command.dial.dialId");
|
|
2325
2325
|
break;
|
|
2326
2326
|
case "connect-back":
|
|
2327
2327
|
dial("command.connectBack.dialId");
|
|
@@ -2433,7 +2433,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2433
2433
|
if (type === "decline")
|
|
2434
2434
|
offered("decline");
|
|
2435
2435
|
break;
|
|
2436
|
-
case "
|
|
2436
|
+
case "dial":
|
|
2437
2437
|
inPhase("command.phase.preview", "preview");
|
|
2438
2438
|
break;
|
|
2439
2439
|
case "hold":
|
|
@@ -2458,7 +2458,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2458
2458
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2459
2459
|
if (command.action === "start" || command.action === "resume") {
|
|
2460
2460
|
interaction();
|
|
2461
|
-
into.require(task.
|
|
2461
|
+
into.require(task.audio === "started", "recording.request.phase", path, "capture needs live audio");
|
|
2462
2462
|
}
|
|
2463
2463
|
else
|
|
2464
2464
|
inPhase("command.phase.interaction", "in-progress", "paused", "completing");
|
|
@@ -2551,7 +2551,7 @@ const RESULT_STATUSES = {
|
|
|
2551
2551
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
|
2552
2552
|
endBreak: { success: "ended", failure: "failed" },
|
|
2553
2553
|
executeTeam: { success: "applied", failure: "failed" },
|
|
2554
|
-
|
|
2554
|
+
openAudio: { success: "opened", failure: "unavailable" },
|
|
2555
2555
|
setPreference: { success: "applied", failure: "failed" },
|
|
2556
2556
|
recordStep: { success: "recorded", failure: "failed" },
|
|
2557
2557
|
};
|
|
@@ -2571,22 +2571,22 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
2571
2571
|
into.add("result.shape", path, `${method} must answer an object`);
|
|
2572
2572
|
return into.violations;
|
|
2573
2573
|
}
|
|
2574
|
-
if (method === "
|
|
2575
|
-
into.require(result.
|
|
2574
|
+
if (method === "openAudio" && result.status !== "opened") {
|
|
2575
|
+
into.require(result.audio === undefined, "result.audio.unexpected", `${path}.audio`, "only opened carries an audio session");
|
|
2576
2576
|
}
|
|
2577
2577
|
if (result.status === statuses.success) {
|
|
2578
2578
|
into.require(result.failure === undefined, "result.failure.unexpected", `${path}.failure`, `${statuses.success} carries no failure`);
|
|
2579
|
-
if (method === "
|
|
2580
|
-
if (isPlainObject(result.
|
|
2579
|
+
if (method === "openAudio") {
|
|
2580
|
+
if (isPlainObject(result.audio)) {
|
|
2581
2581
|
// Streams may come from another realm, and Node conformance runs have no MediaStream
|
|
2582
2582
|
// constructor. Check the boundary shape without claiming to verify live audio.
|
|
2583
|
-
into.require(isPlainObject(result.
|
|
2583
|
+
into.require(isPlainObject(result.audio.remoteAudio), "result.audio.remoteAudio", `${path}.audio.remoteAudio`, "an opened session carries its remote audio stream");
|
|
2584
2584
|
for (const method of ["setMuted", "close"]) {
|
|
2585
|
-
into.require(typeof result.
|
|
2585
|
+
into.require(typeof result.audio[method] === "function", `result.audio.${method}`, `${path}.audio.${method}`, `an audio session implements ${method}`);
|
|
2586
2586
|
}
|
|
2587
2587
|
}
|
|
2588
2588
|
else {
|
|
2589
|
-
into.add("result.
|
|
2589
|
+
into.add("result.audio", `${path}.audio`, "opened carries the audio session");
|
|
2590
2590
|
}
|
|
2591
2591
|
}
|
|
2592
2592
|
if (dials) {
|
|
@@ -2893,7 +2893,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
|
|
|
2893
2893
|
check(false, "policy.shape", at, "expected permitted actions");
|
|
2894
2894
|
continue;
|
|
2895
2895
|
}
|
|
2896
|
-
keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["
|
|
2896
|
+
keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["storageId"] : [])], at);
|
|
2897
2897
|
check(RECORDING_ACTIONS.some(action => policy[action] !== undefined), "policy.empty", at, "omit paths offering no actions");
|
|
2898
2898
|
for (const action of RECORDING_ACTIONS) {
|
|
2899
2899
|
if (policy[action] === undefined)
|
|
@@ -2901,7 +2901,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
|
|
|
2901
2901
|
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2902
2902
|
}
|
|
2903
2903
|
if (source === "host")
|
|
2904
|
-
check(filled(policy.
|
|
2904
|
+
check(filled(policy.storageId), "storage", `${at}.storageId`, "host storage must be named per task");
|
|
2905
2905
|
}
|
|
2906
2906
|
return violations;
|
|
2907
2907
|
}
|
|
@@ -2909,15 +2909,15 @@ export function validateHostRecording(value, softphone, path = "host.recording")
|
|
|
2909
2909
|
if (value === undefined)
|
|
2910
2910
|
return [];
|
|
2911
2911
|
const { violations, check, keys } = collector();
|
|
2912
|
-
check(softphone, "host.channel", path, "initial host recording requires voice softphone
|
|
2912
|
+
check(softphone, "host.channel", path, "initial host recording requires voice softphone audio");
|
|
2913
2913
|
if (!object(value)) {
|
|
2914
2914
|
check(false, "host.shape", path, "expected host recording declaration and executor");
|
|
2915
2915
|
return violations;
|
|
2916
2916
|
}
|
|
2917
|
-
keys(value, ["actions", "
|
|
2917
|
+
keys(value, ["actions", "storageIds", "execute", "announcesToCaller"], path);
|
|
2918
2918
|
if (value.announcesToCaller !== undefined)
|
|
2919
2919
|
check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
|
|
2920
|
-
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["
|
|
2920
|
+
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["storageIds", undefined]]) {
|
|
2921
2921
|
const list = value[key];
|
|
2922
2922
|
if (!Array.isArray(list)) {
|
|
2923
2923
|
check(false, "host.list", `${path}.${key}`, "expected explicit array");
|
|
@@ -3033,14 +3033,14 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3033
3033
|
state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
|
|
3034
3034
|
if (object(context.host)) {
|
|
3035
3035
|
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.
|
|
3036
|
+
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
3037
|
}
|
|
3038
3038
|
}
|
|
3039
3039
|
const fresh = effectiveRecordingState(state, context.now);
|
|
3040
3040
|
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
3041
3041
|
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
3042
3042
|
const opening = command.action === "start" || command.action === "resume";
|
|
3043
|
-
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.
|
|
3043
|
+
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
3044
|
return violations;
|
|
3045
3045
|
}
|
|
3046
3046
|
/** Check recording-specific result semantics against the owner's confirming observations. */
|