@haikit/server 0.17.0 → 0.19.0

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/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { estTokens, isConversationBusy, isStaleLease, makeCap, } from "@haikit/core";
2
+ import { Broadcast } from "./signals.js";
2
3
  /**
3
4
  * Opens the history when init runs before the user has said anything: the
4
5
  * Messages API requires the first message to be the user's. Bracketed like
@@ -24,6 +25,15 @@ const WOKEN = "[The user has not said anything. The notifications above arrived
24
25
  /** Appended to the init tool's description in what the model sees. */
25
26
  const INIT_NOTE = "This ran automatically at the start of the conversation, and its result is already above. " +
26
27
  "Calling it again does not run it.";
28
+ /** The longest delay a Node timer keeps; past it, one fires after about a millisecond. */
29
+ const MAX_TIMER_MS = 2_147_483_647;
30
+ /** How long `hai.update` waits for a turn to let the conversation go, by default. */
31
+ const UPDATE_TIMEOUT_MS = 30_000;
32
+ /** How many conversations `hai.update` remembers its surfaces' handles for. */
33
+ const UPDATE_NAMES_KEPT = 10_000;
34
+ /** The first wait before `hai.update` tries a busy conversation again, and the longest. */
35
+ const UPDATE_RETRY_MS = 100;
36
+ const UPDATE_RETRY_MAX_MS = 2_000;
27
37
  export class Hai {
28
38
  config;
29
39
  surfaces = new Map();
@@ -34,6 +44,18 @@ export class Hai {
34
44
  queryUi;
35
45
  /** Handles each conversation has a revision of in flight, so two calls can't revise one at once. */
36
46
  revising = new WeakMap();
47
+ signals = new Map();
48
+ /** Signals sent, streams open, and presence, here and on the other servers. */
49
+ broadcast;
50
+ /** `hai.update` calls by conversation and surface: one writing, and the newest waiting behind it. */
51
+ updating = new Map();
52
+ /**
53
+ * The surfaces `hai.update` has revised, by conversation: each one's first
54
+ * handle and its latest, both mapped to the first, so calls naming either
55
+ * share a queue. Kept for the most recent conversations only; one let go of
56
+ * costs coalescing, never correctness, since every write revises the latest.
57
+ */
58
+ surfaceNames = new Map();
37
59
  /** What the model gets as its system prompt: the app's, then the scope. */
38
60
  system;
39
61
  seq = 0;
@@ -60,6 +82,25 @@ export class Hai {
60
82
  throw new RangeError(`maxWakes must be a whole count of at least 1 per a positive, finite number of milliseconds (got ${count} per ${perMs})`);
61
83
  }
62
84
  this.maxWakes = { count, perMs };
85
+ for (const s of config.signals ?? []) {
86
+ if (this.signals.has(s.name))
87
+ throw new Error(`two signals are named "${s.name}"`);
88
+ this.signals.set(s.name, s);
89
+ }
90
+ const { graceMs = 5_000, heartbeatMs = 10_000 } = config.presence ?? {};
91
+ for (const [name, ms] of [["graceMs", graceMs], ["heartbeatMs", heartbeatMs]]) {
92
+ // Past the longest timer Node keeps, it fires after about a millisecond:
93
+ // a grace would end at once, and a heartbeat would spin.
94
+ // Whole milliseconds, as timers count them: a fractional one would be
95
+ // scheduled shorter than the staleness arithmetic assumes.
96
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
97
+ throw new RangeError(`presence.${name} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
98
+ }
99
+ }
100
+ this.broadcast = new Broadcast(config.store, graceMs, heartbeatMs);
101
+ // hear the other servers' signals from now, so a stream that opens later gets their latest
102
+ if (this.signals.size)
103
+ this.broadcast.start();
63
104
  // query_ui is DERIVED, never authored. It cannot drift from the surfaces
64
105
  // that actually exist, and it always exists.
65
106
  const queryUi = this.buildQueryUiTool();
@@ -72,13 +113,27 @@ export class Hai {
72
113
  this.tools.set(config.init.name, config.init);
73
114
  }
74
115
  }
75
- /** Whether this app sends notices, so the browser should open the events stream. */
116
+ /**
117
+ * Whether this app sends notices, `hai.update`'s included: what the
118
+ * conversation's history may have unread, and the events stream may have to
119
+ * deliver.
120
+ */
76
121
  get sendsNotices() {
77
- return this.notices.size > 0;
122
+ return this.notices.size > 0 || this.config.updates === true;
78
123
  }
79
- /** Whether any of its notices are `wake` notices, which the events route starts turns for. */
124
+ /**
125
+ * Whether the browser should open the events stream: for notices, or for
126
+ * signals, which travel on it too.
127
+ */
128
+ get streamsEvents() {
129
+ return this.sendsNotices || this.signals.size > 0;
130
+ }
131
+ /**
132
+ * Whether any of its notices may be `wake` notices, which the events route
133
+ * starts turns for: a registered one, or an update sent with `wake`.
134
+ */
80
135
  get wakes() {
81
- return [...this.notices.values()].some((n) => n.notice.kind === "wake");
136
+ return this.config.updates === true || [...this.notices.values()].some((n) => n.notice.kind === "wake");
82
137
  }
83
138
  /**
84
139
  * Send a notice to a conversation: its payload to the browser now, over the
@@ -123,6 +178,278 @@ export class Hai {
123
178
  });
124
179
  return { seq: record.seq };
125
180
  };
181
+ /**
182
+ * Send a signal to every open events stream, on this server and, through a
183
+ * store with `publish` and `subscribe`, on the others. Not stored, not
184
+ * numbered, never in the model's context or the transcript: the browser
185
+ * hands it to the app's handler for it. Each signal's latest payload is
186
+ * kept, and a stream that opens later is sent it first.
187
+ *
188
+ * Throws for a signal not listed in `signals`, or a payload that fails its
189
+ * schema.
190
+ */
191
+ signal = (signal, payload) => {
192
+ const name = signal?.name;
193
+ if (this.signals.get(name) !== signal) {
194
+ throw new Error(`signal "${name}" is not registered: list it in createHai({ signals })`);
195
+ }
196
+ this.broadcast.send({ type: "signal", name, version: signal.version, payload: signal.payload.parse(payload) });
197
+ };
198
+ /**
199
+ * How many conversations have an events stream open, on every server
200
+ * sharing the store: a tab with the app open, as near as the server can
201
+ * tell. `listener` hears the count now and whenever it changes. A stream
202
+ * that closes still counts for `presence.graceMs`, so a reconnect doesn't
203
+ * flicker it. Returns a function that stops listening.
204
+ *
205
+ * Each server counts its own and the totals are summed, so a conversation
206
+ * counts once per server it has a stream on: one that reconnects to another
207
+ * server counts on both until the grace runs out, and one with streams on
208
+ * two servers at once counts twice. (The client opens one stream per
209
+ * conversation, so the second doesn't happen with it.)
210
+ *
211
+ * Only the number leaves a server: no conversation ids, and nothing the
212
+ * model sees. An app with no notices, updates or signals opens no events
213
+ * stream, so counts nothing.
214
+ */
215
+ onPresence(listener) {
216
+ return this.broadcast.onPresence(listener);
217
+ }
218
+ /**
219
+ * An events stream opened for `conversationId`: it hears every signal from
220
+ * now on, starting with each one's latest payload, and counts toward
221
+ * presence. The events route calls this; call it too if you serve the
222
+ * stream yourself, and call what it returns when the stream closes.
223
+ */
224
+ streamOpened(conversationId, emit) {
225
+ return this.broadcast.opened(conversationId, emit);
226
+ }
227
+ /**
228
+ * Revise a surface from outside any turn: a webhook, a job, a price feed.
229
+ * The revision is stored and attached as `ctx.update` does, under the
230
+ * conversation's lease, so a click can never act on props the server doesn't
231
+ * hold. Then an *update notice* tells both sides: the events stream sends
232
+ * the browser the revision, which it swaps into the component on screen,
233
+ * and the model hears which handle replaced which, with the new digest, in
234
+ * the next user message (or at once, with `wake`).
235
+ *
236
+ * `handle` may be any handle the surface has had: the revision replaces the
237
+ * latest. It throws if that one was answered, if the conversation is out of
238
+ * date, and, after waiting `timeoutMs` for a turn to let the conversation
239
+ * go, with `ConversationBusy`. A question waiting on the surface waits on
240
+ * the revision.
241
+ *
242
+ * One write per surface is in flight at a time. A call made meanwhile
243
+ * replaces any still waiting behind it, and every call it replaced resolves
244
+ * to the handle it writes; a wake any of them asked for still wakes.
245
+ */
246
+ update = (conversationId, surface, handle, props, options = {}) => {
247
+ const impl = surface;
248
+ const name = impl?.surface?.name;
249
+ if (this.surfaces.get(name) !== impl) {
250
+ return Promise.reject(new Error(`surface "${name}" is not registered: list it in createHai({ surfaces })`));
251
+ }
252
+ // Required even when notices open the events stream: it is also what has
253
+ // the events route start the turn an update sent with `wake` asks for.
254
+ if (this.config.updates !== true) {
255
+ return Promise.reject(new Error("hai.update is off: set createHai({ updates: true })"));
256
+ }
257
+ const { timeoutMs = UPDATE_TIMEOUT_MS, model, wake } = options;
258
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
259
+ return Promise.reject(new RangeError(`timeoutMs must be a finite number of milliseconds, zero or more (got ${String(timeoutMs)})`));
260
+ }
261
+ if (model !== undefined && typeof model !== "string")
262
+ return Promise.reject(new TypeError("model must be a string"));
263
+ let parsed;
264
+ try {
265
+ // Validated before anything waits: props may come from outside this process.
266
+ parsed = impl.surface.props.parse(props);
267
+ }
268
+ catch (err) {
269
+ return Promise.reject(err);
270
+ }
271
+ return new Promise((resolve, reject) => {
272
+ // the surface's first handle, when it is one `hai.update` has revised here
273
+ const first = this.surfaceNames.get(conversationId)?.get(handle) ?? handle;
274
+ const key = `${conversationId}\u0000${first}`;
275
+ const waiter = { resolve, reject };
276
+ const queue = this.updating.get(key);
277
+ if (queue) {
278
+ const behind = queue.next;
279
+ queue.next = {
280
+ props: parsed,
281
+ model,
282
+ wake: wake === true || behind?.wake === true,
283
+ timeoutMs,
284
+ waiters: [...(behind?.waiters ?? []), waiter],
285
+ };
286
+ return;
287
+ }
288
+ const fresh = { next: { props: parsed, model, wake: wake === true, timeoutMs, waiters: [waiter] } };
289
+ this.updating.set(key, fresh);
290
+ void (async () => {
291
+ for (let job = fresh.next; job; job = fresh.next) {
292
+ fresh.next = null;
293
+ try {
294
+ const { replaced, ...written } = await this.revise(conversationId, impl, handle, job);
295
+ this.name(conversationId, first, replaced, written.handle);
296
+ for (const w of job.waiters)
297
+ w.resolve(written);
298
+ }
299
+ catch (err) {
300
+ for (const w of job.waiters)
301
+ w.reject(err);
302
+ }
303
+ }
304
+ this.updating.delete(key);
305
+ })();
306
+ });
307
+ };
308
+ /** Record that `first`'s surface went from `replaced` to `latest`, for the next call's queue. */
309
+ name(conversationId, first, replaced, latest) {
310
+ const names = this.surfaceNames.get(conversationId) ?? new Map();
311
+ // only the first and the latest are kept: what a caller is likely to hold
312
+ if (replaced !== first)
313
+ names.delete(replaced);
314
+ names.set(first, first).set(latest, first);
315
+ // most recently revised last, so the oldest conversation goes first
316
+ this.surfaceNames.delete(conversationId);
317
+ this.surfaceNames.set(conversationId, names);
318
+ if (this.surfaceNames.size > UPDATE_NAMES_KEPT)
319
+ this.surfaceNames.delete(this.surfaceNames.keys().next().value);
320
+ }
321
+ /**
322
+ * Append the update notice this conversation owes, if any, before anything
323
+ * else is done with it: a revision whose own attempt to append it failed.
324
+ * One a holder appended before its save failed is not appended again.
325
+ */
326
+ async announce(conversation) {
327
+ const owed = conversation.announcing;
328
+ if (!owed)
329
+ return;
330
+ // The model hasn't taken it in either way: that commits with clearing this.
331
+ const unread = (await this.config.store.getNotices(conversation.id, conversation.noticedThrough ?? 0)) ?? [];
332
+ if (!unread.some((n) => n.handle === owed.handle && n.replaces === owed.replaces))
333
+ await this.config.store.putNotice(owed);
334
+ conversation.announcing = null;
335
+ }
336
+ /** One `hai.update` write: take the lease, revise, let the conversation go. */
337
+ async revise(conversationId, impl, handle, job) {
338
+ const { store } = this.config;
339
+ // Loading an unknown id would start a new conversation, so make sure it
340
+ // exists first, with the cheapest read there is.
341
+ if ((await store.getNotices(conversationId, Number.MAX_SAFE_INTEGER, 1)) === null) {
342
+ throw new Error(`no conversation ${conversationId}`);
343
+ }
344
+ // A turn in progress lands first: wait for it, backing off, up to the timeout.
345
+ const deadline = Date.now() + job.timeoutMs;
346
+ let conversation = null;
347
+ for (let wait = UPDATE_RETRY_MS; !conversation; wait = Math.min(wait * 2, UPDATE_RETRY_MAX_MS)) {
348
+ try {
349
+ conversation = await store.loadConversation(conversationId);
350
+ }
351
+ catch (err) {
352
+ const left = deadline - Date.now();
353
+ if (!isConversationBusy(err) || left <= 0)
354
+ throw err;
355
+ await new Promise((r) => setTimeout(r, Math.min(wait, left)));
356
+ // not another try once the timeout has passed, even if the turn has gone
357
+ if (Date.now() >= deadline)
358
+ throw err;
359
+ }
360
+ }
361
+ let failure = null;
362
+ let written = null;
363
+ try {
364
+ // one an earlier update still owes goes first, in order
365
+ await this.announce(conversation);
366
+ written = await this.reviseHeld(conversation, impl, handle, job);
367
+ }
368
+ catch (err) {
369
+ failure = err;
370
+ }
371
+ // As a route does at the end of a request: let the conversation go, with
372
+ // the token kept so the save can prove this is still the rightful holder.
373
+ conversation.leaseUntil = null;
374
+ if (!isStaleLease(failure)) {
375
+ try {
376
+ await store.saveConversation(conversation);
377
+ }
378
+ catch (err) {
379
+ failure ??= err;
380
+ }
381
+ }
382
+ if (failure)
383
+ throw failure;
384
+ return written;
385
+ }
386
+ async reviseHeld(conversation, impl, handle, job) {
387
+ const { store } = this.config;
388
+ if (!conversation.handles.includes(handle))
389
+ throw new Error(`no surface ${handle} in conversation ${conversation.id}`);
390
+ // the surface as it is now: updates queued one behind another each revise the last
391
+ const current = currentHandle(conversation, handle);
392
+ if (conversation.frozen.includes(current))
393
+ throw new Error(`${current} was answered, so it can't be revised`);
394
+ const record = await store.getPayload(current, conversation.id);
395
+ if (!record)
396
+ throw new Error(`no surface ${current} in conversation ${conversation.id}`);
397
+ if (record.component !== impl.surface.name)
398
+ throw new Error(`${current} is a ${record.component}, not a ${impl.surface.name}`);
399
+ // After the reads, right before the write, as for `ctx.update`: a revision
400
+ // must not bring an out-of-date conversation back by superseding the
401
+ // surface that closed it.
402
+ if (await this.refuseIfExpired(conversation, () => { })) {
403
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
404
+ }
405
+ const next = await store.putPayload({
406
+ conversationId: conversation.id,
407
+ component: impl.surface.name,
408
+ version: impl.surface.version,
409
+ props: job.props,
410
+ mode: record.mode,
411
+ staleAfterMs: windowOf(impl),
412
+ }, conversation.leaseToken);
413
+ const digest = impl.impl.digest(job.props, { handle: next });
414
+ // …and once more as it takes effect: the write and the digest take a while too
415
+ const window = strictest(record.staleAfterMs, windowOf(impl));
416
+ if (window !== "never" && !(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
417
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
418
+ }
419
+ conversation.handles.push(next);
420
+ (conversation.superseded ??= {})[current] = next;
421
+ // A question waiting on the surface waits on its revision. Its digest stays
422
+ // as the model was shown it: the update's text rides in the same message as
423
+ // the answer, after it, saying what changed.
424
+ if (conversation.pending?.handle === current)
425
+ conversation.pending = { ...conversation.pending, handle: next };
426
+ // The notice it owes commits with it, in one fenced save, so the revision
427
+ // and its notice can't come apart. Saved before the notice is appended,
428
+ // which proves the lease is still held: a notice for a revision the history
429
+ // lost would show the browser a surface every click on is refused.
430
+ const notice = {
431
+ conversationId: conversation.id,
432
+ name: impl.surface.name,
433
+ version: impl.surface.version,
434
+ payload: null,
435
+ model: `${job.model ? `${job.model} ` : ""}${current} was replaced by ${next}; its earlier digest is out of date. Now: ${digest}`,
436
+ handle: next,
437
+ replaces: current,
438
+ ...(job.wake ? { kind: "wake" } : {}),
439
+ };
440
+ conversation.announcing = notice;
441
+ await store.saveConversation(conversation);
442
+ try {
443
+ await store.putNotice(notice);
444
+ }
445
+ catch (err) {
446
+ // Still owed, and appended by whoever holds the conversation next, before
447
+ // anything else: the model hears of the revision before it runs again.
448
+ throw new Error(`${next} is saved, but its update notice could not be stored yet: it goes out with the conversation's next request (${err.message})`);
449
+ }
450
+ conversation.announcing = null;
451
+ return { handle: next, replaced: current };
452
+ }
126
453
  /**
127
454
  * Start a turn for the `wake` notices this conversation hasn't taken in, if
128
455
  * it can take one now. The events route calls this, and streams the turn
@@ -188,6 +515,7 @@ export class Hai {
188
515
  return outcome;
189
516
  }
190
517
  async wakeTurn(conversation, emit) {
518
+ await this.announce(conversation);
191
519
  // a load of an unknown id starts a new conversation, which has not begun either
192
520
  if (!conversation.messages.length || conversation.status === "awaiting")
193
521
  return "idle";
@@ -280,11 +608,13 @@ export class Hai {
280
608
  */
281
609
  async unreadNotices(conversation) {
282
610
  const through = conversation.noticedThrough ?? 0;
283
- if (!this.notices.size)
611
+ if (!this.sendsNotices)
284
612
  return { blocks: [], through, wake: 0 };
285
613
  const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
286
614
  return {
287
- blocks: fresh.flatMap((n) => n.model === null ? [] : [{ type: "text", text: `[App notification: ${n.name}] ${n.model}` }]),
615
+ blocks: fresh.flatMap((n) => n.model === null
616
+ ? []
617
+ : [{ type: "text", text: n.replaces === undefined ? `[App notification: ${n.name}] ${n.model}` : `[UI update] ${n.model}` }]),
288
618
  through: fresh.at(-1)?.seq ?? through,
289
619
  // the newest unread wake notice, or 0
290
620
  wake: fresh.findLast((n) => n.kind === "wake")?.seq ?? 0,
@@ -364,6 +694,7 @@ export class Hai {
364
694
  }
365
695
  // ─────────────────────────────────────────────────── public API
366
696
  async send(conversation, text, emit) {
697
+ await this.announce(conversation);
367
698
  if (await this.refuseIfExpired(conversation, emit))
368
699
  return;
369
700
  // The first message is the one that starts the conversation.
@@ -413,6 +744,7 @@ export class Hai {
413
744
  async start(conversation, emit) {
414
745
  if (!this.config.init || conversation.messages.length > 0)
415
746
  return;
747
+ await this.announce(conversation);
416
748
  const unread = await this.unreadNotices(conversation);
417
749
  if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through))
418
750
  return; // parked on its surface
@@ -497,6 +829,7 @@ export class Hai {
497
829
  return false;
498
830
  }
499
831
  async interact(conversation, input, emit) {
832
+ await this.announce(conversation);
500
833
  if (await this.refuseIfExpired(conversation, emit))
501
834
  return;
502
835
  // The payload row carries a conversation id, but that alone is not enough.
@@ -634,8 +967,9 @@ export class Hai {
634
967
  async refuseIfExpired(conversation, emit) {
635
968
  if (!conversation.handles.length)
636
969
  return false;
637
- const now = Date.now();
638
970
  const records = await this.config.store.getPayloads(conversation.handles, conversation.id);
971
+ // after the read, so a window that ran out while it was in flight counts
972
+ const now = Date.now();
639
973
  // the surface that went out of date first is the one that closed it
640
974
  let first = null;
641
975
  // getPayloads omits handles it cannot find, and the loop below only sees
@@ -734,8 +1068,18 @@ export class Hai {
734
1068
  // Hold the resolved siblings too — the API is all-or-nothing per batch.
735
1069
  conversation.status = "awaiting";
736
1070
  // At the question's latest revision: a later call in this reply may
737
- // have revised it.
738
- conversation.pending = { ...parked, handle: currentHandle(conversation, parked.handle), results };
1071
+ // have revised it, and then its answer goes out under the revision's
1072
+ // digest, not the original's.
1073
+ // Only the question's part is replaced: anything the asking call said
1074
+ // after it rides along as before.
1075
+ const handle = currentHandle(conversation, parked.handle);
1076
+ const asking = hop.asking;
1077
+ let digest = parked.digest;
1078
+ if (handle !== parked.handle && asking?.handle === handle && asking.digest !== undefined) {
1079
+ const asked = asking.question;
1080
+ digest = asked !== undefined && parked.digest.startsWith(asked) ? asking.digest + parked.digest.slice(asked.length) : asking.digest;
1081
+ }
1082
+ conversation.pending = { ...parked, handle, digest, results };
739
1083
  emit({ type: "status", status: "awaiting" });
740
1084
  await this.emitContext(conversation, emit);
741
1085
  return;
@@ -770,7 +1114,10 @@ export class Hai {
770
1114
  // back to the tool for the model.
771
1115
  const show = async (impl, props, surfaceMode,
772
1116
  // the handle this revises in place, for `update`
773
- replaces) => {
1117
+ replaces,
1118
+ // last word before the surface is attached and sent: throwing leaves the
1119
+ // stored payload unreferenced, inert as an overtaken turn's
1120
+ beforeAttach) => {
774
1121
  // Validate before storing: props may originate outside this process.
775
1122
  const parsed = impl.surface.props.parse(props);
776
1123
  const window = windowOf(impl);
@@ -790,6 +1137,7 @@ export class Hai {
790
1137
  }, conversation.leaseToken);
791
1138
  // The split, in two lines. Digest -> model. Payload -> browser.
792
1139
  const digest = impl.impl.digest(parsed, { handle });
1140
+ beforeAttach?.();
793
1141
  // Recorded only once the digest is in hand. A surface that fails here is
794
1142
  // never shown, and a handle in the history counts toward its freshness,
795
1143
  // so it could otherwise close the conversation over something unseen.
@@ -843,6 +1191,7 @@ export class Hai {
843
1191
  try {
844
1192
  const ret = await show(impl, props, "elicit");
845
1193
  claim.handle = ret.handle;
1194
+ claim.question = ret.model;
846
1195
  asked = { handle: ret.handle, digest: ret.model };
847
1196
  return ret;
848
1197
  }
@@ -878,13 +1227,35 @@ export class Hai {
878
1227
  if (record.component !== impl.surface.name) {
879
1228
  throw new Error(`${handle} is a ${record.component}, not a ${impl.surface.name}`);
880
1229
  }
881
- const ret = await show(impl, props, record.mode, handle);
1230
+ // Freshness again, last, right before the write: a long turn can
1231
+ // outlast a window that was open when it began, and so can the reads
1232
+ // above. A revision must not bring an out-of-date conversation back
1233
+ // by superseding the surface that closed it.
1234
+ if (await this.refuseIfExpired(conversation, () => { })) {
1235
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
1236
+ }
1237
+ // …and once more as it takes effect: the write and the digest above can
1238
+ // take a while too, and the browser closes the conversation at the old
1239
+ // surface's deadline whether or not a revision is on its way.
1240
+ const window = strictest(record.staleAfterMs, windowOf(impl));
1241
+ const stillFresh = () => {
1242
+ if (window === "never")
1243
+ return;
1244
+ if (!(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
1245
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
1246
+ }
1247
+ };
1248
+ const ret = await show(impl, props, record.mode, handle, stillFresh);
882
1249
  (conversation.superseded ??= {})[handle] = ret.handle;
883
- // a question waiting on the old surface waits on the new one
884
- if (asked?.handle === handle)
1250
+ // a question waiting on the old surface waits on the new one; when this
1251
+ // call asked it, its result starts with the revision's digest now, so
1252
+ // that is the part a later call's revision replaces
1253
+ const mine = asked?.handle === handle;
1254
+ if (mine)
885
1255
  asked = { handle: ret.handle, digest: ret.model };
886
- if (hop.asking?.handle === handle)
887
- hop.asking.handle = ret.handle;
1256
+ if (hop.asking?.handle === handle) {
1257
+ Object.assign(hop.asking, { handle: ret.handle, digest: ret.model }, mine ? { question: ret.model } : {});
1258
+ }
888
1259
  return ret;
889
1260
  }
890
1261
  finally {