@xema/omni-protocol 0.1.103 → 0.1.104
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 +41 -12
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +17 -6
- package/dist/validation.js +102 -28
- package/guide.md +104 -47
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -153,6 +153,12 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
153
153
|
authenticationMethods: AuthenticationMethod[];
|
|
154
154
|
idleCapabilities?: IdleCapabilities<C>;
|
|
155
155
|
/** Optional provider-clock sampling through Connection.checkTime; absent means unsupported. */
|
|
156
|
+
/**
|
|
157
|
+
* Implements `checkTime`, and states `providerTime` on every snapshot. Required of a provider
|
|
158
|
+
* that publishes an instant the desk renders as a running duration -- a member's `since`, a
|
|
159
|
+
* lead's `listening.since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts
|
|
160
|
+
* from the provider's clock and two screens differ by their delay alone.
|
|
161
|
+
*/
|
|
156
162
|
timeCheck?: true;
|
|
157
163
|
/** Provider uses its own timestamps for final records; host timestamps are advisory. Omission makes no trust promise. */
|
|
158
164
|
timestampAuthority?: "provider";
|
|
@@ -1206,8 +1212,13 @@ export interface BreakRequest {
|
|
|
1206
1212
|
*/
|
|
1207
1213
|
export type ForcedBreak = {
|
|
1208
1214
|
by: UserId | "provider";
|
|
1209
|
-
/**
|
|
1210
|
-
|
|
1215
|
+
/**
|
|
1216
|
+
* How long the break is expected to run yet, in whole seconds from this publication, restated
|
|
1217
|
+
* on every publication that carries it so every screen counts down the same number from
|
|
1218
|
+
* receipt. Advisory only: nothing ends the break but the agent. Absent once it has run out, or
|
|
1219
|
+
* where none was expected.
|
|
1220
|
+
*/
|
|
1221
|
+
expectedEndsInSeconds?: DurationSeconds;
|
|
1211
1222
|
};
|
|
1212
1223
|
export interface BreakState {
|
|
1213
1224
|
status: BreakStatus;
|
|
@@ -1264,10 +1275,10 @@ export interface TeamMember {
|
|
|
1264
1275
|
break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
|
|
1265
1276
|
/** The member's open tasks, trimmed, kept current by the provider; `[]` when they hold none, omitted only where the provider cannot see them. */
|
|
1266
1277
|
tasks?: MemberTask[];
|
|
1267
|
-
/**
|
|
1268
|
-
listening?: MemberListening;
|
|
1269
|
-
/** The member's own history for the day: sign-in, sign-out, breaks and productivity. Omitted where the provider cannot say. */
|
|
1270
|
-
shift?:
|
|
1278
|
+
/** Every lead on this member's call, listening, coaching or joined, published to every lead alike; absent while none is. */
|
|
1279
|
+
listening?: MemberListening[];
|
|
1280
|
+
/** The member's own history for the day: sign-in, sign-out, breaks and productivity, the same numbers the member's own snapshot carries. Omitted where the provider cannot say. */
|
|
1281
|
+
shift?: Shift;
|
|
1271
1282
|
/** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
|
|
1272
1283
|
request?: MemberRequest;
|
|
1273
1284
|
}
|
|
@@ -1310,11 +1321,14 @@ export type MemberTask<C extends Channel = Channel> = {
|
|
|
1310
1321
|
audio?: never;
|
|
1311
1322
|
});
|
|
1312
1323
|
/**
|
|
1313
|
-
*
|
|
1314
|
-
* when.
|
|
1315
|
-
*
|
|
1324
|
+
* A lead on this member's call: who, which of the member's calls, in the mode they are heard,
|
|
1325
|
+
* since when. Every lead sees every entry; the application opens `openAudio` on the entry whose
|
|
1326
|
+
* `leadId` is the signed-in lead, and closes it when that entry goes. A lead who is heard --
|
|
1327
|
+
* `join-call` -- is also on the task's `onCall` as an `agent`; `listen` and `coach` leave no trace
|
|
1328
|
+
* on the member's task.
|
|
1316
1329
|
*/
|
|
1317
1330
|
export interface MemberListening {
|
|
1331
|
+
leadId: UserId;
|
|
1318
1332
|
assignmentId: AssignmentId;
|
|
1319
1333
|
mode: ListeningMode;
|
|
1320
1334
|
since: IsoTimestamp;
|
|
@@ -1326,11 +1340,13 @@ export interface ShiftEvent {
|
|
|
1326
1340
|
kind: ShiftEventKind;
|
|
1327
1341
|
}
|
|
1328
1342
|
/**
|
|
1329
|
-
*
|
|
1343
|
+
* An agent's own history for the day, about the person rather than any one call: when they
|
|
1330
1344
|
* signed in and out, today's totals as the provider counts them, each present only when the
|
|
1331
|
-
* provider knows it, and the day's sign-in, sign-out and break events, oldest first.
|
|
1345
|
+
* provider knows it, and the day's sign-in, sign-out and break events, oldest first. Published
|
|
1346
|
+
* to the agent on their snapshot and on `shift-updated`, and to their lead on the team member
|
|
1347
|
+
* list: the same numbers from the same count.
|
|
1332
1348
|
*/
|
|
1333
|
-
export interface
|
|
1349
|
+
export interface Shift {
|
|
1334
1350
|
signedInAt: IsoTimestamp;
|
|
1335
1351
|
signedOutAt?: IsoTimestamp;
|
|
1336
1352
|
talkSeconds?: DurationSeconds;
|
|
@@ -1550,6 +1566,12 @@ export type PreferenceResult = {
|
|
|
1550
1566
|
export interface Snapshot<C extends Channel = Channel> {
|
|
1551
1567
|
transport: TransportStatus;
|
|
1552
1568
|
loginId: string;
|
|
1569
|
+
/**
|
|
1570
|
+
* The provider's own instant of this read, required of a provider that declares `timeCheck`:
|
|
1571
|
+
* every duration the desk renders off the active call is counted from the provider's clock, and
|
|
1572
|
+
* this sample saves a `checkTime` for it. Every envelope's `occurredAt` is a sample too.
|
|
1573
|
+
*/
|
|
1574
|
+
providerTime?: IsoTimestamp;
|
|
1553
1575
|
break: BreakState;
|
|
1554
1576
|
/** Every task currently owned by this agent for this provider. */
|
|
1555
1577
|
tasks: Task<C>[];
|
|
@@ -1557,6 +1579,8 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
1557
1579
|
taskCount: number;
|
|
1558
1580
|
contacts?: Contact[];
|
|
1559
1581
|
calendar?: ScheduledActivity[];
|
|
1582
|
+
/** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
|
|
1583
|
+
shift?: Shift;
|
|
1560
1584
|
team?: TeamMembers;
|
|
1561
1585
|
}
|
|
1562
1586
|
/**
|
|
@@ -1668,6 +1692,11 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1668
1692
|
} | {
|
|
1669
1693
|
type: "calendar-updated";
|
|
1670
1694
|
calendar: ScheduledActivity[];
|
|
1695
|
+
}
|
|
1696
|
+
/** The agent's own day, whole, whenever a total moves: a task ended, a break ended. */
|
|
1697
|
+
| {
|
|
1698
|
+
type: "shift-updated";
|
|
1699
|
+
shift: Shift;
|
|
1671
1700
|
};
|
|
1672
1701
|
/**
|
|
1673
1702
|
* The `Provider` prefix survives here for a mechanical reason rather than a naming one: `Event`
|
package/dist/testing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
7
7
|
* rules -- each optional part of a task, each optional part of the break state and team member list, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "contacts", "calendar", "team.policies"];
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "shift", "contacts", "calendar", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
package/dist/testing.js
CHANGED
|
@@ -28,6 +28,7 @@ const STATE_SUBJECTS = [
|
|
|
28
28
|
"team.tasks",
|
|
29
29
|
"team.listening",
|
|
30
30
|
"team.shift",
|
|
31
|
+
"shift",
|
|
31
32
|
"contacts",
|
|
32
33
|
"calendar",
|
|
33
34
|
"team.policies",
|
|
@@ -38,7 +39,7 @@ const EVENT_TYPES = {
|
|
|
38
39
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
39
40
|
"task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
40
41
|
"team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
|
|
41
|
-
"contacts-updated": true, "calendar-updated": true,
|
|
42
|
+
"contacts-updated": true, "calendar-updated": true, "shift-updated": true,
|
|
42
43
|
};
|
|
43
44
|
const CONTRACT_SUBJECTS = [
|
|
44
45
|
...STATE_SUBJECTS,
|
|
@@ -94,7 +95,7 @@ function observeMember(member, seen) {
|
|
|
94
95
|
return;
|
|
95
96
|
if (some(member.tasks))
|
|
96
97
|
seen.add("team.tasks");
|
|
97
|
-
if (member.listening
|
|
98
|
+
if (some(member.listening))
|
|
98
99
|
seen.add("team.listening");
|
|
99
100
|
if (member.shift !== undefined)
|
|
100
101
|
seen.add("team.shift");
|
|
@@ -126,6 +127,8 @@ function observeSnapshot(value, seen) {
|
|
|
126
127
|
seen.add("contacts");
|
|
127
128
|
if (some(value.calendar))
|
|
128
129
|
seen.add("calendar");
|
|
130
|
+
if (value.shift !== undefined)
|
|
131
|
+
seen.add("shift");
|
|
129
132
|
}
|
|
130
133
|
function observeEvent(envelope, seen) {
|
|
131
134
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
@@ -162,6 +165,9 @@ function observeEvent(envelope, seen) {
|
|
|
162
165
|
if (some(event.calendar))
|
|
163
166
|
seen.add("calendar");
|
|
164
167
|
break;
|
|
168
|
+
case "shift-updated":
|
|
169
|
+
seen.add("shift");
|
|
170
|
+
break;
|
|
165
171
|
default: break;
|
|
166
172
|
}
|
|
167
173
|
}
|
|
@@ -719,9 +725,14 @@ function userIdsIn(snapshot) {
|
|
|
719
725
|
add(snapshot?.break?.forced?.by);
|
|
720
726
|
const team = snapshot?.team;
|
|
721
727
|
if (isRecord(team)) {
|
|
722
|
-
for (const member of Array.isArray(team.members) ? team.members : [])
|
|
723
|
-
if (isRecord(member))
|
|
724
|
-
|
|
728
|
+
for (const member of Array.isArray(team.members) ? team.members : []) {
|
|
729
|
+
if (!isRecord(member))
|
|
730
|
+
continue;
|
|
731
|
+
add(member.id);
|
|
732
|
+
for (const entry of Array.isArray(member.listening) ? member.listening : [])
|
|
733
|
+
if (isRecord(entry))
|
|
734
|
+
add(entry.leadId);
|
|
735
|
+
}
|
|
725
736
|
}
|
|
726
737
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
727
738
|
const t = task;
|
|
@@ -1063,7 +1074,7 @@ const REACHABLE_PHASES = {
|
|
|
1063
1074
|
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
1064
1075
|
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
1065
1076
|
"task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
|
|
1066
|
-
"contacts-updated", "calendar-updated",
|
|
1077
|
+
"contacts-updated", "calendar-updated", "shift-updated",
|
|
1067
1078
|
]);
|
|
1068
1079
|
export class TaskStream {
|
|
1069
1080
|
tasks = new Map();
|
package/dist/validation.js
CHANGED
|
@@ -1313,8 +1313,9 @@ function validateForcedBreak(value, path, into) {
|
|
|
1313
1313
|
for (const field of ["endsAutomatically", "endsAt"]) {
|
|
1314
1314
|
into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
|
|
1315
1315
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1316
|
+
into.require(!Object.hasOwn(value, "expectedDurationMs"), "break.forced.expectedDurationMs.renamed", `${path}.expectedDurationMs`, "the state says how long is left, as expectedEndsInSeconds; the duration asked for travels on the lead's command alone");
|
|
1317
|
+
if (value.expectedEndsInSeconds !== undefined)
|
|
1318
|
+
into.require(isDurationSeconds(value.expectedEndsInSeconds), "break.forced.expectedEndsInSeconds", `${path}.expectedEndsInSeconds`, "expectedEndsInSeconds is how long the break is expected to run yet: a whole number of seconds, zero or more, counted from this publication");
|
|
1318
1319
|
}
|
|
1319
1320
|
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
1320
1321
|
export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
@@ -1435,7 +1436,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1435
1436
|
into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
|
|
1436
1437
|
}
|
|
1437
1438
|
else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
|
|
1438
|
-
|
|
1439
|
+
// Every lead's entry is on the member; the one this command moves is the signed-in lead's.
|
|
1440
|
+
into.require(isPlainObject(target) && Array.isArray(target.listening) && target.listening.some((entry) => isPlainObject(entry) && entry.leadId === self), "team.command.listening", path, `${command.type} needs this lead on this member's call: the team member list shows listening on them naming this lead`);
|
|
1439
1441
|
}
|
|
1440
1442
|
return into.violations;
|
|
1441
1443
|
}
|
|
@@ -1631,10 +1633,19 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
|
1631
1633
|
}
|
|
1632
1634
|
validateTeamMemberInto(member, at, context, into);
|
|
1633
1635
|
});
|
|
1634
|
-
// One member's call at a time.
|
|
1635
|
-
const
|
|
1636
|
-
|
|
1637
|
-
|
|
1636
|
+
// One member's call at a time, per lead.
|
|
1637
|
+
const onCalls = new Map();
|
|
1638
|
+
for (const member of teamMembers.members) {
|
|
1639
|
+
if (!isPlainObject(member) || !Array.isArray(member.listening))
|
|
1640
|
+
continue;
|
|
1641
|
+
const leads = new Set(member.listening.filter(isPlainObject).map(entry => entry.leadId).filter((id) => typeof id === "string"));
|
|
1642
|
+
for (const leadId of leads)
|
|
1643
|
+
onCalls.set(leadId, (onCalls.get(leadId) ?? 0) + 1);
|
|
1644
|
+
}
|
|
1645
|
+
for (const [leadId, count] of onCalls) {
|
|
1646
|
+
if (count > 1)
|
|
1647
|
+
into.add("team.listening.single", `${path}.members`, `${leadId} is on ${count} members' calls: a lead is on one member's call at a time`);
|
|
1648
|
+
}
|
|
1638
1649
|
}
|
|
1639
1650
|
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
1640
1651
|
export function validateTeamMember(member, path = "member", context = {}) {
|
|
@@ -1679,23 +1690,41 @@ function validateTeamMemberInto(member, at, context, into) {
|
|
|
1679
1690
|
into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
|
|
1680
1691
|
}
|
|
1681
1692
|
}
|
|
1682
|
-
//
|
|
1693
|
+
// Every lead on this member's call, in the mode they are heard, published to every lead alike.
|
|
1683
1694
|
if (member.listening !== undefined) {
|
|
1684
|
-
if (!
|
|
1685
|
-
into.add("team.member.listening.shape", `${at}.listening`, "listening
|
|
1695
|
+
if (!Array.isArray(member.listening)) {
|
|
1696
|
+
into.add("team.member.listening.shape", `${at}.listening`, "listening is an array of the leads on the member's call: absent while none is");
|
|
1686
1697
|
}
|
|
1687
1698
|
else {
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1699
|
+
into.require(member.listening.length > 0, "team.member.listening.empty", `${at}.listening`, "nobody on the call is absence, not an empty list");
|
|
1700
|
+
const leads = new Set();
|
|
1701
|
+
member.listening.forEach((entry, index) => {
|
|
1702
|
+
const here = `${at}.listening[${index}]`;
|
|
1703
|
+
if (!isPlainObject(entry)) {
|
|
1704
|
+
into.add("team.member.listening.entry", here, "each listening entry must be an object");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (into.require(isUserId(entry.leadId), "team.member.listening.leadId", `${here}.leadId`, "a listening entry names the lead on the call, by user id")) {
|
|
1708
|
+
if (leads.has(entry.leadId))
|
|
1709
|
+
into.add("team.member.listening.unique", `${here}.leadId`, `${entry.leadId} is listed twice on one member: a lead is on a member's call once`);
|
|
1710
|
+
leads.add(entry.leadId);
|
|
1711
|
+
}
|
|
1712
|
+
const task = Array.isArray(member.tasks) ? member.tasks.find((task) => isPlainObject(task) && task.assignmentId === entry.assignmentId) : undefined;
|
|
1713
|
+
if (into.require(isAssignmentId(entry.assignmentId), "team.member.listening.assignmentId", `${here}.assignmentId`, "a listening entry names which of the member's calls the lead is on: the application opens its own audio on it") && Array.isArray(member.tasks)) {
|
|
1714
|
+
into.require(task !== undefined, "team.member.listening.assignment", `${here}.assignmentId`, "the call the lead is on is one the member's tasks carry");
|
|
1715
|
+
}
|
|
1716
|
+
if (into.oneOf(entry.mode, LISTENING_MODES, "team.member.listening.mode", `${here}.mode`) && entry.mode === "join-call"
|
|
1717
|
+
&& isPlainObject(task) && Array.isArray(task.onCall) && isUserId(entry.leadId)) {
|
|
1718
|
+
// A lead who is heard is in the room the member sees; a silent one is not.
|
|
1719
|
+
into.require(task.onCall.some((who) => isPlainObject(who) && who.role === "agent" && who.userId === entry.leadId), "team.member.listening.heard", `${here}.mode`, `${entry.leadId} has joined the call and is heard, so the task's onCall carries them as an agent`);
|
|
1720
|
+
}
|
|
1721
|
+
into.timestamp(entry.since, "team.member.listening.since", `${here}.since`);
|
|
1722
|
+
});
|
|
1694
1723
|
}
|
|
1695
1724
|
}
|
|
1696
1725
|
// The member's own history for the day: about the person, not any one call.
|
|
1697
1726
|
if (member.shift !== undefined)
|
|
1698
|
-
validateShiftInto(member.shift, `${at}.shift`, into);
|
|
1727
|
+
validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
|
|
1699
1728
|
// The member's standing ask for a lead, on one of the calls they hold.
|
|
1700
1729
|
if (member.request !== undefined) {
|
|
1701
1730
|
if (!isPlainObject(member.request)) {
|
|
@@ -1721,6 +1750,18 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1721
1750
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
1722
1751
|
const levels = context.levels ?? manifestLevels(manifest);
|
|
1723
1752
|
into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
|
|
1753
|
+
// The provider's own instant of the read: required where the provider has a clock to state, and
|
|
1754
|
+
// a clock nobody declared is not one the desk may count from.
|
|
1755
|
+
if (isPlainObject(manifest) && manifest.timeCheck === true) {
|
|
1756
|
+
into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
|
|
1757
|
+
}
|
|
1758
|
+
else if (snapshot.providerTime !== undefined) {
|
|
1759
|
+
into.add("snapshot.providerTime.unexpected", `${path}.providerTime`, "providerTime is the clock of a provider that declares timeCheck; declare it, or send none");
|
|
1760
|
+
}
|
|
1761
|
+
requireClockInto(snapshot, manifest, path, into);
|
|
1762
|
+
// The agent's own day, as the provider counts it: the same numbers their lead sees.
|
|
1763
|
+
if (snapshot.shift !== undefined)
|
|
1764
|
+
validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
|
|
1724
1765
|
if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
|
|
1725
1766
|
&& context.loginId !== undefined) {
|
|
1726
1767
|
into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
|
|
@@ -1894,6 +1935,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1894
1935
|
return into.violations;
|
|
1895
1936
|
}
|
|
1896
1937
|
const at = `${path}.event`;
|
|
1938
|
+
// A snapshot event is read by validateSnapshot, which asks the clock question of it there.
|
|
1939
|
+
if (event.type !== "snapshot")
|
|
1940
|
+
requireClockInto(event, manifest, at, into);
|
|
1897
1941
|
into.require(!Object.hasOwn(event, "taskId"), "event.assignmentId.renamed", `${at}.taskId`, "an event names a task by its assignment alone; the former field is not accepted");
|
|
1898
1942
|
switch (event.type) {
|
|
1899
1943
|
case "snapshot":
|
|
@@ -2004,6 +2048,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
2004
2048
|
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
2005
2049
|
}
|
|
2006
2050
|
break;
|
|
2051
|
+
case "shift-updated":
|
|
2052
|
+
validateShiftInto(event.shift, `${at}.shift`, into, "shift");
|
|
2053
|
+
break;
|
|
2007
2054
|
case "calendar-updated":
|
|
2008
2055
|
into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
|
|
2009
2056
|
if (!Array.isArray(event.calendar)) {
|
|
@@ -2055,38 +2102,65 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
2055
2102
|
});
|
|
2056
2103
|
}
|
|
2057
2104
|
/** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
|
|
2058
|
-
|
|
2105
|
+
/** Whether a publication carries an instant the desk renders as a running duration, which the provider's clock reads. */
|
|
2106
|
+
function carriesRunningInstant(value) {
|
|
2107
|
+
if (!isPlainObject(value))
|
|
2108
|
+
return false;
|
|
2109
|
+
if (isPlainObject(value.shift) && value.shift.signedInAt !== undefined)
|
|
2110
|
+
return true;
|
|
2111
|
+
const tasks = [
|
|
2112
|
+
...(Array.isArray(value.tasks) ? value.tasks : []),
|
|
2113
|
+
...(isPlainObject(value.task) ? [value.task] : []),
|
|
2114
|
+
];
|
|
2115
|
+
if (tasks.some(task => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))
|
|
2116
|
+
return true;
|
|
2117
|
+
const members = [
|
|
2118
|
+
...(isPlainObject(value.team) && Array.isArray(value.team.members) ? value.team.members : []),
|
|
2119
|
+
...(isPlainObject(value.member) ? [value.member] : []),
|
|
2120
|
+
];
|
|
2121
|
+
return members.some(member => isPlainObject(member) && (member.since !== undefined
|
|
2122
|
+
|| (Array.isArray(member.listening) && member.listening.some((entry) => isPlainObject(entry) && entry.since !== undefined))
|
|
2123
|
+
|| (isPlainObject(member.shift) && member.shift.signedInAt !== undefined)
|
|
2124
|
+
|| (Array.isArray(member.tasks) && member.tasks.some((task) => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))));
|
|
2125
|
+
}
|
|
2126
|
+
/** A running duration is counted from the provider's clock: a provider that publishes one declares timeCheck. */
|
|
2127
|
+
function requireClockInto(value, manifest, path, into) {
|
|
2128
|
+
if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
|
|
2129
|
+
return;
|
|
2130
|
+
into.add("manifest.timeCheck.required", path, "this publishes an instant the desk renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
|
|
2131
|
+
}
|
|
2132
|
+
function validateShiftInto(value, path, into, rule) {
|
|
2059
2133
|
if (!isPlainObject(value)) {
|
|
2060
|
-
into.add(
|
|
2134
|
+
into.add(`${rule}.shape`, path, "shift must be an object when present");
|
|
2061
2135
|
return;
|
|
2062
2136
|
}
|
|
2063
|
-
const signedIn = into.timestamp(value.signedInAt,
|
|
2064
|
-
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt,
|
|
2065
|
-
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt),
|
|
2137
|
+
const signedIn = into.timestamp(value.signedInAt, `${rule}.signedInAt`, `${path}.signedInAt`);
|
|
2138
|
+
if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, `${rule}.signedOutAt`, `${path}.signedOutAt`) && signedIn) {
|
|
2139
|
+
into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), `${rule}.signedOutAt.order`, `${path}.signedOutAt`, "a member signs out after signing in");
|
|
2066
2140
|
}
|
|
2067
2141
|
for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
|
|
2068
2142
|
if (value[field] !== undefined)
|
|
2069
2143
|
into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
|
|
2070
2144
|
}
|
|
2071
2145
|
if (value.tasksHandled !== undefined)
|
|
2072
|
-
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0,
|
|
2146
|
+
into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, `${rule}.tasksHandled`, `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
|
|
2073
2147
|
if (value.events !== undefined) {
|
|
2074
2148
|
if (!Array.isArray(value.events)) {
|
|
2075
|
-
into.add(
|
|
2149
|
+
into.add(`${rule}.events.shape`, `${path}.events`, "events must be an array when present");
|
|
2076
2150
|
return;
|
|
2077
2151
|
}
|
|
2078
2152
|
let previous;
|
|
2079
2153
|
value.events.forEach((event, index) => {
|
|
2080
2154
|
const at = `${path}.events[${index}]`;
|
|
2081
2155
|
if (!isPlainObject(event)) {
|
|
2082
|
-
into.add(
|
|
2156
|
+
into.add(`${rule}.event.shape`, at, "each shift event must be an object");
|
|
2083
2157
|
return;
|
|
2084
2158
|
}
|
|
2085
|
-
into.oneOf(event.kind, SHIFT_EVENT_KINDS,
|
|
2086
|
-
if (into.timestamp(event.at,
|
|
2159
|
+
into.oneOf(event.kind, SHIFT_EVENT_KINDS, `${rule}.event.kind`, `${at}.kind`);
|
|
2160
|
+
if (into.timestamp(event.at, `${rule}.event.at`, `${at}.at`)) {
|
|
2087
2161
|
const instant = Date.parse(event.at);
|
|
2088
2162
|
if (previous !== undefined && instant < previous)
|
|
2089
|
-
into.add(
|
|
2163
|
+
into.add(`${rule}.events.order`, `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
|
|
2090
2164
|
previous = instant;
|
|
2091
2165
|
}
|
|
2092
2166
|
});
|
package/guide.md
CHANGED
|
@@ -474,11 +474,13 @@ type PreferenceResult =
|
|
|
474
474
|
type Snapshot = {
|
|
475
475
|
transport: TransportStatus;
|
|
476
476
|
loginId: string;
|
|
477
|
+
providerTime?: IsoTimestamp;
|
|
477
478
|
break: BreakState;
|
|
478
479
|
tasks: Task[];
|
|
479
480
|
taskCount: number;
|
|
480
481
|
contacts?: Contact[];
|
|
481
482
|
calendar?: ScheduledActivity[];
|
|
483
|
+
shift?: Shift;
|
|
482
484
|
team?: TeamMembers;
|
|
483
485
|
};
|
|
484
486
|
|
|
@@ -933,7 +935,7 @@ type BreakRequest = {
|
|
|
933
935
|
|
|
934
936
|
type ForcedBreak = {
|
|
935
937
|
by: UserId | "provider";
|
|
936
|
-
|
|
938
|
+
expectedEndsInSeconds?: DurationSeconds;
|
|
937
939
|
};
|
|
938
940
|
|
|
939
941
|
type BreakState = {
|
|
@@ -975,8 +977,8 @@ type TeamMember = {
|
|
|
975
977
|
since?: IsoTimestamp;
|
|
976
978
|
break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
|
|
977
979
|
tasks?: MemberTask[];
|
|
978
|
-
listening?: MemberListening;
|
|
979
|
-
shift?:
|
|
980
|
+
listening?: MemberListening[];
|
|
981
|
+
shift?: Shift;
|
|
980
982
|
request?: MemberRequest;
|
|
981
983
|
};
|
|
982
984
|
|
|
@@ -1006,6 +1008,7 @@ type MemberTask<C extends Channel = Channel> = {
|
|
|
1006
1008
|
: { onCall?: never; leadAssist?: never; takenOver?: never; audio?: never });
|
|
1007
1009
|
|
|
1008
1010
|
type MemberListening = {
|
|
1011
|
+
leadId: UserId;
|
|
1009
1012
|
assignmentId: AssignmentId;
|
|
1010
1013
|
mode: ListeningMode;
|
|
1011
1014
|
since: IsoTimestamp;
|
|
@@ -1018,7 +1021,7 @@ type ShiftEvent = {
|
|
|
1018
1021
|
kind: ShiftEventKind;
|
|
1019
1022
|
};
|
|
1020
1023
|
|
|
1021
|
-
type
|
|
1024
|
+
type Shift = {
|
|
1022
1025
|
signedInAt: IsoTimestamp;
|
|
1023
1026
|
signedOutAt?: IsoTimestamp;
|
|
1024
1027
|
talkSeconds?: DurationSeconds;
|
|
@@ -1134,7 +1137,8 @@ type ProviderEvent =
|
|
|
1134
1137
|
| { type: "team-member-removed"; memberId: UserId }
|
|
1135
1138
|
| { type: "team-policies-updated"; policies: TeamPolicies }
|
|
1136
1139
|
| { type: "contacts-updated"; contacts: Contact[] }
|
|
1137
|
-
| { type: "calendar-updated"; calendar: ScheduledActivity[] }
|
|
1140
|
+
| { type: "calendar-updated"; calendar: ScheduledActivity[] }
|
|
1141
|
+
| { type: "shift-updated"; shift: Shift };
|
|
1138
1142
|
|
|
1139
1143
|
type ProviderEventEnvelope = {
|
|
1140
1144
|
id: string;
|
|
@@ -1626,7 +1630,7 @@ compile time.
|
|
|
1626
1630
|
| `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
|
|
1627
1631
|
| `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the agent application) and/or `deskPhone` (a handset the platform rings; the agent application shows the call and opens nothing). The agent application picks one per login. See **How the agent hears the call**. |
|
|
1628
1632
|
| `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
|
|
1629
|
-
| `timeCheck` |
|
|
1633
|
+
| `timeCheck` | `true`: implements `checkTime` and states `providerTime` on every snapshot. Required of a provider that publishes an instant the desk renders as a running duration -- a member's `since`, a lead's `listening.since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts from the provider's clock (`manifest.timeCheck.required`); agent application polling is independently opt-in. See **Every screen counts from the provider's clock**. |
|
|
1630
1634
|
| `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
|
|
1631
1635
|
| `runningStepReports` | The provider takes running reports of an agent application-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the agent application sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The agent application records what it performs**. |
|
|
1632
1636
|
| `settleMs` | Required. How long after `applied` the wire shows what the provider did: the `task-ended` after a `complete`, the `calendar-updated` after a `schedule`. A positive whole number of milliseconds (`manifest.settleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
|
|
@@ -2142,11 +2146,13 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
|
|
|
2142
2146
|
| --- | --- |
|
|
2143
2147
|
| `transport` | Current `TransportStatus` — whether this provider's transport can serve the login. Defined under **`transport-status`**. |
|
|
2144
2148
|
| `loginId` | Identity of this login. It must match the connection context. |
|
|
2149
|
+
| `providerTime` | The provider's own instant of this read. Required of a provider that declares `timeCheck` (`snapshot.providerTime`), forbidden of one that does not (`snapshot.providerTime.unexpected`): a clock sample the desk counts durations from, saving it a `checkTime`. |
|
|
2145
2150
|
| `break` | Complete break state, including status, whether the agent may ask, reasons, retry details, and any forced break. |
|
|
2146
2151
|
| `tasks` | Complete set of tasks currently offered to or owned by this agent. |
|
|
2147
2152
|
| `taskCount` | The provider's own count of those tasks, stated rather than inferred, and it must equal `tasks.length`. A snapshot with no work says `taskCount: 0` in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty. |
|
|
2148
2153
|
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
2149
2154
|
| `calendar` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
2155
|
+
| `shift` | The agent's own day so far, as the provider counts it -- the same `Shift` their lead sees on the team member list, the same numbers from the same count. Replaced whole by `shift-updated`. Omitted only where the provider cannot say. See **The agent's day is on the wire**. |
|
|
2150
2156
|
| `team` | Required `TeamMembers` when the login declares `capabilities.lead` and the lead has the team feature on, `members: []` when nobody is in it. Forbidden otherwise — the login is the permission, and a lead who turned the feature off gets nothing of the team. |
|
|
2151
2157
|
|
|
2152
2158
|
## Live connection
|
|
@@ -2798,6 +2804,11 @@ Migration from the earlier spellings:
|
|
|
2798
2804
|
| schedule on voice alone | `schedule` on every channel, under a manifest that declares `calendar` (`task.capability.calendar.required`), bounded by `settleMs` (`drive.schedule.unsettled`) |
|
|
2799
2805
|
| a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
|
|
2800
2806
|
| end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
|
|
2807
|
+
| ForcedBreak.expectedDurationMs | `expectedEndsInSeconds`: seconds left, restated (`break.forced.expectedEndsInSeconds`, `.expectedDurationMs.renamed`); the command keeps `expectedDurationMs` |
|
|
2808
|
+
| MemberShift; the day on the lead's list alone | `Shift`, on `Snapshot.shift` and `shift-updated` for the agent, on `TeamMember.shift` for the lead |
|
|
2809
|
+
| TeamMember.listening as this lead's one entry | `MemberListening[]`, every lead's, each with `leadId` (`team.member.listening.leadId`, `.unique`, `.empty`, `.entry`, `.heard`) |
|
|
2810
|
+
| a member's history trimmed on the lead's list | the record whole, as the member's desk holds it |
|
|
2811
|
+
| durations counted from the screen's clock | the provider's: `timeCheck` required of a provider publishing a running instant (`manifest.timeCheck.required`), `Snapshot.providerTime` with it (`snapshot.providerTime`, `.unexpected`) |
|
|
2801
2812
|
|
|
2802
2813
|
Update producers, consumers, saved task snapshots, and validation-rule assertions together.
|
|
2803
2814
|
History and report rule names use `history` and `historyReport`; assignment rules use
|
|
@@ -3747,14 +3758,14 @@ The team break command formerly named release is now `end-forced-break`, and its
|
|
|
3747
3758
|
`team.break.command.endForcedBreak`. The former release command and the interim end spelling are rejected without aliases.
|
|
3748
3759
|
The agent’s `endBreak` now applies to forced breaks as well as requested breaks.
|
|
3749
3760
|
|
|
3750
|
-
`ForcedBreak` names who forced the break and may
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3761
|
+
`ForcedBreak` names who forced the break and may say how long it is expected to run yet, as
|
|
3762
|
+
`expectedEndsInSeconds`: whole seconds from the publication that carries it, restated on every
|
|
3763
|
+
publication, so every screen -- the agent's, reloaded or not, and every lead's -- counts down the
|
|
3764
|
+
same number from receipt (`break.forced.expectedEndsInSeconds`). The duration the lead asked for
|
|
3765
|
+
travels on the `force-break` command alone; the state says what is left, and drops the field once
|
|
3766
|
+
it has run out. The agent must explicitly resume when ready. The retired endsAutomatically and
|
|
3767
|
+
endsAt fields are rejected, including when an expectation is also provided. Who forced it is
|
|
3768
|
+
`by`, as **A forced break is the provider's act** below sets out.
|
|
3758
3769
|
|
|
3759
3770
|
Omni resolves the name to show with `getUserDetails()`, so a provider sends the identifier and never
|
|
3760
3771
|
a display name.
|
|
@@ -3769,16 +3780,16 @@ For example:
|
|
|
3769
3780
|
```ts
|
|
3770
3781
|
forced: {
|
|
3771
3782
|
by: "manager-1042",
|
|
3772
|
-
|
|
3783
|
+
expectedEndsInSeconds: 600
|
|
3773
3784
|
}
|
|
3774
3785
|
```
|
|
3775
3786
|
|
|
3776
3787
|
The agent application keeps **Resume** available for an agent on a forced break. The agent explicitly chooses
|
|
3777
3788
|
Resume; the agent application sends `endBreak()` and follows the provider-confirmed state before resuming
|
|
3778
|
-
work.
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3789
|
+
work. The expectation is whole seconds still to run, restated on every publication; omission
|
|
3790
|
+
means none was stated, or it has run out. Neither its reaching zero nor an overdue indication
|
|
3791
|
+
authorizes the agent application or provider to end the break, restore availability, or route
|
|
3792
|
+
work to the agent.
|
|
3782
3793
|
|
|
3783
3794
|
**A forced break is the provider's act.** A lead's `force-break` is a command to the provider,
|
|
3784
3795
|
which forces the break on the member; `by` names the lead who asked it to. A break the platform
|
|
@@ -3802,9 +3813,9 @@ must correlate resumption to the authenticated agent's action; a state-only orde
|
|
|
3802
3813
|
cannot establish the cause of a transition. The usual multi-provider end/reconciliation flow
|
|
3803
3814
|
still applies, and work resumes only on confirmed provider state.
|
|
3804
3815
|
|
|
3805
|
-
An agent application
|
|
3806
|
-
|
|
3807
|
-
|
|
3816
|
+
An agent application counts `expectedEndsInSeconds` down from receipt, on every publication that
|
|
3817
|
+
carries it, and shows nothing of it where the provider sends none. It invents no start and no
|
|
3818
|
+
return time, and nothing acts on the number reaching zero.
|
|
3808
3819
|
|
|
3809
3820
|
A break applies to the **agent**, not to one provider. When a provider forces one, Omni stops the
|
|
3810
3821
|
agent everywhere else at once, and not by asking: it states `setCapacity({ count: 0 })` on every
|
|
@@ -4210,10 +4221,10 @@ computes nothing. See **`team-updated`** and its companions.
|
|
|
4210
4221
|
| `availability` | Required. What the member is doing now. |
|
|
4211
4222
|
| `since` | Optional. When the current `availability` began — not when they signed in, and not when the team member list was read. |
|
|
4212
4223
|
| `break` | Present only while the member has an outstanding break request. See **A member waiting for a break**. |
|
|
4213
|
-
| `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, trimmed by the provider. The assignment, title, task type, phase, party, room and audio are what the lead reads
|
|
4214
|
-
| `listening` |
|
|
4224
|
+
| `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, trimmed by the provider. The assignment, title, task type, phase, party, room and audio are what the lead reads, and the task's record -- `history`, steps and totals -- travels whole, exactly as the member's desk holds it, so the lead's screen and the member's show the same record; only the workspace — controls, their source, browsers, completion terms — is the member's and travels only if the provider sends it. `[]` when the member holds none; omitted only where the provider cannot see them. |
|
|
4225
|
+
| `listening` | Every lead on this member's call — listening, coaching or joined — published to every lead alike: each entry names the lead by `leadId`, which of the member's calls by `assignmentId`, the `mode` they are heard in, and since when. The entry naming the signed-in lead is what their own audio opens on. Absent while nobody is on the call, never `[]` (`team.member.listening.*`); a lead is on one member's call at a time (`team.listening.single`), and a lead who is heard is also in the task's `onCall` (`team.member.listening.heard`). |
|
|
4215
4226
|
| `request` | Present while the member is asking a lead to join one of their calls: which assignment, the note they wrote, since when. The call named is one their `tasks` carry (`team.member.request.assignment`). Absent when they are not asking. See **Lead assist**. |
|
|
4216
|
-
| `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. Omitted where the provider cannot say. |
|
|
4227
|
+
| `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. The same `Shift` the member's own snapshot carries, the same numbers from the same count. Omitted where the provider cannot say. |
|
|
4217
4228
|
|
|
4218
4229
|
Each availability value means one thing:
|
|
4219
4230
|
|
|
@@ -4236,11 +4247,19 @@ by it, as a colleague signing in is. `team-member-removed` takes one off. Never
|
|
|
4236
4247
|
member, never a task of theirs on its own: a hold on a member's desk is that member republished
|
|
4237
4248
|
whole on the lead's, and nothing else on the lead's screen moves.
|
|
4238
4249
|
|
|
4250
|
+
**The agent's day is on the wire.** The lead sees `shift` totals as the provider counts them, and
|
|
4251
|
+
the agent sees the same: `Snapshot.shift` and `shift-updated` carry the agent's own day in the same
|
|
4252
|
+
shape, from the same count, so "calls today: 12" is one number on both screens. The desk computes
|
|
4253
|
+
nothing of the day; `tasksHandled` moves when a task ends and `talkSeconds` when a leg closes, and
|
|
4254
|
+
the active call's running time is the screen's own until then, as **Every screen counts from the
|
|
4255
|
+
provider's clock** sets out.
|
|
4256
|
+
|
|
4239
4257
|
**Omit `since` rather than inventing one.** Omni renders it as a duration, so a timestamp
|
|
4240
4258
|
synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for
|
|
4241
4259
|
everybody — worse than showing nothing, because it looks like data. Send it only when the provider
|
|
4242
4260
|
knows when the state actually began. It times the current `availability`, so it moves every time
|
|
4243
|
-
that value does.
|
|
4261
|
+
that value does. And it is counted from the provider's clock, never the screen's, so a provider
|
|
4262
|
+
that publishes it declares `timeCheck` -- see **Every screen counts from the provider's clock**.
|
|
4244
4263
|
|
|
4245
4264
|
**The login is the permission.** A team member list goes to a login that declares
|
|
4246
4265
|
`capabilities.lead`, once the team feature is on, and to nobody else. Omni never decides who
|
|
@@ -4333,9 +4352,10 @@ executeTeam({ command: { type: "decline", memberId: "A-1", reason: "In a call" }
|
|
|
4333
4352
|
```
|
|
4334
4353
|
|
|
4335
4354
|
**On `join` the provider bridges the lead's channel into the call.** The member's task moves to
|
|
4336
|
-
`leadAssist: { stage: "joined", leadId }
|
|
4337
|
-
|
|
4338
|
-
|
|
4355
|
+
`leadAssist: { stage: "joined", leadId }` and its `onCall` gains the lead as an `agent`, since a
|
|
4356
|
+
lead who is heard is in the room; the member on the team member list carries
|
|
4357
|
+
`listening: [{ leadId, assignmentId, mode: "join-call", since }]`, and the lead's audio opens on
|
|
4358
|
+
it as **Listening to a call** describes. Nothing is a task on the lead's desk: a join is the lead's act on the team surface, not
|
|
4339
4359
|
work assigned to them.
|
|
4340
4360
|
|
|
4341
4361
|
**A lead is on one member's call at a time.** A `join` from a lead already on a call -- their own
|
|
@@ -4403,10 +4423,11 @@ history. The three modes determine who hears the lead:
|
|
|
4403
4423
|
All three come with the `lead` flag; a centre reserves none of them per lead.
|
|
4404
4424
|
|
|
4405
4425
|
**There is no team audio.** Audio is the agent's or the lead's, and the lead's audio while
|
|
4406
|
-
listening follows the provider's published state exactly as a task's does:
|
|
4407
|
-
member is the provider's word that the lead's channel is in the call.
|
|
4408
|
-
application opens `openAudio({ assignmentId })` on the lead's connection for
|
|
4409
|
-
and plays the `CallAudio` returned; when it goes, the application closes it.
|
|
4426
|
+
listening follows the provider's published state exactly as a task's does: the entry naming this
|
|
4427
|
+
lead in `listening` on the member is the provider's word that the lead's channel is in the call.
|
|
4428
|
+
When it appears, the application opens `openAudio({ assignmentId })` on the lead's connection for
|
|
4429
|
+
the call it names, and plays the `CallAudio` returned; when it goes, the application closes it.
|
|
4430
|
+
Another lead's entry opens nothing: it is shown, and that is all. Nothing else says
|
|
4410
4431
|
when the lead's audio is up, and a reload reopens it from the snapshot's team member list as a task
|
|
4411
4432
|
carried with `audio: "started"` is reopened.
|
|
4412
4433
|
|
|
@@ -4417,13 +4438,15 @@ The flow, end to end:
|
|
|
4417
4438
|
// 2. The lead's channel joins it in silence. Conditions: the lead's voice channel is free --
|
|
4418
4439
|
// not on a call, not listening elsewhere. Capacity is not involved: nothing is assigned.
|
|
4419
4440
|
executeTeam({ command: { type: "listen", memberId: "A-1", assignmentId: "alloc-42" } })
|
|
4420
|
-
// 3. The provider publishes the member whole on team-member-updated; the member carries
|
|
4421
|
-
// listening: { assignmentId: "alloc-42", mode: "listen", since }
|
|
4422
|
-
// --
|
|
4423
|
-
//
|
|
4441
|
+
// 3. The provider publishes the member whole on team-member-updated, to every lead; the member carries
|
|
4442
|
+
// listening: [{ leadId: "L-9", assignmentId: "alloc-42", mode: "listen", since }]
|
|
4443
|
+
// -- L-9's application opens openAudio({ assignmentId: "alloc-42" }) on its own connection; every
|
|
4444
|
+
// other lead's shows L-9 on the call and opens nothing.
|
|
4445
|
+
// 4. The lead changes how they are heard; the member is published again with the entry's new mode. The audio stays open.
|
|
4446
|
+
// join-call is heard by everyone, so the member's task is also republished with L-9 on onCall as an agent.
|
|
4424
4447
|
executeTeam({ command: { type: "coach", memberId: "A-1" } })
|
|
4425
4448
|
executeTeam({ command: { type: "join-call", memberId: "A-1" } })
|
|
4426
|
-
// 5. The lead leaves: the provider publishes the member without
|
|
4449
|
+
// 5. The lead leaves: the provider publishes the member without L-9's entry, and the task without L-9 in the room; the application closes the audio.
|
|
4427
4450
|
executeTeam({ command: { type: "leave", memberId: "A-1" } })
|
|
4428
4451
|
// 6. The member's call ends: the same as leave, from the provider's side.
|
|
4429
4452
|
// 7. The lead takes the call: the member loses listening no later than the offer, the application
|
|
@@ -4434,22 +4457,29 @@ executeTeam({ command: { type: "take-over-call", memberId: "A-1" } })
|
|
|
4434
4457
|
// 8. A join on the member's request is the same flow entered at step 3 with mode: "join-call",
|
|
4435
4458
|
// and the member's task shows leadAssist: joined.
|
|
4436
4459
|
// 9. A reload of the lead's application: the switch is sent again, the team-updated that follows
|
|
4437
|
-
// still carries listening on the member, so the audio is reopened.
|
|
4460
|
+
// still carries this lead's entry in listening on the member, so the audio is reopened.
|
|
4438
4461
|
```
|
|
4439
4462
|
|
|
4440
|
-
**`listening` on the member is the state**,
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4463
|
+
**`listening` on the member is the state**, one entry per lead on the call, restated on every
|
|
4464
|
+
change of mode, naming the lead (`team.member.listening.leadId`, once per member,
|
|
4465
|
+
`team.member.listening.unique`) and the call, so the application knows which audio is its own to
|
|
4466
|
+
open (`team.member.listening.assignmentId`, and one the member's tasks carry,
|
|
4467
|
+
`team.member.listening.assignment`). Every lead sees every entry: a silent lead is hidden from
|
|
4468
|
+
the agent, never from the other leads and managers watching the same member on other screens.
|
|
4469
|
+
A lead is on one member's call at a time (`team.listening.single`).
|
|
4444
4470
|
|
|
4445
4471
|
**A lead listens while holding no call of their own.** Omni offers Listen to a lead whose voice
|
|
4446
4472
|
channel is free, and a provider answers a `listen` from one whose channel is not `failed`. That
|
|
4447
4473
|
includes a lead on a break: a lead working as lead alone is on a break of a working kind, and
|
|
4448
4474
|
listens through it.
|
|
4449
4475
|
|
|
4450
|
-
**
|
|
4451
|
-
|
|
4452
|
-
platform's business and travels on the audio, not on this wire.
|
|
4476
|
+
**A silent lead reaches nobody but the leads.** In `listen` and `coach` the member's task carries
|
|
4477
|
+
no trace of the lead: not on `onCall`, not in the record. Whether coaching is announced to the
|
|
4478
|
+
agent is the platform's business and travels on the audio, not on this wire. In `join-call`, and
|
|
4479
|
+
on a `join` answering a request, the lead is heard by everyone and is in the room: the task's
|
|
4480
|
+
`onCall` carries them as an `agent` by user id, on the member's desk and on every lead's copy of
|
|
4481
|
+
the task alike, and `leave` takes the entry off as it takes the channel off
|
|
4482
|
+
(`team.member.listening.heard`).
|
|
4453
4483
|
|
|
4454
4484
|
### A member waiting for a break
|
|
4455
4485
|
|
|
@@ -4931,6 +4961,24 @@ must specify their uncertainty and validity; elapsed time should use monotonic a
|
|
|
4931
4961
|
jump invalidates the estimate. The optional check below samples provider time without changing event timestamps. Report
|
|
4932
4962
|
source-measured durations where required; timestamp subtraction is not a substitute.
|
|
4933
4963
|
|
|
4964
|
+
#### Every screen counts from the provider's clock
|
|
4965
|
+
|
|
4966
|
+
Two screens watching the same agent -- their own desk, their lead's, a manager's on another floor
|
|
4967
|
+
-- must show the same numbers. Delay between them is fine; arithmetic that differs is not. So
|
|
4968
|
+
nothing the desk shows is its own count, with one exception: the running duration of the active
|
|
4969
|
+
task or call, which each screen times from the moment it saw the call begin, and which two screens
|
|
4970
|
+
may therefore disagree on, since their clocks differ. Everything else comes from the wire.
|
|
4971
|
+
|
|
4972
|
+
Durations off the active call -- how long a member has been `on-break` from their `since`, how
|
|
4973
|
+
long a lead has been listening, how long since `signedInAt` -- are counted from the provider's
|
|
4974
|
+
clock, never the screen's. The desk keeps the latest provider instant it has received -- a
|
|
4975
|
+
snapshot's `providerTime`, an envelope's `occurredAt`, a `checkTime` result -- with the monotonic
|
|
4976
|
+
elapsed time since receipt, and renders every such duration from that; a sample older than
|
|
4977
|
+
`maxSampleAgeMs` is refreshed with `checkTime`. Without a usable sample the desk shows the instant
|
|
4978
|
+
and no count. A provider that publishes any of these instants therefore declares `timeCheck`
|
|
4979
|
+
(`manifest.timeCheck.required`) and states `providerTime` on every snapshot
|
|
4980
|
+
(`snapshot.providerTime`). Two screens then differ by their delay and nothing else.
|
|
4981
|
+
|
|
4934
4982
|
#### Optional periodic provider time checks and agent application estimates
|
|
4935
4983
|
|
|
4936
4984
|
A provider may declare `Manifest.timeCheck: true` and implement `Connection.checkTime(request)`.
|
|
@@ -5316,6 +5364,13 @@ the `contacts` idle capability.
|
|
|
5316
5364
|
Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest
|
|
5317
5365
|
declares the `calendar` idle capability.
|
|
5318
5366
|
|
|
5367
|
+
### `shift-updated`
|
|
5368
|
+
|
|
5369
|
+
The agent's own day, whole, replacing `Snapshot.shift`: emitted whenever a total moves -- a task
|
|
5370
|
+
ended, a break ended, a sign-out -- and never to say nothing changed. It carries the same `Shift`
|
|
5371
|
+
the agent's lead sees for them on the team member list, so the two screens show one day. See
|
|
5372
|
+
**The agent's day is on the wire**.
|
|
5373
|
+
|
|
5319
5374
|
## Utilities
|
|
5320
5375
|
|
|
5321
5376
|
### `assignmentKey(providerId, assignmentId)`
|
|
@@ -5380,7 +5435,9 @@ carries that agent reports `team.member.self`, on a `team-member-updated` and a
|
|
|
5380
5435
|
reports `team.unentitled`, and a team event to such a login reports `event.team.capability`; given
|
|
5381
5436
|
`leadFeatures: true`, a snapshot without a team reports `team.required`, and given `leadFeatures: false`,
|
|
5382
5437
|
anything of the team reaching the lead reports `team.unexpected` or `event.team.features`. Without them those rules are not checked, because they cannot be.
|
|
5383
|
-
`exerciseAdapter` passes all three, holding the switch off until it has sent it.
|
|
5438
|
+
`exerciseAdapter` passes all three, holding the switch off until it has sent it. Given the manifest, a
|
|
5439
|
+
snapshot or event that carries an instant the desk renders as a running duration under a manifest
|
|
5440
|
+
without `timeCheck` reports `manifest.timeCheck.required`.
|
|
5384
5441
|
|
|
5385
5442
|
`assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
|
|
5386
5443
|
`violations` array — when the list is non-empty.
|