@xema/omni-protocol 0.1.63 → 0.1.65

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 CHANGED
@@ -272,7 +272,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
272
272
  // What the host's Mute does is stated where the host holds a microphone, and nowhere else.
273
273
  violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
274
274
  // The login's store is the host's to provide: an adapter that composes a record keeps in it what its platform cannot.
275
- violations.push(...validateLoginStore(context.store, "context.store"));
275
+ const storeShape = validateLoginStore(context.store, "context.store");
276
+ violations.push(...storeShape);
277
+ // The harness watches the store it hands over, so what an adapter leaves behind is seen rather than asked for.
278
+ const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set() };
276
279
  unsubscribeHost = context.host.subscribe(report => {
277
280
  violations.push(...validateHostReport(report, "context.host"));
278
281
  });
@@ -284,7 +287,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
284
287
  report: () => { consulted = true; return context.host.report(); },
285
288
  subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
286
289
  };
287
- connection = await adapter.connect({ ...context, host });
290
+ const connected = { ...context, host, store: watched.store };
291
+ connection = await adapter.connect(connected);
288
292
  const live = connection;
289
293
  // Dial is declared by presence: the capability object carries a destination policy rather
290
294
  // than an `enabled` flag, so its presence is the declaration.
@@ -374,7 +378,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
374
378
  violations.push(...await driveOneCall({
375
379
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
376
380
  timeoutMs: options.driveTimeoutMs ?? 5000,
377
- context, secrets: authenticationSecrets, reader, rebuild: options.rebuild,
381
+ context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
378
382
  }));
379
383
  }
380
384
  }
@@ -1019,6 +1023,29 @@ async function driveOneCall(drive) {
1019
1023
  }
1020
1024
  }
1021
1025
  };
1026
+ // A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
1027
+ // store says which keys were the task's; one still held after task-ended is a key about to be
1028
+ // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1029
+ let retainedAtEnd;
1030
+ const storeReleased = async () => {
1031
+ await Promise.resolve();
1032
+ await Promise.resolve();
1033
+ const retained = [...drive.held].filter(key => key.includes(taskId));
1034
+ retainedAtEnd = new Set(retained);
1035
+ if (retained.length > 0) {
1036
+ refuse("drive.store.retained", "drive.store", `${taskId} has ended and the login's store still holds ${retained.join(", ")}: a task's keys go with the task, or the next offer of the same id inherits them`);
1037
+ }
1038
+ };
1039
+ // A key written about the task after its end -- a persist hung off a timer, a late command -- is the
1040
+ // same hazard arriving later, so the store is read once more when the drive is done.
1041
+ const nothingLate = () => {
1042
+ if (retainedAtEnd === undefined)
1043
+ return;
1044
+ const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1045
+ if (late.length > 0) {
1046
+ 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`);
1047
+ }
1048
+ };
1022
1049
  // The task, as first seen: on the snapshot, or on the first offer.
1023
1050
  let cursor = 0;
1024
1051
  let task = isRecord(drive.snapshot) && Array.isArray(drive.snapshot.tasks) ? drive.snapshot.tasks.find(isTask) : undefined;
@@ -1224,11 +1251,12 @@ async function driveOneCall(drive) {
1224
1251
  if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
1225
1252
  command.disposition = dispositions.codes[0].id;
1226
1253
  }
1227
- if (await send(command) !== undefined)
1228
- await ended();
1254
+ if (await send(command) !== undefined && await ended() !== undefined)
1255
+ await storeReleased();
1229
1256
  }
1230
1257
  else if (latestTask().phase === "completing") {
1231
- await ended();
1258
+ if (await ended() !== undefined)
1259
+ await storeReleased();
1232
1260
  }
1233
1261
  // The record kept by the provider has the leg the host reported, wherever the provider restated it.
1234
1262
  if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
@@ -1244,6 +1272,9 @@ async function driveOneCall(drive) {
1244
1272
  }
1245
1273
  }
1246
1274
  }
1275
+ // A timer fires after the microtasks: one turn of the loop is what a late persist needs to show itself.
1276
+ await new Promise(resolve => setTimeout(resolve, 0));
1277
+ nothingLate();
1247
1278
  return found;
1248
1279
  }
1249
1280
  // A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
@@ -1323,6 +1354,18 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
1323
1354
  envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
1324
1355
  assertNoViolations(found, "The media follows the task");
1325
1356
  }
1357
+ /** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
1358
+ function watchStore(store) {
1359
+ const held = new Set();
1360
+ return {
1361
+ held,
1362
+ store: {
1363
+ get: key => store.get(key),
1364
+ set: async (key, value) => { await store.set(key, value); held.add(key); },
1365
+ delete: async (key) => { await store.delete(key); held.delete(key); },
1366
+ },
1367
+ };
1368
+ }
1326
1369
  /** A login store that lives in memory for the test: what most adapter tests hand `exerciseAdapter` as `context.store`. */
1327
1370
  export function memoryStore() {
1328
1371
  const kept = new Map();
package/guide.md CHANGED
@@ -1946,7 +1946,7 @@ Creates one live provider connection for the signed-in agent.
1946
1946
  | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
1947
1947
  | `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
1948
1948
  | `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's host states what its Mute does, and a desk-phone or conversation login's host cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the host's**. |
1949
- | `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. The harness requires it of every connection (`store.shape`, `store.get`, `.set`, `.delete`). |
1949
+ | `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. **A task's keys carry the task id and go with the task**: a key written about a task names the task's id in the key, and is deleted before the task's end is published, because a platform retires a task id minutes after closing it and a requeue takes seconds, so a key that outlives its task is inherited by the next offer of the same id -- a record with legs the host never reported against it. A login-scoped key carries no task id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`drive.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`drive.store.late`). The ordinary late write is the host's, not the adapter's: a host reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.task-not-found` and writes nothing -- the host ends its own open legs at the task's end, so such a report is the host's error to see, never a record to keep; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
1950
1950
  | `host` | The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The host reports, the adapter decides**. |
1951
1951
  | `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
1952
1952
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
@@ -4599,7 +4599,13 @@ adapter is held to what the first was: the same provider (`drive.reload.manifest
4599
4599
  stands as any snapshot must, and a record that lost none of the entries the first had published --
4600
4600
  a record once read is not unread across a reload either. A reconnect on the same adapter object
4601
4601
  would prove nothing, since an in-process adapter's memory survives it; only a second object
4602
- separates kept-in-the-store from never-lost. Outside the
4602
+ separates kept-in-the-store from never-lost. The second adapter opens its session from the same
4603
+ `secrets`, so the reload is a restore before it is anything else: an adapter that holds a session
4604
+ has already put what would rebuild it into the secrets store, from the moment it was handed one,
4605
+ not only when a flow completes -- the session is the adapter's, the store is the host's, and a
4606
+ host reload is exactly when no flow will run. And once the task has ended, the store the drive
4607
+ handed the adapter holds no key carrying the task's id (`drive.store.retained`): a task's keys go
4608
+ with the task, or the next offer of the same id inherits them. Outside the
4603
4609
  handling phases with `hold` still declared -- in
4604
4610
  `confirmed`, where the provider publishes it, and in `completing` once the call has ended -- the
4605
4611
  drive sends `hold` past the validator that would hold it back, and expects `failed`: the adapter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",