@xema/omni-protocol 0.1.66 → 0.1.68
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 +36 -3
- package/dist/index.js +2 -2
- package/dist/testing.d.ts +20 -1
- package/dist/testing.js +233 -22
- package/dist/validation.d.ts +16 -2
- package/dist/validation.js +149 -22
- package/guide.md +103 -36
- 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, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateLoginStore, validateHandlingReport, 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
|
|
@@ -154,6 +154,8 @@ function observeEvent(envelope, seen) {
|
|
|
154
154
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
155
155
|
*/
|
|
156
156
|
export async function exerciseAdapter(adapter, context, options = {}) {
|
|
157
|
+
const rulesEvaluated = new Set();
|
|
158
|
+
const stopObserving = observeRules(rule => { rulesEvaluated.add(rule); });
|
|
157
159
|
const violations = [...validateManifest(adapter.manifest)];
|
|
158
160
|
const events = [];
|
|
159
161
|
const seen = new Set();
|
|
@@ -198,7 +200,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
198
200
|
self: current().identity.id,
|
|
199
201
|
capabilities: current().capabilities,
|
|
200
202
|
loginId: context.loginId,
|
|
201
|
-
autoAcceptTasks: context.autoAcceptTasks
|
|
203
|
+
autoAcceptTasks: context.autoAcceptTasks,
|
|
202
204
|
});
|
|
203
205
|
// The optional methods are optional only until something declares a need for them. Each
|
|
204
206
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
@@ -301,7 +303,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
301
303
|
// record of that leg is the provider's to take; on a desk phone the host holds no microphone.
|
|
302
304
|
if (softphone)
|
|
303
305
|
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
304
|
-
const
|
|
306
|
+
const eventPayloads = new Map();
|
|
307
|
+
let capacityStated;
|
|
308
|
+
const reloading = { now: false };
|
|
305
309
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
306
310
|
const waiters = new Set();
|
|
307
311
|
unsubscribe = connection.subscribe(envelope => {
|
|
@@ -309,21 +313,48 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
309
313
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
310
314
|
// A diagnostic is informational to a host and a failure to a conformance run: the platform
|
|
311
315
|
// under test broke a rule the adapter relies on, and a green result must not paper over it.
|
|
312
|
-
|
|
316
|
+
// A reload in life has no first connection: the page is gone. The drive keeps its first
|
|
317
|
+
// connection up while the second reads, so a platform that pushes the open task to the new
|
|
318
|
+
// client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
|
|
319
|
+
// defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
|
|
320
|
+
// second adapter is up; every other moment it is.
|
|
321
|
+
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
|
|
313
322
|
violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
|
|
314
323
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
315
324
|
}
|
|
316
325
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
317
326
|
if (eventNamesUsers(envelope))
|
|
318
327
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
329
|
+
// against nothing, and an offer beyond the count is one too many -- unless the task is the
|
|
330
|
+
// host's own dial arriving, which counts against nothing.
|
|
331
|
+
if (isRecord(envelope?.event) && envelope.event.type === "task-offered") {
|
|
332
|
+
if (capacityStated === undefined) {
|
|
333
|
+
violations.push({ rule: "stream.taskOffered.beforeCapacity", path: "event.task",
|
|
334
|
+
message: "a task was offered before the host stated any capacity: work is pulled, and nothing is allocated against a capacity nobody stated" });
|
|
335
|
+
}
|
|
336
|
+
else if (seeded && stream.openCount() >= capacityStated && !TaskStream.carriesDial(envelope.event.task)) {
|
|
337
|
+
violations.push({ rule: "stream.taskOffered.overCapacity", path: "event.task",
|
|
338
|
+
message: `a task was offered with ${stream.openCount()} already open against a stated capacity of ${capacityStated}` });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// A re-delivered envelope is harmless and applied once; the same id carrying a different
|
|
342
|
+
// payload is a reused id, which no dedupe can make harmless, and is named.
|
|
322
343
|
if (typeof envelope?.id === "string") {
|
|
323
|
-
|
|
344
|
+
const payload = JSON.stringify(envelope.event);
|
|
345
|
+
const earlier = eventPayloads.get(envelope.id);
|
|
346
|
+
if (earlier !== undefined) {
|
|
347
|
+
if (earlier !== payload) {
|
|
348
|
+
violations.push({ rule: "event.id.reused", path: "event.id",
|
|
349
|
+
message: `envelope ${envelope.id} was delivered again with a different event: an id names one event for the life of the login` });
|
|
350
|
+
}
|
|
324
351
|
return;
|
|
325
|
-
|
|
352
|
+
}
|
|
353
|
+
eventPayloads.set(envelope.id, payload);
|
|
326
354
|
}
|
|
355
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
356
|
+
if (seeded)
|
|
357
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
327
358
|
events.push(envelope);
|
|
328
359
|
for (const waiter of waiters)
|
|
329
360
|
waiter(envelope);
|
|
@@ -336,8 +367,26 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
336
367
|
breaks.seed(snapshot);
|
|
337
368
|
seeded = true;
|
|
338
369
|
requireCapabilityMethods(live, current().capabilities);
|
|
339
|
-
if (publishesUserIds(snapshot))
|
|
370
|
+
if (publishesUserIds(snapshot)) {
|
|
340
371
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
372
|
+
// Required by presence is not enough: the names the snapshot published are looked up, and what
|
|
373
|
+
// comes back is held to the shape -- an empty answer to a roster of colleagues is named.
|
|
374
|
+
const named = userIdsIn(snapshot);
|
|
375
|
+
if (typeof live.describeUsers === "function" && named.length > 0) {
|
|
376
|
+
let described;
|
|
377
|
+
try {
|
|
378
|
+
described = await live.describeUsers(named);
|
|
379
|
+
violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
|
|
380
|
+
if (Array.isArray(described) && described.length === 0) {
|
|
381
|
+
violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
|
|
382
|
+
message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
341
390
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
342
391
|
// that will not accept one is a connection nothing can be given to.
|
|
343
392
|
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
@@ -349,7 +398,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
349
398
|
message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
|
|
350
399
|
});
|
|
351
400
|
}
|
|
352
|
-
|
|
401
|
+
capacityStated = 1;
|
|
402
|
+
const capacity = await connection.setCapacity({ count: capacityStated });
|
|
353
403
|
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
354
404
|
violations.push(...malformed);
|
|
355
405
|
// The provider republishes the agent's day: once connected, the identity carries the zone the
|
|
@@ -378,7 +428,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
378
428
|
violations.push(...await driveOneCall({
|
|
379
429
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
380
430
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
381
|
-
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
|
|
431
|
+
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
|
|
382
432
|
}));
|
|
383
433
|
}
|
|
384
434
|
}
|
|
@@ -425,6 +475,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
425
475
|
}
|
|
426
476
|
if (!options.collectOnly)
|
|
427
477
|
assertNoViolations(violations);
|
|
478
|
+
stopObserving();
|
|
428
479
|
return {
|
|
429
480
|
events: events,
|
|
430
481
|
authenticationState: authenticationState,
|
|
@@ -432,6 +483,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
432
483
|
notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
|
|
433
484
|
disconnectWasClean,
|
|
434
485
|
violations,
|
|
486
|
+
rulesEvaluated,
|
|
435
487
|
};
|
|
436
488
|
}
|
|
437
489
|
/**
|
|
@@ -448,6 +500,42 @@ function publishesUserIds(snapshot) {
|
|
|
448
500
|
return false;
|
|
449
501
|
return snapshot.tasks.some(taskNamesUsers);
|
|
450
502
|
}
|
|
503
|
+
/** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the roster. */
|
|
504
|
+
function userIdsIn(snapshot) {
|
|
505
|
+
const ids = new Set();
|
|
506
|
+
const add = (value) => { if (typeof value === "string" && value.length > 0)
|
|
507
|
+
ids.add(value); };
|
|
508
|
+
add(snapshot?.break?.imposed?.by);
|
|
509
|
+
const team = snapshot?.team;
|
|
510
|
+
if (isRecord(team)) {
|
|
511
|
+
for (const member of Array.isArray(team.members) ? team.members : [])
|
|
512
|
+
if (isRecord(member))
|
|
513
|
+
add(member.id);
|
|
514
|
+
for (const request of Array.isArray(team.requests) ? team.requests : [])
|
|
515
|
+
if (isRecord(request))
|
|
516
|
+
add(request.memberId);
|
|
517
|
+
}
|
|
518
|
+
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
519
|
+
const t = task;
|
|
520
|
+
if (isRecord(t.handlingHistory) && Array.isArray(t.handlingHistory.steps))
|
|
521
|
+
for (const step of t.handlingHistory.steps)
|
|
522
|
+
if (isRecord(step))
|
|
523
|
+
add(step.by);
|
|
524
|
+
if (Array.isArray(t.onCall))
|
|
525
|
+
for (const entry of t.onCall)
|
|
526
|
+
if (isRecord(entry))
|
|
527
|
+
add(entry.userId);
|
|
528
|
+
if (isRecord(t.leadAssist))
|
|
529
|
+
add(t.leadAssist.leadId);
|
|
530
|
+
if (isRecord(t.assisting))
|
|
531
|
+
add(t.assisting.memberId);
|
|
532
|
+
if (isRecord(t.monitoring)) {
|
|
533
|
+
add(t.monitoring.memberId);
|
|
534
|
+
add(t.monitoring.agentId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return [...ids];
|
|
538
|
+
}
|
|
451
539
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
452
540
|
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
453
541
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
@@ -720,9 +808,26 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
720
808
|
// completing or ending, never a phase the audio decided.
|
|
721
809
|
// ---------------------------------------------------------------------------
|
|
722
810
|
const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
811
|
+
/** Where audio can arrive: a task at work. A completing task's call is over; a connect-back returns it to in-progress before any media. */
|
|
812
|
+
const AT_WORK = new Set(["in-progress", "paused"]);
|
|
723
813
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
814
|
+
const TASK_PHASES_ORDERED = ["pending", "confirmed", "preview", "in-progress", "paused", "completing"];
|
|
815
|
+
/** Where a task may be next, by the guide's transition table, closed over the phases between two publications. */
|
|
816
|
+
const REACHABLE_PHASES = {
|
|
817
|
+
pending: new Set(["pending", "confirmed", "preview", "in-progress", "paused", "completing"]),
|
|
818
|
+
confirmed: new Set(["confirmed", "preview", "in-progress", "paused", "completing"]),
|
|
819
|
+
preview: new Set(["preview", "in-progress", "paused", "completing"]),
|
|
820
|
+
"in-progress": new Set(["in-progress", "paused", "completing"]),
|
|
821
|
+
paused: new Set(["paused", "in-progress", "completing"]),
|
|
822
|
+
completing: new Set(["completing"]),
|
|
823
|
+
};
|
|
724
824
|
export class TaskStream {
|
|
725
825
|
tasks = new Map();
|
|
826
|
+
// Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
|
|
827
|
+
// one, an event about a task names the life that is open, and a late event for a life that ended
|
|
828
|
+
// is recognised as that rather than landing on the next customer under the same id.
|
|
829
|
+
allocations = new Set();
|
|
830
|
+
endedAllocations = new Set();
|
|
726
831
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
727
832
|
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
728
833
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
@@ -744,6 +849,36 @@ export class TaskStream {
|
|
|
744
849
|
this.dialled(entry.dialId);
|
|
745
850
|
}
|
|
746
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Whether an event about a task names the life that is open under its id. One naming a life that
|
|
854
|
+
* has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
|
|
855
|
+
* a different fault. Either way it is not applied to the open task.
|
|
856
|
+
*/
|
|
857
|
+
namesTheOpenLife(event, known, at, refuse) {
|
|
858
|
+
const named = event.allocationId;
|
|
859
|
+
if (typeof named !== "string" || named === known.allocation)
|
|
860
|
+
return true;
|
|
861
|
+
if (this.endedAllocations.has(named)) {
|
|
862
|
+
refuse("stream.allocation.ended", `${at}.allocationId`, `${String(event.type)} names allocation ${named}, a life of ${String(event.taskId)} that has ended; the life open under that id is ${known.allocation}, and a late event lands on the life it names, never the next`);
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
|
|
866
|
+
}
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
/** How many tasks the stream currently holds open. */
|
|
870
|
+
openCount() { return this.tasks.size; }
|
|
871
|
+
/** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
|
|
872
|
+
static carriesDial(task) {
|
|
873
|
+
if (!isRecord(task) || !Array.isArray(task.onCall))
|
|
874
|
+
return false;
|
|
875
|
+
return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
|
|
876
|
+
}
|
|
877
|
+
/** Whether the update shows the party being dialled again -- a connect-back, a platform's callback -- the one thing that brings a completing task back. */
|
|
878
|
+
static partyDialled(task) {
|
|
879
|
+
return isRecord(task) && Array.isArray(task.onCall)
|
|
880
|
+
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
|
|
881
|
+
}
|
|
747
882
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
748
883
|
static record(task) {
|
|
749
884
|
if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
|
|
@@ -769,7 +904,7 @@ export class TaskStream {
|
|
|
769
904
|
stages.set(entry.dialId, entry.stage);
|
|
770
905
|
}
|
|
771
906
|
}
|
|
772
|
-
return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
|
|
907
|
+
return { 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) };
|
|
773
908
|
}
|
|
774
909
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
775
910
|
seed(snapshot) {
|
|
@@ -777,8 +912,11 @@ export class TaskStream {
|
|
|
777
912
|
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
778
913
|
return;
|
|
779
914
|
for (const task of snapshot.tasks) {
|
|
780
|
-
if (isRecord(task) && typeof task.id === "string")
|
|
915
|
+
if (isRecord(task) && typeof task.id === "string") {
|
|
781
916
|
this.tasks.set(task.id, TaskStream.stated(task));
|
|
917
|
+
if (typeof task.allocationId === "string")
|
|
918
|
+
this.allocations.add(task.allocationId);
|
|
919
|
+
}
|
|
782
920
|
this.noteDials(task);
|
|
783
921
|
}
|
|
784
922
|
}
|
|
@@ -794,6 +932,7 @@ export class TaskStream {
|
|
|
794
932
|
const known = id === undefined ? undefined : this.tasks.get(id);
|
|
795
933
|
switch (event.type) {
|
|
796
934
|
case "snapshot":
|
|
935
|
+
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory");
|
|
797
936
|
// A snapshot replaces what is known, and still may not say a task lost terms it had read.
|
|
798
937
|
if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
|
|
799
938
|
event.snapshot.tasks.forEach((task, index) => {
|
|
@@ -812,17 +951,27 @@ export class TaskStream {
|
|
|
812
951
|
}
|
|
813
952
|
this.seed(event.snapshot);
|
|
814
953
|
break;
|
|
815
|
-
case "task-offered":
|
|
954
|
+
case "task-offered": {
|
|
955
|
+
ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
|
|
816
956
|
if (id === undefined)
|
|
817
957
|
break;
|
|
818
958
|
if (known !== undefined)
|
|
819
959
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
960
|
+
const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
|
|
961
|
+
if (typeof allocation === "string") {
|
|
962
|
+
if (this.allocations.has(allocation)) {
|
|
963
|
+
refuse("stream.taskOffered.allocation", `${at}.task.allocationId`, `${allocation} was already an allocation on this login: an allocation is minted once per offer and never reused, whatever the task id does`);
|
|
964
|
+
}
|
|
965
|
+
this.allocations.add(allocation);
|
|
966
|
+
}
|
|
820
967
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
821
968
|
this.noteDials(event.task);
|
|
822
969
|
break;
|
|
970
|
+
}
|
|
823
971
|
case "task-updated":
|
|
824
972
|
if (id === undefined)
|
|
825
973
|
break;
|
|
974
|
+
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
826
975
|
if (known === undefined) {
|
|
827
976
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
828
977
|
break;
|
|
@@ -833,6 +982,27 @@ export class TaskStream {
|
|
|
833
982
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
834
983
|
refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
|
|
835
984
|
}
|
|
985
|
+
// An update is of the life that is open. One carrying another allocation is a copy of a
|
|
986
|
+
// different life of this id -- a stale republish of the last customer's call, or the next one's.
|
|
987
|
+
if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
|
|
988
|
+
refuse("stream.taskUpdated.allocation", `${at}.task.allocationId`, `${id} is open as allocation ${known.allocation} and the update says ${String(event.task.allocationId)}: an update restates the life that is open, never another`);
|
|
989
|
+
}
|
|
990
|
+
// A task does not go backwards. The stream sees publications, not transitions, and a task may
|
|
991
|
+
// pass through a phase between two, so what is refused is a phase unreachable from the last
|
|
992
|
+
// one read by the guide's table: back to pending, back to confirmed once work began, out of
|
|
993
|
+
// completing except through a connect-back -- which the update itself shows, the party
|
|
994
|
+
// carrying the host's dial.
|
|
995
|
+
{
|
|
996
|
+
const from = known.phase;
|
|
997
|
+
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
998
|
+
const reachable = REACHABLE_PHASES[from];
|
|
999
|
+
if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
|
|
1000
|
+
const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
|
|
1001
|
+
if (!connectingBack) {
|
|
1002
|
+
refuse("stream.taskUpdated.phase", `${at}.task.phase`, `${id} was ${from} and the update says ${to}: a task does not go backwards, and ${from === "completing" ? "only the party being dialled again, a connect-back or a callback, with its stage on the call, brings a completing task back" : `${to} is not reachable from ${from}`}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
836
1006
|
// A record once read is not unread: an update restates it whole, or with more, never with less.
|
|
837
1007
|
{
|
|
838
1008
|
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
@@ -867,13 +1037,16 @@ export class TaskStream {
|
|
|
867
1037
|
this.noteDials(event.task);
|
|
868
1038
|
break;
|
|
869
1039
|
case "task-media-started":
|
|
1040
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
|
|
870
1041
|
if (id === undefined)
|
|
871
1042
|
break;
|
|
872
1043
|
if (known === undefined) {
|
|
873
1044
|
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
874
1045
|
break;
|
|
875
1046
|
}
|
|
876
|
-
if (!
|
|
1047
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1048
|
+
break;
|
|
1049
|
+
if (!AT_WORK.has(known.phase)) {
|
|
877
1050
|
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`);
|
|
878
1051
|
}
|
|
879
1052
|
if (known.media === "started") {
|
|
@@ -882,12 +1055,15 @@ export class TaskStream {
|
|
|
882
1055
|
known.media = "started";
|
|
883
1056
|
break;
|
|
884
1057
|
case "task-media-ended":
|
|
1058
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
|
|
885
1059
|
if (id === undefined)
|
|
886
1060
|
break;
|
|
887
1061
|
if (known === undefined) {
|
|
888
1062
|
refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
889
1063
|
break;
|
|
890
1064
|
}
|
|
1065
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1066
|
+
break;
|
|
891
1067
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
892
1068
|
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`);
|
|
893
1069
|
}
|
|
@@ -897,15 +1073,27 @@ export class TaskStream {
|
|
|
897
1073
|
known.media = "ended";
|
|
898
1074
|
break;
|
|
899
1075
|
case "task-ended":
|
|
1076
|
+
ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
|
|
900
1077
|
if (id === undefined)
|
|
901
1078
|
break;
|
|
902
|
-
if (known === undefined)
|
|
1079
|
+
if (known === undefined) {
|
|
903
1080
|
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1084
|
+
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1085
|
+
break;
|
|
1086
|
+
this.endedAllocations.add(known.allocation);
|
|
904
1087
|
this.tasks.delete(id);
|
|
905
1088
|
break;
|
|
906
1089
|
case "dial-outcome": {
|
|
907
1090
|
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
908
|
-
// a dial placed late routinely outlives its call, which is why the dial has its own identity
|
|
1091
|
+
// a dial placed late routinely outlives its call, which is why the dial has its own identity,
|
|
1092
|
+
// and why the outcome names the allocation: the host routes it to that life, ended or not.
|
|
1093
|
+
ruleEvaluated("stream.allocation.unknown");
|
|
1094
|
+
if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
|
|
1095
|
+
refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
|
|
1096
|
+
}
|
|
909
1097
|
if (typeof event.dialId !== "string")
|
|
910
1098
|
break;
|
|
911
1099
|
const dial = this.dials.get(event.dialId);
|
|
@@ -960,6 +1148,7 @@ async function driveOneCall(drive) {
|
|
|
960
1148
|
drive.waiters.add(waiter);
|
|
961
1149
|
});
|
|
962
1150
|
const recordSurvivesReload = async (at) => {
|
|
1151
|
+
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
|
|
963
1152
|
let again;
|
|
964
1153
|
try {
|
|
965
1154
|
again = drive.rebuild();
|
|
@@ -976,7 +1165,17 @@ async function driveOneCall(drive) {
|
|
|
976
1165
|
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
977
1166
|
return;
|
|
978
1167
|
}
|
|
1168
|
+
drive.reloading.now = true;
|
|
979
1169
|
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1170
|
+
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1171
|
+
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1172
|
+
const restored = await session.state();
|
|
1173
|
+
found.push(...validateAuthenticationState(restored, "drive.reload.login"));
|
|
1174
|
+
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
|
|
1175
|
+
if (!same) {
|
|
1176
|
+
refuse("drive.reload.login", "drive.reload.login", `a second adapter built from the same login and secrets did not restore it: the session says ${String(isRecord(restored) ? restored.status : restored)}${isRecord(restored) && isRecord(restored.identity) ? ` as ${String(restored.identity.id)}` : ""}, and the login is ${String(drive.reader().self)}`);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
980
1179
|
second = await again.connect(drive.context);
|
|
981
1180
|
const snapshot = await second.snapshot();
|
|
982
1181
|
// The second adapter's snapshot is held to everything a first one is.
|
|
@@ -987,6 +1186,10 @@ async function driveOneCall(drive) {
|
|
|
987
1186
|
refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
|
|
988
1187
|
return;
|
|
989
1188
|
}
|
|
1189
|
+
// The allocation is part of the task, not memory beside it: a reload brings the same life back.
|
|
1190
|
+
if (carried.allocationId !== allocationOf()) {
|
|
1191
|
+
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`);
|
|
1192
|
+
}
|
|
990
1193
|
const history = carried.handlingHistory;
|
|
991
1194
|
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
992
1195
|
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
@@ -1021,6 +1224,7 @@ async function driveOneCall(drive) {
|
|
|
1021
1224
|
catch (error) {
|
|
1022
1225
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1023
1226
|
}
|
|
1227
|
+
drive.reloading.now = false;
|
|
1024
1228
|
}
|
|
1025
1229
|
};
|
|
1026
1230
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
@@ -1028,6 +1232,7 @@ async function driveOneCall(drive) {
|
|
|
1028
1232
|
// inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
|
|
1029
1233
|
let retainedAtEnd;
|
|
1030
1234
|
const storeReleased = async () => {
|
|
1235
|
+
ruleEvaluated("drive.store.retained");
|
|
1031
1236
|
await Promise.resolve();
|
|
1032
1237
|
await Promise.resolve();
|
|
1033
1238
|
const retained = [...drive.held].filter(key => key.includes(taskId));
|
|
@@ -1041,6 +1246,7 @@ async function driveOneCall(drive) {
|
|
|
1041
1246
|
const nothingLate = () => {
|
|
1042
1247
|
if (retainedAtEnd === undefined)
|
|
1043
1248
|
return;
|
|
1249
|
+
ruleEvaluated("drive.store.late");
|
|
1044
1250
|
const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
|
|
1045
1251
|
if (late.length > 0) {
|
|
1046
1252
|
refuse("drive.store.late", "drive.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
|
|
@@ -1060,6 +1266,7 @@ async function driveOneCall(drive) {
|
|
|
1060
1266
|
cursor = offered.at;
|
|
1061
1267
|
}
|
|
1062
1268
|
const taskId = task.id;
|
|
1269
|
+
const allocationOf = () => String(latestTask().allocationId);
|
|
1063
1270
|
const latestTask = () => task;
|
|
1064
1271
|
const updated = (until, what) => waitFor(what, envelope => {
|
|
1065
1272
|
const event = envelope.event;
|
|
@@ -1075,14 +1282,16 @@ async function driveOneCall(drive) {
|
|
|
1075
1282
|
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
1076
1283
|
}, cursor);
|
|
1077
1284
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1285
|
+
ruleEvaluated("drive.timeout");
|
|
1078
1286
|
const send = async (command, dialId) => {
|
|
1287
|
+
ruleEvaluated("drive.command.rejected", "drive.command.failed");
|
|
1079
1288
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
|
|
1080
1289
|
found.push(...own);
|
|
1081
1290
|
if (own.length > 0)
|
|
1082
1291
|
return undefined;
|
|
1083
1292
|
let result;
|
|
1084
1293
|
try {
|
|
1085
|
-
result = await drive.connection.execute({ taskId, command });
|
|
1294
|
+
result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
|
|
1086
1295
|
}
|
|
1087
1296
|
catch (error) {
|
|
1088
1297
|
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1124,10 +1333,11 @@ async function driveOneCall(drive) {
|
|
|
1124
1333
|
// control still declared: a host holds it back (command.phase.handling), and an adapter that
|
|
1125
1334
|
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1126
1335
|
const holdRefusedOutsideHandling = async () => {
|
|
1336
|
+
ruleEvaluated("drive.command.rejected", "drive.command.handling");
|
|
1127
1337
|
const phase = String(latestTask().phase);
|
|
1128
1338
|
let answer;
|
|
1129
1339
|
try {
|
|
1130
|
-
answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
|
|
1340
|
+
answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
|
|
1131
1341
|
}
|
|
1132
1342
|
catch (error) {
|
|
1133
1343
|
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
@@ -1154,7 +1364,7 @@ async function driveOneCall(drive) {
|
|
|
1154
1364
|
return found;
|
|
1155
1365
|
cursor = Math.max(cursor, started.at);
|
|
1156
1366
|
}
|
|
1157
|
-
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
1367
|
+
const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
|
|
1158
1368
|
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
1159
1369
|
if (isRecord(opened) && opened.status === "opened")
|
|
1160
1370
|
session = opened.session;
|
|
@@ -1168,8 +1378,9 @@ async function driveOneCall(drive) {
|
|
|
1168
1378
|
if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
|
|
1169
1379
|
const at = new Date().toISOString();
|
|
1170
1380
|
const report = async (body) => {
|
|
1381
|
+
ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
|
|
1171
1382
|
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1172
|
-
const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
|
|
1383
|
+
const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
|
|
1173
1384
|
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1174
1385
|
found.push(...own);
|
|
1175
1386
|
if (own.length > 0)
|
package/dist/validation.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export declare class ProtocolConformanceError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
/** Throws `ProtocolConformanceError` when any violation is present. */
|
|
8
8
|
export declare function assertNoViolations(violations: readonly ProtocolViolation[], summary?: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
11
|
+
* green run can then say which rules it looked at rather than only which subjects it reached: a
|
|
12
|
+
* rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
|
|
13
|
+
*/
|
|
14
|
+
export declare function observeRules(listener: (rule: string) => void): () => void;
|
|
15
|
+
/** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
|
|
16
|
+
export declare function ruleEvaluated(...rules: readonly string[]): void;
|
|
9
17
|
/** Every field is optional, so this checks what is present rather than what is missing. */
|
|
10
18
|
export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
|
|
11
19
|
export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
|
|
@@ -20,7 +28,7 @@ export interface TaskValidationContext {
|
|
|
20
28
|
channel: string;
|
|
21
29
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
22
30
|
levels?: readonly string[];
|
|
23
|
-
/** `ConnectContext.autoAcceptTasks` as sent
|
|
31
|
+
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
24
32
|
autoAcceptTasks?: boolean;
|
|
25
33
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
26
34
|
dialOutcomesDeclared?: boolean;
|
|
@@ -45,7 +53,7 @@ export interface ReaderContext {
|
|
|
45
53
|
levels?: readonly string[];
|
|
46
54
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
47
55
|
loginId?: string;
|
|
48
|
-
/** `ConnectContext.autoAcceptTasks` as sent
|
|
56
|
+
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
49
57
|
autoAcceptTasks?: boolean;
|
|
50
58
|
}
|
|
51
59
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
@@ -94,6 +102,12 @@ export declare function isTimeZone(value: unknown): value is string;
|
|
|
94
102
|
export declare function sameTimeZone(a: unknown, b: unknown): boolean;
|
|
95
103
|
/** The zone a host sends at connect: required, and an IANA name. */
|
|
96
104
|
export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
|
|
105
|
+
/**
|
|
106
|
+
* What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
107
|
+
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
108
|
+
* answers somebody nobody asked about.
|
|
109
|
+
*/
|
|
110
|
+
export declare function validateDescribedUsers(users: unknown, asked: readonly string[], path?: string): ProtocolViolation[];
|
|
97
111
|
/** What a login is validated against beyond its own shape. */
|
|
98
112
|
export interface LoginValidationContext {
|
|
99
113
|
/** The level ids in force, from the manifest. The defaults when absent. */
|