@xema/omni-protocol 0.1.67 → 0.1.68
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 +22 -0
- package/dist/index.js +2 -2
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +89 -15
- package/dist/validation.js +19 -0
- package/guide.md +46 -17
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export type IsoTimestamp = string;
|
|
|
26
26
|
export type UserId = string;
|
|
27
27
|
/** A non-empty, opaque task identifier unique within one provider. Scope it with `taskKey()`. */
|
|
28
28
|
export type TaskId = string;
|
|
29
|
+
/**
|
|
30
|
+
* One life of a task on the wire. A platform retires a task id minutes after closing it and a
|
|
31
|
+
* requeue takes seconds, so the same `TaskId` comes back for another customer while a late dial
|
|
32
|
+
* outcome or a late handling report for the first is still in flight. The allocation id is minted
|
|
33
|
+
* once per offer and never reused for the life of the login, and every event, command and report
|
|
34
|
+
* that names a task names its allocation too, so nothing lands on the next life of the id.
|
|
35
|
+
*/
|
|
36
|
+
export type AllocationId = string;
|
|
29
37
|
/**
|
|
30
38
|
* The host's identity for one dial, minted before the command leaves and unique across the
|
|
31
39
|
* installation. A dial is the one command whose outcome arrives later and apart, so the provider
|
|
@@ -770,6 +778,7 @@ export interface TaskAssisting {
|
|
|
770
778
|
export interface TaskMonitoring {
|
|
771
779
|
memberId: UserId;
|
|
772
780
|
taskId: TaskId;
|
|
781
|
+
allocationId: AllocationId;
|
|
773
782
|
mode: MonitorMode;
|
|
774
783
|
since: IsoTimestamp;
|
|
775
784
|
}
|
|
@@ -826,6 +835,8 @@ export type CapabilitySource = "queue" | "ungoverned" | "undetermined";
|
|
|
826
835
|
export declare const CAPABILITY_SOURCES: readonly ["queue", "ungoverned", "undetermined"];
|
|
827
836
|
export type Task<C extends Channel = Channel> = {
|
|
828
837
|
id: TaskId;
|
|
838
|
+
/** This life of the task: minted once per offer, never reused for the life of the login. See `AllocationId`. */
|
|
839
|
+
allocationId: AllocationId;
|
|
829
840
|
title: string;
|
|
830
841
|
channel: C;
|
|
831
842
|
/** The provider's own name for a category of work. Finer-grained than a channel. */
|
|
@@ -1035,6 +1046,7 @@ export interface CustomTaskCommand {
|
|
|
1035
1046
|
export type TaskCommand<C extends Channel = Channel> = (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand) | CustomTaskCommand;
|
|
1036
1047
|
export interface TaskCommandRequest<C extends Channel = Channel> {
|
|
1037
1048
|
taskId: TaskId;
|
|
1049
|
+
allocationId: AllocationId;
|
|
1038
1050
|
command: TaskCommand<C>;
|
|
1039
1051
|
}
|
|
1040
1052
|
/**
|
|
@@ -1182,6 +1194,7 @@ export interface LeadRequest {
|
|
|
1182
1194
|
id: string;
|
|
1183
1195
|
memberId: UserId;
|
|
1184
1196
|
taskId: TaskId;
|
|
1197
|
+
allocationId: AllocationId;
|
|
1185
1198
|
note?: string;
|
|
1186
1199
|
since: IsoTimestamp;
|
|
1187
1200
|
}
|
|
@@ -1280,6 +1293,7 @@ export interface VoiceMediaSession {
|
|
|
1280
1293
|
}
|
|
1281
1294
|
export interface OpenMediaRequest {
|
|
1282
1295
|
taskId: TaskId;
|
|
1296
|
+
allocationId: AllocationId;
|
|
1283
1297
|
/** The agent's microphone as Omni captured it, `HostReport.audio.input.localAudio`; absent while that input is `unavailable`. */
|
|
1284
1298
|
localAudio?: MediaStream;
|
|
1285
1299
|
}
|
|
@@ -1339,6 +1353,7 @@ export type SetPreferenceRequest = {
|
|
|
1339
1353
|
*/
|
|
1340
1354
|
export type HandlingReport = {
|
|
1341
1355
|
taskId: TaskId;
|
|
1356
|
+
allocationId: AllocationId;
|
|
1342
1357
|
at: IsoTimestamp;
|
|
1343
1358
|
} & (
|
|
1344
1359
|
/** A muted leg says whose the silence was: the host's own Mute, or the station's slider or system. */
|
|
@@ -1427,12 +1442,15 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1427
1442
|
} | {
|
|
1428
1443
|
type: "task-media-started";
|
|
1429
1444
|
taskId: TaskId;
|
|
1445
|
+
allocationId: AllocationId;
|
|
1430
1446
|
} | {
|
|
1431
1447
|
type: "task-media-ended";
|
|
1432
1448
|
taskId: TaskId;
|
|
1449
|
+
allocationId: AllocationId;
|
|
1433
1450
|
} | {
|
|
1434
1451
|
type: "task-ended";
|
|
1435
1452
|
taskId: TaskId;
|
|
1453
|
+
allocationId: AllocationId;
|
|
1436
1454
|
outcome: TaskOutcome;
|
|
1437
1455
|
}
|
|
1438
1456
|
/**
|
|
@@ -1447,6 +1465,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1447
1465
|
dialId: DialId;
|
|
1448
1466
|
outcome: DialOutcome;
|
|
1449
1467
|
taskId?: TaskId;
|
|
1468
|
+
allocationId?: AllocationId;
|
|
1450
1469
|
destinationId?: string;
|
|
1451
1470
|
reason?: string;
|
|
1452
1471
|
} | {
|
|
@@ -1463,6 +1482,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1463
1482
|
expected: string;
|
|
1464
1483
|
observed: string;
|
|
1465
1484
|
taskId?: TaskId;
|
|
1485
|
+
allocationId?: AllocationId;
|
|
1466
1486
|
} | {
|
|
1467
1487
|
type: "team-updated";
|
|
1468
1488
|
team: TeamRoster;
|
|
@@ -1603,6 +1623,8 @@ export interface BrowserSessionKeyInput {
|
|
|
1603
1623
|
/** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
|
|
1604
1624
|
providerId: string;
|
|
1605
1625
|
taskId: TaskId;
|
|
1626
|
+
/** The task's allocation: a task-scoped session lives one life of the task, never the next customer's under a reused id. */
|
|
1627
|
+
allocationId: AllocationId;
|
|
1606
1628
|
/** `Task.taskType`. */
|
|
1607
1629
|
taskType: string;
|
|
1608
1630
|
browser: TaskBrowser;
|
package/dist/index.js
CHANGED
|
@@ -196,7 +196,7 @@ export function sameCapabilities(a, b) {
|
|
|
196
196
|
* cannot collide with a provider called `a` and a tab called `b`.
|
|
197
197
|
*/
|
|
198
198
|
export function browserSessionKey(input) {
|
|
199
|
-
const { providerId,
|
|
199
|
+
const { providerId, allocationId, taskType, browser } = input;
|
|
200
200
|
if (browser.sharedSession !== true)
|
|
201
201
|
return undefined;
|
|
202
202
|
// `encodeURIComponent` leaves `.` untouched, and `.` is the separator: a raw join would let
|
|
@@ -204,7 +204,7 @@ export function browserSessionKey(input) {
|
|
|
204
204
|
const part = (value) => encodeURIComponent(value).replaceAll(".", "%2E");
|
|
205
205
|
switch (browser.isolationScheme) {
|
|
206
206
|
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_ID__TAB_NAME:
|
|
207
|
-
return `${part(providerId)}.${part(
|
|
207
|
+
return `${part(providerId)}.${part(allocationId)}.${part(browser.name)}`;
|
|
208
208
|
case BROWSER_ISOLATION_SCHEMES.TAB_NAME:
|
|
209
209
|
return part(browser.name);
|
|
210
210
|
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME:
|
package/dist/testing.d.ts
CHANGED
|
@@ -135,10 +135,18 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
135
135
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
136
136
|
export declare class TaskStream {
|
|
137
137
|
private readonly tasks;
|
|
138
|
+
private readonly allocations;
|
|
139
|
+
private readonly endedAllocations;
|
|
138
140
|
private readonly dials;
|
|
139
141
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
140
142
|
dialled(dialId: string): void;
|
|
141
143
|
private noteDials;
|
|
144
|
+
/**
|
|
145
|
+
* Whether an event about a task names the life that is open under its id. One naming a life that
|
|
146
|
+
* has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
|
|
147
|
+
* a different fault. Either way it is not applied to the open task.
|
|
148
|
+
*/
|
|
149
|
+
private namesTheOpenLife;
|
|
142
150
|
/** How many tasks the stream currently holds open. */
|
|
143
151
|
openCount(): number;
|
|
144
152
|
/** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
|
package/dist/testing.js
CHANGED
|
@@ -305,6 +305,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
305
305
|
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
306
306
|
const eventPayloads = new Map();
|
|
307
307
|
let capacityStated;
|
|
308
|
+
const reloading = { now: false };
|
|
308
309
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
309
310
|
const waiters = new Set();
|
|
310
311
|
unsubscribe = connection.subscribe(envelope => {
|
|
@@ -312,7 +313,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
312
313
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
313
314
|
// A diagnostic is informational to a host and a failure to a conformance run: the platform
|
|
314
315
|
// under test broke a rule the adapter relies on, and a green result must not paper over it.
|
|
315
|
-
|
|
316
|
+
// A reload in life has no first connection: the page is gone. The drive keeps its first
|
|
317
|
+
// connection up while the second reads, so a platform that pushes the open task to the new
|
|
318
|
+
// client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
|
|
319
|
+
// defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
|
|
320
|
+
// second adapter is up; every other moment it is.
|
|
321
|
+
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
|
|
316
322
|
violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
|
|
317
323
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
318
324
|
}
|
|
@@ -422,7 +428,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
422
428
|
violations.push(...await driveOneCall({
|
|
423
429
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
424
430
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
425
|
-
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
|
|
431
|
+
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
|
|
426
432
|
}));
|
|
427
433
|
}
|
|
428
434
|
}
|
|
@@ -817,6 +823,11 @@ const REACHABLE_PHASES = {
|
|
|
817
823
|
};
|
|
818
824
|
export class TaskStream {
|
|
819
825
|
tasks = new Map();
|
|
826
|
+
// Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
|
|
827
|
+
// one, an event about a task names the life that is open, and a late event for a life that ended
|
|
828
|
+
// is recognised as that rather than landing on the next customer under the same id.
|
|
829
|
+
allocations = new Set();
|
|
830
|
+
endedAllocations = new Set();
|
|
820
831
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
821
832
|
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
822
833
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
@@ -838,6 +849,23 @@ export class TaskStream {
|
|
|
838
849
|
this.dialled(entry.dialId);
|
|
839
850
|
}
|
|
840
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Whether an event about a task names the life that is open under its id. One naming a life that
|
|
854
|
+
* has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
|
|
855
|
+
* a different fault. Either way it is not applied to the open task.
|
|
856
|
+
*/
|
|
857
|
+
namesTheOpenLife(event, known, at, refuse) {
|
|
858
|
+
const named = event.allocationId;
|
|
859
|
+
if (typeof named !== "string" || named === known.allocation)
|
|
860
|
+
return true;
|
|
861
|
+
if (this.endedAllocations.has(named)) {
|
|
862
|
+
refuse("stream.allocation.ended", `${at}.allocationId`, `${String(event.type)} names allocation ${named}, a life of ${String(event.taskId)} that has ended; the life open under that id is ${known.allocation}, and a late event lands on the life it names, never the next`);
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
|
|
866
|
+
}
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
841
869
|
/** How many tasks the stream currently holds open. */
|
|
842
870
|
openCount() { return this.tasks.size; }
|
|
843
871
|
/** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
|
|
@@ -876,7 +904,7 @@ export class TaskStream {
|
|
|
876
904
|
stages.set(entry.dialId, entry.stage);
|
|
877
905
|
}
|
|
878
906
|
}
|
|
879
|
-
return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
|
|
907
|
+
return { 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) };
|
|
880
908
|
}
|
|
881
909
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
882
910
|
seed(snapshot) {
|
|
@@ -884,8 +912,11 @@ export class TaskStream {
|
|
|
884
912
|
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
885
913
|
return;
|
|
886
914
|
for (const task of snapshot.tasks) {
|
|
887
|
-
if (isRecord(task) && typeof task.id === "string")
|
|
915
|
+
if (isRecord(task) && typeof task.id === "string") {
|
|
888
916
|
this.tasks.set(task.id, TaskStream.stated(task));
|
|
917
|
+
if (typeof task.allocationId === "string")
|
|
918
|
+
this.allocations.add(task.allocationId);
|
|
919
|
+
}
|
|
889
920
|
this.noteDials(task);
|
|
890
921
|
}
|
|
891
922
|
}
|
|
@@ -920,19 +951,27 @@ export class TaskStream {
|
|
|
920
951
|
}
|
|
921
952
|
this.seed(event.snapshot);
|
|
922
953
|
break;
|
|
923
|
-
case "task-offered":
|
|
924
|
-
ruleEvaluated("stream.taskOffered.duplicate");
|
|
954
|
+
case "task-offered": {
|
|
955
|
+
ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
|
|
925
956
|
if (id === undefined)
|
|
926
957
|
break;
|
|
927
958
|
if (known !== undefined)
|
|
928
959
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
960
|
+
const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
|
|
961
|
+
if (typeof allocation === "string") {
|
|
962
|
+
if (this.allocations.has(allocation)) {
|
|
963
|
+
refuse("stream.taskOffered.allocation", `${at}.task.allocationId`, `${allocation} was already an allocation on this login: an allocation is minted once per offer and never reused, whatever the task id does`);
|
|
964
|
+
}
|
|
965
|
+
this.allocations.add(allocation);
|
|
966
|
+
}
|
|
929
967
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
930
968
|
this.noteDials(event.task);
|
|
931
969
|
break;
|
|
970
|
+
}
|
|
932
971
|
case "task-updated":
|
|
933
972
|
if (id === undefined)
|
|
934
973
|
break;
|
|
935
|
-
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
974
|
+
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
936
975
|
if (known === undefined) {
|
|
937
976
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
938
977
|
break;
|
|
@@ -943,6 +982,11 @@ export class TaskStream {
|
|
|
943
982
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
944
983
|
refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
|
|
945
984
|
}
|
|
985
|
+
// An update is of the life that is open. One carrying another allocation is a copy of a
|
|
986
|
+
// different life of this id -- a stale republish of the last customer's call, or the next one's.
|
|
987
|
+
if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
|
|
988
|
+
refuse("stream.taskUpdated.allocation", `${at}.task.allocationId`, `${id} is open as allocation ${known.allocation} and the update says ${String(event.task.allocationId)}: an update restates the life that is open, never another`);
|
|
989
|
+
}
|
|
946
990
|
// A task does not go backwards. The stream sees publications, not transitions, and a task may
|
|
947
991
|
// pass through a phase between two, so what is refused is a phase unreachable from the last
|
|
948
992
|
// one read by the guide's table: back to pending, back to confirmed once work began, out of
|
|
@@ -993,13 +1037,15 @@ export class TaskStream {
|
|
|
993
1037
|
this.noteDials(event.task);
|
|
994
1038
|
break;
|
|
995
1039
|
case "task-media-started":
|
|
996
|
-
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
|
|
1040
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
|
|
997
1041
|
if (id === undefined)
|
|
998
1042
|
break;
|
|
999
1043
|
if (known === undefined) {
|
|
1000
1044
|
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1001
1045
|
break;
|
|
1002
1046
|
}
|
|
1047
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1048
|
+
break;
|
|
1003
1049
|
if (!AT_WORK.has(known.phase)) {
|
|
1004
1050
|
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`);
|
|
1005
1051
|
}
|
|
@@ -1009,13 +1055,15 @@ export class TaskStream {
|
|
|
1009
1055
|
known.media = "started";
|
|
1010
1056
|
break;
|
|
1011
1057
|
case "task-media-ended":
|
|
1012
|
-
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
|
|
1058
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1013
1059
|
if (id === undefined)
|
|
1014
1060
|
break;
|
|
1015
1061
|
if (known === undefined) {
|
|
1016
1062
|
refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1017
1063
|
break;
|
|
1018
1064
|
}
|
|
1065
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1066
|
+
break;
|
|
1019
1067
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
1020
1068
|
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`);
|
|
1021
1069
|
}
|
|
@@ -1025,15 +1073,27 @@ export class TaskStream {
|
|
|
1025
1073
|
known.media = "ended";
|
|
1026
1074
|
break;
|
|
1027
1075
|
case "task-ended":
|
|
1076
|
+
ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1028
1077
|
if (id === undefined)
|
|
1029
1078
|
break;
|
|
1030
|
-
if (known === undefined)
|
|
1079
|
+
if (known === undefined) {
|
|
1031
1080
|
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1084
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1085
|
+
break;
|
|
1086
|
+
this.endedAllocations.add(known.allocation);
|
|
1032
1087
|
this.tasks.delete(id);
|
|
1033
1088
|
break;
|
|
1034
1089
|
case "dial-outcome": {
|
|
1035
1090
|
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
1036
|
-
// a dial placed late routinely outlives its call, which is why the dial has its own identity
|
|
1091
|
+
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1092
|
+
// and why the outcome names the allocation: the host routes it to that life, ended or not.
|
|
1093
|
+
ruleEvaluated("stream.allocation.unknown");
|
|
1094
|
+
if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
|
|
1095
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
|
|
1096
|
+
}
|
|
1037
1097
|
if (typeof event.dialId !== "string")
|
|
1038
1098
|
break;
|
|
1039
1099
|
const dial = this.dials.get(event.dialId);
|
|
@@ -1088,6 +1148,7 @@ async function driveOneCall(drive) {
|
|
|
1088
1148
|
drive.waiters.add(waiter);
|
|
1089
1149
|
});
|
|
1090
1150
|
const recordSurvivesReload = async (at) => {
|
|
1151
|
+
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
|
|
1091
1152
|
let again;
|
|
1092
1153
|
try {
|
|
1093
1154
|
again = drive.rebuild();
|
|
@@ -1104,6 +1165,7 @@ async function driveOneCall(drive) {
|
|
|
1104
1165
|
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
1105
1166
|
return;
|
|
1106
1167
|
}
|
|
1168
|
+
drive.reloading.now = true;
|
|
1107
1169
|
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1108
1170
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1109
1171
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
@@ -1124,6 +1186,10 @@ async function driveOneCall(drive) {
|
|
|
1124
1186
|
refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
|
|
1125
1187
|
return;
|
|
1126
1188
|
}
|
|
1189
|
+
// The allocation is part of the task, not memory beside it: a reload brings the same life back.
|
|
1190
|
+
if (carried.allocationId !== allocationOf()) {
|
|
1191
|
+
refuse("drive.reload.allocation", "drive.reload.allocation", `a second adapter built from the same login carries ${taskId} as allocation ${String(carried.allocationId)}; the first published ${allocationOf()}, and a life does not change its name on a reload`);
|
|
1192
|
+
}
|
|
1127
1193
|
const history = carried.handlingHistory;
|
|
1128
1194
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1129
1195
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
@@ -1158,6 +1224,7 @@ async function driveOneCall(drive) {
|
|
|
1158
1224
|
catch (error) {
|
|
1159
1225
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1160
1226
|
}
|
|
1227
|
+
drive.reloading.now = false;
|
|
1161
1228
|
}
|
|
1162
1229
|
};
|
|
1163
1230
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
@@ -1165,6 +1232,7 @@ async function driveOneCall(drive) {
|
|
|
1165
1232
|
// inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
|
|
1166
1233
|
let retainedAtEnd;
|
|
1167
1234
|
const storeReleased = async () => {
|
|
1235
|
+
ruleEvaluated("drive.store.retained");
|
|
1168
1236
|
await Promise.resolve();
|
|
1169
1237
|
await Promise.resolve();
|
|
1170
1238
|
const retained = [...drive.held].filter(key => key.includes(taskId));
|
|
@@ -1178,6 +1246,7 @@ async function driveOneCall(drive) {
|
|
|
1178
1246
|
const nothingLate = () => {
|
|
1179
1247
|
if (retainedAtEnd === undefined)
|
|
1180
1248
|
return;
|
|
1249
|
+
ruleEvaluated("drive.store.late");
|
|
1181
1250
|
const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
|
|
1182
1251
|
if (late.length > 0) {
|
|
1183
1252
|
refuse("drive.store.late", "drive.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
|
|
@@ -1197,6 +1266,7 @@ async function driveOneCall(drive) {
|
|
|
1197
1266
|
cursor = offered.at;
|
|
1198
1267
|
}
|
|
1199
1268
|
const taskId = task.id;
|
|
1269
|
+
const allocationOf = () => String(latestTask().allocationId);
|
|
1200
1270
|
const latestTask = () => task;
|
|
1201
1271
|
const updated = (until, what) => waitFor(what, envelope => {
|
|
1202
1272
|
const event = envelope.event;
|
|
@@ -1212,14 +1282,16 @@ async function driveOneCall(drive) {
|
|
|
1212
1282
|
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
1213
1283
|
}, cursor);
|
|
1214
1284
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1285
|
+
ruleEvaluated("drive.timeout");
|
|
1215
1286
|
const send = async (command, dialId) => {
|
|
1287
|
+
ruleEvaluated("drive.command.rejected", "drive.command.failed");
|
|
1216
1288
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
|
|
1217
1289
|
found.push(...own);
|
|
1218
1290
|
if (own.length > 0)
|
|
1219
1291
|
return undefined;
|
|
1220
1292
|
let result;
|
|
1221
1293
|
try {
|
|
1222
|
-
result = await drive.connection.execute({ taskId, command });
|
|
1294
|
+
result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
|
|
1223
1295
|
}
|
|
1224
1296
|
catch (error) {
|
|
1225
1297
|
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1261,10 +1333,11 @@ async function driveOneCall(drive) {
|
|
|
1261
1333
|
// control still declared: a host holds it back (command.phase.handling), and an adapter that
|
|
1262
1334
|
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1263
1335
|
const holdRefusedOutsideHandling = async () => {
|
|
1336
|
+
ruleEvaluated("drive.command.rejected", "drive.command.handling");
|
|
1264
1337
|
const phase = String(latestTask().phase);
|
|
1265
1338
|
let answer;
|
|
1266
1339
|
try {
|
|
1267
|
-
answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
|
|
1340
|
+
answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
|
|
1268
1341
|
}
|
|
1269
1342
|
catch (error) {
|
|
1270
1343
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1291,7 +1364,7 @@ async function driveOneCall(drive) {
|
|
|
1291
1364
|
return found;
|
|
1292
1365
|
cursor = Math.max(cursor, started.at);
|
|
1293
1366
|
}
|
|
1294
|
-
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
1367
|
+
const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1295
1368
|
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
1296
1369
|
if (isRecord(opened) && opened.status === "opened")
|
|
1297
1370
|
session = opened.session;
|
|
@@ -1305,8 +1378,9 @@ async function driveOneCall(drive) {
|
|
|
1305
1378
|
if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
|
|
1306
1379
|
const at = new Date().toISOString();
|
|
1307
1380
|
const report = async (body) => {
|
|
1381
|
+
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1308
1382
|
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1309
|
-
const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
|
|
1383
|
+
const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
|
|
1310
1384
|
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1311
1385
|
found.push(...own);
|
|
1312
1386
|
if (own.length > 0)
|
package/dist/validation.js
CHANGED
|
@@ -131,6 +131,7 @@ const isDurationSeconds = (value) => typeof value === "number" && Number.isInteg
|
|
|
131
131
|
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
132
132
|
const isUserId = isFilled;
|
|
133
133
|
const isTaskId = isFilled;
|
|
134
|
+
const isAllocationId = isFilled;
|
|
134
135
|
const ruleListeners = new Set();
|
|
135
136
|
/**
|
|
136
137
|
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
@@ -941,6 +942,7 @@ function validateMonitoring(value, channel, path, into) {
|
|
|
941
942
|
}
|
|
942
943
|
into.require(isUserId(value.memberId), "task.monitoring.memberId", `${path}.memberId`, "a monitored call names the member on it");
|
|
943
944
|
into.require(isTaskId(value.taskId), "task.monitoring.taskId", `${path}.taskId`, "a monitored call names the member's task");
|
|
945
|
+
into.require(isAllocationId(value.allocationId), "task.monitoring.allocationId", `${path}.allocationId`, "a monitored call names the member's task by its allocation too");
|
|
944
946
|
into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
|
|
945
947
|
into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
|
|
946
948
|
}
|
|
@@ -955,6 +957,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
955
957
|
return;
|
|
956
958
|
}
|
|
957
959
|
into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
|
|
960
|
+
into.require(isAllocationId(task.allocationId), "task.allocationId", `${path}.allocationId`, "a task names this life of itself: an allocation id minted once per offer, never reused for the life of the login");
|
|
958
961
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
959
962
|
into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
|
|
960
963
|
if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
|
|
@@ -1234,6 +1237,7 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
1234
1237
|
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1235
1238
|
}
|
|
1236
1239
|
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
1240
|
+
into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
|
|
1237
1241
|
if (request.note !== undefined)
|
|
1238
1242
|
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
1239
1243
|
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
@@ -1522,12 +1526,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1522
1526
|
break;
|
|
1523
1527
|
case "task-media-started":
|
|
1524
1528
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
1529
|
+
into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1525
1530
|
break;
|
|
1526
1531
|
case "task-media-ended":
|
|
1527
1532
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1533
|
+
into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1528
1534
|
break;
|
|
1529
1535
|
case "task-ended":
|
|
1530
1536
|
into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1537
|
+
into.require(isAllocationId(event.allocationId), "event.taskEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
|
|
1531
1538
|
validateTaskOutcome(event.outcome, `${at}.outcome`, into);
|
|
1532
1539
|
break;
|
|
1533
1540
|
case "dial-outcome": {
|
|
@@ -1539,6 +1546,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1539
1546
|
}
|
|
1540
1547
|
if (event.taskId !== undefined)
|
|
1541
1548
|
into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
1549
|
+
allocationWithTask(event, "event.dialOutcome", at, into);
|
|
1542
1550
|
if (event.destinationId !== undefined)
|
|
1543
1551
|
into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
|
|
1544
1552
|
if (event.reason !== undefined)
|
|
@@ -1559,6 +1567,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1559
1567
|
into.filled(event.observed, "event.diagnostic.observed", `${at}.observed`, "a diagnostic states what was observed instead");
|
|
1560
1568
|
if (event.taskId !== undefined)
|
|
1561
1569
|
into.require(isTaskId(event.taskId), "event.diagnostic.taskId", `${at}.taskId`, "taskId must be a task id when present");
|
|
1570
|
+
allocationWithTask(event, "event.diagnostic", at, into);
|
|
1562
1571
|
break;
|
|
1563
1572
|
case "queue-summary":
|
|
1564
1573
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
@@ -1744,6 +1753,7 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
|
|
|
1744
1753
|
return into.violations;
|
|
1745
1754
|
}
|
|
1746
1755
|
into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
|
|
1756
|
+
into.require(isAllocationId(report.allocationId), "handlingReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
|
|
1747
1757
|
into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
|
|
1748
1758
|
into.timestamp(report.at, "handlingReport.at", `${path}.at`);
|
|
1749
1759
|
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
@@ -1776,6 +1786,15 @@ function validateMutedBy(device, rule, path, into) {
|
|
|
1776
1786
|
into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
|
|
1777
1787
|
}
|
|
1778
1788
|
}
|
|
1789
|
+
/** An event that may name a task names its allocation with it, and never an allocation alone. */
|
|
1790
|
+
function allocationWithTask(event, rule, at, into) {
|
|
1791
|
+
if (event.taskId !== undefined) {
|
|
1792
|
+
into.require(isAllocationId(event.allocationId), `${rule}.allocationId`, `${at}.allocationId`, "an event that names a task names its allocation too");
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
into.require(event.allocationId === undefined, `${rule}.allocationId.unexpected`, `${at}.allocationId`, "an allocation is named with its task, never alone");
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1779
1798
|
export function validateHostReport(report, path = "host") {
|
|
1780
1799
|
const into = new Collector();
|
|
1781
1800
|
if (!isPlainObject(report)) {
|
package/guide.md
CHANGED
|
@@ -97,6 +97,7 @@ the semantic name in every contract field rather than repeating the primitive ty
|
|
|
97
97
|
type IsoTimestamp = string;
|
|
98
98
|
type UserId = string;
|
|
99
99
|
type TaskId = string;
|
|
100
|
+
type AllocationId = string;
|
|
100
101
|
type DialId = string;
|
|
101
102
|
type DurationSeconds = number;
|
|
102
103
|
```
|
|
@@ -396,7 +397,7 @@ type SetPreferenceRequest =
|
|
|
396
397
|
| { id: PreferenceId; enabled: boolean }
|
|
397
398
|
| { id: PreferenceId; inherit: true };
|
|
398
399
|
|
|
399
|
-
type HandlingReport = { taskId: TaskId; at: IsoTimestamp } & (
|
|
400
|
+
type HandlingReport = { taskId: TaskId; allocationId: AllocationId; at: IsoTimestamp } & (
|
|
400
401
|
| { step: "muted"; mutedBy: MutedBy }
|
|
401
402
|
| { step: Exclude<HandlingStep, "muted">; mutedBy?: never }
|
|
402
403
|
) & (
|
|
@@ -552,6 +553,7 @@ type PersonalBrowser = Browser;
|
|
|
552
553
|
type BrowserSessionKeyInput = {
|
|
553
554
|
providerId: string;
|
|
554
555
|
taskId: TaskId;
|
|
556
|
+
allocationId: AllocationId;
|
|
555
557
|
taskType: string;
|
|
556
558
|
browser: TaskBrowser;
|
|
557
559
|
};
|
|
@@ -643,6 +645,7 @@ type TaskAssisting = {
|
|
|
643
645
|
type TaskMonitoring = {
|
|
644
646
|
memberId: UserId;
|
|
645
647
|
taskId: TaskId;
|
|
648
|
+
allocationId: AllocationId;
|
|
646
649
|
mode: MonitorMode;
|
|
647
650
|
since: IsoTimestamp;
|
|
648
651
|
};
|
|
@@ -667,6 +670,7 @@ type CapabilitySource = "queue" | "ungoverned" | "undetermined";
|
|
|
667
670
|
|
|
668
671
|
type Task<C extends Channel = Channel> = {
|
|
669
672
|
id: TaskId;
|
|
673
|
+
allocationId: AllocationId;
|
|
670
674
|
title: string;
|
|
671
675
|
channel: C;
|
|
672
676
|
taskType: string;
|
|
@@ -772,6 +776,7 @@ type TaskCommand<C extends Channel = Channel> =
|
|
|
772
776
|
|
|
773
777
|
type TaskCommandRequest<C extends Channel = Channel> = {
|
|
774
778
|
taskId: TaskId;
|
|
779
|
+
allocationId: AllocationId;
|
|
775
780
|
command: TaskCommand<C>;
|
|
776
781
|
};
|
|
777
782
|
|
|
@@ -924,6 +929,7 @@ type LeadRequest = {
|
|
|
924
929
|
id: string;
|
|
925
930
|
memberId: UserId;
|
|
926
931
|
taskId: TaskId;
|
|
932
|
+
allocationId: AllocationId;
|
|
927
933
|
note?: string;
|
|
928
934
|
since: IsoTimestamp;
|
|
929
935
|
};
|
|
@@ -1003,6 +1009,7 @@ type OpenMediaResult =
|
|
|
1003
1009
|
|
|
1004
1010
|
type OpenMediaRequest = {
|
|
1005
1011
|
taskId: TaskId;
|
|
1012
|
+
allocationId: AllocationId;
|
|
1006
1013
|
localAudio?: MediaStream;
|
|
1007
1014
|
};
|
|
1008
1015
|
```
|
|
@@ -1033,13 +1040,13 @@ type ProviderEvent =
|
|
|
1033
1040
|
allocationExpiresAt?: IsoTimestamp;
|
|
1034
1041
|
}
|
|
1035
1042
|
| { type: "task-updated"; task: Task }
|
|
1036
|
-
| { type: "task-media-started"; taskId: TaskId }
|
|
1037
|
-
| { type: "task-media-ended"; taskId: TaskId }
|
|
1038
|
-
| { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
|
|
1039
|
-
| { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; destinationId?: string; reason?: string }
|
|
1043
|
+
| { type: "task-media-started"; taskId: TaskId; allocationId: AllocationId }
|
|
1044
|
+
| { type: "task-media-ended"; taskId: TaskId; allocationId: AllocationId }
|
|
1045
|
+
| { type: "task-ended"; taskId: TaskId; allocationId: AllocationId; outcome: TaskOutcome }
|
|
1046
|
+
| { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; allocationId?: AllocationId; destinationId?: string; reason?: string }
|
|
1040
1047
|
| { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
|
|
1041
1048
|
| { type: "queue-summary"; summary: QueueSummary }
|
|
1042
|
-
| { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId }
|
|
1049
|
+
| { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId; allocationId?: AllocationId }
|
|
1043
1050
|
| { type: "team-updated"; team: TeamRoster }
|
|
1044
1051
|
| { type: "contacts-updated"; contacts: Contact[] }
|
|
1045
1052
|
| { type: "calendar-updated"; scheduledActivities: ScheduledActivity[] };
|
|
@@ -2221,7 +2228,8 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2221
2228
|
|
|
2222
2229
|
| Field | Contract |
|
|
2223
2230
|
| --- | --- |
|
|
2224
|
-
| `id` | Required `TaskId`,
|
|
2231
|
+
| `id` | Required `TaskId`, the platform's own identity for the task. Unique among the tasks open at once; a platform retires an id minutes after closing it, so the same id comes back for another customer. Omni scopes it with the provider ID. |
|
|
2232
|
+
| `allocationId` | Required `AllocationId`: this life of the task, minted once per offer and never reused for the life of the login, whatever the id does. Every event, command and report that names a task names its allocation too, so a late dial outcome or a late handling report for the first customer never lands on the next under the same id. The stream refuses an offer reusing one (`stream.taskOffered.allocation`), an update restating another life of the id (`stream.taskUpdated.allocation`), and a media or ending event naming a life that has ended or that nobody has seen (`stream.allocation.ended`, `.unknown`); a `dial-outcome` may name an ended life, since a dial placed late routinely outlives its call, and the host routes it there. See **A task's life on the wire**. |
|
|
2225
2233
|
| `title` | Agent-facing task title. |
|
|
2226
2234
|
| `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
|
|
2227
2235
|
| `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
|
|
@@ -2287,6 +2295,24 @@ The canonical task transitions are:
|
|
|
2287
2295
|
| `completing` | Agent connects back to the party (`connect-back`) | `in-progress` |
|
|
2288
2296
|
| Any phase | Provider emits `task-ended` | Removed |
|
|
2289
2297
|
|
|
2298
|
+
#### A task's life on the wire
|
|
2299
|
+
|
|
2300
|
+
A task id is the platform's, and platforms reuse them: a closed id is retired minutes later, a
|
|
2301
|
+
requeue takes seconds, and on one platform a call was offered twice, thirteen seconds apart, under
|
|
2302
|
+
one id, through a close and a re-offer. Everything that names a task after the fact -- a dial
|
|
2303
|
+
outcome, a media event, an ending, a handling report, a command -- would land on whichever life of
|
|
2304
|
+
the id is open when it arrives. So every offer mints an `allocationId`, unique for the life of the
|
|
2305
|
+
login, and every one of those names it beside the `taskId`. The host keys its state on the pair, a
|
|
2306
|
+
late event finds the life it belongs to or is refused, and a task-scoped browser session
|
|
2307
|
+
(`PROVIDER_NAME__TASK_ID__TAB_NAME`) lives one allocation, never the next customer's cookies under
|
|
2308
|
+
a reused id. A dial the host placed was already safe, since its outcome is placed by the host's own
|
|
2309
|
+
`dialId`; where the allocation earns its place is everything the host does not mint --
|
|
2310
|
+
`task-media-started`, `task-media-ended`, `task-ended`, a `recordStep` naming a task -- any of which
|
|
2311
|
+
would otherwise find the new task under the old id and act on it. The allocation is part of the task
|
|
2312
|
+
the adapter keeps, not a field held in memory beside it: a rebuilt adapter comes back with the same
|
|
2313
|
+
allocation on the same task, or every late event it was minted to catch mismatches
|
|
2314
|
+
(`drive.reload.allocation`).
|
|
2315
|
+
|
|
2290
2316
|
Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
|
|
2291
2317
|
task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
|
|
2292
2318
|
subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
|
|
@@ -2603,11 +2629,12 @@ writes it into its record as it writes every other leg:
|
|
|
2603
2629
|
```ts
|
|
2604
2630
|
declare const connection: Connection<"voice">;
|
|
2605
2631
|
declare const taskId: TaskId;
|
|
2632
|
+
declare const allocationId: AllocationId;
|
|
2606
2633
|
declare const at: IsoTimestamp;
|
|
2607
2634
|
|
|
2608
|
-
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
|
|
2609
|
-
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
|
|
2610
|
-
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
|
|
2635
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
|
|
2636
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
|
|
2637
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
|
|
2611
2638
|
```
|
|
2612
2639
|
|
|
2613
2640
|
The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
|
|
@@ -2724,7 +2751,7 @@ The supported `BrowserIsolationScheme` values, declared under **Shapes**, key as
|
|
|
2724
2751
|
|
|
2725
2752
|
| Enum member | Example session key |
|
|
2726
2753
|
| --- | --- |
|
|
2727
|
-
| `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102.CRM` |
|
|
2754
|
+
| `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102%2Ea1.CRM` -- the task's allocation, so one life of the id, never the next customer's cookies |
|
|
2728
2755
|
| `TAB_NAME` | `CRM` |
|
|
2729
2756
|
| `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
|
|
2730
2757
|
| `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
|
|
@@ -3085,15 +3112,16 @@ across the installation, and it travels four ways so no leg is a lookup:
|
|
|
3085
3112
|
```ts
|
|
3086
3113
|
declare const connection: Connection<"voice">;
|
|
3087
3114
|
declare const taskId: TaskId;
|
|
3115
|
+
declare const allocationId: AllocationId;
|
|
3088
3116
|
|
|
3089
3117
|
// 1. Out, minted by the host.
|
|
3090
|
-
const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
|
|
3118
|
+
const result = await connection.execute({ taskId, allocationId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
|
|
3091
3119
|
|
|
3092
3120
|
// 2. Back on the result, restated, so the host compares and confirms.
|
|
3093
3121
|
expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
|
|
3094
3122
|
|
|
3095
3123
|
// 3. On the outcome, however late, against the task it belonged to.
|
|
3096
|
-
const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
|
|
3124
|
+
const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, allocationId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
|
|
3097
3125
|
|
|
3098
3126
|
// 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
|
|
3099
3127
|
const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
|
|
@@ -3769,7 +3797,7 @@ executeTeamMonitor({ command: { type: "monitor", memberId: "A-1" } })
|
|
|
3769
3797
|
|
|
3770
3798
|
// 2. The lead's own task arrives -- task-offered with `automatic`, as a joined call does -- and
|
|
3771
3799
|
// carries `monitoring`; the task id is the lead's, the member's call is named inside it.
|
|
3772
|
-
// monitoring: { memberId: "A-1", taskId: "call-42", mode: "monitor", since }
|
|
3800
|
+
// monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "monitor", since }
|
|
3773
3801
|
|
|
3774
3802
|
// 3. The lead changes how they are heard; the provider restates the task with the new mode.
|
|
3775
3803
|
executeTeamMonitor({ command: { type: "whisper" } })
|
|
@@ -3806,7 +3834,7 @@ const monitoringLead = {
|
|
|
3806
3834
|
channel: "voice",
|
|
3807
3835
|
capabilities: {},
|
|
3808
3836
|
phase: "in-progress",
|
|
3809
|
-
monitoring: { memberId: "A-1", taskId: "call-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
|
|
3837
|
+
monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
|
|
3810
3838
|
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "monitoring">;
|
|
3811
3839
|
```
|
|
3812
3840
|
|
|
@@ -4438,7 +4466,8 @@ when a lead who joined it leaves -- see **Lead assist**.
|
|
|
4438
4466
|
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
|
|
4439
4467
|
The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
|
|
4440
4468
|
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|
|
4441
|
-
same envelope ID is harmless
|
|
4469
|
+
same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
|
|
4470
|
+
recognised as the late event it is, never applied to the life now open under the same id.
|
|
4442
4471
|
|
|
4443
4472
|
### `dial-outcome`
|
|
4444
4473
|
|
|
@@ -4475,7 +4504,7 @@ fails loudly rather than passing with a note. A task published under `capability
|
|
|
4475
4504
|
| --- | --- |
|
|
4476
4505
|
| `expected` | Required. The rule that was broken, as a sentence a person can read. |
|
|
4477
4506
|
| `observed` | Required. What the platform answered instead. |
|
|
4478
|
-
| `taskId` | Optional. The task concerned, where there is one. |
|
|
4507
|
+
| `taskId` | Optional. The task concerned, where there is one, with its `allocationId` beside it: a task is named with its life, and an allocation never alone. |
|
|
4479
4508
|
|
|
4480
4509
|
### `queue-summary`
|
|
4481
4510
|
|