@xema/omni-protocol 0.1.0
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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/design.d.ts +49 -0
- package/dist/design.js +27 -0
- package/dist/index.d.ts +894 -0
- package/dist/index.js +185 -0
- package/dist/testing.d.ts +62 -0
- package/dist/testing.js +294 -0
- package/dist/validation.d.ts +21 -0
- package/dist/validation.js +940 -0
- package/guide.md +2935 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The Omni protocol.
|
|
2
|
+
//
|
|
3
|
+
// Where this file and guide.md disagree, the guide is right and this is a defect.
|
|
4
|
+
/** The protocol version implemented by this package. */
|
|
5
|
+
export const OMNI_PROTOCOL_VERSION = 1;
|
|
6
|
+
/** Every version this package can interoperate with. */
|
|
7
|
+
export const OMNI_SUPPORTED_PROTOCOL_VERSIONS = [OMNI_PROTOCOL_VERSION];
|
|
8
|
+
/**
|
|
9
|
+
* Highest version supported by both the adapter and the host, or `undefined` when they cannot
|
|
10
|
+
* interoperate. Omni must refuse to connect on `undefined` rather than attempting partial
|
|
11
|
+
* compatibility.
|
|
12
|
+
*/
|
|
13
|
+
export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SUPPORTED_PROTOCOL_VERSIONS) {
|
|
14
|
+
const host = new Set(hostVersions);
|
|
15
|
+
const shared = adapterVersions.filter(version => host.has(version));
|
|
16
|
+
return shared.length === 0 ? undefined : Math.max(...shared);
|
|
17
|
+
}
|
|
18
|
+
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
19
|
+
export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
|
|
20
|
+
/** What Omni calls each idle capability. */
|
|
21
|
+
export const IDLE_CAPABILITY_UI = {
|
|
22
|
+
dial: "Dialpad",
|
|
23
|
+
personalBrowser: "Browser",
|
|
24
|
+
calendar: "Calendar",
|
|
25
|
+
contacts: "Contacts",
|
|
26
|
+
};
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Task workspace.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* How a reusing browser's session is keyed.
|
|
32
|
+
*
|
|
33
|
+
* Named constants rather than a bare union because the values are structured strings: easy to
|
|
34
|
+
* mistype and unreadable as an argument.
|
|
35
|
+
*/
|
|
36
|
+
export const BROWSER_ISOLATION_SCHEMES = {
|
|
37
|
+
PROVIDER_NAME__TASK_ID__TAB_NAME: "ProviderName.TaskId.TabName",
|
|
38
|
+
TAB_NAME: "TabName",
|
|
39
|
+
PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME: "ProviderName.TaskTypeName.TabName",
|
|
40
|
+
PROVIDER_NAME__TAB_NAME: "ProviderName.TabName",
|
|
41
|
+
PROVIDER_NAME__TASK_TYPE_NAME: "ProviderName.TaskTypeName",
|
|
42
|
+
TASK_TYPE_NAME__TAB_NAME: "TaskTypeName.TabName",
|
|
43
|
+
};
|
|
44
|
+
export const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"];
|
|
45
|
+
export function isAllowedBrowserUrl(url) {
|
|
46
|
+
try {
|
|
47
|
+
return ALLOWED_BROWSER_URL_SCHEMES.includes(new URL(url).protocol);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Task commands.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
export const TASK_COMMAND_NAMES = {
|
|
57
|
+
voice: ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect",
|
|
58
|
+
"transfer", "conference", "recording", "complete"],
|
|
59
|
+
chat: ["accept", "reject", "pause", "resume", "complete"],
|
|
60
|
+
email: ["accept", "reject", "complete"],
|
|
61
|
+
};
|
|
62
|
+
export const BREAK_KINDS = [
|
|
63
|
+
"short-break", "meal", "rest", "training", "coaching",
|
|
64
|
+
"meeting", "administrative", "technical", "personal", "other",
|
|
65
|
+
];
|
|
66
|
+
export const OMNI_FAILURE_CODES = [
|
|
67
|
+
"omni.not-authenticated",
|
|
68
|
+
"omni.capability-not-enabled",
|
|
69
|
+
"omni.task-not-found",
|
|
70
|
+
"omni.destination-not-permitted",
|
|
71
|
+
"omni.rate-limited",
|
|
72
|
+
"omni.unavailable",
|
|
73
|
+
"omni.break-already-committed",
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* Preserves the adapter's inferred concrete type while checking it implements `Adapter`.
|
|
77
|
+
* No connection, no runtime side effects.
|
|
78
|
+
*/
|
|
79
|
+
export function defineAdapter(adapter) {
|
|
80
|
+
return adapter;
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Presentation defaults.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
export const DEFAULT_TASK_PHASE_LABELS = {
|
|
86
|
+
voice: {
|
|
87
|
+
pending: "Offered", confirmed: "Accepted", preparing: "Preview",
|
|
88
|
+
"in-progress": "On Call", paused: "On Hold", completing: "After Call Work",
|
|
89
|
+
},
|
|
90
|
+
chat: {
|
|
91
|
+
pending: "Incoming Chat", confirmed: "Accepted", preparing: "Preparing",
|
|
92
|
+
"in-progress": "In Chat", paused: "Paused", completing: "Wrap-up",
|
|
93
|
+
},
|
|
94
|
+
email: {
|
|
95
|
+
pending: "Assigned", confirmed: "Accepted", preparing: "Reviewing",
|
|
96
|
+
"in-progress": "Working", paused: "Paused", completing: "Completing",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
export const DEFAULT_TASK_TYPE_PRESENTATION = {
|
|
100
|
+
voice: { singular: "Call", plural: "Calls", referenceLabel: "Call ID" },
|
|
101
|
+
chat: { singular: "Chat", plural: "Chats", referenceLabel: "Chat ID" },
|
|
102
|
+
email: { singular: "Email", plural: "Emails", referenceLabel: "Email ID" },
|
|
103
|
+
};
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Utilities.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* A collision-safe global task key.
|
|
109
|
+
*
|
|
110
|
+
* Task ids are unique only within one provider, so two providers will eventually issue the same
|
|
111
|
+
* string. Encoding before joining is what stops a provider id containing a separator from
|
|
112
|
+
* forging another provider's key.
|
|
113
|
+
*/
|
|
114
|
+
export const taskKey = (providerId, taskId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(taskId)}`;
|
|
115
|
+
/**
|
|
116
|
+
* The same treatment for a `UserId`, and needed for the same reason.
|
|
117
|
+
*
|
|
118
|
+
* There is no Omni-wide user identity: one person on several providers has several identities
|
|
119
|
+
* and nothing here pairs them. A bare `UserId` is only ever compared against another from the
|
|
120
|
+
* same provider; anything wider goes through this.
|
|
121
|
+
*/
|
|
122
|
+
export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(userId)}`;
|
|
123
|
+
/** Every handling step somebody takes part in. `queued` is the one nobody does. */
|
|
124
|
+
export const HANDLING_STEPS_WITH_A_PERSON = [
|
|
125
|
+
"offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
|
|
126
|
+
];
|
|
127
|
+
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
128
|
+
export function handlingStepExpectsAPerson(step) {
|
|
129
|
+
return HANDLING_STEPS_WITH_A_PERSON.includes(step);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The storage-profile key a reusing browser shares, or `undefined` where it shares nothing.
|
|
133
|
+
*
|
|
134
|
+
* Fails closed. A browser with `reuse: false` has no key; nor does a reusing one whose scheme is
|
|
135
|
+
* missing or unknown -- the type forbids that, but an adapter compiled against another version can
|
|
136
|
+
* still send it, and the safe reading is "do not share", never "share with everyone named the
|
|
137
|
+
* same". Every part is encoded, separator included, before joining, so a tab called `a.b`
|
|
138
|
+
* cannot collide with a provider called `a` and a tab called `b`.
|
|
139
|
+
*/
|
|
140
|
+
export function browserSessionKey(input) {
|
|
141
|
+
const { providerId, taskId, taskType, browser } = input;
|
|
142
|
+
if (browser.reuse !== true)
|
|
143
|
+
return undefined;
|
|
144
|
+
// `encodeURIComponent` leaves `.` untouched, and `.` is the separator: a raw join would let
|
|
145
|
+
// provider `Acme.Voice` with type `Support` forge the key of `Acme` with `Voice.Support`.
|
|
146
|
+
const part = (value) => encodeURIComponent(value).replaceAll(".", "%2E");
|
|
147
|
+
switch (browser.isolationScheme) {
|
|
148
|
+
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_ID__TAB_NAME:
|
|
149
|
+
return `${part(providerId)}.${part(taskId)}.${part(browser.name)}`;
|
|
150
|
+
case BROWSER_ISOLATION_SCHEMES.TAB_NAME:
|
|
151
|
+
return part(browser.name);
|
|
152
|
+
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME:
|
|
153
|
+
return `${part(providerId)}.${part(taskType)}.${part(browser.name)}`;
|
|
154
|
+
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TAB_NAME:
|
|
155
|
+
return `${part(providerId)}.${part(browser.name)}`;
|
|
156
|
+
case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME:
|
|
157
|
+
return `${part(providerId)}.${part(taskType)}`;
|
|
158
|
+
case BROWSER_ISOLATION_SCHEMES.TASK_TYPE_NAME__TAB_NAME:
|
|
159
|
+
return `${part(taskType)}.${part(browser.name)}`;
|
|
160
|
+
default:
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The comparison key for a contact number. Never for display: keep the original value for that.
|
|
166
|
+
*
|
|
167
|
+
* Applies NFKC, strips whitespace, brackets, slashes, periods and every Unicode dash, and rewrites
|
|
168
|
+
* a leading `00` to `+`, so `+1 (415) 555-0100`, `+1.415.555.0100` and `0014155550100` all merge.
|
|
169
|
+
* Cross-provider merging is reliable only for E.164 input: a national-format number carries no
|
|
170
|
+
* country context, nothing in this protocol supplies one, and so it does not merge with its
|
|
171
|
+
* `+`-prefixed twin.
|
|
172
|
+
*/
|
|
173
|
+
export function normalizeContactNumber(number) {
|
|
174
|
+
const compact = number
|
|
175
|
+
.normalize("NFKC")
|
|
176
|
+
.trim()
|
|
177
|
+
.replace(/[\s\p{Pd}().\/\\[\]]/gu, "")
|
|
178
|
+
.replace(/^00/, "+");
|
|
179
|
+
const digits = compact.replace(/\D/g, "");
|
|
180
|
+
return compact.startsWith("+") ? `+${digits}` : digits;
|
|
181
|
+
}
|
|
182
|
+
/** The comparison key for a contact email. Never for display. */
|
|
183
|
+
export function normalizeContactEmail(email) {
|
|
184
|
+
return email.normalize("NFKC").trim().toLocaleLowerCase("en-US");
|
|
185
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Connection, type Snapshot, type Task, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type DialRequest, type TaskCommandRequest } from "./index.js";
|
|
2
|
+
import { type ProtocolViolation } from "./validation.js";
|
|
3
|
+
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
|
+
export interface AdapterContractResult {
|
|
5
|
+
events: ProviderEventEnvelope[];
|
|
6
|
+
authenticationState: AuthenticationState;
|
|
7
|
+
/** True only when `disconnect()` and `close()` both settled without throwing. */
|
|
8
|
+
disconnectWasClean: boolean;
|
|
9
|
+
/** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
|
|
10
|
+
violations: readonly ProtocolViolation[];
|
|
11
|
+
}
|
|
12
|
+
export interface ExerciseAdapterOptions {
|
|
13
|
+
/** Return violations in the result instead of throwing. Defaults to `false`. */
|
|
14
|
+
collectOnly?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
18
|
+
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
19
|
+
* validates the snapshot and every delivered event, states a capacity, then unsubscribes and
|
|
20
|
+
* disconnects.
|
|
21
|
+
*
|
|
22
|
+
* Violations are collected rather than thrown from inside the adapter's own
|
|
23
|
+
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
24
|
+
* for a synchronous emitter, and is swallowed as an unhandled rejection for an
|
|
25
|
+
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
26
|
+
*/
|
|
27
|
+
export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;
|
|
28
|
+
/** Verifies the at-most-once contract by issuing the same command twice. */
|
|
29
|
+
export declare function assertCommandIdempotency(connection: Pick<Connection, "execute">, request: TaskCommandRequest): Promise<void>;
|
|
30
|
+
/** Validates restored authentication followed by a refresh failure or expiry. */
|
|
31
|
+
export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
|
|
32
|
+
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
33
|
+
export declare function assertDuplicateEventDelivery<C extends Channel>(envelopes: readonly ProviderEventEnvelope<C>[]): ProviderEventEnvelope<C>[];
|
|
34
|
+
/** Validates an authoritative reconnect snapshot containing assignments missed while offline. */
|
|
35
|
+
export declare function assertReconnectWithMissedAssignments<C extends Channel>(before: Snapshot<C>, reconnect: ProviderEventEnvelope<C>, missedTaskIds: readonly string[]): void;
|
|
36
|
+
/**
|
|
37
|
+
* Validates a refused break followed by a later request that is granted.
|
|
38
|
+
*
|
|
39
|
+
* There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
|
|
40
|
+
* pending request nobody is coming to decide is worse than none. So the scenario is a request
|
|
41
|
+
* that goes back to not-requested, and a later one that is granted.
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
44
|
+
/** Verifies that retrying one dial command cannot place a second call. */
|
|
45
|
+
export declare function assertDialIdempotency(connection: Pick<Connection, "dial">, request: DialRequest): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Validates the deadline derived from media end and the task's fixed wrap allowance.
|
|
48
|
+
* `toleranceMs` absorbs scheduler jitter in a real implementation; pass 0 to demand
|
|
49
|
+
* an exact match.
|
|
50
|
+
*/
|
|
51
|
+
export declare function assertWrapTimeout(task: Pick<Task, "completionAllowance">, mediaEndedAt: string, observedDeadline: string, toleranceMs?: number): void;
|
|
52
|
+
/** One browser in one task of one provider. `providerId` is `Manifest.id`, never `displayName`. */
|
|
53
|
+
export type BrowserIsolationScenario = BrowserSessionKeyInput;
|
|
54
|
+
/** Validates whether two task-browser definitions should share one browser session. */
|
|
55
|
+
export declare function assertBrowserIsolationAndReuse(left: BrowserIsolationScenario, right: BrowserIsolationScenario, expectedReuse: boolean): void;
|
|
56
|
+
/**
|
|
57
|
+
* Asserts that no two distinct scenarios in `scenarios` derive the same session key.
|
|
58
|
+
* Feed it adversarial names — a provider called `A.B` against a task type called
|
|
59
|
+
* `B`, casing variants, separators — because a collision silently shares cookies,
|
|
60
|
+
* storage, and permissions between two backends.
|
|
61
|
+
*/
|
|
62
|
+
export declare function assertNoBrowserSessionKeyCollisions(scenarios: readonly BrowserIsolationScenario[]): void;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { browserSessionKey, } from "./index.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateSnapshot, } from "./validation.js";
|
|
3
|
+
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
|
+
/**
|
|
5
|
+
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
6
|
+
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
7
|
+
* validates the snapshot and every delivered event, states a capacity, then unsubscribes and
|
|
8
|
+
* disconnects.
|
|
9
|
+
*
|
|
10
|
+
* Violations are collected rather than thrown from inside the adapter's own
|
|
11
|
+
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
12
|
+
* for a synchronous emitter, and is swallowed as an unhandled rejection for an
|
|
13
|
+
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
14
|
+
*/
|
|
15
|
+
export async function exerciseAdapter(adapter, context, options = {}) {
|
|
16
|
+
const violations = [...validateManifest(adapter.manifest)];
|
|
17
|
+
const events = [];
|
|
18
|
+
const storedSecrets = new Map();
|
|
19
|
+
const authentication = await adapter.createAuthenticationSession({
|
|
20
|
+
...context,
|
|
21
|
+
secrets: {
|
|
22
|
+
get: async (key) => storedSecrets.get(key),
|
|
23
|
+
set: async (key, value) => { storedSecrets.set(key, value); },
|
|
24
|
+
delete: async (key) => { storedSecrets.delete(key); },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
let connection;
|
|
28
|
+
let unsubscribe;
|
|
29
|
+
let authenticationState;
|
|
30
|
+
let disconnectWasClean = false;
|
|
31
|
+
try {
|
|
32
|
+
authenticationState = await authentication.state();
|
|
33
|
+
violations.push(...validateAuthenticationState(authenticationState));
|
|
34
|
+
if (authenticationState.status !== "authenticated") {
|
|
35
|
+
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
36
|
+
}
|
|
37
|
+
connection = await adapter.connect(context);
|
|
38
|
+
const live = connection;
|
|
39
|
+
// The optional methods are optional only until something declares a need for them. Each
|
|
40
|
+
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
41
|
+
// table does; a missing one is a control the agent would be shown and could never use.
|
|
42
|
+
const requireMethod = (name, because) => {
|
|
43
|
+
if (typeof live[name] !== "function") {
|
|
44
|
+
violations.push({
|
|
45
|
+
rule: `connection.${name}.required`,
|
|
46
|
+
path: `connection.${name}`,
|
|
47
|
+
message: `${because}, but the connection does not implement ${name}()`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
52
|
+
// than an `enabled` flag, so its presence is the declaration.
|
|
53
|
+
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
54
|
+
requireMethod("dial", "the manifest declares dial");
|
|
55
|
+
// Every voice task's audio lands in Omni, so there is no voice adapter that does not open it.
|
|
56
|
+
if (adapter.manifest.channel === "voice")
|
|
57
|
+
requireMethod("openMedia", "the manifest channel is voice");
|
|
58
|
+
const eventIds = new Set();
|
|
59
|
+
unsubscribe = connection.subscribe(envelope => {
|
|
60
|
+
violations.push(...validateEventEnvelope(envelope, adapter.manifest));
|
|
61
|
+
if (typeof envelope?.id === "string") {
|
|
62
|
+
if (eventIds.has(envelope.id))
|
|
63
|
+
return;
|
|
64
|
+
eventIds.add(envelope.id);
|
|
65
|
+
}
|
|
66
|
+
events.push(envelope);
|
|
67
|
+
});
|
|
68
|
+
const snapshot = await connection.snapshot();
|
|
69
|
+
violations.push(...validateSnapshot(snapshot, adapter.manifest));
|
|
70
|
+
if (snapshot?.sessionCapabilities?.breaks === true) {
|
|
71
|
+
// The four stand or fall together: `granted` is a promise to honour a later commit, and
|
|
72
|
+
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
|
73
|
+
for (const method of ["requestBreak", "commitBreak", "cancelBreak", "endBreak"]) {
|
|
74
|
+
requireMethod(method, "the snapshot declares sessionCapabilities.breaks");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (snapshot?.team?.breakControl === true)
|
|
78
|
+
requireMethod("executeTeamBreak", "the roster carries breakControl");
|
|
79
|
+
if (publishesUserIds(snapshot))
|
|
80
|
+
requireMethod("describeUsers", "the snapshot publishes a UserId");
|
|
81
|
+
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
82
|
+
// that will not accept one is a connection nothing can be given to.
|
|
83
|
+
const capacity = await connection.setCapacity({ count: 1 });
|
|
84
|
+
if (capacity.status === "failed") {
|
|
85
|
+
violations.push({
|
|
86
|
+
rule: "connection.setCapacity.failed",
|
|
87
|
+
path: "connection.setCapacity",
|
|
88
|
+
message: `the provider would not accept a capacity: ${capacity.failure.code}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
let clean = true;
|
|
94
|
+
try {
|
|
95
|
+
unsubscribe?.();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
clean = false;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await connection?.disconnect();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
clean = false;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await authentication.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
clean = false;
|
|
111
|
+
}
|
|
112
|
+
disconnectWasClean = clean;
|
|
113
|
+
}
|
|
114
|
+
if (!disconnectWasClean) {
|
|
115
|
+
violations.push({
|
|
116
|
+
rule: "connection.disconnect.clean",
|
|
117
|
+
path: "connection.disconnect",
|
|
118
|
+
message: "unsubscribe(), disconnect(), or close() threw during shutdown",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (!options.collectOnly)
|
|
122
|
+
assertNoViolations(violations);
|
|
123
|
+
return {
|
|
124
|
+
events: events,
|
|
125
|
+
authenticationState: authenticationState,
|
|
126
|
+
disconnectWasClean,
|
|
127
|
+
violations,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether a snapshot carries any `UserId`, which is what obliges an adapter to describe users.
|
|
132
|
+
* The snapshot is untrusted input that has already been reported on, so nothing here assumes
|
|
133
|
+
* its shape.
|
|
134
|
+
*/
|
|
135
|
+
function publishesUserIds(snapshot) {
|
|
136
|
+
if (snapshot?.break?.imposed?.by !== undefined)
|
|
137
|
+
return true;
|
|
138
|
+
if (Array.isArray(snapshot?.team?.members) && snapshot.team.members.length > 0)
|
|
139
|
+
return true;
|
|
140
|
+
if (!Array.isArray(snapshot?.tasks))
|
|
141
|
+
return false;
|
|
142
|
+
return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
|
|
143
|
+
}
|
|
144
|
+
/** Verifies the at-most-once contract by issuing the same command twice. */
|
|
145
|
+
export async function assertCommandIdempotency(connection, request) {
|
|
146
|
+
const first = await connection.execute(request);
|
|
147
|
+
if (first.commandId !== request.commandId)
|
|
148
|
+
throw new Error("Command result id mismatch");
|
|
149
|
+
if (first.status === "failed") {
|
|
150
|
+
throw new Error(`Command failed: ${first.failure.code}`);
|
|
151
|
+
}
|
|
152
|
+
const retry = await connection.execute(request);
|
|
153
|
+
if (retry.commandId !== request.commandId)
|
|
154
|
+
throw new Error("Retried command result id mismatch");
|
|
155
|
+
if (retry.status !== "already-applied") {
|
|
156
|
+
throw new Error(`Retried command must return already-applied, received ${retry.status}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Validates restored authentication followed by a refresh failure or expiry. */
|
|
160
|
+
export function assertAuthenticationRestoreAndExpiry(states) {
|
|
161
|
+
if (states.length < 2 || states[0]?.status !== "authenticated") {
|
|
162
|
+
throw new Error("Authentication scenario must start with a restored authenticated state");
|
|
163
|
+
}
|
|
164
|
+
const expiredIndex = states.findIndex(state => state.status === "expired");
|
|
165
|
+
if (expiredIndex < 1 || states.at(-1)?.status !== "expired") {
|
|
166
|
+
throw new Error("Authentication scenario must end in an expired state");
|
|
167
|
+
}
|
|
168
|
+
const refreshingIndex = states.findIndex(state => state.status === "refreshing");
|
|
169
|
+
if (refreshingIndex >= 0 && refreshingIndex > expiredIndex) {
|
|
170
|
+
throw new Error("Refreshing state must occur before expiry");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
174
|
+
export function assertDuplicateEventDelivery(envelopes) {
|
|
175
|
+
const byId = new Map();
|
|
176
|
+
let duplicateFound = false;
|
|
177
|
+
for (const envelope of envelopes) {
|
|
178
|
+
const existing = byId.get(envelope.id);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
byId.set(envelope.id, envelope);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
duplicateFound = true;
|
|
184
|
+
if (JSON.stringify(existing) !== JSON.stringify(envelope)) {
|
|
185
|
+
throw new Error(`Duplicate event ID '${envelope.id}' changed its payload`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!duplicateFound)
|
|
189
|
+
throw new Error("Duplicate delivery scenario requires a repeated event ID");
|
|
190
|
+
return [...byId.values()];
|
|
191
|
+
}
|
|
192
|
+
/** Validates an authoritative reconnect snapshot containing assignments missed while offline. */
|
|
193
|
+
export function assertReconnectWithMissedAssignments(before, reconnect, missedTaskIds) {
|
|
194
|
+
if (reconnect.event.type !== "snapshot" || reconnect.event.reason !== "reconnected") {
|
|
195
|
+
throw new Error("Reconnect scenario requires a reconnected snapshot event");
|
|
196
|
+
}
|
|
197
|
+
const beforeIds = new Set(before.tasks.map(task => task.id));
|
|
198
|
+
const refreshedIds = new Set(reconnect.event.snapshot.tasks.map(task => task.id));
|
|
199
|
+
for (const taskId of missedTaskIds) {
|
|
200
|
+
if (beforeIds.has(taskId))
|
|
201
|
+
throw new Error(`Task '${taskId}' was not missed before reconnect`);
|
|
202
|
+
if (!refreshedIds.has(taskId))
|
|
203
|
+
throw new Error(`Reconnect snapshot is missing task '${taskId}'`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Validates a refused break followed by a later request that is granted.
|
|
208
|
+
*
|
|
209
|
+
* There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
|
|
210
|
+
* pending request nobody is coming to decide is worse than none. So the scenario is a request
|
|
211
|
+
* that goes back to not-requested, and a later one that is granted.
|
|
212
|
+
*/
|
|
213
|
+
export function assertDeniedAndRetriedBreak(approvals) {
|
|
214
|
+
const asked = approvals.indexOf("awaiting-decision");
|
|
215
|
+
if (asked < 0)
|
|
216
|
+
throw new Error("Break retry scenario requires an initial request");
|
|
217
|
+
const refused = approvals.indexOf("not-requested", asked + 1);
|
|
218
|
+
if (refused < 0)
|
|
219
|
+
throw new Error("A refused break must return to not-requested, leaving nothing pending");
|
|
220
|
+
const granted = approvals.findIndex((approval, index) => index > refused && (approval === "granted" || approval === "in-effect"));
|
|
221
|
+
if (granted < 0)
|
|
222
|
+
throw new Error("Break retry scenario must grant a later request");
|
|
223
|
+
const last = approvals.at(-1);
|
|
224
|
+
if (last !== "granted" && last !== "in-effect") {
|
|
225
|
+
throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Verifies that retrying one dial command cannot place a second call. */
|
|
229
|
+
export async function assertDialIdempotency(connection, request) {
|
|
230
|
+
if (!connection.dial)
|
|
231
|
+
throw new Error("Dial capability requires Connection.dial()");
|
|
232
|
+
const first = await connection.dial(request);
|
|
233
|
+
if (first.commandId !== request.commandId)
|
|
234
|
+
throw new Error("Dial result id mismatch");
|
|
235
|
+
if (first.status === "failed")
|
|
236
|
+
throw new Error(`Dial failed: ${first.failure.code}`);
|
|
237
|
+
const retry = await connection.dial(request);
|
|
238
|
+
if (retry.commandId !== request.commandId)
|
|
239
|
+
throw new Error("Retried dial result id mismatch");
|
|
240
|
+
// Each method answers in its own words: a retried dial says already-dialled, not
|
|
241
|
+
// already-applied, because what it did was dial.
|
|
242
|
+
if (retry.status !== "already-dialled") {
|
|
243
|
+
throw new Error(`Retried dial must return already-dialled, received ${retry.status}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Validates the deadline derived from media end and the task's fixed wrap allowance.
|
|
248
|
+
* `toleranceMs` absorbs scheduler jitter in a real implementation; pass 0 to demand
|
|
249
|
+
* an exact match.
|
|
250
|
+
*/
|
|
251
|
+
export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranceMs = 1_000) {
|
|
252
|
+
const ended = Date.parse(mediaEndedAt);
|
|
253
|
+
const deadline = Date.parse(observedDeadline);
|
|
254
|
+
if (Number.isNaN(ended) || Number.isNaN(deadline))
|
|
255
|
+
throw new Error("Wrap scenario requires valid ISO-8601 times");
|
|
256
|
+
const expected = ended + task.completionAllowance * 1_000;
|
|
257
|
+
if (Math.abs(deadline - expected) > toleranceMs) {
|
|
258
|
+
throw new Error(`Wrap deadline mismatch: expected ${new Date(expected).toISOString()} within ${toleranceMs}ms, received ${observedDeadline}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** The session key one scenario derives, or `undefined` where the browser shares nothing. */
|
|
262
|
+
const sessionKeyFor = (scenario) => browserSessionKey(scenario);
|
|
263
|
+
/** Validates whether two task-browser definitions should share one browser session. */
|
|
264
|
+
export function assertBrowserIsolationAndReuse(left, right, expectedReuse) {
|
|
265
|
+
const leftKey = sessionKeyFor(left);
|
|
266
|
+
const rightKey = sessionKeyFor(right);
|
|
267
|
+
// A browser that does not reuse has no session key at all, so two of them never share one.
|
|
268
|
+
// Treating "no key" as a match would report reuse nobody asked for.
|
|
269
|
+
const actualReuse = leftKey !== undefined && leftKey === rightKey;
|
|
270
|
+
if (actualReuse !== expectedReuse) {
|
|
271
|
+
throw new Error(`Browser reuse mismatch: expected ${expectedReuse}, received ${actualReuse} (${String(leftKey)} vs ${String(rightKey)})`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Asserts that no two distinct scenarios in `scenarios` derive the same session key.
|
|
276
|
+
* Feed it adversarial names — a provider called `A.B` against a task type called
|
|
277
|
+
* `B`, casing variants, separators — because a collision silently shares cookies,
|
|
278
|
+
* storage, and permissions between two backends.
|
|
279
|
+
*/
|
|
280
|
+
export function assertNoBrowserSessionKeyCollisions(scenarios) {
|
|
281
|
+
const byKey = new Map();
|
|
282
|
+
for (const scenario of scenarios) {
|
|
283
|
+
const key = sessionKeyFor(scenario);
|
|
284
|
+
if (key === undefined)
|
|
285
|
+
continue;
|
|
286
|
+
const existing = byKey.get(key);
|
|
287
|
+
if (existing) {
|
|
288
|
+
throw new Error(`Browser session key collision on '${key}': ` +
|
|
289
|
+
`${JSON.stringify({ provider: existing.providerId, taskType: existing.taskType, tab: existing.browser.name })} and ` +
|
|
290
|
+
`${JSON.stringify({ provider: scenario.providerId, taskType: scenario.taskType, tab: scenario.browser.name })}`);
|
|
291
|
+
}
|
|
292
|
+
byKey.set(key, scenario);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ProtocolViolation } from "./index.js";
|
|
2
|
+
export type { ProtocolViolation } from "./index.js";
|
|
3
|
+
export declare class ProtocolConformanceError extends Error {
|
|
4
|
+
readonly violations: readonly ProtocolViolation[];
|
|
5
|
+
constructor(violations: readonly ProtocolViolation[], summary?: string);
|
|
6
|
+
}
|
|
7
|
+
/** Throws `ProtocolConformanceError` when any violation is present. */
|
|
8
|
+
export declare function assertNoViolations(violations: readonly ProtocolViolation[], summary?: string): void;
|
|
9
|
+
/** Every field is optional, so this checks what is present rather than what is missing. */
|
|
10
|
+
export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
|
|
11
|
+
export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
|
|
12
|
+
export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
|
|
13
|
+
export interface TaskValidationContext {
|
|
14
|
+
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
15
|
+
channel: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
18
|
+
export declare function validateTeamRoster(roster: unknown, path?: string): ProtocolViolation[];
|
|
19
|
+
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string): ProtocolViolation[];
|
|
20
|
+
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string): ProtocolViolation[];
|
|
21
|
+
export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
|