@xema/omni-protocol 0.1.70 → 0.1.71
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/testing.js +92 -47
- package/guide.md +16 -10
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -168,8 +168,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
168
168
|
set: async (key, value) => { storedSecrets.set(key, value); },
|
|
169
169
|
delete: async (key) => { storedSecrets.delete(key); },
|
|
170
170
|
};
|
|
171
|
-
|
|
171
|
+
let authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
|
|
172
172
|
let connection;
|
|
173
|
+
/** Whether the run holds a client at all: false between a reload's first client going down and its second standing, and after a reload that failed. */
|
|
174
|
+
let clientLive = true;
|
|
173
175
|
let unsubscribe;
|
|
174
176
|
let unsubscribeAuthentication;
|
|
175
177
|
let unsubscribeHost;
|
|
@@ -318,10 +320,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
318
320
|
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
319
321
|
const eventPayloads = new Map();
|
|
320
322
|
let capacityStated;
|
|
321
|
-
const reloading = { now: false };
|
|
322
323
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
323
324
|
const waiters = new Set();
|
|
324
|
-
|
|
325
|
+
const onEnvelope = (envelope) => {
|
|
325
326
|
observeEvent(envelope, seen);
|
|
326
327
|
const broken = validateEventEnvelope(envelope, adapter.manifest, "event", reader());
|
|
327
328
|
violations.push(...broken);
|
|
@@ -334,7 +335,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
334
335
|
// client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
|
|
335
336
|
// defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
|
|
336
337
|
// second adapter is up; every other moment it is.
|
|
337
|
-
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic"
|
|
338
|
+
if (isRecord(envelope?.event) && envelope.event.type === "diagnostic") {
|
|
338
339
|
violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
|
|
339
340
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
340
341
|
}
|
|
@@ -374,7 +375,45 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
374
375
|
events.push(envelope);
|
|
375
376
|
for (const waiter of waiters)
|
|
376
377
|
waiter(envelope);
|
|
377
|
-
}
|
|
378
|
+
};
|
|
379
|
+
unsubscribe = connection.subscribe(onEnvelope);
|
|
380
|
+
// A host reload is the first client dying and a second coming up in its place. The drive hands
|
|
381
|
+
// the run over: the first connection is unsubscribed, disconnected and its session closed before
|
|
382
|
+
// the second connects, and the run continues on the second -- so there is no first client for a
|
|
383
|
+
// platform to push the open task back to, and nothing to exempt.
|
|
384
|
+
const handOver = {
|
|
385
|
+
isLive: () => clientLive,
|
|
386
|
+
down: async () => {
|
|
387
|
+
let clean = true;
|
|
388
|
+
try {
|
|
389
|
+
unsubscribe?.();
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
clean = false;
|
|
393
|
+
}
|
|
394
|
+
unsubscribe = undefined;
|
|
395
|
+
try {
|
|
396
|
+
await connection?.disconnect();
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
clean = false;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
await authentication.close();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
clean = false;
|
|
406
|
+
}
|
|
407
|
+
clientLive = false;
|
|
408
|
+
return clean;
|
|
409
|
+
},
|
|
410
|
+
up: (session, second) => {
|
|
411
|
+
authentication = session;
|
|
412
|
+
connection = second;
|
|
413
|
+
unsubscribe = second.subscribe(onEnvelope);
|
|
414
|
+
clientLive = true;
|
|
415
|
+
},
|
|
416
|
+
};
|
|
378
417
|
const snapshot = await connection.snapshot();
|
|
379
418
|
observeSnapshot(snapshot, seen);
|
|
380
419
|
{
|
|
@@ -449,7 +488,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
449
488
|
violations.push(...await driveOneCall({
|
|
450
489
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
451
490
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
452
|
-
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
|
|
491
|
+
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
|
|
492
|
+
streams: { stream, breaks },
|
|
453
493
|
}));
|
|
454
494
|
}
|
|
455
495
|
// Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
|
|
@@ -459,9 +499,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
459
499
|
// capacity is elsewhere, and the provider allocates nothing and refuses nothing for it. Any offer
|
|
460
500
|
// after a lower count is caught against it (stream.taskOffered.overCapacity).
|
|
461
501
|
ruleEvaluated("connection.setCapacity.lowered", "connection.setCapacity.zero");
|
|
462
|
-
for (const count of [2, 1, 0]) {
|
|
502
|
+
for (const count of clientLive ? [2, 1, 0] : []) {
|
|
463
503
|
capacityStated = count;
|
|
464
|
-
const restated = await
|
|
504
|
+
const restated = await connection.setCapacity({ count });
|
|
465
505
|
const shape = validateResult(restated, "setCapacity", "connection.setCapacity");
|
|
466
506
|
violations.push(...shape);
|
|
467
507
|
if (shape.length === 0 && restated.status === "failed") {
|
|
@@ -493,17 +533,20 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
493
533
|
catch {
|
|
494
534
|
clean = false;
|
|
495
535
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
536
|
+
// A run whose reload took the first client down and stood no second holds nothing to close.
|
|
537
|
+
if (clientLive) {
|
|
538
|
+
try {
|
|
539
|
+
await connection?.disconnect();
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
clean = false;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
await authentication.close();
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
clean = false;
|
|
549
|
+
}
|
|
507
550
|
}
|
|
508
551
|
disconnectWasClean = clean;
|
|
509
552
|
}
|
|
@@ -1189,7 +1232,7 @@ async function driveOneCall(drive) {
|
|
|
1189
1232
|
drive.waiters.add(waiter);
|
|
1190
1233
|
});
|
|
1191
1234
|
const recordSurvivesReload = async (at) => {
|
|
1192
|
-
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
|
|
1235
|
+
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history", "drive.reload.handover");
|
|
1193
1236
|
let again;
|
|
1194
1237
|
try {
|
|
1195
1238
|
again = drive.rebuild();
|
|
@@ -1198,16 +1241,17 @@ async function driveOneCall(drive) {
|
|
|
1198
1241
|
refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
|
|
1199
1242
|
return;
|
|
1200
1243
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1244
|
+
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
1245
|
+
if (again.manifest.id !== drive.manifest.id) {
|
|
1246
|
+
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
|
|
1250
|
+
if (!await drive.handOver.down()) {
|
|
1251
|
+
refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
|
|
1252
|
+
}
|
|
1203
1253
|
try {
|
|
1204
|
-
|
|
1205
|
-
if (again.manifest.id !== drive.manifest.id) {
|
|
1206
|
-
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
drive.reloading.now = true;
|
|
1210
|
-
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1254
|
+
const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1211
1255
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1212
1256
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1213
1257
|
const restored = await session.state();
|
|
@@ -1215,12 +1259,22 @@ async function driveOneCall(drive) {
|
|
|
1215
1259
|
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
|
|
1216
1260
|
if (!same) {
|
|
1217
1261
|
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)}`);
|
|
1262
|
+
// A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
|
|
1263
|
+
try {
|
|
1264
|
+
await session.close();
|
|
1265
|
+
}
|
|
1266
|
+
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1218
1267
|
return;
|
|
1219
1268
|
}
|
|
1220
|
-
second = await again.connect(drive.context);
|
|
1269
|
+
const second = await again.connect(drive.context);
|
|
1270
|
+
// From here the second client is the run's connection: what it publishes is validated as before.
|
|
1271
|
+
drive.handOver.up(session, second);
|
|
1272
|
+
drive.connection = second;
|
|
1221
1273
|
const snapshot = await second.snapshot();
|
|
1222
|
-
// The second adapter's snapshot is held to everything a first one is.
|
|
1274
|
+
// The second adapter's snapshot is held to everything a first one is, and the streams take it as the state now.
|
|
1223
1275
|
found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
|
|
1276
|
+
drive.streams.stream.seed(snapshot);
|
|
1277
|
+
drive.streams.breaks.seed(snapshot);
|
|
1224
1278
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1225
1279
|
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
1226
1280
|
if (carried === undefined) {
|
|
@@ -1248,25 +1302,12 @@ async function driveOneCall(drive) {
|
|
|
1248
1302
|
else if (leg.mutedBy !== "host") {
|
|
1249
1303
|
refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
|
|
1250
1304
|
}
|
|
1305
|
+
// The drive goes on with the task as the reloaded client holds it.
|
|
1306
|
+
task = carried;
|
|
1251
1307
|
}
|
|
1252
1308
|
catch (error) {
|
|
1253
1309
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1254
1310
|
}
|
|
1255
|
-
finally {
|
|
1256
|
-
try {
|
|
1257
|
-
await second?.disconnect();
|
|
1258
|
-
}
|
|
1259
|
-
catch (error) {
|
|
1260
|
-
refuse("drive.reload.rejected", "drive.reload", `the second adapter's disconnect threw: ${String(error)}`);
|
|
1261
|
-
}
|
|
1262
|
-
try {
|
|
1263
|
-
await session?.close();
|
|
1264
|
-
}
|
|
1265
|
-
catch (error) {
|
|
1266
|
-
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1267
|
-
}
|
|
1268
|
-
drive.reloading.now = false;
|
|
1269
|
-
}
|
|
1270
1311
|
};
|
|
1271
1312
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
1272
1313
|
// store says which keys were the task's; one still held after task-ended is a key about to be
|
|
@@ -1458,8 +1499,12 @@ async function driveOneCall(drive) {
|
|
|
1458
1499
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1459
1500
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1460
1501
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1461
|
-
if (drive.rebuild !== undefined)
|
|
1502
|
+
if (drive.rebuild !== undefined) {
|
|
1462
1503
|
await recordSurvivesReload(at);
|
|
1504
|
+
// A reload that stood no client ends the run: there is nothing left to drive.
|
|
1505
|
+
if (!drive.handOver.isLive())
|
|
1506
|
+
return found;
|
|
1507
|
+
}
|
|
1463
1508
|
}
|
|
1464
1509
|
// 4. Hold and resume, where offered.
|
|
1465
1510
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
package/guide.md
CHANGED
|
@@ -4641,7 +4641,9 @@ A refusal has an aftermath on the desk and a report to the provider, and both ar
|
|
|
4641
4641
|
vanish from the desk while the call is up.
|
|
4642
4642
|
- **A refused event is dropped and counted.** The state the host holds does not move for it. The
|
|
4643
4643
|
provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
|
|
4644
|
-
nothing.
|
|
4644
|
+
nothing. The desk shows nothing for it: the view kept moving for every other event, so "last
|
|
4645
|
+
update refused" would be false of it, and a chip that said nothing would be right about the
|
|
4646
|
+
transport. Counted, and the provider told, is all a host can truthfully do for one event.
|
|
4645
4647
|
- **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
|
|
4646
4648
|
an event, and every violation with its rule and path. An adapter logs it at error and treats it as
|
|
4647
4649
|
its own defect until shown otherwise: what the host refused never reached the agent, and a
|
|
@@ -4710,15 +4712,19 @@ the drive mutes it for one second and reports the leg through `recordStep`, begu
|
|
|
4710
4712
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4711
4713
|
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4712
4714
|
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4713
|
-
does, the drive
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4715
|
+
does, the drive reloads the host as a reload happens: the first client is unsubscribed,
|
|
4716
|
+
disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
|
|
4717
|
+
built and connected for the same login with the same context and the same `store`; its snapshot
|
|
4718
|
+
must carry the task with that leg and the host's word, and the run goes on with the second as its
|
|
4719
|
+
connection to the end. A platform that holds the record hands it back, and an adapter that composed
|
|
4720
|
+
the record in memory has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`,
|
|
4721
|
+
`.rejected`). The second adapter is held to what the first was: the same provider
|
|
4722
|
+
(`drive.reload.manifest`), a snapshot that stands as any snapshot must, and a record that lost none
|
|
4723
|
+
of the entries the first had published -- a record once read is not unread across a reload either.
|
|
4724
|
+
A reconnect on the same adapter object would prove nothing, since an in-process adapter's memory
|
|
4725
|
+
survives it; only a second object separates kept-in-the-store from never-lost. And because the
|
|
4726
|
+
first client is gone before the second connects, there is no client for the platform to push the
|
|
4727
|
+
open task back to, and nothing about the reload is exempt from any rule. The second adapter opens its session from the same
|
|
4722
4728
|
`secrets`, so the reload is a restore before it is anything else: an adapter that holds a session
|
|
4723
4729
|
has already put what would rebuild it into the secrets store, from the moment it was handed one,
|
|
4724
4730
|
not only when a flow completes -- the session is the adapter's, the store is the host's, and a
|