@xema/omni-protocol 0.1.70 → 0.1.72
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 +111 -50
- package/dist/validation.js +6 -0
- package/guide.md +22 -11
- 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
|
}
|
|
@@ -1012,7 +1055,7 @@ export class TaskStream {
|
|
|
1012
1055
|
case "task-updated":
|
|
1013
1056
|
if (id === undefined)
|
|
1014
1057
|
break;
|
|
1015
|
-
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");
|
|
1058
|
+
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
1016
1059
|
if (known === undefined) {
|
|
1017
1060
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
1018
1061
|
break;
|
|
@@ -1051,6 +1094,15 @@ export class TaskStream {
|
|
|
1051
1094
|
refuse("stream.taskUpdated.handlingHistory", `${at}.task.handlingHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
|
|
1052
1095
|
}
|
|
1053
1096
|
}
|
|
1097
|
+
// A task completes after its media ends, never around it: an update that moves a task to
|
|
1098
|
+
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1099
|
+
// whoever caused the ending -- the drive's end-call, a transfer, the provider's own hand.
|
|
1100
|
+
{
|
|
1101
|
+
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1102
|
+
if (to === "completing" && known.media === "started" && known.phase !== "completing") {
|
|
1103
|
+
refuse("stream.taskUpdated.mediaOpen", `${at}.task.phase`, `${id} moves to completing with its media still started: the audio ends first, on task-media-ended, and a wrap-up with the call still up is audio that never ended`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1054
1106
|
if (known.media === "ended") {
|
|
1055
1107
|
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1056
1108
|
if (phase !== "completing") {
|
|
@@ -1189,7 +1241,7 @@ async function driveOneCall(drive) {
|
|
|
1189
1241
|
drive.waiters.add(waiter);
|
|
1190
1242
|
});
|
|
1191
1243
|
const recordSurvivesReload = async (at) => {
|
|
1192
|
-
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
|
|
1244
|
+
ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history", "drive.reload.handover");
|
|
1193
1245
|
let again;
|
|
1194
1246
|
try {
|
|
1195
1247
|
again = drive.rebuild();
|
|
@@ -1198,16 +1250,17 @@ async function driveOneCall(drive) {
|
|
|
1198
1250
|
refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
|
|
1199
1251
|
return;
|
|
1200
1252
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1253
|
+
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
1254
|
+
if (again.manifest.id !== drive.manifest.id) {
|
|
1255
|
+
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
|
|
1259
|
+
if (!await drive.handOver.down()) {
|
|
1260
|
+
refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
|
|
1261
|
+
}
|
|
1203
1262
|
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 });
|
|
1263
|
+
const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1211
1264
|
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1212
1265
|
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1213
1266
|
const restored = await session.state();
|
|
@@ -1215,12 +1268,22 @@ async function driveOneCall(drive) {
|
|
|
1215
1268
|
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
|
|
1216
1269
|
if (!same) {
|
|
1217
1270
|
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)}`);
|
|
1271
|
+
// A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
|
|
1272
|
+
try {
|
|
1273
|
+
await session.close();
|
|
1274
|
+
}
|
|
1275
|
+
catch { /* the session that would not restore is closed as far as it can be */ }
|
|
1218
1276
|
return;
|
|
1219
1277
|
}
|
|
1220
|
-
second = await again.connect(drive.context);
|
|
1278
|
+
const second = await again.connect(drive.context);
|
|
1279
|
+
// From here the second client is the run's connection: what it publishes is validated as before.
|
|
1280
|
+
drive.handOver.up(session, second);
|
|
1281
|
+
drive.connection = second;
|
|
1221
1282
|
const snapshot = await second.snapshot();
|
|
1222
|
-
// The second adapter's snapshot is held to everything a first one is.
|
|
1283
|
+
// The second adapter's snapshot is held to everything a first one is, and the streams take it as the state now.
|
|
1223
1284
|
found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
|
|
1285
|
+
drive.streams.stream.seed(snapshot);
|
|
1286
|
+
drive.streams.breaks.seed(snapshot);
|
|
1224
1287
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1225
1288
|
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
1226
1289
|
if (carried === undefined) {
|
|
@@ -1248,25 +1311,12 @@ async function driveOneCall(drive) {
|
|
|
1248
1311
|
else if (leg.mutedBy !== "host") {
|
|
1249
1312
|
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
1313
|
}
|
|
1314
|
+
// The drive goes on with the task as the reloaded client holds it.
|
|
1315
|
+
task = carried;
|
|
1251
1316
|
}
|
|
1252
1317
|
catch (error) {
|
|
1253
1318
|
refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1254
1319
|
}
|
|
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
1320
|
};
|
|
1271
1321
|
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
1272
1322
|
// store says which keys were the task's; one still held after task-ended is a key about to be
|
|
@@ -1458,8 +1508,12 @@ async function driveOneCall(drive) {
|
|
|
1458
1508
|
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1459
1509
|
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1460
1510
|
// the store -- or it composed the record in memory and the record died with it.
|
|
1461
|
-
if (drive.rebuild !== undefined)
|
|
1511
|
+
if (drive.rebuild !== undefined) {
|
|
1462
1512
|
await recordSurvivesReload(at);
|
|
1513
|
+
// A reload that stood no client ends the run: there is nothing left to drive.
|
|
1514
|
+
if (!drive.handOver.isLive())
|
|
1515
|
+
return found;
|
|
1516
|
+
}
|
|
1463
1517
|
}
|
|
1464
1518
|
// 4. Hold and resume, where offered.
|
|
1465
1519
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
@@ -1474,11 +1528,18 @@ async function driveOneCall(drive) {
|
|
|
1474
1528
|
// 5. End the call, where the agent may.
|
|
1475
1529
|
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1476
1530
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
1531
|
+
// A task that completes with its audio still up is refused by the stream as it passes
|
|
1532
|
+
// (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
|
|
1533
|
+
// that is not coming, so the run reads by the rule and not by its timeout.
|
|
1477
1534
|
const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
|
|
1478
1535
|
const event = envelope.event;
|
|
1479
|
-
|
|
1536
|
+
if (event.type === "task-media-ended" && event.taskId === taskId)
|
|
1537
|
+
return "ended";
|
|
1538
|
+
if (event.type === "task-updated" && isTask(event.task) && event.task.id === taskId && event.task.phase === "completing")
|
|
1539
|
+
return "completing";
|
|
1540
|
+
return undefined;
|
|
1480
1541
|
}, cursor);
|
|
1481
|
-
if (mediaEnded
|
|
1542
|
+
if (mediaEnded?.found === "ended")
|
|
1482
1543
|
cursor = mediaEnded.at;
|
|
1483
1544
|
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1484
1545
|
await holdRefusedOutsideHandling();
|
package/dist/validation.js
CHANGED
|
@@ -1021,6 +1021,12 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1021
1021
|
// The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
|
|
1022
1022
|
// task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
|
|
1023
1023
|
// wire said about the call stays true for ever. Empty and absent both say nobody.
|
|
1024
|
+
// A completing task's call is over: its media has ended, or it never started. Audio still stated
|
|
1025
|
+
// as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
|
|
1026
|
+
// the agent's notes -- and a task stating it contradicts itself.
|
|
1027
|
+
if (task.phase === "completing" && task.media === "started") {
|
|
1028
|
+
into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started: the call is over, and media still started is audio that never ended");
|
|
1029
|
+
}
|
|
1024
1030
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1025
1031
|
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
1026
1032
|
}
|
package/guide.md
CHANGED
|
@@ -2854,7 +2854,12 @@ which the update itself shows: the party on the call carrying a `stage`. A compl
|
|
|
2854
2854
|
republished as `in-progress` from a stale copy carries no such stage, and that is the ending the
|
|
2855
2855
|
agent never saw. Audio arrives only on a task at work: `task-media-started` on a `completing` task
|
|
2856
2856
|
is refused as it is on a pending one (`stream.taskMediaStarted.beforeWork`), since a connect-back
|
|
2857
|
-
returns the task to `in-progress` before any media.
|
|
2857
|
+
returns the task to `in-progress` before any media. And a task completes after its audio ends,
|
|
2858
|
+
never around it: an update moving a task to `completing` while the stream holds its media as
|
|
2859
|
+
started is refused (`stream.taskUpdated.mediaOpen`), whoever caused the ending, and a task stating
|
|
2860
|
+
`completing` with `media: "started"` contradicts itself on any snapshot or update
|
|
2861
|
+
(`task.media.completing`). The consequence the rule exists for is concrete: the customer's audio
|
|
2862
|
+
keeps playing through the agent's wrap-up.
|
|
2858
2863
|
|
|
2859
2864
|
What the agent is told differs by source, and only one source tells them anything. Under `queue`
|
|
2860
2865
|
and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
|
|
@@ -4641,7 +4646,9 @@ A refusal has an aftermath on the desk and a report to the provider, and both ar
|
|
|
4641
4646
|
vanish from the desk while the call is up.
|
|
4642
4647
|
- **A refused event is dropped and counted.** The state the host holds does not move for it. The
|
|
4643
4648
|
provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
|
|
4644
|
-
nothing.
|
|
4649
|
+
nothing. The desk shows nothing for it: the view kept moving for every other event, so "last
|
|
4650
|
+
update refused" would be false of it, and a chip that said nothing would be right about the
|
|
4651
|
+
transport. Counted, and the provider told, is all a host can truthfully do for one event.
|
|
4645
4652
|
- **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
|
|
4646
4653
|
an event, and every violation with its rule and path. An adapter logs it at error and treats it as
|
|
4647
4654
|
its own defect until shown otherwise: what the host refused never reached the agent, and a
|
|
@@ -4710,15 +4717,19 @@ the drive mutes it for one second and reports the leg through `recordStep`, begu
|
|
|
4710
4717
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4711
4718
|
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4712
4719
|
(`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
|
-
|
|
4720
|
+
does, the drive reloads the host as a reload happens: the first client is unsubscribed,
|
|
4721
|
+
disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
|
|
4722
|
+
built and connected for the same login with the same context and the same `store`; its snapshot
|
|
4723
|
+
must carry the task with that leg and the host's word, and the run goes on with the second as its
|
|
4724
|
+
connection to the end. A platform that holds the record hands it back, and an adapter that composed
|
|
4725
|
+
the record in memory has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`,
|
|
4726
|
+
`.rejected`). The second adapter is held to what the first was: the same provider
|
|
4727
|
+
(`drive.reload.manifest`), a snapshot that stands as any snapshot must, and a record that lost none
|
|
4728
|
+
of the entries the first had published -- a record once read is not unread across a reload either.
|
|
4729
|
+
A reconnect on the same adapter object would prove nothing, since an in-process adapter's memory
|
|
4730
|
+
survives it; only a second object separates kept-in-the-store from never-lost. And because the
|
|
4731
|
+
first client is gone before the second connects, there is no client for the platform to push the
|
|
4732
|
+
open task back to, and nothing about the reload is exempt from any rule. The second adapter opens its session from the same
|
|
4722
4733
|
`secrets`, so the reload is a restore before it is anything else: an adapter that holds a session
|
|
4723
4734
|
has already put what would rebuild it into the secrets store, from the moment it was handed one,
|
|
4724
4735
|
not only when a flow completes -- the session is the adapter's, the store is the host's, and a
|