@xema/omni-protocol 0.1.32 → 0.1.33
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 +94 -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 +199 -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,13 @@ 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. A manifest declares which of these its platform can
|
|
80
|
+
* tell apart (`dialOutcomes`), and a `dial-outcome` carries only a declared one.
|
|
81
|
+
*/
|
|
82
|
+
export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected";
|
|
83
|
+
export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected"];
|
|
71
84
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
72
85
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
73
86
|
export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
@@ -113,6 +126,12 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
113
126
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
114
127
|
/** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
|
|
115
128
|
orgLevels?: LevelDeclaration[];
|
|
129
|
+
/**
|
|
130
|
+
* The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
|
|
131
|
+
* not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
|
|
132
|
+
* a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
|
|
133
|
+
*/
|
|
134
|
+
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
116
135
|
/**
|
|
117
136
|
* The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
|
|
118
137
|
* so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
|
|
@@ -520,6 +539,12 @@ export interface TaskHandlingHistory {
|
|
|
520
539
|
export interface TaskHandlingStep {
|
|
521
540
|
step: HandlingStep;
|
|
522
541
|
at: IsoTimestamp;
|
|
542
|
+
/**
|
|
543
|
+
* On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
|
|
544
|
+
* that dial when a host placed it, and the address it went to. Neither belongs on any other step.
|
|
545
|
+
*/
|
|
546
|
+
dialId?: DialId;
|
|
547
|
+
destination?: string;
|
|
523
548
|
/**
|
|
524
549
|
* Reported, never derived. An entry may be written while its leg is still running, so there is
|
|
525
550
|
* no end to subtract from -- and omitted is the honest report of that, where nought would
|
|
@@ -547,16 +572,29 @@ export type TaskCompletion = {
|
|
|
547
572
|
completionMode: "provider-automatic";
|
|
548
573
|
wrapAllowance: DurationSeconds;
|
|
549
574
|
};
|
|
575
|
+
export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
576
|
+
export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
|
|
550
577
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
578
|
+
* Somebody on the call right now, as the provider states it: who, since when, and whether they
|
|
579
|
+
* are held aside. `party` is the customer; `agent` a person, by user id; `consulted` and
|
|
580
|
+
* `conferenced` someone a dial brought in, carrying the dial when a host placed it and the address
|
|
581
|
+
* either way. `label` names a destination -- a person, a queue -- not a phrase. At most one
|
|
582
|
+
* `party` and one `consulted`: the consult commands name neither because there is exactly one.
|
|
554
583
|
*/
|
|
555
|
-
export
|
|
584
|
+
export type OnCall = {
|
|
585
|
+
since: IsoTimestamp;
|
|
586
|
+
held?: true;
|
|
587
|
+
} & ({
|
|
588
|
+
role: "party";
|
|
589
|
+
} | {
|
|
590
|
+
role: "agent";
|
|
591
|
+
userId: UserId;
|
|
592
|
+
} | {
|
|
593
|
+
role: "consulted" | "conferenced";
|
|
556
594
|
destination: string;
|
|
595
|
+
dialId?: DialId;
|
|
557
596
|
label?: string;
|
|
558
|
-
|
|
559
|
-
}
|
|
597
|
+
});
|
|
560
598
|
/**
|
|
561
599
|
* The agent's request for a lead, from asking until the lead leaves or the request ends.
|
|
562
600
|
* `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
|
|
@@ -643,12 +681,12 @@ export type Task<C extends Channel = Channel> = {
|
|
|
643
681
|
attributes?: TaskAttribute[];
|
|
644
682
|
handlingHistory?: TaskHandlingHistory;
|
|
645
683
|
} & TaskCompletion & (C extends "voice" ? {
|
|
646
|
-
|
|
684
|
+
onCall?: OnCall[];
|
|
647
685
|
lead?: TaskLead;
|
|
648
686
|
assisting?: TaskAssisting;
|
|
649
687
|
media?: TaskMediaState;
|
|
650
688
|
} : {
|
|
651
|
-
|
|
689
|
+
onCall?: never;
|
|
652
690
|
lead?: never;
|
|
653
691
|
assisting?: never;
|
|
654
692
|
media?: never;
|
|
@@ -708,13 +746,15 @@ export type VoiceTaskCommand = {
|
|
|
708
746
|
} | {
|
|
709
747
|
type: "disconnect";
|
|
710
748
|
}
|
|
711
|
-
/** Issuable only in `completing`, under the `callback` capability.
|
|
749
|
+
/** Issuable only in `completing`, under the `callback` capability. Dials the party's own number, so it names none. */
|
|
712
750
|
| {
|
|
713
751
|
type: "callback";
|
|
752
|
+
dialId: DialId;
|
|
714
753
|
}
|
|
715
754
|
/** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
|
|
716
755
|
| {
|
|
717
756
|
type: "transfer";
|
|
757
|
+
dialId: DialId;
|
|
718
758
|
destination: string;
|
|
719
759
|
action?: never;
|
|
720
760
|
}
|
|
@@ -722,14 +762,15 @@ export type VoiceTaskCommand = {
|
|
|
722
762
|
| {
|
|
723
763
|
type: "transfer";
|
|
724
764
|
action: "consult";
|
|
765
|
+
dialId: DialId;
|
|
725
766
|
destination: string;
|
|
726
767
|
}
|
|
727
|
-
/** Hand the customer to the consulted destination and leave. Needs `Task.
|
|
768
|
+
/** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
|
|
728
769
|
| {
|
|
729
770
|
type: "transfer";
|
|
730
771
|
action: "complete";
|
|
731
772
|
}
|
|
732
|
-
/** Drop the consulted destination and return to the customer. Needs `Task.
|
|
773
|
+
/** Drop the consulted destination and return to the customer. Needs a `consulted` entry on `Task.onCall`. */
|
|
733
774
|
| {
|
|
734
775
|
type: "transfer";
|
|
735
776
|
action: "cancel";
|
|
@@ -754,10 +795,19 @@ export type VoiceTaskCommand = {
|
|
|
754
795
|
| {
|
|
755
796
|
type: "lead";
|
|
756
797
|
action: "leave";
|
|
757
|
-
}
|
|
798
|
+
}
|
|
799
|
+
/** Dial `destination` into the call. Gated by `conference`. */
|
|
800
|
+
| {
|
|
801
|
+
type: "conference";
|
|
802
|
+
action: "add";
|
|
803
|
+
dialId: DialId;
|
|
804
|
+
destination: string;
|
|
805
|
+
}
|
|
806
|
+
/** Drop the `conferenced` entry on `Task.onCall` with this destination. */
|
|
807
|
+
| {
|
|
758
808
|
type: "conference";
|
|
759
|
-
|
|
760
|
-
|
|
809
|
+
action: "remove";
|
|
810
|
+
destination: string;
|
|
761
811
|
} | {
|
|
762
812
|
type: "recording";
|
|
763
813
|
action: "start" | "pause" | "resume" | "stop";
|
|
@@ -795,22 +845,33 @@ export interface TaskCommandRequest<C extends Channel = Channel> {
|
|
|
795
845
|
/**
|
|
796
846
|
* `applied` rather than a verb per command: the command travels in the request, so
|
|
797
847
|
* `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
|
|
848
|
+
* A command that dials answers `dialling` instead, restating the host's `dialId`: accepted, the
|
|
849
|
+
* call being placed, and the outcome still to come on `dial-outcome`.
|
|
798
850
|
*
|
|
799
851
|
* A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
|
|
800
852
|
* 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
|
|
853
|
+
* provider did. A command therefore carries no retry key of Omni's making; a `dialId` is not
|
|
854
|
+
* one, it is how an outcome arriving later is placed.
|
|
802
855
|
*/
|
|
803
856
|
export type TaskCommandResult = {
|
|
804
857
|
status: "applied";
|
|
858
|
+
} | {
|
|
859
|
+
status: "dialling";
|
|
860
|
+
dialId: DialId;
|
|
805
861
|
} | {
|
|
806
862
|
status: "failed";
|
|
807
863
|
failure: ProtocolFailure;
|
|
808
864
|
};
|
|
865
|
+
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
866
|
+
export declare function commandDialId(command: TaskCommand): DialId | undefined;
|
|
809
867
|
export interface DialRequest {
|
|
868
|
+
dialId: DialId;
|
|
810
869
|
destination: string;
|
|
811
870
|
}
|
|
871
|
+
/** `dialling` says accepted and being placed, nothing more; the outcome arrives on `dial-outcome`. */
|
|
812
872
|
export type DialResult = {
|
|
813
|
-
status: "
|
|
873
|
+
status: "dialling";
|
|
874
|
+
dialId: DialId;
|
|
814
875
|
} | {
|
|
815
876
|
status: "failed";
|
|
816
877
|
failure: ProtocolFailure;
|
|
@@ -1148,6 +1209,20 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1148
1209
|
type: "task-ended";
|
|
1149
1210
|
taskId: TaskId;
|
|
1150
1211
|
outcome: TaskOutcome;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* How a dial the host placed ended, once, either way -- `answered` is stated, never read off
|
|
1215
|
+
* somebody appearing on the call. `taskId` where the dial was on a task, and that task may
|
|
1216
|
+
* already have ended: a dial placed late routinely outlives its call. `reason` is the switch's
|
|
1217
|
+
* own words, shown to the agent as such.
|
|
1218
|
+
*/
|
|
1219
|
+
| {
|
|
1220
|
+
type: "dial-outcome";
|
|
1221
|
+
dialId: DialId;
|
|
1222
|
+
outcome: DialOutcome;
|
|
1223
|
+
taskId?: TaskId;
|
|
1224
|
+
destination?: string;
|
|
1225
|
+
reason?: string;
|
|
1151
1226
|
} | {
|
|
1152
1227
|
type: "announcement";
|
|
1153
1228
|
text: string;
|
|
@@ -1291,6 +1366,9 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
|
|
|
1291
1366
|
export declare const userKey: (providerId: string, userId: UserId) => string;
|
|
1292
1367
|
/** Every handling step somebody takes part in. `queued` is the one nobody does. */
|
|
1293
1368
|
export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
|
|
1369
|
+
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
|
|
1370
|
+
export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
|
|
1371
|
+
export declare const handlingStepDials: (step: HandlingStep) => boolean;
|
|
1294
1372
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
1295
1373
|
export declare function handlingStepExpectsAPerson(step: HandlingStep): boolean;
|
|
1296
1374
|
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"];
|
|
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. */
|