@agent-native/core 0.221.0 → 0.221.1-nightly-20261028032829

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.
@@ -113,10 +113,63 @@ const EMPTY_SNAPSHOT = Object.freeze({
113
113
  isLoading: false,
114
114
  isSynced: false,
115
115
  initialization: { status: "loading" },
116
+ durableUpdatesFailed: false,
117
+ updatesRefused: false,
116
118
  activeUsers: [],
117
119
  agentActive: false,
118
120
  agentPresent: false,
119
121
  });
122
+ function readUpdateAck(body) {
123
+ if (!body || typeof body !== "object") {
124
+ throw new Error("The collab update response was not an object.");
125
+ }
126
+ const { liveCopy, liveCopyReason } = body;
127
+ if (liveCopy === undefined)
128
+ return { liveCopy: null };
129
+ if (liveCopy !== "derived" &&
130
+ liveCopy !== "not_derived" &&
131
+ liveCopy !== "unchanged") {
132
+ throw new Error(`Unknown live-copy outcome ${String(liveCopy)}.`);
133
+ }
134
+ return typeof liveCopyReason === "string"
135
+ ? { liveCopy, liveCopyReason }
136
+ : { liveCopy };
137
+ }
138
+ // Unacknowledged updates kept in localStorage, one entry per connection. Yjs
139
+ // integrates an update once, so sending one the server already holds again
140
+ // changes nothing.
141
+ const OUTBOX_PREFIX = "agent-native-collab-outbox-v1:";
142
+ const VERSIONED_OUTBOX_PREFIX = "agent-native-collab-outbox-v2:";
143
+ const OUTBOX_ORIGIN = "collab-outbox";
144
+ const OUTBOX_OWNER_LOCK_PREFIX = "agent-native-collab-outbox-owner:";
145
+ function outboxPrefix(docKey, email, prefix = OUTBOX_PREFIX) {
146
+ return `${prefix}${encodeURIComponent(docKey)}:${encodeURIComponent(email)}:`;
147
+ }
148
+ function readStoredUpdates(entry, legacyRequireLiveCopyOutcome) {
149
+ if (entry.version === 1)
150
+ return [
151
+ {
152
+ update: base64ToUint8Array(entry.raw),
153
+ requireLiveCopyOutcome: legacyRequireLiveCopyOutcome,
154
+ },
155
+ ];
156
+ const body = JSON.parse(entry.raw);
157
+ if (body?.version !== 2 ||
158
+ !Array.isArray(body.updates) ||
159
+ body.updates.length === 0 ||
160
+ body.updates.length > 2)
161
+ throw new Error("The collab outbox has an unreadable update list.");
162
+ return body.updates.map((value) => {
163
+ const item = value;
164
+ if (typeof item?.update !== "string" ||
165
+ typeof item.requireLiveCopyOutcome !== "boolean")
166
+ throw new Error("The collab outbox has an unreadable update.");
167
+ return {
168
+ update: base64ToUint8Array(item.update),
169
+ requireLiveCopyOutcome: item.requireLiveCopyOutcome,
170
+ };
171
+ });
172
+ }
120
173
  const requestSyncUnavailable = () => Promise.resolve({ status: "unavailable" });
121
174
  // Nothing is outstanding without a connection.
122
175
  const flushUpdatesUnavailable = () => Promise.resolve(true);
@@ -136,13 +189,27 @@ class CollabDocConnection {
136
189
  docMissing = false;
137
190
  requestSource;
138
191
  lastSetUser = null;
192
+ pageHidden = false;
193
+ presenceClear = null;
194
+ signedOut = false;
195
+ keepaliveSnapshots = new Set();
196
+ userGeneration = 0;
139
197
  pendingUpdates = [];
198
+ deliveredUpdates = new WeakSet();
199
+ strictDeliveryVersion = 0;
140
200
  flushTimer = null;
141
201
  updateInFlight = false;
142
- keepaliveInFlight = false;
143
202
  updateErrors = 0;
203
+ updateBatchLimit = Infinity;
144
204
  updateAbortController = null;
145
205
  updateHandlerAttached = false;
206
+ keepsUnsentUpdates = false;
207
+ unsentUpdatesRestored = false;
208
+ onlineDuringUpdate = false;
209
+ releaseOutboxOwner = null;
210
+ outboxOwnerId = `${Date.now().toString(36)}-${Math.random()
211
+ .toString(36)
212
+ .slice(2)}`;
146
213
  syncActive = false;
147
214
  pollTimer = null;
148
215
  pollGeneration = 0;
@@ -176,6 +243,8 @@ class CollabDocConnection {
176
243
  isLoading: true,
177
244
  isSynced: false,
178
245
  initialization: { status: "loading" },
246
+ durableUpdatesFailed: false,
247
+ updatesRefused: false,
179
248
  activeUsers: [],
180
249
  agentActive: false,
181
250
  agentPresent: false,
@@ -188,7 +257,7 @@ class CollabDocConnection {
188
257
  return collabRegistryKey(this.docId, this.baseUrl, this.actorIdentity);
189
258
  }
190
259
  get retiredUpdatesKey() {
191
- const email = this.lastSetUser?.email?.trim().toLowerCase();
260
+ const email = this.lastSetUser?.email.trim().toLowerCase();
192
261
  return email
193
262
  ? `${this.registryKey}\0${email}\0${this.requestSource ?? ""}`
194
263
  : null;
@@ -215,7 +284,7 @@ class CollabDocConnection {
215
284
  collabConnectionRegistry.set(nextKey, this);
216
285
  return true;
217
286
  }
218
- add(id, sub) {
287
+ add(id, sub, user) {
219
288
  this.subscribers.set(id, sub);
220
289
  if (this.disposeTimer) {
221
290
  clearTimeout(this.disposeTimer);
@@ -225,6 +294,15 @@ class CollabDocConnection {
225
294
  this.requestSource = sub.requestSource;
226
295
  registerOwnCollabSource(sub.requestSource);
227
296
  }
297
+ if (user)
298
+ this.setUser(user);
299
+ // Once kept, updates stay kept until acknowledged, even if the caller
300
+ // stops asking.
301
+ if (sub.durableUpdates && !this.keepsUnsentUpdates) {
302
+ this.keepsUnsentUpdates = true;
303
+ this.storeUnsentUpdates();
304
+ this.restoreUnsentUpdates();
305
+ }
228
306
  if (!this.started) {
229
307
  this.started = true;
230
308
  this.start();
@@ -243,6 +321,10 @@ class CollabDocConnection {
243
321
  remove(id) {
244
322
  this.subscribers.delete(id);
245
323
  if (this.subscribers.size === 0) {
324
+ if (this.signedOut) {
325
+ this.dispose();
326
+ return;
327
+ }
246
328
  cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
247
329
  // A lingering connection may outlive its document (a deleted Screen).
248
330
  this.stopSync();
@@ -287,14 +369,16 @@ class CollabDocConnection {
287
369
  }
288
370
  void this.flushPendingUpdates(true);
289
371
  this.detachUpdateHandler();
372
+ this.releaseOutboxOwner?.();
373
+ this.releaseOutboxOwner = null;
290
374
  if (this.agentTimer) {
291
375
  clearTimeout(this.agentTimer);
292
376
  this.agentTimer = null;
293
377
  }
294
378
  this.awareness.off("change", this.handleAwarenessChange);
295
- cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
296
- if (this.awareness.getLocalState()) {
297
- void postAwarenessState(this.baseUrl, this.docId, this.ydoc.clientID, null, true)
379
+ const clear = this.clearPresence();
380
+ if (clear) {
381
+ void clear
298
382
  .catch(() => { })
299
383
  .finally(() => {
300
384
  forgetAwarenessClientSequence(this.baseUrl, this.docId, this.ydoc.clientID);
@@ -312,21 +396,35 @@ class CollabDocConnection {
312
396
  async clearPresenceBeforeSignOut() {
313
397
  if (this.disposed)
314
398
  return;
399
+ this.signedOut = true;
315
400
  this.stopSync();
316
- cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
317
- if (!this.awareness.getLocalState())
318
- return;
401
+ const clear = this.clearPresence();
319
402
  this.awareness.setLocalState(null);
320
- try {
321
- const response = await postAwarenessState(this.baseUrl, this.docId, this.ydoc.clientID, null, true);
322
- if (!response.ok) {
323
- throw new Error(`Awareness clear returned HTTP ${response.status}`);
324
- }
403
+ if (collabConnectionRegistry.get(this.registryKey) === this) {
404
+ collabConnectionRegistry.delete(this.registryKey);
325
405
  }
326
- finally {
327
- forgetAwarenessClientSequence(this.baseUrl, this.docId, this.ydoc.clientID);
406
+ if (this.subscribers.size === 0)
407
+ this.dispose();
408
+ if (!clear)
409
+ return;
410
+ const response = await clear;
411
+ if (!response.ok) {
412
+ throw new Error(`Awareness clear returned HTTP ${response.status}`);
328
413
  }
329
414
  }
415
+ clearPresence() {
416
+ cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
417
+ if (!this.presenceClear && this.awareness.getLocalState()) {
418
+ this.presenceClear = postAwarenessState(this.baseUrl, this.docId, this.ydoc.clientID, null, true);
419
+ }
420
+ return this.presenceClear;
421
+ }
422
+ get canPublishPresence() {
423
+ return (!this.disposed &&
424
+ !this.signedOut &&
425
+ !this.pageHidden &&
426
+ this.presenceClear === null);
427
+ }
330
428
  get effectivePollInterval() {
331
429
  let min = Infinity;
332
430
  for (const sub of this.subscribers.values()) {
@@ -356,6 +454,179 @@ class CollabDocConnection {
356
454
  sub.onSnapshot(this.snapshot);
357
455
  }
358
456
  }
457
+ get hasStrictSubscriber() {
458
+ return Array.from(this.subscribers.values()).some((sub) => sub.requireLiveCopyOutcome());
459
+ }
460
+ get outboxScope() {
461
+ const email = this.lastSetUser?.email.trim().toLowerCase();
462
+ if (!email || typeof window === "undefined")
463
+ return null;
464
+ const prefix = outboxPrefix(this.registryKey, email);
465
+ const versionedPrefix = outboxPrefix(this.registryKey, email, VERSIONED_OUTBOX_PREFIX);
466
+ return {
467
+ prefix,
468
+ key: prefix + this.outboxOwnerId,
469
+ versionedPrefix,
470
+ versionedKey: versionedPrefix + this.outboxOwnerId,
471
+ };
472
+ }
473
+ markDurableUpdatesFailed() {
474
+ if (!this.snapshot.durableUpdatesFailed) {
475
+ this.setSnapshot({ durableUpdatesFailed: true });
476
+ }
477
+ }
478
+ holdOutboxOwner(key) {
479
+ if (this.disposed || this.releaseOutboxOwner)
480
+ return;
481
+ const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
482
+ if (typeof locks?.request !== "function") {
483
+ this.markDurableUpdatesFailed();
484
+ return;
485
+ }
486
+ // Queue the owner lock before publishing bytes an adopting tab can read.
487
+ const held = new Promise((release) => {
488
+ this.releaseOutboxOwner = release;
489
+ });
490
+ void locks
491
+ .request(OUTBOX_OWNER_LOCK_PREFIX + key, () => held)
492
+ .catch(() => {
493
+ if (!this.disposed)
494
+ this.markDurableUpdatesFailed();
495
+ });
496
+ }
497
+ storeUnsentUpdates() {
498
+ if (!this.keepsUnsentUpdates)
499
+ return;
500
+ const scope = this.outboxScope;
501
+ if (!scope)
502
+ return;
503
+ this.holdOutboxOwner(scope.key);
504
+ try {
505
+ if (this.pendingUpdates.length === 0) {
506
+ window.localStorage.removeItem(scope.key);
507
+ window.localStorage.removeItem(scope.versionedKey);
508
+ }
509
+ else {
510
+ const encode = (items) => uint8ArrayToBase64(Y.mergeUpdates(items.map((item) => item.update)));
511
+ const updates = [true, false].flatMap((requireLiveCopyOutcome) => {
512
+ const items = this.pendingUpdates.filter((item) => item.requireLiveCopyOutcome === requireLiveCopyOutcome);
513
+ return items.length
514
+ ? [{ update: encode(items), requireLiveCopyOutcome }]
515
+ : [];
516
+ });
517
+ // Older clients only understand v1's bare Yjs bytes and must not
518
+ // adopt or delete a versioned custody record.
519
+ window.localStorage.setItem(scope.versionedKey, JSON.stringify({ version: 2, updates }));
520
+ window.localStorage.removeItem(scope.key);
521
+ }
522
+ }
523
+ catch {
524
+ this.markDurableUpdatesFailed();
525
+ }
526
+ }
527
+ /**
528
+ * Sends what other connections for this doc and user kept: a tab that
529
+ * closed or reloaded before its updates were acknowledged. Applying them
530
+ * queues what the doc lacks as this connection's own unsent updates, which
531
+ * are kept before the other entry is removed. A live or frozen tab keeps
532
+ * its owner lock, so another connection leaves its entry untouched.
533
+ */
534
+ restoreUnsentUpdates() {
535
+ if (this.unsentUpdatesRestored ||
536
+ !this.keepsUnsentUpdates ||
537
+ !this.updateHandlerAttached ||
538
+ this.disposed) {
539
+ return;
540
+ }
541
+ const scope = this.outboxScope;
542
+ if (!scope)
543
+ return;
544
+ this.unsentUpdatesRestored = true;
545
+ const stored = [];
546
+ try {
547
+ const storage = window.localStorage;
548
+ for (let index = 0; index < storage.length; index += 1) {
549
+ const key = storage.key(index);
550
+ if (!key ||
551
+ (!key.startsWith(scope.prefix) &&
552
+ !key.startsWith(scope.versionedPrefix)) ||
553
+ key === scope.key ||
554
+ key === scope.versionedKey)
555
+ continue;
556
+ const raw = storage.getItem(key);
557
+ if (raw !== null)
558
+ stored.push({
559
+ key,
560
+ raw,
561
+ version: key.startsWith(scope.versionedPrefix) ? 2 : 1,
562
+ });
563
+ }
564
+ }
565
+ catch {
566
+ this.markDurableUpdatesFailed();
567
+ return;
568
+ }
569
+ const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
570
+ if (typeof locks?.request !== "function") {
571
+ this.markDurableUpdatesFailed();
572
+ return;
573
+ }
574
+ for (const entry of stored) {
575
+ const ownerKey = entry.key.startsWith(scope.versionedPrefix)
576
+ ? scope.prefix + entry.key.slice(scope.versionedPrefix.length)
577
+ : entry.key;
578
+ void locks
579
+ .request(OUTBOX_OWNER_LOCK_PREFIX + ownerKey, { ifAvailable: true }, (lock) => {
580
+ if (!lock ||
581
+ this.disposed ||
582
+ this.outboxScope?.prefix !== scope.prefix)
583
+ return;
584
+ try {
585
+ const raw = window.localStorage.getItem(entry.key);
586
+ if (raw !== null)
587
+ this.adoptStoredOutbox({ ...entry, raw });
588
+ }
589
+ catch {
590
+ this.markDurableUpdatesFailed();
591
+ }
592
+ })
593
+ .catch(() => {
594
+ if (!this.disposed)
595
+ this.markDurableUpdatesFailed();
596
+ });
597
+ }
598
+ }
599
+ adoptStoredOutbox(entry) {
600
+ try {
601
+ for (const item of readStoredUpdates(entry, this.hasStrictSubscriber)) {
602
+ if (entry.version === 2 || item.requireLiveCopyOutcome) {
603
+ Y.applyUpdate(this.ydoc, item.update, "remote");
604
+ // Applying one custody group can integrate another's pending
605
+ // dependencies without emitting a separate update event.
606
+ this.enqueueUpdate(item.update, item.requireLiveCopyOutcome);
607
+ }
608
+ else {
609
+ Y.applyUpdate(this.ydoc, item.update, OUTBOX_ORIGIN);
610
+ }
611
+ }
612
+ // Kept nowhere else while this connection cannot keep its own.
613
+ if (this.snapshot.durableUpdatesFailed)
614
+ return;
615
+ }
616
+ catch {
617
+ // An entry that cannot be applied never will be; it is reported,
618
+ // then removed so it is not reported on every open.
619
+ this.markDurableUpdatesFailed();
620
+ }
621
+ try {
622
+ if (window.localStorage.getItem(entry.key) === entry.raw) {
623
+ window.localStorage.removeItem(entry.key);
624
+ }
625
+ }
626
+ catch {
627
+ this.markDurableUpdatesFailed();
628
+ }
629
+ }
359
630
  markAgentActive() {
360
631
  this.setSnapshot({ agentActive: true });
361
632
  if (this.agentTimer)
@@ -371,6 +642,8 @@ class CollabDocConnection {
371
642
  if (!this.bindActorIdentity(user.email))
372
643
  return;
373
644
  const prev = this.lastSetUser;
645
+ if (prev?.email !== user.email)
646
+ this.userGeneration++;
374
647
  if (prev && prev.email !== user.email) {
375
648
  if (this.pendingUpdates.length && this.retiredUpdatesKey) {
376
649
  retiredCollabUpdates.set(this.retiredUpdatesKey, this.pendingUpdates);
@@ -381,6 +654,12 @@ class CollabDocConnection {
381
654
  clearTimeout(this.flushTimer);
382
655
  this.flushTimer = null;
383
656
  this.updateErrors = 0;
657
+ this.updateBatchLimit = Infinity;
658
+ if (this.snapshot.updatesRefused) {
659
+ this.setSnapshot({ updatesRefused: false });
660
+ }
661
+ // Kept updates are per account.
662
+ this.unsentUpdatesRestored = false;
384
663
  }
385
664
  if (prev &&
386
665
  prev.name === user.name &&
@@ -406,11 +685,13 @@ class CollabDocConnection {
406
685
  retiredCollabUpdates.delete(retiredKey);
407
686
  this.pendingUpdates.push(...retired);
408
687
  if (this.snapshot.initialization.status === "ready") {
409
- for (const update of retired)
410
- Y.applyUpdate(this.ydoc, update, "remote");
688
+ for (const item of retired)
689
+ Y.applyUpdate(this.ydoc, item.update, "remote");
411
690
  void this.flushPendingUpdates();
412
691
  }
413
692
  }
693
+ this.storeUnsentUpdates();
694
+ this.restoreUnsentUpdates();
414
695
  this.awareness.setLocalStateField("user", {
415
696
  name: user.name,
416
697
  email: user.email,
@@ -449,7 +730,7 @@ class CollabDocConnection {
449
730
  this.snapshot.isSynced &&
450
731
  origin === "local" &&
451
732
  this.syncActive &&
452
- !this.disposed) {
733
+ this.canPublishPresence) {
453
734
  scheduleAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID, () => this.awareness.getLocalState());
454
735
  }
455
736
  };
@@ -528,8 +809,8 @@ class CollabDocConnection {
528
809
  validationDoc.destroy();
529
810
  }
530
811
  Y.applyUpdate(this.ydoc, binary, "remote");
531
- for (const update of this.pendingUpdates) {
532
- Y.applyUpdate(this.ydoc, update, "remote");
812
+ for (const item of this.pendingUpdates) {
813
+ Y.applyUpdate(this.ydoc, item.update, "remote");
533
814
  }
534
815
  }
535
816
  catch {
@@ -543,6 +824,7 @@ class CollabDocConnection {
543
824
  initialization: { status: "ready" },
544
825
  });
545
826
  this.startTransport();
827
+ this.restoreUnsentUpdates();
546
828
  }, () => {
547
829
  if (this.disposed)
548
830
  return;
@@ -601,8 +883,13 @@ class CollabDocConnection {
601
883
  clearTimeout(this.flushTimer);
602
884
  this.flushTimer = null;
603
885
  }
886
+ const strictDeliveryVersion = this.strictDeliveryVersion;
604
887
  await this.flushPendingUpdates();
605
- if (this.updateErrors > 0)
888
+ if (this.snapshot.updatesRefused)
889
+ return false;
890
+ if (this.updateErrors > 0 &&
891
+ (strictDeliveryVersion === this.strictDeliveryVersion ||
892
+ !this.pendingUpdates.some((item) => !item.requireLiveCopyOutcome)))
606
893
  return false;
607
894
  }
608
895
  return this.pendingUpdates.length === 0;
@@ -610,7 +897,11 @@ class CollabDocConnection {
610
897
  handleDocUpdate = (update, origin) => {
611
898
  if (origin === "remote")
612
899
  return;
613
- this.pendingUpdates.push(update);
900
+ this.enqueueUpdate(update, this.hasStrictSubscriber);
901
+ };
902
+ enqueueUpdate(update, requireLiveCopyOutcome) {
903
+ this.pendingUpdates.push({ update, requireLiveCopyOutcome });
904
+ this.storeUnsentUpdates();
614
905
  if (this.updateErrors && this.flushTimer)
615
906
  return;
616
907
  if (this.flushTimer)
@@ -619,9 +910,35 @@ class CollabDocConnection {
619
910
  this.flushTimer = null;
620
911
  void this.flushPendingUpdates();
621
912
  }, UPDATE_DEBOUNCE_MS);
622
- };
913
+ }
623
914
  handlePageHide = () => {
624
- this.flushPendingUpdates(true);
915
+ void this.flushPendingUpdates(true);
916
+ this.pageHidden = true;
917
+ void this.clearPresence()?.catch(() => { });
918
+ };
919
+ handlePageShow = () => {
920
+ if (!this.pageHidden || this.disposed || this.signedOut)
921
+ return;
922
+ this.pageHidden = false;
923
+ // BFCache must retain the doc's sequence/token until it is retired.
924
+ this.presenceClear = null;
925
+ this.publishVisibility(!isDocumentHidden());
926
+ this.pollNow();
927
+ };
928
+ // A tab that comes back online sends what it kept without waiting out the
929
+ // retry backoff it built up while offline.
930
+ handleOnline = () => {
931
+ if (!this.keepsUnsentUpdates)
932
+ return;
933
+ if (this.updateInFlight) {
934
+ this.onlineDuringUpdate = true;
935
+ return;
936
+ }
937
+ if (this.flushTimer) {
938
+ clearTimeout(this.flushTimer);
939
+ this.flushTimer = null;
940
+ }
941
+ void this.flushPendingUpdates();
625
942
  };
626
943
  attachUpdateHandler() {
627
944
  if (this.updateHandlerAttached || this.docMissing)
@@ -630,6 +947,8 @@ class CollabDocConnection {
630
947
  this.ydoc.on("update", this.handleDocUpdate);
631
948
  if (typeof window !== "undefined") {
632
949
  window.addEventListener("pagehide", this.handlePageHide);
950
+ window.addEventListener("online", this.handleOnline);
951
+ window.addEventListener("pageshow", this.handlePageShow);
633
952
  }
634
953
  }
635
954
  detachUpdateHandler() {
@@ -639,14 +958,16 @@ class CollabDocConnection {
639
958
  this.ydoc.off("update", this.handleDocUpdate);
640
959
  if (typeof window !== "undefined") {
641
960
  window.removeEventListener("pagehide", this.handlePageHide);
961
+ window.removeEventListener("online", this.handleOnline);
962
+ window.removeEventListener("pageshow", this.handlePageShow);
642
963
  }
643
964
  }
644
965
  async flushPendingUpdates(keepalive = false) {
645
- if (keepalive
646
- ? this.keepaliveInFlight
647
- : this.disposed ||
648
- this.updateInFlight ||
649
- (this.updateErrors > 0 && this.flushTimer !== null))
966
+ if (this.snapshot.updatesRefused ||
967
+ (!keepalive &&
968
+ (this.disposed ||
969
+ this.updateInFlight ||
970
+ (this.updateErrors > 0 && this.flushTimer !== null))))
650
971
  return;
651
972
  if (this.flushTimer) {
652
973
  clearTimeout(this.flushTimer);
@@ -654,17 +975,35 @@ class CollabDocConnection {
654
975
  }
655
976
  if (this.pendingUpdates.length === 0)
656
977
  return;
657
- const toSend = this.pendingUpdates.slice();
978
+ const ordinary = this.pendingUpdates.filter((item) => !item.requireLiveCopyOutcome);
979
+ // Ordinary successors may depend on retained strict bytes. Transport
980
+ // acceptance does not release those bytes' outcome custody.
981
+ const predecessors = ordinary.length
982
+ ? this.pendingUpdates.filter((item) => item.requireLiveCopyOutcome && !this.deliveredUpdates.has(item))
983
+ : [];
984
+ const toSend = (predecessors.length
985
+ ? predecessors
986
+ : ordinary.length
987
+ ? ordinary
988
+ : this.pendingUpdates).slice(0, this.updateBatchLimit);
989
+ if (keepalive &&
990
+ [...this.keepaliveSnapshots].some((snapshot) => toSend.every((item) => snapshot.includes(item))))
991
+ return;
992
+ if (keepalive)
993
+ this.keepaliveSnapshots.add(toSend);
658
994
  const retiredKey = this.retiredUpdatesKey;
659
- const merged = toSend.length === 1 ? toSend[0] : Y.mergeUpdates(toSend);
995
+ const merged = Y.mergeUpdates(toSend.map((item) => item.update));
660
996
  const controller = new AbortController();
661
- if (keepalive)
662
- this.keepaliveInFlight = true;
663
- else {
997
+ if (!keepalive) {
664
998
  this.updateInFlight = true;
665
999
  this.updateAbortController = controller;
666
1000
  }
667
1001
  const timeout = setTimeout(() => controller.abort(), STATE_VECTOR_FETCH_TIMEOUT_MS);
1002
+ const userGeneration = this.userGeneration;
1003
+ const requireLiveCopyOutcome = toSend.some((item) => item.requireLiveCopyOutcome);
1004
+ const acknowledgedOwners = new Set();
1005
+ const ordinaryReceiptOwners = Array.from(this.subscribers.values()).filter((sub) => !sub.requireLiveCopyOutcome());
1006
+ let ack = null;
668
1007
  try {
669
1008
  const response = await fetch(`${this.baseUrl}/${this.docId}/update`, {
670
1009
  method: "POST",
@@ -676,8 +1015,56 @@ class CollabDocConnection {
676
1015
  signal: controller.signal,
677
1016
  ...(keepalive ? { keepalive: true } : {}),
678
1017
  });
1018
+ // The same bytes are refused again, so a refused batch is sent in
1019
+ // halves, and a refused single update stops this tab's updates.
1020
+ if (this.keepsUnsentUpdates && response.status === 413) {
1021
+ if (userGeneration !== this.userGeneration)
1022
+ return;
1023
+ if (!toSend.some((item) => this.pendingUpdates.includes(item) &&
1024
+ !this.deliveredUpdates.has(item)))
1025
+ return;
1026
+ if (toSend.length > 1) {
1027
+ this.updateBatchLimit = Math.ceil(toSend.length / 2);
1028
+ }
1029
+ else {
1030
+ this.setSnapshot({ updatesRefused: true });
1031
+ }
1032
+ return;
1033
+ }
679
1034
  if (!response.ok)
680
1035
  throw new Error(`HTTP ${response.status}`);
1036
+ if (userGeneration !== this.userGeneration)
1037
+ return;
1038
+ for (const item of toSend) {
1039
+ if (!this.deliveredUpdates.has(item)) {
1040
+ this.deliveredUpdates.add(item);
1041
+ if (item.requireLiveCopyOutcome)
1042
+ this.strictDeliveryVersion++;
1043
+ }
1044
+ }
1045
+ const parsedAck = requireLiveCopyOutcome ||
1046
+ Array.from(this.subscribers.values()).some((sub) => sub.onUpdateAcknowledged !== undefined && sub.canAcknowledge())
1047
+ ? readUpdateAck(await response.json())
1048
+ : null;
1049
+ if (userGeneration !== this.userGeneration)
1050
+ return;
1051
+ if (requireLiveCopyOutcome) {
1052
+ if (!parsedAck?.liveCopy)
1053
+ throw new Error("The collab update response omitted its live-copy outcome.");
1054
+ for (const sub of this.subscribers.values()) {
1055
+ if (this.disposed || userGeneration !== this.userGeneration)
1056
+ break;
1057
+ if (sub.requireLiveCopyOutcome() &&
1058
+ sub.canAcknowledge() &&
1059
+ sub.onUpdateAcknowledged) {
1060
+ sub.onUpdateAcknowledged(parsedAck);
1061
+ acknowledgedOwners.add(sub);
1062
+ }
1063
+ }
1064
+ if (acknowledgedOwners.size === 0)
1065
+ throw new Error("The live-copy acknowledgment has no current strict subscriber.");
1066
+ }
1067
+ ack = parsedAck;
681
1068
  const previous = this.pendingUpdates;
682
1069
  const acknowledged = new Set(toSend);
683
1070
  this.pendingUpdates = previous.filter((update) => !acknowledged.has(update));
@@ -693,31 +1080,60 @@ class CollabDocConnection {
693
1080
  retiredCollabUpdates.delete(retiredKey);
694
1081
  }
695
1082
  }
1083
+ this.storeUnsentUpdates();
696
1084
  this.updateErrors = 0;
1085
+ if (this.pendingUpdates.length === 0)
1086
+ this.updateBatchLimit = Infinity;
697
1087
  }
698
1088
  catch {
699
- this.updateErrors++;
1089
+ if (userGeneration === this.userGeneration)
1090
+ this.updateErrors++;
700
1091
  }
701
1092
  finally {
702
- clearTimeout(timeout);
703
1093
  if (keepalive)
704
- this.keepaliveInFlight = false;
705
- else {
1094
+ this.keepaliveSnapshots.delete(toSend);
1095
+ clearTimeout(timeout);
1096
+ let online = false;
1097
+ if (!keepalive) {
706
1098
  this.updateInFlight = false;
707
1099
  this.updateAbortController = null;
1100
+ online = this.onlineDuringUpdate;
1101
+ this.onlineDuringUpdate = false;
708
1102
  }
709
- if (!this.disposed && this.pendingUpdates.length && !this.flushTimer) {
710
- this.flushTimer = setTimeout(() => {
711
- this.flushTimer = null;
712
- void this.flushPendingUpdates();
713
- }, this.updateErrors
714
- ? calcBackoff(this.updateErrors)
715
- : UPDATE_DEBOUNCE_MS);
1103
+ this.schedulePendingFlush(online);
1104
+ }
1105
+ // Ordinary receipts keep nullable-ACK compatibility. Strict owners have
1106
+ // already handled their outcome before its pending bytes were removed.
1107
+ if (ack && !this.disposed && userGeneration === this.userGeneration) {
1108
+ const receiptOwners = [
1109
+ ...ordinaryReceiptOwners.filter((sub) => !sub.requireLiveCopyOutcome()),
1110
+ ...Array.from(this.subscribers.values()).filter((sub) => sub.requireLiveCopyOutcome()),
1111
+ ];
1112
+ for (const sub of receiptOwners) {
1113
+ if (userGeneration === this.userGeneration &&
1114
+ !acknowledgedOwners.has(sub) &&
1115
+ sub.canAcknowledge() &&
1116
+ (!sub.requireLiveCopyOutcome() || ack.liveCopy !== null))
1117
+ sub.onUpdateAcknowledged?.(ack);
716
1118
  }
717
1119
  }
718
1120
  }
1121
+ schedulePendingFlush(online) {
1122
+ if (this.disposed ||
1123
+ this.snapshot.updatesRefused ||
1124
+ this.pendingUpdates.length === 0 ||
1125
+ this.flushTimer)
1126
+ return;
1127
+ this.flushTimer = setTimeout(() => {
1128
+ this.flushTimer = null;
1129
+ void this.flushPendingUpdates();
1130
+ }, this.updateErrors && !online
1131
+ ? calcBackoff(this.updateErrors)
1132
+ : UPDATE_DEBOUNCE_MS);
1133
+ }
719
1134
  startSync() {
720
1135
  if (this.syncActive ||
1136
+ this.signedOut ||
721
1137
  this.docMissing ||
722
1138
  this.disposed ||
723
1139
  this.subscribers.size === 0) {
@@ -1008,7 +1424,9 @@ class CollabDocConnection {
1008
1424
  if (!this.isPollCurrent(generation))
1009
1425
  return;
1010
1426
  const localState = this.awareness.getLocalState();
1011
- if (localState && this.isPollCurrent(generation)) {
1427
+ if (localState &&
1428
+ this.canPublishPresence &&
1429
+ this.isPollCurrent(generation)) {
1012
1430
  try {
1013
1431
  const awarenessRes = await postAwarenessState(this.baseUrl, this.docId, this.ydoc.clientID, localState);
1014
1432
  if (!this.isPollCurrent(generation))
@@ -1070,6 +1488,8 @@ class CollabDocConnection {
1070
1488
  void this.poll(this.pollGeneration);
1071
1489
  }
1072
1490
  publishVisibility(visible) {
1491
+ if (!this.canPublishPresence || !this.awareness.getLocalState())
1492
+ return;
1073
1493
  this.awareness.setLocalStateField("visible", visible);
1074
1494
  const localState = this.awareness.getLocalState();
1075
1495
  if (!localState)
@@ -1121,8 +1541,8 @@ class CollabDocConnection {
1121
1541
  }
1122
1542
  }
1123
1543
  const collabConnectionRegistry = new Map();
1124
- // ponytail: in-memory retention survives component remounts; use a durable
1125
- // outbox if offline reload recovery becomes supported. No retired retry loop.
1544
+ // In-memory retention survives component remounts; a connection with durable
1545
+ // updates also keeps them in localStorage. No retired retry loop.
1126
1546
  const retiredCollabUpdates = new Map();
1127
1547
  registerBeforeSignOutCleanup(async () => {
1128
1548
  const results = await Promise.allSettled(Array.from(new Set(collabConnectionRegistry.values()), (connection) => connection.clearPresenceBeforeSignOut()));
@@ -1167,7 +1587,12 @@ export function _collabDocRegistrySizeForTests() {
1167
1587
  return collabConnectionRegistry.size;
1168
1588
  }
1169
1589
  export function useCollaborativeDoc(options) {
1170
- const { docId, pollInterval = 2000, pollIntervalWithSse = 12000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, activityResource, onEvent, } = options;
1590
+ const { docId, pollInterval = 2000, pollIntervalWithSse = 12000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, activityResource, durableUpdates = false, requireLiveCopyOutcome = false, onUpdateAcknowledged, onEvent, } = options;
1591
+ const onUpdateAcknowledgedRef = useRef(onUpdateAcknowledged);
1592
+ onUpdateAcknowledgedRef.current = onUpdateAcknowledged;
1593
+ const requireLiveCopyOutcomeRef = useRef(requireLiveCopyOutcome);
1594
+ requireLiveCopyOutcomeRef.current = requireLiveCopyOutcome;
1595
+ const wantsAcks = onUpdateAcknowledged !== undefined;
1171
1596
  const onEventRef = useRef(onEvent);
1172
1597
  onEventRef.current = onEvent;
1173
1598
  useEffect(() => {
@@ -1199,6 +1624,9 @@ export function useCollaborativeDoc(options) {
1199
1624
  return getOrCreateCollabConnection(docId, baseUrl, actorIdentity);
1200
1625
  }, [docId, baseUrl, actorIdentity, generation, signedOutAfterAuthentication]);
1201
1626
  const previousActorConnection = useRef({ actorIdentity, conn });
1627
+ const acknowledgementScope = useMemo(() => ({ conn, email: resolvedUser?.email }), [conn, resolvedUser?.email]);
1628
+ const activeAcknowledgementScope = useRef(acknowledgementScope);
1629
+ activeAcknowledgementScope.current = acknowledgementScope;
1202
1630
  const [snapshot, setSnapshot] = useState(() => conn ? conn.snapshot : EMPTY_SNAPSHOT);
1203
1631
  const [prevConn, setPrevConn] = useState(conn);
1204
1632
  if (prevConn !== conn) {
@@ -1217,15 +1645,38 @@ export function useCollaborativeDoc(options) {
1217
1645
  pollInterval,
1218
1646
  pollIntervalWithSse,
1219
1647
  pauseWhenHidden,
1220
- requestSource,
1648
+ ...(requestSource !== undefined ? { requestSource } : {}),
1649
+ durableUpdates,
1650
+ requireLiveCopyOutcome: () => activeAcknowledgementScope.current === acknowledgementScope &&
1651
+ requireLiveCopyOutcomeRef.current,
1652
+ canAcknowledge: () => activeAcknowledgementScope.current === acknowledgementScope &&
1653
+ onUpdateAcknowledgedRef.current !== undefined,
1654
+ ...(wantsAcks
1655
+ ? {
1656
+ onUpdateAcknowledged: (ack) => {
1657
+ if (activeAcknowledgementScope.current === acknowledgementScope)
1658
+ onUpdateAcknowledgedRef.current?.(ack);
1659
+ },
1660
+ }
1661
+ : {}),
1221
1662
  onSnapshot: setSnapshot,
1222
1663
  onEvent: (event) => onEventRef.current?.(event),
1223
- });
1664
+ }, resolvedUser);
1224
1665
  setSnapshot(conn.snapshot);
1225
1666
  return () => {
1226
1667
  conn.remove(id);
1227
1668
  };
1228
- }, [conn, pollInterval, pollIntervalWithSse, pauseWhenHidden, requestSource]);
1669
+ }, [
1670
+ conn,
1671
+ pollInterval,
1672
+ pollIntervalWithSse,
1673
+ pauseWhenHidden,
1674
+ requestSource,
1675
+ durableUpdates,
1676
+ requireLiveCopyOutcome,
1677
+ wantsAcks,
1678
+ wantsAcks ? acknowledgementScope : null,
1679
+ ]);
1229
1680
  useEffect(() => {
1230
1681
  if (!conn || conn.detached || !resolvedUser)
1231
1682
  return;
@@ -1259,6 +1710,8 @@ export function useCollaborativeDoc(options) {
1259
1710
  : () => { },
1260
1711
  requestSync: conn ? conn.requestSync : requestSyncUnavailable,
1261
1712
  flushUpdates: conn ? conn.flushUpdates : flushUpdatesUnavailable,
1713
+ durableUpdatesFailed: snapshot.durableUpdatesFailed,
1714
+ updatesRefused: snapshot.updatesRefused,
1262
1715
  activeUsers: snapshot.activeUsers,
1263
1716
  agentActive: snapshot.agentActive,
1264
1717
  agentPresent: snapshot.agentPresent,