@xema/omni-protocol 0.1.117 → 0.1.119
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 +52 -29
- package/dist/index.js +10 -1
- package/dist/testing.d.ts +14 -2
- package/dist/testing.js +58 -2
- package/dist/validation.d.ts +19 -9
- package/dist/validation.js +113 -85
- package/guide/agent.md +18 -1
- package/guide/phone.md +1 -1
- package/guide/voice.md +115 -109
- package/guide.md +31 -25
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -513,40 +513,61 @@ export interface TaskRecordingPolicy {
|
|
|
513
513
|
storageId: string;
|
|
514
514
|
};
|
|
515
515
|
}
|
|
516
|
-
/**
|
|
517
|
-
export type
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
516
|
+
/** Whose side of the call a recording captures: the party's channel, or the agent's own. */
|
|
517
|
+
export type RecordingSide = "party" | "agent";
|
|
518
|
+
export declare const RECORDING_SIDES: readonly ["party", "agent"];
|
|
519
|
+
/**
|
|
520
|
+
* Where a recording stands, as its recorder last stated it. `starting` is asked for and not yet
|
|
521
|
+
* confirmed. A stop or a pause in flight stays `recording` until the recorder confirms it, since
|
|
522
|
+
* telling the agent they are not recorded when they may be is the one thing this must never say.
|
|
523
|
+
*/
|
|
524
|
+
export type RecordingStatus = "starting" | "recording" | "paused" | "not-recording" | "unknown";
|
|
525
|
+
export declare const RECORDING_STATUSES: readonly ["starting", "recording", "paused", "not-recording", "unknown"];
|
|
526
|
+
/** Why a recording is paused: someone asked, or the platform paused it while sensitive details are taken. */
|
|
527
|
+
export type RecordingPausedReason = "requested" | "sensitive-details";
|
|
528
|
+
export declare const RECORDING_PAUSED_REASONS: readonly ["requested", "sensitive-details"];
|
|
529
|
+
/** Why a recording's state is unknown: its recorder cannot be reached, or a command could not be settled either way. */
|
|
530
|
+
export type RecordingUnknownReason = "recorder-unreachable" | "unsettled";
|
|
531
|
+
export declare const RECORDING_UNKNOWN_REASONS: readonly ["recorder-unreachable", "unsettled"];
|
|
532
|
+
/**
|
|
533
|
+
* One recording of one side of the call, published whenever it changes and restated whole with
|
|
534
|
+
* every publication of the task and on every snapshot. Nothing expires: the mark stands as last
|
|
535
|
+
* stated until its recorder says otherwise. `id` is the recorder's, minted when the start is
|
|
536
|
+
* accepted -- before the recorder has answered -- and kept through pause and resume; a later
|
|
537
|
+
* start is a new recording with a new id.
|
|
538
|
+
*/
|
|
539
|
+
export type Recording = {
|
|
540
|
+
id: string;
|
|
541
|
+
follows: RecordingSide;
|
|
527
542
|
} & ({
|
|
528
|
-
status: "
|
|
529
|
-
|
|
543
|
+
status: "starting" | "recording" | "not-recording";
|
|
544
|
+
reason?: never;
|
|
530
545
|
} | {
|
|
531
|
-
status: "
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
|
|
546
|
+
status: "paused";
|
|
547
|
+
reason: RecordingPausedReason;
|
|
548
|
+
} | {
|
|
549
|
+
status: "unknown";
|
|
550
|
+
reason: RecordingUnknownReason;
|
|
551
|
+
});
|
|
552
|
+
/**
|
|
553
|
+
* A start names the side to record; every other action names the recording by its id, and the
|
|
554
|
+
* recorder refuses one that no longer stands where the action needs it.
|
|
555
|
+
*/
|
|
535
556
|
export type RecordingCommand = {
|
|
536
557
|
type: "recording";
|
|
537
558
|
source: RecordingSource;
|
|
538
|
-
/** Compare-and-set against current evidence; never act on a replacement recording. */
|
|
539
|
-
observationId: string;
|
|
540
559
|
} & ({
|
|
541
560
|
action: "start";
|
|
561
|
+
follows: RecordingSide;
|
|
542
562
|
recordingId?: never;
|
|
543
563
|
} | {
|
|
544
564
|
action: "pause" | "resume" | "stop" | "cancel";
|
|
545
565
|
recordingId: string;
|
|
566
|
+
follows?: never;
|
|
546
567
|
});
|
|
547
568
|
export interface HostRecordingReport {
|
|
548
569
|
assignmentId: AssignmentId;
|
|
549
|
-
|
|
570
|
+
recordings: Recording[];
|
|
550
571
|
}
|
|
551
572
|
/** Host declaration in ConnectContext, not the provider-owned Manifest. */
|
|
552
573
|
export interface HostRecording {
|
|
@@ -834,7 +855,7 @@ export type TaskAttribute = TaskAttributeBase & ({
|
|
|
834
855
|
type: "timestamp";
|
|
835
856
|
at: IsoTimestamp;
|
|
836
857
|
});
|
|
837
|
-
export type HistoryStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
|
|
858
|
+
export type HistoryStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered" | "callback-requested";
|
|
838
859
|
/**
|
|
839
860
|
* The call record: the steps that brought the task here, one entry per occurrence and oldest
|
|
840
861
|
* first, and what they add up to before this agent -- stated by the provider from its own record,
|
|
@@ -869,8 +890,9 @@ export interface TaskHistoryStep {
|
|
|
869
890
|
*/
|
|
870
891
|
seconds?: DurationSeconds;
|
|
871
892
|
/**
|
|
872
|
-
* Who took part. Absent on `queued`, where nobody does; absent on
|
|
873
|
-
*
|
|
893
|
+
* Who took part. Absent on `queued`, where nobody does; absent on `callback-requested`, where the
|
|
894
|
+
* caller chose it in the queue, and present there when an agent arranged it; absent on any other
|
|
895
|
+
* step means the provider could not attribute it, which is a different claim and a legitimate one.
|
|
874
896
|
*/
|
|
875
897
|
by?: UserId;
|
|
876
898
|
/** On a `muted` step, and only there: whose the silence was, as the host reported it. */
|
|
@@ -1064,15 +1086,13 @@ export type Task<C extends Channel = Channel> = {
|
|
|
1064
1086
|
attributes?: TaskAttribute[];
|
|
1065
1087
|
history?: TaskHistory;
|
|
1066
1088
|
} & TaskCompletion & (C extends "voice" ? {
|
|
1067
|
-
|
|
1068
|
-
provider?: RecordingState;
|
|
1069
|
-
};
|
|
1089
|
+
recordings?: Recording[];
|
|
1070
1090
|
onCall?: OnCall[];
|
|
1071
1091
|
leadAssist?: TaskLeadAssist;
|
|
1072
1092
|
takenOver?: TaskTakenOver;
|
|
1073
1093
|
audio?: TaskAudioState;
|
|
1074
1094
|
} : {
|
|
1075
|
-
|
|
1095
|
+
recordings?: never;
|
|
1076
1096
|
onCall?: never;
|
|
1077
1097
|
leadAssist?: never;
|
|
1078
1098
|
takenOver?: never;
|
|
@@ -1878,7 +1898,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
|
|
|
1878
1898
|
occurredAt: IsoTimestamp;
|
|
1879
1899
|
event: ProviderEvent<C>;
|
|
1880
1900
|
}
|
|
1881
|
-
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.break-forced-by-provider"];
|
|
1901
|
+
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.on-hold", "omni.break-forced-by-provider"];
|
|
1882
1902
|
export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
1883
1903
|
/**
|
|
1884
1904
|
* What the host refused, told to the adapter that published it: a snapshot the host would not
|
|
@@ -2022,7 +2042,10 @@ export declare const assignmentKey: (providerId: string, assignmentId: Assignmen
|
|
|
2022
2042
|
* same provider; anything wider goes through this.
|
|
2023
2043
|
*/
|
|
2024
2044
|
export declare const userKey: (providerId: string, userId: UserId) => string;
|
|
2025
|
-
/**
|
|
2045
|
+
/**
|
|
2046
|
+
* Every history step somebody from the provider takes part in. `queued` is the one nobody does, and
|
|
2047
|
+
* `callback-requested` is the caller's choice unless an agent arranged it, so neither is here.
|
|
2048
|
+
*/
|
|
2026
2049
|
export declare const HISTORY_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
|
|
2027
2050
|
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
|
|
2028
2051
|
export declare const HISTORY_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,10 @@ export const LISTENING_MODES = ["listen", "coach", "join-call"];
|
|
|
32
32
|
export const MUTED_BY = ["host", "station"];
|
|
33
33
|
export const HOST_MUTES = ["stream", "station"];
|
|
34
34
|
export const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"];
|
|
35
|
+
export const RECORDING_SIDES = ["party", "agent"];
|
|
36
|
+
export const RECORDING_STATUSES = ["starting", "recording", "paused", "not-recording", "unknown"];
|
|
37
|
+
export const RECORDING_PAUSED_REASONS = ["requested", "sensitive-details"];
|
|
38
|
+
export const RECORDING_UNKNOWN_REASONS = ["recorder-unreachable", "unsettled"];
|
|
35
39
|
// ---------------------------------------------------------------------------
|
|
36
40
|
// Task workspace.
|
|
37
41
|
// ---------------------------------------------------------------------------
|
|
@@ -107,6 +111,8 @@ export const OMNI_FAILURE_CODES = [
|
|
|
107
111
|
"omni.break-already-committed",
|
|
108
112
|
/** A recording command the provider could not settle either way: no start, stop, pause or resume it can vouch for. */
|
|
109
113
|
"omni.recording-unsettled",
|
|
114
|
+
/** Refused because the caller is on hold: the host's Mute, or end-call, waits for resume. */
|
|
115
|
+
"omni.on-hold",
|
|
110
116
|
/** A lead asked to lift a break the platform itself imposed; the platform lifts its own. */
|
|
111
117
|
"omni.break-forced-by-provider",
|
|
112
118
|
];
|
|
@@ -158,7 +164,10 @@ export const assignmentKey = (providerId, assignmentId) => `${encodeURIComponent
|
|
|
158
164
|
* same provider; anything wider goes through this.
|
|
159
165
|
*/
|
|
160
166
|
export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(userId)}`;
|
|
161
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Every history step somebody from the provider takes part in. `queued` is the one nobody does, and
|
|
169
|
+
* `callback-requested` is the caller's choice unless an agent arranged it, so neither is here.
|
|
170
|
+
*/
|
|
162
171
|
export const HISTORY_STEPS_WITH_A_PERSON = [
|
|
163
172
|
"offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
|
|
164
173
|
];
|
package/dist/testing.d.ts
CHANGED
|
@@ -160,8 +160,13 @@ export declare class TaskStream {
|
|
|
160
160
|
*/
|
|
161
161
|
private holdCountdowns;
|
|
162
162
|
private readonly dials;
|
|
163
|
+
private readonly placedHere;
|
|
163
164
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
164
165
|
dialled(dialId: string): void;
|
|
166
|
+
/** A dial the test placed itself and the provider answered dialling: it is owed exactly one outcome. */
|
|
167
|
+
placedByTest(dialId: string): void;
|
|
168
|
+
/** The dials the test placed that never got their outcome. */
|
|
169
|
+
dialsWithoutOutcome(): string[];
|
|
165
170
|
private noteDials;
|
|
166
171
|
/**
|
|
167
172
|
* Why an event names no open task: the assignment ended, so this is the late event that must not
|
|
@@ -210,8 +215,6 @@ export declare class TeamStream {
|
|
|
210
215
|
private static ids;
|
|
211
216
|
/** A snapshot carrying a team is the team whole; one carrying none leaves the lead with none. */
|
|
212
217
|
seed(snapshot: unknown): void;
|
|
213
|
-
/** Whether the team currently carries this member, as the events left it. */
|
|
214
|
-
has(id: string): boolean;
|
|
215
218
|
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
216
219
|
}
|
|
217
220
|
export declare class BreakStream {
|
|
@@ -250,6 +253,15 @@ export declare function stillHost(report?: HostReport, guarantees?: HostGuarante
|
|
|
250
253
|
mute?: never;
|
|
251
254
|
recording?: never;
|
|
252
255
|
};
|
|
256
|
+
/**
|
|
257
|
+
* One call's history as it reaches a second task: an earlier agent's task and a later one of the same
|
|
258
|
+
* call -- a hand-over, a take-over, a callback. The later task restates every earlier step, in order,
|
|
259
|
+
* as the earlier task had it: the same step, instant, person, dial and destination, and the same
|
|
260
|
+
* duration wherever the earlier task had already stated one (a leg still open there may be closed
|
|
261
|
+
* here). Throws on the first step that disagrees, and when either task carries no history, since
|
|
262
|
+
* two absent records agree about nothing.
|
|
263
|
+
*/
|
|
264
|
+
export declare function assertHistoryAgreesAcrossTasks(earlier: Task, later: Task): void;
|
|
253
265
|
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
254
266
|
export interface BreakCandidate {
|
|
255
267
|
id: string;
|
package/dist/testing.js
CHANGED
|
@@ -689,6 +689,12 @@ export async function testAdapter(adapter, context, options = {}) {
|
|
|
689
689
|
violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
|
|
690
690
|
message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
|
|
691
691
|
}
|
|
692
|
+
// Every dial the test placed, and the provider answered dialling, is owed exactly one outcome.
|
|
693
|
+
ruleTested("stream.dialOutcome.missing");
|
|
694
|
+
for (const id of stream.dialsWithoutOutcome()) {
|
|
695
|
+
violations.push({ rule: "stream.dialOutcome.missing", path: "event.dial-outcome",
|
|
696
|
+
message: `${id} was answered dialling and never got its dial-outcome: every dial ends in exactly one, however it ended` });
|
|
697
|
+
}
|
|
692
698
|
}
|
|
693
699
|
// Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
|
|
694
700
|
// host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
|
|
@@ -915,6 +921,10 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
915
921
|
}
|
|
916
922
|
}
|
|
917
923
|
/** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
|
|
924
|
+
/** The failure code of a failed answer, as the provider stated it. */
|
|
925
|
+
function failureCode(answer) {
|
|
926
|
+
return isRecord(answer.failure) ? answer.failure.code : undefined;
|
|
927
|
+
}
|
|
918
928
|
/** Whether a published state ends the session, so its stored secrets must already be gone. */
|
|
919
929
|
function sessionEnded(state) {
|
|
920
930
|
return state.status === "signed-out" || state.status === "terminated"
|
|
@@ -1189,11 +1199,19 @@ export class TaskStream {
|
|
|
1189
1199
|
// task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
|
|
1190
1200
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
1191
1201
|
dials = new Map();
|
|
1202
|
+
placedHere = new Set();
|
|
1192
1203
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
1193
1204
|
dialled(dialId) {
|
|
1194
1205
|
if (!this.dials.has(dialId))
|
|
1195
1206
|
this.dials.set(dialId, "placed");
|
|
1196
1207
|
}
|
|
1208
|
+
/** A dial the test placed itself and the provider answered dialling: it is owed exactly one outcome. */
|
|
1209
|
+
placedByTest(dialId) {
|
|
1210
|
+
this.dialled(dialId);
|
|
1211
|
+
this.placedHere.add(dialId);
|
|
1212
|
+
}
|
|
1213
|
+
/** The dials the test placed that never got their outcome. */
|
|
1214
|
+
dialsWithoutOutcome() { return [...this.placedHere].filter(id => this.dials.get(id) === "placed"); }
|
|
1197
1215
|
noteDials(task) {
|
|
1198
1216
|
if (!isRecord(task))
|
|
1199
1217
|
return;
|
|
@@ -1856,6 +1874,7 @@ async function testOneCall(call) {
|
|
|
1856
1874
|
call.stream.dialled(dialId);
|
|
1857
1875
|
if (await send({ type: "dial", dialId }, dialId) === undefined)
|
|
1858
1876
|
return found;
|
|
1877
|
+
call.stream.placedByTest(dialId);
|
|
1859
1878
|
}
|
|
1860
1879
|
if (latestTask().phase === "preview" && await updated(t => t.phase === "in-progress" || t.phase === "completing", pressCall ? "the task leaving preview after Call" : "the task leaving preview after the provider's dial") === undefined)
|
|
1861
1880
|
return found;
|
|
@@ -1992,6 +2011,9 @@ async function testOneCall(call) {
|
|
|
1992
2011
|
if (isRecord(answer) && answer.status !== "failed") {
|
|
1993
2012
|
refuse("test.recordStep.held", "test.recordStep", "the provider recorded the host's mute begun with the caller on hold: the Mute waits for resume, and the adapter is the second gate");
|
|
1994
2013
|
}
|
|
2014
|
+
else if (isRecord(answer) && failureCode(answer) !== "omni.on-hold") {
|
|
2015
|
+
refuse("test.recordStep.held", "test.recordStep", `the provider refused the mute begun on hold as ${String(failureCode(answer))}: a refusal because the caller is on hold answers omni.on-hold, so the agent application can say resume first`);
|
|
2016
|
+
}
|
|
1995
2017
|
};
|
|
1996
2018
|
// Ending my part from hold would leave the caller in the hold with nobody coming back to them: a
|
|
1997
2019
|
// host holds it back (command.endCall.held), and an adapter that receives it anyway must refuse
|
|
@@ -2011,6 +2033,9 @@ async function testOneCall(call) {
|
|
|
2011
2033
|
// Applied, the task leaves paused; the run reads where the adapter put it rather than resuming a call it ended.
|
|
2012
2034
|
await updated(t => t.phase !== "paused", "the task after an end-call applied from hold");
|
|
2013
2035
|
}
|
|
2036
|
+
else if (isRecord(answer) && failureCode(answer) !== "omni.on-hold") {
|
|
2037
|
+
refuse("test.command.held", "test.command.end-call", `the provider refused end-call on hold as ${String(failureCode(answer))}: a refusal because the caller is on hold answers omni.on-hold, so the agent application can say resume first`);
|
|
2038
|
+
}
|
|
2014
2039
|
};
|
|
2015
2040
|
// 4. Hold and resume, where offered.
|
|
2016
2041
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
@@ -2210,8 +2235,6 @@ export class TeamStream {
|
|
|
2210
2235
|
seed(snapshot) {
|
|
2211
2236
|
this.members = TeamStream.ids(isRecord(snapshot) ? snapshot.team : undefined);
|
|
2212
2237
|
}
|
|
2213
|
-
/** Whether the team currently carries this member, as the events left it. */
|
|
2214
|
-
has(id) { return this.members?.has(id) === true; }
|
|
2215
2238
|
apply(envelope, path = "event") {
|
|
2216
2239
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
2217
2240
|
if (!isRecord(event))
|
|
@@ -2343,6 +2366,39 @@ export function memoryStore() {
|
|
|
2343
2366
|
export function stillHost(report = { online: true }, guarantees = {}, mute) {
|
|
2344
2367
|
return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
|
|
2345
2368
|
}
|
|
2369
|
+
/**
|
|
2370
|
+
* One call's history as it reaches a second task: an earlier agent's task and a later one of the same
|
|
2371
|
+
* call -- a hand-over, a take-over, a callback. The later task restates every earlier step, in order,
|
|
2372
|
+
* as the earlier task had it: the same step, instant, person, dial and destination, and the same
|
|
2373
|
+
* duration wherever the earlier task had already stated one (a leg still open there may be closed
|
|
2374
|
+
* here). Throws on the first step that disagrees, and when either task carries no history, since
|
|
2375
|
+
* two absent records agree about nothing.
|
|
2376
|
+
*/
|
|
2377
|
+
export function assertHistoryAgreesAcrossTasks(earlier, later) {
|
|
2378
|
+
const steps = (task, which) => {
|
|
2379
|
+
const history = task.history;
|
|
2380
|
+
if (history === undefined || !Array.isArray(history.steps)) {
|
|
2381
|
+
throw new Error(`The ${which} task ${task.assignmentId} carries no history to compare`);
|
|
2382
|
+
}
|
|
2383
|
+
return history.steps;
|
|
2384
|
+
};
|
|
2385
|
+
const before = steps(earlier, "earlier");
|
|
2386
|
+
const after = steps(later, "later");
|
|
2387
|
+
if (after.length < before.length) {
|
|
2388
|
+
throw new Error(`${later.assignmentId} restates ${after.length} step(s) of the call and ${earlier.assignmentId} already had ${before.length}: a later task carries every earlier step`);
|
|
2389
|
+
}
|
|
2390
|
+
before.forEach((step, index) => {
|
|
2391
|
+
const restated = after[index];
|
|
2392
|
+
for (const field of ["step", "at", "by", "dialId", "destinationId", "mutedBy"]) {
|
|
2393
|
+
if (step[field] !== restated[field]) {
|
|
2394
|
+
throw new Error(`steps[${index}].${field} is ${String(step[field])} on ${earlier.assignmentId} and ${String(restated[field])} on ${later.assignmentId}: one call has one history`);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
if (step.seconds !== undefined && restated.seconds !== step.seconds) {
|
|
2398
|
+
throw new Error(`steps[${index}].seconds is ${step.seconds} on ${earlier.assignmentId} and ${String(restated.seconds)} on ${later.assignmentId}: a stated duration does not change`);
|
|
2399
|
+
}
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2346
2402
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
2347
2403
|
/**
|
|
2348
2404
|
* A break one provider forces stops the agent everywhere: the host states a capacity of zero on
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Channel, type Phone, 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[];
|
|
@@ -198,24 +198,34 @@ export declare function validateAuthenticationFailure(failure: unknown, path?: s
|
|
|
198
198
|
*/
|
|
199
199
|
export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
|
200
200
|
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
/**
|
|
202
|
+
* One recorder's recordings for one task, as published: each with its id, the side it follows,
|
|
203
|
+
* its status, and a reason where it is paused or unknown; ids unique, and at most one recording
|
|
204
|
+
* live on each side at once.
|
|
205
|
+
*/
|
|
206
|
+
export declare function validateRecordings(value: unknown, path?: string): ProtocolViolation[];
|
|
204
207
|
export declare function validateRecordingPolicy(value: unknown, path?: string): ProtocolViolation[];
|
|
205
208
|
export declare function validateHostRecording(value: unknown, softphone: boolean, path?: string): ProtocolViolation[];
|
|
206
209
|
export declare function validateHostRecordings(value: unknown, path?: string): ProtocolViolation[];
|
|
207
210
|
export declare function validateRecordingCommandShape(value: unknown, source: "provider" | "host", path?: string): ProtocolViolation[];
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Whether the action is permitted on this recorder, and whether the recordings as last published
|
|
213
|
+
* let it act: a start on a side with nothing capturing, every other action on the recording it
|
|
214
|
+
* names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
|
|
215
|
+
*/
|
|
216
|
+
export declare function validateRecordingCommandState(command: unknown, policy: unknown, recordings: unknown, path?: string): ProtocolViolation[];
|
|
217
|
+
/** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
|
|
211
218
|
export declare function validateRecordingRequest(request: unknown, task: unknown, context: {
|
|
212
219
|
source: "provider" | "host";
|
|
213
|
-
now: number | undefined;
|
|
214
220
|
host?: unknown;
|
|
215
221
|
hostReport?: unknown;
|
|
216
222
|
softphone?: boolean;
|
|
217
223
|
/** The same provider/task context used to validate the published task. */
|
|
218
224
|
taskContext?: Omit<TaskValidationContext, "channel">;
|
|
219
225
|
}, path?: string): ProtocolViolation[];
|
|
220
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* A recording command's answer against the recordings published before and after it: applied
|
|
228
|
+
* means the recorder confirmed what was asked -- a new recording on the side for a start, the
|
|
229
|
+
* same recording in its new status for anything else -- and failed means nothing changed there.
|
|
230
|
+
*/
|
|
221
231
|
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 { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_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, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_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") {
|
|
@@ -59,6 +59,7 @@ const TEAM_AVAILABILITIES = membersOf({
|
|
|
59
59
|
});
|
|
60
60
|
const HISTORY_STEPS = membersOf({
|
|
61
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
62
|
+
"callback-requested": true,
|
|
62
63
|
});
|
|
63
64
|
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
64
65
|
const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
|
|
@@ -1214,16 +1215,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1214
1215
|
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1215
1216
|
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1216
1217
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
for (const key of Object.keys(task.recording))
|
|
1223
|
-
into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
|
|
1224
|
-
if (task.recording.provider !== undefined)
|
|
1225
|
-
into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
|
|
1226
|
-
}
|
|
1218
|
+
// The provider's own recordings, a list; the host's are on its report, never on a provider's task.
|
|
1219
|
+
into.require(task.recording === undefined, "recording.task.renamed", `${path}.recording`, "a task carries the provider's recordings as recordings, a list with the side each follows; the former recording state is not accepted");
|
|
1220
|
+
if (task.recordings !== undefined) {
|
|
1221
|
+
into.require(context.channel === "voice", "recording.task.channel", `${path}.recordings`, "recording is voice-only");
|
|
1222
|
+
into.violations.push(...validateRecordings(task.recordings, `${path}.recordings`));
|
|
1227
1223
|
}
|
|
1228
1224
|
if (context.member !== true)
|
|
1229
1225
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
@@ -2786,7 +2782,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2786
2782
|
if (!offered("recording"))
|
|
2787
2783
|
break;
|
|
2788
2784
|
const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
|
|
2789
|
-
const state =
|
|
2785
|
+
const state = task.recordings ?? [];
|
|
2790
2786
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2791
2787
|
if (command.action === "start" || command.action === "resume") {
|
|
2792
2788
|
interaction();
|
|
@@ -3163,7 +3159,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
3163
3159
|
}
|
|
3164
3160
|
return into.violations;
|
|
3165
3161
|
}
|
|
3166
|
-
//
|
|
3162
|
+
// Recordings: published on change, restated whole, never expiring; and dispatch validation.
|
|
3167
3163
|
const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3168
3164
|
const filled = (v) => typeof v === "string" && v.trim().length > 0;
|
|
3169
3165
|
function collector() {
|
|
@@ -3180,40 +3176,49 @@ function collector() {
|
|
|
3180
3176
|
};
|
|
3181
3177
|
return { violations, check, keys };
|
|
3182
3178
|
}
|
|
3183
|
-
/**
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
export function
|
|
3179
|
+
/** A recording that is, or may be, capturing: its side has no room for a second start. */
|
|
3180
|
+
const LIVE_RECORDING = ["starting", "recording", "paused", "unknown"];
|
|
3181
|
+
/**
|
|
3182
|
+
* One recorder's recordings for one task, as published: each with its id, the side it follows,
|
|
3183
|
+
* its status, and a reason where it is paused or unknown; ids unique, and at most one recording
|
|
3184
|
+
* live on each side at once.
|
|
3185
|
+
*/
|
|
3186
|
+
export function validateRecordings(value, path = "recordings") {
|
|
3191
3187
|
const { violations, check, keys } = collector();
|
|
3192
|
-
if (!
|
|
3193
|
-
check(false, "
|
|
3194
|
-
return violations;
|
|
3195
|
-
}
|
|
3196
|
-
if (value.status === "unknown") {
|
|
3197
|
-
keys(value, ["status"], path);
|
|
3188
|
+
if (!Array.isArray(value)) {
|
|
3189
|
+
check(false, "list.shape", path, "recordings are a list, empty when nothing has been recorded");
|
|
3198
3190
|
return violations;
|
|
3199
3191
|
}
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3192
|
+
const ids = new Set();
|
|
3193
|
+
const live = new Set();
|
|
3194
|
+
value.forEach((entry, index) => {
|
|
3195
|
+
const at = `${path}[${index}]`;
|
|
3196
|
+
if (!object(entry)) {
|
|
3197
|
+
check(false, "shape", at, "a recording is an object");
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
keys(entry, ["id", "follows", "status", "reason"], at);
|
|
3201
|
+
check(filled(entry.id), "id", `${at}.id`, "a recording names itself by its recorder's id");
|
|
3202
|
+
check(!ids.has(entry.id), "id.unique", `${at}.id`, "two recordings share an id");
|
|
3203
|
+
ids.add(entry.id);
|
|
3204
|
+
check(RECORDING_SIDES.includes(entry.follows), "follows", `${at}.follows`, "a recording follows the party's side or the agent's");
|
|
3205
|
+
if (!check(RECORDING_STATUSES.includes(entry.status), "status", `${at}.status`, "a recording is starting, recording, paused, not-recording or unknown"))
|
|
3206
|
+
return;
|
|
3207
|
+
const reasons = entry.status === "paused" ? RECORDING_PAUSED_REASONS
|
|
3208
|
+
: entry.status === "unknown" ? RECORDING_UNKNOWN_REASONS : undefined;
|
|
3209
|
+
if (reasons !== undefined) {
|
|
3210
|
+
check(reasons.includes(entry.reason), "reason", `${at}.reason`, `a ${String(entry.status)} recording says why: ${reasons.join(", ")}`);
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
check(entry.reason === undefined, "reason.unexpected", `${at}.reason`, "only a paused or unknown recording carries a reason");
|
|
3214
|
+
}
|
|
3215
|
+
if (LIVE_RECORDING.includes(entry.status)) {
|
|
3216
|
+
check(!live.has(entry.follows), "follows.live", `${at}.follows`, `the ${String(entry.follows)} side already has a recording that is, or may be, capturing`);
|
|
3217
|
+
live.add(entry.follows);
|
|
3218
|
+
}
|
|
3219
|
+
});
|
|
3209
3220
|
return violations;
|
|
3210
3221
|
}
|
|
3211
|
-
/** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
|
|
3212
|
-
export function effectiveRecordingState(state, now) {
|
|
3213
|
-
if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
|
|
3214
|
-
return { status: "unknown" };
|
|
3215
|
-
return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
|
|
3216
|
-
}
|
|
3217
3222
|
export function validateRecordingPolicy(value, path = "capabilities.recording") {
|
|
3218
3223
|
const { violations, check, keys } = collector();
|
|
3219
3224
|
if (!object(value)) {
|
|
@@ -3286,12 +3291,12 @@ export function validateHostRecordings(value, path = "host.recordings") {
|
|
|
3286
3291
|
check(false, "report.shape", at, "expected scoped report");
|
|
3287
3292
|
return;
|
|
3288
3293
|
}
|
|
3289
|
-
keys(report, ["assignmentId", "
|
|
3294
|
+
keys(report, ["assignmentId", "recordings"], at);
|
|
3290
3295
|
check(filled(report.assignmentId), "report.scope", at, "report identifies the assignment");
|
|
3291
3296
|
const key = JSON.stringify([report.assignmentId]);
|
|
3292
|
-
check(!seen.has(key), "report.duplicate", at, "one host
|
|
3297
|
+
check(!seen.has(key), "report.duplicate", at, "one host report per assignment");
|
|
3293
3298
|
seen.add(key);
|
|
3294
|
-
violations.push(...
|
|
3299
|
+
violations.push(...validateRecordings(report.recordings, `${at}.recordings`));
|
|
3295
3300
|
});
|
|
3296
3301
|
return violations;
|
|
3297
3302
|
}
|
|
@@ -3301,50 +3306,62 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
3301
3306
|
check(false, "command.shape", path, "expected recording command");
|
|
3302
3307
|
return violations;
|
|
3303
3308
|
}
|
|
3304
|
-
|
|
3309
|
+
const starting = value.action === "start";
|
|
3310
|
+
keys(value, ["type", "source", "action", starting ? "follows" : "recordingId"], path);
|
|
3305
3311
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
3306
3312
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
3307
|
-
|
|
3308
|
-
check(
|
|
3313
|
+
if (starting)
|
|
3314
|
+
check(RECORDING_SIDES.includes(value.follows), "command.follows", `${path}.follows`, "a start names the side to record: party or agent");
|
|
3315
|
+
else
|
|
3316
|
+
check(filled(value.recordingId), "command.identity", `${path}.recordingId`, "every action but start names the recording by its id");
|
|
3309
3317
|
return violations;
|
|
3310
3318
|
}
|
|
3311
3319
|
/** One transition table shared by dispatch checks and outcome checks. */
|
|
3312
3320
|
const RECORDING_TRANSITIONS = {
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
cancel: { from: ["active", "paused"], to: "inactive" },
|
|
3321
|
+
pause: { from: ["recording"], to: "paused" },
|
|
3322
|
+
resume: { from: ["paused"], to: "recording" },
|
|
3323
|
+
stop: { from: ["recording", "paused"], to: "not-recording" },
|
|
3324
|
+
cancel: { from: ["recording", "paused"], to: "not-recording" },
|
|
3318
3325
|
};
|
|
3319
3326
|
function recordingTransition(action) {
|
|
3320
3327
|
return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
|
|
3321
3328
|
? RECORDING_TRANSITIONS[action] : undefined;
|
|
3322
3329
|
}
|
|
3323
|
-
|
|
3324
|
-
|
|
3330
|
+
const entries = (value) => Array.isArray(value) ? value.filter(object) : [];
|
|
3331
|
+
/**
|
|
3332
|
+
* Whether the action is permitted on this recorder, and whether the recordings as last published
|
|
3333
|
+
* let it act: a start on a side with nothing capturing, every other action on the recording it
|
|
3334
|
+
* names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
|
|
3335
|
+
*/
|
|
3336
|
+
export function validateRecordingCommandState(command, policy, recordings, path = "command") {
|
|
3325
3337
|
const { violations, check } = collector();
|
|
3326
3338
|
if (!object(command)) {
|
|
3327
3339
|
check(false, "command.shape", path, "expected recording command");
|
|
3328
3340
|
return violations;
|
|
3329
3341
|
}
|
|
3330
|
-
|
|
3331
|
-
if (transition === undefined) {
|
|
3342
|
+
if (!RECORDING_ACTIONS.includes(command.action)) {
|
|
3332
3343
|
check(false, "command.action", `${path}.action`, "unsupported recording action");
|
|
3333
3344
|
return violations;
|
|
3334
3345
|
}
|
|
3335
3346
|
const permitted = object(policy) && policy[command.action] === true;
|
|
3336
3347
|
check(permitted, "command.permission", path, "task does not permit this action on this recorder");
|
|
3337
|
-
if (
|
|
3338
|
-
check(false, "command.state", path, "
|
|
3348
|
+
if (recordings !== undefined && validateRecordings(recordings).length) {
|
|
3349
|
+
check(false, "command.state", path, "the recordings as published are malformed");
|
|
3350
|
+
return violations;
|
|
3351
|
+
}
|
|
3352
|
+
const list = entries(recordings);
|
|
3353
|
+
if (command.action === "start") {
|
|
3354
|
+
check(!list.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "command.state", path, `the ${String(command.follows)} side already has a recording that is, or may be, capturing`);
|
|
3339
3355
|
return violations;
|
|
3340
3356
|
}
|
|
3341
|
-
|
|
3342
|
-
check(
|
|
3343
|
-
|
|
3344
|
-
|
|
3357
|
+
const target = list.find(entry => entry.id === command.recordingId);
|
|
3358
|
+
if (!check(target !== undefined, "command.recordingId", path, "no recording on this task has that id"))
|
|
3359
|
+
return violations;
|
|
3360
|
+
const transition = recordingTransition(command.action);
|
|
3361
|
+
check(transition.from.includes(target.status), "command.state", path, `${String(command.action)} needs the recording ${transition.from.join(" or ")}, and it is ${String(target.status)}`);
|
|
3345
3362
|
return violations;
|
|
3346
3363
|
}
|
|
3347
|
-
/** Validate immediately before dispatch
|
|
3364
|
+
/** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
|
|
3348
3365
|
export function validateRecordingRequest(request, task, context, path = "request") {
|
|
3349
3366
|
const { violations, check, keys } = collector();
|
|
3350
3367
|
if (!object(request) || !object(task)) {
|
|
@@ -3362,58 +3379,69 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3362
3379
|
const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
|
|
3363
3380
|
violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
|
|
3364
3381
|
const policy = object(caps) ? caps[context.source] : undefined;
|
|
3365
|
-
let
|
|
3382
|
+
let recordings = task.recordings ?? [];
|
|
3366
3383
|
if (context.source === "host") {
|
|
3367
3384
|
violations.push(...validateHostRecording(context.host, context.softphone === true));
|
|
3368
3385
|
check(object(context.host), "host.required", path, "host did not declare recording support");
|
|
3369
3386
|
const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
|
|
3370
3387
|
violations.push(...validateHostRecordings(reports));
|
|
3371
|
-
|
|
3388
|
+
const report = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId) : undefined;
|
|
3389
|
+
recordings = object(report) ? report.recordings : [];
|
|
3372
3390
|
if (object(context.host)) {
|
|
3373
3391
|
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
3374
3392
|
check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
|
|
3375
3393
|
}
|
|
3376
3394
|
}
|
|
3377
|
-
|
|
3378
|
-
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
3379
|
-
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
3395
|
+
violations.push(...validateRecordingCommandState(command, policy, recordings, `${path}.command`));
|
|
3380
3396
|
const opening = command.action === "start" || command.action === "resume";
|
|
3381
3397
|
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
|
|
3382
3398
|
return violations;
|
|
3383
3399
|
}
|
|
3384
|
-
/**
|
|
3400
|
+
/**
|
|
3401
|
+
* A recording command's answer against the recordings published before and after it: applied
|
|
3402
|
+
* means the recorder confirmed what was asked -- a new recording on the side for a start, the
|
|
3403
|
+
* same recording in its new status for anything else -- and failed means nothing changed there.
|
|
3404
|
+
*/
|
|
3385
3405
|
export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
|
|
3386
3406
|
const { violations, check } = collector();
|
|
3387
|
-
violations.push(...
|
|
3388
|
-
if (!object(command) || !
|
|
3389
|
-
check(false, "outcome.shape", path, "command and both
|
|
3407
|
+
violations.push(...validateRecordings(before, `${path}.before`), ...validateRecordings(after, `${path}.after`));
|
|
3408
|
+
if (!object(command) || !Array.isArray(before) || !Array.isArray(after)) {
|
|
3409
|
+
check(false, "outcome.shape", path, "command and both lists of recordings required");
|
|
3390
3410
|
return violations;
|
|
3391
3411
|
}
|
|
3392
3412
|
violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
|
|
3393
|
-
// A rejected promise has no result: any
|
|
3413
|
+
// A rejected promise has no result: any state its recorder publishes may follow it.
|
|
3394
3414
|
if (result === undefined)
|
|
3395
3415
|
return violations;
|
|
3396
3416
|
violations.push(...validateResult(result, "execute", `${path}.result`));
|
|
3397
3417
|
if (!object(result))
|
|
3398
3418
|
return violations;
|
|
3399
3419
|
check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
|
|
3420
|
+
const prior = entries(before);
|
|
3421
|
+
const next = entries(after);
|
|
3422
|
+
if (command.action === "start") {
|
|
3423
|
+
const fresh = next.filter(entry => entry.follows === command.follows && !prior.some(old => old.id === entry.id));
|
|
3424
|
+
if (result.status === "applied") {
|
|
3425
|
+
check(fresh.some(entry => entry.status === "recording"), "outcome.state", path, "an applied start shows a new recording on that side, recording");
|
|
3426
|
+
check(!prior.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "outcome.origin", path, "applied cannot claim a start on a side already capturing");
|
|
3427
|
+
}
|
|
3428
|
+
else if (result.status === "failed") {
|
|
3429
|
+
check(!fresh.some(entry => LIVE_RECORDING.includes(entry.status)), "outcome.noEffect", path, "a failed start leaves nothing capturing on that side");
|
|
3430
|
+
}
|
|
3431
|
+
return violations;
|
|
3432
|
+
}
|
|
3433
|
+
const was = prior.find(entry => entry.id === command.recordingId);
|
|
3434
|
+
const now = next.find(entry => entry.id === command.recordingId);
|
|
3400
3435
|
if (result.status === "failed") {
|
|
3401
|
-
check(
|
|
3436
|
+
check(was !== undefined && now !== undefined && now.status === was.status, "outcome.noEffect", path, "failed confirms no effect: the recording stands as it was; a partial effect is unknown, unsettled");
|
|
3402
3437
|
}
|
|
3403
3438
|
else if (result.status === "applied") {
|
|
3404
3439
|
const transition = recordingTransition(command.action);
|
|
3405
3440
|
if (transition === undefined)
|
|
3406
3441
|
return violations; // shape validation already reported the unknown action
|
|
3407
|
-
check(
|
|
3408
|
-
check(
|
|
3409
|
-
check(
|
|
3410
|
-
check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
3411
|
-
if (command.action !== "start")
|
|
3412
|
-
check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
|
|
3413
|
-
if (command.action === "pause" || command.action === "resume")
|
|
3414
|
-
check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
|
|
3415
|
-
if (instant(before.observedAt) && instant(after.observedAt))
|
|
3416
|
-
check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
|
|
3442
|
+
check(now !== undefined, "outcome.continuity", path, "the recording keeps its id through the action");
|
|
3443
|
+
check(now?.status === transition.to, "outcome.state", path, `applied requires the recording ${transition.to}`);
|
|
3444
|
+
check(was !== undefined && transition.from.includes(was.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
3417
3445
|
}
|
|
3418
3446
|
return violations;
|
|
3419
3447
|
}
|
package/guide/agent.md
CHANGED
|
@@ -384,6 +384,9 @@ Migration from the earlier spellings:
|
|
|
384
384
|
| the phone unspoken | `PhoneState` -- the device, its mute, its channels -- on `Snapshot.phone`, `phone-updated` and `TeamMember.phone`, from a manifest declaring `phoneStatus` (`snapshot.phone.required`, `.unexpected`, `phone.*`, `phone.channel.*`, `event.phone.capability`) |
|
|
385
385
|
| the agent's readiness for the next call read into setCapacity | the agent's own queue: `capabilities.nextCall`, `requestNextCall`/`cancelNextCall`/`releaseLinedUp`, `Snapshot.nextCall` and `linedUp`, the `next-call` and `lined-up` events (`snapshot.nextCall.idle`, `nextCall.*`, `linedUp.*`) |
|
|
386
386
|
| a harness that exercised an adapter, drove a call and evaluated rules | the harness reads as a test: `testAdapter`, `withCall`, `timeoutMs`, `notTested`, `rulesTested`, rules `test.*` (`test.timeout`, `test.offer.expired`, `test.preview.deadline`, ...) |
|
|
387
|
+
| a `callback-requested` step, and a callback task starting its history afresh | a `callback-requested` step (`by` when an agent arranged it); a callback's task carries the earlier call's steps, oldest first |
|
|
388
|
+
| a recording state with an observation id, an observation instant and a validity window, and the helpers that aged it | `recordings` on the task and on `HostRecordingReport`: a list, each recording with `id`, `follows` (`party` or `agent`), `status` (`starting`, `recording`, `paused`, `not-recording`, `unknown`) and a `reason` where paused or unknown; nothing expires; commands name `follows` for a start and `recordingId` otherwise; `validateRecordings` (`recording.task.renamed` refuses the former field) |
|
|
389
|
+
| a refusal on hold under `omni.command-not-permitted` | `omni.on-hold` |
|
|
387
390
|
| an `end-call` while the caller was on hold | refused: a `paused` task is resumed first (`command.endCall.held`), the agent computer shows End call disabled on hold, the provider answers `failed`, and the test names an adapter that ends from hold (`test.command.held`); `terminate-call` is not gated, it ends the caller's channel too |
|
|
388
391
|
| the handset spelled by its place on a desk | `hardphone`, on the manifest's `phones` and the login's `phone`; the former spelling is refused |
|
|
389
392
|
| the desk | the agent computer, in the guide's prose |
|
|
@@ -426,7 +429,20 @@ something else, which will arrive under its own name and must not be folded in h
|
|
|
426
429
|
It rides in the snapshot and is replaced whole like everything else there.
|
|
427
430
|
|
|
428
431
|
Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
|
|
429
|
-
`unanswered`, and each is defined on `TaskHistoryStep`.
|
|
432
|
+
`unanswered`, `callback-requested`, and each is defined on `TaskHistoryStep`.
|
|
433
|
+
|
|
434
|
+
**A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
|
|
435
|
+
arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
|
|
436
|
+
is absent when the caller chose it in the queue. The callback reaches an agent as a new task, and that
|
|
437
|
+
task's history carries the earlier call's steps, oldest first, the `callback-requested` step among them,
|
|
438
|
+
then the callback's own -- as a hand-over carries the steps before it. The agent opening a callback
|
|
439
|
+
sees that the caller waited and asked, or whose promise they are keeping, and a second try reads as
|
|
440
|
+
one, the way contact-centre platforms keep a callback with its original contact.
|
|
441
|
+
|
|
442
|
+
**One call has one history.** Every task the call reaches restates the earlier steps exactly as they
|
|
443
|
+
stood: the same step, instant, person, dial and destination, and a duration once stated never
|
|
444
|
+
changes. `assertHistoryAgreesAcrossTasks(earlier, later)` holds two tasks of one call to that, for an
|
|
445
|
+
adopter's own test that drives a hand-over, take-over or callback.
|
|
430
446
|
|
|
431
447
|
**The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
|
|
432
448
|
when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
|
|
@@ -1099,6 +1115,7 @@ react rather than only display the message:
|
|
|
1099
1115
|
| `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
|
|
1100
1116
|
| `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
|
|
1101
1117
|
| `omni.recording-unsettled` | A recording command the provider could not settle either way: a settled `failed`, never a promise left unresolved. See **Independent task recording** in `guide/voice.md`. |
|
|
1118
|
+
| `omni.on-hold` | Refused because the caller is on hold: the agent application's own Mute begun on a `paused` task, or `end-call` there. The agent application says resume first. The test expects this code on both (`test.recordStep.held`, `test.command.held`). |
|
|
1102
1119
|
| `omni.break-forced-by-provider` | A lead asked to lift a break the platform imposed; the platform lifts its own. See **Forced breaks** in `guide/breaks.md`. |
|
|
1103
1120
|
|
|
1104
1121
|
They are published as `OMNI_FAILURE_CODES`.
|
package/guide/phone.md
CHANGED
|
@@ -238,7 +238,7 @@ softphone the agent application's own Mute is unavailable while the task is `pau
|
|
|
238
238
|
promised at login (`muteUnavailableOnHold`): mute then hold is ordinary, and the muted leg runs on
|
|
239
239
|
across the held one; hold then mute is not, and a `muted` leg with `mutedBy: "host"` beginning on a
|
|
240
240
|
paused task is held back by the agent application (`historyReport.muted.held`) and refused by a
|
|
241
|
-
provider that receives one anyway. The test sends one past the validator while the task is held
|
|
241
|
+
provider that receives one anyway, `failed` with `omni.on-hold`. The test sends one past the validator while the task is held
|
|
242
242
|
and names an adapter that records it (`test.recordStep.held`). A station mute the agent
|
|
243
243
|
application observes on hold -- the headset's slider, the operating system -- is a fact, reported
|
|
244
244
|
with `mutedBy: "station"`, and stands. The agent application reports every such period
|
package/guide/voice.md
CHANGED
|
@@ -262,7 +262,7 @@ provider decides: another IVR, a queue, a survey, or the end of the call. Gated
|
|
|
262
262
|
with nobody coming back to them, and the provider's path for a caller who continues never begins.
|
|
263
263
|
So the caller is taken off hold first: the agent computer holds the command back while the task is `paused`
|
|
264
264
|
(`command.endCall.held`) and shows End call disabled on hold, and a provider that receives it
|
|
265
|
-
anyway answers `failed
|
|
265
|
+
anyway answers `failed` with `omni.on-hold`, so the agent application can say resume first. Resume, then end. `terminate-call` needs no such gate: it ends the
|
|
266
266
|
caller's channel with the rest, held or not, and nobody is left waiting.
|
|
267
267
|
|
|
268
268
|
**`terminate-call` ends the whole call.** Every channel on it ends at the provider: the agent's,
|
|
@@ -463,114 +463,120 @@ agent's.
|
|
|
463
463
|
## Independent task recording
|
|
464
464
|
|
|
465
465
|
Recording is voice-only. A task can arrive already recording, including while pending, and offer
|
|
466
|
-
no recording controls.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
an unknown storage place or an unsupported action is refused visibly, never redirected to provider
|
|
480
|
-
recording or a default upload location. Initial agent application support requires voice softphone audio and
|
|
481
|
-
capture of both local and remote audio; an agent application unable to capture either must refuse start/resume.
|
|
482
|
-
This package declares that contract; it supplies no recorder, audio mixing, storage or upload.
|
|
483
|
-
|
|
484
|
-
| Action | Required current recording state | Confirmed outcome |
|
|
485
|
-
| --- | --- | --- |
|
|
486
|
-
| start | inactive | active with a new recording identity |
|
|
487
|
-
| pause | active | paused, preserving recording identity and captured audio |
|
|
488
|
-
| resume | paused | active with the same recording identity |
|
|
489
|
-
| stop | active or paused | inactive; finish and retain captured audio |
|
|
490
|
-
| cancel | active or paused | inactive; abandon and discard captured audio |
|
|
491
|
-
|
|
492
|
-
Stop finishes the recording and retains captured audio. Cancel abandons the recording and discards
|
|
493
|
-
its captured audio; the task policy offers it with `cancel: true`. There is no configurable cancel
|
|
494
|
-
effect. A recorder that cannot confirm completion must not offer Cancel; it can offer Stop instead.
|
|
495
|
-
Cancel never means cancelling an in-flight start request or deleting arbitrary past recordings.
|
|
496
|
-
Stop can be applied only after finalization/retention succeeds; Cancel only after both cessation
|
|
497
|
-
and completion of this recording's audio succeed under the recorder's storage contract.
|
|
498
|
-
Partial success -- capture stopped but the storage outcome unknown, a start the recorder cannot
|
|
499
|
-
vouch for either way -- is a settled `failed`, under the code `omni.recording-unsettled`: the
|
|
500
|
-
command did not do what it promised, and the state the provider publishes with it is whatever
|
|
501
|
-
capture state is actually known. It is never `applied`, and never an unsettled promise the agent
|
|
502
|
-
application would be left to resync, since the provider knows exactly what it could not settle.
|
|
503
|
-
There is no request identity on a recording command: the outcome is the state, and what the
|
|
504
|
-
agent sees is the state. Retention is not a promise of sample-perfect audio.
|
|
505
|
-
|
|
506
|
-
Provider commands go exclusively to `Connection.execute`; agent application commands go exclusively to
|
|
507
|
-
`HostRecording.execute`. Both include the assignment and observation identities; all
|
|
508
|
-
non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
|
|
509
|
-
task and recorder owner. They are never inferred from filenames or current agent identity. A
|
|
510
|
-
recording ID survives pause/resume and reassignment only where the same recorder confirms continuity;
|
|
511
|
-
commands always name the current assignment. A later start gets a different ID. Reconnect does not
|
|
512
|
-
create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
|
|
513
|
-
There is no automatic restart, hand-over to another recorder, or stop on task hold/disconnect.
|
|
514
|
-
Task removal does not prove recording stopped: outstanding agent application recorders remain tracked by the
|
|
515
|
-
agent application until its executor reconciles/finishes them, with visible unresolved cleanup failures.
|
|
516
|
-
|
|
517
|
-
Only start/resume require an in-progress or paused task with started audio. Pause/stop/cancel may
|
|
518
|
-
also finish an independently observed recorder while the task is completing. A pending task may
|
|
519
|
-
show active recording, but agent controls wait until interaction begins. The two recording paths may
|
|
520
|
-
both be active. A command to either path has no implied effect on the other.
|
|
521
|
-
|
|
522
|
-
Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
|
|
523
|
-
observation instant and an exclusive expiry. These times describe current evidence, never historical
|
|
524
|
-
capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
|
|
525
|
-
Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
|
|
526
|
-
Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
|
|
527
|
-
consumers using the explicit recording-expiry clock-estimation exception must invalidate that
|
|
528
|
-
estimate and use monotonic aging so clock rollback cannot
|
|
529
|
-
revive expired evidence. The executor compares observation identity and recording identity against
|
|
530
|
-
its latest state atomically before I/O. An intervening observation or assignment change refuses the
|
|
531
|
-
stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
|
|
532
|
-
identity or observation time publish unknown and offer no issuable controls.
|
|
533
|
-
|
|
534
|
-
Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
|
|
535
|
-
Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
|
|
536
|
-
task and explicit observer-domain time. For agent application commands also supply the current agent application declaration,
|
|
537
|
-
full agent application report and softphone context. The same checks must run at the executing boundary;
|
|
538
|
-
client validation alone is not authorization. The executor serializes operations on each recorder
|
|
539
|
-
and compares `observationId` and `recordingId` against its latest evidence before acting: a
|
|
540
|
-
command against superseded evidence is refused, and nothing is retried on the executor's own
|
|
541
|
-
account.
|
|
542
|
-
|
|
543
|
-
Keep command progress in agent application UI separately from authoritative state. Applied means the requested
|
|
544
|
-
recording transition and any retain/discard effect actually completed; publish the confirming task or agent application report before
|
|
545
|
-
resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
|
|
546
|
-
show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
|
|
547
|
-
updates remain full current task/agent application views, not replayed provider events. These current-state fields
|
|
548
|
-
create no interaction-history entries and infer no actors, durations or historical capture boundaries.
|
|
549
|
-
|
|
550
|
-
Migration is explicit: replace the old true recording capability with per-path action policies,
|
|
551
|
-
replace untargeted commands with scoped commands, and retain protocol version 1. During pre-release
|
|
552
|
-
development, hosts and adapters must align their recording contracts; negotiation does not detect
|
|
553
|
-
differences between package revisions that share that protocol number. No legacy-shape fallback is provided.
|
|
554
|
-
This change does not publish a package or enable recording in any existing agent application/provider by itself.
|
|
555
|
-
|
|
556
|
-
`validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
|
|
557
|
-
semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
|
|
558
|
-
retention/completion or source truth from a status flag; those remain executor obligations.
|
|
559
|
-
|
|
560
|
-
The storage place named on the task is bound when start actually creates a recording. Existing recordings
|
|
561
|
-
keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
|
|
562
|
-
The executor rejects a mismatched storage place rather than moving or discarding another binding.
|
|
563
|
-
Action permission does not authorize unattended invocation: agent application controls require the agent's explicit
|
|
564
|
-
act, and provider authorization remains enforced at its authenticated command boundary.
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
Recording dispatch must receive the same known task-validation context as the published task.
|
|
568
|
-
Pass organisation levels and other known restrictions through the optional fourth argument of
|
|
569
|
-
`validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`. This keeps a valid
|
|
570
|
-
custom organisation lock from being rejected against the default ladder, and prevents known
|
|
571
|
-
capability restrictions from disappearing at dispatch. The standalone `validateRecordingCommandState`
|
|
572
|
-
requires an affirmative permission; false, malformed permission declarations and unknown actions grant nothing.
|
|
466
|
+
no recording controls. **Each recorder publishes its own recordings, as a list, whenever one
|
|
467
|
+
changes.** The provider's are the task's `recordings`, restated whole on every publication of the task
|
|
468
|
+
and on every snapshot. The agent application's are on its `HostReport.recordings`, one entry per
|
|
469
|
+
assignment, never on a provider's task. A provider task update cannot replace the agent
|
|
470
|
+
application's recordings, and a command to one recorder has no effect on the other.
|
|
471
|
+
|
|
472
|
+
```json
|
|
473
|
+
"recordings": [
|
|
474
|
+
{ "id": "rec-7", "follows": "party", "status": "recording" },
|
|
475
|
+
{ "id": "rec-8", "follows": "agent", "status": "paused", "reason": "sensitive-details" },
|
|
476
|
+
{ "id": "rec-3", "follows": "party", "status": "not-recording" }
|
|
477
|
+
]
|
|
478
|
+
```
|
|
573
479
|
|
|
480
|
+
**A recording follows one side of the call.** `follows` says whose channel it captures: the
|
|
481
|
+
`party`'s, or the `agent`'s own. The two are separate captures on most platforms, and the agent
|
|
482
|
+
computer needs to know which: a recording that follows the party keeps capturing hold music
|
|
483
|
+
while the agent talks to a colleague, and only one that follows the agent says whether the
|
|
484
|
+
agent's own voice is on it. Each side has at most one recording that is, or may be, capturing at
|
|
485
|
+
once -- `starting`, `recording`, `paused` or `unknown` (`recording.follows.live`) -- and any number
|
|
486
|
+
that have finished.
|
|
487
|
+
|
|
488
|
+
**Nothing expires.** A recording stands as its recorder last stated it until the recorder says
|
|
489
|
+
otherwise. There is no observation time, no validity window and no clock to compare: everything
|
|
490
|
+
the agent computer shows comes from the wire, and a recording mark that went dark when a recorder
|
|
491
|
+
check ran late, then came back, was a fault reported from the floor. The recorder publishes on
|
|
492
|
+
change and restates the whole list on connect and resume.
|
|
493
|
+
|
|
494
|
+
| Status | Meaning |
|
|
495
|
+
| --- | --- |
|
|
496
|
+
| `starting` | Asked for and not yet confirmed by the recorder. |
|
|
497
|
+
| `recording` | Capturing. A pause or a stop still in flight stays `recording` until the recorder confirms it: telling the agent they are not recorded when they may be is the one thing this must never say. |
|
|
498
|
+
| `paused` | Open but not capturing, with a `reason`: `requested`, someone asked for it, or `sensitive-details`, the platform paused it while card or account details are taken. |
|
|
499
|
+
| `not-recording` | Finished, or never started on that side; captured audio is kept or discarded as its stop or cancel said. |
|
|
500
|
+
| `unknown` | The recorder cannot say, with a `reason`: `recorder-unreachable`, or `unsettled`, a command whose effect it could not establish either way. Never shown as stopped. |
|
|
501
|
+
|
|
502
|
+
A mark that is not lit says why: `paused` and `unknown` carry a reason from those closed lists, and
|
|
503
|
+
nothing else carries one (`recording.reason`, `.reason.unexpected`). Task hold, task pause and
|
|
504
|
+
recording pause are independent.
|
|
505
|
+
|
|
506
|
+
**Permission is per task.** The policy is `Task.capabilities.recording`, with independently
|
|
507
|
+
optional provider and agent application action sets. The outer capability may be locked. Absence
|
|
508
|
+
grants no permission, and a recording may exist without any permission. A provider may offer only
|
|
509
|
+
stop for a recording started automatically, or withdraw a control on a later task update. There is
|
|
510
|
+
no global recording mode and no automatic start from a capability declaration. Agent application
|
|
511
|
+
support is declared on `ConnectContext.host.recording`, not the provider-owned manifest. The task
|
|
512
|
+
names one of the storage places the agent application provisioned, by `storageId`; an unknown
|
|
513
|
+
storage place or an unsupported action is refused visibly, never redirected to provider recording
|
|
514
|
+
or a default upload location. Initial agent application support requires voice softphone audio and
|
|
515
|
+
capture of both local and remote audio; an agent application unable to capture either must refuse
|
|
516
|
+
start/resume. This package declares that contract; it supplies no recorder, audio mixing, storage
|
|
517
|
+
or upload.
|
|
518
|
+
|
|
519
|
+
**A command names what it acts on.** A start names the side, `follows`; every other action names
|
|
520
|
+
the recording by its `recordingId`. The recorder mints the id when it accepts the start, before
|
|
521
|
+
the recorder has answered, publishes the recording `starting` under it, and keeps it through pause
|
|
522
|
+
and resume; a later start is a new recording with a new id. The recorder serializes the operations
|
|
523
|
+
on each recording and refuses an action the recording no longer stands for: a pause on a recording
|
|
524
|
+
that is not `recording`, a stop on one already `not-recording`, a start on a side already
|
|
525
|
+
capturing, a recording the task does not carry (`recording.command.state`,
|
|
526
|
+
`.command.recordingId`). Nothing is retried on the recorder's own account.
|
|
527
|
+
|
|
528
|
+
| Action | The recording before | Confirmed outcome |
|
|
529
|
+
| --- | --- | --- |
|
|
530
|
+
| start | none live on that side | a new recording on that side, `recording` |
|
|
531
|
+
| pause | `recording` | the same recording, `paused`, captured audio kept |
|
|
532
|
+
| resume | `paused` | the same recording, `recording` |
|
|
533
|
+
| stop | `recording` or `paused` | the same recording, `not-recording`; captured audio finished and kept |
|
|
534
|
+
| cancel | `recording` or `paused` | the same recording, `not-recording`; captured audio discarded |
|
|
535
|
+
|
|
536
|
+
Stop finishes the recording and retains captured audio. Cancel abandons it and discards captured
|
|
537
|
+
audio; the task policy offers it with `cancel: true`, and there is no configurable cancel effect.
|
|
538
|
+
A recorder that cannot confirm completion must not offer Cancel; it can offer Stop instead. Cancel
|
|
539
|
+
never means cancelling an in-flight start or deleting past recordings. Stop is applied only after
|
|
540
|
+
finalization and retention succeed; Cancel only after both cessation and discarding succeed.
|
|
541
|
+
**Applied means confirmed**: the recorder publishes the confirming list before it answers
|
|
542
|
+
`applied`. Failed means confirmed no effect. Partial success -- capture stopped but the storage
|
|
543
|
+
outcome unknown, a start the recorder cannot vouch for either way -- is a settled `failed` under
|
|
544
|
+
`omni.recording-unsettled`, and the recording is published `unknown` with the reason `unsettled`.
|
|
545
|
+
A rejected promise means the outcome is unknown: show the failure, reconcile, and do not retry or
|
|
546
|
+
pretend `not-recording`. There is no request identity on a recording command: the outcome is the
|
|
547
|
+
recording, and what the agent sees is the recording. Retention is not a promise of sample-perfect
|
|
548
|
+
audio.
|
|
549
|
+
|
|
550
|
+
Provider commands go only to `Connection.execute`; agent application commands go only to
|
|
551
|
+
`HostRecording.execute`. Both name the assignment. Ids are opaque and scoped by provider login,
|
|
552
|
+
task and recorder; they are never inferred from filenames or the current agent. Reconnect creates
|
|
553
|
+
no recording; the recorder restates its list. There is no automatic restart, hand-over to another
|
|
554
|
+
recorder, or stop on task hold or disconnect. Task removal does not prove recording stopped:
|
|
555
|
+
outstanding agent application recordings stay tracked by the agent application until its recorder
|
|
556
|
+
finishes them, with unresolved cleanup failures visible.
|
|
557
|
+
|
|
558
|
+
Only start and resume need an `in-progress` or `paused` task with started audio. Pause, stop and
|
|
559
|
+
cancel may also finish a recording while the task is `completing`. A pending task may show a
|
|
560
|
+
recording, but agent controls wait until interaction begins.
|
|
561
|
+
|
|
562
|
+
Use `validateTask` and `validateTaskCommand` for the published shape and static permissions.
|
|
563
|
+
Immediately before dispatch, also use `validateRecordingRequest` against the full current task;
|
|
564
|
+
for agent application commands supply the current declaration, full report and softphone context.
|
|
565
|
+
The same checks run at the recorder; client validation alone is not authorization.
|
|
566
|
+
`validateRecordings` checks one recorder's list, and `validateRecordingOutcome` checks an answer
|
|
567
|
+
against the lists published before and after it: an applied start shows a new recording on its side,
|
|
568
|
+
anything else the same recording in its new status, and a failed one nothing changed there. It
|
|
569
|
+
cannot prove retention or source truth from a status; those remain the recorder's obligations. The
|
|
570
|
+
storage place named on the task is bound when start creates a recording, and later policy cannot
|
|
571
|
+
redirect it.
|
|
572
|
+
|
|
573
|
+
Recording dispatch receives the same known task-validation context as the published task: pass
|
|
574
|
+
organisation levels and other known restrictions through the optional fourth argument of
|
|
575
|
+
`validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`, so a valid custom
|
|
576
|
+
organisation lock is not rejected against the default ladder. Migration is explicit: the former
|
|
577
|
+
`recording` state with its observation and validity window is refused on a task
|
|
578
|
+
(`recording.task.renamed`), and a command carries no observation id. Protocol version 1 stays;
|
|
579
|
+
there is no legacy-shape fallback.
|
|
574
580
|
|
|
575
581
|
### Agent application recording announcements to the caller
|
|
576
582
|
|
|
@@ -590,7 +596,7 @@ cancel, they hear that recording stopped. If pause/resume is supported, say paus
|
|
|
590
596
|
pause is not presented as a finished recording. Announcements follow actual capture transitions,
|
|
591
597
|
not button clicks, accepted requests or task-policy changes. When attaching to an already-active
|
|
592
598
|
agent application recording, announce that recording is active, without inventing its original start time.
|
|
593
|
-
|
|
599
|
+
Restating the same status does not repeat an announcement. A recording that is `unknown`
|
|
594
600
|
must never produce a stopped announcement.
|
|
595
601
|
|
|
596
602
|
A local sound, UI indicator, screen-reader message to the agent, or sound leaking through the
|
package/guide.md
CHANGED
|
@@ -83,7 +83,7 @@ user IDs, returned user details and directory requirements are unchanged.
|
|
|
83
83
|
|
|
84
84
|
### `OMNI_PROTOCOL_VERSION`
|
|
85
85
|
|
|
86
|
-
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;
|
|
86
|
+
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; there is no compatibility mapping for older recording shapes.
|
|
87
87
|
|
|
88
88
|
### `Manifest.supportedProtocolVersions`
|
|
89
89
|
|
|
@@ -343,23 +343,22 @@ interface TaskRecordingPolicy {
|
|
|
343
343
|
provider?: RecordingActions;
|
|
344
344
|
host?: RecordingActions & { storageId: string };
|
|
345
345
|
}
|
|
346
|
-
type
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
| { action: "
|
|
358
|
-
| { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string }
|
|
346
|
+
type RecordingSide = "party" | "agent";
|
|
347
|
+
type RecordingStatus = "starting" | "recording" | "paused" | "not-recording" | "unknown";
|
|
348
|
+
type RecordingPausedReason = "requested" | "sensitive-details";
|
|
349
|
+
type RecordingUnknownReason = "recorder-unreachable" | "unsettled";
|
|
350
|
+
type Recording = { id: string; follows: RecordingSide } & (
|
|
351
|
+
| { status: "starting" | "recording" | "not-recording"; reason?: never }
|
|
352
|
+
| { status: "paused"; reason: RecordingPausedReason }
|
|
353
|
+
| { status: "unknown"; reason: RecordingUnknownReason }
|
|
354
|
+
);
|
|
355
|
+
type RecordingCommand = { type: "recording"; source: RecordingSource } & (
|
|
356
|
+
| { action: "start"; follows: RecordingSide; recordingId?: never }
|
|
357
|
+
| { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string; follows?: never }
|
|
359
358
|
);
|
|
360
359
|
interface HostRecordingReport {
|
|
361
360
|
assignmentId: AssignmentId;
|
|
362
|
-
|
|
361
|
+
recordings: Recording[];
|
|
363
362
|
}
|
|
364
363
|
interface HostRecording {
|
|
365
364
|
announcesToCaller?: true;
|
|
@@ -694,7 +693,8 @@ type HistoryStep =
|
|
|
694
693
|
| "muted"
|
|
695
694
|
| "transferred"
|
|
696
695
|
| "conferenced"
|
|
697
|
-
| "unanswered"
|
|
696
|
+
| "unanswered"
|
|
697
|
+
| "callback-requested";
|
|
698
698
|
|
|
699
699
|
type TaskHistory = {
|
|
700
700
|
steps: TaskHistoryStep[];
|
|
@@ -777,8 +777,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
777
777
|
history?: TaskHistory;
|
|
778
778
|
} & TaskCompletion & (
|
|
779
779
|
C extends "voice"
|
|
780
|
-
? {
|
|
781
|
-
: {
|
|
780
|
+
? { recordings?: Recording[]; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; takenOver?: TaskTakenOver; audio?: TaskAudioState }
|
|
781
|
+
: { recordings?: never; onCall?: never; leadAssist?: never; takenOver?: never; audio?: never }
|
|
782
782
|
);
|
|
783
783
|
|
|
784
784
|
type PreviewDeadline = "provider-dials" | "host-dials" | "waits";
|
|
@@ -1345,6 +1345,7 @@ const OMNI_FAILURE_CODES = [
|
|
|
1345
1345
|
"omni.unavailable",
|
|
1346
1346
|
"omni.break-already-committed",
|
|
1347
1347
|
"omni.recording-unsettled",
|
|
1348
|
+
"omni.on-hold",
|
|
1348
1349
|
"omni.break-forced-by-provider",
|
|
1349
1350
|
] as const;
|
|
1350
1351
|
type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
@@ -2415,8 +2416,6 @@ The current exceptions and their reasons are:
|
|
|
2415
2416
|
| --- | --- |
|
|
2416
2417
|
| Agent application display of provider deadlines (`expiresInSeconds`, `previewEndsInSeconds`, `wrapEndsInSeconds`) | Each is seconds from the publication that carried it, counted down on the agent computer from receipt: the provider's own arithmetic, so no clock is compared and the transport delay is the only error. The countdown estimates the display only; it does not establish that the provider acted. Without usable time, show timing uncertainty. |
|
|
2417
2418
|
| Agent application-triggered preview end | The agent computer counts `previewEndsInSeconds` down from receipt and issues `dial` when it reaches zero: the provider owns the deadline and the agent computer owns the trigger. It does not dial before the countdown has run out, and a reload restarts it from the next publication. |
|
|
2418
|
-
| Recording evidence expiry | A bounded observer-domain clock estimate with monotonic aging may assess freshness because observation and expiry belong to the recorder's clock. If time cannot be trusted, recording state is unknown; never renew evidence from receipt or replay. |
|
|
2419
|
-
| Unknown recording state | `observedAt` and `validUntil` are absent because there is no confirmed observation. The containing provider event still has its own `occurredAt`; that publication is not a recorder observation. |
|
|
2420
2419
|
| Direct snapshot reads and method requests/results | These are reads/operations, not event envelopes, and their current types have no general event timestamp. A snapshot event still carries `occurredAt`; embedded source instants remain unchanged. Do not treat a method result or read completion time as an occurrence boundary. |
|
|
2421
2420
|
| Agent application-provided provider-clock estimate | Best-effort ISO time may be supplied by the agent application for optional comparisons; it is not a source observation or accuracy guarantee. The reason is that the agent application does not own the provider clock. |
|
|
2422
2421
|
| Provider receipt timestamps | The provider may use its own receipt instant for final records of receipt/processing because agent application timestamps are untrusted advisory input. Receipt must not be presented as an earlier action or capture boundary. |
|
|
@@ -2524,14 +2523,13 @@ A provider need not trust or adopt any agent application-supplied timestamp. It
|
|
|
2524
2523
|
message using its own clock at receipt and use that for its own processing/accounting. The reason
|
|
2525
2524
|
for this exception is that agent application clocks and agent application estimates are not authoritative at the provider.
|
|
2526
2525
|
Receipt time must be described as receipt/observation time, not relabeled as the original agent application
|
|
2527
|
-
action,
|
|
2526
|
+
action, a recording's start or stop, or proof an operation applied. An existing field with a
|
|
2528
2527
|
specific occurrence meaning still requires that evidence; this option does not silently redefine
|
|
2529
2528
|
it. Agent application-only instants remain advisory input; the provider owns its authoritative publication.
|
|
2530
2529
|
|
|
2531
2530
|
This agent application estimate is another explicit timestamp exception: it helps optional displays and
|
|
2532
2531
|
provider-clock comparisons when the agent application lacks that clock directly. It is not a source occurrence,
|
|
2533
|
-
may not replace event/history timestamps, and alone cannot authorize deadline actions
|
|
2534
|
-
recording freshness. Uses requiring a trustworthy bound still need independently established
|
|
2532
|
+
may not replace event/history timestamps, and alone cannot authorize deadline actions. Uses requiring a trustworthy bound still need independently established
|
|
2535
2533
|
clock accuracy/drift assumptions. It neither synchronizes the operating-system clock nor changes
|
|
2536
2534
|
ISO timestamps already published by the provider.
|
|
2537
2535
|
|
|
@@ -2550,7 +2548,9 @@ a backend that does not know the session yet — yields no snapshot, and the ada
|
|
|
2550
2548
|
|
|
2551
2549
|
A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
|
|
2552
2550
|
everything emitted when it was requested. Omni drops the state-replacing events held during the
|
|
2553
|
-
read on that promise, and applies the rest after it (see **`Connection.snapshot()`**)
|
|
2551
|
+
read on that promise, and applies the rest after it (see **`Connection.snapshot()`**). Which kinds a
|
|
2552
|
+
snapshot restates, and so supersedes, is exported from the testing module as
|
|
2553
|
+
`SUPERSEDED_BY_A_SNAPSHOT`, so an agent application and the harness drop the same ones; an adapter
|
|
2554
2554
|
that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
|
|
2555
2555
|
snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
|
|
2556
2556
|
snapshot to accounting for every task event it superseded: a task published during the read and
|
|
@@ -2713,7 +2713,9 @@ snapshots until it ends.
|
|
|
2713
2713
|
with `task-ended` and an outcome, whatever became of the call: answered and completed, declined,
|
|
2714
2714
|
withdrawn, abandoned in the ring, lapsed, taken over. An offer that is simply never mentioned
|
|
2715
2715
|
again leaves the agent computer holding a task nobody will close, and the test names it
|
|
2716
|
-
(`stream.taskOffered.unended`).
|
|
2716
|
+
(`stream.taskOffered.unended`). **Every dial is owed its outcome** the same way: a dial the test
|
|
2717
|
+
placed and the provider answered `dialling` with no `dial-outcome` by the end of the run is named
|
|
2718
|
+
(`stream.dialOutcome.missing`).
|
|
2717
2719
|
|
|
2718
2720
|
### `task-updated`
|
|
2719
2721
|
|
|
@@ -2935,6 +2937,9 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
2935
2937
|
| `validateProviderTimeEstimate(estimate, scope)` | Optional agent application estimate shape and provider/login scope; no accuracy guarantee. |
|
|
2936
2938
|
| `validateHistoryReport(report, path?, manifest?, task?)` | What the agent application reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. Given the task, the agent application's own mute beginning on a `paused` task is refused (`historyReport.muted.held`): the Mute waits for resume. |
|
|
2937
2939
|
| `validateHostReport(report)` | The agent application's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever agent application a test hands the adapter; `stillHost(report)` builds one that never changes. |
|
|
2940
|
+
| `validateNextCall(value)` | The agent's standing ask for the next call, on its own as `next-call` and `Snapshot.nextCall` carry it: since when, and nothing else (`nextCall.since`, `nextCall.field`). |
|
|
2941
|
+
| `validateLinedUp(value, path?, levels?)` | A call lined up in the agent's own queue, on its own as `lined-up` and `Snapshot.linedUp` carry it: the caller under the same contact rules as a task's party, the queue's label, the waits, and `release` by presence (`linedUp.*`). |
|
|
2942
|
+
| `validatePhoneState(value, path?, context?)` | The phone as the platform sees it, on its own as `phone-updated` and `Snapshot.phone` carry it: its status, its one active channel, its mute where a hardphone's is observed (`phone.*`). |
|
|
2938
2943
|
| `validateHostMute(mute, softphone)` | What the agent application's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the agent application holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
|
|
2939
2944
|
| `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
|
|
2940
2945
|
| `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
|
|
@@ -3144,6 +3149,7 @@ cannot be established from TypeScript structure alone.
|
|
|
3144
3149
|
| `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for an agent application that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
|
|
3145
3150
|
| `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a forced break arriving in effect with `forced`. The harness applies the same rules after the connect snapshot. |
|
|
3146
3151
|
| `assertAudioFollowsTheTask(envelopes, snapshot?)` | The audio follows the task and never decides it: every task is introduced once, `task-audio-started` and `task-audio-ended` alternate on work that has begun, audio ends only where it arrived, and what follows the audio ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no audio satisfies it by never testing it — pair it with the assertion that the audio end is present. |
|
|
3152
|
+
| `assertHistoryAgreesAcrossTasks(earlier, later)` | Two tasks of one call -- a hand-over, a take-over, a callback: the later restates every earlier step in order, with the same step, instant, person, dial and destination, and any duration already stated unchanged. Throws when either carries no history. |
|
|
3147
3153
|
| `assertBreakAttemptProviders(candidates, asked)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3148
3154
|
| `assertForcedBreakStopsTheRest(forcedOn, candidates, stopped)` | A forced break on one provider stops the agent everywhere else: capacity zero stated on every other usable provider holding capacity, nothing on the forcing one, nothing on a dead login. See **Forced breaks** in `guide/breaks.md`. |
|
|
3149
3155
|
| `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `on-break` only once nothing is outstanding — never beside a task, never later than the step that has none. |
|