@xema/omni-protocol 0.1.83 → 0.1.84
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 +29 -29
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +34 -34
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +59 -56
- package/guide.md +157 -130
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type TaskId = string;
|
|
|
29
29
|
/**
|
|
30
30
|
* One life of a task on the wire. A platform retires a task id minutes after closing it and a
|
|
31
31
|
* requeue takes seconds, so the same `TaskId` comes back for another customer while a late dial
|
|
32
|
-
* outcome or a late
|
|
32
|
+
* outcome or a late interaction report for the first is still in flight. The allocation id is minted
|
|
33
33
|
* once per offer and never reused for the life of the login, and every event, command and report
|
|
34
34
|
* that names a task names its allocation too, so nothing lands on the next life of the id.
|
|
35
35
|
*/
|
|
@@ -180,13 +180,13 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
180
180
|
*/
|
|
181
181
|
runningStepReports?: true;
|
|
182
182
|
/**
|
|
183
|
-
* How long after an applied
|
|
183
|
+
* How long after an applied completion -- `complete`, or a lead's `take-over` -- the provider's
|
|
184
184
|
* `task-ended` is owed, in milliseconds. A warm transfer's `complete` is not one: the agent's
|
|
185
185
|
* wrap runs after it as after any call. `applied` says the provider
|
|
186
|
-
* has
|
|
186
|
+
* has completed the task; the ending follows within this, or the host resyncs and shows the
|
|
187
187
|
* task as unsettled. Stated per provider, since platforms settle at different speeds.
|
|
188
188
|
*/
|
|
189
|
-
|
|
189
|
+
completionSettleMs: number;
|
|
190
190
|
}
|
|
191
191
|
export interface SecretStore {
|
|
192
192
|
get(key: string): Promise<string | undefined>;
|
|
@@ -195,7 +195,7 @@ export interface SecretStore {
|
|
|
195
195
|
}
|
|
196
196
|
/**
|
|
197
197
|
* A small store for one login's operational state -- what an adapter holds about open work that
|
|
198
|
-
* its platform cannot hold for it, such as the
|
|
198
|
+
* its platform cannot hold for it, such as the interaction legs a host reported. Kept by the host for
|
|
199
199
|
* the life of the login, across a reload of the host, and cleared at sign-out. Never for anything
|
|
200
200
|
* sensitive: that is `SecretStore`, whose contract is that a host may clear it aggressively.
|
|
201
201
|
*/
|
|
@@ -370,7 +370,7 @@ export interface AuthenticationSession {
|
|
|
370
370
|
/**
|
|
371
371
|
* Who silenced a device that is present and permitted: the host, by its own Mute, or the station --
|
|
372
372
|
* a slider on the headset, the operating system -- which the host observes and, on a browser,
|
|
373
|
-
* cannot touch. The same word on a `muted`
|
|
373
|
+
* cannot touch. The same word on a `muted` interaction leg says whose the silence was.
|
|
374
374
|
*/
|
|
375
375
|
export type MutedBy = "host" | "station";
|
|
376
376
|
export declare const MUTED_BY: readonly ["host", "station"];
|
|
@@ -669,7 +669,7 @@ export interface SharedTaskCapabilities {
|
|
|
669
669
|
export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
|
|
670
670
|
decline?: Lockable<true>;
|
|
671
671
|
hold?: Lockable<true>;
|
|
672
|
-
/** The provider ends the caller connection and agent-added channels owned by this
|
|
672
|
+
/** The provider ends the caller connection and agent-added channels owned by this interaction, including inherited channels after transfer/takeover. Interaction completion remains separate. */
|
|
673
673
|
endCall?: Lockable<true>;
|
|
674
674
|
/** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
|
|
675
675
|
connectBack?: Lockable<true>;
|
|
@@ -765,7 +765,7 @@ export type TaskAttribute = TaskAttributeBase & ({
|
|
|
765
765
|
type: "timestamp";
|
|
766
766
|
at: IsoTimestamp;
|
|
767
767
|
});
|
|
768
|
-
export type
|
|
768
|
+
export type InteractionStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
|
|
769
769
|
/**
|
|
770
770
|
* The call record: the steps that brought the task here, one entry per occurrence and oldest
|
|
771
771
|
* first, and what they add up to before this agent -- stated by the provider from its own record,
|
|
@@ -773,10 +773,10 @@ export type HandlingStep = "queued" | "offered" | "answered" | "held" | "muted"
|
|
|
773
773
|
* absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The
|
|
774
774
|
* record rides on the task and is replaced with it, so a late entry corrects the sums.
|
|
775
775
|
*/
|
|
776
|
-
export interface
|
|
777
|
-
steps:
|
|
778
|
-
/** Seconds others spent
|
|
779
|
-
|
|
776
|
+
export interface TaskInteractionHistory {
|
|
777
|
+
steps: TaskInteractionStep[];
|
|
778
|
+
/** Seconds others spent working on it before this agent. */
|
|
779
|
+
interactionSeconds?: DurationSeconds;
|
|
780
780
|
/** Seconds the caller spent on hold at others' hands. */
|
|
781
781
|
holdSeconds?: DurationSeconds;
|
|
782
782
|
/** Seconds waiting before anyone answered. */
|
|
@@ -784,8 +784,8 @@ export interface TaskHandlingHistory {
|
|
|
784
784
|
/** How many times the task changed hands. */
|
|
785
785
|
transfers?: number;
|
|
786
786
|
}
|
|
787
|
-
export interface
|
|
788
|
-
step:
|
|
787
|
+
export interface TaskInteractionStep {
|
|
788
|
+
step: InteractionStep;
|
|
789
789
|
at: IsoTimestamp;
|
|
790
790
|
/**
|
|
791
791
|
* On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
|
|
@@ -808,7 +808,7 @@ export interface TaskHandlingStep {
|
|
|
808
808
|
mutedBy?: MutedBy;
|
|
809
809
|
}
|
|
810
810
|
/**
|
|
811
|
-
* How the task ends, and how long after
|
|
811
|
+
* How the task ends, and how long after interaction the provider allows for it.
|
|
812
812
|
*
|
|
813
813
|
* The allowance is coupled to the mode. Under `provider-automatic` the provider acts on it, so it
|
|
814
814
|
* is required. Under `agent-command` the provider will not complete the task itself, so it may
|
|
@@ -991,7 +991,7 @@ export type Task<C extends Channel = Channel> = {
|
|
|
991
991
|
/** The identifier an agent reads back to a customer, where the provider has one. */
|
|
992
992
|
reference?: string;
|
|
993
993
|
attributes?: TaskAttribute[];
|
|
994
|
-
|
|
994
|
+
interactionHistory?: TaskInteractionHistory;
|
|
995
995
|
} & TaskCompletion & (C extends "voice" ? {
|
|
996
996
|
recording?: {
|
|
997
997
|
provider?: RecordingState;
|
|
@@ -1072,7 +1072,7 @@ export type VoiceTaskCommand = {
|
|
|
1072
1072
|
} | {
|
|
1073
1073
|
type: "resume";
|
|
1074
1074
|
}
|
|
1075
|
-
/** End the caller connection and all agent-added channels owned or inherited by this
|
|
1075
|
+
/** End the caller connection and all agent-added channels owned or inherited by this interaction. Wrap/completion remain separate. Gated by `endCall`. */
|
|
1076
1076
|
| {
|
|
1077
1077
|
type: "end-call";
|
|
1078
1078
|
}
|
|
@@ -1419,7 +1419,7 @@ export interface TeamBreakCommandRequest {
|
|
|
1419
1419
|
}
|
|
1420
1420
|
/**
|
|
1421
1421
|
* The task's real-time audio as the provider holds it: `started` while audio should be attached,
|
|
1422
|
-
* `ended` once primary
|
|
1422
|
+
* `ended` once primary interaction's audio ended, and the field omitted while none should be. The
|
|
1423
1423
|
* provider's word -- a desk attaches and renders audio from it, never from its own senses.
|
|
1424
1424
|
*/
|
|
1425
1425
|
export type TaskMediaState = "started" | "ended";
|
|
@@ -1481,14 +1481,14 @@ export type SetPreferenceRequest = {
|
|
|
1481
1481
|
inherit: true;
|
|
1482
1482
|
};
|
|
1483
1483
|
/**
|
|
1484
|
-
* The host's report of
|
|
1484
|
+
* The host's report of an interaction leg it performed itself -- a mute, which is the host's and
|
|
1485
1485
|
* never the provider's -- so the provider's record has an account of it. Keyed by `step` and `at`: the
|
|
1486
1486
|
* same entry is reported when it begins, as often as the host cares to while it runs, and once
|
|
1487
1487
|
* more with `ended`, when `seconds` is the final duration. The host is the authority for the
|
|
1488
1488
|
* legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
|
|
1489
1489
|
* inferred from a number's presence. What the adapter forwards upstream, and how often, is its own.
|
|
1490
1490
|
*/
|
|
1491
|
-
export type
|
|
1491
|
+
export type InteractionReport = {
|
|
1492
1492
|
taskId: TaskId;
|
|
1493
1493
|
allocationId: AllocationId;
|
|
1494
1494
|
at: IsoTimestamp;
|
|
@@ -1498,7 +1498,7 @@ export type HandlingReport = {
|
|
|
1498
1498
|
step: "muted";
|
|
1499
1499
|
mutedBy: MutedBy;
|
|
1500
1500
|
} | {
|
|
1501
|
-
step: Exclude<
|
|
1501
|
+
step: Exclude<InteractionStep, "muted">;
|
|
1502
1502
|
mutedBy?: never;
|
|
1503
1503
|
}) & ({
|
|
1504
1504
|
ended: true;
|
|
@@ -1507,7 +1507,7 @@ export type HandlingReport = {
|
|
|
1507
1507
|
ended?: never;
|
|
1508
1508
|
seconds?: DurationSeconds;
|
|
1509
1509
|
});
|
|
1510
|
-
export type
|
|
1510
|
+
export type InteractionReportResult = {
|
|
1511
1511
|
status: "recorded";
|
|
1512
1512
|
at: IsoTimestamp;
|
|
1513
1513
|
} | {
|
|
@@ -1717,8 +1717,8 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1717
1717
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
1718
1718
|
/** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
|
|
1719
1719
|
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
|
1720
|
-
/** Records
|
|
1721
|
-
recordStep?(report:
|
|
1720
|
+
/** Records an interaction leg the host performed. Required of a softphone login's connection: the host mutes its microphone on any call, and the record is the provider's. */
|
|
1721
|
+
recordStep?(report: InteractionReport): Promise<InteractionReportResult>;
|
|
1722
1722
|
}
|
|
1723
1723
|
export interface Adapter<C extends Channel = Channel> {
|
|
1724
1724
|
manifest: Manifest<C>;
|
|
@@ -1789,13 +1789,13 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
|
|
|
1789
1789
|
* same provider; anything wider goes through this.
|
|
1790
1790
|
*/
|
|
1791
1791
|
export declare const userKey: (providerId: string, userId: UserId) => string;
|
|
1792
|
-
/** Every
|
|
1793
|
-
export declare const
|
|
1792
|
+
/** Every interaction step somebody takes part in. `queued` is the one nobody does. */
|
|
1793
|
+
export declare const INTERACTION_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
|
|
1794
1794
|
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
|
|
1795
|
-
export declare const
|
|
1796
|
-
export declare const
|
|
1795
|
+
export declare const INTERACTION_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
|
|
1796
|
+
export declare const interactionStepDials: (step: InteractionStep) => boolean;
|
|
1797
1797
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
1798
|
-
export declare function
|
|
1798
|
+
export declare function interactionStepExpectsAPerson(step: InteractionStep): boolean;
|
|
1799
1799
|
export interface BrowserSessionKeyInput {
|
|
1800
1800
|
/** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
|
|
1801
1801
|
providerId: string;
|
package/dist/index.js
CHANGED
|
@@ -159,19 +159,19 @@ export const taskKey = (providerId, taskId) => `${encodeURIComponent(providerId)
|
|
|
159
159
|
* same provider; anything wider goes through this.
|
|
160
160
|
*/
|
|
161
161
|
export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(userId)}`;
|
|
162
|
-
/** Every
|
|
163
|
-
export const
|
|
162
|
+
/** Every interaction step somebody takes part in. `queued` is the one nobody does. */
|
|
163
|
+
export const INTERACTION_STEPS_WITH_A_PERSON = [
|
|
164
164
|
"offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
|
|
165
165
|
];
|
|
166
166
|
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
|
|
167
|
-
export const
|
|
168
|
-
export const
|
|
169
|
-
// Pinned both ways: a step added to `
|
|
167
|
+
export const INTERACTION_STEPS_THAT_DIAL = ["transferred", "conferenced", "unanswered"];
|
|
168
|
+
export const interactionStepDials = (step) => INTERACTION_STEPS_THAT_DIAL.includes(step);
|
|
169
|
+
// Pinned both ways: a step added to `InteractionStep` has to be placed here, and a step listed here
|
|
170
170
|
// has to exist there. `satisfies` on the list covers the second; this statement covers the first.
|
|
171
171
|
true;
|
|
172
172
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
173
|
-
export function
|
|
174
|
-
return
|
|
173
|
+
export function interactionStepExpectsAPerson(step) {
|
|
174
|
+
return INTERACTION_STEPS_WITH_A_PERSON.includes(step);
|
|
175
175
|
}
|
|
176
176
|
/**
|
|
177
177
|
* Whether two logins declare the same capabilities, field by field. The comparison an adapter
|
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.
|
|
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.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[];
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore,
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateInteractionReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
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
|
|
@@ -11,7 +11,7 @@ const STATE_SUBJECTS = [
|
|
|
11
11
|
"tasks",
|
|
12
12
|
"task.browsers",
|
|
13
13
|
"task.attributes",
|
|
14
|
-
"task.
|
|
14
|
+
"task.interactionHistory",
|
|
15
15
|
"task.onCall",
|
|
16
16
|
"task.leadAssist",
|
|
17
17
|
"task.assisting",
|
|
@@ -53,8 +53,8 @@ function observeTask(value, seen) {
|
|
|
53
53
|
seen.add("task.browsers");
|
|
54
54
|
if (some(value.attributes))
|
|
55
55
|
seen.add("task.attributes");
|
|
56
|
-
if (isRecord(value.
|
|
57
|
-
seen.add("task.
|
|
56
|
+
if (isRecord(value.interactionHistory) && some(value.interactionHistory.steps))
|
|
57
|
+
seen.add("task.interactionHistory");
|
|
58
58
|
if (some(value.onCall))
|
|
59
59
|
seen.add("task.onCall");
|
|
60
60
|
if (value.leadAssist !== undefined)
|
|
@@ -661,8 +661,8 @@ function userIdsIn(snapshot) {
|
|
|
661
661
|
}
|
|
662
662
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
663
663
|
const t = task;
|
|
664
|
-
if (isRecord(t.
|
|
665
|
-
for (const step of t.
|
|
664
|
+
if (isRecord(t.interactionHistory) && Array.isArray(t.interactionHistory.steps))
|
|
665
|
+
for (const step of t.interactionHistory.steps)
|
|
666
666
|
if (isRecord(step))
|
|
667
667
|
add(step.by);
|
|
668
668
|
if (Array.isArray(t.onCall))
|
|
@@ -681,7 +681,7 @@ function userIdsIn(snapshot) {
|
|
|
681
681
|
return [...ids];
|
|
682
682
|
}
|
|
683
683
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
684
|
-
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.
|
|
684
|
+
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.interactionHistory) && Array.isArray(task.interactionHistory.steps) && task.interactionHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
685
685
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
686
686
|
isRecord(task.assisting) ||
|
|
687
687
|
isRecord(task.monitoring));
|
|
@@ -1007,7 +1007,7 @@ export class TaskStream {
|
|
|
1007
1007
|
return;
|
|
1008
1008
|
const entries = [
|
|
1009
1009
|
...(Array.isArray(task.onCall) ? task.onCall : []),
|
|
1010
|
-
...(isRecord(task.
|
|
1010
|
+
...(isRecord(task.interactionHistory) && Array.isArray(task.interactionHistory.steps) ? task.interactionHistory.steps : []),
|
|
1011
1011
|
];
|
|
1012
1012
|
for (const entry of entries) {
|
|
1013
1013
|
if (isRecord(entry) && typeof entry.dialId === "string")
|
|
@@ -1052,9 +1052,9 @@ export class TaskStream {
|
|
|
1052
1052
|
}
|
|
1053
1053
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
1054
1054
|
static record(task) {
|
|
1055
|
-
if (!isRecord(task) || !isRecord(task.
|
|
1055
|
+
if (!isRecord(task) || !isRecord(task.interactionHistory) || !Array.isArray(task.interactionHistory.steps))
|
|
1056
1056
|
return undefined;
|
|
1057
|
-
return new Set(task.
|
|
1057
|
+
return new Set(task.interactionHistory.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
|
|
1058
1058
|
}
|
|
1059
1059
|
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
|
1060
1060
|
static lost(was, now) {
|
|
@@ -1092,7 +1092,7 @@ export class TaskStream {
|
|
|
1092
1092
|
resync(snapshot, at) {
|
|
1093
1093
|
const found = [];
|
|
1094
1094
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1095
|
-
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.
|
|
1095
|
+
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.interactionHistory", "stream.snapshot.phase", "stream.snapshot.media");
|
|
1096
1096
|
if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
|
|
1097
1097
|
snapshot.tasks.forEach((task, index) => {
|
|
1098
1098
|
if (!isRecord(task) || typeof task.id !== "string")
|
|
@@ -1106,7 +1106,7 @@ export class TaskStream {
|
|
|
1106
1106
|
// A record once read is not unread: a resync restates it whole, or with more, never with less.
|
|
1107
1107
|
const lost = TaskStream.lost(was.record, TaskStream.record(task));
|
|
1108
1108
|
if (lost.length > 0) {
|
|
1109
|
-
refuse("stream.snapshot.
|
|
1109
|
+
refuse("stream.snapshot.interactionHistory", `${at}.tasks[${index}].interactionHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
|
|
1110
1110
|
}
|
|
1111
1111
|
const to = String(task.phase);
|
|
1112
1112
|
const reachable = REACHABLE_PHASES[was.phase];
|
|
@@ -1178,7 +1178,7 @@ export class TaskStream {
|
|
|
1178
1178
|
break;
|
|
1179
1179
|
}
|
|
1180
1180
|
// The rules about a known task are evaluated only once there is one.
|
|
1181
|
-
ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.
|
|
1181
|
+
ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.interactionHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
|
|
1182
1182
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1183
1183
|
// last statement stands and the failure is a diagnostic; undetermined is a place a task
|
|
1184
1184
|
// starts from, never one it returns to.
|
|
@@ -1210,7 +1210,7 @@ export class TaskStream {
|
|
|
1210
1210
|
{
|
|
1211
1211
|
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
1212
1212
|
if (lost.length > 0) {
|
|
1213
|
-
refuse("stream.taskUpdated.
|
|
1213
|
+
refuse("stream.taskUpdated.interactionHistory", `${at}.task.interactionHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
|
|
1214
1214
|
}
|
|
1215
1215
|
}
|
|
1216
1216
|
// A task completes after its media ends, never around it: an update that moves a task to
|
|
@@ -1319,13 +1319,13 @@ export class TaskStream {
|
|
|
1319
1319
|
refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
|
|
1320
1320
|
}
|
|
1321
1321
|
// Off voice there is no media event, so the update that moves a task to completing is the
|
|
1322
|
-
// provider's word that
|
|
1322
|
+
// provider's word that interaction ended and the moment the wrap allowance starts. A provider
|
|
1323
1323
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1324
1324
|
// completed from in-progress, the allowance it stated was never given.
|
|
1325
1325
|
ruleEvaluated("stream.taskEnded.unwrapped");
|
|
1326
1326
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1327
1327
|
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1328
|
-
refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that
|
|
1328
|
+
refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that interaction ended and the allowance's start, and it was never published`);
|
|
1329
1329
|
}
|
|
1330
1330
|
this.endedAllocations.add(known.allocation);
|
|
1331
1331
|
this.tasks.delete(id);
|
|
@@ -1456,10 +1456,10 @@ async function driveOneCall(drive) {
|
|
|
1456
1456
|
if (carried.allocationId !== allocationOf()) {
|
|
1457
1457
|
refuse("drive.reload.allocation", "drive.reload.allocation", `a second adapter built from the same login carries ${taskId} as allocation ${String(carried.allocationId)}; the first published ${allocationOf()}, and a life does not change its name on a reload`);
|
|
1458
1458
|
}
|
|
1459
|
-
const history = carried.
|
|
1459
|
+
const history = carried.interactionHistory;
|
|
1460
1460
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1461
1461
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
1462
|
-
const before = latestTask().
|
|
1462
|
+
const before = latestTask().interactionHistory;
|
|
1463
1463
|
const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
|
|
1464
1464
|
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
1465
1465
|
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
@@ -1612,11 +1612,11 @@ async function driveOneCall(drive) {
|
|
|
1612
1612
|
if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
|
|
1613
1613
|
return found;
|
|
1614
1614
|
}
|
|
1615
|
-
// The other direction of step 4, wherever the task stands outside the
|
|
1616
|
-
// control still declared: a host holds it back (command.phase.
|
|
1615
|
+
// The other direction of step 4, wherever the task stands outside the interaction phases with the
|
|
1616
|
+
// control still declared: a host holds it back (command.phase.interaction), and an adapter that
|
|
1617
1617
|
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1618
|
-
const
|
|
1619
|
-
ruleEvaluated("drive.command.rejected", "drive.command.
|
|
1618
|
+
const holdRefusedOutsideInteraction = async () => {
|
|
1619
|
+
ruleEvaluated("drive.command.rejected", "drive.command.interaction");
|
|
1620
1620
|
const phase = String(latestTask().phase);
|
|
1621
1621
|
let answer;
|
|
1622
1622
|
try {
|
|
@@ -1626,12 +1626,12 @@ async function driveOneCall(drive) {
|
|
|
1626
1626
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1627
1627
|
}
|
|
1628
1628
|
if (isRecord(answer) && answer.status !== "failed") {
|
|
1629
|
-
refuse("drive.command.
|
|
1629
|
+
refuse("drive.command.interaction", "drive.command.hold", `the provider applied hold on a ${phase} task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
|
|
1630
1630
|
}
|
|
1631
1631
|
};
|
|
1632
1632
|
if (latestTask().phase === "confirmed") {
|
|
1633
1633
|
if (offers("hold"))
|
|
1634
|
-
await
|
|
1634
|
+
await holdRefusedOutsideInteraction();
|
|
1635
1635
|
if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
|
|
1636
1636
|
return found;
|
|
1637
1637
|
}
|
|
@@ -1661,7 +1661,7 @@ async function driveOneCall(drive) {
|
|
|
1661
1661
|
const reportedLegs = [];
|
|
1662
1662
|
/** The duration the provider's record states for the leg at `at`, as the task stands now. */
|
|
1663
1663
|
const closedAs = (at) => {
|
|
1664
|
-
const history = latestTask().
|
|
1664
|
+
const history = latestTask().interactionHistory;
|
|
1665
1665
|
const leg = isRecord(history) && Array.isArray(history.steps)
|
|
1666
1666
|
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1667
1667
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
@@ -1671,7 +1671,7 @@ async function driveOneCall(drive) {
|
|
|
1671
1671
|
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1672
1672
|
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1673
1673
|
const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
|
|
1674
|
-
const own =
|
|
1674
|
+
const own = validateInteractionReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1675
1675
|
found.push(...own);
|
|
1676
1676
|
if (own.length > 0)
|
|
1677
1677
|
return;
|
|
@@ -1775,7 +1775,7 @@ async function driveOneCall(drive) {
|
|
|
1775
1775
|
reportedLegs.push({ at: canonicalTimes.get(openLeg.at), task: openLeg.task, closedAs: before });
|
|
1776
1776
|
}
|
|
1777
1777
|
if (completing !== undefined && offers("hold")) {
|
|
1778
|
-
await
|
|
1778
|
+
await holdRefusedOutsideInteraction();
|
|
1779
1779
|
}
|
|
1780
1780
|
}
|
|
1781
1781
|
}
|
|
@@ -1798,11 +1798,11 @@ async function driveOneCall(drive) {
|
|
|
1798
1798
|
command.disposition = dispositions.codes[0].id;
|
|
1799
1799
|
}
|
|
1800
1800
|
if (await send(command) !== undefined) {
|
|
1801
|
-
// applied says the provider has
|
|
1801
|
+
// applied says the provider has completed the task, and its ending follows within the bound
|
|
1802
1802
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1803
1803
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1804
|
-
ruleEvaluated("drive.
|
|
1805
|
-
const settle = Number(drive.manifest.
|
|
1804
|
+
ruleEvaluated("drive.completion.unsettled");
|
|
1805
|
+
const settle = Number(drive.manifest.completionSettleMs);
|
|
1806
1806
|
let unsettled = false;
|
|
1807
1807
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1808
1808
|
if (end !== undefined)
|
|
@@ -1813,11 +1813,11 @@ async function driveOneCall(drive) {
|
|
|
1813
1813
|
resync = await drive.connection.snapshot();
|
|
1814
1814
|
}
|
|
1815
1815
|
catch (error) {
|
|
1816
|
-
refuse("drive.command.rejected", "drive.
|
|
1816
|
+
refuse("drive.command.rejected", "drive.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
|
|
1817
1817
|
}
|
|
1818
1818
|
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
|
|
1819
|
-
refuse("drive.
|
|
1820
|
-
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider
|
|
1819
|
+
refuse("drive.completion.unsettled", "drive.completion", still
|
|
1820
|
+
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider completed the task, and it has not`
|
|
1821
1821
|
: `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
|
|
1822
1822
|
}
|
|
1823
1823
|
}
|
|
@@ -1842,7 +1842,7 @@ async function driveOneCall(drive) {
|
|
|
1842
1842
|
const published = lastPublished();
|
|
1843
1843
|
if (published === reported.task)
|
|
1844
1844
|
continue;
|
|
1845
|
-
const history = published.
|
|
1845
|
+
const history = published.interactionHistory;
|
|
1846
1846
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1847
1847
|
const { at } = reported;
|
|
1848
1848
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
package/dist/validation.d.ts
CHANGED
|
@@ -122,9 +122,9 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
|
|
|
122
122
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
123
123
|
* carries the final duration.
|
|
124
124
|
*/
|
|
125
|
-
export declare function
|
|
125
|
+
export declare function validateInteractionReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
126
126
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
127
|
-
/** Validate the exact
|
|
127
|
+
/** Validate the exact interaction/allocation target before provider command dispatch. */
|
|
128
128
|
export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
129
129
|
/**
|
|
130
130
|
* What a command needs to be issuable, checked against the task it names: the capability the
|