@xema/omni-protocol 0.1.82 → 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 +81 -35
- package/dist/index.js +7 -7
- package/dist/testing.d.ts +10 -7
- package/dist/testing.js +93 -86
- package/dist/validation.d.ts +34 -1
- package/dist/validation.js +368 -92
- package/guide.md +511 -172
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, 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)
|
|
@@ -305,6 +305,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
305
305
|
// What every connection owes on connect, the first and a reloaded second alike.
|
|
306
306
|
const connectObligations = (on) => {
|
|
307
307
|
// A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
|
|
308
|
+
if (adapter.manifest.timeCheck === true)
|
|
309
|
+
requireMethod(on, "checkTime", "the manifest declares timeCheck");
|
|
308
310
|
requireMethod(on, "refused", "every connection is told what the host refused");
|
|
309
311
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
310
312
|
// than an `enabled` flag, so its presence is the declaration.
|
|
@@ -479,7 +481,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
479
481
|
const snapshot = await connection.snapshot();
|
|
480
482
|
await snapshotRead(live, snapshot, "snapshot");
|
|
481
483
|
stream.seed(snapshot);
|
|
482
|
-
breaks.seed(snapshot);
|
|
484
|
+
violations.push(...breaks.seed(snapshot));
|
|
483
485
|
seeded = true;
|
|
484
486
|
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
485
487
|
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
@@ -659,8 +661,8 @@ function userIdsIn(snapshot) {
|
|
|
659
661
|
}
|
|
660
662
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
661
663
|
const t = task;
|
|
662
|
-
if (isRecord(t.
|
|
663
|
-
for (const step of t.
|
|
664
|
+
if (isRecord(t.interactionHistory) && Array.isArray(t.interactionHistory.steps))
|
|
665
|
+
for (const step of t.interactionHistory.steps)
|
|
664
666
|
if (isRecord(step))
|
|
665
667
|
add(step.by);
|
|
666
668
|
if (Array.isArray(t.onCall))
|
|
@@ -679,7 +681,7 @@ function userIdsIn(snapshot) {
|
|
|
679
681
|
return [...ids];
|
|
680
682
|
}
|
|
681
683
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
682
|
-
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)) ||
|
|
683
685
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
684
686
|
isRecord(task.assisting) ||
|
|
685
687
|
isRecord(task.monitoring));
|
|
@@ -1005,7 +1007,7 @@ export class TaskStream {
|
|
|
1005
1007
|
return;
|
|
1006
1008
|
const entries = [
|
|
1007
1009
|
...(Array.isArray(task.onCall) ? task.onCall : []),
|
|
1008
|
-
...(isRecord(task.
|
|
1010
|
+
...(isRecord(task.interactionHistory) && Array.isArray(task.interactionHistory.steps) ? task.interactionHistory.steps : []),
|
|
1009
1011
|
];
|
|
1010
1012
|
for (const entry of entries) {
|
|
1011
1013
|
if (isRecord(entry) && typeof entry.dialId === "string")
|
|
@@ -1050,9 +1052,9 @@ export class TaskStream {
|
|
|
1050
1052
|
}
|
|
1051
1053
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
1052
1054
|
static record(task) {
|
|
1053
|
-
if (!isRecord(task) || !isRecord(task.
|
|
1055
|
+
if (!isRecord(task) || !isRecord(task.interactionHistory) || !Array.isArray(task.interactionHistory.steps))
|
|
1054
1056
|
return undefined;
|
|
1055
|
-
return new Set(task.
|
|
1057
|
+
return new Set(task.interactionHistory.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
|
|
1056
1058
|
}
|
|
1057
1059
|
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
|
1058
1060
|
static lost(was, now) {
|
|
@@ -1073,9 +1075,9 @@ export class TaskStream {
|
|
|
1073
1075
|
stages.set(entry.dialId, entry.stage);
|
|
1074
1076
|
}
|
|
1075
1077
|
}
|
|
1076
|
-
const
|
|
1077
|
-
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
|
|
1078
|
-
return {
|
|
1078
|
+
const partyRingingBeforeWork = isRecord(task) && Array.isArray(task.onCall)
|
|
1079
|
+
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && (typeof entry.dialId === "string" || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
|
|
1080
|
+
return { partyRingingBeforeWork, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
|
|
1079
1081
|
wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
|
|
1080
1082
|
phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
|
|
1081
1083
|
}
|
|
@@ -1090,7 +1092,7 @@ export class TaskStream {
|
|
|
1090
1092
|
resync(snapshot, at) {
|
|
1091
1093
|
const found = [];
|
|
1092
1094
|
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
1093
|
-
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.
|
|
1095
|
+
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.interactionHistory", "stream.snapshot.phase", "stream.snapshot.media");
|
|
1094
1096
|
if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
|
|
1095
1097
|
snapshot.tasks.forEach((task, index) => {
|
|
1096
1098
|
if (!isRecord(task) || typeof task.id !== "string")
|
|
@@ -1104,7 +1106,7 @@ export class TaskStream {
|
|
|
1104
1106
|
// A record once read is not unread: a resync restates it whole, or with more, never with less.
|
|
1105
1107
|
const lost = TaskStream.lost(was.record, TaskStream.record(task));
|
|
1106
1108
|
if (lost.length > 0) {
|
|
1107
|
-
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`);
|
|
1108
1110
|
}
|
|
1109
1111
|
const to = String(task.phase);
|
|
1110
1112
|
const reachable = REACHABLE_PHASES[was.phase];
|
|
@@ -1176,7 +1178,7 @@ export class TaskStream {
|
|
|
1176
1178
|
break;
|
|
1177
1179
|
}
|
|
1178
1180
|
// The rules about a known task are evaluated only once there is one.
|
|
1179
|
-
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");
|
|
1180
1182
|
// Terms once read stay read. A re-read that fails is not a new fact about the task, so the
|
|
1181
1183
|
// last statement stands and the failure is a diagnostic; undetermined is a place a task
|
|
1182
1184
|
// starts from, never one it returns to.
|
|
@@ -1208,7 +1210,7 @@ export class TaskStream {
|
|
|
1208
1210
|
{
|
|
1209
1211
|
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
1210
1212
|
if (lost.length > 0) {
|
|
1211
|
-
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`);
|
|
1212
1214
|
}
|
|
1213
1215
|
}
|
|
1214
1216
|
// A task completes after its media ends, never around it: an update that moves a task to
|
|
@@ -1268,7 +1270,7 @@ export class TaskStream {
|
|
|
1268
1270
|
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1269
1271
|
break;
|
|
1270
1272
|
}
|
|
1271
|
-
if (!AT_WORK.has(known.phase) && !known.
|
|
1273
|
+
if (!AT_WORK.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1272
1274
|
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1273
1275
|
}
|
|
1274
1276
|
if (known.media === "started") {
|
|
@@ -1290,7 +1292,7 @@ export class TaskStream {
|
|
|
1290
1292
|
refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
|
|
1291
1293
|
break;
|
|
1292
1294
|
}
|
|
1293
|
-
if (!WORK_BEGUN.has(known.phase)) {
|
|
1295
|
+
if (!WORK_BEGUN.has(known.phase) && !known.partyRingingBeforeWork) {
|
|
1294
1296
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
1295
1297
|
}
|
|
1296
1298
|
if (known.media !== "started") {
|
|
@@ -1317,13 +1319,13 @@ export class TaskStream {
|
|
|
1317
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`);
|
|
1318
1320
|
}
|
|
1319
1321
|
// Off voice there is no media event, so the update that moves a task to completing is the
|
|
1320
|
-
// provider's word that
|
|
1322
|
+
// provider's word that interaction ended and the moment the wrap allowance starts. A provider
|
|
1321
1323
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1322
1324
|
// completed from in-progress, the allowance it stated was never given.
|
|
1323
1325
|
ruleEvaluated("stream.taskEnded.unwrapped");
|
|
1324
1326
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1325
1327
|
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1326
|
-
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`);
|
|
1327
1329
|
}
|
|
1328
1330
|
this.endedAllocations.add(known.allocation);
|
|
1329
1331
|
this.tasks.delete(id);
|
|
@@ -1441,7 +1443,7 @@ async function driveOneCall(drive) {
|
|
|
1441
1443
|
// is a place the stream's rules keep working, not one where they all stop.
|
|
1442
1444
|
await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
|
|
1443
1445
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1444
|
-
drive.streams.breaks.seed(snapshot);
|
|
1446
|
+
found.push(...drive.streams.breaks.seed(snapshot));
|
|
1445
1447
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1446
1448
|
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
1447
1449
|
ruleEvaluated("drive.reload.snapshot");
|
|
@@ -1454,10 +1456,10 @@ async function driveOneCall(drive) {
|
|
|
1454
1456
|
if (carried.allocationId !== allocationOf()) {
|
|
1455
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`);
|
|
1456
1458
|
}
|
|
1457
|
-
const history = carried.
|
|
1459
|
+
const history = carried.interactionHistory;
|
|
1458
1460
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
1459
1461
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
1460
|
-
const before = latestTask().
|
|
1462
|
+
const before = latestTask().interactionHistory;
|
|
1461
1463
|
const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
|
|
1462
1464
|
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
1463
1465
|
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
@@ -1610,11 +1612,11 @@ async function driveOneCall(drive) {
|
|
|
1610
1612
|
if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
|
|
1611
1613
|
return found;
|
|
1612
1614
|
}
|
|
1613
|
-
// The other direction of step 4, wherever the task stands outside the
|
|
1614
|
-
// 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
|
|
1615
1617
|
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1616
|
-
const
|
|
1617
|
-
ruleEvaluated("drive.command.rejected", "drive.command.
|
|
1618
|
+
const holdRefusedOutsideInteraction = async () => {
|
|
1619
|
+
ruleEvaluated("drive.command.rejected", "drive.command.interaction");
|
|
1618
1620
|
const phase = String(latestTask().phase);
|
|
1619
1621
|
let answer;
|
|
1620
1622
|
try {
|
|
@@ -1624,12 +1626,12 @@ async function driveOneCall(drive) {
|
|
|
1624
1626
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1625
1627
|
}
|
|
1626
1628
|
if (isRecord(answer) && answer.status !== "failed") {
|
|
1627
|
-
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`);
|
|
1628
1630
|
}
|
|
1629
1631
|
};
|
|
1630
1632
|
if (latestTask().phase === "confirmed") {
|
|
1631
1633
|
if (offers("hold"))
|
|
1632
|
-
await
|
|
1634
|
+
await holdRefusedOutsideInteraction();
|
|
1633
1635
|
if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
|
|
1634
1636
|
return found;
|
|
1635
1637
|
}
|
|
@@ -1655,12 +1657,13 @@ async function driveOneCall(drive) {
|
|
|
1655
1657
|
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1656
1658
|
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1657
1659
|
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1660
|
+
const canonicalTimes = new Map();
|
|
1658
1661
|
const reportedLegs = [];
|
|
1659
1662
|
/** The duration the provider's record states for the leg at `at`, as the task stands now. */
|
|
1660
1663
|
const closedAs = (at) => {
|
|
1661
|
-
const history = latestTask().
|
|
1664
|
+
const history = latestTask().interactionHistory;
|
|
1662
1665
|
const leg = isRecord(history) && Array.isArray(history.steps)
|
|
1663
|
-
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
|
|
1666
|
+
? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
|
|
1664
1667
|
return typeof leg?.seconds === "number" ? leg.seconds : undefined;
|
|
1665
1668
|
};
|
|
1666
1669
|
const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
|
|
@@ -1668,7 +1671,7 @@ async function driveOneCall(drive) {
|
|
|
1668
1671
|
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1669
1672
|
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1670
1673
|
const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
|
|
1671
|
-
const own =
|
|
1674
|
+
const own = validateInteractionReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1672
1675
|
found.push(...own);
|
|
1673
1676
|
if (own.length > 0)
|
|
1674
1677
|
return;
|
|
@@ -1680,7 +1683,15 @@ async function driveOneCall(drive) {
|
|
|
1680
1683
|
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1681
1684
|
return;
|
|
1682
1685
|
}
|
|
1683
|
-
|
|
1686
|
+
const resultErrors = validateResult(answer, "recordStep", "drive.recordStep.result");
|
|
1687
|
+
found.push(...resultErrors);
|
|
1688
|
+
if (!resultErrors.length && isRecord(answer) && answer.status === "recorded" && typeof answer.at === "string" && typeof body.at === "string") {
|
|
1689
|
+
const prior = canonicalTimes.get(body.at);
|
|
1690
|
+
if (prior !== undefined && prior !== answer.at)
|
|
1691
|
+
refuse("drive.recordStep.identity", "drive.recordStep.result.at", "one reported leg must retain its provider-assigned history instant");
|
|
1692
|
+
else
|
|
1693
|
+
canonicalTimes.set(body.at, answer.at);
|
|
1694
|
+
}
|
|
1684
1695
|
if (isRecord(answer) && answer.status === "failed") {
|
|
1685
1696
|
refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1686
1697
|
}
|
|
@@ -1705,12 +1716,12 @@ async function driveOneCall(drive) {
|
|
|
1705
1716
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1706
1717
|
setMuted(false);
|
|
1707
1718
|
await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1708
|
-
reportedLegs.push({ at, task: latestTask() });
|
|
1719
|
+
reportedLegs.push({ at: canonicalTimes.get(at), task: latestTask() });
|
|
1709
1720
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1710
1721
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1711
1722
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1712
|
-
if (drive.rebuild !== undefined) {
|
|
1713
|
-
await recordSurvivesReload(at);
|
|
1723
|
+
if (drive.rebuild !== undefined && canonicalTimes.has(at)) {
|
|
1724
|
+
await recordSurvivesReload(canonicalTimes.get(at));
|
|
1714
1725
|
// A reload that stood no client ends the run: there is nothing left to drive.
|
|
1715
1726
|
if (!drive.handOver.isLive())
|
|
1716
1727
|
return found;
|
|
@@ -1761,10 +1772,10 @@ async function driveOneCall(drive) {
|
|
|
1761
1772
|
// record as it stands now is what every later restatement is held to.
|
|
1762
1773
|
const before = closedAs(openLeg.at);
|
|
1763
1774
|
await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
|
|
1764
|
-
reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
|
|
1775
|
+
reportedLegs.push({ at: canonicalTimes.get(openLeg.at), task: openLeg.task, closedAs: before });
|
|
1765
1776
|
}
|
|
1766
1777
|
if (completing !== undefined && offers("hold")) {
|
|
1767
|
-
await
|
|
1778
|
+
await holdRefusedOutsideInteraction();
|
|
1768
1779
|
}
|
|
1769
1780
|
}
|
|
1770
1781
|
}
|
|
@@ -1787,11 +1798,11 @@ async function driveOneCall(drive) {
|
|
|
1787
1798
|
command.disposition = dispositions.codes[0].id;
|
|
1788
1799
|
}
|
|
1789
1800
|
if (await send(command) !== undefined) {
|
|
1790
|
-
// applied says the provider has
|
|
1801
|
+
// applied says the provider has completed the task, and its ending follows within the bound
|
|
1791
1802
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1792
1803
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1793
|
-
ruleEvaluated("drive.
|
|
1794
|
-
const settle = Number(drive.manifest.
|
|
1804
|
+
ruleEvaluated("drive.completion.unsettled");
|
|
1805
|
+
const settle = Number(drive.manifest.completionSettleMs);
|
|
1795
1806
|
let unsettled = false;
|
|
1796
1807
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1797
1808
|
if (end !== undefined)
|
|
@@ -1802,11 +1813,11 @@ async function driveOneCall(drive) {
|
|
|
1802
1813
|
resync = await drive.connection.snapshot();
|
|
1803
1814
|
}
|
|
1804
1815
|
catch (error) {
|
|
1805
|
-
refuse("drive.command.rejected", "drive.
|
|
1816
|
+
refuse("drive.command.rejected", "drive.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
|
|
1806
1817
|
}
|
|
1807
1818
|
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
|
|
1808
|
-
refuse("drive.
|
|
1809
|
-
? `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`
|
|
1810
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`);
|
|
1811
1822
|
}
|
|
1812
1823
|
}
|
|
@@ -1826,10 +1837,12 @@ async function driveOneCall(drive) {
|
|
|
1826
1837
|
return latestTask();
|
|
1827
1838
|
};
|
|
1828
1839
|
for (const reported of reportedLegs) {
|
|
1840
|
+
if (reported.at === undefined)
|
|
1841
|
+
continue; // A refused/malformed report provides no accepted history identity.
|
|
1829
1842
|
const published = lastPublished();
|
|
1830
1843
|
if (published === reported.task)
|
|
1831
1844
|
continue;
|
|
1832
|
-
const history = published.
|
|
1845
|
+
const history = published.interactionHistory;
|
|
1833
1846
|
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1834
1847
|
const { at } = reported;
|
|
1835
1848
|
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
@@ -1856,62 +1869,56 @@ async function driveOneCall(drive) {
|
|
|
1856
1869
|
// starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
|
|
1857
1870
|
// a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
|
|
1858
1871
|
// with `imposed`. Nothing else is a move the guide describes.
|
|
1859
|
-
const COMMITTED = new Set(["starting-after-task", "in-effect"]);
|
|
1860
|
-
const BACKWARDS = {
|
|
1861
|
-
"in-effect": ["awaiting-decision", "granted", "starting-after-task"],
|
|
1862
|
-
"starting-after-task": ["awaiting-decision", "granted"],
|
|
1863
|
-
granted: ["awaiting-decision"],
|
|
1864
|
-
};
|
|
1865
1872
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
1866
1873
|
export class BreakStream {
|
|
1867
|
-
|
|
1868
|
-
|
|
1874
|
+
state;
|
|
1875
|
+
recovery = true;
|
|
1876
|
+
/** True until a validated authoritative snapshot establishes a usable baseline. */
|
|
1877
|
+
get needsRecovery() { return this.recovery; }
|
|
1878
|
+
/** Caller establishes snapshot freshness/login scope separately; malformed state never replaces the baseline. */
|
|
1869
1879
|
seed(snapshot) {
|
|
1870
1880
|
const state = isRecord(snapshot) ? snapshot.break : undefined;
|
|
1871
|
-
|
|
1881
|
+
const found = validateBreakTransition(state, state, "snapshot.break");
|
|
1882
|
+
if (found.length) {
|
|
1883
|
+
this.recovery = true;
|
|
1884
|
+
return found;
|
|
1885
|
+
}
|
|
1886
|
+
this.state = state;
|
|
1887
|
+
this.recovery = false;
|
|
1888
|
+
return [];
|
|
1872
1889
|
}
|
|
1873
|
-
/**
|
|
1890
|
+
/** Rejected transitions keep accepted state and require snapshot recovery before further deltas. */
|
|
1874
1891
|
apply(envelope, path = "event") {
|
|
1875
|
-
const found = [];
|
|
1876
1892
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
1877
1893
|
if (!isRecord(event))
|
|
1878
|
-
return
|
|
1879
|
-
if (event.type === "
|
|
1880
|
-
this.
|
|
1881
|
-
return
|
|
1882
|
-
}
|
|
1883
|
-
if (event.type !== "break-state" || !isRecord(event.break) || typeof event.break.approval !== "string")
|
|
1884
|
-
return found;
|
|
1885
|
-
const from = this.approval;
|
|
1886
|
-
const to = event.break.approval;
|
|
1887
|
-
const at = `${path}.event.break.approval`;
|
|
1888
|
-
if (from !== undefined) {
|
|
1889
|
-
// A commit's states need a grant behind them. A placed break is the one arrival in a
|
|
1890
|
-
// committed state that nobody asked for -- in effect at once, or starting-after-task while
|
|
1891
|
-
// the member finishes a call -- and it says so with `imposed`.
|
|
1892
|
-
if (COMMITTED.has(to) && (from === "not-requested" || from === "awaiting-decision") && event.break.imposed === undefined) {
|
|
1893
|
-
found.push({ rule: "stream.breakState.commitBeforeGrant", path: at,
|
|
1894
|
-
message: `${to} follows a commit, and a commit follows granted; the break stood at ${from}` });
|
|
1895
|
-
}
|
|
1896
|
-
if ((BACKWARDS[from] ?? []).includes(to)) {
|
|
1897
|
-
found.push({ rule: "stream.breakState.backwards", path: at,
|
|
1898
|
-
message: `a break does not go from ${from} back to ${to}; a new request passes through not-requested` });
|
|
1899
|
-
}
|
|
1894
|
+
return [];
|
|
1895
|
+
if (event.type === "transport-status" && event.status !== "active") {
|
|
1896
|
+
this.recovery = true;
|
|
1897
|
+
return [];
|
|
1900
1898
|
}
|
|
1901
|
-
|
|
1899
|
+
if (event.type === "snapshot")
|
|
1900
|
+
return this.seed(event.snapshot);
|
|
1901
|
+
if (event.type !== "break-state")
|
|
1902
|
+
return [];
|
|
1903
|
+
if (this.recovery)
|
|
1904
|
+
return [{ rule: "stream.breakState.baseline", path: `${path}.event.break`,
|
|
1905
|
+
message: "a validated authoritative snapshot is required before accepting break updates" }];
|
|
1906
|
+
const found = validateBreakTransition(this.state, event.break, `${path}.event.break`);
|
|
1907
|
+
if (found.length)
|
|
1908
|
+
this.recovery = true;
|
|
1909
|
+
else
|
|
1910
|
+
this.state = event.break;
|
|
1902
1911
|
return found;
|
|
1903
1912
|
}
|
|
1904
1913
|
}
|
|
1905
1914
|
/**
|
|
1906
|
-
* A break follows its requests. Given a provider's stream --
|
|
1907
|
-
*
|
|
1915
|
+
* A break follows its requests. Given a provider's stream -- seeded with its initial snapshot,
|
|
1916
|
+
* either supplied separately or carried by a snapshot event -- every `break-state` moves the way the guide describes: a commit's states only
|
|
1908
1917
|
* after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
|
|
1909
1918
|
*/
|
|
1910
1919
|
export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
1911
1920
|
const stream = new BreakStream();
|
|
1912
|
-
|
|
1913
|
-
stream.seed(snapshot);
|
|
1914
|
-
const found = [];
|
|
1921
|
+
const found = snapshot === undefined ? [] : stream.seed(snapshot);
|
|
1915
1922
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
1916
1923
|
assertNoViolations(found, "A break follows its requests");
|
|
1917
1924
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export declare function validateScheduledActivity(activity: unknown, path?: stri
|
|
|
22
22
|
* on voice, absent elsewhere.
|
|
23
23
|
*/
|
|
24
24
|
export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];
|
|
25
|
+
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
26
|
+
export declare function validateProviderTimeEstimate(value: unknown, scope: unknown, path?: string): ProtocolViolation[];
|
|
27
|
+
/** Validate host-local periodic clock-check configuration. Omission disables checking. */
|
|
28
|
+
export declare function validateProviderTimeCheckPolicy(value: unknown, path?: string): ProtocolViolation[];
|
|
29
|
+
export declare function validateProviderTimeCheckRequest(value: unknown, path?: string): ProtocolViolation[];
|
|
30
|
+
/** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
|
|
31
|
+
export declare function validateProviderTimeCheckResult(value: unknown, request: unknown, loginId: string, path?: string): ProtocolViolation[];
|
|
25
32
|
export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
|
|
26
33
|
export interface TaskValidationContext {
|
|
27
34
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
@@ -40,6 +47,30 @@ export interface TaskValidationContext {
|
|
|
40
47
|
locked?: readonly string[];
|
|
41
48
|
}
|
|
42
49
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
50
|
+
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
51
|
+
export declare function validateBreakStatus(state: unknown, tasks: unknown, path?: string): ProtocolViolation[];
|
|
52
|
+
/**
|
|
53
|
+
* Checks lead break dispatch using current authentication, transport, roster (`team`) and,
|
|
54
|
+
* for place/release, the target's full `memberBreak`. The provider must authorize the target
|
|
55
|
+
* and recheck the decision atomically; a roster is not authority to act after it has changed.
|
|
56
|
+
*/
|
|
57
|
+
export declare function validateTeamBreakCommand(request: unknown, context: unknown, path?: string): ProtocolViolation[];
|
|
58
|
+
/** The four agent break methods; this is a validation API, not a wire command. */
|
|
59
|
+
export type BreakMethod = "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak";
|
|
60
|
+
/**
|
|
61
|
+
* Validate immediately before dispatch against current provider state. Context must contain
|
|
62
|
+
* the current authentication and transport. This cannot fence a concurrent backend change;
|
|
63
|
+
* the provider must recheck atomically. Result validation never replaces authoritative state.
|
|
64
|
+
*/
|
|
65
|
+
export declare function validateBreakCommand(method: BreakMethod, request: unknown, state: unknown, context: unknown, path?: string): ProtocolViolation[];
|
|
66
|
+
/**
|
|
67
|
+
* Checks two complete break states within one provider/login's ordered event stream.
|
|
68
|
+
* Call before replacing accepted state; report violations and reconcile on failure.
|
|
69
|
+
* This is not a freshness check: validate a fresh authoritative snapshot separately and
|
|
70
|
+
* use it as a new baseline, never as an event transition. Source ordering and connection
|
|
71
|
+
* fencing remain required; neither timestamps nor approval rank identify a break attempt.
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateBreakTransition(before: unknown, after: unknown, path?: string): ProtocolViolation[];
|
|
43
74
|
/**
|
|
44
75
|
* Who is reading what the adapter published, and what their login declares. The validators check
|
|
45
76
|
* structure without it; given it, they also hold what the adapter publishes to the login.
|
|
@@ -91,8 +122,10 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
|
|
|
91
122
|
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
92
123
|
* carries the final duration.
|
|
93
124
|
*/
|
|
94
|
-
export declare function
|
|
125
|
+
export declare function validateInteractionReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
95
126
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
127
|
+
/** Validate the exact interaction/allocation target before provider command dispatch. */
|
|
128
|
+
export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
|
|
96
129
|
/**
|
|
97
130
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
98
131
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|