@xema/omni-protocol 0.1.79 → 0.1.81
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 +98 -6
- package/dist/index.js +2 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -1
- package/dist/validation.d.ts +20 -1
- package/dist/validation.js +276 -5
- package/guide.md +177 -9
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The protocol version implemented by this package. */
|
|
2
|
-
export declare const OMNI_PROTOCOL_VERSION:
|
|
2
|
+
export declare const OMNI_PROTOCOL_VERSION: 2;
|
|
3
3
|
/** Every version this package can interoperate with. */
|
|
4
4
|
export declare const OMNI_SUPPORTED_PROTOCOL_VERSIONS: readonly number[];
|
|
5
5
|
/**
|
|
@@ -427,7 +427,93 @@ 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
|
+
/** Cancel's captured-audio disposition is explicit; stop always retains captured audio. */
|
|
435
|
+
export type RecordingCancelEffect = "retain" | "discard";
|
|
436
|
+
export interface RecordingActions {
|
|
437
|
+
start?: true;
|
|
438
|
+
pause?: true;
|
|
439
|
+
resume?: true;
|
|
440
|
+
stop?: true;
|
|
441
|
+
cancel?: {
|
|
442
|
+
effect: RecordingCancelEffect;
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/** Per-task permission, not a provider-wide recording switch. Absence grants nothing. */
|
|
446
|
+
export interface TaskRecordingPolicy {
|
|
447
|
+
provider?: RecordingActions;
|
|
448
|
+
host?: RecordingActions & {
|
|
449
|
+
destinationId: string;
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** Current evidence only. Expiry/disconnect means unknown, never a historical stop. */
|
|
453
|
+
export type RecordingState = {
|
|
454
|
+
status: "unknown";
|
|
455
|
+
observationId?: never;
|
|
456
|
+
observedAt?: never;
|
|
457
|
+
validUntil?: never;
|
|
458
|
+
recordingId?: never;
|
|
459
|
+
} | ({
|
|
460
|
+
observationId: string;
|
|
461
|
+
observedAt: IsoTimestamp;
|
|
462
|
+
validUntil: IsoTimestamp;
|
|
463
|
+
} & ({
|
|
464
|
+
status: "inactive";
|
|
465
|
+
recordingId?: never;
|
|
466
|
+
} | {
|
|
467
|
+
status: "active" | "paused";
|
|
468
|
+
recordingId: string;
|
|
469
|
+
}));
|
|
470
|
+
/** Opaque IDs are scoped by login, task and recorder owner; preserved across pause/resume. */
|
|
471
|
+
export type RecordingCommand = {
|
|
472
|
+
type: "recording";
|
|
473
|
+
source: RecordingSource;
|
|
474
|
+
requestId: string;
|
|
475
|
+
/** Compare-and-set against current evidence; never act on a replacement recording. */
|
|
476
|
+
observationId: string;
|
|
477
|
+
} & ({
|
|
478
|
+
action: "start";
|
|
479
|
+
recordingId?: never;
|
|
480
|
+
cancelEffect?: never;
|
|
481
|
+
} | {
|
|
482
|
+
action: "pause" | "resume" | "stop";
|
|
483
|
+
recordingId: string;
|
|
484
|
+
cancelEffect?: never;
|
|
485
|
+
} | {
|
|
486
|
+
action: "cancel";
|
|
487
|
+
recordingId: string;
|
|
488
|
+
cancelEffect: RecordingCancelEffect;
|
|
489
|
+
});
|
|
490
|
+
export interface HostRecordingReport {
|
|
491
|
+
taskId: TaskId;
|
|
492
|
+
allocationId: AllocationId;
|
|
493
|
+
state: RecordingState;
|
|
494
|
+
}
|
|
495
|
+
/** Host declaration in ConnectContext, not the provider-owned Manifest. */
|
|
496
|
+
export interface HostRecording {
|
|
497
|
+
actions: RecordingAction[];
|
|
498
|
+
cancelEffects: RecordingCancelEffect[];
|
|
499
|
+
/** Explicitly provisioned destinations. A task chooses one; no upload/storage fallback. */
|
|
500
|
+
destinationIds: string[];
|
|
501
|
+
execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
|
|
502
|
+
}
|
|
503
|
+
/** Recording applies or fails; it never dials. Rejection without result means unknown. */
|
|
504
|
+
export type RecordingCommandResult = Exclude<TaskCommandResult, {
|
|
505
|
+
status: "dialling";
|
|
506
|
+
}>;
|
|
507
|
+
export interface HostRecordingRequest {
|
|
508
|
+
taskId: TaskId;
|
|
509
|
+
allocationId: AllocationId;
|
|
510
|
+
command: RecordingCommand & {
|
|
511
|
+
source: "host";
|
|
512
|
+
};
|
|
513
|
+
}
|
|
430
514
|
export interface HostReport {
|
|
515
|
+
/** Full current host-owned recording view; never provider-owned state. */
|
|
516
|
+
recordings?: HostRecordingReport[];
|
|
431
517
|
/** 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
518
|
online: boolean;
|
|
433
519
|
audio?: {
|
|
@@ -449,6 +535,8 @@ export interface HostGuarantees {
|
|
|
449
535
|
personConsent?: true;
|
|
450
536
|
}
|
|
451
537
|
export interface Host {
|
|
538
|
+
/** Voice softphone recording support; policy and destination remain per task. */
|
|
539
|
+
recording?: HostRecording;
|
|
452
540
|
guarantees: HostGuarantees;
|
|
453
541
|
/** 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
542
|
mute?: HostMute;
|
|
@@ -494,6 +582,7 @@ export type ConnectContext = {
|
|
|
494
582
|
phone?: "deskPhone";
|
|
495
583
|
host: {
|
|
496
584
|
mute?: never;
|
|
585
|
+
recording?: never;
|
|
497
586
|
};
|
|
498
587
|
});
|
|
499
588
|
export type TransportStatus = "connecting" | "active" | "error";
|
|
@@ -581,7 +670,7 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
|
|
|
581
670
|
/** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
|
|
582
671
|
leadAssist?: Lockable<true>;
|
|
583
672
|
conference?: Lockable<DestinationDirectory>;
|
|
584
|
-
recording?: Lockable<
|
|
673
|
+
recording?: Lockable<TaskRecordingPolicy>;
|
|
585
674
|
} : C extends "chat" ? SharedTaskCapabilities & {
|
|
586
675
|
decline?: Lockable<true>;
|
|
587
676
|
hold?: Lockable<true>;
|
|
@@ -888,12 +977,16 @@ export type Task<C extends Channel = Channel> = {
|
|
|
888
977
|
attributes?: TaskAttribute[];
|
|
889
978
|
handlingHistory?: TaskHandlingHistory;
|
|
890
979
|
} & TaskCompletion & (C extends "voice" ? {
|
|
980
|
+
recording?: {
|
|
981
|
+
provider?: RecordingState;
|
|
982
|
+
};
|
|
891
983
|
onCall?: OnCall[];
|
|
892
984
|
leadAssist?: TaskLeadAssist;
|
|
893
985
|
assisting?: TaskAssisting;
|
|
894
986
|
monitoring?: TaskMonitoring;
|
|
895
987
|
media?: TaskMediaState;
|
|
896
988
|
} : {
|
|
989
|
+
recording?: never;
|
|
897
990
|
onCall?: never;
|
|
898
991
|
leadAssist?: never;
|
|
899
992
|
assisting?: never;
|
|
@@ -1040,10 +1133,9 @@ export type VoiceTaskCommand = {
|
|
|
1040
1133
|
action: "remove";
|
|
1041
1134
|
party: true;
|
|
1042
1135
|
destinationId?: never;
|
|
1043
|
-
} | {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
} | ({
|
|
1136
|
+
} | (RecordingCommand & {
|
|
1137
|
+
source: "provider";
|
|
1138
|
+
}) | ({
|
|
1047
1139
|
type: "complete";
|
|
1048
1140
|
} & DispositionPayload);
|
|
1049
1141
|
export type ChatTaskCommand = {
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Where this file and guide.md disagree, the guide is right and this is a defect.
|
|
4
4
|
/** The protocol version implemented by this package. */
|
|
5
|
-
export const OMNI_PROTOCOL_VERSION =
|
|
5
|
+
export const OMNI_PROTOCOL_VERSION = 2;
|
|
6
6
|
/** Every version this package can interoperate with. */
|
|
7
7
|
export const OMNI_SUPPORTED_PROTOCOL_VERSIONS = [OMNI_PROTOCOL_VERSION];
|
|
8
8
|
/**
|
|
@@ -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); },
|
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[];
|
|
@@ -135,3 +135,22 @@ 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
|
+
}, path?: string): ProtocolViolation[];
|
|
155
|
+
/** Check recording-specific result semantics against the owner's confirming observations. */
|
|
156
|
+
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
|
|
@@ -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)
|
|
@@ -2158,10 +2172,19 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
2158
2172
|
offered("endCall");
|
|
2159
2173
|
handling();
|
|
2160
2174
|
break;
|
|
2161
|
-
case "recording":
|
|
2175
|
+
case "recording": {
|
|
2162
2176
|
offered("recording");
|
|
2163
|
-
|
|
2177
|
+
const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
|
|
2178
|
+
const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
|
|
2179
|
+
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2180
|
+
if (command.action === "start" || command.action === "resume") {
|
|
2181
|
+
handling();
|
|
2182
|
+
into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
|
|
2183
|
+
}
|
|
2184
|
+
else
|
|
2185
|
+
inPhase("command.phase.handling", "in-progress", "paused", "completing");
|
|
2164
2186
|
break;
|
|
2187
|
+
}
|
|
2165
2188
|
case "connect-back":
|
|
2166
2189
|
offered("connectBack");
|
|
2167
2190
|
inPhase("command.phase.completing", "completing");
|
|
@@ -2563,3 +2586,251 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
2563
2586
|
}
|
|
2564
2587
|
return into.violations;
|
|
2565
2588
|
}
|
|
2589
|
+
// Independent current recording evidence and dispatch validation.
|
|
2590
|
+
const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2591
|
+
const filled = (v) => typeof v === "string" && v.trim().length > 0;
|
|
2592
|
+
const effects = ["retain", "discard"];
|
|
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
|
+
if (action === "cancel") {
|
|
2664
|
+
const cancel = policy.cancel;
|
|
2665
|
+
check(object(cancel) && effects.includes(cancel.effect), "cancel.effect", `${at}.cancel`, "cancel explicitly retains or discards this recording's captured audio");
|
|
2666
|
+
if (object(cancel))
|
|
2667
|
+
keys(cancel, ["effect"], `${at}.cancel`);
|
|
2668
|
+
}
|
|
2669
|
+
else
|
|
2670
|
+
check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
|
|
2671
|
+
}
|
|
2672
|
+
if (source === "host")
|
|
2673
|
+
check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
|
|
2674
|
+
}
|
|
2675
|
+
return violations;
|
|
2676
|
+
}
|
|
2677
|
+
export function validateHostRecording(value, softphone, path = "host.recording") {
|
|
2678
|
+
if (value === undefined)
|
|
2679
|
+
return [];
|
|
2680
|
+
const { violations, check, keys } = collector();
|
|
2681
|
+
check(softphone, "host.channel", path, "initial host recording requires voice softphone media");
|
|
2682
|
+
if (!object(value)) {
|
|
2683
|
+
check(false, "host.shape", path, "expected host recording declaration and executor");
|
|
2684
|
+
return violations;
|
|
2685
|
+
}
|
|
2686
|
+
keys(value, ["actions", "cancelEffects", "destinationIds", "execute"], path);
|
|
2687
|
+
for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["cancelEffects", effects], ["destinationIds", undefined]]) {
|
|
2688
|
+
const list = value[key];
|
|
2689
|
+
if (!Array.isArray(list)) {
|
|
2690
|
+
check(false, "host.list", `${path}.${key}`, "expected explicit array");
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
|
|
2694
|
+
check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
|
|
2695
|
+
if (key !== "cancelEffects")
|
|
2696
|
+
check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
|
|
2697
|
+
}
|
|
2698
|
+
if (Array.isArray(value.actions) && Array.isArray(value.cancelEffects)) {
|
|
2699
|
+
check(value.actions.includes("cancel") === (value.cancelEffects.length > 0), "host.cancel", path, "cancel support and explicit effects must agree");
|
|
2700
|
+
check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
|
|
2701
|
+
}
|
|
2702
|
+
check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
|
|
2703
|
+
return violations;
|
|
2704
|
+
}
|
|
2705
|
+
export function validateHostRecordings(value, path = "host.recordings") {
|
|
2706
|
+
if (value === undefined)
|
|
2707
|
+
return [];
|
|
2708
|
+
const { violations, check, keys } = collector();
|
|
2709
|
+
if (!Array.isArray(value)) {
|
|
2710
|
+
check(false, "reports.shape", path, "expected full host recording report array");
|
|
2711
|
+
return violations;
|
|
2712
|
+
}
|
|
2713
|
+
const seen = new Set();
|
|
2714
|
+
value.forEach((report, i) => {
|
|
2715
|
+
const at = `${path}[${i}]`;
|
|
2716
|
+
if (!object(report)) {
|
|
2717
|
+
check(false, "report.shape", at, "expected scoped report");
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
keys(report, ["taskId", "allocationId", "state"], at);
|
|
2721
|
+
check(filled(report.taskId) && filled(report.allocationId), "report.scope", at, "report identifies task and allocation");
|
|
2722
|
+
const key = JSON.stringify([report.taskId, report.allocationId]);
|
|
2723
|
+
check(!seen.has(key), "report.duplicate", at, "one host state per task allocation");
|
|
2724
|
+
seen.add(key);
|
|
2725
|
+
violations.push(...validateRecordingState(report.state, `${at}.state`));
|
|
2726
|
+
});
|
|
2727
|
+
return violations;
|
|
2728
|
+
}
|
|
2729
|
+
export function validateRecordingCommandShape(value, source, path = "command") {
|
|
2730
|
+
const { violations, check, keys } = collector();
|
|
2731
|
+
if (!object(value)) {
|
|
2732
|
+
check(false, "command.shape", path, "expected recording command");
|
|
2733
|
+
return violations;
|
|
2734
|
+
}
|
|
2735
|
+
keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"]), ...(value.action === "cancel" ? ["cancelEffect"] : [])], path);
|
|
2736
|
+
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
2737
|
+
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
2738
|
+
for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
|
|
2739
|
+
check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
|
|
2740
|
+
if (value.action === "cancel")
|
|
2741
|
+
check(effects.includes(value.cancelEffect), "cancel.effect", `${path}.cancelEffect`, "explicit captured-audio disposition required");
|
|
2742
|
+
return violations;
|
|
2743
|
+
}
|
|
2744
|
+
/** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
|
|
2745
|
+
export function validateRecordingCommandState(command, policy, state, path = "command") {
|
|
2746
|
+
const { violations, check } = collector();
|
|
2747
|
+
if (!object(command))
|
|
2748
|
+
return violations;
|
|
2749
|
+
check(object(policy) && policy[command.action] !== undefined, "command.permission", path, "task does not permit this action on this recorder");
|
|
2750
|
+
if (!object(state) || validateRecordingState(state).length) {
|
|
2751
|
+
check(false, "command.state", path, "no validated current recorder observation");
|
|
2752
|
+
return violations;
|
|
2753
|
+
}
|
|
2754
|
+
const allowed = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
|
|
2755
|
+
check(allowed.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
|
|
2756
|
+
check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
|
|
2757
|
+
if (command.action !== "start")
|
|
2758
|
+
check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
|
|
2759
|
+
if (command.action === "cancel" && object(policy))
|
|
2760
|
+
check(object(policy.cancel) && command.cancelEffect === policy.cancel.effect, "command.cancelEffect", path, "cancel disposition must match task policy");
|
|
2761
|
+
return violations;
|
|
2762
|
+
}
|
|
2763
|
+
/** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
|
|
2764
|
+
export function validateRecordingRequest(request, task, context, path = "request") {
|
|
2765
|
+
const { violations, check, keys } = collector();
|
|
2766
|
+
if (!object(request) || !object(task)) {
|
|
2767
|
+
check(false, "request.shape", path, "request and current task required");
|
|
2768
|
+
return violations;
|
|
2769
|
+
}
|
|
2770
|
+
violations.push(...validateTask(task, { channel: "voice" }, `${path}.task`));
|
|
2771
|
+
keys(request, ["taskId", "allocationId", "command"], path);
|
|
2772
|
+
check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
|
|
2773
|
+
check(task.channel === "voice", "request.channel", path, "recording is voice-only");
|
|
2774
|
+
violations.push(...validateRecordingCommandShape(request.command, context.source, `${path}.command`));
|
|
2775
|
+
if (!object(request.command))
|
|
2776
|
+
return violations;
|
|
2777
|
+
const command = request.command;
|
|
2778
|
+
const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
|
|
2779
|
+
violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
|
|
2780
|
+
const policy = object(caps) ? caps[context.source] : undefined;
|
|
2781
|
+
let state = object(task.recording) ? task.recording.provider : undefined;
|
|
2782
|
+
if (context.source === "host") {
|
|
2783
|
+
violations.push(...validateHostRecording(context.host, context.softphone === true));
|
|
2784
|
+
check(object(context.host), "host.required", path, "host did not declare recording support");
|
|
2785
|
+
const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
|
|
2786
|
+
violations.push(...validateHostRecordings(reports));
|
|
2787
|
+
state = Array.isArray(reports) ? reports.find(r => object(r) && r.taskId === task.id && r.allocationId === task.allocationId)?.state : undefined;
|
|
2788
|
+
if (object(context.host)) {
|
|
2789
|
+
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
2790
|
+
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");
|
|
2791
|
+
if (command.action === "cancel")
|
|
2792
|
+
check(Array.isArray(context.host.cancelEffects) && context.host.cancelEffects.includes(command.cancelEffect), "host.cancelEffect", path, "host does not support cancel disposition");
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
const fresh = effectiveRecordingState(state, context.now);
|
|
2796
|
+
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
2797
|
+
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
2798
|
+
const opening = command.action === "start" || command.action === "resume";
|
|
2799
|
+
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");
|
|
2800
|
+
return violations;
|
|
2801
|
+
}
|
|
2802
|
+
/** Check recording-specific result semantics against the owner's confirming observations. */
|
|
2803
|
+
export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
|
|
2804
|
+
const { violations, check } = collector();
|
|
2805
|
+
violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
|
|
2806
|
+
if (!object(command) || !object(before) || !object(after)) {
|
|
2807
|
+
check(false, "outcome.shape", path, "command and both observations required");
|
|
2808
|
+
return violations;
|
|
2809
|
+
}
|
|
2810
|
+
violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
|
|
2811
|
+
// A rejected promise has no result: any independently evidenced state may follow it.
|
|
2812
|
+
if (result === undefined)
|
|
2813
|
+
return violations;
|
|
2814
|
+
violations.push(...validateResult(result, "execute", `${path}.result`));
|
|
2815
|
+
if (!object(result))
|
|
2816
|
+
return violations;
|
|
2817
|
+
check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
|
|
2818
|
+
if (result.status === "failed") {
|
|
2819
|
+
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");
|
|
2820
|
+
}
|
|
2821
|
+
else if (result.status === "applied") {
|
|
2822
|
+
const expected = command.action === "pause" ? "paused" : command.action === "start" || command.action === "resume" ? "active" : "inactive";
|
|
2823
|
+
check(after.status === expected, "outcome.state", path, "applied requires the confirming requested state");
|
|
2824
|
+
check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
|
|
2825
|
+
check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
|
|
2826
|
+
const origin = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
|
|
2827
|
+
check(origin.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
2828
|
+
if (command.action !== "start")
|
|
2829
|
+
check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
|
|
2830
|
+
if (command.action === "pause" || command.action === "resume")
|
|
2831
|
+
check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
|
|
2832
|
+
if (instant(before.observedAt) && instant(after.observedAt))
|
|
2833
|
+
check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
|
|
2834
|
+
}
|
|
2835
|
+
return violations;
|
|
2836
|
+
}
|
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 is `1
|
|
61
|
+
The exact protocol version implemented by this package. The current value is `2`. Version 2 replaces the broad recording flag and untargeted recording command; version 1 is not negotiated by this package. Hosts and adapters must explicitly migrate together or retain their own version-1 implementation.
|
|
62
62
|
|
|
63
63
|
### `Manifest.supportedProtocolVersions`
|
|
64
64
|
|
|
@@ -289,7 +289,57 @@ 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
|
+
type RecordingCancelEffect = "retain" | "discard";
|
|
296
|
+
interface RecordingActions {
|
|
297
|
+
start?: true;
|
|
298
|
+
pause?: true;
|
|
299
|
+
resume?: true;
|
|
300
|
+
stop?: true;
|
|
301
|
+
cancel?: { effect: RecordingCancelEffect };
|
|
302
|
+
}
|
|
303
|
+
interface TaskRecordingPolicy {
|
|
304
|
+
provider?: RecordingActions;
|
|
305
|
+
host?: RecordingActions & { destinationId: string };
|
|
306
|
+
}
|
|
307
|
+
type RecordingState =
|
|
308
|
+
| { status: "unknown"; observationId?: never; observedAt?: never; validUntil?: never; recordingId?: never }
|
|
309
|
+
| ({ observationId: string; observedAt: IsoTimestamp; validUntil: IsoTimestamp } & (
|
|
310
|
+
| { status: "inactive"; recordingId?: never }
|
|
311
|
+
| { status: "active" | "paused"; recordingId: string }
|
|
312
|
+
));
|
|
313
|
+
type RecordingCommand = {
|
|
314
|
+
type: "recording";
|
|
315
|
+
source: RecordingSource;
|
|
316
|
+
requestId: string;
|
|
317
|
+
observationId: string;
|
|
318
|
+
} & (
|
|
319
|
+
| { action: "start"; recordingId?: never; cancelEffect?: never }
|
|
320
|
+
| { action: "pause" | "resume" | "stop"; recordingId: string; cancelEffect?: never }
|
|
321
|
+
| { action: "cancel"; recordingId: string; cancelEffect: RecordingCancelEffect }
|
|
322
|
+
);
|
|
323
|
+
interface HostRecordingReport {
|
|
324
|
+
taskId: TaskId;
|
|
325
|
+
allocationId: AllocationId;
|
|
326
|
+
state: RecordingState;
|
|
327
|
+
}
|
|
328
|
+
interface HostRecording {
|
|
329
|
+
actions: RecordingAction[];
|
|
330
|
+
cancelEffects: RecordingCancelEffect[];
|
|
331
|
+
destinationIds: string[];
|
|
332
|
+
execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
|
|
333
|
+
}
|
|
334
|
+
type RecordingCommandResult = Exclude<TaskCommandResult, { status: "dialling" }>;
|
|
335
|
+
interface HostRecordingRequest {
|
|
336
|
+
taskId: TaskId;
|
|
337
|
+
allocationId: AllocationId;
|
|
338
|
+
command: RecordingCommand & { source: "host" };
|
|
339
|
+
}
|
|
340
|
+
|
|
292
341
|
type HostReport = {
|
|
342
|
+
recordings?: HostRecordingReport[];
|
|
293
343
|
online: boolean;
|
|
294
344
|
audio?: {
|
|
295
345
|
input: HostAudioInput;
|
|
@@ -303,6 +353,7 @@ type HostGuarantees = {
|
|
|
303
353
|
};
|
|
304
354
|
|
|
305
355
|
type Host = {
|
|
356
|
+
recording?: HostRecording;
|
|
306
357
|
guarantees: HostGuarantees;
|
|
307
358
|
mute?: HostMute;
|
|
308
359
|
report(): HostReport;
|
|
@@ -327,7 +378,7 @@ type ConnectContext = {
|
|
|
327
378
|
log?: (entry: unknown) => void;
|
|
328
379
|
} & (
|
|
329
380
|
| { phone: "softphone"; host: { mute: HostMute } }
|
|
330
|
-
| { phone?: "deskPhone"; host: { mute?: never } }
|
|
381
|
+
| { phone?: "deskPhone"; host: { mute?: never; recording?: never } }
|
|
331
382
|
);
|
|
332
383
|
|
|
333
384
|
type TransportStatus = "connecting" | "active" | "error";
|
|
@@ -511,7 +562,7 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
511
562
|
warmTransfer?: Lockable<DestinationDirectory>;
|
|
512
563
|
leadAssist?: Lockable<true>;
|
|
513
564
|
conference?: Lockable<DestinationDirectory>;
|
|
514
|
-
recording?: Lockable<
|
|
565
|
+
recording?: Lockable<TaskRecordingPolicy>;
|
|
515
566
|
}
|
|
516
567
|
: C extends "chat"
|
|
517
568
|
? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
|
|
@@ -686,8 +737,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
686
737
|
handlingHistory?: TaskHandlingHistory;
|
|
687
738
|
} & TaskCompletion & (
|
|
688
739
|
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 }
|
|
740
|
+
? { recording?: { provider?: RecordingState }; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
|
|
741
|
+
: { recording?: never; onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
|
|
691
742
|
);
|
|
692
743
|
|
|
693
744
|
type PreviewDeadline = "calls" | "expires";
|
|
@@ -753,7 +804,7 @@ type VoiceTaskCommand =
|
|
|
753
804
|
| { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
|
|
754
805
|
| { type: "conference"; action: "remove"; destinationId: string; party?: never }
|
|
755
806
|
| { type: "conference"; action: "remove"; party: true; destinationId?: never }
|
|
756
|
-
|
|
|
807
|
+
| (RecordingCommand & { source: "provider" })
|
|
757
808
|
| ({ type: "complete" } & DispositionPayload);
|
|
758
809
|
|
|
759
810
|
type ChatTaskCommand =
|
|
@@ -1454,6 +1505,26 @@ connection is up nothing has been lost, and when it comes back the adapter sends
|
|
|
1454
1505
|
|
|
1455
1506
|
Request/response polling does not have those properties and is not a transport for this contract.
|
|
1456
1507
|
|
|
1508
|
+
### 3. An adapter's memory is the platform's and the login's, never the connection's
|
|
1509
|
+
|
|
1510
|
+
Everything an adapter publishes about a task is derived from what the platform states, or kept in
|
|
1511
|
+
the login's store where the platform cannot hold it. Per offer, the `allocationId` comes from what
|
|
1512
|
+
the offer itself states; per call, the media follows `task-media-started` and `task-media-ended`,
|
|
1513
|
+
never a flag set when the audio was opened; per session, the phone the agent holds is what the
|
|
1514
|
+
platform restates on activation, not a mapping read once over HTTP. What the platform cannot hold
|
|
1515
|
+
-- a host's muted leg -- goes in `ConnectContext.store`, keyed by the task and gone with it.
|
|
1516
|
+
|
|
1517
|
+
A fact read once over a connection and kept in the adapter object dies with the client. A host
|
|
1518
|
+
reload is the first client dying and a second coming up for the same login, and the second
|
|
1519
|
+
publishes the same truth about the same task as the first did, or the first was publishing
|
|
1520
|
+
something it made up: an allocation minted for the connection, media remembered as a boolean, a
|
|
1521
|
+
phone the second never read. Three pieces of exactly that state were found in one adapter in an
|
|
1522
|
+
afternoon, and none of it was visible until the adapter was built twice. The drive's `rebuild` is the test of this
|
|
1523
|
+
principle: given a way to build the adapter again, the run hands the login over and holds the
|
|
1524
|
+
second to what the first published (`drive.reload.allocation`, `.history`, `.openMedia`, and the
|
|
1525
|
+
stream's own rules across the resync). An adapter that cannot be built twice against its platform
|
|
1526
|
+
and publish the same task is not a conformant adapter yet, whatever a single run says.
|
|
1527
|
+
|
|
1457
1528
|
## Package entry points
|
|
1458
1529
|
|
|
1459
1530
|
| Import | Purpose |
|
|
@@ -2974,7 +3045,7 @@ See **Which commands need a capability**.
|
|
|
2974
3045
|
| `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**. |
|
|
2975
3046
|
| `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**. |
|
|
2976
3047
|
| `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**. |
|
|
2977
|
-
| `recording` | Overflow menu item: Recording |
|
|
3048
|
+
| `recording` | Overflow menu item: Recording | Per-task provider and host policies expose only their permitted recording actions; each has independent state and routing. |
|
|
2978
3049
|
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
2979
3050
|
|
|
2980
3051
|
### Publishing codes and destinations
|
|
@@ -4196,7 +4267,7 @@ capability's `notes` setting. A task publishing no codes still receives `complet
|
|
|
4196
4267
|
|
|
4197
4268
|
Every command reaches the provider through `execute`, with no branch at the call site, and every
|
|
4198
4269
|
command asks the provider to **perform** something: `hold`, `transfer`, `conference`, `end-call`,
|
|
4199
|
-
`recording` and the rest act on the platform's own call leg, its bridge, or its record of the
|
|
4270
|
+
Provider-targeted `recording` commands and the rest act on the platform's own call leg, its bridge, or its record of the
|
|
4200
4271
|
task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
|
|
4201
4272
|
|
|
4202
4273
|
**The provider performs every action; the host only offers it.** A control drawn on the host is
|
|
@@ -4305,7 +4376,7 @@ declared:
|
|
|
4305
4376
|
| Everything else | Its own named capability. |
|
|
4306
4377
|
|
|
4307
4378
|
**A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
|
|
4308
|
-
`hold`, `resume` and `pause`, `end-call`,
|
|
4379
|
+
`hold`, `resume` and `pause`, `end-call`, every `transfer` and `conference` action, and
|
|
4309
4380
|
every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
|
|
4310
4381
|
Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
|
|
4311
4382
|
call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
|
|
@@ -4920,3 +4991,100 @@ belongs in this contract is the boundary. A provider says what a control **is**
|
|
|
4920
4991
|
capabilities and what its work is **called** through `phaseLabels` and `taskTypePresentation`; how
|
|
4921
4992
|
any of it is drawn is Omni's. A task cannot select a design language, inject a component, or
|
|
4922
4993
|
override the agent's theme and font preferences.
|
|
4994
|
+
|
|
4995
|
+
## Independent task recording (protocol 2)
|
|
4996
|
+
|
|
4997
|
+
Recording is voice-only. A task can arrive already recording, including while pending, and offer
|
|
4998
|
+
no recording controls. The provider publishes only its own current state on
|
|
4999
|
+
the provider field of the task’s `recording` state. The host publishes its own full scoped view in `HostReport.recordings`.
|
|
5000
|
+
A provider task update cannot replace host state. Neither a capability nor an accepted command
|
|
5001
|
+
establishes recording. Missing state, lost recorder observation, transport loss and expired
|
|
5002
|
+
observation mean unknown; they never mean stopped. Paused means a recording remains open without
|
|
5003
|
+
capturing. Task hold, task pause and recording pause are independent.
|
|
5004
|
+
|
|
5005
|
+
The per-task policy is `Task.capabilities.recording`, with independently optional provider and host
|
|
5006
|
+
action sets. The outer capability may be locked. Absence grants no permission, and a state may
|
|
5007
|
+
exist without any permission. A provider may offer only stop for a recording started automatically,
|
|
5008
|
+
or withdraw a control on a later task update. There is no global recording mode and no automatic
|
|
5009
|
+
start from a capability declaration. Host support is declared on `ConnectContext.host.recording`,
|
|
5010
|
+
not the provider-owned manifest. The task chooses an explicitly provisioned host destination;
|
|
5011
|
+
unknown destinations or unsupported actions are refused visibly, never redirected to provider
|
|
5012
|
+
recording or a default upload location. Initial host support requires voice softphone media and
|
|
5013
|
+
capture of both local and remote audio; a host unable to capture either must refuse start/resume.
|
|
5014
|
+
This package declares that contract; it supplies no recorder, media mixing, storage or upload.
|
|
5015
|
+
|
|
5016
|
+
| Action | Required current recording state | Confirmed outcome |
|
|
5017
|
+
| --- | --- | --- |
|
|
5018
|
+
| start | inactive | active with a new recording identity |
|
|
5019
|
+
| pause | active | paused, preserving recording identity and captured audio |
|
|
5020
|
+
| resume | paused | active with the same recording identity |
|
|
5021
|
+
| stop | active or paused | inactive; finish and retain captured audio |
|
|
5022
|
+
| cancel | active or paused | inactive; abandon with the explicitly declared captured-audio disposition |
|
|
5023
|
+
|
|
5024
|
+
Cancel must declare retain or discard in task policy and echo that exact effect in the command.
|
|
5025
|
+
Retain abandons further capture while preserving captured audio; discard additionally requires
|
|
5026
|
+
confirmed disposal of that recording's audio under the recorder's storage contract. Neither
|
|
5027
|
+
means cancelling an in-flight start request or deleting arbitrary past recordings. A recorder
|
|
5028
|
+
unable to guarantee the offered effect must not advertise it. Stop can be applied only after
|
|
5029
|
+
finalization/retention succeeds; cancel-discard only after cessation and disposition succeed.
|
|
5030
|
+
Partial success (capture stopped but storage outcome unknown) cannot return failed with a claim
|
|
5031
|
+
of no effect. It rejects with unknown outcome, reports the failure visibly and publishes whatever
|
|
5032
|
+
current capture state is actually known. Retention is not a promise of sample-perfect audio.
|
|
5033
|
+
|
|
5034
|
+
Provider commands go exclusively to `Connection.execute`; host commands go exclusively to
|
|
5035
|
+
`HostRecording.execute`. Both include task, allocation, request and observation identities; all
|
|
5036
|
+
non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
|
|
5037
|
+
task and recorder owner. They are never inferred from filenames or current agent identity. A
|
|
5038
|
+
recording ID survives pause/resume and reallocation only where the same recorder confirms continuity;
|
|
5039
|
+
commands always name the current allocation. A later start gets a different ID. Reconnect does not
|
|
5040
|
+
create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
|
|
5041
|
+
There is no automatic restart, transfer to another recorder, or stop on task hold/disconnect.
|
|
5042
|
+
Task removal does not prove recording stopped: outstanding host recorders remain tracked by the
|
|
5043
|
+
host until its executor reconciles/finishes them, with visible unresolved cleanup failures.
|
|
5044
|
+
|
|
5045
|
+
Only start/resume require an in-progress or paused task with started media. Pause/stop/cancel may
|
|
5046
|
+
also finish an independently observed recorder while the task is completing. A pending task may
|
|
5047
|
+
show active recording, but agent controls wait until handling begins. The two recording paths may
|
|
5048
|
+
both be active. A command to either path has no implied effect on the other.
|
|
5049
|
+
|
|
5050
|
+
Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
|
|
5051
|
+
observation instant and an exclusive expiry. These times describe current evidence, never historical
|
|
5052
|
+
capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
|
|
5053
|
+
Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
|
|
5054
|
+
Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
|
|
5055
|
+
consumers must invalidate their clock estimate and use monotonic aging so clock rollback cannot
|
|
5056
|
+
revive expired evidence. The executor compares observation identity and recording identity against
|
|
5057
|
+
its latest state atomically before I/O. An intervening observation or allocation change refuses the
|
|
5058
|
+
stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
|
|
5059
|
+
identity or observation time publish unknown and offer no issuable controls.
|
|
5060
|
+
|
|
5061
|
+
Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
|
|
5062
|
+
Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
|
|
5063
|
+
task and explicit observer-domain time. For host commands also supply the current host declaration,
|
|
5064
|
+
full host report and softphone context. The same checks must run at the executing boundary;
|
|
5065
|
+
client validation alone is not authorization. The executor serializes operations on each recorder
|
|
5066
|
+
and durably correlates request identity with scope, action and outcome: an exact retry refers to the
|
|
5067
|
+
same operation; reuse with different contents is refused. It never silently retries an ambiguous
|
|
5068
|
+
operation. Implementations without reliable request correlation must refuse controls.
|
|
5069
|
+
|
|
5070
|
+
Keep command progress in host UI separately from authoritative state. Applied means the requested
|
|
5071
|
+
transition and disposition actually completed; publish the confirming task or host report before
|
|
5072
|
+
resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
|
|
5073
|
+
show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
|
|
5074
|
+
updates remain full current task/host views, not replayed provider events. These current-state fields
|
|
5075
|
+
create no handling-history entries and infer no actors, durations or historical capture boundaries.
|
|
5076
|
+
|
|
5077
|
+
Migration is explicit: replace the old true recording capability with per-path action policies,
|
|
5078
|
+
replace untargeted commands with scoped commands, and declare protocol 2 only after implementing
|
|
5079
|
+
these semantics. Existing version-1 adapters are refused by negotiation rather than silently mapped.
|
|
5080
|
+
This change does not publish a package or enable recording in any existing host/provider by itself.
|
|
5081
|
+
|
|
5082
|
+
`validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
|
|
5083
|
+
semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
|
|
5084
|
+
retention/disposal or source truth from a status flag; those remain executor obligations.
|
|
5085
|
+
|
|
5086
|
+
Host destination selection binds storage when start actually creates a recording. Existing recordings
|
|
5087
|
+
keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
|
|
5088
|
+
The executor rejects a mismatched destination rather than moving or discarding another binding.
|
|
5089
|
+
Action permission does not authorize unattended invocation: host controls require the agent's explicit
|
|
5090
|
+
act, and provider authorization remains enforced at its authenticated command boundary.
|