@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.
Files changed (3) hide show
  1. package/dist/testing.js +92 -47
  2. package/guide.md +16 -10
  3. 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
- const authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
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
- unsubscribe = connection.subscribe(envelope => {
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" && !reloading.now) {
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, reloading,
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 live.setCapacity({ count });
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
- try {
497
- await connection?.disconnect();
498
- }
499
- catch {
500
- clean = false;
501
- }
502
- try {
503
- await authentication.close();
504
- }
505
- catch {
506
- clean = false;
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
- let session;
1202
- let second;
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
- // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
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 then connects a second adapter for the same login with the same context and the
4714
- same `store`, and expects its snapshot to carry the task with that leg and the host's word: a
4715
- platform that holds the record hands it back, and an adapter that composed the record in memory
4716
- has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`, `.rejected`). The second
4717
- adapter is held to what the first was: the same provider (`drive.reload.manifest`), a snapshot that
4718
- stands as any snapshot must, and a record that lost none of the entries the first had published --
4719
- a record once read is not unread across a reload either. A reconnect on the same adapter object
4720
- would prove nothing, since an in-process adapter's memory survives it; only a second object
4721
- separates kept-in-the-store from never-lost. The second adapter opens its session from the same
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",