@xema/omni-protocol 0.1.32 → 0.1.34
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 +96 -16
- package/dist/index.js +18 -0
- package/dist/testing.d.ts +12 -8
- package/dist/testing.js +53 -14
- package/dist/validation.d.ts +3 -1
- package/dist/validation.js +130 -18
- package/guide.md +210 -68
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export type IsoTimestamp = string;
|
|
|
26
26
|
export type UserId = string;
|
|
27
27
|
/** A non-empty, opaque task identifier unique within one provider. Scope it with `taskKey()`. */
|
|
28
28
|
export type TaskId = string;
|
|
29
|
+
/**
|
|
30
|
+
* The host's identity for one dial, minted before the command leaves and unique across the
|
|
31
|
+
* installation. A dial is the one command whose outcome arrives later and apart, so the provider
|
|
32
|
+
* names this on the outcome and the host has already placed it. Never shown to the agent.
|
|
33
|
+
*/
|
|
34
|
+
export type DialId = string;
|
|
29
35
|
/** A non-negative whole number of seconds. */
|
|
30
36
|
export type DurationSeconds = number;
|
|
31
37
|
/** New channels require a later protocol version. */
|
|
@@ -68,6 +74,14 @@ export type DialDestinations = "contacts-only" | "any-number";
|
|
|
68
74
|
export interface DialCapability {
|
|
69
75
|
destinations: DialDestinations;
|
|
70
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* How a dial ended, as the switch distinguishes it. `answered` is the one success; the rest are
|
|
79
|
+
* the ways a destination was not reached -- `cancelled` because the dialler called it off before
|
|
80
|
+
* anyone answered, not because the destination failed to. A manifest declares which of these its
|
|
81
|
+
* platform can tell apart (`dialOutcomes`), and a `dial-outcome` carries only a declared one.
|
|
82
|
+
*/
|
|
83
|
+
export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled";
|
|
84
|
+
export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled"];
|
|
71
85
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
72
86
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
73
87
|
export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
@@ -113,6 +127,12 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
113
127
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
114
128
|
/** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
|
|
115
129
|
orgLevels?: LevelDeclaration[];
|
|
130
|
+
/**
|
|
131
|
+
* The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
|
|
132
|
+
* not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
|
|
133
|
+
* a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
|
|
134
|
+
*/
|
|
135
|
+
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
116
136
|
/**
|
|
117
137
|
* The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
|
|
118
138
|
* so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
|
|
@@ -520,6 +540,12 @@ export interface TaskHandlingHistory {
|
|
|
520
540
|
export interface TaskHandlingStep {
|
|
521
541
|
step: HandlingStep;
|
|
522
542
|
at: IsoTimestamp;
|
|
543
|
+
/**
|
|
544
|
+
* On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
|
|
545
|
+
* that dial when a host placed it, and the address it went to. Neither belongs on any other step.
|
|
546
|
+
*/
|
|
547
|
+
dialId?: DialId;
|
|
548
|
+
destination?: string;
|
|
523
549
|
/**
|
|
524
550
|
* Reported, never derived. An entry may be written while its leg is still running, so there is
|
|
525
551
|
* no end to subtract from -- and omitted is the honest report of that, where nought would
|
|
@@ -547,16 +573,29 @@ export type TaskCompletion = {
|
|
|
547
573
|
completionMode: "provider-automatic";
|
|
548
574
|
wrapAllowance: DurationSeconds;
|
|
549
575
|
};
|
|
576
|
+
export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
577
|
+
export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
|
|
550
578
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
579
|
+
* Somebody on the call right now, as the provider states it: who, since when, and whether they
|
|
580
|
+
* are held aside. `party` is the customer; `agent` a person, by user id; `consulted` and
|
|
581
|
+
* `conferenced` someone a dial brought in, carrying the dial when a host placed it and the address
|
|
582
|
+
* either way. `label` names a destination -- a person, a queue -- not a phrase. At most one
|
|
583
|
+
* `party` and one `consulted`: the consult commands name neither because there is exactly one.
|
|
554
584
|
*/
|
|
555
|
-
export
|
|
585
|
+
export type OnCall = {
|
|
586
|
+
since: IsoTimestamp;
|
|
587
|
+
held?: true;
|
|
588
|
+
} & ({
|
|
589
|
+
role: "party";
|
|
590
|
+
} | {
|
|
591
|
+
role: "agent";
|
|
592
|
+
userId: UserId;
|
|
593
|
+
} | {
|
|
594
|
+
role: "consulted" | "conferenced";
|
|
556
595
|
destination: string;
|
|
596
|
+
dialId?: DialId;
|
|
557
597
|
label?: string;
|
|
558
|
-
|
|
559
|
-
}
|
|
598
|
+
});
|
|
560
599
|
/**
|
|
561
600
|
* The agent's request for a lead, from asking until the lead leaves or the request ends.
|
|
562
601
|
* `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
|
|
@@ -643,12 +682,12 @@ export type Task<C extends Channel = Channel> = {
|
|
|
643
682
|
attributes?: TaskAttribute[];
|
|
644
683
|
handlingHistory?: TaskHandlingHistory;
|
|
645
684
|
} & TaskCompletion & (C extends "voice" ? {
|
|
646
|
-
|
|
685
|
+
onCall?: OnCall[];
|
|
647
686
|
lead?: TaskLead;
|
|
648
687
|
assisting?: TaskAssisting;
|
|
649
688
|
media?: TaskMediaState;
|
|
650
689
|
} : {
|
|
651
|
-
|
|
690
|
+
onCall?: never;
|
|
652
691
|
lead?: never;
|
|
653
692
|
assisting?: never;
|
|
654
693
|
media?: never;
|
|
@@ -708,13 +747,15 @@ export type VoiceTaskCommand = {
|
|
|
708
747
|
} | {
|
|
709
748
|
type: "disconnect";
|
|
710
749
|
}
|
|
711
|
-
/** Issuable only in `completing`, under the `callback` capability.
|
|
750
|
+
/** Issuable only in `completing`, under the `callback` capability. Dials the party's own number, so it names none. */
|
|
712
751
|
| {
|
|
713
752
|
type: "callback";
|
|
753
|
+
dialId: DialId;
|
|
714
754
|
}
|
|
715
755
|
/** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
|
|
716
756
|
| {
|
|
717
757
|
type: "transfer";
|
|
758
|
+
dialId: DialId;
|
|
718
759
|
destination: string;
|
|
719
760
|
action?: never;
|
|
720
761
|
}
|
|
@@ -722,14 +763,15 @@ export type VoiceTaskCommand = {
|
|
|
722
763
|
| {
|
|
723
764
|
type: "transfer";
|
|
724
765
|
action: "consult";
|
|
766
|
+
dialId: DialId;
|
|
725
767
|
destination: string;
|
|
726
768
|
}
|
|
727
|
-
/** Hand the customer to the consulted destination and leave. Needs `Task.
|
|
769
|
+
/** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
|
|
728
770
|
| {
|
|
729
771
|
type: "transfer";
|
|
730
772
|
action: "complete";
|
|
731
773
|
}
|
|
732
|
-
/** Drop the consulted destination and return to the customer. Needs `Task.
|
|
774
|
+
/** Drop the consulted destination and return to the customer. Needs a `consulted` entry on `Task.onCall`. */
|
|
733
775
|
| {
|
|
734
776
|
type: "transfer";
|
|
735
777
|
action: "cancel";
|
|
@@ -754,10 +796,19 @@ export type VoiceTaskCommand = {
|
|
|
754
796
|
| {
|
|
755
797
|
type: "lead";
|
|
756
798
|
action: "leave";
|
|
757
|
-
}
|
|
799
|
+
}
|
|
800
|
+
/** Dial `destination` into the call. Gated by `conference`. */
|
|
801
|
+
| {
|
|
802
|
+
type: "conference";
|
|
803
|
+
action: "add";
|
|
804
|
+
dialId: DialId;
|
|
805
|
+
destination: string;
|
|
806
|
+
}
|
|
807
|
+
/** Drop the `conferenced` entry on `Task.onCall` with this destination. */
|
|
808
|
+
| {
|
|
758
809
|
type: "conference";
|
|
759
|
-
|
|
760
|
-
|
|
810
|
+
action: "remove";
|
|
811
|
+
destination: string;
|
|
761
812
|
} | {
|
|
762
813
|
type: "recording";
|
|
763
814
|
action: "start" | "pause" | "resume" | "stop";
|
|
@@ -795,22 +846,33 @@ export interface TaskCommandRequest<C extends Channel = Channel> {
|
|
|
795
846
|
/**
|
|
796
847
|
* `applied` rather than a verb per command: the command travels in the request, so
|
|
797
848
|
* `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
|
|
849
|
+
* A command that dials answers `dialling` instead, restating the host's `dialId`: accepted, the
|
|
850
|
+
* call being placed, and the outcome still to come on `dial-outcome`.
|
|
798
851
|
*
|
|
799
852
|
* A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
|
|
800
853
|
* no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the
|
|
801
|
-
* provider did. A command therefore carries no key of Omni's making
|
|
854
|
+
* provider did. A command therefore carries no retry key of Omni's making; a `dialId` is not
|
|
855
|
+
* one, it is how an outcome arriving later is placed.
|
|
802
856
|
*/
|
|
803
857
|
export type TaskCommandResult = {
|
|
804
858
|
status: "applied";
|
|
859
|
+
} | {
|
|
860
|
+
status: "dialling";
|
|
861
|
+
dialId: DialId;
|
|
805
862
|
} | {
|
|
806
863
|
status: "failed";
|
|
807
864
|
failure: ProtocolFailure;
|
|
808
865
|
};
|
|
866
|
+
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
867
|
+
export declare function commandDialId(command: TaskCommand): DialId | undefined;
|
|
809
868
|
export interface DialRequest {
|
|
869
|
+
dialId: DialId;
|
|
810
870
|
destination: string;
|
|
811
871
|
}
|
|
872
|
+
/** `dialling` says accepted and being placed, nothing more; the outcome arrives on `dial-outcome`. */
|
|
812
873
|
export type DialResult = {
|
|
813
|
-
status: "
|
|
874
|
+
status: "dialling";
|
|
875
|
+
dialId: DialId;
|
|
814
876
|
} | {
|
|
815
877
|
status: "failed";
|
|
816
878
|
failure: ProtocolFailure;
|
|
@@ -1148,6 +1210,21 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1148
1210
|
type: "task-ended";
|
|
1149
1211
|
taskId: TaskId;
|
|
1150
1212
|
outcome: TaskOutcome;
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* How a dial the host placed ended, once, either way -- `answered` is stated, never read off
|
|
1216
|
+
* somebody appearing on the call, and says what happened to the dial; who is on the call is
|
|
1217
|
+
* `Task.onCall`. `taskId` is the task the dial was placed on, resolved from the dial and never
|
|
1218
|
+
* from what the agent is looking at, and that task may already have ended: a dial placed late
|
|
1219
|
+
* routinely outlives its call. `reason` is the switch's own words, shown to the agent as such.
|
|
1220
|
+
*/
|
|
1221
|
+
| {
|
|
1222
|
+
type: "dial-outcome";
|
|
1223
|
+
dialId: DialId;
|
|
1224
|
+
outcome: DialOutcome;
|
|
1225
|
+
taskId?: TaskId;
|
|
1226
|
+
destination?: string;
|
|
1227
|
+
reason?: string;
|
|
1151
1228
|
} | {
|
|
1152
1229
|
type: "announcement";
|
|
1153
1230
|
text: string;
|
|
@@ -1291,6 +1368,9 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
|
|
|
1291
1368
|
export declare const userKey: (providerId: string, userId: UserId) => string;
|
|
1292
1369
|
/** Every handling step somebody takes part in. `queued` is the one nobody does. */
|
|
1293
1370
|
export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
|
|
1371
|
+
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
|
|
1372
|
+
export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
|
|
1373
|
+
export declare const handlingStepDials: (step: HandlingStep) => boolean;
|
|
1294
1374
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
1295
1375
|
export declare function handlingStepExpectsAPerson(step: HandlingStep): boolean;
|
|
1296
1376
|
export interface BrowserSessionKeyInput {
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
|
|
|
15
15
|
const shared = adapterVersions.filter(version => host.has(version));
|
|
16
16
|
return shared.length === 0 ? undefined : Math.max(...shared);
|
|
17
17
|
}
|
|
18
|
+
export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled"];
|
|
18
19
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
19
20
|
export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
|
|
20
21
|
/** What Omni calls each idle capability. */
|
|
@@ -50,6 +51,7 @@ export function isAllowedBrowserUrl(url) {
|
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
export const ON_CALL_ROLES = ["party", "agent", "consulted", "conferenced"];
|
|
53
55
|
/**
|
|
54
56
|
* The levels a typical organisation has: the ladder in force when a manifest declares no
|
|
55
57
|
* `orgLevels`. A manifest that declares any states its whole ladder outright.
|
|
@@ -73,6 +75,16 @@ export const TASK_COMMAND_NAMES = {
|
|
|
73
75
|
chat: ["accept", "reject", "pause", "resume", "complete"],
|
|
74
76
|
email: ["accept", "reject", "complete"],
|
|
75
77
|
};
|
|
78
|
+
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
79
|
+
export function commandDialId(command) {
|
|
80
|
+
if (command.type === "callback")
|
|
81
|
+
return command.dialId;
|
|
82
|
+
if (command.type === "transfer" && (command.action === undefined || command.action === "consult"))
|
|
83
|
+
return command.dialId;
|
|
84
|
+
if (command.type === "conference" && command.action === "add")
|
|
85
|
+
return command.dialId;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
76
88
|
export const BREAK_KINDS = [
|
|
77
89
|
"short-break", "meal", "rest", "training", "coaching",
|
|
78
90
|
"meeting", "administrative", "technical", "personal", "other",
|
|
@@ -138,6 +150,12 @@ export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)
|
|
|
138
150
|
export const HANDLING_STEPS_WITH_A_PERSON = [
|
|
139
151
|
"offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
|
|
140
152
|
];
|
|
153
|
+
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
|
|
154
|
+
export const HANDLING_STEPS_THAT_DIAL = ["transferred", "conferenced", "unanswered"];
|
|
155
|
+
export const handlingStepDials = (step) => HANDLING_STEPS_THAT_DIAL.includes(step);
|
|
156
|
+
// Pinned both ways: a step added to `HandlingStep` has to be placed here, and a step listed here
|
|
157
|
+
// has to exist there. `satisfies` on the list covers the second; this statement covers the first.
|
|
158
|
+
true;
|
|
141
159
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
142
160
|
export function handlingStepExpectsAPerson(step) {
|
|
143
161
|
return HANDLING_STEPS_WITH_A_PERSON.includes(step);
|
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 roster, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.onCall", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "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[];
|
|
@@ -98,6 +98,10 @@ export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApp
|
|
|
98
98
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
99
99
|
export declare class TaskStream {
|
|
100
100
|
private readonly tasks;
|
|
101
|
+
private readonly dials;
|
|
102
|
+
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
103
|
+
dialled(dialId: string): void;
|
|
104
|
+
private noteDials;
|
|
101
105
|
private static stated;
|
|
102
106
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
103
107
|
seed(snapshot: unknown): void;
|
|
@@ -127,7 +131,7 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
|
|
|
127
131
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
128
132
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
129
133
|
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;
|
|
130
|
-
/** One provider as the host sees it when freezing a break attempt
|
|
134
|
+
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
131
135
|
export interface BreakCandidate {
|
|
132
136
|
id: string;
|
|
133
137
|
authentication: AuthenticationState["status"];
|
|
@@ -135,13 +139,13 @@ export interface BreakCandidate {
|
|
|
135
139
|
holdsCapacity: boolean;
|
|
136
140
|
}
|
|
137
141
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
142
|
+
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
143
|
+
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
|
144
|
+
* true of it: nothing can be asked of it, and a host that waits on it stalls the break for
|
|
145
|
+
* everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
|
|
146
|
+
* usable: identity and capabilities remain available and work continues.
|
|
143
147
|
*/
|
|
144
|
-
export declare function
|
|
148
|
+
export declare function assertBreakAttemptProviders(candidates: readonly BreakCandidate[], asked: readonly string[]): void;
|
|
145
149
|
/** One published moment of a break asked for on a task: the approval, and how many tasks were outstanding. */
|
|
146
150
|
export interface BreakOnTaskStep {
|
|
147
151
|
approval: BreakApproval;
|
package/dist/testing.js
CHANGED
|
@@ -12,7 +12,7 @@ const STATE_SUBJECTS = [
|
|
|
12
12
|
"task.browsers",
|
|
13
13
|
"task.attributes",
|
|
14
14
|
"task.handlingHistory",
|
|
15
|
-
"task.
|
|
15
|
+
"task.onCall",
|
|
16
16
|
"task.lead",
|
|
17
17
|
"task.assisting",
|
|
18
18
|
"task.media",
|
|
@@ -33,7 +33,7 @@ const STATE_SUBJECTS = [
|
|
|
33
33
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
34
34
|
const EVENT_TYPES = {
|
|
35
35
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
36
|
-
"task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
36
|
+
"task-media-started": true, "task-media-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
37
37
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
38
38
|
};
|
|
39
39
|
const CONTRACT_SUBJECTS = [
|
|
@@ -54,8 +54,8 @@ function observeTask(value, seen) {
|
|
|
54
54
|
seen.add("task.attributes");
|
|
55
55
|
if (isRecord(value.handlingHistory) && some(value.handlingHistory.steps))
|
|
56
56
|
seen.add("task.handlingHistory");
|
|
57
|
-
if (value.
|
|
58
|
-
seen.add("task.
|
|
57
|
+
if (some(value.onCall))
|
|
58
|
+
seen.add("task.onCall");
|
|
59
59
|
if (value.lead !== undefined)
|
|
60
60
|
seen.add("task.lead");
|
|
61
61
|
if (value.assisting !== undefined)
|
|
@@ -617,6 +617,27 @@ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
|
617
617
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
618
618
|
export class TaskStream {
|
|
619
619
|
tasks = new Map();
|
|
620
|
+
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
621
|
+
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
622
|
+
// to whoever holds the task now. `ended` once its outcome arrived, since an outcome comes once.
|
|
623
|
+
dials = new Map();
|
|
624
|
+
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
625
|
+
dialled(dialId) {
|
|
626
|
+
if (!this.dials.has(dialId))
|
|
627
|
+
this.dials.set(dialId, "placed");
|
|
628
|
+
}
|
|
629
|
+
noteDials(task) {
|
|
630
|
+
if (!isRecord(task))
|
|
631
|
+
return;
|
|
632
|
+
const entries = [
|
|
633
|
+
...(Array.isArray(task.onCall) ? task.onCall : []),
|
|
634
|
+
...(isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) ? task.handlingHistory.steps : []),
|
|
635
|
+
];
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
if (isRecord(entry) && typeof entry.dialId === "string")
|
|
638
|
+
this.dialled(entry.dialId);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
620
641
|
static stated(task) {
|
|
621
642
|
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
622
643
|
return { phase: String(isRecord(task) ? task.phase : undefined), media };
|
|
@@ -629,6 +650,7 @@ export class TaskStream {
|
|
|
629
650
|
for (const task of snapshot.tasks) {
|
|
630
651
|
if (isRecord(task) && typeof task.id === "string")
|
|
631
652
|
this.tasks.set(task.id, TaskStream.stated(task));
|
|
653
|
+
this.noteDials(task);
|
|
632
654
|
}
|
|
633
655
|
}
|
|
634
656
|
/** Applies one envelope and returns what it may not say given what came before. */
|
|
@@ -651,6 +673,7 @@ export class TaskStream {
|
|
|
651
673
|
if (known !== undefined)
|
|
652
674
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
653
675
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
676
|
+
this.noteDials(event.task);
|
|
654
677
|
break;
|
|
655
678
|
case "task-updated":
|
|
656
679
|
if (id === undefined)
|
|
@@ -675,6 +698,7 @@ export class TaskStream {
|
|
|
675
698
|
}
|
|
676
699
|
this.tasks.set(id, next);
|
|
677
700
|
}
|
|
701
|
+
this.noteDials(event.task);
|
|
678
702
|
break;
|
|
679
703
|
case "task-media-started":
|
|
680
704
|
if (id === undefined)
|
|
@@ -713,6 +737,21 @@ export class TaskStream {
|
|
|
713
737
|
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
714
738
|
this.tasks.delete(id);
|
|
715
739
|
break;
|
|
740
|
+
case "dial-outcome": {
|
|
741
|
+
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
742
|
+
// a dial placed late routinely outlives its call, which is why the dial has its own identity.
|
|
743
|
+
if (typeof event.dialId !== "string")
|
|
744
|
+
break;
|
|
745
|
+
const dial = this.dials.get(event.dialId);
|
|
746
|
+
if (dial === undefined) {
|
|
747
|
+
refuse("stream.dialOutcome.unknown", `${at}.dialId`, `${event.dialId} is not a dial the host placed or a task carried: an outcome ends a dial somebody made`);
|
|
748
|
+
}
|
|
749
|
+
else if (dial === "ended") {
|
|
750
|
+
refuse("stream.dialOutcome.duplicate", `${at}.dialId`, `${event.dialId} already has its outcome; a dial ends once`);
|
|
751
|
+
}
|
|
752
|
+
this.dials.set(event.dialId, "ended");
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
716
755
|
default:
|
|
717
756
|
break;
|
|
718
757
|
}
|
|
@@ -802,26 +841,26 @@ export function stillHost(report = { online: true }, guarantees = {}) {
|
|
|
802
841
|
}
|
|
803
842
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
804
843
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
844
|
+
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
845
|
+
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
|
846
|
+
* true of it: nothing can be asked of it, and a host that waits on it stalls the break for
|
|
847
|
+
* everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
|
|
848
|
+
* usable: identity and capabilities remain available and work continues.
|
|
810
849
|
*/
|
|
811
|
-
export function
|
|
812
|
-
const chosen = new Set(
|
|
813
|
-
for (const id of
|
|
850
|
+
export function assertBreakAttemptProviders(candidates, asked) {
|
|
851
|
+
const chosen = new Set(asked);
|
|
852
|
+
for (const id of asked) {
|
|
814
853
|
if (!candidates.some(candidate => candidate.id === id))
|
|
815
854
|
throw new Error(`${id} is not a provider the host knows`);
|
|
816
855
|
}
|
|
817
856
|
for (const candidate of candidates) {
|
|
818
857
|
const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
|
|
819
858
|
if (expected && !chosen.has(candidate.id)) {
|
|
820
|
-
throw new Error(`${candidate.id} can give the agent work and must be
|
|
859
|
+
throw new Error(`${candidate.id} can give the agent work and must be asked`);
|
|
821
860
|
}
|
|
822
861
|
if (!expected && chosen.has(candidate.id)) {
|
|
823
862
|
const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
|
|
824
|
-
throw new Error(`${candidate.id} ${why} and is not
|
|
863
|
+
throw new Error(`${candidate.id} ${why} and is not asked: nothing can be asked of it`);
|
|
825
864
|
}
|
|
826
865
|
}
|
|
827
866
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface TaskValidationContext {
|
|
|
17
17
|
levels?: readonly string[];
|
|
18
18
|
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
|
|
19
19
|
autoAcceptTasks?: boolean;
|
|
20
|
+
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
21
|
+
dialOutcomesDeclared?: boolean;
|
|
20
22
|
}
|
|
21
23
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
22
24
|
/**
|
|
@@ -64,7 +66,7 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
64
66
|
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
65
67
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
66
68
|
*/
|
|
67
|
-
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
69
|
+
export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
|
|
68
70
|
/** What a login is validated against beyond its own shape. */
|
|
69
71
|
export interface LoginValidationContext {
|
|
70
72
|
/** The level ids in force, from the manifest. The defaults when absent. */
|