@xema/omni-protocol 0.1.67 → 0.1.69
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 +36 -0
- package/dist/index.js +2 -2
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +112 -17
- package/dist/validation.js +19 -0
- package/guide.md +77 -20
- 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;
|
|
@@ -1487,10 +1507,24 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
|
|
|
1487
1507
|
}
|
|
1488
1508
|
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
|
|
1489
1509
|
export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
1510
|
+
/**
|
|
1511
|
+
* What the host refused, told to the adapter that published it: a snapshot the host would not
|
|
1512
|
+
* replace its state with, or an event it dropped, and every rule it broke. A refusal the provider
|
|
1513
|
+
* never hears of is a desk frozen at its last good state for a shift with nothing wrong on the
|
|
1514
|
+
* provider's side; this is how both sides see it.
|
|
1515
|
+
*/
|
|
1516
|
+
export interface Refusal {
|
|
1517
|
+
artefact: "snapshot" | "event";
|
|
1518
|
+
/** The envelope refused, where the artefact is an event. */
|
|
1519
|
+
envelopeId?: string;
|
|
1520
|
+
violations: ProtocolViolation[];
|
|
1521
|
+
}
|
|
1490
1522
|
export interface Connection<C extends Channel = Channel> {
|
|
1491
1523
|
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
|
1492
1524
|
/** Delivery order must match the order the provider observes changes. Never replay. */
|
|
1493
1525
|
subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
|
|
1526
|
+
/** Told what the host refused, snapshot or event, with every rule broken. Always; a refusal is visible on both sides. */
|
|
1527
|
+
refused(report: Refusal): void;
|
|
1494
1528
|
/** Nothing may be allocated until a capacity is stated, so every connection receives it. */
|
|
1495
1529
|
setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
|
|
1496
1530
|
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
@@ -1603,6 +1637,8 @@ export interface BrowserSessionKeyInput {
|
|
|
1603
1637
|
/** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
|
|
1604
1638
|
providerId: string;
|
|
1605
1639
|
taskId: TaskId;
|
|
1640
|
+
/** The task's allocation: a task-scoped session lives one life of the task, never the next customer's under a reused id. */
|
|
1641
|
+
allocationId: AllocationId;
|
|
1606
1642
|
/** `Task.taskType`. */
|
|
1607
1643
|
taskType: string;
|
|
1608
1644
|
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
|
@@ -292,6 +292,19 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
292
292
|
const connected = { ...context, host, store: watched.store };
|
|
293
293
|
connection = await adapter.connect(connected);
|
|
294
294
|
const live = connection;
|
|
295
|
+
// A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
|
|
296
|
+
requireMethod(live, "refused", "every connection is told what the host refused");
|
|
297
|
+
const tellRefused = (report) => {
|
|
298
|
+
if (typeof live.refused !== "function")
|
|
299
|
+
return;
|
|
300
|
+
ruleEvaluated("connection.refused.rejected");
|
|
301
|
+
try {
|
|
302
|
+
live.refused(report);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
violations.push({ rule: "connection.refused.rejected", path: "connection.refused", message: `told of a refusal, the adapter threw: ${String(error)}` });
|
|
306
|
+
}
|
|
307
|
+
};
|
|
295
308
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
296
309
|
// than an `enabled` flag, so its presence is the declaration.
|
|
297
310
|
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
@@ -305,14 +318,23 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
305
318
|
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
306
319
|
const eventPayloads = new Map();
|
|
307
320
|
let capacityStated;
|
|
321
|
+
const reloading = { now: false };
|
|
308
322
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
309
323
|
const waiters = new Set();
|
|
310
324
|
unsubscribe = connection.subscribe(envelope => {
|
|
311
325
|
observeEvent(envelope, seen);
|
|
312
|
-
|
|
326
|
+
const broken = validateEventEnvelope(envelope, adapter.manifest, "event", reader());
|
|
327
|
+
violations.push(...broken);
|
|
328
|
+
if (broken.length > 0)
|
|
329
|
+
tellRefused({ artefact: "event", envelopeId: typeof envelope?.id === "string" ? envelope.id : undefined, violations: broken });
|
|
313
330
|
// A diagnostic is informational to a host and a failure to a conformance run: the platform
|
|
314
331
|
// under test broke a rule the adapter relies on, and a green result must not paper over it.
|
|
315
|
-
|
|
332
|
+
// A reload in life has no first connection: the page is gone. The drive keeps its first
|
|
333
|
+
// connection up while the second reads, so a platform that pushes the open task to the new
|
|
334
|
+
// client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
|
|
335
|
+
// defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
|
|
336
|
+
// second adapter is up; every other moment it is.
|
|
337
|
+
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
|
|
316
338
|
violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
|
|
317
339
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
318
340
|
}
|
|
@@ -355,7 +377,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
355
377
|
});
|
|
356
378
|
const snapshot = await connection.snapshot();
|
|
357
379
|
observeSnapshot(snapshot, seen);
|
|
358
|
-
|
|
380
|
+
{
|
|
381
|
+
const broken = validateSnapshot(snapshot, adapter.manifest, "snapshot", reader());
|
|
382
|
+
violations.push(...broken);
|
|
383
|
+
if (broken.length > 0)
|
|
384
|
+
tellRefused({ artefact: "snapshot", violations: broken });
|
|
385
|
+
}
|
|
359
386
|
violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
|
|
360
387
|
stream.seed(snapshot);
|
|
361
388
|
breaks.seed(snapshot);
|
|
@@ -422,7 +449,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
422
449
|
violations.push(...await driveOneCall({
|
|
423
450
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
424
451
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
425
|
-
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
|
|
452
|
+
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
|
|
426
453
|
}));
|
|
427
454
|
}
|
|
428
455
|
}
|
|
@@ -817,6 +844,11 @@ const REACHABLE_PHASES = {
|
|
|
817
844
|
};
|
|
818
845
|
export class TaskStream {
|
|
819
846
|
tasks = new Map();
|
|
847
|
+
// Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
|
|
848
|
+
// one, an event about a task names the life that is open, and a late event for a life that ended
|
|
849
|
+
// is recognised as that rather than landing on the next customer under the same id.
|
|
850
|
+
allocations = new Set();
|
|
851
|
+
endedAllocations = new Set();
|
|
820
852
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
821
853
|
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
822
854
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
@@ -838,6 +870,23 @@ export class TaskStream {
|
|
|
838
870
|
this.dialled(entry.dialId);
|
|
839
871
|
}
|
|
840
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* Whether an event about a task names the life that is open under its id. One naming a life that
|
|
875
|
+
* has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
|
|
876
|
+
* a different fault. Either way it is not applied to the open task.
|
|
877
|
+
*/
|
|
878
|
+
namesTheOpenLife(event, known, at, refuse) {
|
|
879
|
+
const named = event.allocationId;
|
|
880
|
+
if (typeof named !== "string" || named === known.allocation)
|
|
881
|
+
return true;
|
|
882
|
+
if (this.endedAllocations.has(named)) {
|
|
883
|
+
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`);
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
|
|
887
|
+
}
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
841
890
|
/** How many tasks the stream currently holds open. */
|
|
842
891
|
openCount() { return this.tasks.size; }
|
|
843
892
|
/** 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 +925,7 @@ export class TaskStream {
|
|
|
876
925
|
stages.set(entry.dialId, entry.stage);
|
|
877
926
|
}
|
|
878
927
|
}
|
|
879
|
-
return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
|
|
928
|
+
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
929
|
}
|
|
881
930
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
882
931
|
seed(snapshot) {
|
|
@@ -884,8 +933,11 @@ export class TaskStream {
|
|
|
884
933
|
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
885
934
|
return;
|
|
886
935
|
for (const task of snapshot.tasks) {
|
|
887
|
-
if (isRecord(task) && typeof task.id === "string")
|
|
936
|
+
if (isRecord(task) && typeof task.id === "string") {
|
|
888
937
|
this.tasks.set(task.id, TaskStream.stated(task));
|
|
938
|
+
if (typeof task.allocationId === "string")
|
|
939
|
+
this.allocations.add(task.allocationId);
|
|
940
|
+
}
|
|
889
941
|
this.noteDials(task);
|
|
890
942
|
}
|
|
891
943
|
}
|
|
@@ -920,19 +972,27 @@ export class TaskStream {
|
|
|
920
972
|
}
|
|
921
973
|
this.seed(event.snapshot);
|
|
922
974
|
break;
|
|
923
|
-
case "task-offered":
|
|
924
|
-
ruleEvaluated("stream.taskOffered.duplicate");
|
|
975
|
+
case "task-offered": {
|
|
976
|
+
ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
|
|
925
977
|
if (id === undefined)
|
|
926
978
|
break;
|
|
927
979
|
if (known !== undefined)
|
|
928
980
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
981
|
+
const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
|
|
982
|
+
if (typeof allocation === "string") {
|
|
983
|
+
if (this.allocations.has(allocation)) {
|
|
984
|
+
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`);
|
|
985
|
+
}
|
|
986
|
+
this.allocations.add(allocation);
|
|
987
|
+
}
|
|
929
988
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
930
989
|
this.noteDials(event.task);
|
|
931
990
|
break;
|
|
991
|
+
}
|
|
932
992
|
case "task-updated":
|
|
933
993
|
if (id === undefined)
|
|
934
994
|
break;
|
|
935
|
-
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
995
|
+
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
996
|
if (known === undefined) {
|
|
937
997
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
938
998
|
break;
|
|
@@ -943,6 +1003,11 @@ export class TaskStream {
|
|
|
943
1003
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
944
1004
|
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
1005
|
}
|
|
1006
|
+
// An update is of the life that is open. One carrying another allocation is a copy of a
|
|
1007
|
+
// different life of this id -- a stale republish of the last customer's call, or the next one's.
|
|
1008
|
+
if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
|
|
1009
|
+
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`);
|
|
1010
|
+
}
|
|
946
1011
|
// A task does not go backwards. The stream sees publications, not transitions, and a task may
|
|
947
1012
|
// pass through a phase between two, so what is refused is a phase unreachable from the last
|
|
948
1013
|
// one read by the guide's table: back to pending, back to confirmed once work began, out of
|
|
@@ -993,13 +1058,15 @@ export class TaskStream {
|
|
|
993
1058
|
this.noteDials(event.task);
|
|
994
1059
|
break;
|
|
995
1060
|
case "task-media-started":
|
|
996
|
-
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
|
|
1061
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
|
|
997
1062
|
if (id === undefined)
|
|
998
1063
|
break;
|
|
999
1064
|
if (known === undefined) {
|
|
1000
1065
|
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1001
1066
|
break;
|
|
1002
1067
|
}
|
|
1068
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1069
|
+
break;
|
|
1003
1070
|
if (!AT_WORK.has(known.phase)) {
|
|
1004
1071
|
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
1072
|
}
|
|
@@ -1009,13 +1076,15 @@ export class TaskStream {
|
|
|
1009
1076
|
known.media = "started";
|
|
1010
1077
|
break;
|
|
1011
1078
|
case "task-media-ended":
|
|
1012
|
-
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
|
|
1079
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1013
1080
|
if (id === undefined)
|
|
1014
1081
|
break;
|
|
1015
1082
|
if (known === undefined) {
|
|
1016
1083
|
refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1017
1084
|
break;
|
|
1018
1085
|
}
|
|
1086
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1087
|
+
break;
|
|
1019
1088
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
1020
1089
|
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
1090
|
}
|
|
@@ -1025,15 +1094,27 @@ export class TaskStream {
|
|
|
1025
1094
|
known.media = "ended";
|
|
1026
1095
|
break;
|
|
1027
1096
|
case "task-ended":
|
|
1097
|
+
ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
|
|
1028
1098
|
if (id === undefined)
|
|
1029
1099
|
break;
|
|
1030
|
-
if (known === undefined)
|
|
1100
|
+
if (known === undefined) {
|
|
1031
1101
|
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1105
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1106
|
+
break;
|
|
1107
|
+
this.endedAllocations.add(known.allocation);
|
|
1032
1108
|
this.tasks.delete(id);
|
|
1033
1109
|
break;
|
|
1034
1110
|
case "dial-outcome": {
|
|
1035
1111
|
// 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
|
|
1112
|
+
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1113
|
+
// and why the outcome names the allocation: the host routes it to that life, ended or not.
|
|
1114
|
+
ruleEvaluated("stream.allocation.unknown");
|
|
1115
|
+
if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
|
|
1116
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
|
|
1117
|
+
}
|
|
1037
1118
|
if (typeof event.dialId !== "string")
|
|
1038
1119
|
break;
|
|
1039
1120
|
const dial = this.dials.get(event.dialId);
|
|
@@ -1088,6 +1169,7 @@ async function driveOneCall(drive) {
|
|
|
1088
1169
|
drive.waiters.add(waiter);
|
|
1089
1170
|
});
|
|
1090
1171
|
const recordSurvivesReload = async (at) => {
|
|
1172
|
+
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
|
|
1091
1173
|
let again;
|
|
1092
1174
|
try {
|
|
1093
1175
|
again = drive.rebuild();
|
|
@@ -1104,6 +1186,7 @@ async function driveOneCall(drive) {
|
|
|
1104
1186
|
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
1187
|
return;
|
|
1106
1188
|
}
|
|
1189
|
+
drive.reloading.now = true;
|
|
1107
1190
|
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1108
1191
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1109
1192
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
@@ -1124,6 +1207,10 @@ async function driveOneCall(drive) {
|
|
|
1124
1207
|
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
1208
|
return;
|
|
1126
1209
|
}
|
|
1210
|
+
// The allocation is part of the task, not memory beside it: a reload brings the same life back.
|
|
1211
|
+
if (carried.allocationId !== allocationOf()) {
|
|
1212
|
+
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`);
|
|
1213
|
+
}
|
|
1127
1214
|
const history = carried.handlingHistory;
|
|
1128
1215
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1129
1216
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
@@ -1158,6 +1245,7 @@ async function driveOneCall(drive) {
|
|
|
1158
1245
|
catch (error) {
|
|
1159
1246
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1160
1247
|
}
|
|
1248
|
+
drive.reloading.now = false;
|
|
1161
1249
|
}
|
|
1162
1250
|
};
|
|
1163
1251
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
@@ -1165,6 +1253,7 @@ async function driveOneCall(drive) {
|
|
|
1165
1253
|
// inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
|
|
1166
1254
|
let retainedAtEnd;
|
|
1167
1255
|
const storeReleased = async () => {
|
|
1256
|
+
ruleEvaluated("drive.store.retained");
|
|
1168
1257
|
await Promise.resolve();
|
|
1169
1258
|
await Promise.resolve();
|
|
1170
1259
|
const retained = [...drive.held].filter(key => key.includes(taskId));
|
|
@@ -1178,6 +1267,7 @@ async function driveOneCall(drive) {
|
|
|
1178
1267
|
const nothingLate = () => {
|
|
1179
1268
|
if (retainedAtEnd === undefined)
|
|
1180
1269
|
return;
|
|
1270
|
+
ruleEvaluated("drive.store.late");
|
|
1181
1271
|
const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
|
|
1182
1272
|
if (late.length > 0) {
|
|
1183
1273
|
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 +1287,7 @@ async function driveOneCall(drive) {
|
|
|
1197
1287
|
cursor = offered.at;
|
|
1198
1288
|
}
|
|
1199
1289
|
const taskId = task.id;
|
|
1290
|
+
const allocationOf = () => String(latestTask().allocationId);
|
|
1200
1291
|
const latestTask = () => task;
|
|
1201
1292
|
const updated = (until, what) => waitFor(what, envelope => {
|
|
1202
1293
|
const event = envelope.event;
|
|
@@ -1212,14 +1303,16 @@ async function driveOneCall(drive) {
|
|
|
1212
1303
|
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
1213
1304
|
}, cursor);
|
|
1214
1305
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1306
|
+
ruleEvaluated("drive.timeout");
|
|
1215
1307
|
const send = async (command, dialId) => {
|
|
1308
|
+
ruleEvaluated("drive.command.rejected", "drive.command.failed");
|
|
1216
1309
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
|
|
1217
1310
|
found.push(...own);
|
|
1218
1311
|
if (own.length > 0)
|
|
1219
1312
|
return undefined;
|
|
1220
1313
|
let result;
|
|
1221
1314
|
try {
|
|
1222
|
-
result = await drive.connection.execute({ taskId, command });
|
|
1315
|
+
result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
|
|
1223
1316
|
}
|
|
1224
1317
|
catch (error) {
|
|
1225
1318
|
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1261,10 +1354,11 @@ async function driveOneCall(drive) {
|
|
|
1261
1354
|
// control still declared: a host holds it back (command.phase.handling), and an adapter that
|
|
1262
1355
|
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1263
1356
|
const holdRefusedOutsideHandling = async () => {
|
|
1357
|
+
ruleEvaluated("drive.command.rejected", "drive.command.handling");
|
|
1264
1358
|
const phase = String(latestTask().phase);
|
|
1265
1359
|
let answer;
|
|
1266
1360
|
try {
|
|
1267
|
-
answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
|
|
1361
|
+
answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
|
|
1268
1362
|
}
|
|
1269
1363
|
catch (error) {
|
|
1270
1364
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1291,7 +1385,7 @@ async function driveOneCall(drive) {
|
|
|
1291
1385
|
return found;
|
|
1292
1386
|
cursor = Math.max(cursor, started.at);
|
|
1293
1387
|
}
|
|
1294
|
-
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
1388
|
+
const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1295
1389
|
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
1296
1390
|
if (isRecord(opened) && opened.status === "opened")
|
|
1297
1391
|
session = opened.session;
|
|
@@ -1305,8 +1399,9 @@ async function driveOneCall(drive) {
|
|
|
1305
1399
|
if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
|
|
1306
1400
|
const at = new Date().toISOString();
|
|
1307
1401
|
const report = async (body) => {
|
|
1402
|
+
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1308
1403
|
// 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 };
|
|
1404
|
+
const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
|
|
1310
1405
|
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1311
1406
|
found.push(...own);
|
|
1312
1407
|
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[] };
|
|
@@ -1059,9 +1066,16 @@ adapter compiled against the browser lib.
|
|
|
1059
1066
|
### Adapter and connection
|
|
1060
1067
|
|
|
1061
1068
|
```ts
|
|
1069
|
+
type Refusal = {
|
|
1070
|
+
artefact: "snapshot" | "event";
|
|
1071
|
+
envelopeId?: string;
|
|
1072
|
+
violations: ProtocolViolation[];
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1062
1075
|
type Connection<C extends Channel = Channel> = {
|
|
1063
1076
|
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
|
1064
1077
|
subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
|
|
1078
|
+
refused(report: Refusal): void;
|
|
1065
1079
|
setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
|
|
1066
1080
|
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
1067
1081
|
disconnect(): Promise<void>;
|
|
@@ -2021,6 +2035,7 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
2021
2035
|
| `snapshot()` | Always. |
|
|
2022
2036
|
| `subscribe(listener)` | Always. |
|
|
2023
2037
|
| `disconnect()` | Always. |
|
|
2038
|
+
| `refused(report)` | Always. The host tells the adapter what it would not take -- a snapshot it did not replace its state with, an event it dropped -- with every rule broken, so a refusal is visible on both sides. See **What the host does with what it refuses**. |
|
|
2024
2039
|
| `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
|
|
2025
2040
|
| `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
|
|
2026
2041
|
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. Each `User` carries its `timeZone` where the provider knows it. |
|
|
@@ -2221,7 +2236,8 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2221
2236
|
|
|
2222
2237
|
| Field | Contract |
|
|
2223
2238
|
| --- | --- |
|
|
2224
|
-
| `id` | Required `TaskId`,
|
|
2239
|
+
| `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. |
|
|
2240
|
+
| `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
2241
|
| `title` | Agent-facing task title. |
|
|
2226
2242
|
| `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
|
|
2227
2243
|
| `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 +2303,24 @@ The canonical task transitions are:
|
|
|
2287
2303
|
| `completing` | Agent connects back to the party (`connect-back`) | `in-progress` |
|
|
2288
2304
|
| Any phase | Provider emits `task-ended` | Removed |
|
|
2289
2305
|
|
|
2306
|
+
#### A task's life on the wire
|
|
2307
|
+
|
|
2308
|
+
A task id is the platform's, and platforms reuse them: a closed id is retired minutes later, a
|
|
2309
|
+
requeue takes seconds, and on one platform a call was offered twice, thirteen seconds apart, under
|
|
2310
|
+
one id, through a close and a re-offer. Everything that names a task after the fact -- a dial
|
|
2311
|
+
outcome, a media event, an ending, a handling report, a command -- would land on whichever life of
|
|
2312
|
+
the id is open when it arrives. So every offer mints an `allocationId`, unique for the life of the
|
|
2313
|
+
login, and every one of those names it beside the `taskId`. The host keys its state on the pair, a
|
|
2314
|
+
late event finds the life it belongs to or is refused, and a task-scoped browser session
|
|
2315
|
+
(`PROVIDER_NAME__TASK_ID__TAB_NAME`) lives one allocation, never the next customer's cookies under
|
|
2316
|
+
a reused id. A dial the host placed was already safe, since its outcome is placed by the host's own
|
|
2317
|
+
`dialId`; where the allocation earns its place is everything the host does not mint --
|
|
2318
|
+
`task-media-started`, `task-media-ended`, `task-ended`, a `recordStep` naming a task -- any of which
|
|
2319
|
+
would otherwise find the new task under the old id and act on it. The allocation is part of the task
|
|
2320
|
+
the adapter keeps, not a field held in memory beside it: a rebuilt adapter comes back with the same
|
|
2321
|
+
allocation on the same task, or every late event it was minted to catch mismatches
|
|
2322
|
+
(`drive.reload.allocation`).
|
|
2323
|
+
|
|
2290
2324
|
Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
|
|
2291
2325
|
task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
|
|
2292
2326
|
subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
|
|
@@ -2332,9 +2366,10 @@ Reaching the instant is not itself a transition: the provider reports what it di
|
|
|
2332
2366
|
|
|
2333
2367
|
**Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
|
|
2334
2368
|
spreading the `preview` one carries `previewEndsAt` and `atDeadline` with it, and the host refuses
|
|
2335
|
-
the update (`task.preview.deadline.unexpected`)
|
|
2336
|
-
|
|
2337
|
-
wait for, so it carries neither
|
|
2369
|
+
the update (`task.preview.deadline.unexpected`): the desk keeps the task as it last stood, and the
|
|
2370
|
+
provider is told through `refused` exactly which rule, so the state that looked right on its side
|
|
2371
|
+
is named on its side. The task past preview has no deadline to wait for, so it carries neither
|
|
2372
|
+
field.
|
|
2338
2373
|
|
|
2339
2374
|
**A task is never its audio.** A voice task is the allocation: the call is offered when it is
|
|
2340
2375
|
routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
|
|
@@ -2603,11 +2638,12 @@ writes it into its record as it writes every other leg:
|
|
|
2603
2638
|
```ts
|
|
2604
2639
|
declare const connection: Connection<"voice">;
|
|
2605
2640
|
declare const taskId: TaskId;
|
|
2641
|
+
declare const allocationId: AllocationId;
|
|
2606
2642
|
declare const at: IsoTimestamp;
|
|
2607
2643
|
|
|
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
|
|
2644
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
|
|
2645
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
|
|
2646
|
+
void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
|
|
2611
2647
|
```
|
|
2612
2648
|
|
|
2613
2649
|
The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
|
|
@@ -2724,7 +2760,7 @@ The supported `BrowserIsolationScheme` values, declared under **Shapes**, key as
|
|
|
2724
2760
|
|
|
2725
2761
|
| Enum member | Example session key |
|
|
2726
2762
|
| --- | --- |
|
|
2727
|
-
| `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102.CRM` |
|
|
2763
|
+
| `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
2764
|
| `TAB_NAME` | `CRM` |
|
|
2729
2765
|
| `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
|
|
2730
2766
|
| `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
|
|
@@ -3085,15 +3121,16 @@ across the installation, and it travels four ways so no leg is a lookup:
|
|
|
3085
3121
|
```ts
|
|
3086
3122
|
declare const connection: Connection<"voice">;
|
|
3087
3123
|
declare const taskId: TaskId;
|
|
3124
|
+
declare const allocationId: AllocationId;
|
|
3088
3125
|
|
|
3089
3126
|
// 1. Out, minted by the host.
|
|
3090
|
-
const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
|
|
3127
|
+
const result = await connection.execute({ taskId, allocationId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
|
|
3091
3128
|
|
|
3092
3129
|
// 2. Back on the result, restated, so the host compares and confirms.
|
|
3093
3130
|
expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
|
|
3094
3131
|
|
|
3095
3132
|
// 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" };
|
|
3133
|
+
const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, allocationId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
|
|
3097
3134
|
|
|
3098
3135
|
// 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
|
|
3099
3136
|
const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
|
|
@@ -3769,7 +3806,7 @@ executeTeamMonitor({ command: { type: "monitor", memberId: "A-1" } })
|
|
|
3769
3806
|
|
|
3770
3807
|
// 2. The lead's own task arrives -- task-offered with `automatic`, as a joined call does -- and
|
|
3771
3808
|
// 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 }
|
|
3809
|
+
// monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "monitor", since }
|
|
3773
3810
|
|
|
3774
3811
|
// 3. The lead changes how they are heard; the provider restates the task with the new mode.
|
|
3775
3812
|
executeTeamMonitor({ command: { type: "whisper" } })
|
|
@@ -3806,7 +3843,7 @@ const monitoringLead = {
|
|
|
3806
3843
|
channel: "voice",
|
|
3807
3844
|
capabilities: {},
|
|
3808
3845
|
phase: "in-progress",
|
|
3809
|
-
monitoring: { memberId: "A-1", taskId: "call-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
|
|
3846
|
+
monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
|
|
3810
3847
|
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "monitoring">;
|
|
3811
3848
|
```
|
|
3812
3849
|
|
|
@@ -4438,7 +4475,8 @@ when a lead who joined it leaves -- see **Lead assist**.
|
|
|
4438
4475
|
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
|
|
4439
4476
|
The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
|
|
4440
4477
|
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|
|
4441
|
-
same envelope ID is harmless
|
|
4478
|
+
same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
|
|
4479
|
+
recognised as the late event it is, never applied to the life now open under the same id.
|
|
4442
4480
|
|
|
4443
4481
|
### `dial-outcome`
|
|
4444
4482
|
|
|
@@ -4475,7 +4513,7 @@ fails loudly rather than passing with a note. A task published under `capability
|
|
|
4475
4513
|
| --- | --- |
|
|
4476
4514
|
| `expected` | Required. The rule that was broken, as a sentence a person can read. |
|
|
4477
4515
|
| `observed` | Required. What the platform answered instead. |
|
|
4478
|
-
| `taskId` | Optional. The task concerned, where there is one. |
|
|
4516
|
+
| `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
4517
|
|
|
4480
4518
|
### `queue-summary`
|
|
4481
4519
|
|
|
@@ -4576,6 +4614,25 @@ and may be compiled against a different protocol version, so its output is untru
|
|
|
4576
4614
|
Validating a snapshot before it replaces provider state is what stops a malformed task from
|
|
4577
4615
|
reaching the workspace.
|
|
4578
4616
|
|
|
4617
|
+
### What the host does with what it refuses
|
|
4618
|
+
|
|
4619
|
+
A refusal has an aftermath on the desk and a report to the provider, and both are stated.
|
|
4620
|
+
|
|
4621
|
+
- **A refused snapshot replaces nothing.** The host keeps the last state it took from this
|
|
4622
|
+
provider whole, and shows the provider as faulted -- the transport as it stands, and the words
|
|
4623
|
+
"last update refused" with the rule -- so the agent knows the view is standing still rather than
|
|
4624
|
+
believing it current. It does not adopt the good half of a snapshot: a task the agent is on would
|
|
4625
|
+
vanish from the desk while the call is up.
|
|
4626
|
+
- **A refused event is dropped and counted.** The state the host holds does not move for it. The
|
|
4627
|
+
provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
|
|
4628
|
+
nothing.
|
|
4629
|
+
- **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
|
|
4630
|
+
an event, and every violation with its rule and path. An adapter logs it at error and treats it as
|
|
4631
|
+
its own defect until shown otherwise: what the host refused never reached the agent, and a
|
|
4632
|
+
provider that hears nothing runs a whole shift beside a frozen desk with nothing wrong on its
|
|
4633
|
+
side. The harness tells an adapter under test exactly as a host does
|
|
4634
|
+
(`connection.refused.required`, `connection.refused.rejected`).
|
|
4635
|
+
|
|
4579
4636
|
## Conformance helpers
|
|
4580
4637
|
|
|
4581
4638
|
### `exerciseAdapter(adapter, context, options?)`
|