@xema/omni-protocol 0.1.80 → 0.1.82
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 +89 -5
- package/dist/index.js +1 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +10 -4
- package/dist/validation.d.ts +23 -2
- package/dist/validation.js +286 -8
- package/guide.md +201 -9
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -427,7 +427,85 @@ export type HostAudioOutput =
|
|
|
427
427
|
*/
|
|
428
428
|
/** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
|
|
429
429
|
export type UrlVisibility = "full" | "domain" | "hidden";
|
|
430
|
+
/** Independent recorder owners. A command never targets both. */
|
|
431
|
+
export type RecordingSource = "provider" | "host";
|
|
432
|
+
export type RecordingAction = "start" | "pause" | "resume" | "stop" | "cancel";
|
|
433
|
+
export declare const RECORDING_ACTIONS: readonly ["start", "pause", "resume", "stop", "cancel"];
|
|
434
|
+
export interface RecordingActions {
|
|
435
|
+
start?: true;
|
|
436
|
+
pause?: true;
|
|
437
|
+
resume?: true;
|
|
438
|
+
stop?: true;
|
|
439
|
+
/** Abandon this recording and discard its captured audio. */
|
|
440
|
+
cancel?: true;
|
|
441
|
+
}
|
|
442
|
+
/** Per-task permission, not a provider-wide recording switch. Absence grants nothing. */
|
|
443
|
+
export interface TaskRecordingPolicy {
|
|
444
|
+
provider?: RecordingActions;
|
|
445
|
+
host?: RecordingActions & {
|
|
446
|
+
destinationId: string;
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/** Current evidence only. Expiry/disconnect means unknown, never a historical stop. */
|
|
450
|
+
export type RecordingState = {
|
|
451
|
+
status: "unknown";
|
|
452
|
+
observationId?: never;
|
|
453
|
+
observedAt?: never;
|
|
454
|
+
validUntil?: never;
|
|
455
|
+
recordingId?: never;
|
|
456
|
+
} | ({
|
|
457
|
+
observationId: string;
|
|
458
|
+
observedAt: IsoTimestamp;
|
|
459
|
+
validUntil: IsoTimestamp;
|
|
460
|
+
} & ({
|
|
461
|
+
status: "inactive";
|
|
462
|
+
recordingId?: never;
|
|
463
|
+
} | {
|
|
464
|
+
status: "active" | "paused";
|
|
465
|
+
recordingId: string;
|
|
466
|
+
}));
|
|
467
|
+
/** Opaque IDs are scoped by login, task and recorder owner; preserved across pause/resume. */
|
|
468
|
+
export type RecordingCommand = {
|
|
469
|
+
type: "recording";
|
|
470
|
+
source: RecordingSource;
|
|
471
|
+
requestId: string;
|
|
472
|
+
/** Compare-and-set against current evidence; never act on a replacement recording. */
|
|
473
|
+
observationId: string;
|
|
474
|
+
} & ({
|
|
475
|
+
action: "start";
|
|
476
|
+
recordingId?: never;
|
|
477
|
+
} | {
|
|
478
|
+
action: "pause" | "resume" | "stop" | "cancel";
|
|
479
|
+
recordingId: string;
|
|
480
|
+
});
|
|
481
|
+
export interface HostRecordingReport {
|
|
482
|
+
taskId: TaskId;
|
|
483
|
+
allocationId: AllocationId;
|
|
484
|
+
state: RecordingState;
|
|
485
|
+
}
|
|
486
|
+
/** Host declaration in ConnectContext, not the provider-owned Manifest. */
|
|
487
|
+
export interface HostRecording {
|
|
488
|
+
/** Audible recording status announcements to the remote party, for host-owned capture only. */
|
|
489
|
+
announcesToCaller?: true;
|
|
490
|
+
actions: RecordingAction[];
|
|
491
|
+
/** Explicitly provisioned destinations. A task chooses one; no upload/storage fallback. */
|
|
492
|
+
destinationIds: string[];
|
|
493
|
+
execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
|
|
494
|
+
}
|
|
495
|
+
/** Recording applies or fails; it never dials. Rejection without result means unknown. */
|
|
496
|
+
export type RecordingCommandResult = Exclude<TaskCommandResult, {
|
|
497
|
+
status: "dialling";
|
|
498
|
+
}>;
|
|
499
|
+
export interface HostRecordingRequest {
|
|
500
|
+
taskId: TaskId;
|
|
501
|
+
allocationId: AllocationId;
|
|
502
|
+
command: RecordingCommand & {
|
|
503
|
+
source: "host";
|
|
504
|
+
};
|
|
505
|
+
}
|
|
430
506
|
export interface HostReport {
|
|
507
|
+
/** Full current host-owned recording view; never provider-owned state. */
|
|
508
|
+
recordings?: HostRecordingReport[];
|
|
431
509
|
/** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
|
|
432
510
|
online: boolean;
|
|
433
511
|
audio?: {
|
|
@@ -449,6 +527,8 @@ export interface HostGuarantees {
|
|
|
449
527
|
personConsent?: true;
|
|
450
528
|
}
|
|
451
529
|
export interface Host {
|
|
530
|
+
/** Voice softphone recording support; policy and destination remain per task. */
|
|
531
|
+
recording?: HostRecording;
|
|
452
532
|
guarantees: HostGuarantees;
|
|
453
533
|
/** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a desk phone and off voice. */
|
|
454
534
|
mute?: HostMute;
|
|
@@ -494,6 +574,7 @@ export type ConnectContext = {
|
|
|
494
574
|
phone?: "deskPhone";
|
|
495
575
|
host: {
|
|
496
576
|
mute?: never;
|
|
577
|
+
recording?: never;
|
|
497
578
|
};
|
|
498
579
|
});
|
|
499
580
|
export type TransportStatus = "connecting" | "active" | "error";
|
|
@@ -581,7 +662,7 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
|
|
|
581
662
|
/** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
|
|
582
663
|
leadAssist?: Lockable<true>;
|
|
583
664
|
conference?: Lockable<DestinationDirectory>;
|
|
584
|
-
recording?: Lockable<
|
|
665
|
+
recording?: Lockable<TaskRecordingPolicy>;
|
|
585
666
|
} : C extends "chat" ? SharedTaskCapabilities & {
|
|
586
667
|
decline?: Lockable<true>;
|
|
587
668
|
hold?: Lockable<true>;
|
|
@@ -888,12 +969,16 @@ export type Task<C extends Channel = Channel> = {
|
|
|
888
969
|
attributes?: TaskAttribute[];
|
|
889
970
|
handlingHistory?: TaskHandlingHistory;
|
|
890
971
|
} & TaskCompletion & (C extends "voice" ? {
|
|
972
|
+
recording?: {
|
|
973
|
+
provider?: RecordingState;
|
|
974
|
+
};
|
|
891
975
|
onCall?: OnCall[];
|
|
892
976
|
leadAssist?: TaskLeadAssist;
|
|
893
977
|
assisting?: TaskAssisting;
|
|
894
978
|
monitoring?: TaskMonitoring;
|
|
895
979
|
media?: TaskMediaState;
|
|
896
980
|
} : {
|
|
981
|
+
recording?: never;
|
|
897
982
|
onCall?: never;
|
|
898
983
|
leadAssist?: never;
|
|
899
984
|
assisting?: never;
|
|
@@ -1040,10 +1125,9 @@ export type VoiceTaskCommand = {
|
|
|
1040
1125
|
action: "remove";
|
|
1041
1126
|
party: true;
|
|
1042
1127
|
destinationId?: never;
|
|
1043
|
-
} | {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
} | ({
|
|
1128
|
+
} | (RecordingCommand & {
|
|
1129
|
+
source: "provider";
|
|
1130
|
+
}) | ({
|
|
1047
1131
|
type: "complete";
|
|
1048
1132
|
} & DispositionPayload);
|
|
1049
1133
|
export type ChatTaskCommand = {
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export const IDLE_CAPABILITY_UI = {
|
|
|
29
29
|
export const MONITOR_MODES = ["monitor", "whisper", "barge"];
|
|
30
30
|
export const MUTED_BY = ["host", "station"];
|
|
31
31
|
export const HOST_MUTES = ["stream", "station"];
|
|
32
|
+
export const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"];
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
// Task workspace.
|
|
34
35
|
// ---------------------------------------------------------------------------
|
package/dist/testing.d.ts
CHANGED
|
@@ -220,6 +220,7 @@ export declare function stillHost(report: HostReport, guarantees: HostGuarantees
|
|
|
220
220
|
};
|
|
221
221
|
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host & {
|
|
222
222
|
mute?: never;
|
|
223
|
+
recording?: never;
|
|
223
224
|
};
|
|
224
225
|
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
225
226
|
export interface BreakCandidate {
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -267,6 +267,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
267
267
|
// audio and reports it; on a desk phone, or off voice, there is none for it to report.
|
|
268
268
|
violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
|
|
269
269
|
const softphone = adapter.manifest.channel === "voice" && context.phone === "softphone";
|
|
270
|
+
violations.push(...validateHostRecording(context.host.recording, softphone));
|
|
270
271
|
const first = context.host.report();
|
|
271
272
|
violations.push(...validateHostReport(first, "context.host"));
|
|
272
273
|
const hasAudio = isRecord(first) && first.audio !== undefined;
|
|
@@ -291,6 +292,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
291
292
|
// The wrapped host carries the mute kind the test's host stated, so the arms of ConnectContext still hold.
|
|
292
293
|
const host = {
|
|
293
294
|
guarantees: context.host.guarantees,
|
|
295
|
+
...(context.host.recording === undefined ? {} : { recording: context.host.recording }),
|
|
294
296
|
...(context.host.mute === undefined ? {} : { mute: context.host.mute }),
|
|
295
297
|
report: () => { consulted.report = true; return context.host.report(); },
|
|
296
298
|
subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
|
|
@@ -870,11 +872,11 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
|
|
|
870
872
|
if (withdrawn.length === 0) {
|
|
871
873
|
throw new Error("Task capability withdrawal must end with at least one capability the offer declared withdrawn; a locked control is present without permission, not withdrawn");
|
|
872
874
|
}
|
|
873
|
-
const before = validateTaskCommand(command, first, "command");
|
|
875
|
+
const before = validateTaskCommand(command, first, "command", context);
|
|
874
876
|
if (before.length > 0) {
|
|
875
877
|
throw new Error(`Task capability withdrawal: the command must be issuable against the task as offered, or its later refusal proves nothing (${before.map(v => v.rule).join(", ")})`);
|
|
876
878
|
}
|
|
877
|
-
const after = validateTaskCommand(command, last, "command");
|
|
879
|
+
const after = validateTaskCommand(command, last, "command", context);
|
|
878
880
|
const forWant = after.filter(v => withdrawn.some(name => v.rule === `command.capability.${name}`));
|
|
879
881
|
if (forWant.length === 0) {
|
|
880
882
|
throw new Error(`Task capability withdrawal: the command must be refused against the republished task for want of ${withdrawn.join(" or ")}; it was ${after.length === 0 ? "accepted" : `refused for ${after.map(v => v.rule).join(", ")}`}`);
|
|
@@ -1558,7 +1560,11 @@ async function driveOneCall(drive) {
|
|
|
1558
1560
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1559
1561
|
ruleEvaluated("drive.timeout");
|
|
1560
1562
|
const send = async (command, dialId) => {
|
|
1561
|
-
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}
|
|
1563
|
+
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
|
|
1564
|
+
levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
|
|
1565
|
+
dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
|
|
1566
|
+
autoAcceptTasks: drive.context.autoAcceptTasks,
|
|
1567
|
+
});
|
|
1562
1568
|
found.push(...own);
|
|
1563
1569
|
if (own.length > 0)
|
|
1564
1570
|
return undefined;
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
1
|
+
import { type RecordingState, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -100,7 +100,7 @@ export declare function validateHostReport(report: unknown, path?: string): Prot
|
|
|
100
100
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
101
101
|
* for a control the task never offered is the host's error, and this names it.
|
|
102
102
|
*/
|
|
103
|
-
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];
|
|
103
|
+
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
104
104
|
/** The connection methods whose results `validateResult` knows. */
|
|
105
105
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
|
|
106
106
|
/**
|
|
@@ -135,3 +135,24 @@ export declare function validateAuthenticationFailure(failure: unknown, path?: s
|
|
|
135
135
|
*/
|
|
136
136
|
export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
|
137
137
|
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
|
138
|
+
export declare function validateRecordingState(value: unknown, path?: string): ProtocolViolation[];
|
|
139
|
+
/** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
|
|
140
|
+
export declare function effectiveRecordingState(state: RecordingState | undefined, now: number | undefined): RecordingState;
|
|
141
|
+
export declare function validateRecordingPolicy(value: unknown, path?: string): ProtocolViolation[];
|
|
142
|
+
export declare function validateHostRecording(value: unknown, softphone: boolean, path?: string): ProtocolViolation[];
|
|
143
|
+
export declare function validateHostRecordings(value: unknown, path?: string): ProtocolViolation[];
|
|
144
|
+
export declare function validateRecordingCommandShape(value: unknown, source: "provider" | "host", path?: string): ProtocolViolation[];
|
|
145
|
+
/** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
|
|
146
|
+
export declare function validateRecordingCommandState(command: unknown, policy: unknown, state: unknown, path?: string): ProtocolViolation[];
|
|
147
|
+
/** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
|
|
148
|
+
export declare function validateRecordingRequest(request: unknown, task: unknown, context: {
|
|
149
|
+
source: "provider" | "host";
|
|
150
|
+
now: number | undefined;
|
|
151
|
+
host?: unknown;
|
|
152
|
+
hostReport?: unknown;
|
|
153
|
+
softphone?: boolean;
|
|
154
|
+
/** The same provider/task context used to validate the published task. */
|
|
155
|
+
taskContext?: Omit<TaskValidationContext, "channel">;
|
|
156
|
+
}, path?: string): ProtocolViolation[];
|
|
157
|
+
/** Check recording-specific result semantics against the owner's confirming observations. */
|
|
158
|
+
export declare function validateRecordingOutcome(command: unknown, before: unknown, after: unknown, result: unknown, path?: string): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
12
|
+
import { RECORDING_ACTIONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -1116,6 +1116,17 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1116
1116
|
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1117
1117
|
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1118
1118
|
}
|
|
1119
|
+
if (task.recording !== undefined) {
|
|
1120
|
+
into.require(context.channel === "voice", "recording.task.channel", `${path}.recording`, "recording is voice-only");
|
|
1121
|
+
if (!isPlainObject(task.recording))
|
|
1122
|
+
into.add("recording.task.shape", `${path}.recording`, "expected provider recording state");
|
|
1123
|
+
else {
|
|
1124
|
+
for (const key of Object.keys(task.recording))
|
|
1125
|
+
into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
|
|
1126
|
+
if (task.recording.provider !== undefined)
|
|
1127
|
+
into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1119
1130
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1120
1131
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1121
1132
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
@@ -1181,6 +1192,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1181
1192
|
continue;
|
|
1182
1193
|
}
|
|
1183
1194
|
switch (name) {
|
|
1195
|
+
case "recording":
|
|
1196
|
+
into.violations.push(...validateRecordingPolicy(declared, `${path}.capabilities.recording`));
|
|
1197
|
+
break;
|
|
1184
1198
|
case "dispositions":
|
|
1185
1199
|
validateDispositions(declared, `${path}.capabilities.dispositions`, into);
|
|
1186
1200
|
break;
|
|
@@ -1946,6 +1960,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1946
1960
|
return into.violations;
|
|
1947
1961
|
}
|
|
1948
1962
|
into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
|
|
1963
|
+
into.violations.push(...validateHostRecordings(report.recordings, `${path}.recordings`));
|
|
1949
1964
|
if (report.audio === undefined)
|
|
1950
1965
|
return into.violations;
|
|
1951
1966
|
if (!isPlainObject(report.audio)) {
|
|
@@ -2005,7 +2020,6 @@ export function validateHostReport(report, path = "host") {
|
|
|
2005
2020
|
const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
|
|
2006
2021
|
const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
|
|
2007
2022
|
const CONFERENCE_ACTIONS = ["add", "remove"];
|
|
2008
|
-
const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
|
|
2009
2023
|
/**
|
|
2010
2024
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
2011
2025
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
@@ -2013,7 +2027,7 @@ const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
|
|
|
2013
2027
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
2014
2028
|
* for a control the task never offered is the host's error, and this names it.
|
|
2015
2029
|
*/
|
|
2016
|
-
export function validateTaskCommand(command, task, path = "command") {
|
|
2030
|
+
export function validateTaskCommand(command, task, path = "command", taskContext) {
|
|
2017
2031
|
const into = new Collector();
|
|
2018
2032
|
if (!isPlainObject(command)) {
|
|
2019
2033
|
into.add("command.shape", path, "a command must be an object");
|
|
@@ -2068,7 +2082,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2068
2082
|
}
|
|
2069
2083
|
break;
|
|
2070
2084
|
case "recording":
|
|
2071
|
-
into.
|
|
2085
|
+
into.violations.push(...validateRecordingCommandShape(command, "provider", path));
|
|
2072
2086
|
break;
|
|
2073
2087
|
case "complete":
|
|
2074
2088
|
if (command.disposition !== undefined)
|
|
@@ -2086,7 +2100,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2086
2100
|
// checking against a task nobody has -- so the task is validated first, and a command is held
|
|
2087
2101
|
// only to a task that stands.
|
|
2088
2102
|
const published = new Collector();
|
|
2089
|
-
validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2103
|
+
validateTaskInto(task, { ...taskContext, channel: channel ?? "voice" }, `${path}.task`, published);
|
|
2090
2104
|
if (published.violations.length > 0 || !isPlainObject(task)) {
|
|
2091
2105
|
into.add("command.task", `${path}.task`, `the task the command is held to is not one the wire published (${published.violations.map(v => v.rule).join(", ")}): pass the task as the provider sent it, or validate the command's shape alone`);
|
|
2092
2106
|
return into.violations;
|
|
@@ -2158,10 +2172,20 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2158
2172
|
offered("endCall");
|
|
2159
2173
|
handling();
|
|
2160
2174
|
break;
|
|
2161
|
-
case "recording":
|
|
2162
|
-
offered("recording")
|
|
2163
|
-
|
|
2175
|
+
case "recording": {
|
|
2176
|
+
if (!offered("recording"))
|
|
2177
|
+
break;
|
|
2178
|
+
const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
|
|
2179
|
+
const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
|
|
2180
|
+
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2181
|
+
if (command.action === "start" || command.action === "resume") {
|
|
2182
|
+
handling();
|
|
2183
|
+
into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
|
|
2184
|
+
}
|
|
2185
|
+
else
|
|
2186
|
+
inPhase("command.phase.handling", "in-progress", "paused", "completing");
|
|
2164
2187
|
break;
|
|
2188
|
+
}
|
|
2165
2189
|
case "connect-back":
|
|
2166
2190
|
offered("connectBack");
|
|
2167
2191
|
inPhase("command.phase.completing", "completing");
|
|
@@ -2563,3 +2587,257 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
2563
2587
|
}
|
|
2564
2588
|
return into.violations;
|
|
2565
2589
|
}
|
|
2590
|
+
// Independent current recording evidence and dispatch validation.
|
|
2591
|
+
const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2592
|
+
const filled = (v) => typeof v === "string" && v.trim().length > 0;
|
|
2593
|
+
function collector() {
|
|
2594
|
+
const violations = [];
|
|
2595
|
+
const check = (ok, rule, path, message) => {
|
|
2596
|
+
ruleEvaluated(`recording.${rule}`);
|
|
2597
|
+
if (!ok)
|
|
2598
|
+
violations.push({ rule: `recording.${rule}`, path, message });
|
|
2599
|
+
return Boolean(ok);
|
|
2600
|
+
};
|
|
2601
|
+
const keys = (v, allowed, path) => {
|
|
2602
|
+
for (const key of Object.keys(v))
|
|
2603
|
+
check(allowed.includes(key), "field", `${path}.${key}`, "field is not permitted in this recording variant");
|
|
2604
|
+
};
|
|
2605
|
+
return { violations, check, keys };
|
|
2606
|
+
}
|
|
2607
|
+
/** Recording observations use canonical UTC millisecond instants, never native capture boundaries. */
|
|
2608
|
+
function instant(v) {
|
|
2609
|
+
if (typeof v !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v))
|
|
2610
|
+
return false;
|
|
2611
|
+
const n = Date.parse(v);
|
|
2612
|
+
return Number.isFinite(n) && new Date(n).toISOString() === v;
|
|
2613
|
+
}
|
|
2614
|
+
export function validateRecordingState(value, path = "recording") {
|
|
2615
|
+
const { violations, check, keys } = collector();
|
|
2616
|
+
if (!object(value)) {
|
|
2617
|
+
check(false, "state.shape", path, "expected recording state");
|
|
2618
|
+
return violations;
|
|
2619
|
+
}
|
|
2620
|
+
if (value.status === "unknown") {
|
|
2621
|
+
keys(value, ["status"], path);
|
|
2622
|
+
return violations;
|
|
2623
|
+
}
|
|
2624
|
+
check(["inactive", "active", "paused"].includes(value.status), "state.status", `${path}.status`, "expected unknown, inactive, active or paused");
|
|
2625
|
+
keys(value, ["status", "observationId", "observedAt", "validUntil", ...(value.status === "inactive" ? [] : ["recordingId"])], path);
|
|
2626
|
+
check(filled(value.observationId), "observationId", `${path}.observationId`, "a confirming observation has an opaque identity");
|
|
2627
|
+
check(instant(value.observedAt), "observedAt", `${path}.observedAt`, "expected canonical UTC millisecond observation instant");
|
|
2628
|
+
check(instant(value.validUntil), "validUntil", `${path}.validUntil`, "expected canonical UTC millisecond expiry instant");
|
|
2629
|
+
if (instant(value.observedAt) && instant(value.validUntil))
|
|
2630
|
+
check(value.observedAt < value.validUntil, "expiry", path, "expiry must be strictly after observation");
|
|
2631
|
+
if (value.status !== "inactive")
|
|
2632
|
+
check(filled(value.recordingId), "recordingId", `${path}.recordingId`, "active and paused states identify the actual recording");
|
|
2633
|
+
return violations;
|
|
2634
|
+
}
|
|
2635
|
+
/** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
|
|
2636
|
+
export function effectiveRecordingState(state, now) {
|
|
2637
|
+
if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
|
|
2638
|
+
return { status: "unknown" };
|
|
2639
|
+
return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
|
|
2640
|
+
}
|
|
2641
|
+
export function validateRecordingPolicy(value, path = "capabilities.recording") {
|
|
2642
|
+
const { violations, check, keys } = collector();
|
|
2643
|
+
if (!object(value)) {
|
|
2644
|
+
check(false, "policy.shape", path, "expected per-task provider/host action policy; true is no longer supported");
|
|
2645
|
+
return violations;
|
|
2646
|
+
}
|
|
2647
|
+
keys(value, ["provider", "host"], path);
|
|
2648
|
+
check(value.provider !== undefined || value.host !== undefined, "policy.empty", path, "declare at least one recording path");
|
|
2649
|
+
for (const source of ["provider", "host"]) {
|
|
2650
|
+
const policy = value[source];
|
|
2651
|
+
if (policy === undefined)
|
|
2652
|
+
continue;
|
|
2653
|
+
const at = `${path}.${source}`;
|
|
2654
|
+
if (!object(policy)) {
|
|
2655
|
+
check(false, "policy.shape", at, "expected permitted actions");
|
|
2656
|
+
continue;
|
|
2657
|
+
}
|
|
2658
|
+
keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["destinationId"] : [])], at);
|
|
2659
|
+
check(RECORDING_ACTIONS.some(action => policy[action] !== undefined), "policy.empty", at, "omit paths offering no actions");
|
|
2660
|
+
for (const action of RECORDING_ACTIONS) {
|
|
2661
|
+
if (policy[action] === undefined)
|
|
2662
|
+
continue;
|
|
2663
|
+
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2664
|
+
}
|
|
2665
|
+
if (source === "host")
|
|
2666
|
+
check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
|
|
2667
|
+
}
|
|
2668
|
+
return violations;
|
|
2669
|
+
}
|
|
2670
|
+
export function validateHostRecording(value, softphone, path = "host.recording") {
|
|
2671
|
+
if (value === undefined)
|
|
2672
|
+
return [];
|
|
2673
|
+
const { violations, check, keys } = collector();
|
|
2674
|
+
check(softphone, "host.channel", path, "initial host recording requires voice softphone media");
|
|
2675
|
+
if (!object(value)) {
|
|
2676
|
+
check(false, "host.shape", path, "expected host recording declaration and executor");
|
|
2677
|
+
return violations;
|
|
2678
|
+
}
|
|
2679
|
+
keys(value, ["actions", "destinationIds", "execute", "announcesToCaller"], path);
|
|
2680
|
+
if (value.announcesToCaller !== undefined)
|
|
2681
|
+
check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
|
|
2682
|
+
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["destinationIds", undefined]]) {
|
|
2683
|
+
const list = value[key];
|
|
2684
|
+
if (!Array.isArray(list)) {
|
|
2685
|
+
check(false, "host.list", `${path}.${key}`, "expected explicit array");
|
|
2686
|
+
continue;
|
|
2687
|
+
}
|
|
2688
|
+
check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
|
|
2689
|
+
check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
|
|
2690
|
+
check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
|
|
2691
|
+
}
|
|
2692
|
+
if (Array.isArray(value.actions)) {
|
|
2693
|
+
check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
|
|
2694
|
+
}
|
|
2695
|
+
check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
|
|
2696
|
+
return violations;
|
|
2697
|
+
}
|
|
2698
|
+
export function validateHostRecordings(value, path = "host.recordings") {
|
|
2699
|
+
if (value === undefined)
|
|
2700
|
+
return [];
|
|
2701
|
+
const { violations, check, keys } = collector();
|
|
2702
|
+
if (!Array.isArray(value)) {
|
|
2703
|
+
check(false, "reports.shape", path, "expected full host recording report array");
|
|
2704
|
+
return violations;
|
|
2705
|
+
}
|
|
2706
|
+
const seen = new Set();
|
|
2707
|
+
value.forEach((report, i) => {
|
|
2708
|
+
const at = `${path}[${i}]`;
|
|
2709
|
+
if (!object(report)) {
|
|
2710
|
+
check(false, "report.shape", at, "expected scoped report");
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
keys(report, ["taskId", "allocationId", "state"], at);
|
|
2714
|
+
check(filled(report.taskId) && filled(report.allocationId), "report.scope", at, "report identifies task and allocation");
|
|
2715
|
+
const key = JSON.stringify([report.taskId, report.allocationId]);
|
|
2716
|
+
check(!seen.has(key), "report.duplicate", at, "one host state per task allocation");
|
|
2717
|
+
seen.add(key);
|
|
2718
|
+
violations.push(...validateRecordingState(report.state, `${at}.state`));
|
|
2719
|
+
});
|
|
2720
|
+
return violations;
|
|
2721
|
+
}
|
|
2722
|
+
export function validateRecordingCommandShape(value, source, path = "command") {
|
|
2723
|
+
const { violations, check, keys } = collector();
|
|
2724
|
+
if (!object(value)) {
|
|
2725
|
+
check(false, "command.shape", path, "expected recording command");
|
|
2726
|
+
return violations;
|
|
2727
|
+
}
|
|
2728
|
+
keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
|
|
2729
|
+
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
2730
|
+
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
2731
|
+
for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
2732
|
+
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
2733
|
+
return violations;
|
|
2734
|
+
}
|
|
2735
|
+
/** One transition table shared by dispatch checks and outcome checks. */
|
|
2736
|
+
const RECORDING_TRANSITIONS = {
|
|
2737
|
+
start: { from: ["inactive"], to: "active" },
|
|
2738
|
+
pause: { from: ["active"], to: "paused" },
|
|
2739
|
+
resume: { from: ["paused"], to: "active" },
|
|
2740
|
+
stop: { from: ["active", "paused"], to: "inactive" },
|
|
2741
|
+
cancel: { from: ["active", "paused"], to: "inactive" },
|
|
2742
|
+
};
|
|
2743
|
+
function recordingTransition(action) {
|
|
2744
|
+
return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
|
|
2745
|
+
? RECORDING_TRANSITIONS[action] : undefined;
|
|
2746
|
+
}
|
|
2747
|
+
/** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
|
|
2748
|
+
export function validateRecordingCommandState(command, policy, state, path = "command") {
|
|
2749
|
+
const { violations, check } = collector();
|
|
2750
|
+
if (!object(command)) {
|
|
2751
|
+
check(false, "command.shape", path, "expected recording command");
|
|
2752
|
+
return violations;
|
|
2753
|
+
}
|
|
2754
|
+
const transition = recordingTransition(command.action);
|
|
2755
|
+
if (transition === undefined) {
|
|
2756
|
+
check(false, "command.action", `${path}.action`, "unsupported recording action");
|
|
2757
|
+
return violations;
|
|
2758
|
+
}
|
|
2759
|
+
const permitted = object(policy) && policy[command.action] === true;
|
|
2760
|
+
check(permitted, "command.permission", path, "task does not permit this action on this recorder");
|
|
2761
|
+
if (!object(state) || validateRecordingState(state).length) {
|
|
2762
|
+
check(false, "command.state", path, "no validated current recorder observation");
|
|
2763
|
+
return violations;
|
|
2764
|
+
}
|
|
2765
|
+
check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
|
|
2766
|
+
check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
|
|
2767
|
+
if (command.action !== "start")
|
|
2768
|
+
check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
|
|
2769
|
+
return violations;
|
|
2770
|
+
}
|
|
2771
|
+
/** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
|
|
2772
|
+
export function validateRecordingRequest(request, task, context, path = "request") {
|
|
2773
|
+
const { violations, check, keys } = collector();
|
|
2774
|
+
if (!object(request) || !object(task)) {
|
|
2775
|
+
check(false, "request.shape", path, "request and current task required");
|
|
2776
|
+
return violations;
|
|
2777
|
+
}
|
|
2778
|
+
violations.push(...validateTask(task, { ...context.taskContext, channel: "voice" }, `${path}.task`));
|
|
2779
|
+
keys(request, ["taskId", "allocationId", "command"], path);
|
|
2780
|
+
check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
|
|
2781
|
+
check(task.channel === "voice", "request.channel", path, "recording is voice-only");
|
|
2782
|
+
violations.push(...validateRecordingCommandShape(request.command, context.source, `${path}.command`));
|
|
2783
|
+
if (!object(request.command))
|
|
2784
|
+
return violations;
|
|
2785
|
+
const command = request.command;
|
|
2786
|
+
const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
|
|
2787
|
+
violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
|
|
2788
|
+
const policy = object(caps) ? caps[context.source] : undefined;
|
|
2789
|
+
let state = object(task.recording) ? task.recording.provider : undefined;
|
|
2790
|
+
if (context.source === "host") {
|
|
2791
|
+
violations.push(...validateHostRecording(context.host, context.softphone === true));
|
|
2792
|
+
check(object(context.host), "host.required", path, "host did not declare recording support");
|
|
2793
|
+
const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
|
|
2794
|
+
violations.push(...validateHostRecordings(reports));
|
|
2795
|
+
state = Array.isArray(reports) ? reports.find(r => object(r) && r.taskId === task.id && r.allocationId === task.allocationId)?.state : undefined;
|
|
2796
|
+
if (object(context.host)) {
|
|
2797
|
+
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
2798
|
+
check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
const fresh = effectiveRecordingState(state, context.now);
|
|
2802
|
+
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
2803
|
+
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
2804
|
+
const opening = command.action === "start" || command.action === "resume";
|
|
2805
|
+
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.media === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task media; terminal controls may finish recordings during wrap-up");
|
|
2806
|
+
return violations;
|
|
2807
|
+
}
|
|
2808
|
+
/** Check recording-specific result semantics against the owner's confirming observations. */
|
|
2809
|
+
export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
|
|
2810
|
+
const { violations, check } = collector();
|
|
2811
|
+
violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
|
|
2812
|
+
if (!object(command) || !object(before) || !object(after)) {
|
|
2813
|
+
check(false, "outcome.shape", path, "command and both observations required");
|
|
2814
|
+
return violations;
|
|
2815
|
+
}
|
|
2816
|
+
violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
|
|
2817
|
+
// A rejected promise has no result: any independently evidenced state may follow it.
|
|
2818
|
+
if (result === undefined)
|
|
2819
|
+
return violations;
|
|
2820
|
+
violations.push(...validateResult(result, "execute", `${path}.result`));
|
|
2821
|
+
if (!object(result))
|
|
2822
|
+
return violations;
|
|
2823
|
+
check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
|
|
2824
|
+
if (result.status === "failed") {
|
|
2825
|
+
check(before.status !== "unknown" && after.status === before.status && after.recordingId === before.recordingId, "outcome.noEffect", path, "failed promises confirmed no effect; ambiguous or partial effects have no result");
|
|
2826
|
+
}
|
|
2827
|
+
else if (result.status === "applied") {
|
|
2828
|
+
const transition = recordingTransition(command.action);
|
|
2829
|
+
if (transition === undefined)
|
|
2830
|
+
return violations; // shape validation already reported the unknown action
|
|
2831
|
+
check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
|
|
2832
|
+
check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
|
|
2833
|
+
check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
|
|
2834
|
+
check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
2835
|
+
if (command.action !== "start")
|
|
2836
|
+
check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
|
|
2837
|
+
if (command.action === "pause" || command.action === "resume")
|
|
2838
|
+
check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
|
|
2839
|
+
if (instant(before.observedAt) && instant(after.observedAt))
|
|
2840
|
+
check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
|
|
2841
|
+
}
|
|
2842
|
+
return violations;
|
|
2843
|
+
}
|
package/guide.md
CHANGED
|
@@ -58,7 +58,7 @@ transport, a task, a break and a call each have a word of their own:
|
|
|
58
58
|
|
|
59
59
|
### `OMNI_PROTOCOL_VERSION`
|
|
60
60
|
|
|
61
|
-
The exact protocol version implemented by this package. The current value
|
|
61
|
+
The exact protocol version implemented by this package. The current value remains `1` during pre-release development. Recording contract changes do not introduce a new protocol version. Hosts and adapters adopt the current declarations together; this does not provide compatibility mapping for older recording shapes.
|
|
62
62
|
|
|
63
63
|
### `Manifest.supportedProtocolVersions`
|
|
64
64
|
|
|
@@ -289,7 +289,55 @@ type HostAudioOutput =
|
|
|
289
289
|
|
|
290
290
|
type UrlVisibility = "full" | "domain" | "hidden";
|
|
291
291
|
|
|
292
|
+
type RecordingSource = "provider" | "host";
|
|
293
|
+
type RecordingAction = "start" | "pause" | "resume" | "stop" | "cancel";
|
|
294
|
+
const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"] as const satisfies readonly RecordingAction[];
|
|
295
|
+
interface RecordingActions {
|
|
296
|
+
start?: true;
|
|
297
|
+
pause?: true;
|
|
298
|
+
resume?: true;
|
|
299
|
+
stop?: true;
|
|
300
|
+
cancel?: true;
|
|
301
|
+
}
|
|
302
|
+
interface TaskRecordingPolicy {
|
|
303
|
+
provider?: RecordingActions;
|
|
304
|
+
host?: RecordingActions & { destinationId: string };
|
|
305
|
+
}
|
|
306
|
+
type RecordingState =
|
|
307
|
+
| { status: "unknown"; observationId?: never; observedAt?: never; validUntil?: never; recordingId?: never }
|
|
308
|
+
| ({ observationId: string; observedAt: IsoTimestamp; validUntil: IsoTimestamp } & (
|
|
309
|
+
| { status: "inactive"; recordingId?: never }
|
|
310
|
+
| { status: "active" | "paused"; recordingId: string }
|
|
311
|
+
));
|
|
312
|
+
type RecordingCommand = {
|
|
313
|
+
type: "recording";
|
|
314
|
+
source: RecordingSource;
|
|
315
|
+
requestId: string;
|
|
316
|
+
observationId: string;
|
|
317
|
+
} & (
|
|
318
|
+
| { action: "start"; recordingId?: never }
|
|
319
|
+
| { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string }
|
|
320
|
+
);
|
|
321
|
+
interface HostRecordingReport {
|
|
322
|
+
taskId: TaskId;
|
|
323
|
+
allocationId: AllocationId;
|
|
324
|
+
state: RecordingState;
|
|
325
|
+
}
|
|
326
|
+
interface HostRecording {
|
|
327
|
+
announcesToCaller?: true;
|
|
328
|
+
actions: RecordingAction[];
|
|
329
|
+
destinationIds: string[];
|
|
330
|
+
execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
|
|
331
|
+
}
|
|
332
|
+
type RecordingCommandResult = Exclude<TaskCommandResult, { status: "dialling" }>;
|
|
333
|
+
interface HostRecordingRequest {
|
|
334
|
+
taskId: TaskId;
|
|
335
|
+
allocationId: AllocationId;
|
|
336
|
+
command: RecordingCommand & { source: "host" };
|
|
337
|
+
}
|
|
338
|
+
|
|
292
339
|
type HostReport = {
|
|
340
|
+
recordings?: HostRecordingReport[];
|
|
293
341
|
online: boolean;
|
|
294
342
|
audio?: {
|
|
295
343
|
input: HostAudioInput;
|
|
@@ -303,6 +351,7 @@ type HostGuarantees = {
|
|
|
303
351
|
};
|
|
304
352
|
|
|
305
353
|
type Host = {
|
|
354
|
+
recording?: HostRecording;
|
|
306
355
|
guarantees: HostGuarantees;
|
|
307
356
|
mute?: HostMute;
|
|
308
357
|
report(): HostReport;
|
|
@@ -327,7 +376,7 @@ type ConnectContext = {
|
|
|
327
376
|
log?: (entry: unknown) => void;
|
|
328
377
|
} & (
|
|
329
378
|
| { phone: "softphone"; host: { mute: HostMute } }
|
|
330
|
-
| { phone?: "deskPhone"; host: { mute?: never } }
|
|
379
|
+
| { phone?: "deskPhone"; host: { mute?: never; recording?: never } }
|
|
331
380
|
);
|
|
332
381
|
|
|
333
382
|
type TransportStatus = "connecting" | "active" | "error";
|
|
@@ -511,7 +560,7 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
511
560
|
warmTransfer?: Lockable<DestinationDirectory>;
|
|
512
561
|
leadAssist?: Lockable<true>;
|
|
513
562
|
conference?: Lockable<DestinationDirectory>;
|
|
514
|
-
recording?: Lockable<
|
|
563
|
+
recording?: Lockable<TaskRecordingPolicy>;
|
|
515
564
|
}
|
|
516
565
|
: C extends "chat"
|
|
517
566
|
? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
|
|
@@ -686,8 +735,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
686
735
|
handlingHistory?: TaskHandlingHistory;
|
|
687
736
|
} & TaskCompletion & (
|
|
688
737
|
C extends "voice"
|
|
689
|
-
? { onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
|
|
690
|
-
: { onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
|
|
738
|
+
? { recording?: { provider?: RecordingState }; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
|
|
739
|
+
: { recording?: never; onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
|
|
691
740
|
);
|
|
692
741
|
|
|
693
742
|
type PreviewDeadline = "calls" | "expires";
|
|
@@ -753,7 +802,7 @@ type VoiceTaskCommand =
|
|
|
753
802
|
| { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
|
|
754
803
|
| { type: "conference"; action: "remove"; destinationId: string; party?: never }
|
|
755
804
|
| { type: "conference"; action: "remove"; party: true; destinationId?: never }
|
|
756
|
-
|
|
|
805
|
+
| (RecordingCommand & { source: "provider" })
|
|
757
806
|
| ({ type: "complete" } & DispositionPayload);
|
|
758
807
|
|
|
759
808
|
type ChatTaskCommand =
|
|
@@ -2994,7 +3043,7 @@ See **Which commands need a capability**.
|
|
|
2994
3043
|
| `warmTransfer` | Secondary menu item: Warm transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Warm transfer**. |
|
|
2995
3044
|
| `leadAssist` | Secondary menu item: Lead assist | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.leadAssist`. See **Lead assist**. |
|
|
2996
3045
|
| `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and remove one person from it -- a conferenced entry, one still ringing included, which calls the dial off, or the party, leaving the agent with the colleague. See **Ending a call, and removing one person from it**. |
|
|
2997
|
-
| `recording` | Overflow menu item: Recording |
|
|
3046
|
+
| `recording` | Overflow menu item: Recording | Per-task provider and host policies expose only their permitted recording actions; each has independent state and routing. |
|
|
2998
3047
|
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
2999
3048
|
|
|
3000
3049
|
### Publishing codes and destinations
|
|
@@ -4216,7 +4265,7 @@ capability's `notes` setting. A task publishing no codes still receives `complet
|
|
|
4216
4265
|
|
|
4217
4266
|
Every command reaches the provider through `execute`, with no branch at the call site, and every
|
|
4218
4267
|
command asks the provider to **perform** something: `hold`, `transfer`, `conference`, `end-call`,
|
|
4219
|
-
`recording` and the rest act on the platform's own call leg, its bridge, or its record of the
|
|
4268
|
+
Provider-targeted `recording` commands and the rest act on the platform's own call leg, its bridge, or its record of the
|
|
4220
4269
|
task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
|
|
4221
4270
|
|
|
4222
4271
|
**The provider performs every action; the host only offers it.** A control drawn on the host is
|
|
@@ -4325,7 +4374,7 @@ declared:
|
|
|
4325
4374
|
| Everything else | Its own named capability. |
|
|
4326
4375
|
|
|
4327
4376
|
**A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
|
|
4328
|
-
`hold`, `resume` and `pause`, `end-call`,
|
|
4377
|
+
`hold`, `resume` and `pause`, `end-call`, every `transfer` and `conference` action, and
|
|
4329
4378
|
every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
|
|
4330
4379
|
Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
|
|
4331
4380
|
call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
|
|
@@ -4940,3 +4989,146 @@ belongs in this contract is the boundary. A provider says what a control **is**
|
|
|
4940
4989
|
capabilities and what its work is **called** through `phaseLabels` and `taskTypePresentation`; how
|
|
4941
4990
|
any of it is drawn is Omni's. A task cannot select a design language, inject a component, or
|
|
4942
4991
|
override the agent's theme and font preferences.
|
|
4992
|
+
|
|
4993
|
+
## Independent task recording
|
|
4994
|
+
|
|
4995
|
+
Recording is voice-only. A task can arrive already recording, including while pending, and offer
|
|
4996
|
+
no recording controls. The provider publishes only its own current state on
|
|
4997
|
+
the provider field of the task’s `recording` state. The host publishes its own full scoped view in `HostReport.recordings`.
|
|
4998
|
+
A provider task update cannot replace host state. Neither a capability nor an accepted command
|
|
4999
|
+
establishes recording. Missing state, lost recorder observation, transport loss and expired
|
|
5000
|
+
observation mean unknown; they never mean stopped. Paused means a recording remains open without
|
|
5001
|
+
capturing. Task hold, task pause and recording pause are independent.
|
|
5002
|
+
|
|
5003
|
+
The per-task policy is `Task.capabilities.recording`, with independently optional provider and host
|
|
5004
|
+
action sets. The outer capability may be locked. Absence grants no permission, and a state may
|
|
5005
|
+
exist without any permission. A provider may offer only stop for a recording started automatically,
|
|
5006
|
+
or withdraw a control on a later task update. There is no global recording mode and no automatic
|
|
5007
|
+
start from a capability declaration. Host support is declared on `ConnectContext.host.recording`,
|
|
5008
|
+
not the provider-owned manifest. The task chooses an explicitly provisioned host destination;
|
|
5009
|
+
unknown destinations or unsupported actions are refused visibly, never redirected to provider
|
|
5010
|
+
recording or a default upload location. Initial host support requires voice softphone media and
|
|
5011
|
+
capture of both local and remote audio; a host unable to capture either must refuse start/resume.
|
|
5012
|
+
This package declares that contract; it supplies no recorder, media mixing, storage or upload.
|
|
5013
|
+
|
|
5014
|
+
| Action | Required current recording state | Confirmed outcome |
|
|
5015
|
+
| --- | --- | --- |
|
|
5016
|
+
| start | inactive | active with a new recording identity |
|
|
5017
|
+
| pause | active | paused, preserving recording identity and captured audio |
|
|
5018
|
+
| resume | paused | active with the same recording identity |
|
|
5019
|
+
| stop | active or paused | inactive; finish and retain captured audio |
|
|
5020
|
+
| cancel | active or paused | inactive; abandon and discard captured audio |
|
|
5021
|
+
|
|
5022
|
+
Stop finishes the recording and retains captured audio. Cancel abandons the recording and discards
|
|
5023
|
+
its captured audio; the task policy offers it with `cancel: true`. There is no configurable cancel
|
|
5024
|
+
effect. A recorder that cannot confirm disposal must not offer Cancel; it can offer Stop instead.
|
|
5025
|
+
Cancel never means cancelling an in-flight start request or deleting arbitrary past recordings.
|
|
5026
|
+
Stop can be applied only after finalization/retention succeeds; Cancel only after both cessation
|
|
5027
|
+
and disposal of this recording's audio succeed under the recorder's storage contract.
|
|
5028
|
+
Partial success (capture stopped but storage outcome unknown) cannot return failed with a claim
|
|
5029
|
+
of no effect. It rejects with unknown outcome, reports the failure visibly and publishes whatever
|
|
5030
|
+
current capture state is actually known. Retention is not a promise of sample-perfect audio.
|
|
5031
|
+
|
|
5032
|
+
Provider commands go exclusively to `Connection.execute`; host commands go exclusively to
|
|
5033
|
+
`HostRecording.execute`. Both include task, allocation, request and observation identities; all
|
|
5034
|
+
non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
|
|
5035
|
+
task and recorder owner. They are never inferred from filenames or current agent identity. A
|
|
5036
|
+
recording ID survives pause/resume and reallocation only where the same recorder confirms continuity;
|
|
5037
|
+
commands always name the current allocation. A later start gets a different ID. Reconnect does not
|
|
5038
|
+
create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
|
|
5039
|
+
There is no automatic restart, transfer to another recorder, or stop on task hold/disconnect.
|
|
5040
|
+
Task removal does not prove recording stopped: outstanding host recorders remain tracked by the
|
|
5041
|
+
host until its executor reconciles/finishes them, with visible unresolved cleanup failures.
|
|
5042
|
+
|
|
5043
|
+
Only start/resume require an in-progress or paused task with started media. Pause/stop/cancel may
|
|
5044
|
+
also finish an independently observed recorder while the task is completing. A pending task may
|
|
5045
|
+
show active recording, but agent controls wait until handling begins. The two recording paths may
|
|
5046
|
+
both be active. A command to either path has no implied effect on the other.
|
|
5047
|
+
|
|
5048
|
+
Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
|
|
5049
|
+
observation instant and an exclusive expiry. These times describe current evidence, never historical
|
|
5050
|
+
capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
|
|
5051
|
+
Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
|
|
5052
|
+
Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
|
|
5053
|
+
consumers must invalidate their clock estimate and use monotonic aging so clock rollback cannot
|
|
5054
|
+
revive expired evidence. The executor compares observation identity and recording identity against
|
|
5055
|
+
its latest state atomically before I/O. An intervening observation or allocation change refuses the
|
|
5056
|
+
stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
|
|
5057
|
+
identity or observation time publish unknown and offer no issuable controls.
|
|
5058
|
+
|
|
5059
|
+
Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
|
|
5060
|
+
Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
|
|
5061
|
+
task and explicit observer-domain time. For host commands also supply the current host declaration,
|
|
5062
|
+
full host report and softphone context. The same checks must run at the executing boundary;
|
|
5063
|
+
client validation alone is not authorization. The executor serializes operations on each recorder
|
|
5064
|
+
and durably correlates request identity with scope, action and outcome: an exact retry refers to the
|
|
5065
|
+
same operation; reuse with different contents is refused. It never silently retries an ambiguous
|
|
5066
|
+
operation. Implementations without reliable request correlation must refuse controls.
|
|
5067
|
+
|
|
5068
|
+
Keep command progress in host UI separately from authoritative state. Applied means the requested
|
|
5069
|
+
transition and disposition actually completed; publish the confirming task or host report before
|
|
5070
|
+
resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
|
|
5071
|
+
show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
|
|
5072
|
+
updates remain full current task/host views, not replayed provider events. These current-state fields
|
|
5073
|
+
create no handling-history entries and infer no actors, durations or historical capture boundaries.
|
|
5074
|
+
|
|
5075
|
+
Migration is explicit: replace the old true recording capability with per-path action policies,
|
|
5076
|
+
replace untargeted commands with scoped commands, and retain protocol version 1. During pre-release
|
|
5077
|
+
development, hosts and adapters must align their recording contracts; negotiation does not detect
|
|
5078
|
+
differences between package revisions that share that protocol number. No legacy-shape fallback is provided.
|
|
5079
|
+
This change does not publish a package or enable recording in any existing host/provider by itself.
|
|
5080
|
+
|
|
5081
|
+
`validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
|
|
5082
|
+
semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
|
|
5083
|
+
retention/disposal or source truth from a status flag; those remain executor obligations.
|
|
5084
|
+
|
|
5085
|
+
Host destination selection binds storage when start actually creates a recording. Existing recordings
|
|
5086
|
+
keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
|
|
5087
|
+
The executor rejects a mismatched destination rather than moving or discarding another binding.
|
|
5088
|
+
Action permission does not authorize unattended invocation: host controls require the agent's explicit
|
|
5089
|
+
act, and provider authorization remains enforced at its authenticated command boundary.
|
|
5090
|
+
|
|
5091
|
+
|
|
5092
|
+
Recording dispatch must receive the same known task-validation context as the published task.
|
|
5093
|
+
Pass organisation levels and other known restrictions through the optional fourth argument of
|
|
5094
|
+
`validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`. This keeps a valid
|
|
5095
|
+
custom organisation lock from being rejected against the default ladder, and prevents known
|
|
5096
|
+
capability restrictions from disappearing at dispatch. The standalone `validateRecordingCommandState`
|
|
5097
|
+
requires an affirmative permission; false, malformed permission declarations and unknown actions grant nothing.
|
|
5098
|
+
|
|
5099
|
+
|
|
5100
|
+
### Host recording announcements to the caller
|
|
5101
|
+
|
|
5102
|
+
`HostRecording.announcesToCaller`, when true, is a guarantee that the host delivers audible recording
|
|
5103
|
+
status messages to the remote party over the call's outgoing audio. It belongs inside the optional
|
|
5104
|
+
host recording capability: without that capability there is no such guarantee. Omission makes no
|
|
5105
|
+
promise; false is invalid. It does not belong in the provider manifest, the task's recording policy
|
|
5106
|
+
or the general `HostGuarantees` object. A provider can inspect this host guarantee when deciding
|
|
5107
|
+
whether to permit host recording on a task.
|
|
5108
|
+
|
|
5109
|
+
The guarantee applies only to host-owned recordings. Providers implement announcements for their
|
|
5110
|
+
own recordings at their end; provider state updates must not cause the host to announce those
|
|
5111
|
+
recordings. Both recorders can operate independently on the same call.
|
|
5112
|
+
|
|
5113
|
+
On a confirmed host start, the remote party hears that recording started. On a confirmed stop or
|
|
5114
|
+
cancel, they hear that recording stopped. If pause/resume is supported, say paused/resumed so a
|
|
5115
|
+
pause is not presented as a finished recording. Announcements follow actual capture transitions,
|
|
5116
|
+
not button clicks, accepted requests or task-policy changes. When attaching to an already-active
|
|
5117
|
+
host recording, announce that recording is active, without inventing its original start time.
|
|
5118
|
+
Repeated observations of the same state do not repeat announcements. Unknown or expired evidence
|
|
5119
|
+
must never produce a stopped announcement.
|
|
5120
|
+
|
|
5121
|
+
A local sound, UI indicator, screen-reader message to the agent, or sound leaking through the
|
|
5122
|
+
microphone does not fulfil this guarantee. The host must deliver the message directly in the audio
|
|
5123
|
+
sent to the remote party, even when the agent microphone is muted. It may also notify the agent.
|
|
5124
|
+
Only declare the guarantee when the host can provide that outgoing audio path. An announcement
|
|
5125
|
+
that cannot be delivered, including after the remote party has disconnected, is a visible failure;
|
|
5126
|
+
never claim delivery or replay the notice into a different call.
|
|
5127
|
+
|
|
5128
|
+
For a command under this guarantee, applied requires both the recording action and its announcement
|
|
5129
|
+
to succeed. If capture changes but announcement delivery fails, publish the actual recording state,
|
|
5130
|
+
report the announcement failure visibly and reject with unknown command outcome. Do not claim no
|
|
5131
|
+
effect, automatically repeat the recording action or silently switch to an agent-only notice.
|
|
5132
|
+
|
|
5133
|
+
`validateHostRecording` checks the declaration, including its true-or-absent guarantee. Actual audio
|
|
5134
|
+
delivery remains the host implementation's responsibility and must be exercised in its own tests.
|