codex-overleaf-link 2.3.5 → 2.4.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.
Files changed (57) hide show
  1. package/README.md +63 -31
  2. package/README.zh-CN.md +543 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +7 -0
  5. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  6. package/extension/src/content/activeTurnControl.js +1 -0
  7. package/extension/src/content/assetTransferBroker.js +2 -0
  8. package/extension/src/content/contentRuntime.js +77 -57
  9. package/extension/src/content/contextTray.js +4 -0
  10. package/extension/src/content/diagnosticsController.js +17 -3
  11. package/extension/src/content/diagnosticsPanel.js +129 -5
  12. package/extension/src/content/generated/content.bundle.js +1422 -348
  13. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  14. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  15. package/extension/src/content/modelPicker.js +21 -9
  16. package/extension/src/content/modelPickerSupport.js +38 -1
  17. package/extension/src/content/nativeCompatibilityController.js +4 -1
  18. package/extension/src/content/otWarmMirror.js +126 -92
  19. package/extension/src/content/otWarmMirrorController.js +122 -1
  20. package/extension/src/content/pageBridgeClient.js +2 -0
  21. package/extension/src/content/panelMaintenance.js +26 -15
  22. package/extension/src/content/providerSettingsCoordinator.js +1 -0
  23. package/extension/src/content/runFailureNotice.js +104 -0
  24. package/extension/src/content/runResultActions.js +16 -1
  25. package/extension/src/content/runScrollLayout.js +124 -0
  26. package/extension/src/content/runTimelineView.js +37 -11
  27. package/extension/src/content/sessionForkController.js +20 -11
  28. package/extension/src/content/settingsPanel.js +28 -11
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/updateNotice.js +24 -4
  31. package/extension/src/content/writebackOrchestrator.js +31 -0
  32. package/extension/src/page/binaryAssetUploader.js +101 -29
  33. package/extension/src/page/overleafRealtimeObserver.js +120 -25
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  37. package/extension/src/page/treeOperations.js +30 -2
  38. package/extension/src/page/writebackRouter.js +94 -48
  39. package/extension/src/pageBridge.js +62 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +77 -37
  42. package/extension/src/shared/managedUpdateProjection.js +1 -1
  43. package/extension/src/shared/otText.js +0 -1
  44. package/extension/src/shared/pageRpcContract.js +2 -1
  45. package/extension/src/shared/sessionState.js +23 -23
  46. package/extension/src/shared/sessionTitle.js +120 -0
  47. package/extension/src/shared/storageDb.js +10 -12
  48. package/extension/src/shared/storageRunActions.js +29 -1
  49. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  50. package/extension/src/shared/writebackSettlement.js +19 -5
  51. package/extension/styles/panel.css +128 -24
  52. package/native-host/src/mirrorWorkspace.js +20 -1
  53. package/native-host/src/releaseProxy.js +177 -0
  54. package/native-host/src/releaseTransport.js +154 -0
  55. package/native-host/src/taskRunnerRuntime.js +25 -0
  56. package/native-host/src/updateManager.js +40 -39
  57. package/package.json +5 -1
@@ -1,6 +1,5 @@
1
1
  (function initCodexOverleafOtWarmMirror() {
2
2
  'use strict';
3
-
4
3
  // Experimental OT warm-mirror glue carved out of contentRuntime.js (v1.4.9
5
4
  // structural-debt phase 5): the per-project enable toggle flow, the
6
5
  // poll/flush timers and patch queue, mirror prefetch, warm-start
@@ -50,7 +49,9 @@
50
49
  } = deps;
51
50
 
52
51
  let currentOtStatus = 'off';
52
+ let otTogglePending = false;
53
53
  let otSyncRequestId = 0;
54
+ let otStartRetryCount = 0;
54
55
  let otWarmMirrorProjectId = '';
55
56
  let lastExperimentalOtProjectId = '';
56
57
  let otWarmMirrorState = {
@@ -61,7 +62,10 @@
61
62
  patchQueue: [],
62
63
  lastStatus: 'off',
63
64
  lastPatchAt: 0,
64
- lastErrorCode: ''
65
+ lastErrorCode: '',
66
+ failureActive: false,
67
+ lastFailure: null,
68
+ suspended: false
65
69
  };
66
70
  let mirrorPrefetchState = {
67
71
  inFlight: null,
@@ -159,14 +163,14 @@
159
163
  }
160
164
 
161
165
  async function handleExperimentalOtToggleClick(event) {
162
- // The handler is bound to the visible checkbox: by the time click fires,
163
- // pre-click activation has ALREADY flipped .checked — that flipped value
164
- // is the user's intended target. preventDefault then rolls the visual
165
- // state back (synchronously, when dispatch completes), and the confirm
166
- // flow applies the target only after approval.
166
+ // Capture the pre-activated target, then let cancelled click dispatch
167
+ // restore the old checkbox value before projecting the approved state.
167
168
  const clicked = event?.currentTarget || event?.target;
168
169
  const targetEnabled = clicked && typeof clicked.checked === 'boolean' ? clicked.checked : undefined;
170
+ const projectId = getCurrentProjectId();
169
171
  event.preventDefault();
172
+ await new Promise(resolve => window.setTimeout(resolve, 0));
173
+ if (projectId !== getCurrentProjectId()) return;
170
174
  await toggleExperimentalOtCheckbox(targetEnabled);
171
175
  }
172
176
 
@@ -180,9 +184,12 @@
180
184
 
181
185
  async function toggleExperimentalOtCheckbox(targetEnabled) {
182
186
  const checkbox = getPanel()?.querySelector('[data-experimental-ot]');
183
- if (!checkbox) {
187
+ if (!checkbox || otTogglePending) {
184
188
  return;
185
189
  }
190
+ const projectId = getCurrentProjectId();
191
+ otTogglePending = true;
192
+ try {
186
193
  // Explicit target from the click interceptor wins; the keyboard/legacy
187
194
  // path (no argument, un-flipped checkbox) falls back to inversion.
188
195
  const nextEnabled = typeof targetEnabled === 'boolean' ? targetEnabled : !checkbox.checked;
@@ -200,11 +207,15 @@
200
207
  } else {
201
208
  closeDiagnosticsMenu();
202
209
  }
210
+ if (projectId !== getCurrentProjectId() || checkbox !== getPanel()?.querySelector('[data-experimental-ot]')) return;
203
211
  checkbox.checked = nextEnabled;
204
212
  handleExperimentalOtToggleChange({ currentTarget: checkbox });
205
213
  if (nextEnabled) {
206
214
  showPluginToast(tr('experimentalOtEnabledToast'), { status: 'info' });
207
215
  }
216
+ } finally {
217
+ otTogglePending = false;
218
+ }
208
219
  }
209
220
 
210
221
  function handleExperimentalOtToggleChange(event) {
@@ -213,7 +224,9 @@
213
224
  return;
214
225
  }
215
226
  const projectId = getCurrentProjectId();
227
+ otWarmMirrorState.failureActive = false;
216
228
  lastExperimentalOtProjectId = projectId;
229
+ otStartRetryCount = 0;
217
230
  setExperimentalOtEnabledForProject(projectId, checkbox.checked);
218
231
  updateExperimentalOtToggleControl(checkbox.checked);
219
232
  // Per-card saved feedback, same contract as every other settings card.
@@ -230,7 +243,7 @@
230
243
  updateOtStatusDisplay(checkbox.checked ? (currentOtStatus === 'off' ? 'starting' : currentOtStatus) : 'off');
231
244
  syncOtWarmMirrorController().catch(error => {
232
245
  updateOtStatusDisplay('unavailable');
233
- appendPlainLog(tx(`Experimental OT warm mirror unavailable: ${error.message}`, `实验性 OT 预热镜像不可用:${error.message}`));
246
+ appendPlainLog(tx(`OT warm mirror unavailable: ${error.message}`, `OT 预热镜像不可用:${error.message}`));
234
247
  });
235
248
  saveStateSoon();
236
249
  }
@@ -252,6 +265,7 @@
252
265
  otWarmMirrorProjectId = projectId;
253
266
  ensureOtWarmMirrorStateProject(projectId);
254
267
  if (enabled) {
268
+ otWarmMirrorState.suspended = false;
255
269
  const pause = otWarmMirrorController?.shouldPauseOtWarmMirror?.({ running: Boolean(getCurrentRunView()) }) || { pause: false };
256
270
  if (pause.pause) {
257
271
  clearOtEventPolling({ clearPatchQueue: true });
@@ -263,9 +277,12 @@
263
277
  } else {
264
278
  clearOtEventPolling({ clearPatchQueue: true });
265
279
  }
266
- const response = enabled
267
- ? await callPageBridge('startOtObserver', { projectId })
268
- : await callPageBridge('stopOtObserver', {});
280
+ let response;
281
+ try {
282
+ response = enabled ? await callPageBridge('startOtObserver', { projectId }) : await callPageBridge('stopOtObserver', {});
283
+ } catch (error) {
284
+ response = { ok: false, error: otWarmMirrorController.normalizeFailureCode(error?.code || error?.message, 'ot_bridge_unavailable') };
285
+ }
269
286
  if (!isCurrentOtSync(requestId, projectId) || getCurrentProjectId() !== projectId) {
270
287
  if (enabled) {
271
288
  await handleStaleOtStartResponse(projectId, requestId);
@@ -282,7 +299,7 @@
282
299
  }
283
300
  const status = readOtBridgeStatus(response);
284
301
  if (!isSuccessfulOtBridgeResponse(response) || status === 'unavailable') {
285
- handleFailedOtStart(projectId, requestId);
302
+ await handleFailedOtStart(projectId, requestId, response);
286
303
  return response;
287
304
  }
288
305
  const pauseAfterStart = otWarmMirrorController?.shouldPauseOtWarmMirror?.({ running: Boolean(getCurrentRunView()) }) || { pause: false };
@@ -290,7 +307,10 @@
290
307
  await pauseOtWarmMirror(pauseAfterStart.reason || 'paused');
291
308
  return response;
292
309
  }
310
+ if (status === 'starting') recordOtFailure('missing_active_path', 'start');
311
+ else otWarmMirrorState.failureActive = false;
293
312
  updateOtStatusDisplay(status);
313
+ otStartRetryCount = 0;
294
314
  scheduleOtEventPolling(projectId, { immediate: true });
295
315
  return response;
296
316
  }
@@ -314,25 +334,43 @@
314
334
  }
315
335
  }
316
336
 
317
- function handleFailedOtStart(projectId, requestId) {
337
+ async function handleFailedOtStart(projectId, requestId, response) {
318
338
  if (!isCurrentOtSync(requestId, projectId) || getCurrentProjectId() !== projectId) {
319
339
  return;
320
340
  }
321
341
  clearOtEventPolling({ clearPatchQueue: true });
322
- setExperimentalOtEnabledForProject(projectId, false);
323
- const experimentalOtCheckbox = getPanel()?.querySelector('[data-experimental-ot]');
324
- if (experimentalOtCheckbox) {
325
- experimentalOtCheckbox.checked = false;
326
- }
342
+ recordOtFailure(readOtBridgeErrorCode(response, 'ot_start_failed'), 'start');
343
+ otWarmMirrorState.suspended = true;
344
+ // Observer availability is transient; only an explicit user action may
345
+ // change the persisted opt-in. A cold editor/bridge must not turn OT off
346
+ // permanently. failureActive/suspended already prohibit cache reuse.
347
+ updateExperimentalOtToggleControl(isExperimentalOtEnabledForProject(projectId));
327
348
  updateOtStatusDisplay('unavailable');
328
- saveStateSoon();
349
+ try { await callPageBridge('stopOtObserver', { projectId }); } catch (_error) { /* preserve the original failure */ }
350
+ scheduleOtStartRetry(projectId, requestId);
351
+ }
352
+
353
+ function scheduleOtStartRetry(projectId, requestId) {
354
+ const retryDelays = [1000, 3000, 6000];
355
+ const canRetry = () => isCurrentOtSync(requestId, projectId) && getCurrentProjectId() === projectId
356
+ && isExperimentalOtEnabledForProject(projectId) && !getCurrentRunView();
357
+ if (!canRetry() || otStartRetryCount >= retryDelays.length) return;
358
+ // Use the existing owned timer so disabling OT, changing projects and
359
+ // starting a new observation all cancel pending retries normally.
360
+ const delayMs = retryDelays[otStartRetryCount++];
361
+ otWarmMirrorState.pollTimer = window.setTimeout(() => {
362
+ otWarmMirrorState.pollTimer = null;
363
+ if (!canRetry()) return;
364
+ syncOtWarmMirrorController().catch(() => {
365
+ if (getCurrentProjectId() === projectId && isExperimentalOtEnabledForProject(projectId)) {
366
+ updateOtStatusDisplay('unavailable');
367
+ }
368
+ });
369
+ }, delayMs);
329
370
  }
330
371
 
331
372
  function readOtBridgeStatus(response) {
332
- if (!response || response.ok === false) {
333
- return 'unavailable';
334
- }
335
- return response.getState() || response.status || response.result?.getState() || response.result?.status || 'unavailable';
373
+ return otWarmMirrorController.readOtBridgeStatus(response);
336
374
  }
337
375
 
338
376
  function ensureOtWarmMirrorStateProject(projectId) {
@@ -341,8 +379,12 @@
341
379
  }
342
380
  clearOtEventPolling({ clearPatchQueue: true });
343
381
  otWarmMirrorState.projectId = projectId;
382
+ otStartRetryCount = 0;
344
383
  otWarmMirrorState.lastPatchAt = 0;
345
384
  otWarmMirrorState.lastErrorCode = '';
385
+ otWarmMirrorState.failureActive = false;
386
+ otWarmMirrorState.lastFailure = null;
387
+ otWarmMirrorState.suspended = false;
346
388
  otWarmMirrorState.lastStatus = currentOtStatus;
347
389
  }
348
390
 
@@ -386,7 +428,8 @@
386
428
  && otWarmMirrorController?.buildPatchFilesRequest
387
429
  && isExperimentalOtEnabledForProject(projectId)
388
430
  && otWarmMirrorProjectId === projectId
389
- && getCurrentProjectId() === projectId);
431
+ && getCurrentProjectId() === projectId
432
+ && !otWarmMirrorState.suspended);
390
433
  }
391
434
 
392
435
  async function pollOtEvents(projectId = otWarmMirrorState.projectId || getCurrentProjectId()) {
@@ -409,11 +452,13 @@
409
452
  return { ok: false, skipped: true, reason: 'project_changed' };
410
453
  }
411
454
  if (!isSuccessfulOtBridgeResponse(statusResponse)) {
412
- otWarmMirrorState.lastErrorCode = readOtBridgeErrorCode(statusResponse, 'get_ot_status_failed');
455
+ recordOtFailure(readOtBridgeErrorCode(statusResponse, 'get_ot_status_failed'), 'observe');
413
456
  updateOtStatusDisplay('unavailable');
414
457
  return statusResponse;
415
458
  }
416
459
  const status = readOtBridgeStatus(statusResponse);
460
+ if (status === 'observing' && otWarmMirrorState.lastFailure?.phase === 'start') Object.assign(otWarmMirrorState, { failureActive: false, lastErrorCode: '', lastFailure: null });
461
+ if (['unavailable', 'inconsistent'].includes(status)) recordOtFailure(readOtBridgeErrorCode(statusResponse, 'ot_observer_unavailable'), 'observe');
417
462
  if (status) {
418
463
  updateOtStatusDisplay(status);
419
464
  }
@@ -423,7 +468,7 @@
423
468
  return { ok: false, skipped: true, reason: 'project_changed' };
424
469
  }
425
470
  if (!isSuccessfulOtBridgeResponse(drainResponse)) {
426
- otWarmMirrorState.lastErrorCode = readOtBridgeErrorCode(drainResponse, 'drain_ot_events_failed');
471
+ recordOtFailure(readOtBridgeErrorCode(drainResponse, 'drain_ot_events_failed'), 'observe');
427
472
  updateOtStatusDisplay('unavailable');
428
473
  return drainResponse;
429
474
  }
@@ -433,7 +478,8 @@
433
478
  }
434
479
  return drainResponse;
435
480
  } catch (error) {
436
- otWarmMirrorState.lastErrorCode = error?.code || error?.message || 'ot_poll_failed';
481
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
482
+ recordOtFailure(error?.code || error?.message || 'ot_poll_failed', 'observe');
437
483
  updateOtStatusDisplay('unavailable');
438
484
  return { ok: false, error };
439
485
  } finally {
@@ -444,25 +490,16 @@
444
490
  }
445
491
 
446
492
  function readOtBridgeEvents(response) {
447
- if (Array.isArray(response)) {
448
- return response;
449
- }
450
- if (Array.isArray(response?.events)) {
451
- return response.events;
452
- }
453
- if (Array.isArray(response?.result?.events)) {
454
- return response.result.events;
455
- }
456
- return [];
493
+ return otWarmMirrorController.readOtBridgeEvents(response);
457
494
  }
458
-
459
495
  function readOtBridgeErrorCode(response, fallback) {
460
- const value = response?.lastErrorCode
461
- || response?.reason
462
- || response?.error?.code
463
- || response?.error
464
- || fallback;
465
- return typeof value === 'string' ? value : fallback;
496
+ return otWarmMirrorController.readOtBridgeErrorCode(response, fallback);
497
+ }
498
+ function recordOtFailure(code, phase) {
499
+ code = otWarmMirrorController.normalizeFailureCode(code);
500
+ otWarmMirrorState.lastErrorCode = code;
501
+ otWarmMirrorState.failureActive = true;
502
+ otWarmMirrorState.lastFailure = { code, phase, at: Date.now() };
466
503
  }
467
504
 
468
505
  function queueOtPatchEvents(events, projectId = getCurrentProjectId()) {
@@ -470,7 +507,16 @@
470
507
  return;
471
508
  }
472
509
  ensureOtWarmMirrorStateProject(projectId);
473
- otWarmMirrorState.patchQueue.push(...events);
510
+ const queued = otWarmMirrorController.queuePatchEvents(otWarmMirrorState.patchQueue, events);
511
+ if (!queued.ok) {
512
+ recordOtFailure(queued.reason, 'queue');
513
+ clearOtEventPolling({ clearPatchQueue: true });
514
+ otWarmMirrorState.suspended = true;
515
+ updateOtStatusDisplay('inconsistent');
516
+ void callPageBridge('stopOtObserver', { projectId }).catch(() => {});
517
+ return;
518
+ }
519
+ otWarmMirrorState.patchQueue = queued.queue;
474
520
  scheduleOtPatchFlush(projectId);
475
521
  }
476
522
 
@@ -510,11 +556,18 @@
510
556
  return { ok: false, skipped: true, reason: pause.reason || 'paused' };
511
557
  }
512
558
 
513
- const maxBatch = Math.max(1, Number(otWarmMirrorController?.OT_MAX_PATCH_BATCH) || 25);
514
- const batch = otWarmMirrorState.patchQueue.splice(0, maxBatch);
559
+ let batch = otWarmMirrorController.takePatchBatch(otWarmMirrorState.patchQueue);
515
560
  if (!batch.length) {
516
561
  return { ok: true, skipped: true, reason: 'empty_batch' };
517
562
  }
563
+ const fresh = otWarmMirrorController.queuePatchEvents([], batch);
564
+ if (!fresh.ok) {
565
+ recordOtFailure(fresh.reason, 'queue');
566
+ clearOtEventPolling({ clearPatchQueue: true });
567
+ updateOtStatusDisplay('inconsistent');
568
+ return { ok: false, skipped: true, reason: fresh.reason };
569
+ }
570
+ batch = fresh.queue;
518
571
 
519
572
  otWarmMirrorState.flushing = true;
520
573
  try {
@@ -530,46 +583,37 @@
530
583
  }
531
584
 
532
585
  const response = await sendBackgroundNative(request);
586
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
533
587
  if (!response?.ok) {
534
- otWarmMirrorState.lastErrorCode = response?.error?.code || response?.error?.message || 'mirror_patch_failed';
588
+ recordOtFailure(response?.error?.code || 'mirror_patch_failed', 'flush');
535
589
  updateOtStatusDisplay('inconsistent');
536
590
  return response;
537
591
  }
538
592
 
539
- const result = response.result || {};
540
- const skippedFiles = Array.isArray(result?.skippedFiles) ? result.skippedFiles : [];
541
- const skippedCount = Number.isFinite(Number(result?.skippedCount))
542
- ? Number(result.skippedCount)
543
- : skippedFiles.length;
544
- if (skippedFiles.length || skippedCount > 0) {
545
- otWarmMirrorState.lastErrorCode = 'mirror_patch_skipped';
546
- updateOtStatusDisplay('inconsistent');
547
- return response;
548
- }
549
- const appliedFiles = Array.isArray(result?.appliedFiles) ? result.appliedFiles : null;
550
- const appliedCount = Number.isFinite(Number(result?.appliedCount))
551
- ? Number(result.appliedCount)
552
- : NaN;
553
- if (!appliedFiles || !Number.isFinite(appliedCount) || appliedCount <= 0 || appliedFiles.length !== appliedCount) {
554
- otWarmMirrorState.lastErrorCode = 'mirror_patch_invalid_result';
593
+ const receipt = otWarmMirrorController.validatePatchReceipt(batch, response.result);
594
+ if (!receipt.ok) {
595
+ recordOtFailure(receipt.reason, 'flush');
555
596
  updateOtStatusDisplay('inconsistent');
556
597
  return response;
557
598
  }
558
599
 
559
600
  otWarmMirrorState.lastPatchAt = Date.now();
560
601
  otWarmMirrorState.lastErrorCode = '';
561
- if (canPollOtWarmMirror(projectId)) {
602
+ otWarmMirrorState.failureActive = false;
603
+ if (canPollOtWarmMirror(projectId) && !getCurrentRunView()) {
562
604
  updateOtStatusDisplay('observing');
563
605
  }
564
606
  return response;
565
607
  } catch (error) {
566
- otWarmMirrorState.lastErrorCode = error?.code || error?.message || 'mirror_patch_failed';
608
+ if (!canPollOtWarmMirror(projectId)) return { ok: false, skipped: true, reason: 'project_changed' };
609
+ recordOtFailure(error?.code || 'mirror_patch_failed', 'flush');
567
610
  updateOtStatusDisplay('inconsistent');
568
611
  return { ok: false, error };
569
612
  } finally {
570
613
  otWarmMirrorState.flushing = false;
571
- if (otWarmMirrorState.patchQueue.length && canPollOtWarmMirror(projectId) && !getCurrentRunView()) {
572
- scheduleOtPatchFlush(projectId, { immediate: true });
614
+ const nextProjectId = otWarmMirrorState.projectId;
615
+ if (otWarmMirrorState.patchQueue.length && canPollOtWarmMirror(nextProjectId) && !getCurrentRunView()) {
616
+ scheduleOtPatchFlush(nextProjectId, { immediate: true });
573
617
  }
574
618
  }
575
619
  }
@@ -612,12 +656,13 @@
612
656
  }
613
657
 
614
658
  function updateOtStatusDisplay(status = currentOtStatus) {
615
- const normalized = normalizeOtStatus(status);
659
+ const normalized = otWarmMirrorState.failureActive && status === 'observing' ? 'inconsistent' : normalizeOtStatus(status);
616
660
  currentOtStatus = normalized;
617
661
  otWarmMirrorState.lastStatus = normalized;
618
662
  const statusElement = getPanel()?.querySelector('[data-ot-status]');
619
663
  if (statusElement) {
620
- const label = formatOtStatusLabel(normalized);
664
+ const label = formatOtStatusLabel(normalized) + (otWarmMirrorState.failureActive
665
+ ? ': ' + tr(otWarmMirrorController.getFailureMessageKey(otWarmMirrorState.lastErrorCode)) : '');
621
666
  statusElement.textContent = formatOtToggleStatusText(label);
622
667
  statusElement.dataset.otStatus = normalized;
623
668
  statusElement.title = formatOtToggleStatusText(label);
@@ -781,38 +826,24 @@
781
826
  }
782
827
 
783
828
  async function resolveWarmRunStart(taskContext = {}) {
829
+ const projectId = getCurrentProjectId();
784
830
  const focusFiles = taskContext.focusFiles || [];
785
831
  const mode = taskContext.mode || getState()?.mode;
786
832
  const mirrorStatus = await getMirrorFreshness();
833
+ if (getCurrentProjectId() !== projectId) return { useExistingMirror: false, reason: 'project_changed' };
787
834
  const otWarmStart = otWarmMirrorController.canUseOtWarmStart({
788
- enabled: isExperimentalOtEnabled(),
835
+ enabled: isExperimentalOtEnabled() && !otWarmMirrorState.failureActive,
789
836
  focusFiles,
790
837
  mirrorStatus
791
838
  });
792
- if (otWarmStart.ok) {
793
- return {
794
- useExistingMirror: true,
795
- warmStart: true,
796
- otWarmStart: true,
797
- reason: 'ot_focus_fresh',
798
- mirrorStatus,
799
- focusFiles: otWarmStart.focusFiles,
800
- project: {
801
- capabilities: {
802
- fullProjectSnapshot: false,
803
- method: 'ot-warm-mirror'
804
- },
805
- files: []
806
- }
807
- };
808
- }
809
- if (!mirrorStatus?.exists || !isMirrorReusable(mirrorStatus)) {
839
+ if (!mirrorStatus?.exists || (!otWarmStart.ok && !isMirrorReusable(mirrorStatus))) {
810
840
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
811
841
  }
812
842
 
813
843
  let overlayProject = null;
814
844
  try {
815
845
  overlayProject = await callPageBridge('getProjectSnapshot', {
846
+ projectId,
816
847
  force: true,
817
848
  maxAgeMs: 0,
818
849
  preferLightweight: true,
@@ -826,6 +857,7 @@
826
857
  } catch (error) {
827
858
  return { useExistingMirror: false, reason: 'overlay_probe_failed', mirrorStatus };
828
859
  }
860
+ if (getCurrentProjectId() !== projectId) return { useExistingMirror: false, reason: 'project_changed' };
829
861
  const fileOverlays = buildSnapshotFileOverlays(overlayProject, focusFiles);
830
862
  if (!fileOverlays.length) {
831
863
  return { useExistingMirror: false, reason: 'missing_current_overlay', mirrorStatus };
@@ -840,7 +872,9 @@
840
872
  return {
841
873
  useExistingMirror: true,
842
874
  warmStart: true,
843
- reason: mode === 'ask' ? 'warm_whole_project_ask' : 'warm_whole_project',
875
+ otWarmStart: otWarmStart.ok,
876
+ focusFiles,
877
+ reason: otWarmStart.ok ? 'ot_focus_fresh' : mode === 'ask' ? 'warm_whole_project_ask' : 'warm_whole_project',
844
878
  mirrorStatus,
845
879
  fileOverlays,
846
880
  project: overlayProject
@@ -928,7 +962,7 @@
928
962
  return syncOtWarmMirrorController();
929
963
  }).catch(error => {
930
964
  updateOtStatusDisplay('unavailable');
931
- appendPlainLog(tx(`Experimental OT warm mirror unavailable: ${error.message}`, `实验性 OT 预热镜像不可用:${error.message}`));
965
+ appendPlainLog(tx(`OT warm mirror unavailable: ${error.message}`, `OT 预热镜像不可用:${error.message}`));
932
966
  });
933
967
  }
934
968
 
@@ -10,6 +10,12 @@
10
10
  const OT_POLL_INTERVAL_MS = 1000;
11
11
  const OT_PATCH_DEBOUNCE_MS = 500;
12
12
  const OT_MAX_PATCH_BATCH = 25;
13
+ const OT_FRESHNESS_MAX_AGE_MS = 30000;
14
+ const OT_MAX_FILE_BYTES = 1024 * 1024;
15
+ const OT_MAX_PENDING_EVENTS = 64;
16
+ const OT_MAX_PENDING_BYTES = 4 * 1024 * 1024;
17
+ const OT_MAX_BATCH_BYTES = 2 * 1024 * 1024;
18
+ const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
13
19
  const PAUSE_STATES = new Set(['running', 'writing', 'undoing', 'compiling']);
14
20
 
15
21
  function buildPatchFilesRequest({ projectId, events, nativeCompatibility } = {}) {
@@ -67,13 +73,14 @@
67
73
  return { pause: false };
68
74
  }
69
75
 
70
- function canUseOtWarmStart({ enabled, focusFiles, mirrorStatus } = {}) {
76
+ function canUseOtWarmStart({ enabled, focusFiles, mirrorStatus, now = Date.now() } = {}) {
71
77
  if (!enabled) {
72
78
  return { ok: false, reason: 'disabled' };
73
79
  }
74
80
  if (!mirrorStatus || mirrorStatus.exists !== true) {
75
81
  return { ok: false, reason: 'mirror_missing' };
76
82
  }
83
+ if (mirrorStatus.dirty === true) return { ok: false, reason: 'mirror_dirty' };
77
84
 
78
85
  const normalizedFocusFiles = normalizePaths(focusFiles);
79
86
  if (!normalizedFocusFiles.length) {
@@ -85,6 +92,8 @@
85
92
  if (file?.state !== 'fresh') {
86
93
  continue;
87
94
  }
95
+ const age = now - Date.parse(file.observedAt ?? file.lastPatchAt ?? '');
96
+ if (!Number.isFinite(age) || age < 0 || age > OT_FRESHNESS_MAX_AGE_MS) continue;
88
97
  const filePath = normalizePath(file.path);
89
98
  if (filePath) {
90
99
  freshFiles.add(filePath);
@@ -102,6 +111,105 @@
102
111
  return { ok: false, reason: 'missing_ot_fresh_focus' };
103
112
  }
104
113
 
114
+ function textBytes(value) {
115
+ const text = String(value || '');
116
+ return encoder ? encoder.encode(text).byteLength : text.length * 3;
117
+ }
118
+
119
+ function patchBytes(event) { return textBytes(event.nextContent) + textBytes(event.path) + 512; }
120
+
121
+ function queuePatchEvents(queue, events, now = Date.now()) {
122
+ if (!Array.isArray(events) || events.length > OT_MAX_PENDING_EVENTS) return { ok: false, reason: 'ot_queue_limit' };
123
+ const next = Array.isArray(queue) ? queue.slice() : [];
124
+ let bytes = next.reduce((sum, event) => sum + patchBytes(event), 0);
125
+ if (next.length > OT_MAX_PENDING_EVENTS || bytes > OT_MAX_PENDING_BYTES) return { ok: false, reason: 'ot_queue_limit' };
126
+ for (const input of events) {
127
+ const path = normalizePath(input?.path);
128
+ const time = typeof input?.observedAt === 'number' ? input.observedAt : Date.parse(input?.observedAt || '');
129
+ if (!isSafeRelativePath(path) || path.length > 1024 || typeof input?.nextContent !== 'string' ||
130
+ typeof input.baseHash !== 'string' || !input.baseHash || input.baseHash.length > 128) {
131
+ return { ok: false, reason: 'ot_event_invalid' };
132
+ }
133
+ if (!Number.isFinite(time) || now - time < 0 || now - time > OT_FRESHNESS_MAX_AGE_MS) return { ok: false, reason: 'ot_event_expired' };
134
+ if (textBytes(input.nextContent) > OT_MAX_FILE_BYTES) return { ok: false, reason: 'ot_file_limit' };
135
+ const event = { path, baseHash: input.baseHash, nextContent: input.nextContent,
136
+ nextHash: typeof input.nextHash === 'string' && input.nextHash.length <= 128 ? input.nextHash : '',
137
+ observedAt: new Date(time).toISOString() };
138
+ if (isPrimitiveMetadata(input.observedVersion) && String(input.observedVersion).length <= 128) event.observedVersion = input.observedVersion;
139
+ const tail = next[next.length - 1];
140
+ if (tail?.path === path && tail.nextHash && tail.nextHash === event.baseHash) {
141
+ event.baseHash = tail.baseHash;
142
+ bytes -= patchBytes(next.pop());
143
+ }
144
+ if (event.nextHash !== event.baseHash) { next.push(event); bytes += patchBytes(event); }
145
+ if (next.length > OT_MAX_PENDING_EVENTS || bytes > OT_MAX_PENDING_BYTES) return { ok: false, reason: 'ot_queue_limit' };
146
+ }
147
+ return { ok: true, queue: next, bytes };
148
+ }
149
+
150
+ function takePatchBatch(queue) {
151
+ let bytes = 0;
152
+ let count = 0;
153
+ while (count < Math.min(queue.length, OT_MAX_PATCH_BATCH)) {
154
+ const size = patchBytes(queue[count]);
155
+ if (bytes + size > OT_MAX_BATCH_BYTES) break;
156
+ bytes += size;
157
+ count += 1;
158
+ }
159
+ return queue.splice(0, count);
160
+ }
161
+
162
+ function validatePatchReceipt(events, result = {}, now = Date.now()) {
163
+ if ((Array.isArray(result.skippedFiles) && result.skippedFiles.length) || Number(result.skippedCount || 0) > 0) {
164
+ return { ok: false, reason: 'mirror_patch_skipped' };
165
+ }
166
+ if (!events.length || Number(result.appliedCount) !== events.length || !Array.isArray(result.appliedFiles) ||
167
+ result.appliedFiles.length !== events.length || !result.appliedFiles.every((file, index) =>
168
+ normalizePath(file?.path) === events[index].path && (!events[index].nextHash || file.hash === events[index].nextHash))) {
169
+ return { ok: false, reason: 'mirror_patch_invalid_result' };
170
+ }
171
+ if (events.some(event => { const time = Date.parse(event.observedAt);
172
+ return !Number.isFinite(time) || time > now || now - time > OT_FRESHNESS_MAX_AGE_MS;
173
+ })) return { ok: false, reason: 'ot_event_expired' };
174
+ return { ok: true };
175
+ }
176
+
177
+ function readOtBridgeStatus(response) {
178
+ if (!response || response.ok === false) return 'unavailable';
179
+ return response.state || response.status || response.result?.state || response.result?.status || 'unavailable';
180
+ }
181
+
182
+ function readOtBridgeEvents(response) {
183
+ if (Array.isArray(response)) return response;
184
+ if (Array.isArray(response?.events)) return response.events;
185
+ if (Array.isArray(response?.result?.events)) return response.result.events;
186
+ return [];
187
+ }
188
+
189
+ function readOtBridgeErrorCode(response, fallback) {
190
+ const value = response?.lastErrorCode || response?.reason || response?.error?.code || response?.error || fallback;
191
+ return typeof value === 'string' ? value : fallback;
192
+ }
193
+
194
+ function normalizeFailureCode(value, fallback = 'ot_error') {
195
+ const code = typeof value === 'string' ? value : value?.code;
196
+ if (/^[a-z][a-z0-9_]{0,79}$/.test(code || '')) return code;
197
+ if (/page bridge|capability|bridge timed out/i.test(String(code || ''))) return 'ot_bridge_unavailable';
198
+ return fallback;
199
+ }
200
+
201
+ function getFailureMessageKey(value) {
202
+ const code = normalizeFailureCode(value);
203
+ if (/bridge|ot_status|ot_events/.test(code)) return 'otFailureBridge';
204
+ if (/missing_ot_text|observer_create|observer_unavailable/.test(code)) return 'otFailureObserver';
205
+ if (code === 'ot_file_limit') return 'otFailureFileLimit';
206
+ if (code === 'ot_queue_limit') return 'otFailureQueueLimit';
207
+ if (code === 'ot_event_expired') return 'otFailureExpired';
208
+ if (/editor|active_path|document/.test(code)) return 'otFailureEditor';
209
+ if (/mirror|hash|baseline/.test(code)) return 'otFailureMirror';
210
+ return 'otFailureGeneric';
211
+ }
212
+
105
213
  function normalizePath(value) {
106
214
  return String(value ?? '')
107
215
  .trim()
@@ -165,6 +273,19 @@
165
273
  OT_POLL_INTERVAL_MS,
166
274
  OT_PATCH_DEBOUNCE_MS,
167
275
  OT_MAX_PATCH_BATCH,
276
+ OT_FRESHNESS_MAX_AGE_MS,
277
+ OT_MAX_FILE_BYTES,
278
+ OT_MAX_PENDING_EVENTS,
279
+ OT_MAX_PENDING_BYTES,
280
+ OT_MAX_BATCH_BYTES,
281
+ queuePatchEvents,
282
+ takePatchBatch,
283
+ validatePatchReceipt,
284
+ normalizeFailureCode,
285
+ readOtBridgeStatus,
286
+ readOtBridgeEvents,
287
+ readOtBridgeErrorCode,
288
+ getFailureMessageKey,
168
289
  buildPatchFilesRequest,
169
290
  canUseOtWarmStart,
170
291
  normalizePath,
@@ -21,9 +21,11 @@
21
21
  ['src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script'],
22
22
  ['src/page/pageBridgeCapability.js', 'codex-overleaf-page-bridge-capability-script'],
23
23
  ['src/page/treeOperations.js', 'codex-overleaf-tree-operations-script', true],
24
+ ['src/page/textFileCreator.js', 'codex-overleaf-text-file-creator-script', true],
24
25
  ['src/page/binaryAssetUploader.js', 'codex-overleaf-binary-asset-uploader-script', true],
25
26
  ['src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script'],
26
27
  ['src/page/writeGuard.js', 'codex-overleaf-write-guard-script', true],
28
+ ['src/page/trackedChangeCapture.js', 'codex-overleaf-tracked-change-capture-script', true],
27
29
  ['src/page/trackedChangesLifecycle.js', 'codex-overleaf-tracked-changes-lifecycle-script', true],
28
30
  ['src/page/writebackRouter.js', 'codex-overleaf-writeback-router-script', true],
29
31
  ['src/pageBridge.js', 'codex-overleaf-page-bridge-script', true]