@xema/omni-protocol 0.1.100 → 0.1.102
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 +100 -88
- package/dist/index.js +5 -4
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +162 -32
- package/dist/validation.d.ts +6 -3
- package/dist/validation.js +188 -152
- package/guide.md +322 -230
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -24,9 +24,10 @@ const STATE_SUBJECTS = [
|
|
|
24
24
|
"break.reasons",
|
|
25
25
|
"break.forced",
|
|
26
26
|
"team.members",
|
|
27
|
-
"team.
|
|
27
|
+
"team.request",
|
|
28
28
|
"team.tasks",
|
|
29
29
|
"team.listening",
|
|
30
|
+
"team.shift",
|
|
30
31
|
"contacts",
|
|
31
32
|
"calendar",
|
|
32
33
|
"team.policies",
|
|
@@ -36,7 +37,8 @@ const STATE_SUBJECTS = [
|
|
|
36
37
|
const EVENT_TYPES = {
|
|
37
38
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
38
39
|
"task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
39
|
-
"team-updated": true, "team-
|
|
40
|
+
"team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
|
|
41
|
+
"contacts-updated": true, "calendar-updated": true,
|
|
40
42
|
};
|
|
41
43
|
const CONTRACT_SUBJECTS = [
|
|
42
44
|
...STATE_SUBJECTS,
|
|
@@ -87,24 +89,31 @@ function observeBreak(value, seen) {
|
|
|
87
89
|
if (value.forced !== undefined)
|
|
88
90
|
seen.add("break.forced");
|
|
89
91
|
}
|
|
92
|
+
function observeMember(member, seen) {
|
|
93
|
+
if (!isRecord(member))
|
|
94
|
+
return;
|
|
95
|
+
if (some(member.tasks))
|
|
96
|
+
seen.add("team.tasks");
|
|
97
|
+
if (member.listening !== undefined)
|
|
98
|
+
seen.add("team.listening");
|
|
99
|
+
if (member.shift !== undefined)
|
|
100
|
+
seen.add("team.shift");
|
|
101
|
+
if (member.request !== undefined)
|
|
102
|
+
seen.add("team.request");
|
|
103
|
+
}
|
|
104
|
+
function observePolicies(value, seen) {
|
|
105
|
+
if (isRecord(value) && Object.keys(value).length > 0)
|
|
106
|
+
seen.add("team.policies");
|
|
107
|
+
}
|
|
90
108
|
function observeTeam(value, seen) {
|
|
91
109
|
if (!isRecord(value))
|
|
92
110
|
return;
|
|
93
111
|
if (some(value.members))
|
|
94
112
|
seen.add("team.members");
|
|
95
|
-
if (some(value.requests))
|
|
96
|
-
seen.add("team.requests");
|
|
97
113
|
if (Array.isArray(value.members))
|
|
98
|
-
for (const member of value.members)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (some(member.tasks))
|
|
102
|
-
seen.add("team.tasks");
|
|
103
|
-
if (member.listening !== undefined)
|
|
104
|
-
seen.add("team.listening");
|
|
105
|
-
}
|
|
106
|
-
if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
|
|
107
|
-
seen.add("team.policies");
|
|
114
|
+
for (const member of value.members)
|
|
115
|
+
observeMember(member, seen);
|
|
116
|
+
observePolicies(value.policies, seen);
|
|
108
117
|
}
|
|
109
118
|
function observeSnapshot(value, seen) {
|
|
110
119
|
if (!isRecord(value))
|
|
@@ -138,6 +147,13 @@ function observeEvent(envelope, seen) {
|
|
|
138
147
|
case "team-updated":
|
|
139
148
|
observeTeam(event.team, seen);
|
|
140
149
|
break;
|
|
150
|
+
case "team-member-updated":
|
|
151
|
+
seen.add("team.members");
|
|
152
|
+
observeMember(event.member, seen);
|
|
153
|
+
break;
|
|
154
|
+
case "team-policies-updated":
|
|
155
|
+
observePolicies(event.policies, seen);
|
|
156
|
+
break;
|
|
141
157
|
case "contacts-updated":
|
|
142
158
|
if (some(event.contacts))
|
|
143
159
|
seen.add("contacts");
|
|
@@ -169,6 +185,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
169
185
|
const seen = new Set();
|
|
170
186
|
const stream = new TaskStream();
|
|
171
187
|
const breaks = new BreakStream();
|
|
188
|
+
const team = new TeamStream();
|
|
172
189
|
let seeded = false;
|
|
173
190
|
const duringRead = [];
|
|
174
191
|
let lockedPartySeen = false;
|
|
@@ -208,9 +225,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
208
225
|
throw new Error("unreachable: the exercise has an authenticated login");
|
|
209
226
|
return login;
|
|
210
227
|
};
|
|
228
|
+
// Off until the application has said otherwise: the provider assumes nothing of the team feature.
|
|
229
|
+
let leadFeaturesOn = false;
|
|
211
230
|
const reader = () => ({
|
|
212
231
|
self: current().identity.id,
|
|
213
232
|
capabilities: current().capabilities,
|
|
233
|
+
leadFeatures: leadFeaturesOn,
|
|
214
234
|
loginId: context.loginId,
|
|
215
235
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
216
236
|
locked: options.lockedValues,
|
|
@@ -389,7 +409,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
389
409
|
// Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
|
|
390
410
|
// delivered while that snapshot is read is held until it lands, and read then against it.
|
|
391
411
|
if (seeded)
|
|
392
|
-
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
412
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope), ...team.apply(envelope));
|
|
393
413
|
else
|
|
394
414
|
duringRead.push(envelope);
|
|
395
415
|
events.push(envelope);
|
|
@@ -484,6 +504,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
484
504
|
await snapshotRead(live, snapshot, "snapshot");
|
|
485
505
|
stream.seed(snapshot);
|
|
486
506
|
violations.push(...breaks.seed(snapshot));
|
|
507
|
+
team.seed(snapshot);
|
|
487
508
|
seeded = true;
|
|
488
509
|
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
489
510
|
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
@@ -497,7 +518,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
497
518
|
for (const held of duringRead) {
|
|
498
519
|
const event = held.event;
|
|
499
520
|
if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
|
|
500
|
-
violations.push(...stream.apply(held), ...breaks.apply(held));
|
|
521
|
+
violations.push(...stream.apply(held), ...breaks.apply(held), ...team.apply(held));
|
|
501
522
|
continue;
|
|
502
523
|
}
|
|
503
524
|
// Evaluated where a superseded event was held: with none, the question was never asked.
|
|
@@ -544,6 +565,42 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
544
565
|
message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
|
|
545
566
|
});
|
|
546
567
|
}
|
|
568
|
+
// The provider assumes nothing of the team feature: the application says on every connect,
|
|
569
|
+
// and once it has said on, the whole team is owed, once. Before that nothing of the team
|
|
570
|
+
// reaches the lead, which the reader held the connect snapshot to.
|
|
571
|
+
if (current().capabilities.lead === true && typeof live.executeTeam === "function") {
|
|
572
|
+
const from = events.length;
|
|
573
|
+
let switched;
|
|
574
|
+
// On from the moment the application says so: the provider may answer with the team at once.
|
|
575
|
+
leadFeaturesOn = true;
|
|
576
|
+
try {
|
|
577
|
+
switched = await live.executeTeam({ command: { type: "lead-features", enabled: true } });
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
leadFeaturesOn = false;
|
|
581
|
+
violations.push({ rule: "team.switch.rejected", path: "connection.executeTeam", message: `lead-features on was rejected: ${String(error)}` });
|
|
582
|
+
}
|
|
583
|
+
if (switched !== undefined) {
|
|
584
|
+
violations.push(...validateResult(switched, "executeTeam", "connection.executeTeam"));
|
|
585
|
+
ruleEvaluated("team.required");
|
|
586
|
+
const ms = options.driveTimeoutMs ?? 5000;
|
|
587
|
+
const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
|
|
588
|
+
const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
|
|
589
|
+
const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
|
|
590
|
+
const waiter = (envelope) => {
|
|
591
|
+
if (!isBaseline(envelope))
|
|
592
|
+
return;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
waiters.delete(waiter);
|
|
595
|
+
resolve(true);
|
|
596
|
+
};
|
|
597
|
+
waiters.add(waiter);
|
|
598
|
+
});
|
|
599
|
+
if (!baseline)
|
|
600
|
+
violations.push({ rule: "team.required", path: "event.team",
|
|
601
|
+
message: `the application switched the team feature on and no team-updated followed within ${ms}ms: the whole team is owed once it is on, members: [] when nobody is in it` });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
547
604
|
if (options.drive) {
|
|
548
605
|
const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
|
|
549
606
|
? first.audio.input.localAudio : undefined;
|
|
@@ -552,8 +609,15 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
552
609
|
everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
|
|
553
610
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
554
611
|
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
|
|
555
|
-
streams: { stream, breaks },
|
|
612
|
+
streams: { stream, breaks, team },
|
|
556
613
|
}));
|
|
614
|
+
// Every offer is owed an ending. The drive ends the call it drove; anything else the
|
|
615
|
+
// provider offered and left open is an assignment whose ending never came.
|
|
616
|
+
ruleEvaluated("stream.taskOffered.unended");
|
|
617
|
+
for (const id of stream.unended()) {
|
|
618
|
+
violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
|
|
619
|
+
message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
|
|
620
|
+
}
|
|
557
621
|
}
|
|
558
622
|
// Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
|
|
559
623
|
// host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
|
|
@@ -638,7 +702,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
638
702
|
* its shape.
|
|
639
703
|
*/
|
|
640
704
|
function publishesUserIds(snapshot) {
|
|
641
|
-
if (snapshot?.break?.forced?.by !== undefined)
|
|
705
|
+
if (snapshot?.break?.forced?.by !== undefined && snapshot.break.forced.by !== "provider")
|
|
642
706
|
return true;
|
|
643
707
|
if (teamNamesUsers(snapshot?.team))
|
|
644
708
|
return true;
|
|
@@ -651,15 +715,13 @@ function userIdsIn(snapshot) {
|
|
|
651
715
|
const ids = new Set();
|
|
652
716
|
const add = (value) => { if (typeof value === "string" && value.length > 0)
|
|
653
717
|
ids.add(value); };
|
|
654
|
-
|
|
718
|
+
if (snapshot?.break?.forced?.by !== "provider")
|
|
719
|
+
add(snapshot?.break?.forced?.by);
|
|
655
720
|
const team = snapshot?.team;
|
|
656
721
|
if (isRecord(team)) {
|
|
657
722
|
for (const member of Array.isArray(team.members) ? team.members : [])
|
|
658
723
|
if (isRecord(member))
|
|
659
724
|
add(member.id);
|
|
660
|
-
for (const request of Array.isArray(team.requests) ? team.requests : [])
|
|
661
|
-
if (isRecord(request))
|
|
662
|
-
add(request.memberId);
|
|
663
725
|
}
|
|
664
726
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
665
727
|
const t = task;
|
|
@@ -699,7 +761,7 @@ function userIdsIn(snapshot) {
|
|
|
699
761
|
}
|
|
700
762
|
return [...ids];
|
|
701
763
|
}
|
|
702
|
-
const teamNamesUsers = (team) => isRecord(team) &&
|
|
764
|
+
const teamNamesUsers = (team) => isRecord(team) && some(team.members);
|
|
703
765
|
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.history) && Array.isArray(task.history.steps) && task.history.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
704
766
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
705
767
|
isRecord(task.takenOver));
|
|
@@ -710,10 +772,12 @@ function eventNamesUsers(envelope) {
|
|
|
710
772
|
return false;
|
|
711
773
|
switch (event.type) {
|
|
712
774
|
case "snapshot": return publishesUserIds(event.snapshot);
|
|
713
|
-
case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined;
|
|
775
|
+
case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined && event.break.forced.by !== "provider";
|
|
714
776
|
case "task-offered":
|
|
715
777
|
case "task-updated": return taskNamesUsers(event.task);
|
|
716
778
|
case "team-updated": return teamNamesUsers(event.team);
|
|
779
|
+
case "team-member-updated": return isRecord(event.member) && event.member.id !== undefined;
|
|
780
|
+
case "team-member-removed": return true;
|
|
717
781
|
default: return false;
|
|
718
782
|
}
|
|
719
783
|
}
|
|
@@ -998,7 +1062,8 @@ const REACHABLE_PHASES = {
|
|
|
998
1062
|
*/
|
|
999
1063
|
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
1000
1064
|
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
1001
|
-
"task-audio-started", "task-audio-ended", "team-updated", "
|
|
1065
|
+
"task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
|
|
1066
|
+
"contacts-updated", "calendar-updated",
|
|
1002
1067
|
]);
|
|
1003
1068
|
export class TaskStream {
|
|
1004
1069
|
tasks = new Map();
|
|
@@ -1007,8 +1072,10 @@ export class TaskStream {
|
|
|
1007
1072
|
// a task that is open.
|
|
1008
1073
|
assignments = new Set();
|
|
1009
1074
|
endedAssignments = new Set();
|
|
1075
|
+
/** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
|
|
1076
|
+
offered = new Set();
|
|
1010
1077
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
1011
|
-
// task carried on `onCall` or in its record -- which is how a dial made before a
|
|
1078
|
+
// task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
|
|
1012
1079
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
1013
1080
|
dials = new Map();
|
|
1014
1081
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
@@ -1042,6 +1109,8 @@ export class TaskStream {
|
|
|
1042
1109
|
}
|
|
1043
1110
|
/** How many tasks the stream currently holds open. */
|
|
1044
1111
|
openCount() { return this.tasks.size; }
|
|
1112
|
+
/** The assignments an offer introduced that are still open: each was owed a `task-ended` that has not come. */
|
|
1113
|
+
unended() { return [...this.offered].filter(id => this.tasks.has(id)); }
|
|
1045
1114
|
/** 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. */
|
|
1046
1115
|
static carriesDial(task) {
|
|
1047
1116
|
if (!isRecord(task) || !Array.isArray(task.onCall))
|
|
@@ -1171,6 +1240,7 @@ export class TaskStream {
|
|
|
1171
1240
|
refuse("stream.taskOffered.duplicate", `${at}.task.assignmentId`, known !== undefined ? `${id} is already on the stream; an offer introduces an assignment once` : `${id} was already an assignment on this stream: an assignment is named once per offer and never reused, whatever the platform does with its own handle`);
|
|
1172
1241
|
}
|
|
1173
1242
|
this.assignments.add(id);
|
|
1243
|
+
this.offered.add(id);
|
|
1174
1244
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
1175
1245
|
this.noteDials(event.task);
|
|
1176
1246
|
break;
|
|
@@ -1216,7 +1286,7 @@ export class TaskStream {
|
|
|
1216
1286
|
}
|
|
1217
1287
|
// A task completes after its audio ends, never around it: an update that moves a task to
|
|
1218
1288
|
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1219
|
-
// whoever caused the ending -- the drive's end-call, a
|
|
1289
|
+
// whoever caused the ending -- the drive's end-call, a take-over, the provider's own hand.
|
|
1220
1290
|
{
|
|
1221
1291
|
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1222
1292
|
if (to === "completing" && known.audio === "started" && known.phase !== "completing") {
|
|
@@ -1317,8 +1387,9 @@ export class TaskStream {
|
|
|
1317
1387
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1318
1388
|
// completed from in-progress, the allowance it stated was never given.
|
|
1319
1389
|
ruleEvaluated("stream.taskEnded.unwrapped");
|
|
1390
|
+
// An agent's own complete cuts the wrap short from wherever the task stands, so that ending says by agent and is not this.
|
|
1320
1391
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1321
|
-
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1392
|
+
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed" && event.outcome.by === "provider") {
|
|
1322
1393
|
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`);
|
|
1323
1394
|
}
|
|
1324
1395
|
this.endedAssignments.add(id);
|
|
@@ -1438,6 +1509,7 @@ async function driveOneCall(drive) {
|
|
|
1438
1509
|
await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
|
|
1439
1510
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1440
1511
|
found.push(...drive.streams.breaks.seed(snapshot));
|
|
1512
|
+
drive.streams.team.seed(snapshot);
|
|
1441
1513
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1442
1514
|
? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
|
|
1443
1515
|
ruleEvaluated("drive.reload.snapshot");
|
|
@@ -1776,11 +1848,12 @@ async function driveOneCall(drive) {
|
|
|
1776
1848
|
refuse("drive.openAudio.close", "drive.openAudio", "the audio session threw on close");
|
|
1777
1849
|
}
|
|
1778
1850
|
}
|
|
1779
|
-
// 6. Complete,
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1851
|
+
// 6. Complete, under either mode: the agent may finish early and the provider is free to end at
|
|
1852
|
+
// once. Only a task with no wrap at all has nothing to complete, and there the drive waits for the
|
|
1853
|
+
// provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
|
|
1854
|
+
// email, and a voice task offering no end-call, is completed from where it stands.
|
|
1782
1855
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
1783
|
-
if (latestTask().
|
|
1856
|
+
if (latestTask().wrapAllowance !== 0 && completable) {
|
|
1784
1857
|
const command = { type: "complete" };
|
|
1785
1858
|
const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
|
|
1786
1859
|
if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
|
|
@@ -1860,6 +1933,63 @@ async function driveOneCall(drive) {
|
|
|
1860
1933
|
// preserves the committed break. A forced break arrives on-break
|
|
1861
1934
|
// with `forced`. Nothing else is a move the guide describes.
|
|
1862
1935
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
1936
|
+
/**
|
|
1937
|
+
* The team as the events left it: whole on `team-updated` and on any snapshot carrying one, then
|
|
1938
|
+
* one member at a time. A member event before the whole team, or a removal of a member the team
|
|
1939
|
+
* never carried, is a change to a team the lead was never given.
|
|
1940
|
+
*/
|
|
1941
|
+
export class TeamStream {
|
|
1942
|
+
members;
|
|
1943
|
+
static ids(team) {
|
|
1944
|
+
if (!isRecord(team) || !Array.isArray(team.members))
|
|
1945
|
+
return undefined;
|
|
1946
|
+
return new Set(team.members.filter(isRecord).map(member => String(member.id)));
|
|
1947
|
+
}
|
|
1948
|
+
/** A snapshot carrying a team is the team whole; one carrying none leaves the lead with none. */
|
|
1949
|
+
seed(snapshot) {
|
|
1950
|
+
this.members = TeamStream.ids(isRecord(snapshot) ? snapshot.team : undefined);
|
|
1951
|
+
}
|
|
1952
|
+
/** Whether the team currently carries this member, as the events left it. */
|
|
1953
|
+
has(id) { return this.members?.has(id) === true; }
|
|
1954
|
+
apply(envelope, path = "event") {
|
|
1955
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
1956
|
+
if (!isRecord(event))
|
|
1957
|
+
return [];
|
|
1958
|
+
const at = `${path}.event`;
|
|
1959
|
+
switch (event.type) {
|
|
1960
|
+
case "snapshot":
|
|
1961
|
+
this.seed(event.snapshot);
|
|
1962
|
+
return [];
|
|
1963
|
+
case "team-updated":
|
|
1964
|
+
this.members = TeamStream.ids(event.team);
|
|
1965
|
+
return [];
|
|
1966
|
+
case "team-member-updated":
|
|
1967
|
+
case "team-member-removed":
|
|
1968
|
+
case "team-policies-updated": {
|
|
1969
|
+
ruleEvaluated("stream.team.baseline");
|
|
1970
|
+
if (this.members === undefined) {
|
|
1971
|
+
return [{ rule: "stream.team.baseline", path: `${at}.type`,
|
|
1972
|
+
message: `${event.type} arrived before the team: team-updated carries the whole team first, and it changes one member at a time after that` }];
|
|
1973
|
+
}
|
|
1974
|
+
if (event.type === "team-member-updated") {
|
|
1975
|
+
if (isRecord(event.member) && typeof event.member.id === "string")
|
|
1976
|
+
this.members.add(event.member.id);
|
|
1977
|
+
return [];
|
|
1978
|
+
}
|
|
1979
|
+
if (event.type === "team-member-removed") {
|
|
1980
|
+
ruleEvaluated("stream.teamMember.unknown");
|
|
1981
|
+
const id = String(event.memberId);
|
|
1982
|
+
if (!this.members.has(id)) {
|
|
1983
|
+
return [{ rule: "stream.teamMember.unknown", path: `${at}.memberId`, message: `${id} is not a member the team carried: a removal takes off somebody the lead was given` }];
|
|
1984
|
+
}
|
|
1985
|
+
this.members.delete(id);
|
|
1986
|
+
}
|
|
1987
|
+
return [];
|
|
1988
|
+
}
|
|
1989
|
+
default: return [];
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1863
1993
|
export class BreakStream {
|
|
1864
1994
|
state;
|
|
1865
1995
|
recovery = true;
|
package/dist/validation.d.ts
CHANGED
|
@@ -98,8 +98,9 @@ export interface ReaderContext {
|
|
|
98
98
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
99
99
|
loginId?: string;
|
|
100
100
|
/**
|
|
101
|
-
* Whether the lead has the team feature on, as the application last told the provider.
|
|
102
|
-
*
|
|
101
|
+
* Whether the lead has the team feature on, as the application last told the provider. The
|
|
102
|
+
* provider assumes nothing until told: on, a snapshot carries the team; off, nothing of the
|
|
103
|
+
* team is owed or expected. Unknown to a caller without it, and then unchecked either way.
|
|
103
104
|
*/
|
|
104
105
|
leadFeatures?: boolean;
|
|
105
106
|
/** The provider's channel, for a member's task that states none. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`. */
|
|
@@ -114,6 +115,8 @@ export interface ReaderContext {
|
|
|
114
115
|
autoAcceptTasks?: boolean;
|
|
115
116
|
}
|
|
116
117
|
export declare function validateTeamMembers(teamMembers: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
118
|
+
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
119
|
+
export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
117
120
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
118
121
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
119
122
|
/**
|
|
@@ -143,7 +146,7 @@ export declare function validateTaskCommandRequest(request: unknown, task: unkno
|
|
|
143
146
|
/**
|
|
144
147
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
145
148
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
146
|
-
*
|
|
149
|
+
* lead requested, somebody else on the call. Without a task only the command's
|
|
147
150
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
148
151
|
* for a control the task never offered is the host's error, and this names it.
|
|
149
152
|
*/
|