@xema/omni-protocol 0.1.111 → 0.1.112

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 CHANGED
@@ -1662,6 +1662,12 @@ export type HistoryReport = {
1662
1662
  ended?: never;
1663
1663
  seconds?: DurationSeconds;
1664
1664
  });
1665
+ /**
1666
+ * `at` is the provider's instant for the leg, on its own clock, assigned when the provider writes
1667
+ * the leg into its record and never an echo of the host's: the host keeps the answer as the leg's
1668
+ * identity from then on, and names it on the closing report. A provider that echoes the host's
1669
+ * instant has assigned nothing, and the drive names it (`drive.recordStep.identity`).
1670
+ */
1665
1671
  export type HistoryReportResult = {
1666
1672
  status: "recorded";
1667
1673
  at: IsoTimestamp;
package/dist/testing.d.ts CHANGED
@@ -151,6 +151,14 @@ export declare class TaskStream {
151
151
  private readonly endedAssignments;
152
152
  /** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
153
153
  private readonly offered;
154
+ /** The last seconds-left value the stream read per task and field, with the provider instant it was published at. */
155
+ private readonly countdowns;
156
+ /**
157
+ * A seconds-left value is the provider's arithmetic at the instant of the publication, never a
158
+ * value copied from an earlier one: between two publications the countdown loses at least the
159
+ * seconds the provider's own clock says passed, within a second of rounding.
160
+ */
161
+ private holdCountdowns;
154
162
  private readonly dials;
155
163
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
156
164
  dialled(dialId: string): void;
package/dist/testing.js CHANGED
@@ -1139,6 +1139,36 @@ export class TaskStream {
1139
1139
  endedAssignments = new Set();
1140
1140
  /** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
1141
1141
  offered = new Set();
1142
+ /** The last seconds-left value the stream read per task and field, with the provider instant it was published at. */
1143
+ countdowns = new Map();
1144
+ /**
1145
+ * A seconds-left value is the provider's arithmetic at the instant of the publication, never a
1146
+ * value copied from an earlier one: between two publications the countdown loses at least the
1147
+ * seconds the provider's own clock says passed, within a second of rounding.
1148
+ */
1149
+ holdCountdowns(id, task, publishedAt, at, refuse) {
1150
+ const atMs = typeof publishedAt === "string" ? Date.parse(publishedAt) : Number.NaN;
1151
+ if (!isRecord(task) || Number.isNaN(atMs))
1152
+ return;
1153
+ const known = this.countdowns.get(id) ?? new Map();
1154
+ for (const field of ["expiresInSeconds", "previewEndsInSeconds", "wrapEndsInSeconds"]) {
1155
+ const value = task[field];
1156
+ if (typeof value !== "number") {
1157
+ known.delete(field);
1158
+ continue;
1159
+ }
1160
+ const before = known.get(field);
1161
+ if (before !== undefined) {
1162
+ ruleEvaluated("stream.countdown.copied");
1163
+ const elapsed = Math.floor((atMs - before.atMs) / 1000);
1164
+ if (elapsed > 0 && value > before.value - elapsed + 1) {
1165
+ refuse("stream.countdown.copied", `${at}.${field}`, `${id} said ${field} ${before.value} at ${new Date(before.atMs).toISOString()} and ${value} at ${new Date(atMs).toISOString()}: ${elapsed}s passed on the provider's clock and the countdown lost fewer; seconds left are worked out at each publication, never copied from the one before`);
1166
+ }
1167
+ }
1168
+ known.set(field, { value, atMs });
1169
+ }
1170
+ this.countdowns.set(id, known);
1171
+ }
1142
1172
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
1143
1173
  // task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
1144
1174
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
@@ -1240,6 +1270,11 @@ export class TaskStream {
1240
1270
  snapshot.tasks.forEach((task, index) => {
1241
1271
  if (!isRecord(task) || typeof task.assignmentId !== "string")
1242
1272
  return;
1273
+ // A snapshot from a provider with a clock is a publication at providerTime; the countdowns it carries are held to it.
1274
+ if (typeof snapshot.providerTime === "string")
1275
+ this.holdCountdowns(task.assignmentId, task, snapshot.providerTime, `${at}.tasks[${index}]`, refuse);
1276
+ else
1277
+ this.countdowns.delete(task.assignmentId);
1243
1278
  const was = this.tasks.get(task.assignmentId);
1244
1279
  if (was === undefined)
1245
1280
  return;
@@ -1308,6 +1343,7 @@ export class TaskStream {
1308
1343
  this.offered.add(id);
1309
1344
  this.tasks.set(id, TaskStream.stated(event.task));
1310
1345
  this.noteDials(event.task);
1346
+ this.holdCountdowns(id, event.task, isRecord(envelope) ? envelope.occurredAt : undefined, `${at}.task`, refuse);
1311
1347
  break;
1312
1348
  }
1313
1349
  case "task-updated":
@@ -1319,6 +1355,7 @@ export class TaskStream {
1319
1355
  break;
1320
1356
  }
1321
1357
  // The rules about a known task are evaluated only once there is one.
1358
+ this.holdCountdowns(id, event.task, isRecord(envelope) ? envelope.occurredAt : undefined, `${at}.task`, refuse);
1322
1359
  ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.audioOpen", "stream.taskUpdated.history", "stream.taskAudioEnded.follow", "stream.taskUpdated.audio", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
1323
1360
  // Terms once read stay read. A re-read that fails is not a new fact about the task, so the
1324
1361
  // last statement stands and the failure is a diagnostic; not-yet-read is a place a task
@@ -1670,16 +1707,16 @@ async function driveOneCall(drive) {
1670
1707
  task = offered.found;
1671
1708
  cursor = offered.at;
1672
1709
  }
1673
- const taskId = task.assignmentId;
1710
+ let taskId = task.assignmentId;
1674
1711
  const latestTask = () => task;
1675
- const updated = (until, what) => waitFor(what, envelope => {
1712
+ const updated = (until, what, within) => waitFor(what, envelope => {
1676
1713
  const event = envelope.event;
1677
1714
  if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.assignmentId === taskId) {
1678
1715
  task = event.task;
1679
1716
  return until(event.task) ? event.task : undefined;
1680
1717
  }
1681
1718
  return undefined;
1682
- }, cursor).then(hit => { if (hit)
1719
+ }, cursor, within).then(hit => { if (hit)
1683
1720
  cursor = hit.at; return hit; });
1684
1721
  const ended = (within) => waitFor("a task-ended for the driven task", envelope => {
1685
1722
  const event = envelope.event;
@@ -1722,6 +1759,40 @@ async function driveOneCall(drive) {
1722
1759
  // Audio may arrive any time after the accept, before or after the task's own update says
1723
1760
  // in-progress: the event is the provider's word that the audio should attach, never a reply to
1724
1761
  // openAudio, so the drive looks for it from the accept rather than from the last update it read.
1762
+ // 0. A deadline stated is a deadline kept. Where the first offer says how long it has, within
1763
+ // what the drive will wait, the drive leaves it alone and expects the provider to end it expired
1764
+ // when it runs out; the next offer is the one driven. Offers with no deadline, or a longer one,
1765
+ // leave this rule unreached, and the result says so.
1766
+ const settleMs = Number(drive.manifest.settleMs);
1767
+ const lapse = latestTask().phase === "pending" ? latestTask().expiresInSeconds : undefined;
1768
+ if (typeof lapse === "number" && lapse * 1000 <= drive.timeoutMs) {
1769
+ ruleEvaluated("drive.offer.expired");
1770
+ const lapsedId = taskId;
1771
+ let missed = false;
1772
+ const ending = await waitFor(`the expired ending of ${lapsedId}`, envelope => {
1773
+ const event = envelope.event;
1774
+ return event.type === "task-ended" && event.assignmentId === lapsedId ? event : undefined;
1775
+ }, cursor, { ms: lapse * 1000 + settleMs, onExpiry: () => { missed = true; } });
1776
+ if (ending === undefined) {
1777
+ if (missed)
1778
+ refuse("drive.offer.expired", "drive.offer", `${lapsedId} was offered with ${lapse}s to answer and ${lapse * 1000 + settleMs}ms later it has not ended: a deadline stated is a deadline kept, and the offer ends expired naming pending`);
1779
+ return found;
1780
+ }
1781
+ const outcome = isRecord(ending.found.outcome) ? ending.found.outcome : {};
1782
+ if (!(outcome.type === "expired" && outcome.phase === "pending")) {
1783
+ refuse("drive.offer.expired", "drive.offer", `${lapsedId} lapsed unanswered and ended ${String(outcome.type)}: an offer nobody answered before its deadline ends expired naming pending`);
1784
+ }
1785
+ // The next offer is the one driven; a provider with nothing more to offer ends the drive here.
1786
+ const next = await waitFor("a task-offered after the lapsed one", envelope => {
1787
+ const event = envelope.event;
1788
+ return event.type === "task-offered" && isTask(event.task) && event.task.assignmentId !== lapsedId ? event.task : undefined;
1789
+ }, ending.at);
1790
+ if (next === undefined)
1791
+ return found;
1792
+ task = next.found;
1793
+ cursor = next.at;
1794
+ taskId = task.assignmentId;
1795
+ }
1725
1796
  const acceptedAt = drive.events.length;
1726
1797
  // 1. Accept the offer, if it is one.
1727
1798
  if (latestTask().phase === "pending") {
@@ -1730,13 +1801,47 @@ async function driveOneCall(drive) {
1730
1801
  if (await updated(t => t.phase !== "pending", "the task leaving pending after it was accepted") === undefined)
1731
1802
  return found;
1732
1803
  }
1733
- // 2. A preview: press Call, which is a dial.
1804
+ // 2. A preview: press Call, which is a dial. Where the preview says how long it has, within what
1805
+ // the drive will wait, the deadline is kept to its atDeadline first: under provider-dials the
1806
+ // provider dials when it runs out and the drive presses nothing; under host-dials and waits the
1807
+ // preview stands until then, and the drive presses Call at zero as the desk would.
1734
1808
  if (latestTask().phase === "preview") {
1735
- const dialId = `drive-${taskId}`;
1736
- drive.stream.dialled(dialId);
1737
- if (await send({ type: "dial", dialId }, dialId) === undefined)
1738
- return found;
1739
- if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
1809
+ const left = latestTask().previewEndsInSeconds;
1810
+ const atDeadline = latestTask().atDeadline;
1811
+ // Under provider-dials the provider places the call, and the desk presses nothing.
1812
+ let pressCall = true;
1813
+ if (typeof left === "number" && left * 1000 <= drive.timeoutMs && typeof atDeadline === "string") {
1814
+ ruleEvaluated("drive.preview.deadline");
1815
+ const ringing = (t) => t.phase !== "preview"
1816
+ || (Array.isArray(t.onCall) && t.onCall.some(who => isRecord(who) && who.role === "party" && who.stage === "ringing"));
1817
+ if (atDeadline === "provider-dials") {
1818
+ let missed = false;
1819
+ const dialled = await updated(ringing, "the provider dialling at the preview's deadline", { ms: left * 1000 + settleMs, onExpiry: () => { missed = true; } });
1820
+ if (dialled === undefined) {
1821
+ if (!missed)
1822
+ return found;
1823
+ refuse("drive.preview.deadline", "drive.preview", `${taskId} is a preview under provider-dials with ${left}s left, and the provider did not dial when it ran out: the party rings on onCall, or the task leaves preview`);
1824
+ return found;
1825
+ }
1826
+ pressCall = false;
1827
+ }
1828
+ else {
1829
+ // Nothing may move the preview before its deadline under host-dials or waits: the drive
1830
+ // waits the seconds out and expects the task where it left it.
1831
+ const moved = await updated(t => t.phase !== "preview", "nothing", { ms: left * 1000, onExpiry: () => undefined });
1832
+ if (moved !== undefined) {
1833
+ refuse("drive.preview.deadline", "drive.preview", `${taskId} is a preview under ${atDeadline} with ${left}s left, and the provider moved it to ${String(moved.found.phase)} before the deadline: under ${atDeadline} the preview stands until the desk dials, or for as long as the agent needs`);
1834
+ return found;
1835
+ }
1836
+ }
1837
+ }
1838
+ if (pressCall && latestTask().phase === "preview") {
1839
+ const dialId = `drive-${taskId}`;
1840
+ drive.stream.dialled(dialId);
1841
+ if (await send({ type: "dial", dialId }, dialId) === undefined)
1842
+ return found;
1843
+ }
1844
+ if (latestTask().phase === "preview" && await updated(t => t.phase === "in-progress" || t.phase === "completing", pressCall ? "the task leaving preview after Call" : "the task leaving preview after the provider's dial") === undefined)
1740
1845
  return found;
1741
1846
  }
1742
1847
  // The other direction of step 4, wherever the task stands outside the interaction phases with the
package/guide/agent.md CHANGED
@@ -588,8 +588,13 @@ as it begins and ends, and takes the provider's published entry as the closed ac
588
588
  it does not reopen the leg, replace the provider's timestamp, or overwrite the provider's final
589
589
  duration with a later report. A later agent mute is a new leg, never a reopening of the ended one.
590
590
 
591
- Correlate the provider-published `muted` entry using the provider-selected `at` acknowledged
592
- for the current report key; a final `seconds` closes that leg under the history contract.
591
+ **The provider assigns the instant.** `HistoryReportResult.at` is the provider's own instant for the
592
+ leg, on its own clock, written when the provider takes the leg into its record, never an echo of
593
+ the `at` the host reported: the host keeps the answer as the leg's identity from then on and names
594
+ it on the closing report, and a provider that echoes the host's instant has assigned nothing
595
+ (`drive.recordStep.identity`). Correlate the provider-published `muted` entry using that
596
+ provider-selected `at` acknowledged for the current report key; a final `seconds` closes that leg
597
+ under the history contract.
593
598
  This is identity matching, not reconciliation between clocks. An old closed entry, an unrelated
594
599
  task, or a stale connection cannot end a newer local mute. If publication precedes the acknowledgment,
595
600
  retain the current provider view and apply the matching closure once correlation is available;
package/guide.md CHANGED
@@ -2414,6 +2414,25 @@ and no count. A provider that publishes any of these instants therefore declares
2414
2414
  (`manifest.timeCheck.required`) and states `providerTime` on every snapshot
2415
2415
  (`snapshot.providerTime`). Two screens then differ by their delay and nothing else.
2416
2416
 
2417
+ **Seconds left are worked out at each publication.** `expiresInSeconds`, `previewEndsInSeconds`,
2418
+ `wrapEndsInSeconds` and `expectedEndsInSeconds` are each the provider's arithmetic at the instant
2419
+ of the publication that carries them, never a value copied from an earlier one. A provider that
2420
+ builds the next publication by spreading the last one forward hands a reloaded desk a countdown
2421
+ already spent -- sixty seconds shown beside a lead's screen showing twenty -- and the stream
2422
+ names it: between two publications of one countdown the value loses at least the seconds the
2423
+ provider's own clock says passed, within a second of rounding (`stream.countdown.copied`, on an
2424
+ update against the last publication, and on a snapshot from a provider with a clock against its
2425
+ `providerTime`). Under `agent-command` the wrap's value reaches `0` and stays there while the agent
2426
+ overruns; that is the true value, not a copy.
2427
+
2428
+ **A deadline stated is a deadline kept.** The provider that says how long an offer has ends it
2429
+ `expired` when it runs out, and the drive holds it to that: where the first offer carries
2430
+ `expiresInSeconds` within the drive's timeout, the drive leaves that offer unanswered and expects
2431
+ the `expired` ending within the seconds plus `settleMs`, then drives the next offer
2432
+ (`drive.offer.expired`). A preview's deadline is held to its `atDeadline` the same way: under
2433
+ `provider-dials` the provider dials when it runs out, under `host-dials` and `waits` the preview
2434
+ stands until the desk dials (`drive.preview.deadline`).
2435
+
2417
2436
  #### Optional periodic provider time checks and agent application estimates
2418
2437
 
2419
2438
  A provider may declare `Manifest.timeCheck: true` and implement `Connection.checkTime(request)`.
@@ -2976,7 +2995,10 @@ complete it with an outcome where the agent completes -- and holds every step to
2976
2995
  agent application holds a provider to. Each command is validated against the task as published
2977
2996
  (`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
2978
2997
  violation (`drive.command.failed`), and an event the provider owes and never sends is one too
2979
- (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). Around the drive the exercise holds
2998
+ (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A first offer that says how long it has is left to
2999
+ lapse and expected to end `expired`, and a preview's deadline is held to its `atDeadline`
3000
+ (`drive.offer.expired`, `drive.preview.deadline`); see **Every screen counts from the provider's
3001
+ clock**. Around the drive the exercise holds
2980
3002
  the run to what an agent application holds a provider to between commands: an offer before the agent application stated
2981
3003
  capacity, or beyond the count with nothing dialled on it, is named
2982
3004
  (`stream.taskOffered.beforeCapacity`, `.overCapacity`); every user the snapshot names is looked up
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",