@xema/omni-protocol 0.1.81 → 0.1.83
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 +60 -22
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +9 -6
- package/dist/testing.js +67 -56
- package/dist/validation.d.ts +36 -1
- package/dist/validation.js +360 -80
- package/guide.md +453 -97
- 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: 1;
|
|
3
3
|
/** Every version this package can interoperate with. */
|
|
4
4
|
export declare const OMNI_SUPPORTED_PROTOCOL_VERSIONS: readonly number[];
|
|
5
5
|
/**
|
|
@@ -153,6 +153,10 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
153
153
|
supportedProtocolVersions: number[];
|
|
154
154
|
authenticationMethods: AuthenticationMethod[];
|
|
155
155
|
idleCapabilities?: IdleCapabilities<C>;
|
|
156
|
+
/** Optional provider-clock sampling through Connection.checkTime; absent means unsupported. */
|
|
157
|
+
timeCheck?: true;
|
|
158
|
+
/** Provider uses its own timestamps for final records; host timestamps are advisory. Omission makes no trust promise. */
|
|
159
|
+
timestampAuthority?: "provider";
|
|
156
160
|
phaseLabels?: TaskPhaseLabels;
|
|
157
161
|
/** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
|
|
158
162
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
@@ -431,16 +435,13 @@ export type UrlVisibility = "full" | "domain" | "hidden";
|
|
|
431
435
|
export type RecordingSource = "provider" | "host";
|
|
432
436
|
export type RecordingAction = "start" | "pause" | "resume" | "stop" | "cancel";
|
|
433
437
|
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
438
|
export interface RecordingActions {
|
|
437
439
|
start?: true;
|
|
438
440
|
pause?: true;
|
|
439
441
|
resume?: true;
|
|
440
442
|
stop?: true;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
};
|
|
443
|
+
/** Abandon this recording and discard its captured audio. */
|
|
444
|
+
cancel?: true;
|
|
444
445
|
}
|
|
445
446
|
/** Per-task permission, not a provider-wide recording switch. Absence grants nothing. */
|
|
446
447
|
export interface TaskRecordingPolicy {
|
|
@@ -477,15 +478,9 @@ export type RecordingCommand = {
|
|
|
477
478
|
} & ({
|
|
478
479
|
action: "start";
|
|
479
480
|
recordingId?: never;
|
|
480
|
-
cancelEffect?: never;
|
|
481
|
-
} | {
|
|
482
|
-
action: "pause" | "resume" | "stop";
|
|
483
|
-
recordingId: string;
|
|
484
|
-
cancelEffect?: never;
|
|
485
481
|
} | {
|
|
486
|
-
action: "cancel";
|
|
482
|
+
action: "pause" | "resume" | "stop" | "cancel";
|
|
487
483
|
recordingId: string;
|
|
488
|
-
cancelEffect: RecordingCancelEffect;
|
|
489
484
|
});
|
|
490
485
|
export interface HostRecordingReport {
|
|
491
486
|
taskId: TaskId;
|
|
@@ -494,8 +489,9 @@ export interface HostRecordingReport {
|
|
|
494
489
|
}
|
|
495
490
|
/** Host declaration in ConnectContext, not the provider-owned Manifest. */
|
|
496
491
|
export interface HostRecording {
|
|
492
|
+
/** Audible recording status announcements to the remote party, for host-owned capture only. */
|
|
493
|
+
announcesToCaller?: true;
|
|
497
494
|
actions: RecordingAction[];
|
|
498
|
-
cancelEffects: RecordingCancelEffect[];
|
|
499
495
|
/** Explicitly provisioned destinations. A task chooses one; no upload/storage fallback. */
|
|
500
496
|
destinationIds: string[];
|
|
501
497
|
execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
|
|
@@ -534,7 +530,20 @@ export interface HostGuarantees {
|
|
|
534
530
|
/** A `consent` offer is accepted only by the person's own explicit act, never on their behalf. */
|
|
535
531
|
personConsent?: true;
|
|
536
532
|
}
|
|
533
|
+
export interface ProviderTimeScope {
|
|
534
|
+
providerId: string;
|
|
535
|
+
loginId: string;
|
|
536
|
+
}
|
|
537
|
+
/** Best-effort host estimate, never a provider observation or accuracy guarantee. The provider may ignore it and timestamp receipt itself. */
|
|
538
|
+
export interface ProviderTimeEstimate extends ProviderTimeScope {
|
|
539
|
+
at: IsoTimestamp;
|
|
540
|
+
clockId: string;
|
|
541
|
+
/** Estimated uncertainty only; not a guaranteed error bound. */
|
|
542
|
+
uncertaintyMs?: number;
|
|
543
|
+
}
|
|
537
544
|
export interface Host {
|
|
545
|
+
/** Optional current provider-clock estimate; undefined when no usable estimate exists. */
|
|
546
|
+
estimateProviderTime?(scope: ProviderTimeScope): ProviderTimeEstimate | undefined;
|
|
538
547
|
/** Voice softphone recording support; policy and destination remain per task. */
|
|
539
548
|
recording?: HostRecording;
|
|
540
549
|
guarantees: HostGuarantees;
|
|
@@ -623,7 +632,7 @@ export interface DispositionRules {
|
|
|
623
632
|
/**
|
|
624
633
|
* One item the queue configured for the agent to send a contact to: a button or a menu item. The
|
|
625
634
|
* protocol does not say what it does -- a queue, a menu, a line -- the provider executes it when a
|
|
626
|
-
* command names its `id`.
|
|
635
|
+
* command names its `id`. The provider may publish a queue, IVR, named agent or other supported destination; the host does not invent one.
|
|
627
636
|
*/
|
|
628
637
|
export interface Destination {
|
|
629
638
|
id: string;
|
|
@@ -660,7 +669,7 @@ export interface SharedTaskCapabilities {
|
|
|
660
669
|
export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
|
|
661
670
|
decline?: Lockable<true>;
|
|
662
671
|
hold?: Lockable<true>;
|
|
663
|
-
/** The
|
|
672
|
+
/** The provider ends the caller connection and agent-added channels owned by this handling, including inherited channels after transfer/takeover. Handling disposal remains separate. */
|
|
664
673
|
endCall?: Lockable<true>;
|
|
665
674
|
/** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
|
|
666
675
|
connectBack?: Lockable<true>;
|
|
@@ -726,14 +735,20 @@ export type TaskPhase =
|
|
|
726
735
|
/** Accepted, and not yet started. */
|
|
727
736
|
| "confirmed"
|
|
728
737
|
/**
|
|
729
|
-
* Voice only: the customer's record is
|
|
738
|
+
* Voice only: the customer's record is available for preparation, then dialing before answer. The agent
|
|
730
739
|
* presses Call, or `previewEndsAt` arrives and `atDeadline` says what the system does instead.
|
|
731
740
|
*/
|
|
732
741
|
| "preview" | "in-progress" | "paused"
|
|
733
742
|
/** The work is over and the agent is finishing up. */
|
|
734
743
|
| "completing";
|
|
735
744
|
/** What the system does when a preview's deadline passes with no Call pressed. */
|
|
736
|
-
export type PreviewDeadline =
|
|
745
|
+
export type PreviewDeadline =
|
|
746
|
+
/** Provider initiates dialing when preparation ends; never a host timer command. */
|
|
747
|
+
"calls"
|
|
748
|
+
/** Host initiates the ordinary call command when preparation ends. */
|
|
749
|
+
| "host-calls"
|
|
750
|
+
/** Preparation target has elapsed; remain in preview until the agent presses Call. */
|
|
751
|
+
| "waits";
|
|
737
752
|
/** Who ends the task: the agent issuing `complete`, or the provider deciding it is over. */
|
|
738
753
|
export type CompletionMode = "agent-command" | "provider-automatic";
|
|
739
754
|
export interface TaskAttributeBase {
|
|
@@ -966,9 +981,10 @@ export type Task<C extends Channel = Channel> = {
|
|
|
966
981
|
*/
|
|
967
982
|
acceptance?: AcceptanceMode;
|
|
968
983
|
/**
|
|
969
|
-
* In `preview` only, and together:
|
|
970
|
-
* and
|
|
971
|
-
*
|
|
984
|
+
* In `preview` only, and together: the preparation target and behavior when it elapses,
|
|
985
|
+
* and who acts then -- `calls` makes the provider initiate dialing, `host-calls` makes the
|
|
986
|
+
* host issue Call, and `waits` keeps waiting for the agent without dialing or ending the task.
|
|
987
|
+
* Absent, the agent has as long as they need, without a preparation countdown.
|
|
972
988
|
*/
|
|
973
989
|
previewEndsAt?: IsoTimestamp;
|
|
974
990
|
atDeadline?: PreviewDeadline;
|
|
@@ -1056,7 +1072,7 @@ export type VoiceTaskCommand = {
|
|
|
1056
1072
|
} | {
|
|
1057
1073
|
type: "resume";
|
|
1058
1074
|
}
|
|
1059
|
-
/** End the
|
|
1075
|
+
/** End the caller connection and all agent-added channels owned or inherited by this handling. Wrap/disposal remain separate. Gated by `endCall`. */
|
|
1060
1076
|
| {
|
|
1061
1077
|
type: "end-call";
|
|
1062
1078
|
}
|
|
@@ -1493,6 +1509,7 @@ export type HandlingReport = {
|
|
|
1493
1509
|
});
|
|
1494
1510
|
export type HandlingReportResult = {
|
|
1495
1511
|
status: "recorded";
|
|
1512
|
+
at: IsoTimestamp;
|
|
1496
1513
|
} | {
|
|
1497
1514
|
status: "failed";
|
|
1498
1515
|
failure: ProtocolFailure;
|
|
@@ -1644,7 +1661,28 @@ export interface Refusal {
|
|
|
1644
1661
|
envelopeId?: string;
|
|
1645
1662
|
violations: ProtocolViolation[];
|
|
1646
1663
|
}
|
|
1664
|
+
/** Host-local opt-in configuration; no implicit interval or timeout. */
|
|
1665
|
+
export interface ProviderTimeCheckPolicy {
|
|
1666
|
+
intervalMs: number;
|
|
1667
|
+
timeoutMs: number;
|
|
1668
|
+
maxRoundTripMs: number;
|
|
1669
|
+
maxSampleAgeMs: number;
|
|
1670
|
+
}
|
|
1671
|
+
export interface ProviderTimeCheckRequest {
|
|
1672
|
+
/** Fresh opaque correlation ID for one outstanding check, never reused on retry. */
|
|
1673
|
+
requestId: string;
|
|
1674
|
+
}
|
|
1675
|
+
export interface ProviderTimeCheckResult {
|
|
1676
|
+
requestId: string;
|
|
1677
|
+
loginId: string;
|
|
1678
|
+
/** Provider clock domain/incarnation; changes on clock discontinuity or authority replacement. */
|
|
1679
|
+
clockId: string;
|
|
1680
|
+
/** Provider-domain ISO instant sampled after this request arrived, before its response leaves. */
|
|
1681
|
+
providerTime: IsoTimestamp;
|
|
1682
|
+
}
|
|
1647
1683
|
export interface Connection<C extends Channel = Channel> {
|
|
1684
|
+
/** Required only when Manifest.timeCheck is true. A read-only clock sample, never cached/replayed. */
|
|
1685
|
+
checkTime?(request: ProviderTimeCheckRequest): Promise<ProviderTimeCheckResult>;
|
|
1648
1686
|
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
|
1649
1687
|
/** Delivery order must match the order the provider observes changes. Never replay. */
|
|
1650
1688
|
subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
|
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 = 1;
|
|
6
6
|
/** Every version this package can interoperate with. */
|
|
7
7
|
export const OMNI_SUPPORTED_PROTOCOL_VERSIONS = [OMNI_PROTOCOL_VERSION];
|
|
8
8
|
/**
|
package/dist/testing.d.ts
CHANGED
|
@@ -190,15 +190,18 @@ export declare class TaskStream {
|
|
|
190
190
|
}
|
|
191
191
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
192
192
|
export declare class BreakStream {
|
|
193
|
-
private
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
private state;
|
|
194
|
+
private recovery;
|
|
195
|
+
/** True until a validated authoritative snapshot establishes a usable baseline. */
|
|
196
|
+
get needsRecovery(): boolean;
|
|
197
|
+
/** Caller establishes snapshot freshness/login scope separately; malformed state never replaces the baseline. */
|
|
198
|
+
seed(snapshot: unknown): ProtocolViolation[];
|
|
199
|
+
/** Rejected transitions keep accepted state and require snapshot recovery before further deltas. */
|
|
197
200
|
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
198
201
|
}
|
|
199
202
|
/**
|
|
200
|
-
* A break follows its requests. Given a provider's stream --
|
|
201
|
-
*
|
|
203
|
+
* A break follows its requests. Given a provider's stream -- seeded with its initial snapshot,
|
|
204
|
+
* either supplied separately or carried by a snapshot event -- every `break-state` moves the way the guide describes: a commit's states only
|
|
202
205
|
* after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
|
|
203
206
|
*/
|
|
204
207
|
export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
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, validateHostRecording, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, 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
|
|
@@ -305,6 +305,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
305
305
|
// What every connection owes on connect, the first and a reloaded second alike.
|
|
306
306
|
const connectObligations = (on) => {
|
|
307
307
|
// A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
|
|
308
|
+
if (adapter.manifest.timeCheck === true)
|
|
309
|
+
requireMethod(on, "checkTime", "the manifest declares timeCheck");
|
|
308
310
|
requireMethod(on, "refused", "every connection is told what the host refused");
|
|
309
311
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
310
312
|
// than an `enabled` flag, so its presence is the declaration.
|
|
@@ -479,7 +481,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
479
481
|
const snapshot = await connection.snapshot();
|
|
480
482
|
await snapshotRead(live, snapshot, "snapshot");
|
|
481
483
|
stream.seed(snapshot);
|
|
482
|
-
breaks.seed(snapshot);
|
|
484
|
+
violations.push(...breaks.seed(snapshot));
|
|
483
485
|
seeded = true;
|
|
484
486
|
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
485
487
|
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
@@ -872,11 +874,11 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
|
|
|
872
874
|
if (withdrawn.length === 0) {
|
|
873
875
|
throw new Error("Task capability withdrawal must end with at least one capability the offer declared withdrawn; a locked control is present without permission, not withdrawn");
|
|
874
876
|
}
|
|
875
|
-
const before = validateTaskCommand(command, first, "command");
|
|
877
|
+
const before = validateTaskCommand(command, first, "command", context);
|
|
876
878
|
if (before.length > 0) {
|
|
877
879
|
throw new Error(`Task capability withdrawal: the command must be issuable against the task as offered, or its later refusal proves nothing (${before.map(v => v.rule).join(", ")})`);
|
|
878
880
|
}
|
|
879
|
-
const after = validateTaskCommand(command, last, "command");
|
|
881
|
+
const after = validateTaskCommand(command, last, "command", context);
|
|
880
882
|
const forWant = after.filter(v => withdrawn.some(name => v.rule === `command.capability.${name}`));
|
|
881
883
|
if (forWant.length === 0) {
|
|
882
884
|
throw new Error(`Task capability withdrawal: the command must be refused against the republished task for want of ${withdrawn.join(" or ")}; it was ${after.length === 0 ? "accepted" : `refused for ${after.map(v => v.rule).join(", ")}`}`);
|
|
@@ -1073,9 +1075,9 @@ export class TaskStream {
|
|
|
1073
1075
|
stages.set(entry.dialId, entry.stage);
|
|
1074
1076
|
}
|
|
1075
1077
|
}
|
|
1076
|
-
const
|
|
1077
|
-
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
|
|
1078
|
-
return {
|
|
1078
|
+
const partyRingingBeforeWork = isRecord(task) && Array.isArray(task.onCall)
|
|
1079
|
+
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && (typeof entry.dialId === "string" || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
|
|
1080
|
+
return { partyRingingBeforeWork, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
|
|
1079
1081
|
wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
|
|
1080
1082
|
phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
|
|
1081
1083
|
}
|
|
@@ -1268,7 +1270,7 @@ export class TaskStream {
|
|
|
1268
1270
|
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1269
1271
|
break;
|
|
1270
1272
|
}
|
|
1271
|
-
if (!AT_WORK.has(known.phase) && !known.
|
|
1273
|
+
if (!AT_WORK.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1272
1274
|
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1273
1275
|
}
|
|
1274
1276
|
if (known.media === "started") {
|
|
@@ -1290,7 +1292,7 @@ export class TaskStream {
|
|
|
1290
1292
|
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1291
1293
|
break;
|
|
1292
1294
|
}
|
|
1293
|
-
if (!WORK_BEGUN.has(known.phase)) {
|
|
1295
|
+
if (!WORK_BEGUN.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1294
1296
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1295
1297
|
}
|
|
1296
1298
|
if (known.media !== "started") {
|
|
@@ -1441,7 +1443,7 @@ async function driveOneCall(drive) {
|
|
|
1441
1443
|
// is a place the stream's rules keep working, not one where they all stop.
|
|
1442
1444
|
await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
|
|
1443
1445
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1444
|
-
drive.streams.breaks.seed(snapshot);
|
|
1446
|
+
found.push(...drive.streams.breaks.seed(snapshot));
|
|
1445
1447
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1446
1448
|
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
1447
1449
|
ruleEvaluated("drive.reload.snapshot");
|
|
@@ -1560,7 +1562,11 @@ async function driveOneCall(drive) {
|
|
|
1560
1562
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1561
1563
|
ruleEvaluated("drive.timeout");
|
|
1562
1564
|
const send = async (command, dialId) => {
|
|
1563
|
-
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}
|
|
1565
|
+
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
|
|
1566
|
+
levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
|
|
1567
|
+
dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
|
|
1568
|
+
autoAcceptTasks: drive.context.autoAcceptTasks,
|
|
1569
|
+
});
|
|
1564
1570
|
found.push(...own);
|
|
1565
1571
|
if (own.length > 0)
|
|
1566
1572
|
return undefined;
|
|
@@ -1651,12 +1657,13 @@ async function driveOneCall(drive) {
|
|
|
1651
1657
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1652
1658
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1653
1659
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1660
|
+
const canonicalTimes = new Map();
|
|
1654
1661
|
const reportedLegs = [];
|
|
1655
1662
|
/** The duration the provider's record states for the leg at `at`, as the task stands now. */
|
|
1656
1663
|
const closedAs = (at) => {
|
|
1657
1664
|
const history = latestTask().handlingHistory;
|
|
1658
1665
|
const leg = isRecord(history) && Array.isArray(history.steps)
|
|
1659
|
-
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
|
|
1666
|
+
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1660
1667
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
1661
1668
|
};
|
|
1662
1669
|
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
|
|
@@ -1676,7 +1683,15 @@ async function driveOneCall(drive) {
|
|
|
1676
1683
|
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1677
1684
|
return;
|
|
1678
1685
|
}
|
|
1679
|
-
|
|
1686
|
+
const resultErrors = validateResult(answer, "recordStep", "drive.recordStep.result");
|
|
1687
|
+
found.push(...resultErrors);
|
|
1688
|
+
if (!resultErrors.length && isRecord(answer) && answer.status === "recorded" && typeof answer.at === "string" && typeof body.at === "string") {
|
|
1689
|
+
const prior = canonicalTimes.get(body.at);
|
|
1690
|
+
if (prior !== undefined && prior !== answer.at)
|
|
1691
|
+
refuse("drive.recordStep.identity", "drive.recordStep.result.at", "one reported leg must retain its provider-assigned history instant");
|
|
1692
|
+
else
|
|
1693
|
+
canonicalTimes.set(body.at, answer.at);
|
|
1694
|
+
}
|
|
1680
1695
|
if (isRecord(answer) && answer.status === "failed") {
|
|
1681
1696
|
refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1682
1697
|
}
|
|
@@ -1701,12 +1716,12 @@ async function driveOneCall(drive) {
|
|
|
1701
1716
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1702
1717
|
setMuted(false);
|
|
1703
1718
|
await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1704
|
-
reportedLegs.push({ at, task: latestTask() });
|
|
1719
|
+
reportedLegs.push({ at: canonicalTimes.get(at), task: latestTask() });
|
|
1705
1720
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1706
1721
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1707
1722
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1708
|
-
if (drive.rebuild !== undefined) {
|
|
1709
|
-
await recordSurvivesReload(at);
|
|
1723
|
+
if (drive.rebuild !== undefined && canonicalTimes.has(at)) {
|
|
1724
|
+
await recordSurvivesReload(canonicalTimes.get(at));
|
|
1710
1725
|
// A reload that stood no client ends the run: there is nothing left to drive.
|
|
1711
1726
|
if (!drive.handOver.isLive())
|
|
1712
1727
|
return found;
|
|
@@ -1757,7 +1772,7 @@ async function driveOneCall(drive) {
|
|
|
1757
1772
|
// record as it stands now is what every later restatement is held to.
|
|
1758
1773
|
const before = closedAs(openLeg.at);
|
|
1759
1774
|
await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
|
|
1760
|
-
reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
|
|
1775
|
+
reportedLegs.push({ at: canonicalTimes.get(openLeg.at), task: openLeg.task, closedAs: before });
|
|
1761
1776
|
}
|
|
1762
1777
|
if (completing !== undefined && offers("hold")) {
|
|
1763
1778
|
await holdRefusedOutsideHandling();
|
|
@@ -1822,6 +1837,8 @@ async function driveOneCall(drive) {
|
|
|
1822
1837
|
return latestTask();
|
|
1823
1838
|
};
|
|
1824
1839
|
for (const reported of reportedLegs) {
|
|
1840
|
+
if (reported.at === undefined)
|
|
1841
|
+
continue; // A refused/malformed report provides no accepted history identity.
|
|
1825
1842
|
const published = lastPublished();
|
|
1826
1843
|
if (published === reported.task)
|
|
1827
1844
|
continue;
|
|
@@ -1852,62 +1869,56 @@ async function driveOneCall(drive) {
|
|
|
1852
1869
|
// starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
|
|
1853
1870
|
// a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
|
|
1854
1871
|
// with `imposed`. Nothing else is a move the guide describes.
|
|
1855
|
-
const COMMITTED = new Set(["starting-after-task", "in-effect"]);
|
|
1856
|
-
const BACKWARDS = {
|
|
1857
|
-
"in-effect": ["awaiting-decision", "granted", "starting-after-task"],
|
|
1858
|
-
"starting-after-task": ["awaiting-decision", "granted"],
|
|
1859
|
-
granted: ["awaiting-decision"],
|
|
1860
|
-
};
|
|
1861
1872
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
1862
1873
|
export class BreakStream {
|
|
1863
|
-
|
|
1864
|
-
|
|
1874
|
+
state;
|
|
1875
|
+
recovery = true;
|
|
1876
|
+
/** True until a validated authoritative snapshot establishes a usable baseline. */
|
|
1877
|
+
get needsRecovery() { return this.recovery; }
|
|
1878
|
+
/** Caller establishes snapshot freshness/login scope separately; malformed state never replaces the baseline. */
|
|
1865
1879
|
seed(snapshot) {
|
|
1866
1880
|
const state = isRecord(snapshot) ? snapshot.break : undefined;
|
|
1867
|
-
|
|
1881
|
+
const found = validateBreakTransition(state, state, "snapshot.break");
|
|
1882
|
+
if (found.length) {
|
|
1883
|
+
this.recovery = true;
|
|
1884
|
+
return found;
|
|
1885
|
+
}
|
|
1886
|
+
this.state = state;
|
|
1887
|
+
this.recovery = false;
|
|
1888
|
+
return [];
|
|
1868
1889
|
}
|
|
1869
|
-
/**
|
|
1890
|
+
/** Rejected transitions keep accepted state and require snapshot recovery before further deltas. */
|
|
1870
1891
|
apply(envelope, path = "event") {
|
|
1871
|
-
const found = [];
|
|
1872
1892
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
1873
1893
|
if (!isRecord(event))
|
|
1874
|
-
return
|
|
1875
|
-
if (event.type === "
|
|
1876
|
-
this.
|
|
1877
|
-
return
|
|
1878
|
-
}
|
|
1879
|
-
if (event.type !== "break-state" || !isRecord(event.break) || typeof event.break.approval !== "string")
|
|
1880
|
-
return found;
|
|
1881
|
-
const from = this.approval;
|
|
1882
|
-
const to = event.break.approval;
|
|
1883
|
-
const at = `${path}.event.break.approval`;
|
|
1884
|
-
if (from !== undefined) {
|
|
1885
|
-
// A commit's states need a grant behind them. A placed break is the one arrival in a
|
|
1886
|
-
// committed state that nobody asked for -- in effect at once, or starting-after-task while
|
|
1887
|
-
// the member finishes a call -- and it says so with `imposed`.
|
|
1888
|
-
if (COMMITTED.has(to) && (from === "not-requested" || from === "awaiting-decision") && event.break.imposed === undefined) {
|
|
1889
|
-
found.push({ rule: "stream.breakState.commitBeforeGrant", path: at,
|
|
1890
|
-
message: `${to} follows a commit, and a commit follows granted; the break stood at ${from}` });
|
|
1891
|
-
}
|
|
1892
|
-
if ((BACKWARDS[from] ?? []).includes(to)) {
|
|
1893
|
-
found.push({ rule: "stream.breakState.backwards", path: at,
|
|
1894
|
-
message: `a break does not go from ${from} back to ${to}; a new request passes through not-requested` });
|
|
1895
|
-
}
|
|
1894
|
+
return [];
|
|
1895
|
+
if (event.type === "transport-status" && event.status !== "active") {
|
|
1896
|
+
this.recovery = true;
|
|
1897
|
+
return [];
|
|
1896
1898
|
}
|
|
1897
|
-
|
|
1899
|
+
if (event.type === "snapshot")
|
|
1900
|
+
return this.seed(event.snapshot);
|
|
1901
|
+
if (event.type !== "break-state")
|
|
1902
|
+
return [];
|
|
1903
|
+
if (this.recovery)
|
|
1904
|
+
return [{ rule: "stream.breakState.baseline", path: `${path}.event.break`,
|
|
1905
|
+
message: "a validated authoritative snapshot is required before accepting break updates" }];
|
|
1906
|
+
const found = validateBreakTransition(this.state, event.break, `${path}.event.break`);
|
|
1907
|
+
if (found.length)
|
|
1908
|
+
this.recovery = true;
|
|
1909
|
+
else
|
|
1910
|
+
this.state = event.break;
|
|
1898
1911
|
return found;
|
|
1899
1912
|
}
|
|
1900
1913
|
}
|
|
1901
1914
|
/**
|
|
1902
|
-
* A break follows its requests. Given a provider's stream --
|
|
1903
|
-
*
|
|
1915
|
+
* A break follows its requests. Given a provider's stream -- seeded with its initial snapshot,
|
|
1916
|
+
* either supplied separately or carried by a snapshot event -- every `break-state` moves the way the guide describes: a commit's states only
|
|
1904
1917
|
* after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
|
|
1905
1918
|
*/
|
|
1906
1919
|
export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
1907
1920
|
const stream = new BreakStream();
|
|
1908
|
-
|
|
1909
|
-
stream.seed(snapshot);
|
|
1910
|
-
const found = [];
|
|
1921
|
+
const found = snapshot === undefined ? [] : stream.seed(snapshot);
|
|
1911
1922
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
1912
1923
|
assertNoViolations(found, "A break follows its requests");
|
|
1913
1924
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export declare function validateScheduledActivity(activity: unknown, path?: stri
|
|
|
22
22
|
* on voice, absent elsewhere.
|
|
23
23
|
*/
|
|
24
24
|
export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];
|
|
25
|
+
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
26
|
+
export declare function validateProviderTimeEstimate(value: unknown, scope: unknown, path?: string): ProtocolViolation[];
|
|
27
|
+
/** Validate host-local periodic clock-check configuration. Omission disables checking. */
|
|
28
|
+
export declare function validateProviderTimeCheckPolicy(value: unknown, path?: string): ProtocolViolation[];
|
|
29
|
+
export declare function validateProviderTimeCheckRequest(value: unknown, path?: string): ProtocolViolation[];
|
|
30
|
+
/** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
|
|
31
|
+
export declare function validateProviderTimeCheckResult(value: unknown, request: unknown, loginId: string, path?: string): ProtocolViolation[];
|
|
25
32
|
export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
|
|
26
33
|
export interface TaskValidationContext {
|
|
27
34
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
@@ -40,6 +47,30 @@ export interface TaskValidationContext {
|
|
|
40
47
|
locked?: readonly string[];
|
|
41
48
|
}
|
|
42
49
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
50
|
+
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
51
|
+
export declare function validateBreakStatus(state: unknown, tasks: unknown, path?: string): ProtocolViolation[];
|
|
52
|
+
/**
|
|
53
|
+
* Checks lead break dispatch using current authentication, transport, roster (`team`) and,
|
|
54
|
+
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
55
|
+
* and recheck the decision atomically; a roster is not authority to act after it has changed.
|
|
56
|
+
*/
|
|
57
|
+
export declare function validateTeamBreakCommand(request: unknown, context: unknown, path?: string): ProtocolViolation[];
|
|
58
|
+
/** The four agent break methods; this is a validation API, not a wire command. */
|
|
59
|
+
export type BreakMethod = "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak";
|
|
60
|
+
/**
|
|
61
|
+
* Validate immediately before dispatch against current provider state. Context must contain
|
|
62
|
+
* the current authentication and transport. This cannot fence a concurrent backend change;
|
|
63
|
+
* the provider must recheck atomically. Result validation never replaces authoritative state.
|
|
64
|
+
*/
|
|
65
|
+
export declare function validateBreakCommand(method: BreakMethod, request: unknown, state: unknown, context: unknown, path?: string): ProtocolViolation[];
|
|
66
|
+
/**
|
|
67
|
+
* Checks two complete break states within one provider/login's ordered event stream.
|
|
68
|
+
* Call before replacing accepted state; report violations and reconcile on failure.
|
|
69
|
+
* This is not a freshness check: validate a fresh authoritative snapshot separately and
|
|
70
|
+
* use it as a new baseline, never as an event transition. Source ordering and connection
|
|
71
|
+
* fencing remain required; neither timestamps nor approval rank identify a break attempt.
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateBreakTransition(before: unknown, after: unknown, path?: string): ProtocolViolation[];
|
|
43
74
|
/**
|
|
44
75
|
* Who is reading what the adapter published, and what their login declares. The validators check
|
|
45
76
|
* structure without it; given it, they also hold what the adapter publishes to the login.
|
|
@@ -93,6 +124,8 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
|
|
|
93
124
|
*/
|
|
94
125
|
export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
95
126
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
127
|
+
/** Validate the exact handling/allocation target before provider command dispatch. */
|
|
128
|
+
export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
96
129
|
/**
|
|
97
130
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
98
131
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
@@ -100,7 +133,7 @@ export declare function validateHostReport(report: unknown, path?: string): Prot
|
|
|
100
133
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
101
134
|
* for a control the task never offered is the host's error, and this names it.
|
|
102
135
|
*/
|
|
103
|
-
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];
|
|
136
|
+
export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
104
137
|
/** The connection methods whose results `validateResult` knows. */
|
|
105
138
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
|
|
106
139
|
/**
|
|
@@ -151,6 +184,8 @@ export declare function validateRecordingRequest(request: unknown, task: unknown
|
|
|
151
184
|
host?: unknown;
|
|
152
185
|
hostReport?: unknown;
|
|
153
186
|
softphone?: boolean;
|
|
187
|
+
/** The same provider/task context used to validate the published task. */
|
|
188
|
+
taskContext?: Omit<TaskValidationContext, "channel">;
|
|
154
189
|
}, path?: string): ProtocolViolation[];
|
|
155
190
|
/** Check recording-specific result semantics against the owner's confirming observations. */
|
|
156
191
|
export declare function validateRecordingOutcome(command: unknown, before: unknown, after: unknown, result: unknown, path?: string): ProtocolViolation[];
|