@xema/omni-protocol 0.1.84 → 0.1.86
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/README.md +3 -3
- package/dist/index.d.ts +15 -15
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +13 -13
- package/dist/validation.d.ts +5 -5
- package/dist/validation.js +68 -68
- package/guide.md +114 -87
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,9 +42,9 @@ assertNoViolations(violations);
|
|
|
42
42
|
A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was found
|
|
43
43
|
at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
44
44
|
|
|
45
|
-
Some rules need more than the object in hand. A
|
|
45
|
+
Some rules need more than the object in hand. A team member list never carries the agent it is published to,
|
|
46
46
|
a lead's snapshot always carries one and nobody else's ever does — and a validator cannot know who
|
|
47
|
-
is reading, or what their login declares, from the snapshot alone. `
|
|
47
|
+
is reading, or what their login declares, from the snapshot alone. `validateTeamMembers`,
|
|
48
48
|
`validateSnapshot`, and `validateEventEnvelope` each take an optional final `{ self, capabilities }`
|
|
49
49
|
from the `authenticated` state; given them, they report `team.member.self`, `team.request.self`,
|
|
50
50
|
`team.required`, `team.unentitled`, `team.requests.capability`, and `team.requests.required`.
|
|
@@ -61,7 +61,7 @@ validateSnapshot(snapshot, manifest, "snapshot", { self: identity.id, capabiliti
|
|
|
61
61
|
required capability methods, subscribes, validates the snapshot, every delivered event, and every
|
|
62
62
|
authentication state published during the run — against the latest login — states a capacity,
|
|
63
63
|
then unsubscribes and disconnects. `result.notExercised` names what the run never reached — each
|
|
64
|
-
optional part of a task, of the break state and
|
|
64
|
+
optional part of a task, of the break state and team member list, each contribution, each event type — so a
|
|
65
65
|
clean result is read for what it covers and not for the whole contract; `assertReached(result,
|
|
66
66
|
subjects)` is the paired assertion.
|
|
67
67
|
|
package/dist/index.d.ts
CHANGED
|
@@ -268,7 +268,7 @@ export interface UserCapabilities {
|
|
|
268
268
|
breaks?: true;
|
|
269
269
|
/** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
|
|
270
270
|
preferences?: AgentPreference[];
|
|
271
|
-
/** This login leads a team: a `
|
|
271
|
+
/** This login leads a team: a `TeamMembers` object is published to it on every snapshot, including `members: []`. */
|
|
272
272
|
team?: TeamCapabilities;
|
|
273
273
|
}
|
|
274
274
|
/**
|
|
@@ -619,15 +619,15 @@ export interface ScheduledActivity {
|
|
|
619
619
|
party?: Contact;
|
|
620
620
|
attributes?: Attribute[];
|
|
621
621
|
}
|
|
622
|
-
export interface
|
|
622
|
+
export interface OutcomeCode {
|
|
623
623
|
id: string;
|
|
624
624
|
label: string;
|
|
625
625
|
group?: string;
|
|
626
626
|
}
|
|
627
|
-
export interface
|
|
627
|
+
export interface OutcomeRules {
|
|
628
628
|
required?: boolean;
|
|
629
629
|
notes?: "required" | "optional" | "none";
|
|
630
|
-
codes?:
|
|
630
|
+
codes?: OutcomeCode[];
|
|
631
631
|
}
|
|
632
632
|
/**
|
|
633
633
|
* One item the queue configured for the agent to send a contact to: a button or a menu item. The
|
|
@@ -658,7 +658,7 @@ export interface CustomCapability {
|
|
|
658
658
|
}
|
|
659
659
|
export interface SharedTaskCapabilities {
|
|
660
660
|
browsers?: true;
|
|
661
|
-
|
|
661
|
+
outcomes?: true | OutcomeRules;
|
|
662
662
|
custom?: CustomCapability[];
|
|
663
663
|
}
|
|
664
664
|
/**
|
|
@@ -1054,8 +1054,8 @@ export declare const TASK_COMMAND_NAMES: {
|
|
|
1054
1054
|
readonly email: readonly ["accept", "decline", "complete"];
|
|
1055
1055
|
};
|
|
1056
1056
|
export type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> = (typeof TASK_COMMAND_NAMES)[C][number];
|
|
1057
|
-
export interface
|
|
1058
|
-
|
|
1057
|
+
export interface OutcomePayload {
|
|
1058
|
+
outcome?: string;
|
|
1059
1059
|
notes?: string;
|
|
1060
1060
|
}
|
|
1061
1061
|
export type VoiceTaskCommand = {
|
|
@@ -1153,7 +1153,7 @@ export type VoiceTaskCommand = {
|
|
|
1153
1153
|
source: "provider";
|
|
1154
1154
|
}) | ({
|
|
1155
1155
|
type: "complete";
|
|
1156
|
-
} &
|
|
1156
|
+
} & OutcomePayload);
|
|
1157
1157
|
export type ChatTaskCommand = {
|
|
1158
1158
|
type: "accept";
|
|
1159
1159
|
} | {
|
|
@@ -1164,14 +1164,14 @@ export type ChatTaskCommand = {
|
|
|
1164
1164
|
type: "resume";
|
|
1165
1165
|
} | ({
|
|
1166
1166
|
type: "complete";
|
|
1167
|
-
} &
|
|
1167
|
+
} & OutcomePayload);
|
|
1168
1168
|
export type EmailTaskCommand = {
|
|
1169
1169
|
type: "accept";
|
|
1170
1170
|
} | {
|
|
1171
1171
|
type: "decline";
|
|
1172
1172
|
} | ({
|
|
1173
1173
|
type: "complete";
|
|
1174
|
-
} &
|
|
1174
|
+
} & OutcomePayload);
|
|
1175
1175
|
export interface CustomTaskCommand {
|
|
1176
1176
|
type: "custom";
|
|
1177
1177
|
name: string;
|
|
@@ -1336,7 +1336,7 @@ export interface LeadRequest {
|
|
|
1336
1336
|
* The login is the permission: published to a login that declares `capabilities.team`, on every
|
|
1337
1337
|
* snapshot, and to nobody else. What the lead may do with it is on the login too, not here.
|
|
1338
1338
|
*/
|
|
1339
|
-
export interface
|
|
1339
|
+
export interface TeamMembers {
|
|
1340
1340
|
members: TeamMember[];
|
|
1341
1341
|
/** Omitted when the login lacks `team.leadAssistControl`; `[]` when nobody is asking. */
|
|
1342
1342
|
requests?: LeadRequest[];
|
|
@@ -1372,7 +1372,7 @@ export interface TeamLeadAssistCommandRequest {
|
|
|
1372
1372
|
command: TeamLeadAssistCommand;
|
|
1373
1373
|
}
|
|
1374
1374
|
/**
|
|
1375
|
-
* A lead listening to a member's call. `monitor` starts one, silent, on a member from the
|
|
1375
|
+
* A lead listening to a member's call. `monitor` starts one, silent, on a member from the team member list;
|
|
1376
1376
|
* `whisper` and `barge` change how the standing one is heard; `leave` ends it. One member at a
|
|
1377
1377
|
* time, and only the modes the login's `monitorControl` lists.
|
|
1378
1378
|
*/
|
|
@@ -1530,7 +1530,7 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
1530
1530
|
taskCount: number;
|
|
1531
1531
|
contacts?: Contact[];
|
|
1532
1532
|
scheduledActivities?: ScheduledActivity[];
|
|
1533
|
-
team?:
|
|
1533
|
+
team?: TeamMembers;
|
|
1534
1534
|
}
|
|
1535
1535
|
/**
|
|
1536
1536
|
* How many tasks this provider may have allocated to the agent at once.
|
|
@@ -1627,7 +1627,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1627
1627
|
allocationId?: AllocationId;
|
|
1628
1628
|
} | {
|
|
1629
1629
|
type: "team-updated";
|
|
1630
|
-
team:
|
|
1630
|
+
team: TeamMembers;
|
|
1631
1631
|
} | {
|
|
1632
1632
|
type: "contacts-updated";
|
|
1633
1633
|
contacts: Contact[];
|
|
@@ -1692,7 +1692,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1692
1692
|
setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
|
|
1693
1693
|
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
1694
1694
|
disconnect(): Promise<void>;
|
|
1695
|
-
/** Required of any adapter publishing a `UserId` -- on an imposed break, a
|
|
1695
|
+
/** Required of any adapter publishing a `UserId` -- on an imposed break, a team member list, or history. */
|
|
1696
1696
|
describeUsers?(ids: UserId[]): Promise<User[]>;
|
|
1697
1697
|
/** Required when the manifest declares `idleCapabilities.dial`. */
|
|
1698
1698
|
dial?(request: DialRequest): Promise<DialResult>;
|
package/dist/testing.d.ts
CHANGED
|
@@ -4,10 +4,10 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
6
6
|
* a fixture without it exercises none of its rules and passes clean. One subject per family of
|
|
7
|
-
* rules -- each optional part of a task, each optional part of the break state and
|
|
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.interactionHistory", "task.onCall", "task.leadAssist", "task.assisting", "task.monitoring", "task.media", "task.acceptance", "task.
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.interactionHistory", "task.onCall", "task.leadAssist", "task.assisting", "task.monitoring", "task.media", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
|
@@ -89,7 +89,7 @@ export declare function assertAuthenticationRestoreAndExpiry(states: readonly Au
|
|
|
89
89
|
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
90
90
|
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
91
91
|
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
92
|
-
* state, validated against that login -- so a
|
|
92
|
+
* state, validated against that login -- so a team member list still published to a login that no longer
|
|
93
93
|
* leads, or requests to one that may no longer join, is the failure.
|
|
94
94
|
*/
|
|
95
95
|
export declare function assertCapabilityWithdrawal(states: readonly AuthenticationState[], snapshot: Snapshot, manifest: Manifest): void;
|
|
@@ -121,7 +121,7 @@ export declare function assertCommandRefusedAfterWithdrawal(result: {
|
|
|
121
121
|
export declare function assertTaskCapabilityWithdrawal(tasks: readonly Task[], manifest: Manifest, command: Record<string, unknown>): void;
|
|
122
122
|
/**
|
|
123
123
|
* Throws unless the run reached every subject named: the paired assertion beside a clean result,
|
|
124
|
-
* so a fixture that never produced a
|
|
124
|
+
* so a fixture that never produced a team member list cannot pass a test that meant to check one.
|
|
125
125
|
*/
|
|
126
126
|
export declare function assertReached(result: AdapterContractResult, subjects: readonly ContractSubject[]): void;
|
|
127
127
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
package/dist/testing.js
CHANGED
|
@@ -4,7 +4,7 @@ export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
6
6
|
* a fixture without it exercises none of its rules and passes clean. One subject per family of
|
|
7
|
-
* rules -- each optional part of a task, each optional part of the break state and
|
|
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
10
|
const STATE_SUBJECTS = [
|
|
@@ -18,7 +18,7 @@ const STATE_SUBJECTS = [
|
|
|
18
18
|
"task.monitoring",
|
|
19
19
|
"task.media",
|
|
20
20
|
"task.acceptance",
|
|
21
|
-
"task.
|
|
21
|
+
"task.outcomes",
|
|
22
22
|
"task.destinations",
|
|
23
23
|
"task.custom",
|
|
24
24
|
"task.locked",
|
|
@@ -68,8 +68,8 @@ function observeTask(value, seen) {
|
|
|
68
68
|
if (value.acceptance !== undefined)
|
|
69
69
|
seen.add("task.acceptance");
|
|
70
70
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
71
|
-
if (isRecord(capabilities.
|
|
72
|
-
seen.add("task.
|
|
71
|
+
if (isRecord(capabilities.outcomes))
|
|
72
|
+
seen.add("task.outcomes");
|
|
73
73
|
for (const directory of ["coldTransfer", "warmTransfer", "conference"]) {
|
|
74
74
|
const declared = capabilities[directory];
|
|
75
75
|
if (isRecord(declared) && some(declared.destinations))
|
|
@@ -188,7 +188,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
188
188
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
189
189
|
}
|
|
190
190
|
// The harness knows who is signed in and what their login declares, so it can hold the
|
|
191
|
-
// adapter to rules the structural validators cannot check alone: a
|
|
191
|
+
// adapter to rules the structural validators cannot check alone: a team member list never carries the
|
|
192
192
|
// agent it is published to, a lead's snapshot always carries one, nobody else's ever does.
|
|
193
193
|
// Capabilities are current, not fixed, so the login is read when something is validated,
|
|
194
194
|
// never captured at sign-in: a provider that withdraws one republishes `authenticated`, and
|
|
@@ -460,7 +460,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
460
460
|
if (publishesUserIds(read)) {
|
|
461
461
|
requireMethod(source, "describeUsers", "the snapshot publishes a UserId");
|
|
462
462
|
// Required by presence is not enough: the names the snapshot published are looked up, and what
|
|
463
|
-
// comes back is held to the shape -- an empty answer to a
|
|
463
|
+
// comes back is held to the shape -- an empty answer to a team member list of colleagues is named.
|
|
464
464
|
const named = userIdsIn(read);
|
|
465
465
|
if (typeof source.describeUsers === "function" && named.length > 0) {
|
|
466
466
|
let described;
|
|
@@ -644,7 +644,7 @@ function publishesUserIds(snapshot) {
|
|
|
644
644
|
return false;
|
|
645
645
|
return snapshot.tasks.some(taskNamesUsers);
|
|
646
646
|
}
|
|
647
|
-
/** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the
|
|
647
|
+
/** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the team member list. */
|
|
648
648
|
function userIdsIn(snapshot) {
|
|
649
649
|
const ids = new Set();
|
|
650
650
|
const add = (value) => { if (typeof value === "string" && value.length > 0)
|
|
@@ -685,7 +685,7 @@ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.interactionHi
|
|
|
685
685
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
686
686
|
isRecord(task.assisting) ||
|
|
687
687
|
isRecord(task.monitoring));
|
|
688
|
-
/** Whether an event publishes a `UserId`, on a
|
|
688
|
+
/** Whether an event publishes a `UserId`, on a team member list, a task, or the snapshot a reconnect carries. */
|
|
689
689
|
function eventNamesUsers(envelope) {
|
|
690
690
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
691
691
|
if (!isRecord(event))
|
|
@@ -795,7 +795,7 @@ function undeterminedTasks(tasks, path) {
|
|
|
795
795
|
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
796
796
|
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
797
797
|
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
798
|
-
* state, validated against that login -- so a
|
|
798
|
+
* state, validated against that login -- so a team member list still published to a login that no longer
|
|
799
799
|
* leads, or requests to one that may no longer join, is the failure.
|
|
800
800
|
*/
|
|
801
801
|
export function assertCapabilityWithdrawal(states, snapshot, manifest) {
|
|
@@ -890,7 +890,7 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
|
|
|
890
890
|
}
|
|
891
891
|
/**
|
|
892
892
|
* Throws unless the run reached every subject named: the paired assertion beside a clean result,
|
|
893
|
-
* so a fixture that never produced a
|
|
893
|
+
* so a fixture that never produced a team member list cannot pass a test that meant to check one.
|
|
894
894
|
*/
|
|
895
895
|
export function assertReached(result, subjects) {
|
|
896
896
|
const missed = subjects.filter(subject => result.notExercised.includes(subject));
|
|
@@ -1793,9 +1793,9 @@ async function driveOneCall(drive) {
|
|
|
1793
1793
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
1794
1794
|
if (latestTask().completionMode === "agent-command" && completable) {
|
|
1795
1795
|
const command = { type: "complete" };
|
|
1796
|
-
const
|
|
1797
|
-
if (isRecord(
|
|
1798
|
-
command.
|
|
1796
|
+
const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
|
|
1797
|
+
if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
|
|
1798
|
+
command.outcome = outcomes.codes[0].id;
|
|
1799
1799
|
}
|
|
1800
1800
|
if (await send(command) !== undefined) {
|
|
1801
1801
|
// applied says the provider has completed the task, and its ending follows within the bound
|
package/dist/validation.d.ts
CHANGED
|
@@ -50,9 +50,9 @@ export declare function validateTask(task: unknown, context: TaskValidationConte
|
|
|
50
50
|
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
51
51
|
export declare function validateBreakStatus(state: unknown, tasks: unknown, path?: string): ProtocolViolation[];
|
|
52
52
|
/**
|
|
53
|
-
* Checks lead break dispatch using current authentication, transport,
|
|
53
|
+
* Checks lead break dispatch using current authentication, transport, team member list (`team`) and,
|
|
54
54
|
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
55
|
-
* and recheck the decision atomically; a
|
|
55
|
+
* and recheck the decision atomically; a team member list is not authority to act after it has changed.
|
|
56
56
|
*/
|
|
57
57
|
export declare function validateTeamBreakCommand(request: unknown, context: unknown, path?: string): ProtocolViolation[];
|
|
58
58
|
/** The four agent break methods; this is a validation API, not a wire command. */
|
|
@@ -78,12 +78,12 @@ export declare function validateBreakTransition(before: unknown, after: unknown,
|
|
|
78
78
|
export interface ReaderContext {
|
|
79
79
|
/**
|
|
80
80
|
* The signed-in agent, `AuthenticationState.identity.id`. A lead does not report to themself:
|
|
81
|
-
* a
|
|
81
|
+
* a team member list that lists them in `members`, or their own ask in `requests`, is a violation.
|
|
82
82
|
*/
|
|
83
83
|
self?: UserId;
|
|
84
84
|
/**
|
|
85
85
|
* The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
|
|
86
|
-
* snapshot carries a
|
|
86
|
+
* snapshot carries a team member list, nobody else's does, and `requests` need `team.leadAssistControl`.
|
|
87
87
|
*/
|
|
88
88
|
capabilities?: UserCapabilities;
|
|
89
89
|
/** The level ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
|
|
@@ -99,7 +99,7 @@ export interface ReaderContext {
|
|
|
99
99
|
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
100
100
|
autoAcceptTasks?: boolean;
|
|
101
101
|
}
|
|
102
|
-
export declare function
|
|
102
|
+
export declare function validateTeamMembers(teamMembers: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
103
103
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
104
104
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
105
105
|
/**
|
package/dist/validation.js
CHANGED
|
@@ -98,11 +98,11 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
98
98
|
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
99
99
|
const TASK_CAPABILITIES = {
|
|
100
100
|
voice: membersOf({
|
|
101
|
-
browsers: true,
|
|
101
|
+
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
102
102
|
endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
103
103
|
}),
|
|
104
|
-
chat: membersOf({ browsers: true,
|
|
105
|
-
email: membersOf({ browsers: true,
|
|
104
|
+
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
105
|
+
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
106
106
|
};
|
|
107
107
|
// The published list and the type's keys are the same set, or one of them is wrong.
|
|
108
108
|
true;
|
|
@@ -607,41 +607,41 @@ function validateDestinationDirectory(value, path, into) {
|
|
|
607
607
|
into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
|
|
608
608
|
});
|
|
609
609
|
}
|
|
610
|
-
function
|
|
610
|
+
function validateOutcomes(value, path, into) {
|
|
611
611
|
if (value === true)
|
|
612
612
|
return;
|
|
613
613
|
if (!isPlainObject(value)) {
|
|
614
|
-
into.add("task.
|
|
614
|
+
into.add("task.outcomes.shape", path, "must be true or an outcome policy");
|
|
615
615
|
return;
|
|
616
616
|
}
|
|
617
617
|
if (value.required !== undefined) {
|
|
618
|
-
into.require(typeof value.required === "boolean", "task.
|
|
618
|
+
into.require(typeof value.required === "boolean", "task.outcomes.required", `${path}.required`, "required must be a boolean when present");
|
|
619
619
|
}
|
|
620
620
|
// A code must be collected before completion, so there must be one to collect.
|
|
621
621
|
if (value.required === true && !(Array.isArray(value.codes) && value.codes.length > 0)) {
|
|
622
|
-
into.add("task.
|
|
622
|
+
into.add("task.outcomes.required.codes", `${path}.codes`, "a required outcome policy must publish at least one code");
|
|
623
623
|
}
|
|
624
624
|
if (value.notes !== undefined)
|
|
625
|
-
into.oneOf(value.notes, NOTES_RULES, "task.
|
|
625
|
+
into.oneOf(value.notes, NOTES_RULES, "task.outcomes.notes", `${path}.notes`);
|
|
626
626
|
if (value.codes === undefined)
|
|
627
627
|
return;
|
|
628
628
|
if (!Array.isArray(value.codes)) {
|
|
629
|
-
into.add("task.
|
|
629
|
+
into.add("task.outcomes.codes", `${path}.codes`, "codes must be an array when present");
|
|
630
630
|
return;
|
|
631
631
|
}
|
|
632
632
|
const seen = new Set();
|
|
633
633
|
value.codes.forEach((code, index) => {
|
|
634
634
|
const at = `${path}.codes[${index}]`;
|
|
635
635
|
if (!isPlainObject(code)) {
|
|
636
|
-
into.add("task.
|
|
636
|
+
into.add("task.outcome.shape", at, "each outcome code must be an object");
|
|
637
637
|
return;
|
|
638
638
|
}
|
|
639
|
-
if (into.filled(code.id, "task.
|
|
639
|
+
if (into.filled(code.id, "task.outcome.id", `${at}.id`, "an outcome code needs an id")) {
|
|
640
640
|
if (seen.has(code.id))
|
|
641
|
-
into.add("task.
|
|
641
|
+
into.add("task.outcome.unique", `${at}.id`, `duplicate outcome code: ${code.id}`);
|
|
642
642
|
seen.add(code.id);
|
|
643
643
|
}
|
|
644
|
-
into.filled(code.label, "task.
|
|
644
|
+
into.filled(code.label, "task.outcome.label", `${at}.label`, "an outcome code needs a label");
|
|
645
645
|
});
|
|
646
646
|
}
|
|
647
647
|
function validateCustomCapabilities(value, path, into) {
|
|
@@ -697,7 +697,7 @@ const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
|
|
|
697
697
|
const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
|
|
698
698
|
const POLICY_KEYS = new Set([
|
|
699
699
|
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
700
|
-
].filter(name => name !== "browsers" && name !== "
|
|
700
|
+
].filter(name => name !== "browsers" && name !== "outcomes" && name !== "custom"));
|
|
701
701
|
const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
702
702
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
703
703
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
@@ -1260,9 +1260,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1260
1260
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
1261
1261
|
}
|
|
1262
1262
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
1263
|
-
// queue provides -- browsers,
|
|
1263
|
+
// queue provides -- browsers, outcomes, custom controls -- is content, not a control.
|
|
1264
1264
|
if (isLocked(declared)) {
|
|
1265
|
-
if (into.require(name !== "browsers" && name !== "
|
|
1265
|
+
if (into.require(name !== "browsers" && name !== "outcomes" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
|
|
1266
1266
|
validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.levels, into);
|
|
1267
1267
|
}
|
|
1268
1268
|
continue;
|
|
@@ -1271,8 +1271,8 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1271
1271
|
case "recording":
|
|
1272
1272
|
into.violations.push(...validateRecordingPolicy(declared, `${path}.capabilities.recording`));
|
|
1273
1273
|
break;
|
|
1274
|
-
case "
|
|
1275
|
-
|
|
1274
|
+
case "outcomes":
|
|
1275
|
+
validateOutcomes(declared, `${path}.capabilities.outcomes`, into);
|
|
1276
1276
|
break;
|
|
1277
1277
|
case "custom":
|
|
1278
1278
|
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
@@ -1348,9 +1348,9 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
|
1348
1348
|
return into.violations;
|
|
1349
1349
|
}
|
|
1350
1350
|
/**
|
|
1351
|
-
* Checks lead break dispatch using current authentication, transport,
|
|
1351
|
+
* Checks lead break dispatch using current authentication, transport, team member list (`team`) and,
|
|
1352
1352
|
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
1353
|
-
* and recheck the decision atomically; a
|
|
1353
|
+
* and recheck the decision atomically; a team member list is not authority to act after it has changed.
|
|
1354
1354
|
*/
|
|
1355
1355
|
export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
|
|
1356
1356
|
const into = new Collector();
|
|
@@ -1388,7 +1388,7 @@ export function validateTeamBreakCommand(request, context, path = "teamBreakComm
|
|
|
1388
1388
|
const members = Array.isArray(team.members) ? team.members : [];
|
|
1389
1389
|
const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
|
|
1390
1390
|
const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
|
|
1391
|
-
into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized
|
|
1391
|
+
into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized team member list");
|
|
1392
1392
|
if (command.type === "decide") {
|
|
1393
1393
|
into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
|
|
1394
1394
|
into.require(isPlainObject(member) && member.break === "awaiting-decision", "team.break.command.awaiting", path, "decide only a currently awaiting-decision request");
|
|
@@ -1562,38 +1562,38 @@ function validateBreakState(value, path, into) {
|
|
|
1562
1562
|
into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.approval)} on a provider that publishes reasons names the one it is on`);
|
|
1563
1563
|
}
|
|
1564
1564
|
}
|
|
1565
|
-
export function
|
|
1565
|
+
export function validateTeamMembers(teamMembers, path = "team", context = {}) {
|
|
1566
1566
|
const into = new Collector();
|
|
1567
|
-
|
|
1567
|
+
validateTeamMembersInto(teamMembers, path, context, into);
|
|
1568
1568
|
return into.violations;
|
|
1569
1569
|
}
|
|
1570
|
-
function
|
|
1571
|
-
if (!isPlainObject(
|
|
1572
|
-
into.add("team.shape", path, "a team
|
|
1570
|
+
function validateTeamMembersInto(teamMembers, path, context, into) {
|
|
1571
|
+
if (!isPlainObject(teamMembers)) {
|
|
1572
|
+
into.add("team.shape", path, "a team member list must be an object");
|
|
1573
1573
|
return;
|
|
1574
1574
|
}
|
|
1575
|
-
// The login is the permission: a
|
|
1575
|
+
// The login is the permission: a team member list reaches a login that declares `capabilities.team` and
|
|
1576
1576
|
// nobody else. Only a caller holding the login can check it.
|
|
1577
1577
|
if (context.capabilities !== undefined && context.capabilities.team === undefined) {
|
|
1578
|
-
into.add("team.unentitled", path, "a
|
|
1578
|
+
into.add("team.unentitled", path, "a team member list published to a login that does not declare capabilities.team: the login is the permission");
|
|
1579
1579
|
}
|
|
1580
|
-
// The team's policies travel with the
|
|
1580
|
+
// The team's policies travel with the team member list exactly when the login may set them.
|
|
1581
1581
|
if (context.capabilities !== undefined) {
|
|
1582
1582
|
const may = context.capabilities.team?.policyControl === true;
|
|
1583
|
-
if (may &&
|
|
1584
|
-
into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the
|
|
1583
|
+
if (may && teamMembers.policies === undefined) {
|
|
1584
|
+
into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the team member list carries the team's policies");
|
|
1585
1585
|
}
|
|
1586
|
-
if (!may &&
|
|
1586
|
+
if (!may && teamMembers.policies !== undefined) {
|
|
1587
1587
|
into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
|
|
1588
1588
|
}
|
|
1589
1589
|
}
|
|
1590
|
-
if (
|
|
1591
|
-
validateTeamPoliciesInto(
|
|
1592
|
-
if (
|
|
1590
|
+
if (teamMembers.policies !== undefined)
|
|
1591
|
+
validateTeamPoliciesInto(teamMembers.policies, `${path}.policies`, into, context.levels);
|
|
1592
|
+
if (teamMembers.requests === undefined) {
|
|
1593
1593
|
// `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
|
|
1594
1594
|
// asked therefore always carries the list.
|
|
1595
1595
|
if (context.capabilities?.team?.leadAssistControl === true) {
|
|
1596
|
-
into.add("team.requests.required", `${path}.requests`, "the login declares team.leadAssistControl, so the
|
|
1596
|
+
into.add("team.requests.required", `${path}.requests`, "the login declares team.leadAssistControl, so the team member list carries requests: [] when nobody is asking");
|
|
1597
1597
|
}
|
|
1598
1598
|
}
|
|
1599
1599
|
else {
|
|
@@ -1602,12 +1602,12 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
1602
1602
|
if (context.capabilities !== undefined) {
|
|
1603
1603
|
into.require(context.capabilities.team?.leadAssistControl === true, "team.requests.capability", `${path}.requests`, "requests require team.leadAssistControl on the login: a lead who may not join has nothing to decide");
|
|
1604
1604
|
}
|
|
1605
|
-
if (!Array.isArray(
|
|
1605
|
+
if (!Array.isArray(teamMembers.requests)) {
|
|
1606
1606
|
into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
|
|
1607
1607
|
}
|
|
1608
1608
|
else {
|
|
1609
1609
|
const seenRequests = new Set();
|
|
1610
|
-
|
|
1610
|
+
teamMembers.requests.forEach((request, index) => {
|
|
1611
1611
|
const at = `${path}.requests[${index}]`;
|
|
1612
1612
|
if (!isPlainObject(request)) {
|
|
1613
1613
|
into.add("team.request.shape", at, "each request must be an object");
|
|
@@ -1619,7 +1619,7 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
1619
1619
|
seenRequests.add(request.id);
|
|
1620
1620
|
}
|
|
1621
1621
|
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
1622
|
-
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the
|
|
1622
|
+
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the team member list carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
1623
1623
|
}
|
|
1624
1624
|
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
1625
1625
|
into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
|
|
@@ -1629,22 +1629,22 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
1629
1629
|
});
|
|
1630
1630
|
}
|
|
1631
1631
|
}
|
|
1632
|
-
if (!Array.isArray(
|
|
1633
|
-
into.add("team.members.shape", `${path}.members`, "a
|
|
1632
|
+
if (!Array.isArray(teamMembers.members)) {
|
|
1633
|
+
into.add("team.members.shape", `${path}.members`, "a team member list must carry a members array");
|
|
1634
1634
|
return;
|
|
1635
1635
|
}
|
|
1636
1636
|
const seen = new Set();
|
|
1637
|
-
|
|
1637
|
+
teamMembers.members.forEach((member, index) => {
|
|
1638
1638
|
const at = `${path}.members[${index}]`;
|
|
1639
1639
|
if (!isPlainObject(member)) {
|
|
1640
|
-
into.add("team.member.shape", at, "each
|
|
1640
|
+
into.add("team.member.shape", at, "each team member must be an object");
|
|
1641
1641
|
return;
|
|
1642
1642
|
}
|
|
1643
|
-
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a
|
|
1643
|
+
if (into.require(isUserId(member.id), "team.member.id", `${at}.id`, "a team member needs a user id")) {
|
|
1644
1644
|
if (seen.has(member.id))
|
|
1645
|
-
into.add("team.member.unique", `${at}.id`, `duplicate
|
|
1645
|
+
into.add("team.member.unique", `${at}.id`, `duplicate team member: ${member.id}`);
|
|
1646
1646
|
seen.add(member.id);
|
|
1647
|
-
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the
|
|
1647
|
+
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the team member list carries the agent it is published to: a lead does not report to themself");
|
|
1648
1648
|
}
|
|
1649
1649
|
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
1650
1650
|
if (member.since !== undefined)
|
|
@@ -1752,13 +1752,13 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1752
1752
|
into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
|
|
1753
1753
|
}
|
|
1754
1754
|
}
|
|
1755
|
-
// The login is the permission: a lead's snapshot carries a
|
|
1756
|
-
// direction -- a
|
|
1755
|
+
// The login is the permission: a lead's snapshot carries a team member list, `[]` included. The other
|
|
1756
|
+
// direction -- a team member list to a login that does not lead -- is the team member list's own rule.
|
|
1757
1757
|
if (context.capabilities?.team !== undefined && snapshot.team === undefined) {
|
|
1758
|
-
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a
|
|
1758
|
+
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a team member list: [] when nobody is in it");
|
|
1759
1759
|
}
|
|
1760
1760
|
if (snapshot.team !== undefined)
|
|
1761
|
-
|
|
1761
|
+
validateTeamMembersInto(snapshot.team, `${path}.team`, { ...context, levels }, into);
|
|
1762
1762
|
return into.violations;
|
|
1763
1763
|
}
|
|
1764
1764
|
// ---------------------------------------------------------------------------
|
|
@@ -1949,7 +1949,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1949
1949
|
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1950
1950
|
break;
|
|
1951
1951
|
case "team-updated":
|
|
1952
|
-
|
|
1952
|
+
validateTeamMembersInto(event.team, `${at}.team`, { ...context, levels }, into);
|
|
1953
1953
|
break;
|
|
1954
1954
|
case "contacts-updated":
|
|
1955
1955
|
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
@@ -2298,7 +2298,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2298
2298
|
fields.push("dialId");
|
|
2299
2299
|
break;
|
|
2300
2300
|
case "complete":
|
|
2301
|
-
fields.push("
|
|
2301
|
+
fields.push("outcome", "notes");
|
|
2302
2302
|
break;
|
|
2303
2303
|
case "transfer":
|
|
2304
2304
|
fields.push("action");
|
|
@@ -2359,8 +2359,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2359
2359
|
into.violations.push(...validateRecordingCommandShape(command, "provider", path));
|
|
2360
2360
|
break;
|
|
2361
2361
|
case "complete":
|
|
2362
|
-
if (command.
|
|
2363
|
-
into.filled(command.
|
|
2362
|
+
if (command.outcome !== undefined)
|
|
2363
|
+
into.filled(command.outcome, "command.complete.outcome", `${path}.outcome`, "an outcome must not be empty when present");
|
|
2364
2364
|
if (command.notes !== undefined)
|
|
2365
2365
|
into.require(typeof command.notes === "string", "command.complete.notes", `${path}.notes`, "notes must be a string when present");
|
|
2366
2366
|
break;
|
|
@@ -2509,27 +2509,27 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2509
2509
|
}
|
|
2510
2510
|
case "complete": {
|
|
2511
2511
|
into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
|
|
2512
|
-
// What travels with complete is what the
|
|
2512
|
+
// What travels with complete is what the outcomes capability published: a code from its
|
|
2513
2513
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2514
|
-
const
|
|
2515
|
-
if (
|
|
2516
|
-
into.require(command.
|
|
2517
|
-
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no
|
|
2514
|
+
const outcomes = capabilities.outcomes;
|
|
2515
|
+
if (outcomes === undefined) {
|
|
2516
|
+
into.require(command.outcome === undefined, "command.complete.outcome.unexpected", `${path}.outcome`, "the task declares no outcomes; complete travels with no code");
|
|
2517
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no outcomes; complete travels with no notes");
|
|
2518
2518
|
}
|
|
2519
|
-
else if (
|
|
2520
|
-
into.require(command.
|
|
2519
|
+
else if (outcomes === true) {
|
|
2520
|
+
into.require(command.outcome === undefined, "command.complete.outcome.unexpected", `${path}.outcome`, "a bare outcomes control publishes no code, so complete carries none");
|
|
2521
2521
|
}
|
|
2522
|
-
else if (isPlainObject(
|
|
2523
|
-
if (Array.isArray(
|
|
2524
|
-
into.require(
|
|
2522
|
+
else if (isPlainObject(outcomes)) {
|
|
2523
|
+
if (Array.isArray(outcomes.codes) && command.outcome !== undefined) {
|
|
2524
|
+
into.require(outcomes.codes.some(code => isPlainObject(code) && code.id === command.outcome), "command.complete.outcome.unknown", `${path}.outcome`, `${describeValue(command.outcome)} is not a code the task published`);
|
|
2525
2525
|
}
|
|
2526
|
-
if (
|
|
2527
|
-
into.require(command.
|
|
2526
|
+
if (outcomes.required === true) {
|
|
2527
|
+
into.require(command.outcome !== undefined, "command.complete.outcome.required", `${path}.outcome`, "the task requires an outcome and complete carries none");
|
|
2528
2528
|
}
|
|
2529
|
-
if (
|
|
2529
|
+
if (outcomes.notes === "none") {
|
|
2530
2530
|
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
|
|
2531
2531
|
}
|
|
2532
|
-
else if (
|
|
2532
|
+
else if (outcomes.notes === "required") {
|
|
2533
2533
|
into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
|
|
2534
2534
|
}
|
|
2535
2535
|
}
|